Phase 3: actual ZUGFeRD/Factur-X e-invoice generation
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>
This commit is contained in:
@@ -17,6 +17,8 @@ One canonical implementation, consumed by both repos, makes this class of drift
|
||||
|
||||
**2026-07-23, Phase 2:** `InvoiceSeller.bankDetails` (a free-text textarea on `company-settings`) became structured `iban`/`bic` fields — EN16931 e-invoicing wants discrete PaymentMeans data, not a paragraph a human formatted by hand. The footer's bank-details line also changed from "Bankverbindung (für Überweisung): …" to plain "Bankverbindung: IBAN … · BIC …", shown whenever either is set — it was never actually conditional on the order's payment method (that label was misleading), and there's no reason to hide it from a card/PayPal customer who might still want it (e.g. for a refund).
|
||||
|
||||
**2026-07-23, Phase 3: actual e-invoicing.** `renderInvoiceEInvoice()`/`renderCorrectionInvoiceEInvoice()` (in `einvoice/`) produce a ZUGFeRD/Factur-X hybrid PDF/A-3 with an embedded EN16931 XML instead of a plain PDF — see "E-invoicing" below.
|
||||
|
||||
## How this is consumed
|
||||
|
||||
Not published to npm — installed as a git dependency:
|
||||
@@ -36,3 +38,16 @@ Ships raw TypeScript/TSX source (no build step) via `main`/`types` pointing stra
|
||||
- `invoicePdf.tsx` — the original invoice ("Rechnung"): `InvoiceDocument`, `renderInvoicePdf()`, plus `SAMPLE_INVOICE_ORDER` (used by the frontend's Payload Live Preview for company-settings).
|
||||
- `correctionInvoicePdf.tsx` — Stornorechnung/Gutschrift: `renderCorrectionInvoicePdf()`.
|
||||
- `seller.ts` — the shared `InvoiceSeller` type both document types render in their footer.
|
||||
- `einvoice/` — the ZUGFeRD/Factur-X layer (see "E-invoicing" below).
|
||||
|
||||
## E-invoicing
|
||||
|
||||
`renderInvoiceEInvoice(order, seller)` / `renderCorrectionInvoiceEInvoice(kind, order, seller)` (`einvoice/renderEInvoice.ts`) are the e-invoice equivalents of `renderInvoicePdf()`/`renderCorrectionInvoicePdf()` — same inputs, but the returned `Uint8Array` is a **Factur-X-EN16931 hybrid PDF/A-3** (a normal-looking PDF with a machine-readable `factur-x.xml` embedded), not a plain PDF. The plain renderers still exist unchanged and are still what Live Preview/etc. use — nothing about the existing visual templates changed, this only adds a post-processing step on top for the actual send/download paths.
|
||||
|
||||
- **`einvoice/buildEInvoiceData.ts`** — maps `InvoiceOrder`/`CorrectionInvoiceOrder` + `InvoiceSeller` into the raw UBL-shaped `Invoice` object `@e-invoice-eu/core` expects (the library converts UBL → CII internally for Factur-X output — this package only ever builds the UBL shape, regardless of target format). Reuses `computeTaxBreakdown()` for the per-rate VAT grouping, same as the visual PDFs — one tax-math implementation feeding both the human-readable and machine-readable side of the same document.
|
||||
- Every EN16931 amount field turned out, at runtime (via the library's own ajv JSON-schema validation — **not visible in its TypeScript types at all**), to require a sibling `*@currencyID` key the moment the amount itself is present, and every quantity a `*@unitCode`. `amt()`/`qty()` return both keys at once via object spread so a call site can't add one without the other — found by actually running a sample invoice through `generate()` and reading the ajv errors, not from the library's own docs.
|
||||
- Original invoice: `InvoiceTypeCode` `380` ("Commercial invoice"). Correction invoice: `381` ("Credit note") — this library has no separate credit-note type, same `Invoice` shape either way, just the type code — with a `cac:BillingReference` pointing back at the original invoice number. Amounts stay positive either way (the credited amount, not a negative number) — EN16931/UBL convention puts the polarity in the type code, not the sign; the PDF's own visual "-{amount}" is a *display* convention layered on top (`correctionInvoicePdf.tsx`'s own `groupByTaxRate()`), not something this XML mapper re-derives independently.
|
||||
- VAT category is always `S` ("Standard rated") — correct for *any* positive VAT rate under EN16931/Peppol BIS convention (19% and 7% both use `S`, with the actual percentage in `cbc:Percent`); this shop has no exports/reverse-charge/exempt sales.
|
||||
- Payment means: included whenever `seller.iban` is set (matching the visual PDF footer's own "always show it" behavior since Phase 2), with a `PaymentMeansCode` mapped from the order's actual `paymentMethodTitle` (`Überweisung` → `30` credit transfer, `Kreditkarte` → `48`, `PayPal` → `68`, anything unrecognized → `1` "Instrument not defined" — a payment method added in Payload doesn't need a matching code deploy here to keep e-invoice generation working).
|
||||
- **`einvoice/countryCode.ts`** — `sellerCountry`/`order.country` are free text ("Deutschland"), not an ISO-3166 select field, but EN16931 wants a fixed two-letter code. Small closed mapping (DACH region only, this shop's actual shipping footprint), falling back to `DE`.
|
||||
- **Library: `@e-invoice-eu/core`**, format `'Factur-X-EN16931'` (the ZUGFeRD "Comfort" profile, the minimum EN16931-compliant level). Verified by actually generating a sample invoice from `SAMPLE_INVOICE_ORDER` and inflating the embedded XML stream out of the resulting PDF/A-3 by hand (the library ships no attachment-reading API of its own to check this against) — confirmed correct `CrossIndustryInvoice` XML, EN16931 guideline reference, per-rate tax breakdown, and payment means, not just "it didn't throw."
|
||||
|
||||
Generated
+318
-8
@@ -7,6 +7,9 @@
|
||||
"": {
|
||||
"name": "@einfach-produktiv/invoicing",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@e-invoice-eu/core": "^3.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@react-pdf/renderer": "^4.5.1",
|
||||
"@types/node": "^20",
|
||||
@@ -31,6 +34,71 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@cantoo/pdf-lib": {
|
||||
"version": "2.7.4",
|
||||
"resolved": "https://registry.npmjs.org/@cantoo/pdf-lib/-/pdf-lib-2.7.4.tgz",
|
||||
"integrity": "sha512-tMet5mSJOESLAvSw/NnVoty2L7hHtWGtXt7CjxL4jeq1r3fg8lB7+sO0qKRyqR3kxeWfa8W5C5IuyNtHSVaTeQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@pdf-lib/standard-fonts": "^1.0.0",
|
||||
"@pdf-lib/upng": "^1.0.1",
|
||||
"color": "^4.2.3",
|
||||
"crypto-js": "^4.2.0",
|
||||
"node-html-better-parser": ">=1.4.0",
|
||||
"pako": "^1.0.11",
|
||||
"tslib": ">=2"
|
||||
}
|
||||
},
|
||||
"node_modules/@e-invoice-eu/core": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@e-invoice-eu/core/-/core-3.1.1.tgz",
|
||||
"integrity": "sha512-SCy4VQO95H5wLmiPx6e959cW6/ujGbYTeHOalYsUYYbmgXhbFcWOry8Ge3ceRJWC3mfFKBVbdP2NteGVyKvTLA==",
|
||||
"license": "WTFPL",
|
||||
"dependencies": {
|
||||
"@cantoo/pdf-lib": "^2.6.5",
|
||||
"@e965/xlsx": "^0.20.3",
|
||||
"@esgettext/runtime": "^1.3.10",
|
||||
"ajv": "^8.18.0",
|
||||
"jsonpath-plus": "^10.4.0",
|
||||
"tmp-promise": "^3.0.3",
|
||||
"tslib": "^2.8.1",
|
||||
"xmlbuilder2": "^4.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@e-invoice-eu/core/node_modules/ajv": {
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
|
||||
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
"json-schema-traverse": "^1.0.0",
|
||||
"require-from-string": "^2.0.2"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/@e-invoice-eu/core/node_modules/json-schema-traverse": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@e965/xlsx": {
|
||||
"version": "0.20.3",
|
||||
"resolved": "https://registry.npmjs.org/@e965/xlsx/-/xlsx-0.20.3.tgz",
|
||||
"integrity": "sha512-703RN/3OdsRD5mtse2HBX7Um7xwaP9tlswEG6svOtjqokXoX7rJdQj7DyabD2I+xk22RgaIIU+R6BHgkpZGB/w==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"xlsx": "bin/xlsx.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
|
||||
@@ -65,6 +133,15 @@
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@esgettext/runtime": {
|
||||
"version": "1.3.10",
|
||||
"resolved": "https://registry.npmjs.org/@esgettext/runtime/-/runtime-1.3.10.tgz",
|
||||
"integrity": "sha512-thIUBFoim5wv3e/HLz0Gums8X+/lpoXXb48Nycfre/fSH+RGmzNjYXQwXVNl9AhkazVMuBmY8nKiJS9IBlg9SQ==",
|
||||
"license": "WTFPL",
|
||||
"dependencies": {
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint-community/eslint-utils": {
|
||||
"version": "4.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz",
|
||||
@@ -282,6 +359,30 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@jsep-plugin/assignment": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@jsep-plugin/assignment/-/assignment-1.3.0.tgz",
|
||||
"integrity": "sha512-VVgV+CXrhbMI3aSusQyclHkenWSAm95WaiKrMxRFam3JSUiIaQjoMIw2sEs/OX4XifnqeQUN4DYbJjlA8EfktQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 10.16.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"jsep": "^0.4.0||^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jsep-plugin/regex": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@jsep-plugin/regex/-/regex-1.0.4.tgz",
|
||||
"integrity": "sha512-q7qL4Mgjs1vByCaTnDFcBnV9HS7GVPJX5vyVoCgZHNSC9rjwIlmbXG5sUuorR5ndfHAIlJ8pVStxvjXHbNvtUg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 10.16.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"jsep": "^0.4.0||^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
|
||||
@@ -327,6 +428,54 @@
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@oozcitak/dom": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@oozcitak/dom/-/dom-2.0.2.tgz",
|
||||
"integrity": "sha512-GjpKhkSYC3Mj4+lfwEyI1dqnsKTgwGy48ytZEhm4A/xnH/8z9M3ZVXKr/YGQi3uCLs1AEBS+x5T2JPiueEDW8w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@oozcitak/infra": "^2.0.2",
|
||||
"@oozcitak/url": "^3.0.0",
|
||||
"@oozcitak/util": "^10.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@oozcitak/infra": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@oozcitak/infra/-/infra-2.0.2.tgz",
|
||||
"integrity": "sha512-2g+E7hoE2dgCz/APPOEK5s3rMhJvNxSMBrP+U+j1OWsIbtSpWxxlUjq1lU8RIsFJNYv7NMlnVsCuHcUzJW+8vA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@oozcitak/util": "^10.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@oozcitak/url": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@oozcitak/url/-/url-3.0.0.tgz",
|
||||
"integrity": "sha512-ZKfET8Ak1wsLAiLWNfFkZc/BraDccuTJKR6svTYc7sVjbR+Iu0vtXdiDMY4o6jaFl5TW2TlS7jbLl4VovtAJWQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@oozcitak/infra": "^2.0.2",
|
||||
"@oozcitak/util": "^10.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@oozcitak/util": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@oozcitak/util/-/util-10.0.0.tgz",
|
||||
"integrity": "sha512-hAX0pT/73190NLqBPPWSdBVGtbY6VOhWYK3qqHqtXQ1gK7kS2yz4+ivsN07hpJ6I3aeMtKP6J6npsEKOAzuTLA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-project/types": {
|
||||
"version": "0.139.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz",
|
||||
@@ -337,6 +486,24 @@
|
||||
"url": "https://github.com/sponsors/Boshen"
|
||||
}
|
||||
},
|
||||
"node_modules/@pdf-lib/standard-fonts": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz",
|
||||
"integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pako": "^1.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@pdf-lib/upng": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@pdf-lib/upng/-/upng-1.0.1.tgz",
|
||||
"integrity": "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pako": "^1.0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-pdf/fns": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@react-pdf/fns/-/fns-3.1.3.tgz",
|
||||
@@ -1063,7 +1230,6 @@
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
||||
"dev": true,
|
||||
"license": "Python-2.0"
|
||||
},
|
||||
"node_modules/assertion-error": {
|
||||
@@ -1192,11 +1358,23 @@
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/color": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
|
||||
"integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1",
|
||||
"color-string": "^1.9.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
@@ -1209,7 +1387,6 @@
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/color-name": {
|
||||
@@ -1235,6 +1412,22 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/color/node_modules/color-name": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/color/node_modules/color-string": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz",
|
||||
"integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "^1.0.0",
|
||||
"simple-swizzle": "^0.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/concat-map": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||
@@ -1264,6 +1457,12 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/crypto-js": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
|
||||
"integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
@@ -1528,7 +1727,6 @@
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-json-stable-stringify": {
|
||||
@@ -1545,6 +1743,22 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-uri": {
|
||||
"version": "3.1.4",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz",
|
||||
"integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/fdir": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
@@ -1707,6 +1921,22 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/html-entities": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz",
|
||||
"integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/mdevils"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://patreon.com/mdevils"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/hyphen": {
|
||||
"version": "1.14.1",
|
||||
"resolved": "https://registry.npmjs.org/hyphen/-/hyphen-1.14.1.tgz",
|
||||
@@ -1758,6 +1988,12 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/is-arrayish": {
|
||||
"version": "0.3.4",
|
||||
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz",
|
||||
"integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/is-extglob": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||
@@ -1823,7 +2059,6 @@
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
|
||||
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -1842,6 +2077,15 @@
|
||||
"js-yaml": "bin/js-yaml.js"
|
||||
}
|
||||
},
|
||||
"node_modules/jsep": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/jsep/-/jsep-1.4.0.tgz",
|
||||
"integrity": "sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 10.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/json-buffer": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
|
||||
@@ -1863,6 +2107,24 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jsonpath-plus": {
|
||||
"version": "10.4.0",
|
||||
"resolved": "https://registry.npmjs.org/jsonpath-plus/-/jsonpath-plus-10.4.0.tgz",
|
||||
"integrity": "sha512-T92WWatJXmhBbKsgH/0hl+jxjdXrifi5IKeMY02DWggRxX0UElcbVzPlmgLTbvsPeW1PasQ6xE2Q75stkhGbsA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jsep-plugin/assignment": "^1.3.0",
|
||||
"@jsep-plugin/regex": "^1.0.4",
|
||||
"jsep": "^1.4.0"
|
||||
},
|
||||
"bin": {
|
||||
"jsonpath": "bin/jsonpath-cli.js",
|
||||
"jsonpath-plus": "bin/jsonpath-cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/keyv": {
|
||||
"version": "4.5.4",
|
||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||
@@ -2280,6 +2542,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-html-better-parser": {
|
||||
"version": "1.5.8",
|
||||
"resolved": "https://registry.npmjs.org/node-html-better-parser/-/node-html-better-parser-1.5.8.tgz",
|
||||
"integrity": "sha512-t/wAKvaTSKco43X+yf9+76RiMt18MtMmzd4wc7rKj+fWav6DV4ajDEKdWlLzSE8USDF5zr/06uGj0Wr/dGAFtw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"html-entities": "^2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/normalize-svg-path": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/normalize-svg-path/-/normalize-svg-path-1.1.0.tgz",
|
||||
@@ -2368,7 +2639,6 @@
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
||||
"dev": true,
|
||||
"license": "(MIT AND Zlib)"
|
||||
},
|
||||
"node_modules/parent-module": {
|
||||
@@ -2546,7 +2816,6 @@
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -2661,6 +2930,15 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/simple-swizzle": {
|
||||
"version": "0.2.4",
|
||||
"resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz",
|
||||
"integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-arrayish": "^0.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
@@ -2779,11 +3057,28 @@
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tmp": {
|
||||
"version": "0.2.7",
|
||||
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz",
|
||||
"integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.14"
|
||||
}
|
||||
},
|
||||
"node_modules/tmp-promise": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tmp-promise/-/tmp-promise-3.0.3.tgz",
|
||||
"integrity": "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tmp": "^0.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"dev": true,
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/type-check": {
|
||||
@@ -3092,6 +3387,21 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/xmlbuilder2": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/xmlbuilder2/-/xmlbuilder2-4.0.3.tgz",
|
||||
"integrity": "sha512-bx8Q1STctnNaaDymWnkfQLKofs0mGNN7rLLapJlGuV3VlvegD7Ls4ggMjE3aUSWItCCzU0PEv45lI87iSigiCA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@oozcitak/dom": "^2.0.2",
|
||||
"@oozcitak/infra": "^2.0.2",
|
||||
"@oozcitak/util": "^10.0.0",
|
||||
"js-yaml": "^4.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/yocto-queue": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||
|
||||
+5
-2
@@ -12,8 +12,8 @@
|
||||
"lint": "eslint ."
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.0.0",
|
||||
"@react-pdf/renderer": "^4.0.0"
|
||||
"@react-pdf/renderer": "^4.0.0",
|
||||
"react": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@react-pdf/renderer": "^4.5.1",
|
||||
@@ -23,5 +23,8 @@
|
||||
"react": "^19.2.4",
|
||||
"typescript": "^5",
|
||||
"vitest": "^4.1.10"
|
||||
},
|
||||
"dependencies": {
|
||||
"@e-invoice-eu/core": "^3.1.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
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 }))),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// `sellerCountry`/`order.country` are free text (e.g. "Deutschland"), not
|
||||
// an ISO-3166 select field — but EN16931's Seller/Buyer country fields are
|
||||
// a fixed two-letter code (`SellerCountryCode`/`BuyerCountryCode` in
|
||||
// @e-invoice-eu/core's generated types). Small, closed mapping rather than
|
||||
// a full i18n country-name library — this shop only actually ships within
|
||||
// the DACH region today; unrecognized names fall back to "DE" (the far
|
||||
// more common failure mode is a typo/casing variant of a country this
|
||||
// shop already ships to, not a genuinely new country appearing overnight).
|
||||
const COUNTRY_CODES: Record<string, string> = {
|
||||
deutschland: "DE",
|
||||
germany: "DE",
|
||||
österreich: "AT",
|
||||
austria: "AT",
|
||||
schweiz: "CH",
|
||||
switzerland: "CH",
|
||||
};
|
||||
|
||||
export function countryCode(name: string): string {
|
||||
return COUNTRY_CODES[name.trim().toLowerCase()] ?? "DE";
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { InvoiceService } from "@e-invoice-eu/core";
|
||||
import { renderInvoicePdf, type InvoiceOrder } from "../invoicePdf";
|
||||
import { renderCorrectionInvoicePdf, type CorrectionInvoiceKind, type CorrectionInvoiceOrder } from "../correctionInvoicePdf";
|
||||
import type { InvoiceSeller } from "../seller";
|
||||
import { buildEInvoiceData, buildCorrectionEInvoiceData } from "./buildEInvoiceData";
|
||||
|
||||
// `console` satisfies @e-invoice-eu/core's minimal Logger interface
|
||||
// (debug/info/warn/error) without needing a dedicated logger dependency —
|
||||
// this only ever runs server-side (Node.js), console output lands in the
|
||||
// same place `payload.logger`/plain `console.error` calls already do
|
||||
// elsewhere in both consuming repos.
|
||||
const invoiceService = new InvoiceService(console);
|
||||
|
||||
// Renders the existing @react-pdf/renderer visual PDF unchanged, then
|
||||
// passes that buffer through @e-invoice-eu/core's Factur-X embed step to
|
||||
// get back a PDF/A-3 file with the EN16931 XML attached (`factur-x.xml`)
|
||||
// — same visual document a human opens, but AP software that understands
|
||||
// Factur-X/ZUGFeRD can also extract and process it automatically. Format
|
||||
// 'Factur-X-EN16931' — the "Comfort" profile, the minimum EN16931-compliant
|
||||
// Factur-X level (see this package's README/the project's e-invoicing
|
||||
// migration plan for why EN16931 over a lower profile like BASIC).
|
||||
export async function renderInvoiceEInvoice(order: InvoiceOrder, seller: InvoiceSeller): Promise<Uint8Array> {
|
||||
const pdf = await renderInvoicePdf(order, seller);
|
||||
const invoiceData = buildEInvoiceData(order, seller);
|
||||
const result = await invoiceService.generate(invoiceData, {
|
||||
format: "Factur-X-EN16931",
|
||||
lang: "de",
|
||||
pdf: { buffer: pdf, filename: `Rechnung-${order.invoiceNumber}.pdf`, mimetype: "application/pdf" },
|
||||
});
|
||||
// Factur-X formats always return the PDF/A-3 bytes, never the bare XML
|
||||
// string variant `generate()`'s return type also allows for XML-only
|
||||
// formats (XRechnung/UBL/CII) — this function only ever requests a
|
||||
// Factur-X format, so this case is unreachable in practice, just
|
||||
// satisfying the union type.
|
||||
if (typeof result === "string") throw new Error("Expected a PDF/A-3 buffer from Factur-X generation, got a string.");
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function renderCorrectionInvoiceEInvoice(kind: CorrectionInvoiceKind, order: CorrectionInvoiceOrder, seller: InvoiceSeller): Promise<Uint8Array> {
|
||||
const pdf = await renderCorrectionInvoicePdf(kind, order, seller);
|
||||
const invoiceData = buildCorrectionEInvoiceData(kind, order, seller);
|
||||
const result = await invoiceService.generate(invoiceData, {
|
||||
format: "Factur-X-EN16931",
|
||||
lang: "de",
|
||||
pdf: { buffer: pdf, filename: `${kind === "storno" ? "Stornorechnung" : "Gutschrift"}-${order.correctionInvoiceNumber}.pdf`, mimetype: "application/pdf" },
|
||||
});
|
||||
if (typeof result === "string") throw new Error("Expected a PDF/A-3 buffer from Factur-X generation, got a string.");
|
||||
return result;
|
||||
}
|
||||
@@ -14,3 +14,5 @@ export {
|
||||
type CorrectionInvoiceItem,
|
||||
type CorrectionInvoiceOrder,
|
||||
} from "./correctionInvoicePdf";
|
||||
export { renderInvoiceEInvoice, renderCorrectionInvoiceEInvoice } from "./einvoice/renderEInvoice";
|
||||
export { buildEInvoiceData, buildCorrectionEInvoiceData } from "./einvoice/buildEInvoiceData";
|
||||
|
||||
Reference in New Issue
Block a user