import type { Invoice } from "@e-invoice-eu/core"; import { computeTaxBreakdown } from "../taxBreakdown"; 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`), // formatted to exactly 2 decimals — and every `*Amount` field 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. `amt()` returns both keys at once via object // spread, so call sites can't add one without the other. function amt(key: string, value: number): Record { return { [key]: value.toFixed(2), [`${key}@currencyID`]: "EUR" }; } // Same "runtime-required sibling key" story as `amt()`, 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); } 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 }, }, }; } function taxTotal(rateGroups: { rate: number; net: number; tax: number }[]): UblInvoice["cac:TaxTotal"] { const totalTax = rateGroups.reduce((sum, g) => sum + g.tax, 0); return [ { ...amt("cbc:TaxAmount", totalTax), "cac:TaxSubtotal": rateGroups.map((g) => ({ ...amt("cbc:TaxableAmount", g.net), ...amt("cbc:TaxAmount", g.tax), "cac:TaxCategory": { "cbc:ID": VAT_CATEGORY, "cbc:Percent": String(g.rate), "cac:TaxScheme": { "cbc:ID": "VAT" }, }, })), }, ] as UblInvoice["cac:TaxTotal"]; } 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" }, }, }, "cac:Price": amt("cbc:PriceAmount", item.unitPrice), })) as unknown as UblInvoice["cac:InvoiceLine"]; } // Full original invoice — one line per order item, positive amounts, // InvoiceTypeCode 380 ("Commercial invoice"). export function buildEInvoiceData(order: InvoiceOrder, seller: InvoiceSeller): Invoice { const rateGroups = computeTaxBreakdown( order.items.map((item) => ({ quantity: item.quantity, unitPrice: item.unitPrice, taxRatePercent: item.taxRatePercent })), order.subtotal, order.discountAmount, order.shippingCost, ); const totalNet = rateGroups.reduce((sum, g) => sum + g.net, 0); const totalTax = rateGroups.reduce((sum, g) => sum + g.tax, 0); 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:PaymentMeans": paymentMeans(seller, order.paymentMethodTitle), "cac:TaxTotal": taxTotal(rateGroups), "cac:LegalMonetaryTotal": { ...amt("cbc:LineExtensionAmount", totalNet), ...amt("cbc:TaxExclusiveAmount", totalNet), ...amt("cbc:TaxInclusiveAmount", totalNet + totalTax), ...amt("cbc:PayableAmount", order.total), } as unknown as UblInvoice["cac:LegalMonetaryTotal"], "cac:InvoiceLine": invoiceLines(order.items), }, }; } // 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()/groupByTaxRate() in correctionInvoicePdf.tsx // exactly — same Stornorechnung-vs-Gutschrift policy (full reversal incl. // shipping vs. only returned quantities, no shipping, no discount // reproration) — deliberately not re-derived independently here. export function buildCorrectionEInvoiceData(kind: CorrectionInvoiceKind, order: CorrectionInvoiceOrder, seller: InvoiceSeller): Invoice { const lines = kind === "storno" ? order.items.map((item) => ({ item, effectiveQuantity: item.quantity })) : order.items.filter((item) => (item.returnQuantity ?? 0) > 0).map((item) => ({ item, effectiveQuantity: item.returnQuantity as number })); const rateGroups = computeTaxBreakdown( lines.map(({ item, effectiveQuantity }) => ({ quantity: effectiveQuantity, unitPrice: item.unitPrice, taxRatePercent: item.taxRatePercent })), order.subtotal, kind === "storno" ? order.discountAmount : 0, kind === "storno" ? order.shippingCost : 0, ); 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); 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:PaymentMeans": paymentMeans(seller, "Überweisung"), "cac:TaxTotal": taxTotal(rateGroups), "cac:LegalMonetaryTotal": { ...amt("cbc:LineExtensionAmount", totalNet), ...amt("cbc:TaxExclusiveAmount", totalNet), ...amt("cbc:TaxInclusiveAmount", totalNet + totalTax), ...amt("cbc:PayableAmount", grandTotal), } as unknown as UblInvoice["cac:LegalMonetaryTotal"], "cac:InvoiceLine": invoiceLines(lines.map(({ item, effectiveQuantity }) => ({ ...item, quantity: effectiveQuantity }))), }, }; }