From 3396aaee10ae4ab14ba4c26f9bc6d6aec8ec3149 Mon Sep 17 00:00:00 2001 From: Antoine Maas Date: Thu, 25 Jun 2026 14:39:00 +0200 Subject: [PATCH 1/4] fix: reconcile PEPPOL monetary totals on sub-cent invoices (BR-CO-10/15/16) --- .../edocument/profiles/peppol/generator.py | 49 ++++++++++++++----- 1 file changed, 37 insertions(+), 12 deletions(-) diff --git a/edocument/edocument/profiles/peppol/generator.py b/edocument/edocument/profiles/peppol/generator.py index c0fda2c..03b570f 100644 --- a/edocument/edocument/profiles/peppol/generator.py +++ b/edocument/edocument/profiles/peppol/generator.py @@ -452,7 +452,10 @@ def _add_line_item(self, root: ET.Element, item): invoice_line = line_elem line_amount = ET.SubElement(invoice_line, f"{{{self.namespaces['cbc']}}}LineExtensionAmount") - line_amount.text = f"{item_amount:.2f}" + # Round through flt (the site's configured rounding), not f"{x:.2f}", so the line amount + # matches the document LineExtensionAmount (BT-106), which is also flt-rounded — otherwise + # the two disagree and BR-CO-10 fails on sub-cent (3-decimal) amounts. + line_amount.text = f"{flt(item_amount, 2):.2f}" line_amount.set("currencyID", self.invoice.currency) item_elem = ET.SubElement(invoice_line, f"{{{self.namespaces['cac']}}}Item") @@ -835,9 +838,16 @@ def _set_totals(self): tax_exclusive_amount.text = f"{inv_net_total:.2f}" tax_exclusive_amount.set("currencyID", self.invoice.currency) - # Tax Inclusive Amount (BT-112) - Invoice total amount with VAT + # Tax Inclusive Amount (BT-112) = Tax Exclusive + total VAT. Derive it from the rounded + # parts instead of reading grand_total, so it still reconciles when the invoice carries + # sub-cent (3-decimal) amounts whose independent 2-decimal roundings no longer add up + # (BR-CO-15). + total_tax = flt(self.invoice.total_taxes_and_charges, 2) + if is_credit: + total_tax = abs(total_tax) + tax_inclusive = flt(inv_net_total + total_tax, 2) tax_inclusive_amount = ET.SubElement(legal_total, f"{{{self.namespaces['cbc']}}}TaxInclusiveAmount") - tax_inclusive_amount.text = f"{inv_grand_total:.2f}" + tax_inclusive_amount.text = f"{tax_inclusive:.2f}" tax_inclusive_amount.set("currencyID", self.invoice.currency) # Allowance Total Amount (BT-107) - Sum of allowances on document level (discount) @@ -862,24 +872,39 @@ def _set_totals(self): charge_total.text = "0.00" charge_total.set("currencyID", self.invoice.currency) - # Prepaid Amount (BT-113) - Sum of amounts already paid - # This is required when PayableAmount != TaxInclusiveAmount to satisfy BR-CO-16: - # PayableAmount = TaxInclusiveAmount - PrepaidAmount + RoundingAmount + # Prepaid Amount (BT-113) - already-paid portion, measured against the rounded total due + # (what the customer actually owes) so partially-paid invoices with a rounding adjustment + # still balance; falls back to grand_total when Round Off is disabled (rounded_total = 0). + prepaid_value = 0.0 if not is_credit: - prepaid_value = flt(self.invoice.grand_total, 2) - flt(self.invoice.outstanding_amount, 2) + rounded_total = flt(self.invoice.rounded_total, 2) or flt(self.invoice.grand_total, 2) + prepaid_value = flt(rounded_total - flt(self.invoice.outstanding_amount, 2), 2) if prepaid_value > 0: prepaid_amount = ET.SubElement(legal_total, f"{{{self.namespaces['cbc']}}}PrepaidAmount") - prepaid_amount.text = f"{flt(prepaid_value, 2):.2f}" + prepaid_amount.text = f"{prepaid_value:.2f}" prepaid_amount.set("currencyID", self.invoice.currency) + else: + prepaid_value = 0.0 - # Payable Amount (BT-115) - Amount due for payment - payable_amount = ET.SubElement(legal_total, f"{{{self.namespaces['cbc']}}}PayableAmount") + # Payable Amount (BT-115) - the real amount due if is_credit: - # For credit notes, use positive grand_total (amount to be credited) payable_value = inv_grand_total else: - # For invoices, use outstanding_amount payable_value = flt(self.invoice.outstanding_amount, 2) + + # Payable Rounding Amount (BT-114) - residual that reconciles the derived tax-inclusive + # total with the amount due (BR-CO-16: Payable = TaxInclusive - Prepaid + Rounding). On a + # sub-cent invoice this carries the <=1 cent gap. Must come after PrepaidAmount and before + # PayableAmount per the UBL XSD order; emitted only when non-zero. + rounding_amount = flt(payable_value - (tax_inclusive - prepaid_value), 2) + if rounding_amount: + payable_rounding = ET.SubElement( + legal_total, f"{{{self.namespaces['cbc']}}}PayableRoundingAmount" + ) + payable_rounding.text = f"{rounding_amount:.2f}" + payable_rounding.set("currencyID", self.invoice.currency) + + payable_amount = ET.SubElement(legal_total, f"{{{self.namespaces['cbc']}}}PayableAmount") payable_amount.text = f"{payable_value:.2f}" payable_amount.set("currencyID", self.invoice.currency) From ea9c8d76d88f303004dba54205b1598f9d1c7094 Mon Sep 17 00:00:00 2001 From: Antoine Maas Date: Thu, 25 Jun 2026 14:39:01 +0200 Subject: [PATCH 2/4] test: cover LegalMonetaryTotal reconciliation (BR-CO-15/16) --- .../profiles/peppol/test_generator.py | 156 ++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 edocument/edocument/profiles/peppol/test_generator.py diff --git a/edocument/edocument/profiles/peppol/test_generator.py b/edocument/edocument/profiles/peppol/test_generator.py new file mode 100644 index 0000000..966d99d --- /dev/null +++ b/edocument/edocument/profiles/peppol/test_generator.py @@ -0,0 +1,156 @@ +# Copyright (c) 2025, Prilk Consulting BV and Contributors +# See license.txt + +import frappe +from frappe.utils.data import flt +from lxml import etree as ET + +try: + from frappe.tests import IntegrationTestCase as TestCase +except ImportError: + from frappe.tests.utils import FrappeTestCase as TestCase + +from edocument.edocument.profiles.peppol import UBL_NAMESPACES +from edocument.edocument.profiles.peppol.generator import PEPPOLGenerator + +CBC = UBL_NAMESPACES["cbc"] +CAC = UBL_NAMESPACES["cac"] + + +def _build_legal_monetary_total(invoice, document_type="Invoice"): + """Run PEPPOLGenerator._set_totals against a stand-in invoice and return the + LegalMonetaryTotal element. __init__ is bypassed because it loads related + addresses/contacts from the database, which _set_totals does not need.""" + generator = PEPPOLGenerator.__new__(PEPPOLGenerator) + generator.invoice = invoice + generator.document_type = document_type + generator.root = ET.Element("Root") + generator._set_totals() + return generator.root.find(f"{{{CAC}}}LegalMonetaryTotal") + + +def _amount(legal_total, tag): + element = legal_total.find(f"{{{CBC}}}{tag}") + return flt(element.text, 2) if element is not None else None + + +class TestLegalMonetaryTotals(TestCase): + """TaxInclusiveAmount (BT-112) is derived as TaxExclusive + total VAT rather than read from + grand_total, and a PayableRoundingAmount (BT-114) carries the residual, so the totals satisfy: + + - BR-CO-15: TaxInclusiveAmount = TaxExclusiveAmount + total VAT + - BR-CO-16: PayableAmount = TaxInclusiveAmount - PrepaidAmount + PayableRoundingAmount + """ + + def _invoice(self, *, net, tax=0.0, grand, rounded, outstanding, total=None, taxes=None): + return frappe._dict( + total=net if total is None else total, + net_total=net, + grand_total=grand, + rounded_total=rounded, + outstanding_amount=outstanding, + total_taxes_and_charges=tax, + currency="EUR", + taxes=taxes or [], + ) + + def _assert_calculation_rules(self, legal_total, *, tax): + tax_exclusive = _amount(legal_total, "TaxExclusiveAmount") + # BR-CO-15 + self.assertEqual(_amount(legal_total, "TaxInclusiveAmount"), flt(tax_exclusive + flt(tax, 2), 2)) + # BR-CO-16 + payable = _amount(legal_total, "PayableAmount") + tax_inclusive = _amount(legal_total, "TaxInclusiveAmount") + prepaid = _amount(legal_total, "PrepaidAmount") or 0.0 + rounding = _amount(legal_total, "PayableRoundingAmount") or 0.0 + self.assertEqual(payable, flt(tax_inclusive - prepaid + rounding, 2)) + + def test_subcent_tax_inclusive_is_derived(self): + # The reported regression: net 607.025 + VAT 127.475 (3-decimal). Reading grand_total + # (734.50) for TaxInclusive while TaxExclusive + VAT rounds to a different cent broke + # BR-CO-15. Deriving TaxInclusive from the rounded parts keeps it consistent, and + # PayableAmount still equals the real amount due. + legal_total = _build_legal_monetary_total( + self._invoice(net=607.025, tax=127.475, grand=734.50, rounded=734.50, outstanding=734.50) + ) + self.assertEqual( + _amount(legal_total, "TaxInclusiveAmount"), flt(flt(607.025, 2) + flt(127.475, 2), 2) + ) + self.assertEqual(_amount(legal_total, "PayableAmount"), 734.50) + self._assert_calculation_rules(legal_total, tax=127.475) + + def test_rounded_up_unpaid_invoice(self): + # Customer owes the rounded total (100.00) while TaxInclusive lands a cent lower; BT-114 + # carries the residual so PayableAmount stays the real amount due. + legal_total = _build_legal_monetary_total( + self._invoice(net=99.99, grand=99.99, rounded=100.00, outstanding=100.00) + ) + self.assertEqual(_amount(legal_total, "TaxInclusiveAmount"), 99.99) + self.assertEqual(_amount(legal_total, "PayableRoundingAmount"), 0.01) + self.assertEqual(_amount(legal_total, "PayableAmount"), 100.00) + self.assertIsNone(legal_total.find(f"{{{CBC}}}PrepaidAmount")) + self._assert_calculation_rules(legal_total, tax=0.0) + + def test_rounded_down_unpaid_invoice(self): + legal_total = _build_legal_monetary_total( + self._invoice(net=100.01, grand=100.01, rounded=100.00, outstanding=100.00) + ) + self.assertEqual(_amount(legal_total, "PayableRoundingAmount"), -0.01) + self.assertEqual(_amount(legal_total, "PayableAmount"), 100.00) + self._assert_calculation_rules(legal_total, tax=0.0) + + def test_partially_paid_invoice(self): + # Paid portion is measured against the rounded total due; the remainder still reconciles. + legal_total = _build_legal_monetary_total( + self._invoice(net=99.99, grand=99.99, rounded=100.00, outstanding=40.00) + ) + self.assertEqual(_amount(legal_total, "PrepaidAmount"), 60.00) + self.assertEqual(_amount(legal_total, "PayableAmount"), 40.00) + self._assert_calculation_rules(legal_total, tax=0.0) + + def test_fully_paid_invoice(self): + legal_total = _build_legal_monetary_total( + self._invoice(net=100.00, grand=100.00, rounded=100.00, outstanding=0.0) + ) + self.assertEqual(_amount(legal_total, "PrepaidAmount"), 100.00) + self.assertEqual(_amount(legal_total, "PayableAmount"), 0.00) + self.assertIsNone(legal_total.find(f"{{{CBC}}}PayableRoundingAmount")) + self._assert_calculation_rules(legal_total, tax=0.0) + + def test_no_rounding_omits_rounding_amount(self): + # grand_total and rounded_total agree and the invoice is unpaid: no prepaid, no rounding. + legal_total = _build_legal_monetary_total( + self._invoice(net=100.00, grand=100.00, rounded=100.00, outstanding=100.00) + ) + self.assertIsNone(legal_total.find(f"{{{CBC}}}PrepaidAmount")) + self.assertIsNone(legal_total.find(f"{{{CBC}}}PayableRoundingAmount")) + self._assert_calculation_rules(legal_total, tax=0.0) + + def test_disabled_rounded_total_falls_back_to_grand_total(self): + # Round Off disabled stores rounded_total as 0; the total due falls back to grand_total. + legal_total = _build_legal_monetary_total( + self._invoice(net=100.00, grand=100.00, rounded=0.0, outstanding=100.00) + ) + self.assertIsNone(legal_total.find(f"{{{CBC}}}PrepaidAmount")) + self.assertIsNone(legal_total.find(f"{{{CBC}}}PayableRoundingAmount")) + self._assert_calculation_rules(legal_total, tax=0.0) + + def test_credit_note_skips_prepaid_and_rounding(self): + # Credit notes use grand_total for PayableAmount and emit neither BT-113 nor BT-114. + legal_total = _build_legal_monetary_total( + self._invoice(net=-140.0, tax=-29.40, grand=-169.40, rounded=-169.40, outstanding=-169.40), + document_type="CreditNote", + ) + self.assertEqual(_amount(legal_total, "TaxInclusiveAmount"), 169.40) + self.assertEqual(_amount(legal_total, "PayableAmount"), 169.40) + self.assertIsNone(legal_total.find(f"{{{CBC}}}PrepaidAmount")) + self.assertIsNone(legal_total.find(f"{{{CBC}}}PayableRoundingAmount")) + + def test_rounding_amount_precedes_payable_amount(self): + # UBL XSD requires PrepaidAmount, then PayableRoundingAmount, then PayableAmount. + legal_total = _build_legal_monetary_total( + self._invoice(net=99.99, grand=99.99, rounded=100.00, outstanding=40.00) + ) + tags = [ET.QName(child).localname for child in legal_total] + self.assertLess(tags.index("PrepaidAmount"), tags.index("PayableRoundingAmount")) + self.assertLess(tags.index("PayableRoundingAmount"), tags.index("PayableAmount")) From b8c5a5eb06bde122709c7461a9d7b5dffea40ec6 Mon Sep 17 00:00:00 2001 From: Antoine Maas Date: Fri, 26 Jun 2026 11:08:16 +0200 Subject: [PATCH 3/4] fix: reconcile per-line net amounts with document total (BR-CO-10) --- .../edocument/profiles/peppol/generator.py | 47 +++++++++++++++++-- 1 file changed, 43 insertions(+), 4 deletions(-) diff --git a/edocument/edocument/profiles/peppol/generator.py b/edocument/edocument/profiles/peppol/generator.py index 03b570f..c2e37c2 100644 --- a/edocument/edocument/profiles/peppol/generator.py +++ b/edocument/edocument/profiles/peppol/generator.py @@ -409,11 +409,48 @@ def _add_delivery(self): frappe.db.get_value("Country", delivery_address.country, "code") or "DE" ).upper() + def _line_extension_amounts(self) -> dict: + """Per-line net amounts (BT-131), rounded to 2 decimals and adjusted so they sum + exactly to the document LineExtensionAmount (BT-106), keyed by item.idx. + + EN16931 BR-CO-10 requires the sum of BT-131 to equal BT-106 with no tolerance. Rounding + each line on its own breaks that when lines carry sub-cent (3-decimal) amounts, because + the sum of the rounded lines need not equal the rounded document total. We keep the + document total authoritative and hand the at-most-one-cent-per-line residual to the lines + that lost the most precision (largest-remainder method). Each line moves by at most a cent, + well inside PEPPOL-EN16931-R120's +/-0.02 quantity x price tolerance.""" + is_credit = self.document_type == "CreditNote" + target = abs(flt(self.invoice.total, 2)) if is_credit else flt(self.invoice.total, 2) + target_cents = round(target * 100) + + exact = {} + line_cents = {} + for item in self.invoice.items: + amount = flt(item.amount, item.precision("amount")) + if is_credit: + amount = abs(amount) + exact[item.idx] = amount + line_cents[item.idx] = round(flt(amount, 2) * 100) + + residual = target_cents - sum(line_cents.values()) + if residual: + # +1 cent to the lines rounded down the most, -1 to those rounded up the most. + step = 1 if residual > 0 else -1 + by_remainder = sorted( + exact, key=lambda idx: step * (exact[idx] - line_cents[idx] / 100), reverse=True + ) + for idx in by_remainder[: abs(residual)]: + line_cents[idx] += step + + return {idx: cents / 100 for idx, cents in line_cents.items()} + def _add_line_items(self): # Add invoice line items if not hasattr(self, "root") or self.root is None: return + # Reconcile per-line amounts up front so their sum matches the document BT-106 (BR-CO-10). + self._line_amounts = self._line_extension_amounts() for item in self.invoice.items: self._add_line_item(self.root, item) @@ -452,10 +489,12 @@ def _add_line_item(self, root: ET.Element, item): invoice_line = line_elem line_amount = ET.SubElement(invoice_line, f"{{{self.namespaces['cbc']}}}LineExtensionAmount") - # Round through flt (the site's configured rounding), not f"{x:.2f}", so the line amount - # matches the document LineExtensionAmount (BT-106), which is also flt-rounded — otherwise - # the two disagree and BR-CO-10 fails on sub-cent (3-decimal) amounts. - line_amount.text = f"{flt(item_amount, 2):.2f}" + # Use the reconciled per-line amount (see _line_extension_amounts): rounded to 2 decimals + # and adjusted so the line amounts sum to the document LineExtensionAmount BT-106 (BR-CO-10), + # which plain per-line rounding breaks on sub-cent (3-decimal) amounts. Fall back to a direct + # flt round if the map is absent (e.g. a line emitted outside _add_line_items). + reconciled = getattr(self, "_line_amounts", {}) + line_amount.text = f"{reconciled.get(item.idx, flt(item_amount, 2)):.2f}" line_amount.set("currencyID", self.invoice.currency) item_elem = ET.SubElement(invoice_line, f"{{{self.namespaces['cac']}}}Item") From 2453c31c7b78a1b9b5db0941871b990270515ede Mon Sep 17 00:00:00 2001 From: Antoine Maas Date: Fri, 26 Jun 2026 11:08:27 +0200 Subject: [PATCH 4/4] test: cover multi-line sub-cent line reconciliation (BR-CO-10) --- .../profiles/peppol/test_generator.py | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/edocument/edocument/profiles/peppol/test_generator.py b/edocument/edocument/profiles/peppol/test_generator.py index 966d99d..bc62f50 100644 --- a/edocument/edocument/profiles/peppol/test_generator.py +++ b/edocument/edocument/profiles/peppol/test_generator.py @@ -1,6 +1,8 @@ # Copyright (c) 2025, Prilk Consulting BV and Contributors # See license.txt +from types import SimpleNamespace + import frappe from frappe.utils.data import flt from lxml import etree as ET @@ -154,3 +156,76 @@ def test_rounding_amount_precedes_payable_amount(self): tags = [ET.QName(child).localname for child in legal_total] self.assertLess(tags.index("PrepaidAmount"), tags.index("PayableRoundingAmount")) self.assertLess(tags.index("PayableRoundingAmount"), tags.index("PayableAmount")) + + +class _Item: + """Minimal stand-in for a Sales Invoice Item row: _line_extension_amounts only + reads amount, idx, and precision().""" + + def __init__(self, idx, amount, precision=3): + self.idx = idx + self.amount = amount + self._precision = precision + + def precision(self, fieldname): + return self._precision + + +def _line_amounts(items, total, document_type="Invoice"): + generator = PEPPOLGenerator.__new__(PEPPOLGenerator) + # SimpleNamespace, not frappe._dict: a _dict's `items` attribute resolves to the built-in + # dict.items method, shadowing the line list (a real Sales Invoice is a Document, not a dict). + generator.invoice = SimpleNamespace(total=total, items=items) + generator.document_type = document_type + return generator._line_extension_amounts() + + +class TestLineExtensionReconciliation(TestCase): + """The sum of the per-line LineExtensionAmount (BT-131) must equal the document + LineExtensionAmount (BT-106) with no tolerance (BR-CO-10). On sub-cent (3-decimal) + lines, rounding each line on its own breaks that; the residual is spread across the + lines, each moving at most a cent (within PEPPOL-EN16931-R120's +/-0.02 tolerance).""" + + def _assert_reconciles(self, items, total, *, document_type="Invoice"): + amounts = _line_amounts(items, total, document_type) + expected_total = abs(flt(total, 2)) if document_type == "CreditNote" else flt(total, 2) + # BR-CO-10: the rounded sum of the lines equals the document total. + self.assertEqual(flt(sum(amounts.values()), 2), expected_total) + # PEPPOL-EN16931-R120: each emitted line stays within 0.02 of its true amount. + for item in items: + true_amount = abs(item.amount) if document_type == "CreditNote" else item.amount + self.assertLessEqual(abs(amounts[item.idx] - true_amount), 0.02) + return amounts + + def test_two_subcent_lines(self): + # 10.006 + 10.006: each rounds to 10.01 (sum 20.02) but the document total is 20.01, + # so one line is nudged down a cent. + amounts = self._assert_reconciles([_Item(1, 10.006), _Item(2, 10.006)], 20.012) + self.assertEqual(sorted(amounts.values()), [10.00, 10.01]) + + def test_three_subcent_lines(self): + # 3 x 10.004: each rounds to 10.00 (sum 30.00) but the document total is 30.01, + # so one line gains a cent. + amounts = self._assert_reconciles([_Item(1, 10.004), _Item(2, 10.004), _Item(3, 10.004)], 30.012) + self.assertEqual(sorted(amounts.values()), [10.00, 10.00, 10.01]) + + def test_plain_two_decimal_lines_unchanged(self): + # No sub-cent amounts: nothing to redistribute. + amounts = self._assert_reconciles([_Item(1, 10.00, precision=2), _Item(2, 20.00, precision=2)], 30.00) + self.assertEqual(amounts, {1: 10.00, 2: 20.00}) + + def test_single_subcent_line_unaffected(self): + # A lone line already reconciles: round-of-sum is round-of-itself. + amounts = self._assert_reconciles([_Item(1, 607.025)], 607.025) + self.assertEqual(amounts, {1: flt(607.025, 2)}) + + def test_credit_note_lines_use_absolute_values(self): + amounts = self._assert_reconciles( + [_Item(1, -10.006), _Item(2, -10.006)], -20.012, document_type="CreditNote" + ) + self.assertEqual(sorted(amounts.values()), [10.00, 10.01]) + + def test_deduction_line_keeps_sign(self): + # An invoice deduction line (negative amount) keeps its sign and still reconciles. + amounts = self._assert_reconciles([_Item(1, 100.005), _Item(2, -10.005)], 90.00) + self.assertLess(amounts[2], 0)