de1a7c14a0
Validate e-invoices / mustang (push) Successful in 23s
"Why this exists" replaced a phase-by-phase dated bugfix log with a short summary of what the package does today. Git history is the actual changelog. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
102 lines
18 KiB
Markdown
102 lines
18 KiB
Markdown
# @einfach-produktiv/invoicing
|
|
|
|
Shared invoice / correction-invoice (Stornorechnung, Gutschrift) PDF generation and VAT-breakdown math, used by both:
|
|
|
|
- `einfach-produktiv` (the Next.js storefront — generates the original invoice at checkout, plus on-demand re-downloads of both document types)
|
|
- `payload` (the Payload CMS backend — generates the authoritative Stornorechnung/Gutschrift the moment an order's status changes)
|
|
|
|
## Onboarding a new client with this module
|
|
|
|
This package is the reference example for "modular, separately licensable" pieces of this stack — module + paid integration per client, not a hosted SaaS. To bring a new client onto it:
|
|
|
|
1. **Install as a git dependency**, not from npm (see "How this is consumed" below):
|
|
```json
|
|
"@einfach-produktiv/invoicing": "git+https://git.mk360.de/Marco/einfach-produktiv-invoicing.git#main"
|
|
```
|
|
Pin to `#main` unless the client needs a specific historical version — there's no separate release/tag process today, `main` is always the current shipping state.
|
|
2. **Add it to `transpilePackages`** in the consumer's `next.config.ts` — it ships raw TS/TSX, no build step (see "How this is consumed").
|
|
3. **Provide `react` and `@react-pdf/renderer`** yourself — both are peer dependencies, not bundled.
|
|
4. **Feed it structured seller/order data** matching `InvoiceSeller`/`InvoiceOrder`/`CorrectionInvoiceOrder` (see `src/seller.ts`, `src/invoicePdf.tsx`) — this package renders PDFs and computes tax breakdowns, it does not fetch or own any of that data itself. Decide upfront: does this client need Kleinunternehmerregelung (§19 UStG)? Cross-border VAT exemption (innergemeinschaftliche Lieferung)? Both are supported but each needs the *consuming* app's own checkout logic to detect and pass the right flag (see "VAT exemption"/"Kleinunternehmerregelung" sections below) — this package never decides either on its own.
|
|
5. **Decide whether the client needs e-invoicing** (ZUGFeRD/Factur-X, mandatory for German B2B from 2027 onward) — if yes, wire up `renderInvoiceEInvoice`/`renderCorrectionInvoiceEInvoice` (see "E-invoicing" below) instead of the plain PDF renderers; if the client only sells B2C for now, the plain renderers are sufficient and simpler.
|
|
6. **Known gotcha to check for this specific client**: if their `company-settings.iban` might ever be unset, fix the `@e-invoice-eu/core` crash noted at the end of "E-invoicing" below *before* going live — it currently only doesn't affect the original tenant because their IBAN is always set.
|
|
|
|
Everything below this point is the module's own development history/changelog and technical reference — read it for the "why" behind any given behavior, not as a first-time setup guide.
|
|
|
|
## What this package does
|
|
|
|
Renders the original invoice ("Rechnung") and its two correction types (Stornorechnung, Gutschrift) as PDF or e-invoice (ZUGFeRD/Factur-X), plus the shared VAT-breakdown math and a handful of small validation utilities (VIES, VAT-ID format, PLZ, carrier tracking) — one canonical implementation for both consuming repos instead of hand-duplicated copies drifting apart.
|
|
|
|
Supports three order-level VAT treatments a consumer can opt into: standard VAT, cross-border exemption (innergemeinschaftliche Lieferung, see "VAT exemption"), and Kleinunternehmerregelung (§19 UStG, see "Kleinunternehmerregelung") — the consuming app's checkout decides which applies per order; this package never guesses.
|
|
|
|
Fonts are embedded (Liberation Sans, SIL OFL-1.1, metrically identical to Helvetica/Arial) rather than relying on react-pdf's unembedded standard-14 fonts — required for PDF/A-3 conformance in the e-invoicing pipeline. `fonts.ts` resolves its font files differently depending on `typeof window` — Node.js (`fileURLToPath`+`path.join`) vs. browser (`new URL(..., import.meta.url)`, correctly asset-hashed by whichever Next.js app's own bundler is consuming it) — since this package is consumed by two independent Next.js apps whose bundlers each need this resolved through their own asset pipeline, and the one shape that works for one breaks the other.
|
|
|
|
## How this is consumed
|
|
|
|
Not published to npm — installed as a git dependency:
|
|
|
|
```json
|
|
"@einfach-produktiv/invoicing": "git+https://git.mk360.de/Marco/einfach-produktiv-invoicing.git"
|
|
```
|
|
|
|
Ships raw TypeScript/TSX source (no build step) via `main`/`types` pointing straight at `src/index.ts`. Each consuming Next.js app must add this package to its own `next.config.ts`'s `transpilePackages` array so its own bundler compiles the source — the same pattern a monorepo tool like Turborepo uses for internal packages, just without the monorepo.
|
|
|
|
`react` and `@react-pdf/renderer` are peer dependencies — each consumer supplies its own copy rather than this package pinning a version that could conflict.
|
|
|
|
## Layout
|
|
|
|
- `taxBreakdown.ts` — `computeTaxBreakdown()`, the per-VAT-rate net/tax grouping math shared by every document type here.
|
|
- `formatters.ts` — `formatPrice()`/`formatDate()`, canonical formatting for every document.
|
|
- `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()`.
|
|
- `fonts.ts` — registers the embedded Liberation Sans font (`src/assets/fonts/`) once for both PDF modules.
|
|
- `seller.ts` — the shared `InvoiceSeller` type both document types render in their footer.
|
|
- `einvoice/` — the ZUGFeRD/Factur-X layer (see "E-invoicing" below).
|
|
- `vies.ts` — `checkVatIdViaVies()`, a live check against the EU Commission's VIES API (server-only — import from `@einfach-produktiv/invoicing/vies`, not the main barrel).
|
|
- `vatId.ts` — `normalizeVatId()`/`isValidVatId()`, EU VAT-ID format validation (client- and server-safe).
|
|
- `plz.ts` — `isValidPlz()`/`plzInputPattern()`, postal-code digit-count validation keyed by a caller-supplied digit count per country.
|
|
- `carrierTracking.ts` — `CARRIER_LABELS`/`buildTrackingUrl()`, shipping-carrier tracking-link generation.
|
|
|
|
These four were unified from what used to be hand-duplicated, independently-drifting copies in both consuming repos (each had its own `vies.ts`/`vatId.ts`/PLZ-regex/`tracking.ts`) — see each consumer's own README for where they're used. The small in-memory rate limiter each repo also has (`rateLimit.ts`) is deliberately left duplicated — small enough (~15 lines) that a shared dependency isn't worth the coupling.
|
|
|
|
## 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 `S` ("Standard rated") for any positive-rate order — 19% and 7% both use `S`, with the actual percentage in `cbc:Percent`. When `order.vatExempt` is set (innergemeinschaftliche Lieferung, §4 Nr. 1b UStG), category `K` ("VAT exempt for EEA intra-community supply of goods and services") is used instead, with a `VATEX-EU-IC` exemption reason (BT-120/BT-121). When `order.kleinunternehmer` is set (§19 UStG, takes precedence over `vatExempt`), category `E` ("Exempt from tax") is used with a free-text exemption reason only — §19 UStG is a national provision with no EU-wide VATEX code, and BR-E-10 accepts the reason text alone without a code. Both `K`/`E` fields live — but **only** on `cac:TaxTotal`'s own `TaxSubtotal.TaxCategory`, never on an `InvoiceLine`'s `ClassifiedTaxCategory` or an `AllowanceCharge`'s own `TaxCategory` — `@e-invoice-eu/core`'s generated ajv schema rejects `TaxExemptionReasonCode`/`TaxExemptionReason` as "additional properties" on those two despite them being conceptually the same UBL `TaxCategory` complex type. Caught locally (a full PDF/A-3 render + hand-inflated CII XML inspection) before ever reaching Mustang. This shop has no domestic reverse-charge (`AE`) sales — only these two exemption cases exist, and they're mutually exclusive (see `VatMode` in `buildEInvoiceData.ts`).
|
|
- 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). **Known gotcha, not yet fixed**: when `seller.iban` is unset, `cac:PaymentMeans` is simply omitted (`undefined`) — but `@e-invoice-eu/core`'s UBL→CII conversion then throws (`Cannot read properties of undefined (reading 'length')` inside its own `format-cii.service.ts`), unrelated to anything in this package's own code. Doesn't affect this shop's production data (this tenant's `company-settings.iban` is always set), but would break e-invoice generation entirely for any future tenant/order whose seller has no IBAN configured — worth fixing (always pass an empty array rather than `undefined`?) before that ever happens.
|
|
- **`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 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) — confirmed correct `CrossIndustryInvoice` XML, EN16931 guideline reference, per-rate tax breakdown, and payment means. The `vatExempt` path is confirmed the same way (`CategoryCode>K`, `ExemptionReasonCode>VATEX-EU-IC` present in the actual embedded XML). The `kleinunternehmer` path is covered by `__tests__/buildEInvoiceData.test.ts` (asserts the pre-conversion UBL `TaxCategory` shape: `E` + the §19 reason text, no code) and by its own Mustang CI fixture (`kleinunternehmer-invoice.pdf`) — not yet manually re-verified against hand-inflated CII XML the way `K` was; worth doing once this ships to a real Kleinunternehmer tenant.
|
|
|
|
## VAT exemption
|
|
|
|
`InvoiceOrder.vatExempt`/`CorrectionInvoiceOrder.vatExempt` (optional, default falsy) — set by the consuming app after its own checkout confirms (live VIES lookup) that a sale qualifies as an innergemeinschaftliche Lieferung. This package never decides the exemption itself — by the time an order reaches these renderers, every item's `unitPrice` is already de-grossed (net) and `taxRatePercent` already `0`; `vatExempt` only controls **display**:
|
|
|
|
- Visual PDF (`invoicePdf.tsx`/`correctionInvoicePdf.tsx`): the "enthält X% MwSt." annotation under Gesamt becomes "Steuerfreie innergemeinschaftliche Lieferung (§4 Nr. 1b UStG)" instead — rendered as its own full-width row below the summary card (same treatment as the Vorkasse/unpaid notice), not squeezed into the card's narrow right-aligned column.
|
|
- E-invoice XML (`buildEInvoiceData.ts`): VAT category `K` + VATEX-EU-IC exemption reason — see "E-invoicing" above for exactly where those fields are (and aren't) allowed to live.
|
|
|
|
See the frontend repo's own README ("VAT exemption" section) for the actual VIES lookup, de-grossing math, and checkout UI this feeds from, and the Payload backend's README ("B2B checkout & VAT exemption") for the persisted `Orders.vatExempt`/`vatIdValidatedAt` fields and audit-trail reasoning.
|
|
|
|
## Kleinunternehmerregelung (§19 UStG)
|
|
|
|
`InvoiceOrder.kleinunternehmer`/`CorrectionInvoiceOrder.kleinunternehmer` (optional, default falsy) — a **seller-level**, not order-level, business fact (whether this tenant is a small business under §19 UStG), but still snapshotted onto each order at checkout time (mirroring how `vatExempt` is already frozen there) rather than read live off the seller when an invoice renders. This matters for a reason `vatExempt` doesn't have to worry about: `vatExempt` is inherently decided per order (a live VIES lookup against that specific sale), so there's no "past" value to protect. `kleinunternehmer` is a standing tenant setting that can be toggled on/off at any time — without the snapshot, a tenant switching it later would rewrite the tax treatment of every already-issued invoice the next time it's re-rendered (e.g. a customer's "Rechnung erneut herunterladen"), which is both legally wrong (the treatment at the moment of sale is what counts) and silent. The consuming frontend's checkout is the only place this ever gets read live, from its own `company-settings.kleinunternehmer` — see that repo's README.
|
|
|
|
Unlike `vatExempt`, this package does **not** expect `unitPrice` to be de-grossed for a `kleinunternehmer` order — a Kleinunternehmer never charged VAT in the first place, so the catalog gross price and the actual net charge are the same figure; only `taxRatePercent` becomes `0` on every item. Display:
|
|
|
|
- Visual PDF (`invoicePdf.tsx`/`correctionInvoicePdf.tsx`): the "enthält X% MwSt." annotation under Gesamt becomes "Gemäß § 19 UStG wird keine Umsatzsteuer berechnet." instead — takes precedence over the `vatExempt` note if (implausibly) both were ever set.
|
|
- E-invoice XML (`buildEInvoiceData.ts`): VAT category `E` + a free-text exemption reason, no VATEX code — see "E-invoicing" above.
|
|
- **No Netto row** — every other invoice type still shows Netto directly under the tax note/annotation, including `vatExempt` ones, where Netto = Gesamt is a *coincidence* worth keeping for layout consistency (0% happens to apply to that one sale). For `kleinunternehmer` it isn't a coincidence — §19 UStG means no tax component ever existed on any of this tenant's invoices, so the row would just repeat Gesamt directly under the §19 notice above it instead of adding information.
|
|
|
|
`InvoiceSeller.vatId` is now optional (`string | null`, was a plain required `string`) for the same reason — a Kleinunternehmer commonly never registers for an USt-IdNr. at all (no intra-EU trade). Every renderer treats a missing value as "omit the USt-IdNr. line/field" rather than printing an empty one: the visual PDF footer drops the whole `· USt-IdNr. …` segment, and `buildEInvoiceData.ts`'s `sellerParty()` omits `cac:PartyTaxScheme` entirely rather than emitting one with a blank `CompanyID`.
|
|
|
|
## CI validation (Mustang)
|
|
|
|
Every push/PR runs `.gitea/workflows/validate-einvoice.yml` against git.mk360.de's own self-hosted Gitea Actions runner (`vps-runner`, registered on the same VPS as Gitea/Payload — see `/home/marco/dev/docker/docker-compose.yml`'s `act_runner` service):
|
|
|
|
1. `npm run fixtures:einvoice` (`scripts/generate-einvoice-fixtures.mts`) renders 4 PDFs into `.mustang-fixtures/` (gitignored, regenerated every run) — an original invoice with **two simultaneous VAT rates (19%+7%) plus a discount and shipping cost together** (the combination `SAMPLE_INVOICE_ORDER` doesn't cover), a Storno and a Gutschrift against that same order, and a separate single-rate **Kleinunternehmer** original invoice (`kleinunternehmer-invoice.pdf`, every item at 0%, category `E`) — its own fixture rather than a variant of the first, since a real Kleinunternehmer order never has a positive catalog rate to begin with.
|
|
2. [Mustang-CLI](https://www.mustangproject.org/commandline/) (the reference ZUGFeRD/Factur-X validator, `--action validateExpectValid -d .mustang-fixtures`) checks all 4 for EN16931 + PDF/A-3 conformance in one call. Non-zero exit fails the job. The jar is downloaded pinned to a specific release + sha256 (`core-2.24.0`) rather than a floating `latest` tag, right in the workflow — no Docker image for Mustang is actively maintained by the upstream project itself, so downloading the jar directly into a `setup-java` step was simpler and more trustworthy than depending on a third-party wrapper image.
|
|
|
|
Run the same check locally with `npm run fixtures:einvoice`, then point a locally-downloaded `Mustang-CLI-*.jar` at `.mustang-fixtures/` yourself — useful for iterating on `buildEInvoiceData.ts` without waiting on a CI round-trip.
|