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[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 = { Ü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 { 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 { 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(); 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, 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[] = []; 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), }, }; }