Fix BR-CO-16: declare the PayableAmount/order.total gap via PayableRoundingAmount
Validate e-invoices / mustang (push) Successful in 18s

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.
This commit is contained in:
Marco
2026-07-23 12:43:06 +00:00
parent 97a7f6a036
commit fd5d8e688b
+22 -6
View File
@@ -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),
},