Document VAT exemption support and today's invoice-layout fixes
Validate e-invoices / mustang (push) Successful in 22s
Validate e-invoices / mustang (push) Successful in 22s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -21,6 +21,10 @@ One canonical implementation, consumed by both repos, makes this class of drift
|
||||
|
||||
**2026-07-23, Phase 4: CI validation.** `.gitea/workflows/validate-einvoice.yml` runs on every push/PR (via git.mk360.de's own self-hosted Gitea Actions runner) — see "CI validation (Mustang)" below.
|
||||
|
||||
**2026-07-23, later the same day: B2B buyer fields + VAT exemption.** `InvoiceOrder`/`CorrectionInvoiceOrder` gained optional `companyName`/`vatId` (shown in the "An" recipient block) and `vatExempt` (innergemeinschaftliche Lieferung, §4 Nr. 1b UStG — decided by the consuming frontend's own checkout via a live VIES lookup, never guessed here) — see "VAT exemption" below.
|
||||
|
||||
**2026-07-23, invoice-layout fixes.** The "Bereits beglichen" confirmation is now plain green text, not a tinted pill/box. Item-table rows share one uniform tinted background (no more alternating zebra striping). The summary card's Netto/MwSt rows were replaced with a genuinely additive Zwischensumme→Rabatt→Versand→Gesamt chain plus an "enthält X% MwSt." annotation below it — the previous layout showed Rabatt/Versand as their own rows *and* folded into the tax-rate groups below them (tax-correct, since ancillary costs are legally apportioned across rates — but double-counted visually), so the visible rows never actually summed to the printed Gesamt. Caught against a real production order, not a synthetic edge case.
|
||||
|
||||
## How this is consumed
|
||||
|
||||
Not published to npm — installed as a git dependency:
|
||||
@@ -49,10 +53,19 @@ Ships raw TypeScript/TSX source (no build step) via `main`/`types` pointing stra
|
||||
- **`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).
|
||||
- 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) — 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 the one cross-border-EU exemption case exists.
|
||||
- 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 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."
|
||||
- **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." Same manual-inflation verification repeated for the `vatExempt` path (2026-07-23) — confirmed `CategoryCode>K`, `ExemptionReasonCode>VATEX-EU-IC`, `ExemptionReason>Innergemeinschaftliche Lieferung` all present in the actual embedded XML, not just the pre-conversion UBL JSON.
|
||||
|
||||
## 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.
|
||||
- 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.
|
||||
|
||||
## CI validation (Mustang)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user