Fix missing degross step in shipping/discount allocation (real ~1 EUR bug on a real order)
Validate e-invoices / mustang (push) Successful in 23s
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:
@@ -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),
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user