From e9104be1ee15e042d5b5a73bcbaa4effae72012a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Tue, 12 Nov 2024 17:39:42 +0100 Subject: [PATCH] Round together only taxable lines with the same taxes and sign The rounding error must not be distributed between taxable lines having different taxes otherwise the tax amount is not stable for the same base amount. Closes #13673 --- modules/account/tax.py | 70 +++++++++++++++++----------- modules/account/tests/test_module.py | 44 +++++++++++++++++ 2 files changed, 86 insertions(+), 28 deletions(-) diff --git a/modules/account/tax.py b/modules/account/tax.py index 8df933bc68..400db59537 100644 --- a/modules/account/tax.py +++ b/modules/account/tax.py @@ -1,7 +1,7 @@ # This file is part of Tryton. The COPYRIGHT file at the top level of # this repository contains the full copyright notices and license terms. import datetime -from collections import namedtuple +from collections import defaultdict, namedtuple from decimal import Decimal from itertools import cycle, groupby @@ -1192,8 +1192,13 @@ class _TaxKey(dict): return hash(self._key()) -_TaxableLine = namedtuple( - '_TaxableLine', ('taxes', 'unit_price', 'quantity', 'tax_date')) +class _TaxableLine(namedtuple( + '_TaxableLine', + ('taxes', 'unit_price', 'quantity', 'tax_date'))): + @property + def _key(self): + base = self.unit_price * Decimal(str(self.quantity or 0)) + return (tuple(self.taxes), base >= 0) class TaxableMixin(object): @@ -1266,34 +1271,43 @@ class TaxableMixin(object): Tax = pool.get('account.tax') Configuration = pool.get('account.configuration') - taxes = {} + all_taxes = {} with Transaction().set_context(self._get_tax_context()): config = Configuration(1) tax_rounding = config.get_multivalue('tax_rounding') - taxable_lines = [_TaxableLine(*params) - for params in self.taxable_lines] - for line in taxable_lines: - assert all(t.company == self.company for t in line.taxes) - l_taxes = Tax.compute(Tax.browse(line.taxes), line.unit_price, - line.quantity, line.tax_date or self.tax_date) - current_taxes = {} - for tax in l_taxes: - taxline = self._compute_tax_line(**tax) - # Base must always be rounded per line as there will be one - # tax line per taxable_lines - if self.currency: - taxline['base'] = self.currency.round(taxline['base']) - if taxline not in taxes: - taxes[taxline] = taxline - else: - taxes[taxline]['base'] += taxline['base'] - taxes[taxline]['amount'] += taxline['amount'] - current_taxes[taxline] = taxes[taxline] - if tax_rounding == 'line': - self._round_taxes(current_taxes) - if tax_rounding == 'document': - self._round_taxes(taxes) - return taxes + taxable_lines = defaultdict(list) + for params in self.taxable_lines: + taxable_line = _TaxableLine(*params) + taxable_lines[taxable_line._key].append(taxable_line) + for grouped_taxable_lines in taxable_lines.values(): + taxes = {} + for line in grouped_taxable_lines: + assert all(t.company == self.company for t in line.taxes) + l_taxes = Tax.compute( + Tax.browse(line.taxes), line.unit_price, + line.quantity, line.tax_date or self.tax_date) + current_taxes = {} + for tax in l_taxes: + taxline = self._compute_tax_line(**tax) + # Base must always be rounded per line as there will be + # one tax line per taxable_lines + if self.currency: + taxline['base'] = self.currency.round( + taxline['base']) + if taxline not in taxes: + taxes[taxline] = taxline + else: + taxes[taxline]['base'] += taxline['base'] + taxes[taxline]['amount'] += taxline['amount'] + current_taxes[taxline] = taxes[taxline] + if tax_rounding == 'line': + self._round_taxes(current_taxes) + if tax_rounding == 'document': + self._round_taxes(taxes) + if all_taxes.keys() & taxes.keys(): + raise ValueError("Duplicate tax line keys") + all_taxes.update(taxes) + return all_taxes class TaxLine(ModelSQL, ModelView): diff --git a/modules/account/tests/test_module.py b/modules/account/tests/test_module.py index 7998a1a778..aa188f9120 100644 --- a/modules/account/tests/test_module.py +++ b/modules/account/tests/test_module.py @@ -1457,6 +1457,50 @@ class AccountTestCase( self.assertEqual(taxline_3['base'], Decimal('-2.95')) self.assertEqual(taxline_3['amount'], Decimal('-0.30')) + @with_transaction() + def test_taxable_mixin_tax_residual_rounding_per_same_taxes(self): + """Test TaxableMixin for rounding with residual amount only for same + taxes""" + pool = Pool() + Account = pool.get('account.account') + Tax = pool.get('account.tax') + Configuration = pool.get('account.configuration') + currency = create_currency('cur') + + company = create_company() + with set_company(company): + create_chart(company) + + config = Configuration(1) + config.tax_rounding = 'document' + config.save() + + tax_account, = Account.search([ + ('code', '=', '6.3.6'), + ]) + + tax = Tax() + tax.name = tax.description = "Tax" + tax.type = 'percentage' + tax.rate = Decimal('.06') + tax.invoice_account = tax_account + tax.credit_note_account = tax_account + tax.save() + + taxable = self.Taxable( + currency=currency, + taxable_lines=[ + ([tax], Decimal('11.2736'), 1.04, None), + ([tax], Decimal('0.4529'), -1, None), + ], + company=company) + + taxline_1, taxline_2 = taxable._get_taxes() + self.assertEqual(taxline_1['base'], Decimal('11.72')) + self.assertEqual(taxline_1['amount'], Decimal('0.7')) + self.assertEqual(taxline_2['base'], Decimal('-0.45')) + self.assertEqual(taxline_2['amount'], Decimal('-0.03')) + @with_transaction() def test_tax_compute_with_children_update_unit_price(self): "Test tax compute with children taxes modifying unit_price"