63e3a02212
renderInvoiceEInvoice()/renderCorrectionInvoiceEInvoice() produce a Factur-X-EN16931 hybrid PDF/A-3 (existing @react-pdf/renderer PDF + embedded EN16931 XML) via @e-invoice-eu/core, instead of a plain PDF. The plain renderInvoicePdf()/renderCorrectionInvoicePdf() stay unchanged and are still what Live Preview uses — this only adds a post-processing step for the actual send/download paths. buildEInvoiceData()/buildCorrectionEInvoiceData() map InvoiceOrder/ CorrectionInvoiceOrder + InvoiceSeller into the library's raw UBL-shaped Invoice object, reusing computeTaxBreakdown() for the tax math — one implementation feeding both the human-readable and machine-readable side of the same document. Verified end-to-end: rendered a sample invoice and correction invoice, inflated the embedded XML stream out of the resulting PDF/A-3 by hand (the library has no attachment-reading API to check against), confirmed correct CrossIndustryInvoice XML, EN16931 guideline reference, per-rate tax breakdown matching the order totals exactly, and payment means with the IBAN from Phase 2 — not just "it didn't throw." Found only through actually running it (not documented anywhere): every EN16931 amount field requires a sibling `*@currencyID` key via the library's runtime ajv validation, invisible in its TypeScript types. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
258 lines
11 KiB
TypeScript
258 lines
11 KiB
TypeScript
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<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`),
|
|
// 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<string, string> {
|
|
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<string, string> {
|
|
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 }))),
|
|
},
|
|
};
|
|
}
|