diff --git a/.gitea/workflows/publish.yml b/.gitea/workflows/publish.yml index 683b45d..82e4467 100644 --- a/.gitea/workflows/publish.yml +++ b/.gitea/workflows/publish.yml @@ -3,16 +3,24 @@ name: Publish Package on: push: branches: - - "**" + - "8.0" workflow_dispatch: +permissions: + contents: read + jobs: publish: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: https://gitea.com/actions/checkout@v4 + with: + token: ${{ secrets.GITEA_TOKEN }} + github-server-url: "http://unpaismejor-gitea-app:3000" + ref: "8.0" + fetch-depth: 0 - name: Detect package branch run: | @@ -21,7 +29,9 @@ jobs: import re branch = ( - os.environ.get('GITHUB_REF_NAME') + os.environ.get('GITEA_REF_NAME') + or os.environ.get('GITEA_HEAD_REF') + or os.environ.get('GITHUB_REF_NAME') or os.environ.get('GITHUB_HEAD_REF') or '') publish = bool(re.fullmatch(r'\d+\.\d+', branch)) @@ -75,7 +85,7 @@ jobs: OWNER = 'tryton-do' PACKAGE_TYPE = 'pypi' - API_ROOT = 'https://gitea.joseagrc.com/api/v1' + API_ROOT = 'https://code.unpaismejor.org.do/api/v1' def normalize(name): return re.sub(r'[-_.]+', '-', name).lower() @@ -187,5 +197,5 @@ jobs: TWINE_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }} run: | python -m twine upload \ - --repository-url https://gitea.joseagrc.com/api/packages/tryton-do/pypi \ + --repository-url https://code.unpaismejor.org.do/api/packages/tryton-do/pypi \ dist/* diff --git a/CHANGELOG b/CHANGELOG new file mode 100644 index 0000000..5b41a1b --- /dev/null +++ b/CHANGELOG @@ -0,0 +1,8 @@ +Version 8.0.0 - 2026-08-14 +-------------------------- + +* Preserve the source invoice's signed adjustment lines and manual taxes. +* Make partial debit rounding operate on signed net amounts. +* Reject ambiguous fixed-amount operations on multiple source invoices. +* Declare Fundación Un País Mejor as maintainer and package publisher. +* Add the Gitea Actions package publishing workflow for the 8.0 series. diff --git a/README.rst b/README.rst index 8dd6d87..1d0b205 100644 --- a/README.rst +++ b/README.rst @@ -13,7 +13,7 @@ Installing Install the package and activate the module in the database:: - pip install trytond_account_debit_note + pip install unpaismejor_account_debit_note trytond-admin -d -u account_debit_note Publishing diff --git a/invoice.py b/invoice.py index 81e6cb4..c12811b 100644 --- a/invoice.py +++ b/invoice.py @@ -30,6 +30,7 @@ _ONE_HUNDRED = Decimal('100') def _decimal(value): + """Return *value* as Decimal without binary floating-point artifacts.""" if value is None: return _ZERO return Decimal(str(value)) @@ -107,7 +108,8 @@ class Invoice(metaclass=PoolMeta): Tryton core exposes Invoice._credit(), which copies the invoice and reverses line signs. Reusing it keeps the same field-copying behavior - as the core credit flow; the debit note then restores normal signs. + as the core credit flow; the debit note then reverses those signs once + more to restore the exact signs of the source invoice. """ debit = self._credit(**values) self.__class__._debit_force_positive_sign(debit) @@ -115,48 +117,49 @@ class Invoice(metaclass=PoolMeta): @classmethod def _debit_force_positive_sign(cls, debit_note): - Line = Pool().get('account.invoice.line') + """Undo the sign reversal performed by Tryton's core ``_credit``. + + Do not use ``abs`` here: invoices may legitimately contain negative + adjustment lines or manual taxes. A debit note must preserve those + relative signs rather than turn every component positive. + """ touched = [] for line in debit_note.lines: if line.type != 'line': continue quantity = _decimal(line.quantity) - unit_price = _decimal(line.unit_price) - new_quantity = abs(quantity) - new_unit_price = abs(unit_price) + new_quantity = -quantity - if quantity != new_quantity or unit_price != new_unit_price: + if quantity != new_quantity: line.quantity = new_quantity - line.unit_price = new_unit_price touched.append(line) if touched and all(getattr(line, 'id', None) for line in touched): + Line = Pool().get('account.invoice.line') Line.save(touched) for tax in getattr(debit_note, 'taxes', None) or []: - tax.base = abs(_decimal(tax.base)) - tax.amount = abs(_decimal(tax.amount)) + tax.base = -_decimal(tax.base) + tax.amount = -_decimal(tax.amount) @classmethod def _debit_apply_factor(cls, origin, debit_note, factor): if factor == _ONE: return - Line = Pool().get('account.invoice.line') lines = [line for line in debit_note.lines if line.type == 'line'] touched = [] for line in lines: - quantity = _decimal(line.quantity) unit_price = _decimal(line.unit_price) scale = _unit_price_scale(line) - line.quantity = abs(quantity) - line.unit_price = _round_decimal(abs(unit_price) * factor, scale) + line.unit_price = _round_decimal(unit_price * factor, scale) touched.append(line) if touched and all(getattr(line, 'id', None) for line in touched): + Line = Pool().get('account.invoice.line') Line.save(touched) cls._debit_adjust_last_line(origin, touched, factor) @@ -166,11 +169,11 @@ class Invoice(metaclass=PoolMeta): if not lines: return - Line = Pool().get('account.invoice.line') - target_untaxed_abs = abs(_decimal(origin.untaxed_amount)) * factor - current_untaxed_abs = sum( - abs(_decimal(cls._debit_line_amount(line))) for line in lines) - difference = target_untaxed_abs - current_untaxed_abs + target_untaxed = _decimal(origin.untaxed_amount) * factor + current_untaxed = sum( + (_decimal(cls._debit_line_amount(line)) for line in lines), + _ZERO) + difference = target_untaxed - current_untaxed if abs(difference) < Decimal('0.0001'): return @@ -179,19 +182,18 @@ class Invoice(metaclass=PoolMeta): if quantity == _ZERO: return - unit_price_abs = abs(_decimal(last.unit_price)) - unit_price_delta_abs = difference / abs(quantity) - new_unit_price_abs = unit_price_abs + unit_price_delta_abs - if new_unit_price_abs < _ZERO: - new_unit_price_abs = _ZERO - - last.quantity = abs(quantity) - last.unit_price = _round_decimal(new_unit_price_abs, _unit_price_scale(last)) + new_unit_price = _decimal(last.unit_price) + difference / quantity + last.unit_price = _round_decimal( + new_unit_price, _unit_price_scale(last)) if getattr(last, 'id', None): + Line = Pool().get('account.invoice.line') Line.save([last]) @classmethod def _debit_line_amount(cls, line): + on_change_with_amount = getattr(line, 'on_change_with_amount', None) + if on_change_with_amount: + return on_change_with_amount() return _decimal(line.quantity) * _decimal(line.unit_price) @classmethod @@ -223,6 +225,10 @@ class Invoice(metaclass=PoolMeta): debit_amount = values.pop('debit_amount', None) debit_amount_basis = values.pop('debit_amount_basis', None) or 'total' + if debit_mode == 'amount' and len(invoices) != 1: + raise UserError(gettext( + 'account_debit_note.msg_amount_multiple_invoices')) + factors = [ cls._debit_compute_factor( origin, debit_mode, debit_percent, debit_amount, diff --git a/locale/es.po b/locale/es.po index 61b9160..be35913 100644 --- a/locale/es.po +++ b/locale/es.po @@ -66,6 +66,10 @@ msgctxt "model:ir.message,text:msg_missing_debit_percent" msgid "Debit percentage is required." msgstr "El porcentaje de débito es obligatorio." +msgctxt "model:ir.message,text:msg_amount_multiple_invoices" +msgid "A fixed debit amount can only be applied to one source invoice at a time." +msgstr "Un importe fijo de débito solo puede aplicarse a una factura de origen a la vez." + msgctxt "model:ir.ui.view,name:debit_start_view_form" msgid "debit_start_form" msgstr "debit_start_form" diff --git a/locale/es_419.po b/locale/es_419.po index d235fde..f79912d 100644 --- a/locale/es_419.po +++ b/locale/es_419.po @@ -70,6 +70,10 @@ msgctxt "model:ir.message,text:msg_missing_debit_percent" msgid "Debit percentage is required." msgstr "El porcentaje de débito es requerido." +msgctxt "model:ir.message,text:msg_amount_multiple_invoices" +msgid "A fixed debit amount can only be applied to one source invoice at a time." +msgstr "Un monto fijo de débito solo puede aplicarse a una factura de origen a la vez." + msgctxt "model:ir.ui.view,name:debit_start_view_form" msgid "debit_start_form" msgstr "debit_start_form" diff --git a/message.xml b/message.xml index fe910d7..06ae238 100644 --- a/message.xml +++ b/message.xml @@ -19,5 +19,8 @@ Invalid debit note mode. + + A fixed debit amount can only be applied to one source invoice at a time. + diff --git a/pyproject.toml b/pyproject.toml index c8dcda4..8696dc9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,11 +3,11 @@ requires = ['hatchling >= 1', 'hatch-tryton'] build-backend = 'hatchling.build' [project] -name = 'trytond_account_debit_note' +name = 'unpaismejor_account_debit_note' dynamic = ['version', 'dependencies', 'optional-dependencies', 'authors', 'readme'] requires-python = '>=3.10' maintainers = [ - {name = "Solutema"}, + {name = "Fundación Un País Mejor"}, ] description = "Debit notes for Tryton invoices" license = 'GPL-3.0-or-later' @@ -26,11 +26,12 @@ classifiers = [ account_debit_note = 'trytond.modules.account_debit_note' [project.urls] -homepage = "https://www.tryton.org/" -repository = "https://gitea.joseagrc.com/tryton-do/account_debit_note" +homepage = "https://www.unpaismejor.org.do" +repository = "https://code.unpaismejor.org.do/tryton-do/account_debit_note" [tool.hatch.build] include = [ + 'CHANGELOG', 'COPYRIGHT', 'LICENSE', 'README.rst', diff --git a/tests/test_module.py b/tests/test_module.py index a844ec0..e83f449 100644 --- a/tests/test_module.py +++ b/tests/test_module.py @@ -11,6 +11,28 @@ class MockInvoice: total_amount = Decimal('118.00') +class MockLine: + type = 'line' + + def __init__(self, quantity, unit_price): + self.quantity = Decimal(quantity) + self.unit_price = Decimal(unit_price) + + +class MockTax: + + def __init__(self, base, amount): + self.base = Decimal(base) + self.amount = Decimal(amount) + + +class MockDebitNote: + + def __init__(self, lines=(), taxes=()): + self.lines = list(lines) + self.taxes = list(taxes) + + class AccountDebitNoteTestCase(ModuleTestCase): 'Test Account Debit Note module' module = 'account_debit_note' @@ -49,6 +71,47 @@ class AccountDebitNoteLogicTestCase(unittest.TestCase): Invoice._debit_compute_factor( MockInvoice(), 'percent', Decimal('101'), None, 'total') + def test_fixed_amount_rejects_multiple_invoices(self): + with self.assertRaises(UserError): + Invoice.debit( + [MockInvoice(), MockInvoice()], + debit_mode='amount', debit_amount=Decimal('10')) + + def test_debit_restores_negative_adjustment_line_sign(self): + # Core _credit() changed the original quantity 2 to -2 while keeping + # the original negative adjustment price. + line = MockLine('-2', '-15') + debit = MockDebitNote([line]) + + Invoice._debit_force_positive_sign(debit) + + self.assertEqual(line.quantity, Decimal('2')) + self.assertEqual(line.unit_price, Decimal('-15')) + + def test_debit_restores_manual_tax_sign_without_abs(self): + # Core _credit() negated the original base 100 and amount -18. + tax = MockTax('-100', '18') + debit = MockDebitNote(taxes=[tax]) + + Invoice._debit_force_positive_sign(debit) + + self.assertEqual(tax.base, Decimal('100')) + self.assertEqual(tax.amount, Decimal('-18')) + + def test_partial_debit_preserves_relative_line_signs(self): + positive = MockLine('2', '50') + adjustment = MockLine('1', '-20') + debit = MockDebitNote([positive, adjustment]) + origin = MockInvoice() + origin.untaxed_amount = Decimal('80') + + Invoice._debit_apply_factor(origin, debit, Decimal('0.5')) + + self.assertEqual(positive.quantity, Decimal('2')) + self.assertEqual(positive.unit_price, Decimal('25.00000')) + self.assertEqual(adjustment.quantity, Decimal('1')) + self.assertEqual(adjustment.unit_price, Decimal('-10.00000')) + del ModuleTestCase