Fix EN16931 line/allowance-charge amounts: report net, not gross
Validate e-invoices / mustang (push) Failing after 18s

Mustang's Phase 4 CI check flagged BR-CO-10/BR-CO-14/BR-S-08 once the Delivery-element fix let it get that far: invoice line amounts were being reported as this shop's normal gross (VAT-inclusive) prices instead of the net amounts EN16931 requires, and shipping/discount had no explicit cac:AllowanceCharge representation at all (silently folded into a scaled net total instead) — so the document totals didn't reconcile against the line items the way a validating AP system checks.

De-grosses each line's own unit price per its own VAT rate for LineExtensionAmount/PriceAmount, and adds explicit per-rate AllowanceCharge entries (proportionally allocated the same way computeTaxBreakdown's scale factor already works internally, just split back into its two components instead of one combined adjustment). taxBreakdown.ts gained two additive fields (rawGross/rawNet) for this; the visual PDF renderers are unaffected, they only ever read rate/net/tax/gross.
This commit is contained in:
Marco
2026-07-23 12:34:10 +00:00
parent dd37b8dc78
commit 9ff4d39de4
2 changed files with 114 additions and 17 deletions
+105 -15
View File
@@ -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<string, unknown>, 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<string, unknown>[] = [];
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),
+9 -2
View File
@@ -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);
}