From b80e2147804bd985556001704938e279a00cbcfc Mon Sep 17 00:00:00 2001 From: Laurent Smet Date: Tue, 20 Apr 2021 08:30:50 +0000 Subject: [PATCH] [FIX] account: Fix price_unit rounding issue with fpos/price included tax - Create an invoice with an empty fiscal position - Create a line with a product having 100.0 as sale price and 21.0% price-included tax => price_unit equals 99.99 This is because 100 / 1.21 ~= 82.64 but 82.64 * 1.21 ~= 99.99 != 100.0. The bug only appears when managing a fiscal position because the code is trying to adapt the product price_unit to the newly computed taxes. closes odoo/odoo#70276 X-original-commit: 3470a3c94e06d92afcd8a8129f80e4dfea14efe4 Signed-off-by: oco-odoo Signed-off-by: Laurent Smet --- addons/account/models/account_move.py | 98 ++++++++++++----- .../tests/test_account_move_out_invoice.py | 104 +++++++++++++++++- 2 files changed, 172 insertions(+), 30 deletions(-) diff --git a/addons/account/models/account_move.py b/addons/account/models/account_move.py index 4558aaa071492..c3a372b25091f 100644 --- a/addons/account/models/account_move.py +++ b/addons/account/models/account_move.py @@ -3264,23 +3264,75 @@ def _get_computed_name(self): return '\n'.join(values) def _get_computed_price_unit(self): + ''' Helper to get the default price unit based on the product by taking care of the taxes + set on the product and the fiscal position. + :return: The price unit. + ''' self.ensure_one() if not self.product_id: - return self.price_unit - elif self.move_id.is_sale_document(include_receipts=True): - # Out invoice. - price_unit = self.product_id.lst_price + return 0.0 + + company = self.move_id.company_id + currency = self.move_id.currency_id + company_currency = company.currency_id + product_uom = self.product_id.uom_id + fiscal_position = self.move_id.fiscal_position_id + is_refund_document = self.move_id.move_type in ('out_refund', 'in_refund') + move_date = self.move_id.date or fields.Date.context_today(self) + + if self.move_id.is_sale_document(include_receipts=True): + product_price_unit = self.product_id.lst_price + product_taxes = self.product_id.taxes_id elif self.move_id.is_purchase_document(include_receipts=True): - # In invoice. - price_unit = self.product_id.standard_price + product_price_unit = self.product_id.standard_price + product_taxes = self.product_id.supplier_taxes_id else: - return self.price_unit + return 0.0 + product_taxes = product_taxes.filtered(lambda tax: tax.company_id == company) + + # Apply unit of measure. + if self.product_uom_id and self.product_uom_id != product_uom: + product_price_unit = product_uom._compute_price(product_price_unit, self.product_uom_id) + + # Apply fiscal position. + if product_taxes and fiscal_position: + product_taxes_after_fp = fiscal_position.map_tax(product_taxes, partner=self.partner_id) + + if set(product_taxes.ids) != set(product_taxes_after_fp.ids): + flattened_taxes_before_fp = product_taxes._origin.flatten_taxes_hierarchy() + if any(tax.price_include for tax in flattened_taxes_before_fp): + taxes_res = flattened_taxes_before_fp.compute_all( + product_price_unit, + quantity=1.0, + currency=company_currency, + product=self.product_id, + partner=self.partner_id, + is_refund=is_refund_document, + ) + product_price_unit = company_currency.round(taxes_res['total_excluded']) + + flattened_taxes_after_fp = product_taxes_after_fp._origin.flatten_taxes_hierarchy() + if any(tax.price_include for tax in flattened_taxes_after_fp): + taxes_res = flattened_taxes_after_fp.compute_all( + product_price_unit, + quantity=1.0, + currency=company_currency, + product=self.product_id, + partner=self.partner_id, + is_refund=is_refund_document, + handle_price_include=False, + ) + for tax_res in taxes_res['taxes']: + tax = self.env['account.tax'].browse(tax_res['id']) + if tax.price_include: + product_price_unit += tax_res['amount'] - if self.product_uom_id != self.product_id.uom_id: - price_unit = self.product_id.uom_id._compute_price(price_unit, self.product_uom_id) + # Apply currency rate. + if currency and currency != company_currency: + product_price_unit = company_currency._convert(product_price_unit, currency, company, move_date) - return price_unit + return product_price_unit def _get_computed_account(self): self.ensure_one() @@ -3581,33 +3633,21 @@ def _onchange_product_id(self): line.name = line._get_computed_name() line.account_id = line._get_computed_account() - line.tax_ids = line._get_computed_taxes() + taxes = line._get_computed_taxes() + if taxes and line.move_id.fiscal_position_id: + taxes = line.move_id.fiscal_position_id.map_tax(taxes, partner=line.partner_id) + line.tax_ids = taxes line.product_uom_id = line._get_computed_uom() line.price_unit = line._get_computed_price_unit() - # price_unit and taxes may need to be adapted following Fiscal Position - line._set_price_and_tax_after_fpos() - - # Convert the unit price to the invoice's currency. - company = line.move_id.company_id - line.price_unit = company.currency_id._convert(line.price_unit, line.move_id.currency_id, company, line.move_id.date, round=False) - @api.onchange('product_uom_id') def _onchange_uom_id(self): ''' Recompute the 'price_unit' depending of the unit of measure. ''' - price_unit = self._get_computed_price_unit() - - # See '_onchange_product_id' for details. taxes = self._get_computed_taxes() if taxes and self.move_id.fiscal_position_id: - price_subtotal = self._get_price_total_and_subtotal(price_unit=price_unit, taxes=taxes)['price_subtotal'] - accounting_vals = self._get_fields_onchange_subtotal(price_subtotal=price_subtotal, currency=self.move_id.company_currency_id) - amount_currency = accounting_vals['amount_currency'] - price_unit = self._get_fields_onchange_balance(amount_currency=amount_currency, force_computation=True).get('price_unit', price_unit) - - # Convert the unit price to the invoice's currency. - company = self.move_id.company_id - self.price_unit = company.currency_id._convert(price_unit, self.move_id.currency_id, company, self.move_id.date, round=False) + taxes = self.move_id.fiscal_position_id.map_tax(taxes, partner=self.partner_id) + self.tax_ids = taxes + self.price_unit = self._get_computed_price_unit() @api.onchange('account_id') def _onchange_account_id(self): diff --git a/addons/account/tests/test_account_move_out_invoice.py b/addons/account/tests/test_account_move_out_invoice.py index 53b7fa53ae1ef..c7d535d0de210 100644 --- a/addons/account/tests/test_account_move_out_invoice.py +++ b/addons/account/tests/test_account_move_out_invoice.py @@ -831,7 +831,7 @@ def test_out_invoice_line_onchange_taxes_1(self): 'amount_total': 1730.0, }) - def test_out_invoice_line_onchange_rounding_price_subtotal(self): + def test_out_invoice_line_onchange_rounding_price_subtotal_1(self): ''' Seek for rounding issue on the price_subtotal when dealing with a price_unit having more digits than the foreign currency one. ''' @@ -905,6 +905,108 @@ def check_invoice_values(invoice): check_invoice_values(invoice_2) + def test_out_invoice_line_onchange_rounding_price_subtotal_2(self): + """ Ensure the cyclic computations implemented using onchanges are not leading to rounding issues when using + price-included taxes. + For example: + 100 / 1.21 ~= 82.64 but 82.64 * 1.21 ~= 99.99 != 100.0. + """ + + def check_invoice_values(invoice): + self.assertInvoiceValues(invoice, [ + { + 'price_unit': 100.0, + 'price_subtotal': 82.64, + 'debit': 0.0, + 'credit': 82.64, + }, + { + 'price_unit': 17.36, + 'price_subtotal': 17.36, + 'debit': 0.0, + 'credit': 17.36, + }, + { + 'price_unit': -100.0, + 'price_subtotal': -100.0, + 'debit': 100.0, + 'credit': 0.0, + }, + ], { + 'amount_untaxed': 82.64, + 'amount_tax': 17.36, + 'amount_total': 100.0, + }) + + tax = self.env['account.tax'].create({ + 'name': '21%', + 'amount': 21.0, + 'price_include': True, + 'include_base_amount': True, + }) + + # == Test assigning tax directly == + + invoice_create = self.env['account.move'].create({ + 'move_type': 'out_invoice', + 'invoice_date': '2017-01-01', + 'date': '2017-01-01', + 'partner_id': self.partner_a.id, + 'invoice_line_ids': [(0, 0, { + 'name': 'test line', + 'price_unit': 100.0, + 'account_id': self.company_data['default_account_revenue'].id, + 'tax_ids': [(6, 0, tax.ids)], + })], + }) + + check_invoice_values(invoice_create) + + move_form = Form(self.env['account.move'].with_context(default_move_type='out_invoice')) + move_form.invoice_date = fields.Date.from_string('2017-01-01') + move_form.partner_id = self.partner_a + with move_form.invoice_line_ids.new() as line_form: + line_form.name = 'test line' + line_form.price_unit = 100.0 + line_form.account_id = self.company_data['default_account_revenue'] + line_form.tax_ids.clear() + line_form.tax_ids.add(tax) + invoice_onchange = move_form.save() + + check_invoice_values(invoice_onchange) + + # == Test when the tax is set on a product == + + product = self.env['product.product'].create({ + 'name': 'product', + 'lst_price': 100.0, + 'property_account_income_id': self.company_data['default_account_revenue'].id, + 'taxes_id': [(6, 0, tax.ids)], + }) + + move_form = Form(self.env['account.move'].with_context(default_move_type='out_invoice')) + move_form.invoice_date = fields.Date.from_string('2017-01-01') + move_form.partner_id = self.partner_a + with move_form.invoice_line_ids.new() as line_form: + line_form.product_id = product + invoice_onchange = move_form.save() + + check_invoice_values(invoice_onchange) + + # == Test with a fiscal position == + + fiscal_position = self.env['account.fiscal.position'].create({'name': 'fiscal_position'}) + + move_form = Form(self.env['account.move'].with_context(default_move_type='out_invoice')) + move_form.invoice_date = fields.Date.from_string('2017-01-01') + move_form.partner_id = self.partner_a + move_form.fiscal_position_id = fiscal_position + with move_form.invoice_line_ids.new() as line_form: + line_form.product_id = product + invoice_onchange = move_form.save() + + check_invoice_values(invoice_onchange) + def test_out_invoice_line_onchange_taxes_2_price_unit_tax_included(self): ''' Seek for rounding issue in the price unit. Suppose a price_unit of 2300 with a 5.5% price-included tax applied on it.