From c4765f146988ba3d42aa582c42199751d6c261f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Arturo=20Garc=C3=ADa?= Date: Sun, 30 Aug 2026 21:29:56 -0400 Subject: [PATCH] Round Dominican invoice taxes independently --- __init__.py | 4 +-- invoice.py | 39 +++++++++++++++++++++++++++ tests/test_module.py | 63 ++++++++++++++++++++++++++++++++++++++++++-- tryton.cfg | 3 +++ 4 files changed, 105 insertions(+), 4 deletions(-) create mode 100644 invoice.py diff --git a/__init__.py b/__init__.py index 5c88c97..81e9197 100644 --- a/__init__.py +++ b/__init__.py @@ -1,6 +1,6 @@ # This file is part of Tryton. The COPYRIGHT file at the top level of # this repository contains the full copyright notices and license terms. -from . import tax +from . import invoice, tax -__all__ = ['tax'] +__all__ = ['invoice', 'tax'] diff --git a/invoice.py b/invoice.py new file mode 100644 index 0000000..4981d19 --- /dev/null +++ b/invoice.py @@ -0,0 +1,39 @@ +"""Dominican invoice tax rounding policy. + +Tryton's generic taxable mixin compensates the aggregate currency-rounding +remainder on the first non-zero tax. That keeps the rounded aggregate equal +to the aggregate of the unrounded taxes, but it can change the reported value +of a specific Dominican tax (ITBIS, ISC or CDT). + +Dominican fiscal documents state every tax in currency precision. Each tax +must therefore retain its own rounded result; no residual is redistributed +between different tax liabilities or tax credits. +""" + +from trytond.model import fields +from trytond.pool import PoolMeta + + +def round_taxes_independently(currency, taxes): + """Round every tax amount without reallocating the aggregate residual.""" + if not currency: + return + for taxline in taxes.values(): + taxline.amount = currency.round(taxline.amount) + + +class IndependentTaxRoundingMixin: + + __slots__ = () + + @fields.depends('currency') + def _round_taxes(self, taxes): + round_taxes_independently(self.currency, taxes) + + +class Invoice(IndependentTaxRoundingMixin, metaclass=PoolMeta): + __name__ = 'account.invoice' + + +class InvoiceLine(IndependentTaxRoundingMixin, metaclass=PoolMeta): + __name__ = 'account.invoice.line' diff --git a/tests/test_module.py b/tests/test_module.py index 21994db..5d6bc3e 100644 --- a/tests/test_module.py +++ b/tests/test_module.py @@ -4,12 +4,15 @@ from collections import Counter from configparser import ConfigParser from decimal import Decimal from pathlib import Path +from types import SimpleNamespace from xml.etree import ElementTree as ET from trytond.pool import Pool from trytond.tests.test_tryton import ModuleTestCase, with_transaction from trytond.transaction import Transaction +from trytond.modules.account_do.invoice import round_taxes_independently + MODULE_DIR = Path(__file__).resolve().parent.parent TAX_CODE_LINE_AUDIT = { @@ -125,6 +128,56 @@ class AccountDoTestCase(ModuleTestCase): 'account_stock_continental', ] + @with_transaction() + def test_invoice_taxes_round_independently(self): + """A rounding residual must not change ITBIS, ISC or CDT.""" + pool = Pool() + Currency = pool.get('currency.currency') + currency, = Currency.create([{ + 'name': 'Peso dominicano', + 'code': 'DOP', + 'symbol': 'RD$', + 'digits': 2, + 'rounding': Decimal('0.01'), + }]) + taxes = { + 'itbis': SimpleNamespace(amount=Decimal('91.8036')), + 'isc': SimpleNamespace(amount=Decimal('51.0020')), + 'cdt': SimpleNamespace(amount=Decimal('9.9000')), + } + + round_taxes_independently(currency, taxes) + + self.assertEqual(taxes['itbis'].amount, Decimal('91.80')) + self.assertEqual(taxes['isc'].amount, Decimal('51.00')) + self.assertEqual(taxes['cdt'].amount, Decimal('9.90')) + self.assertEqual( + sum(tax.amount for tax in taxes.values()), Decimal('152.70')) + + @with_transaction() + def test_invoice_credit_note_taxes_round_independently(self): + """The same policy applies symmetrically to credit notes.""" + pool = Pool() + Currency = pool.get('currency.currency') + currency, = Currency.create([{ + 'name': 'Peso dominicano', + 'code': 'DOP', + 'symbol': 'RD$', + 'digits': 2, + 'rounding': Decimal('0.01'), + }]) + taxes = { + 'itbis': SimpleNamespace(amount=Decimal('-91.8036')), + 'isc': SimpleNamespace(amount=Decimal('-51.0020')), + 'cdt': SimpleNamespace(amount=Decimal('-9.9000')), + } + + round_taxes_independently(currency, taxes) + + self.assertEqual(taxes['itbis'].amount, Decimal('-91.80')) + self.assertEqual(taxes['isc'].amount, Decimal('-51.00')) + self.assertEqual(taxes['cdt'].amount, Decimal('-9.90')) + @with_transaction() def test_chart_template_is_loaded_by_default(self): 'Test English Dominican chart is loaded in an English database' @@ -571,7 +624,7 @@ class AccountDoUnitTestCase(unittest.TestCase): self.assertEqual(config.get('tryton', 'version'), '8.0.5') self.assertEqual( set(lines('tryton', 'depends')), - {'account', 'ir'}) + {'account', 'account_invoice', 'ir'}) self.assertEqual( lines('tryton', 'xml'), [ @@ -587,7 +640,13 @@ class AccountDoUnitTestCase(unittest.TestCase): ]) self.assertEqual( lines('register', 'model'), - ['ir.ModelData', 'tax.TaxTemplate', 'tax.Tax']) + [ + 'ir.ModelData', + 'invoice.Invoice', + 'invoice.InvoiceLine', + 'tax.TaxTemplate', + 'tax.Tax', + ]) self.assertEqual(lines('register', 'wizard'), ['account.CreateChart']) for filename in lines('tryton', 'xml'): self.assertTrue((MODULE_DIR / filename).is_file()) diff --git a/tryton.cfg b/tryton.cfg index 1062b2c..7fc5e93 100644 --- a/tryton.cfg +++ b/tryton.cfg @@ -2,6 +2,7 @@ version=8.0.5 depends: account + account_invoice ir xml: account_chart_do_en.xml @@ -17,6 +18,8 @@ xml: [register] model: ir.ModelData + invoice.Invoice + invoice.InvoiceLine tax.TaxTemplate tax.Tax wizard: