diff --git a/src/einvoice/buildEInvoiceData.ts b/src/einvoice/buildEInvoiceData.ts index 77a006b..562e7ac 100644 --- a/src/einvoice/buildEInvoiceData.ts +++ b/src/einvoice/buildEInvoiceData.ts @@ -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), }, };