Files
einfach-produktiv-invoicing/src/einvoice/buildEInvoiceData.ts
T
Marco 97a7f6a036
Validate e-invoices / mustang (push) Failing after 16s
Rebuild EN16931 monetary fields on an integer-cents pipeline
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.
2026-07-23 12:40:07 +00:00

385 lines
19 KiB
TypeScript

import type { Invoice } from "@e-invoice-eu/core";
import type { InvoiceOrder } from "../invoicePdf";
import type { CorrectionInvoiceKind, CorrectionInvoiceOrder } from "../correctionInvoicePdf";
import type { InvoiceSeller } from "../seller";
import { countryCode } from "./countryCode";
// @e-invoice-eu/core only re-exports the top-level `Invoice` type, not the
// individual leaf field types (PaymentMeansTypeCode, SellerCountryCode,
// ...) — derived here via indexed access instead of reaching into the
// package's internal dist paths, which would be fragile against any
// future restructuring on their end.
type UblInvoice = Invoice["ubl:Invoice"];
type PaymentMeansTypeCode = NonNullable<UblInvoice["cac:PaymentMeans"]>[number]["cbc:PaymentMeansCode"];
type SellerCountryCode = UblInvoice["cac:AccountingSupplierParty"]["cac:Party"]["cac:PostalAddress"]["cac:Country"]["cbc:IdentificationCode"];
type BuyerCountryCode = UblInvoice["cac:AccountingCustomerParty"]["cac:Party"]["cac:PostalAddress"]["cac:Country"]["cbc:IdentificationCode"];
// EN16931 VAT category — 'S' ("Standard rated") is the correct code for
// *any* positive VAT rate under EN16931/Peppol BIS convention, not just
// the statutory default rate; the actual percentage (19%, 7%, ...) goes
// in the category's own `cbc:Percent`, not the code itself. This shop
// only ever sells goods at a positive German VAT rate — no exports,
// reverse-charge, or exempt sales exist yet — so every line and every
// summed group uses this same code.
const VAT_CATEGORY = "S";
// UN/ECE Recommendation 20 unit-of-measure code for "one" (a countable
// piece/unit) — this shop sells discrete products (ToDo-Karten sets,
// notebooks, ...), never anything sold by weight/length/volume, so every
// line uses the same code.
const UNIT_CODE = "C62";
// UNCL4461 payment-means codes — only the ones this shop's actual
// `payment-methods` collection can produce (see the Payload README's
// "Per-product tax rates"-adjacent Commerce section). '1' ("Instrument
// not defined") is the fallback for a payment method title this mapping
// doesn't recognize, not an error — new payment methods get added in
// Payload without a matching code deploy here otherwise breaking e-invoice
// generation entirely.
const PAYMENT_MEANS_CODE: Record<string, PaymentMeansTypeCode> = {
Überweisung: "30", // SEPA credit transfer
Kreditkarte: "48", // Bank card
PayPal: "68", // Online payment service
};
function paymentMeansCode(paymentMethodTitle: string): PaymentMeansTypeCode {
return PAYMENT_MEANS_CODE[paymentMethodTitle] ?? "1";
}
// 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
// `*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<string, string> {
return { [key]: (cents / 100).toFixed(2), [`${key}@currencyID`]: "EUR" };
}
// Same "runtime-required sibling key" story as `amtCents()`, but for
// `cbc:InvoicedQuantity@unitCode`.
function qty(value: number): Record<string, string> {
return { "cbc:InvoicedQuantity": String(value), "cbc:InvoicedQuantity@unitCode": UNIT_CODE };
}
function isoDate(iso: string): string {
return iso.slice(0, 10);
}
// CII's SupplyChainTradeTransaction is a fixed Agreement/Delivery/
// Settlement element sequence — @e-invoice-eu/core's UBL→CII conversion
// drops the whole `ram:ApplicableHeaderTradeDelivery` container unless at
// least one of `cac:Delivery`'s own children actually resolves to a value
// (an empty `{}` isn't enough — the converter recurses into children and
// only vivifies the destination element if one of them produced output),
// which fails schema validation (Mustang: "Invalid content ...
// ApplicableHeaderTradeSettlement. One of ApplicableHeaderTradeDelivery is
// expected", caught by the Phase 4 CI check on the very first real run —
// an empty object there didn't fix it, this date is the actual fix).
//
// This shop doesn't track a separate delivery date distinct from the
// invoice itself (no shipped-at timestamp exists at original-invoice time
// — that's generated at checkout, before shipping even happens), so the
// invoice's own issue date is used as the closest available proxy — BT-72
// is optional under EN16931, and Mustang only checks structural/schema
// conformance here, not the business accuracy of the date's value.
function delivery(issuedAtIso: string): UblInvoice["cac:Delivery"] {
return { "cbc:ActualDeliveryDate": isoDate(issuedAtIso) };
}
function paymentMeans(seller: InvoiceSeller, paymentMethodTitle: string): UblInvoice["cac:PaymentMeans"] {
if (!seller.iban) return undefined;
return [
{
"cbc:PaymentMeansCode": paymentMeansCode(paymentMethodTitle),
"cac:PayeeFinancialAccount": {
"cbc:ID": seller.iban,
...(seller.bankName ? { "cbc:Name": seller.bankName } : {}),
},
},
];
}
function sellerParty(seller: InvoiceSeller): UblInvoice["cac:AccountingSupplierParty"] {
return {
"cac:Party": {
"cac:PostalAddress": {
"cbc:StreetName": seller.sellerStreet,
"cbc:CityName": seller.sellerCity,
"cbc:PostalZone": seller.sellerZip,
"cac:Country": { "cbc:IdentificationCode": countryCode(seller.sellerCountry) as SellerCountryCode },
},
"cac:PartyTaxScheme": [
{
"cbc:CompanyID": seller.vatId,
"cac:TaxScheme": { "cbc:ID": "VAT" },
},
],
"cac:PartyLegalEntity": { "cbc:RegistrationName": seller.sellerName },
"cac:Contact": { "cbc:ElectronicMail": seller.sellerEmail },
},
};
}
function buyerParty(name: string, street: string | null | undefined, zip: string, city: string, country: string): UblInvoice["cac:AccountingCustomerParty"] {
return {
"cac:Party": {
"cac:PostalAddress": {
...(street ? { "cbc:StreetName": street } : {}),
"cbc:CityName": city,
"cbc:PostalZone": zip,
"cac:Country": { "cbc:IdentificationCode": countryCode(country) as BuyerCountryCode },
},
"cac:PartyLegalEntity": { "cbc:RegistrationName": name },
},
};
}
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<number, number>();
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 [
{
...amtCents("cbc:TaxAmount", totalTaxCents),
"cac:TaxSubtotal": rateGroups.map((g) => ({
...amtCents("cbc:TaxableAmount", g.taxableCents),
...amtCents("cbc:TaxAmount", g.taxCents),
"cac:TaxCategory": {
"cbc:ID": VAT_CATEGORY,
"cbc:Percent": String(g.rate),
"cac:TaxScheme": { "cbc:ID": "VAT" },
},
})),
},
] as UblInvoice["cac:TaxTotal"];
}
function allowanceCharges(rateGroups: RateGroup[]): UblInvoice["cac:AllowanceCharge"] {
// Loosely typed here (build as Record<string, unknown>, 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<string, unknown>[] = [];
for (const g of rateGroups) {
const taxCategory = { "cbc:ID": VAT_CATEGORY, "cbc:Percent": String(g.rate), "cac:TaxScheme": { "cbc:ID": "VAT" } };
if (g.allowanceCents > 0) {
entries.push({
"cbc:ChargeIndicator": "false",
"cbc:AllowanceChargeReason": "Rabatt",
...amtCents("cbc:Amount", g.allowanceCents),
"cac:TaxCategory": taxCategory,
});
}
if (g.chargeCents > 0) {
entries.push({
"cbc:ChargeIndicator": "true",
"cbc:AllowanceChargeReason": "Versandkosten",
...amtCents("cbc:Amount", g.chargeCents),
"cac:TaxCategory": taxCategory,
});
}
}
return entries.length ? (entries as unknown as UblInvoice["cac:AllowanceCharge"]) : undefined;
}
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),
...amtCents("cbc:LineExtensionAmount", netCents),
"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" },
},
},
// 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 lines = computeLines(order.items);
const rateGroups = computeRateGroups(lines, order.discountAmount, order.shippingCost);
const allowanceChargeEntries = allowanceCharges(rateGroups);
return {
"ubl:Invoice": {
"cbc:ID": order.invoiceNumber,
"cbc:IssueDate": isoDate(order.invoiceIssuedAt),
"cbc:InvoiceTypeCode": "380",
"cbc:DocumentCurrencyCode": "EUR",
"cac:AccountingSupplierParty": sellerParty(seller),
"cac:AccountingCustomerParty": buyerParty(
`${order.customerFirstName} ${order.customerLastName}`,
order.deliveryMethod === "address" ? order.street : null,
order.zip,
order.city,
order.country,
),
"cac:Delivery": delivery(order.invoiceIssuedAt),
"cac:PaymentMeans": paymentMeans(seller, order.paymentMethodTitle),
...(allowanceChargeEntries ? { "cac:AllowanceCharge": allowanceChargeEntries } : {}),
"cac:TaxTotal": taxTotal(rateGroups),
// 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),
},
};
}
// Stornorechnung/Gutschrift — same UBL `Invoice` shape (this library has
// no separate "credit note" type), but InvoiceTypeCode 381 ("Credit
// note") instead of 380, so a receiving AP system reads it as a
// reduction, not a second charge. Amounts stay positive (the credited
// amount, not a negative number) — EN16931/UBL convention puts the
// 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() 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 effectiveItems =
kind === "storno"
? order.items
: order.items.filter((item) => (item.returnQuantity ?? 0) > 0).map((item) => ({ ...item, quantity: item.returnQuantity as number }));
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": {
"cbc:ID": order.correctionInvoiceNumber,
"cbc:IssueDate": isoDate(order.correctionInvoiceIssuedAt),
"cbc:InvoiceTypeCode": "381",
"cbc:DocumentCurrencyCode": "EUR",
"cac:BillingReference": [
{
"cac:InvoiceDocumentReference": {
"cbc:ID": order.invoiceNumber,
"cbc:IssueDate": isoDate(order.invoiceIssuedAt),
},
},
],
"cac:AccountingSupplierParty": sellerParty(seller),
"cac:AccountingCustomerParty": buyerParty(
`${order.customerFirstName} ${order.customerLastName}`,
order.deliveryMethod === "address" ? order.street : null,
order.zip,
order.city,
order.country,
),
"cac:Delivery": delivery(order.correctionInvoiceIssuedAt),
"cac:PaymentMeans": paymentMeans(seller, "Überweisung"),
...(allowanceChargeEntries ? { "cac:AllowanceCharge": allowanceChargeEntries } : {}),
"cac:TaxTotal": taxTotal(rateGroups),
"cac:LegalMonetaryTotal": legalMonetaryTotal(rateGroups, taxInclusiveCents),
"cac:InvoiceLine": invoiceLines(lines),
},
};
}