Harden debit notes for Tryton 8.0
Publish Package / publish (push) Successful in 2m10s

This commit is contained in:
José Arturo García
2026-08-14 20:07:14 -04:00
parent 2223f611d6
commit 392dadf68a
9 changed files with 135 additions and 36 deletions
+15 -5
View File
@@ -3,16 +3,24 @@ name: Publish Package
on: on:
push: push:
branches: branches:
- "**" - "8.0"
workflow_dispatch: workflow_dispatch:
permissions:
contents: read
jobs: jobs:
publish: publish:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - 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 - name: Detect package branch
run: | run: |
@@ -21,7 +29,9 @@ jobs:
import re import re
branch = ( 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 os.environ.get('GITHUB_HEAD_REF')
or '') or '')
publish = bool(re.fullmatch(r'\d+\.\d+', branch)) publish = bool(re.fullmatch(r'\d+\.\d+', branch))
@@ -75,7 +85,7 @@ jobs:
OWNER = 'tryton-do' OWNER = 'tryton-do'
PACKAGE_TYPE = 'pypi' PACKAGE_TYPE = 'pypi'
API_ROOT = 'https://gitea.joseagrc.com/api/v1' API_ROOT = 'https://code.unpaismejor.org.do/api/v1'
def normalize(name): def normalize(name):
return re.sub(r'[-_.]+', '-', name).lower() return re.sub(r'[-_.]+', '-', name).lower()
@@ -187,5 +197,5 @@ jobs:
TWINE_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }} TWINE_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
run: | run: |
python -m twine upload \ 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/* dist/*
+8
View File
@@ -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.
+1 -1
View File
@@ -13,7 +13,7 @@ Installing
Install the package and activate the module in the database:: 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 <database> -u account_debit_note trytond-admin -d <database> -u account_debit_note
Publishing Publishing
+32 -26
View File
@@ -30,6 +30,7 @@ _ONE_HUNDRED = Decimal('100')
def _decimal(value): def _decimal(value):
"""Return *value* as Decimal without binary floating-point artifacts."""
if value is None: if value is None:
return _ZERO return _ZERO
return Decimal(str(value)) return Decimal(str(value))
@@ -107,7 +108,8 @@ class Invoice(metaclass=PoolMeta):
Tryton core exposes Invoice._credit(), which copies the invoice and Tryton core exposes Invoice._credit(), which copies the invoice and
reverses line signs. Reusing it keeps the same field-copying behavior 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) debit = self._credit(**values)
self.__class__._debit_force_positive_sign(debit) self.__class__._debit_force_positive_sign(debit)
@@ -115,48 +117,49 @@ class Invoice(metaclass=PoolMeta):
@classmethod @classmethod
def _debit_force_positive_sign(cls, debit_note): 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 = [] touched = []
for line in debit_note.lines: for line in debit_note.lines:
if line.type != 'line': if line.type != 'line':
continue continue
quantity = _decimal(line.quantity) quantity = _decimal(line.quantity)
unit_price = _decimal(line.unit_price) new_quantity = -quantity
new_quantity = abs(quantity)
new_unit_price = abs(unit_price)
if quantity != new_quantity or unit_price != new_unit_price: if quantity != new_quantity:
line.quantity = new_quantity line.quantity = new_quantity
line.unit_price = new_unit_price
touched.append(line) touched.append(line)
if touched and all(getattr(line, 'id', None) for line in touched): if touched and all(getattr(line, 'id', None) for line in touched):
Line = Pool().get('account.invoice.line')
Line.save(touched) Line.save(touched)
for tax in getattr(debit_note, 'taxes', None) or []: for tax in getattr(debit_note, 'taxes', None) or []:
tax.base = abs(_decimal(tax.base)) tax.base = -_decimal(tax.base)
tax.amount = abs(_decimal(tax.amount)) tax.amount = -_decimal(tax.amount)
@classmethod @classmethod
def _debit_apply_factor(cls, origin, debit_note, factor): def _debit_apply_factor(cls, origin, debit_note, factor):
if factor == _ONE: if factor == _ONE:
return return
Line = Pool().get('account.invoice.line')
lines = [line for line in debit_note.lines if line.type == 'line'] lines = [line for line in debit_note.lines if line.type == 'line']
touched = [] touched = []
for line in lines: for line in lines:
quantity = _decimal(line.quantity)
unit_price = _decimal(line.unit_price) unit_price = _decimal(line.unit_price)
scale = _unit_price_scale(line) scale = _unit_price_scale(line)
line.quantity = abs(quantity) line.unit_price = _round_decimal(unit_price * factor, scale)
line.unit_price = _round_decimal(abs(unit_price) * factor, scale)
touched.append(line) touched.append(line)
if touched and all(getattr(line, 'id', None) for line in touched): if touched and all(getattr(line, 'id', None) for line in touched):
Line = Pool().get('account.invoice.line')
Line.save(touched) Line.save(touched)
cls._debit_adjust_last_line(origin, touched, factor) cls._debit_adjust_last_line(origin, touched, factor)
@@ -166,11 +169,11 @@ class Invoice(metaclass=PoolMeta):
if not lines: if not lines:
return return
Line = Pool().get('account.invoice.line') target_untaxed = _decimal(origin.untaxed_amount) * factor
target_untaxed_abs = abs(_decimal(origin.untaxed_amount)) * factor current_untaxed = sum(
current_untaxed_abs = sum( (_decimal(cls._debit_line_amount(line)) for line in lines),
abs(_decimal(cls._debit_line_amount(line))) for line in lines) _ZERO)
difference = target_untaxed_abs - current_untaxed_abs difference = target_untaxed - current_untaxed
if abs(difference) < Decimal('0.0001'): if abs(difference) < Decimal('0.0001'):
return return
@@ -179,19 +182,18 @@ class Invoice(metaclass=PoolMeta):
if quantity == _ZERO: if quantity == _ZERO:
return return
unit_price_abs = abs(_decimal(last.unit_price)) new_unit_price = _decimal(last.unit_price) + difference / quantity
unit_price_delta_abs = difference / abs(quantity) last.unit_price = _round_decimal(
new_unit_price_abs = unit_price_abs + unit_price_delta_abs new_unit_price, _unit_price_scale(last))
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))
if getattr(last, 'id', None): if getattr(last, 'id', None):
Line = Pool().get('account.invoice.line')
Line.save([last]) Line.save([last])
@classmethod @classmethod
def _debit_line_amount(cls, line): 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) return _decimal(line.quantity) * _decimal(line.unit_price)
@classmethod @classmethod
@@ -223,6 +225,10 @@ class Invoice(metaclass=PoolMeta):
debit_amount = values.pop('debit_amount', None) debit_amount = values.pop('debit_amount', None)
debit_amount_basis = values.pop('debit_amount_basis', None) or 'total' 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 = [ factors = [
cls._debit_compute_factor( cls._debit_compute_factor(
origin, debit_mode, debit_percent, debit_amount, origin, debit_mode, debit_percent, debit_amount,
+4
View File
@@ -66,6 +66,10 @@ msgctxt "model:ir.message,text:msg_missing_debit_percent"
msgid "Debit percentage is required." msgid "Debit percentage is required."
msgstr "El porcentaje de débito es obligatorio." 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" msgctxt "model:ir.ui.view,name:debit_start_view_form"
msgid "debit_start_form" msgid "debit_start_form"
msgstr "debit_start_form" msgstr "debit_start_form"
+4
View File
@@ -70,6 +70,10 @@ msgctxt "model:ir.message,text:msg_missing_debit_percent"
msgid "Debit percentage is required." msgid "Debit percentage is required."
msgstr "El porcentaje de débito es requerido." 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" msgctxt "model:ir.ui.view,name:debit_start_view_form"
msgid "debit_start_form" msgid "debit_start_form"
msgstr "debit_start_form" msgstr "debit_start_form"
+3
View File
@@ -19,5 +19,8 @@
<record model="ir.message" id="msg_invalid_debit_mode"> <record model="ir.message" id="msg_invalid_debit_mode">
<field name="text">Invalid debit note mode.</field> <field name="text">Invalid debit note mode.</field>
</record> </record>
<record model="ir.message" id="msg_amount_multiple_invoices">
<field name="text">A fixed debit amount can only be applied to one source invoice at a time.</field>
</record>
</data> </data>
</tryton> </tryton>
+5 -4
View File
@@ -3,11 +3,11 @@ requires = ['hatchling >= 1', 'hatch-tryton']
build-backend = 'hatchling.build' build-backend = 'hatchling.build'
[project] [project]
name = 'trytond_account_debit_note' name = 'unpaismejor_account_debit_note'
dynamic = ['version', 'dependencies', 'optional-dependencies', 'authors', 'readme'] dynamic = ['version', 'dependencies', 'optional-dependencies', 'authors', 'readme']
requires-python = '>=3.10' requires-python = '>=3.10'
maintainers = [ maintainers = [
{name = "Solutema"}, {name = "Fundación Un País Mejor"},
] ]
description = "Debit notes for Tryton invoices" description = "Debit notes for Tryton invoices"
license = 'GPL-3.0-or-later' license = 'GPL-3.0-or-later'
@@ -26,11 +26,12 @@ classifiers = [
account_debit_note = 'trytond.modules.account_debit_note' account_debit_note = 'trytond.modules.account_debit_note'
[project.urls] [project.urls]
homepage = "https://www.tryton.org/" homepage = "https://www.unpaismejor.org.do"
repository = "https://gitea.joseagrc.com/tryton-do/account_debit_note" repository = "https://code.unpaismejor.org.do/tryton-do/account_debit_note"
[tool.hatch.build] [tool.hatch.build]
include = [ include = [
'CHANGELOG',
'COPYRIGHT', 'COPYRIGHT',
'LICENSE', 'LICENSE',
'README.rst', 'README.rst',
+63
View File
@@ -11,6 +11,28 @@ class MockInvoice:
total_amount = Decimal('118.00') 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): class AccountDebitNoteTestCase(ModuleTestCase):
'Test Account Debit Note module' 'Test Account Debit Note module'
module = 'account_debit_note' module = 'account_debit_note'
@@ -49,6 +71,47 @@ class AccountDebitNoteLogicTestCase(unittest.TestCase):
Invoice._debit_compute_factor( Invoice._debit_compute_factor(
MockInvoice(), 'percent', Decimal('101'), None, 'total') 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 del ModuleTestCase