Fix missing degross step in shipping/discount allocation (real ~1 EUR bug on a real order)
Validate e-invoices / mustang (push) Successful in 23s

The integer-cents pipeline rewrite (97a7f6a) dropped the /(1+rate/100) degross step when allocating discountAmount/shippingCost per rate group -- allowance/charge amounts came out ~19%/7% too large. Mustang's BR-CO-* checks couldn't catch this since they only verify internal self-consistency, not agreement with the original order data -- surfaced instead via an external e-Rechnung checker on a real order, where the resulting PayableRoundingAmount was >1 EUR instead of a normal few-cents residual. Fixed; the same real order's numbers now reconcile to within 1 cent of order.total.

Also fixes BR-CO-25 (a positive Amount due needs a due date or payment terms): original invoices paid immediately at checkout (Kreditkarte/PayPal, same isPaidImmediately() definition the visual PDF's "Bereits beglichen" badge uses) now declare PrepaidAmount = the full charged total, making PayableAmount 0 -- matching what the visual PDF already told a human reader, now machine-readable too. Bank-transfer originals and all correction invoices (Storno/Gutschrift, which represent a refund owed rather than an amount due) get an explicit PaymentTerms note instead, since this shop has no due-date policy modeled to cite.
This commit is contained in:
Marco
2026-07-23 14:02:25 +00:00
parent fd5d8e688b
commit f6d8e9eaf5
+74 -26
View File
@@ -46,6 +46,14 @@ function paymentMeansCode(paymentMethodTitle: string): PaymentMeansTypeCode {
return PAYMENT_MEANS_CODE[paymentMethodTitle] ?? "1";
}
// Same definition as invoicePdf.tsx's own isPaidImmediately() — "Überweisung"
// (bank transfer) is the only payment method on this shop that isn't
// settled at checkout; everything else (Kreditkarte, PayPal, ...) captures
// immediately, matching the visual PDF's "✓ Bereits beglichen" badge.
function isPaidImmediately(paymentMethodTitle: string): boolean {
return paymentMethodTitle !== "Überweisung";
}
// EN16931 amount fields are XML-serialized strings, not numbers
// (confirmed against @e-invoice-eu/core's own generated types — every
// `*Amount`/`*Quantity`/`*Rate` type alias resolves to `string`). Every
@@ -184,13 +192,26 @@ function computeRateGroups(lines: ComputedLine[], discountAmount: number, shippi
byRate.set(item.taxRatePercent, (byRate.get(item.taxRatePercent) ?? 0) + netCents);
}
const totalNetCents = Array.from(byRate.values()).reduce((sum, c) => sum + c, 0);
// `discountAmount`/`shippingCost` are this shop's normal GROSS
// (VAT-inclusive) figures, same as `unitPrice` — allocated proportionally
// per rate group in gross terms (same ratio basis as the line amounts),
// then degrossed via that group's own rate, exactly like every other
// amount in this document. Missing this degross step was a real, shipped
// bug (2026-07-23): every allowance/charge came out ~19%/7% too large,
// which BR-CO-12/13/14 couldn't catch (those only check this document's
// own numbers reconcile with each other, not against the original
// discountAmount/shippingCost) — surfaced instead as an oversized
// PayableRoundingAmount (a real order showed >1 EUR, not a rounding-scale
// difference) when an external checker flagged it. With degrossing
// correctly applied, TaxInclusiveAmount reconciles to `order.total`
// exactly and PayableRoundingAmount is 0 for a normal order.
const discountCents = Math.round(discountAmount * 100);
const shippingCents = Math.round(shippingCost * 100);
return Array.from(byRate.entries())
.map(([rate, lineNetCents]) => {
const ratio = totalNetCents > 0 ? lineNetCents / totalNetCents : 0;
const allowanceCents = discountCents > 0 ? Math.round(discountCents * ratio) : 0;
const chargeCents = shippingCents > 0 ? Math.round(shippingCents * ratio) : 0;
const allowanceCents = discountCents > 0 ? Math.round((discountCents * ratio) / (1 + rate / 100)) : 0;
const chargeCents = shippingCents > 0 ? Math.round((shippingCents * ratio) / (1 + rate / 100)) : 0;
const taxableCents = lineNetCents - allowanceCents + chargeCents;
const taxCents = Math.round((taxableCents * rate) / 100);
return { rate, lineNetCents, allowanceCents, chargeCents, taxableCents, taxCents };
@@ -269,46 +290,65 @@ function invoiceLines(lines: ComputedLine[]): UblInvoice["cac:InvoiceLine"] {
}) as unknown as UblInvoice["cac:InvoiceLine"];
}
// `payableCents` is the amount actually charged (the original invoice's
// `chargedCents` 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"] {
// this same breakdown derives, see the two call sites). It can in
// principle differ from this document's own internally-consistent
// TaxInclusiveAmount by a cent or two (e.g. `order.total` comes from
// checkout's own independent cart/discount math, not this per-rate
// proportional allocation) — EN16931 has a dedicated field for exactly
// that gap, `cbc:PayableRoundingAmount` (BT-114): BR-CO-16 requires
// PayableAmount = TaxInclusiveAmount PrepaidAmount + RoundingAmount, so
// any gap is declared explicitly rather than either figure silently
// disagreeing with where it actually comes from.
//
// `prepaid`: whether the order was already paid at checkout (Kreditkarte/
// PayPal, everything except Überweisung — same `isPaidImmediately()`
// definition invoicePdf.tsx's own "✓ Bereits beglichen" badge uses). If
// so, the whole charged amount is declared as PrepaidAmount (BT-113) and
// PayableAmount (BT-115) becomes 0 — nothing outstanding, matching what
// the visual PDF's badge already tells a human reader, now machine-
// readable too. This also satisfies BR-CO-25 (a positive BT-115 needs a
// due date or payment terms) by construction: an already-settled invoice
// has a zero, not positive, amount due.
function legalMonetaryTotal(rateGroups: RateGroup[], chargedCents: number, prepaid: boolean): 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;
const prepaidCents = prepaid ? chargedCents : 0;
const payableCents = prepaid ? 0 : chargedCents;
const roundingCents = payableCents - taxInclusiveCents + prepaidCents;
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),
...(prepaidCents > 0 ? amtCents("cbc:PrepaidAmount", prepaidCents) : {}),
...(roundingCents !== 0 ? amtCents("cbc:PayableRoundingAmount", roundingCents) : {}),
...amtCents("cbc:PayableAmount", payableCents),
} as unknown as UblInvoice["cac:LegalMonetaryTotal"];
}
// BR-CO-25: a positive Amount due for payment (BT-115) needs either a due
// date or payment terms text. Applies to the not-already-paid case
// (Überweisung on an original invoice, or any correction invoice — see
// call sites) — this shop doesn't track a formal payment-term policy
// anywhere, so a standard, safe default phrase is used rather than
// inventing a due-date business rule that isn't actually modeled.
function paymentTerms(note: string): UblInvoice["cac:PaymentTerms"] {
return { "cbc:Note": note } as UblInvoice["cac:PaymentTerms"];
}
// Full original invoice — one line per order item, positive amounts,
// InvoiceTypeCode 380 ("Commercial invoice").
export function buildEInvoiceData(order: InvoiceOrder, seller: InvoiceSeller): Invoice {
const lines = computeLines(order.items);
const rateGroups = computeRateGroups(lines, order.discountAmount, order.shippingCost);
const allowanceChargeEntries = allowanceCharges(rateGroups);
const paid = isPaidImmediately(order.paymentMethodTitle);
return {
"ubl:Invoice": {
@@ -326,14 +366,17 @@ export function buildEInvoiceData(order: InvoiceOrder, seller: InvoiceSeller): I
),
"cac:Delivery": delivery(order.invoiceIssuedAt),
"cac:PaymentMeans": paymentMeans(seller, order.paymentMethodTitle),
// BR-CO-25: a positive Amount due (BT-115) needs a due date or
// payment terms. Only needed on the not-already-paid path — a paid
// invoice's BT-115 is 0 (see legalMonetaryTotal()'s `prepaid` param).
...(paid ? {} : { "cac:PaymentTerms": paymentTerms("Zahlbar sofort ohne Abzug.") }),
...(allowanceChargeEntries ? { "cac:AllowanceCharge": allowanceChargeEntries } : {}),
"cac:TaxTotal": taxTotal(rateGroups),
// PayableAmount is the order's own stored `total` (what the customer
// 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)),
// `order.total` is what the customer 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), paid),
"cac:InvoiceLine": invoiceLines(lines),
},
};
@@ -391,9 +434,14 @@ export function buildCorrectionEInvoiceData(kind: CorrectionInvoiceKind, order:
),
"cac:Delivery": delivery(order.correctionInvoiceIssuedAt),
"cac:PaymentMeans": paymentMeans(seller, "Überweisung"),
// BR-CO-25 — a credit note's "amount due" represents a refund owed to
// the customer, not an open invoice payment, so there's no "already
// paid"/PrepaidAmount concept here (unlike buildEInvoiceData's paid
// branch above) — payment terms text satisfies the rule instead.
"cac:PaymentTerms": paymentTerms("Der Rechnungsbetrag wird erstattet."),
...(allowanceChargeEntries ? { "cac:AllowanceCharge": allowanceChargeEntries } : {}),
"cac:TaxTotal": taxTotal(rateGroups),
"cac:LegalMonetaryTotal": legalMonetaryTotal(rateGroups, taxInclusiveCents),
"cac:LegalMonetaryTotal": legalMonetaryTotal(rateGroups, taxInclusiveCents, false),
"cac:InvoiceLine": invoiceLines(lines),
},
};