From fd5d8e688b1ab6b13dfd734e194a780b8a2b12d2 Mon Sep 17 00:00:00 2001 From: Marco Date: Thu, 23 Jul 2026 12:43:06 +0000 Subject: [PATCH] Fix BR-CO-16: declare the PayableAmount/order.total gap via PayableRoundingAmount MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 1-cent drift between this file's per-rate breakdown and the original invoice's independently-stored order.total (checkout's own cart math) turned out to be a hard schematron error (BR-CO-16), not just the non-fatal "Arithmetical issue" warning it looked like from the log alone. EN16931 has a field for exactly this gap, cbc:PayableRoundingAmount (BT-114) — using it keeps both PayableAmount (the actually-charged total) and TaxInclusiveAmount (this breakdown's own correctly-summed total) intact and reconciling, verified by hand before pushing. --- src/einvoice/buildEInvoiceData.ts | 28 ++++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/src/einvoice/buildEInvoiceData.ts b/src/einvoice/buildEInvoiceData.ts index 3593590..77a006b 100644 --- a/src/einvoice/buildEInvoiceData.ts +++ b/src/einvoice/buildEInvoiceData.ts @@ -269,18 +269,36 @@ function invoiceLines(lines: ComputedLine[]): UblInvoice["cac:InvoiceLine"] { }) as unknown as UblInvoice["cac:InvoiceLine"]; } +// `payableCents` is the amount actually charged (the original invoice's +// own stored `order.total`, or — for a correction — the natural total +// this same breakdown derives, see the two call sites). It can differ +// from this document's own internally-consistent TaxInclusiveAmount by a +// cent, since `order.total` comes from checkout's own independent cart/ +// discount math, not this per-rate proportional allocation — EN16931 has +// a dedicated field for exactly this gap, `cbc:PayableRoundingAmount` +// (BT-114): BR-CO-16 requires PayableAmount = TaxInclusiveAmount - +// PrepaidAmount + RoundingAmount, so the gap is declared explicitly here +// rather than either silently forcing PayableAmount to disagree with the +// actually-charged total, or forcing TaxInclusiveAmount to disagree with +// its own correctly-summed parts (caught by Mustang's Phase 4 CI check — +// BR-CO-16 flagged this as a hard error the moment PayableAmount and +// TaxInclusiveAmount didn't match to the cent, not just the pre-existing +// non-fatal "Arithmetical issue" warning this file's comments used to +// (wrongly) assume was the full story). function legalMonetaryTotal(rateGroups: RateGroup[], payableCents: number): UblInvoice["cac:LegalMonetaryTotal"] { const lineExtensionCents = rateGroups.reduce((sum, g) => sum + g.lineNetCents, 0); const allowanceTotalCents = rateGroups.reduce((sum, g) => sum + g.allowanceCents, 0); const chargeTotalCents = rateGroups.reduce((sum, g) => sum + g.chargeCents, 0); const taxExclusiveCents = lineExtensionCents - allowanceTotalCents + chargeTotalCents; const taxInclusiveCents = taxExclusiveCents + rateGroups.reduce((sum, g) => sum + g.taxCents, 0); + const roundingCents = payableCents - taxInclusiveCents; return { ...amtCents("cbc:LineExtensionAmount", lineExtensionCents), ...(allowanceTotalCents > 0 ? amtCents("cbc:AllowanceTotalAmount", allowanceTotalCents) : {}), ...(chargeTotalCents > 0 ? amtCents("cbc:ChargeTotalAmount", chargeTotalCents) : {}), ...amtCents("cbc:TaxExclusiveAmount", taxExclusiveCents), ...amtCents("cbc:TaxInclusiveAmount", taxInclusiveCents), + ...(roundingCents !== 0 ? amtCents("cbc:PayableRoundingAmount", roundingCents) : {}), ...amtCents("cbc:PayableAmount", payableCents), } as unknown as UblInvoice["cac:LegalMonetaryTotal"]; } @@ -311,12 +329,10 @@ export function buildEInvoiceData(order: InvoiceOrder, seller: InvoiceSeller): I ...(allowanceChargeEntries ? { "cac:AllowanceCharge": allowanceChargeEntries } : {}), "cac:TaxTotal": taxTotal(rateGroups), // PayableAmount is the order's own stored `total` (what the customer - // was actually charged at checkout), not a re-derivation from this - // breakdown — the two can differ by a cent from independent rounding - // paths (this file's per-rate allocation vs. checkout's own cart - // math), which Mustang surfaces as a non-fatal arithmetic *warning*, - // not an error; the legally meaningful figure is what was actually - // charged. + // was actually charged at checkout) — any cent of drift against this + // breakdown's own TaxInclusiveAmount is declared via + // PayableRoundingAmount inside legalMonetaryTotal(), not silently + // absorbed into either figure. "cac:LegalMonetaryTotal": legalMonetaryTotal(rateGroups, Math.round(order.total * 100)), "cac:InvoiceLine": invoiceLines(lines), },