Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 76 additions & 12 deletions edocument/edocument/profiles/peppol/generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -452,7 +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")
line_amount.text = f"{item_amount:.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")
Expand Down Expand Up @@ -835,9 +877,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)
Expand All @@ -862,24 +911,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)

Expand Down
231 changes: 231 additions & 0 deletions edocument/edocument/profiles/peppol/test_generator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
# 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

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"))


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)
Loading