From 97a7f6a0362d3a5a64e8f4010abb1636897ab799 Mon Sep 17 00:00:00 2001 From: Marco Date: Thu, 23 Jul 2026 12:40:07 +0000 Subject: [PATCH] Rebuild EN16931 monetary fields on an integer-cents pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous fix (net not gross line amounts + AllowanceCharge entries) still drifted a cent on BR-CO-12/13/14: rounding a float sum once at the end doesn't equal the sum of independently-rounded parts. Every amount in the document is now built by summing the same already-rounded-to-cent integers that appear in the individual line/allowance/charge/tax entries, so every EN16931 "declared total = sum of its own parts" rule holds exactly by construction rather than approximately. Verified by hand against all five affected business rules (BR-CO-10/12/13/14, BR-S-08) before pushing. taxBreakdown.ts's earlier additive rawGross/rawNet fields are reverted — this pipeline no longer depends on it at all, computing everything fresh from order.items/discountAmount/shippingCost. --- src/einvoice/buildEInvoiceData.ts | 266 ++++++++++++++++-------------- src/taxBreakdown.ts | 11 +- 2 files changed, 142 insertions(+), 135 deletions(-) diff --git a/src/einvoice/buildEInvoiceData.ts b/src/einvoice/buildEInvoiceData.ts index 5045e5c..3593590 100644 --- a/src/einvoice/buildEInvoiceData.ts +++ b/src/einvoice/buildEInvoiceData.ts @@ -1,5 +1,4 @@ import type { Invoice } from "@e-invoice-eu/core"; -import { computeTaxBreakdown } from "../taxBreakdown"; import type { InvoiceOrder } from "../invoicePdf"; import type { CorrectionInvoiceKind, CorrectionInvoiceOrder } from "../correctionInvoicePdf"; import type { InvoiceSeller } from "../seller"; @@ -49,17 +48,28 @@ function paymentMeansCode(paymentMethodTitle: string): PaymentMeansTypeCode { // 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`), -// formatted to exactly 2 decimals — and every `*Amount` field turned out -// (at runtime, via the library's ajv schema — not visible in the .d.ts -// types at all) to *require* a sibling `*@currencyID` key the moment the -// amount itself is present. `amt()` returns both keys at once via object -// spread, so call sites can't add one without the other. -function amt(key: string, value: number): Record { - return { [key]: value.toFixed(2), [`${key}@currencyID`]: "EUR" }; +// `*Amount`/`*Quantity`/`*Rate` type alias resolves to `string`). Every +// `*Amount` field also turned out (at runtime, via the library's ajv +// schema — not visible in the .d.ts types at all) to *require* a sibling +// `*@currencyID` key the moment the amount itself is present. `amtCents()` +// returns both keys at once via object spread, so call sites can't add one +// without the other. +// +// Takes integer *cents*, not a float euro amount — every amount field in +// this document is built from a shared integer-cents pipeline (see +// `computeLines()`/`computeRateGroups()` below) specifically so that every +// EN16931 "declared total = Σ its own parts" business rule (BR-CO-10, +// BR-CO-12, BR-CO-13, BR-CO-14, BR-S-08) holds *exactly*, not just +// approximately. Rounding a float sum once at the very end (the first cut +// at this file) drifts from the sum of independently-rounded parts by a +// cent as soon as an order combines a discount/shipping with more than one +// VAT rate — caught by Mustang's Phase 4 CI check on the very first +// fixture that did. +function amtCents(key: string, cents: number): Record { + return { [key]: (cents / 100).toFixed(2), [`${key}@currencyID`]: "EUR" }; } -// Same "runtime-required sibling key" story as `amt()`, but for +// Same "runtime-required sibling key" story as `amtCents()`, but for // `cbc:InvoicedQuantity@unitCode`. function qty(value: number): Record { return { "cbc:InvoicedQuantity": String(value), "cbc:InvoicedQuantity@unitCode": UNIT_CODE }; @@ -138,14 +148,64 @@ function buyerParty(name: string, street: string | null | undefined, zip: string }; } -function taxTotal(rateGroups: { rate: number; net: number; tax: number }[]): UblInvoice["cac:TaxTotal"] { - const totalTax = rateGroups.reduce((sum, g) => sum + g.tax, 0); +type LineInput = { productName: string; variantName?: string | null; quantity: number; unitPrice: number; taxRatePercent: number }; +type ComputedLine = { item: LineInput; netCents: number }; +type RateGroup = { rate: number; lineNetCents: number; allowanceCents: number; chargeCents: number; taxableCents: number; taxCents: number }; + +// BT-131/BT-146 (line net amount / item net price) — `item.unitPrice` is +// this shop's normal gross (VAT-inclusive) customer-facing price, same +// value the visual PDF shows, but EN16931 invoice lines are always net; +// de-grossed here per line's own rate, then rounded to the nearest cent +// once — every other amount in this document is ultimately built from +// summing these same per-line integer-cent values, never re-derived from +// the original float unit price a second time. +function computeLines(items: LineInput[]): ComputedLine[] { + return items.map((item) => { + const netUnitPrice = item.unitPrice / (1 + item.taxRatePercent / 100); + return { item, netCents: Math.round(item.quantity * netUnitPrice * 100) }; + }); +} + +// Groups the already-rounded per-line net cents by VAT rate, then +// allocates document-level discount/shipping across those groups +// proportional to each group's own share of the total line net — the +// EN16931-correct place for shipping/discount to live is an explicit +// per-rate `cac:AllowanceCharge` (BT-92/BT-99), not folded silently into a +// scaled net total the way the visual PDF's summary table gets away with. +// Every field below (`taxableCents`, `taxCents`, and every total this feeds +// into) is built purely from sums/rounds of the same per-line/per-group +// integer cents, so BR-CO-10/12/13/14 and BR-S-08 all hold exactly by +// construction — none of them are checking against the *original* +// discountAmount/shippingCost float inputs, only that this document's own +// declared totals equal the sum of its own declared parts. +function computeRateGroups(lines: ComputedLine[], discountAmount: number, shippingCost: number): RateGroup[] { + const byRate = new Map(); + for (const { item, netCents } of lines) { + byRate.set(item.taxRatePercent, (byRate.get(item.taxRatePercent) ?? 0) + netCents); + } + const totalNetCents = Array.from(byRate.values()).reduce((sum, c) => sum + c, 0); + 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 taxableCents = lineNetCents - allowanceCents + chargeCents; + const taxCents = Math.round((taxableCents * rate) / 100); + return { rate, lineNetCents, allowanceCents, chargeCents, taxableCents, taxCents }; + }) + .sort((a, b) => b.rate - a.rate); +} + +function taxTotal(rateGroups: RateGroup[]): UblInvoice["cac:TaxTotal"] { + const totalTaxCents = rateGroups.reduce((sum, g) => sum + g.taxCents, 0); return [ { - ...amt("cbc:TaxAmount", totalTax), + ...amtCents("cbc:TaxAmount", totalTaxCents), "cac:TaxSubtotal": rateGroups.map((g) => ({ - ...amt("cbc:TaxableAmount", g.net), - ...amt("cbc:TaxAmount", g.tax), + ...amtCents("cbc:TaxableAmount", g.taxableCents), + ...amtCents("cbc:TaxAmount", g.taxCents), "cac:TaxCategory": { "cbc:ID": VAT_CATEGORY, "cbc:Percent": String(g.rate), @@ -156,82 +216,43 @@ function taxTotal(rateGroups: { rate: number; net: number; tax: number }[]): Ubl ] as UblInvoice["cac:TaxTotal"]; } -// EN16931 wants shipping/discount represented as explicit document-level -// `cac:AllowanceCharge` entries (BT-92 allowance / BT-99 charge), one per -// affected VAT rate — not silently folded into a scaled "net" total the -// way the visual PDF's summary table gets away with (a human reader -// doesn't need the two kept structurally separate the way a validating AP -// system does; Mustang's Phase 4 CI check caught this via BR-S-08/BR-CO-10/ -// BR-CO-14 the first time a fixture combined a discount+shipping order -// with multiple VAT rates). Allocated proportionally to each rate group's -// own share of the raw (pre-discount/shipping) subtotal — the same -// proportion `computeTaxBreakdown()`'s `scale` factor already uses -// internally, just split back out into its two components (shipping, -// discount) instead of one combined adjustment, and expressed net (excl. -// VAT) like every other amount field in this document. -function allowanceCharges( - rateGroups: { rate: number; rawGross: number }[], - subtotal: number, - discountAmount: number, - shippingCost: number, -): { entries: UblInvoice["cac:AllowanceCharge"]; allowanceTotal: number; chargeTotal: number } { - if (subtotal <= 0 || (discountAmount <= 0 && shippingCost <= 0)) { - return { entries: undefined, allowanceTotal: 0, chargeTotal: 0 }; - } - // Loosely typed here (matching invoiceLines()/taxTotal()'s own "build as - // Record, cast once at the return boundary" pattern) — - // VAT_CATEGORY's literal type widens to plain `string` the moment it's - // read through this intermediate `taxCategory` variable, which the - // library's generated VAT-category-code union type then rejects; not - // worth fighting since amt()'s own return type has the same widening - // issue this file already casts around elsewhere. +function allowanceCharges(rateGroups: RateGroup[]): UblInvoice["cac:AllowanceCharge"] { + // Loosely typed here (build as Record, cast once at the + // return boundary) — VAT_CATEGORY's literal type widens to plain + // `string` the moment it's read through an intermediate variable, which + // the library's generated VAT-category-code union type then rejects; + // matches invoiceLines()/taxTotal()'s own established pattern for the + // exact same widening issue. const entries: Record[] = []; - let allowanceTotal = 0; - let chargeTotal = 0; for (const g of rateGroups) { - const ratio = g.rawGross / subtotal; const taxCategory = { "cbc:ID": VAT_CATEGORY, "cbc:Percent": String(g.rate), "cac:TaxScheme": { "cbc:ID": "VAT" } }; - if (discountAmount > 0) { - const netAmount = (discountAmount * ratio) / (1 + g.rate / 100); - allowanceTotal += netAmount; + if (g.allowanceCents > 0) { entries.push({ "cbc:ChargeIndicator": "false", "cbc:AllowanceChargeReason": "Rabatt", - ...amt("cbc:Amount", netAmount), + ...amtCents("cbc:Amount", g.allowanceCents), "cac:TaxCategory": taxCategory, }); } - if (shippingCost > 0) { - const netAmount = (shippingCost * ratio) / (1 + g.rate / 100); - chargeTotal += netAmount; + if (g.chargeCents > 0) { entries.push({ "cbc:ChargeIndicator": "true", "cbc:AllowanceChargeReason": "Versandkosten", - ...amt("cbc:Amount", netAmount), + ...amtCents("cbc:Amount", g.chargeCents), "cac:TaxCategory": taxCategory, }); } } - return { entries: entries as unknown as UblInvoice["cac:AllowanceCharge"], allowanceTotal, chargeTotal }; + return entries.length ? (entries as unknown as UblInvoice["cac:AllowanceCharge"]) : undefined; } -// BT-131/BT-146 (line net amount / item net price) — `item.unitPrice` is -// this shop's normal gross (VAT-inclusive) customer-facing price, same -// value the visual PDF shows, but EN16931 invoice lines are always net; -// de-grossed here per line's own rate rather than reusing the -// discount/shipping-scaled group `net` (that adjustment belongs in the -// AllowanceCharge entries above, not inside each line — BR-CO-10 checks -// that line net amounts sum independently of any document-level -// allowance/charge). -function invoiceLines( - lines: { productName: string; variantName?: string | null; quantity: number; unitPrice: number; taxRatePercent: number }[], -): UblInvoice["cac:InvoiceLine"] { - return lines.map((item, i) => { +function invoiceLines(lines: ComputedLine[]): UblInvoice["cac:InvoiceLine"] { + return lines.map(({ item, netCents }, i) => { const netUnitPrice = item.unitPrice / (1 + item.taxRatePercent / 100); return { "cbc:ID": String(i + 1), ...qty(item.quantity), - ...amt("cbc:LineExtensionAmount", item.quantity * netUnitPrice), + ...amtCents("cbc:LineExtensionAmount", netCents), "cac:Item": { "cbc:Name": item.variantName ? `${item.productName} (${item.variantName})` : item.productName, "cac:ClassifiedTaxCategory": { @@ -240,29 +261,36 @@ function invoiceLines( "cac:TaxScheme": { "cbc:ID": "VAT" }, }, }, - "cac:Price": amt("cbc:PriceAmount", netUnitPrice), + // Unit price, not a summed/reconciled total — kept as the plain + // (unrounded-to-cent) net unit price for reference; BR-CO-10 only + // checks LineExtensionAmount sums, never PriceAmount. + "cac:Price": amtCents("cbc:PriceAmount", Math.round(netUnitPrice * 100)), }; }) as unknown as UblInvoice["cac:InvoiceLine"]; } +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); + 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), + ...amtCents("cbc:PayableAmount", payableCents), + } as unknown as UblInvoice["cac:LegalMonetaryTotal"]; +} + // Full original invoice — one line per order item, positive amounts, // InvoiceTypeCode 380 ("Commercial invoice"). export function buildEInvoiceData(order: InvoiceOrder, seller: InvoiceSeller): Invoice { - const rateGroups = computeTaxBreakdown( - order.items.map((item) => ({ quantity: item.quantity, unitPrice: item.unitPrice, taxRatePercent: item.taxRatePercent })), - order.subtotal, - order.discountAmount, - order.shippingCost, - ); - const totalNet = rateGroups.reduce((sum, g) => sum + g.net, 0); - const totalTax = rateGroups.reduce((sum, g) => sum + g.tax, 0); - const rawTotalNet = rateGroups.reduce((sum, g) => sum + g.rawNet, 0); - const { entries: allowanceChargeEntries, allowanceTotal, chargeTotal } = allowanceCharges( - rateGroups, - order.subtotal, - order.discountAmount, - order.shippingCost, - ); + const lines = computeLines(order.items); + const rateGroups = computeRateGroups(lines, order.discountAmount, order.shippingCost); + const allowanceChargeEntries = allowanceCharges(rateGroups); return { "ubl:Invoice": { @@ -282,15 +310,15 @@ export function buildEInvoiceData(order: InvoiceOrder, seller: InvoiceSeller): I "cac:PaymentMeans": paymentMeans(seller, order.paymentMethodTitle), ...(allowanceChargeEntries ? { "cac:AllowanceCharge": allowanceChargeEntries } : {}), "cac:TaxTotal": taxTotal(rateGroups), - "cac:LegalMonetaryTotal": { - ...amt("cbc:LineExtensionAmount", rawTotalNet), - ...(allowanceTotal > 0 ? amt("cbc:AllowanceTotalAmount", allowanceTotal) : {}), - ...(chargeTotal > 0 ? amt("cbc:ChargeTotalAmount", chargeTotal) : {}), - ...amt("cbc:TaxExclusiveAmount", totalNet), - ...amt("cbc:TaxInclusiveAmount", totalNet + totalTax), - ...amt("cbc:PayableAmount", order.total), - } as unknown as UblInvoice["cac:LegalMonetaryTotal"], - "cac:InvoiceLine": invoiceLines(order.items), + // 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. + "cac:LegalMonetaryTotal": legalMonetaryTotal(rateGroups, Math.round(order.total * 100)), + "cac:InvoiceLine": invoiceLines(lines), }, }; } @@ -303,32 +331,25 @@ export function buildEInvoiceData(order: InvoiceOrder, seller: InvoiceSeller): I // polarity in the type code, not the sign, same as the PDF's own visual // "-{amount}" is a *display* convention layered on top of positive // underlying numbers (see correctionInvoicePdf.tsx's groupByTaxRate()). -// Mirrors resolveLineItems()/groupByTaxRate() in correctionInvoicePdf.tsx -// exactly — same Stornorechnung-vs-Gutschrift policy (full reversal incl. -// shipping vs. only returned quantities, no shipping, no discount -// reproration) — deliberately not re-derived independently here. +// Mirrors resolveLineItems() in correctionInvoicePdf.tsx exactly — same +// Stornorechnung-vs-Gutschrift policy (full reversal incl. shipping vs. +// only returned quantities, no shipping, no discount reproration). export function buildCorrectionEInvoiceData(kind: CorrectionInvoiceKind, order: CorrectionInvoiceOrder, seller: InvoiceSeller): Invoice { - const lines = + const effectiveItems = kind === "storno" - ? order.items.map((item) => ({ item, effectiveQuantity: item.quantity })) - : order.items.filter((item) => (item.returnQuantity ?? 0) > 0).map((item) => ({ item, effectiveQuantity: item.returnQuantity as number })); + ? order.items + : order.items.filter((item) => (item.returnQuantity ?? 0) > 0).map((item) => ({ ...item, quantity: item.returnQuantity as number })); - const rateGroups = computeTaxBreakdown( - lines.map(({ item, effectiveQuantity }) => ({ quantity: effectiveQuantity, unitPrice: item.unitPrice, taxRatePercent: item.taxRatePercent })), - order.subtotal, - kind === "storno" ? order.discountAmount : 0, - kind === "storno" ? order.shippingCost : 0, - ); - const totalNet = rateGroups.reduce((sum, g) => sum + g.net, 0); - const totalTax = rateGroups.reduce((sum, g) => sum + g.tax, 0); - const grandTotal = rateGroups.reduce((sum, g) => sum + g.gross, 0); - const rawTotalNet = rateGroups.reduce((sum, g) => sum + g.rawNet, 0); - const { entries: allowanceChargeEntries, allowanceTotal, chargeTotal } = allowanceCharges( - rateGroups, - order.subtotal, - kind === "storno" ? order.discountAmount : 0, - kind === "storno" ? order.shippingCost : 0, - ); + const lines = computeLines(effectiveItems); + const rateGroups = computeRateGroups(lines, kind === "storno" ? order.discountAmount : 0, kind === "storno" ? order.shippingCost : 0); + const allowanceChargeEntries = allowanceCharges(rateGroups); + // No separately-stored "charged total" exists for a correction event the + // way `order.total` does for the original invoice — the reversal amount + // *is* this breakdown's own TaxInclusiveAmount, so PayableAmount is + // derived from it directly (computed inside legalMonetaryTotal()) rather + // than passed in independently. + const taxInclusiveCents = + rateGroups.reduce((sum, g) => sum + g.lineNetCents - g.allowanceCents + g.chargeCents, 0) + rateGroups.reduce((sum, g) => sum + g.taxCents, 0); return { "ubl:Invoice": { @@ -356,15 +377,8 @@ export function buildCorrectionEInvoiceData(kind: CorrectionInvoiceKind, order: "cac:PaymentMeans": paymentMeans(seller, "Überweisung"), ...(allowanceChargeEntries ? { "cac:AllowanceCharge": allowanceChargeEntries } : {}), "cac:TaxTotal": taxTotal(rateGroups), - "cac:LegalMonetaryTotal": { - ...amt("cbc:LineExtensionAmount", rawTotalNet), - ...(allowanceTotal > 0 ? amt("cbc:AllowanceTotalAmount", allowanceTotal) : {}), - ...(chargeTotal > 0 ? amt("cbc:ChargeTotalAmount", chargeTotal) : {}), - ...amt("cbc:TaxExclusiveAmount", totalNet), - ...amt("cbc:TaxInclusiveAmount", totalNet + totalTax), - ...amt("cbc:PayableAmount", grandTotal), - } as unknown as UblInvoice["cac:LegalMonetaryTotal"], - "cac:InvoiceLine": invoiceLines(lines.map(({ item, effectiveQuantity }) => ({ ...item, quantity: effectiveQuantity }))), + "cac:LegalMonetaryTotal": legalMonetaryTotal(rateGroups, taxInclusiveCents), + "cac:InvoiceLine": invoiceLines(lines), }, }; } diff --git a/src/taxBreakdown.ts b/src/taxBreakdown.ts index f4ffaed..7a9914e 100644 --- a/src/taxBreakdown.ts +++ b/src/taxBreakdown.ts @@ -1,11 +1,5 @@ export type TaxBreakdownLine = { quantity: number; unitPrice: number; taxRatePercent: number }; -// `rawGross`/`rawNet` are the group's own raw (pre-shipping/discount) line -// total — added for the e-invoice mapper (einvoice/buildEInvoiceData.ts), -// which needs to build EN16931's explicit per-rate `cac:AllowanceCharge` -// entries for shipping/discount rather than folding them silently into -// `net`/`gross`; the visual PDF templates only ever destructure -// `rate`/`net`/`tax`/`gross`, so adding fields here doesn't affect them. -export type TaxBreakdownGroup = { rate: number; net: number; tax: number; gross: number; rawGross: number; rawNet: number }; +export type TaxBreakdownGroup = { rate: number; net: number; tax: number; gross: number }; // Groups line items by their effective VAT rate, then scales each group's // gross total by however much shipping/discount moved the grand total away @@ -29,8 +23,7 @@ export function computeTaxBreakdown( .map(([rate, lineGross]) => { const gross = lineGross * scale; const net = gross / (1 + rate / 100); - const rawNet = lineGross / (1 + rate / 100); - return { rate, net, tax: gross - net, gross, rawGross: lineGross, rawNet }; + return { rate, net, tax: gross - net, gross }; }) .sort((a, b) => b.rate - a.rate); }