diff --git a/src/einvoice/buildEInvoiceData.ts b/src/einvoice/buildEInvoiceData.ts index 4ee941a..5045e5c 100644 --- a/src/einvoice/buildEInvoiceData.ts +++ b/src/einvoice/buildEInvoiceData.ts @@ -156,23 +156,93 @@ 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. + 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; + entries.push({ + "cbc:ChargeIndicator": "false", + "cbc:AllowanceChargeReason": "Rabatt", + ...amt("cbc:Amount", netAmount), + "cac:TaxCategory": taxCategory, + }); + } + if (shippingCost > 0) { + const netAmount = (shippingCost * ratio) / (1 + g.rate / 100); + chargeTotal += netAmount; + entries.push({ + "cbc:ChargeIndicator": "true", + "cbc:AllowanceChargeReason": "Versandkosten", + ...amt("cbc:Amount", netAmount), + "cac:TaxCategory": taxCategory, + }); + } + } + return { entries: entries as unknown as UblInvoice["cac:AllowanceCharge"], allowanceTotal, chargeTotal }; +} + +// 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) => ({ - "cbc:ID": String(i + 1), - ...qty(item.quantity), - ...amt("cbc:LineExtensionAmount", item.quantity * item.unitPrice), - "cac:Item": { - "cbc:Name": item.variantName ? `${item.productName} (${item.variantName})` : item.productName, - "cac:ClassifiedTaxCategory": { - "cbc:ID": VAT_CATEGORY, - "cbc:Percent": String(item.taxRatePercent), - "cac:TaxScheme": { "cbc:ID": "VAT" }, + return lines.map((item, i) => { + const netUnitPrice = item.unitPrice / (1 + item.taxRatePercent / 100); + return { + "cbc:ID": String(i + 1), + ...qty(item.quantity), + ...amt("cbc:LineExtensionAmount", item.quantity * netUnitPrice), + "cac:Item": { + "cbc:Name": item.variantName ? `${item.productName} (${item.variantName})` : item.productName, + "cac:ClassifiedTaxCategory": { + "cbc:ID": VAT_CATEGORY, + "cbc:Percent": String(item.taxRatePercent), + "cac:TaxScheme": { "cbc:ID": "VAT" }, + }, }, - }, - "cac:Price": amt("cbc:PriceAmount", item.unitPrice), - })) as unknown as UblInvoice["cac:InvoiceLine"]; + "cac:Price": amt("cbc:PriceAmount", netUnitPrice), + }; + }) as unknown as UblInvoice["cac:InvoiceLine"]; } // Full original invoice — one line per order item, positive amounts, @@ -186,6 +256,13 @@ export function buildEInvoiceData(order: InvoiceOrder, seller: InvoiceSeller): I ); 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, + ); return { "ubl:Invoice": { @@ -203,9 +280,12 @@ export function buildEInvoiceData(order: InvoiceOrder, seller: InvoiceSeller): I ), "cac:Delivery": delivery(order.invoiceIssuedAt), "cac:PaymentMeans": paymentMeans(seller, order.paymentMethodTitle), + ...(allowanceChargeEntries ? { "cac:AllowanceCharge": allowanceChargeEntries } : {}), "cac:TaxTotal": taxTotal(rateGroups), "cac:LegalMonetaryTotal": { - ...amt("cbc:LineExtensionAmount", totalNet), + ...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), @@ -242,6 +322,13 @@ export function buildCorrectionEInvoiceData(kind: CorrectionInvoiceKind, order: 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, + ); return { "ubl:Invoice": { @@ -267,9 +354,12 @@ export function buildCorrectionEInvoiceData(kind: CorrectionInvoiceKind, order: ), "cac:Delivery": delivery(order.correctionInvoiceIssuedAt), "cac:PaymentMeans": paymentMeans(seller, "Überweisung"), + ...(allowanceChargeEntries ? { "cac:AllowanceCharge": allowanceChargeEntries } : {}), "cac:TaxTotal": taxTotal(rateGroups), "cac:LegalMonetaryTotal": { - ...amt("cbc:LineExtensionAmount", totalNet), + ...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), diff --git a/src/taxBreakdown.ts b/src/taxBreakdown.ts index 7a9914e..f4ffaed 100644 --- a/src/taxBreakdown.ts +++ b/src/taxBreakdown.ts @@ -1,5 +1,11 @@ export type TaxBreakdownLine = { quantity: number; unitPrice: number; taxRatePercent: number }; -export type TaxBreakdownGroup = { rate: number; net: number; tax: number; gross: 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 }; // 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 @@ -23,7 +29,8 @@ export function computeTaxBreakdown( .map(([rate, lineGross]) => { const gross = lineGross * scale; const net = gross / (1 + rate / 100); - return { rate, net, tax: gross - net, gross }; + const rawNet = lineGross / (1 + rate / 100); + return { rate, net, tax: gross - net, gross, rawGross: lineGross, rawNet }; }) .sort((a, b) => b.rate - a.rate); }