An E-Rechnung checker flagged that generated invoices don't embed their
fonts. Both invoicePdf.tsx and correctionInvoicePdf.tsx used react-pdf's
built-in Helvetica/Helvetica-Bold, which is never embedded — PDF/A-3
(already required by this package's own Factur-X/ZUGFeRD pipeline) has
no exemption for standard fonts. Vendors Liberation Sans (SIL OFL-1.1,
metrically identical to Helvetica) and registers it via Font.register in
a new fonts.ts, imported by both PDF modules.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Same fix invoicePdf.tsx's own unpaid-notice row already got: the
exemption/Kleinunternehmer notice used to live inside summaryBox's
narrow, right-aligned column, squeezing what's actually a full
sentence. Now a full-width sibling row below the card, same treatment
in both invoicePdf.tsx and correctionInvoicePdf.tsx, and ordered ahead
of the unpaid-notice row when both apply.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Reused paidBadgeRow, which lives inside summary's narrow flex-end
column meant for the short one-line paid confirmation — the longer
instruction shrank to content width and read as squeezed under the
summary card. Moved to its own full-width sibling row, left-aligned.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
isPaidImmediately() matched paymentMethodTitle with an exact !== check
against the literal "Überweisung" — the consuming shop renamed its
Vorkasse row to "Überweisung (Vorkasse)" the same day, which would have
silently made every unpaid Vorkasse invoice show "✓ Bereits beglichen".
Changed to startsWith("Überweisung") — same design, tolerant of a
suffix qualifier.
Also adds an explicit instruction for the unpaid case (previously the
absence of the paid confirmation was the only signal, no actual text):
pay to the listed bank details, order processed after payment received
(~1-2 business days).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Netto = Gesamt is structural under §19 UStG (no tax component ever
existed), not the coincidental 0%-this-one-sale case vatExempt orders
are in — showing it right under the §19 notice just duplicated Gesamt.
At the user's own request after reviewing a real invoice.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Payload's company-settings.vatId is no longer hard-required (a
Kleinunternehmer often has no USt-IdNr. at all) — this package's
InvoiceSeller.vatId follows suit, with every renderer now omitting the
USt-IdNr. line/field entirely instead of printing/serializing an empty
value.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
InvoiceOrder/CorrectionInvoiceOrder gain an optional order-level
kleinunternehmer flag, snapshotted per order (same pattern as vatExempt)
so a later toggle of the tenant's setting never rewrites an
already-issued invoice. Takes precedence over vatExempt.
- Visual PDF: "enthält X% MwSt." becomes the §19 UStG notice.
- E-invoice XML: new UNTDID 5305 category "E" with a free-text exemption
reason (no VATEX code — §19 UStG has none, BR-E-10 allows text alone).
- New Mustang CI fixture + buildEInvoiceData unit test for the "E" path.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Was using summaryLabel/summaryValue (fontSize 10, same as Zwischensumme/
Versand — the build-up rows above Gesamt), which visually competed
with Gesamt instead of reading as supplementary detail below it. Now
uses the same small/muted vatNoteLabel/vatNoteValue as the "enthält
X% MwSt." line right above it — both are detail underneath the hero
Gesamt figure, same visual tier.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Order was Gesamt → Netto → "enthält X% MwSt." — reads as if the
annotation describes Netto, which is backwards: Netto already has the
tax removed, Gesamt is what contains it. Reordered to Gesamt →
"enthält X% MwSt." → Netto so the annotation stays unambiguously
attached to the row it actually describes. Verified by re-rendering
and visually inspecting both the original invoice and a Storno.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Netto/Gesamt/MwSt breakdown was gated on !order.vatExempt — on a
vatExempt (innergemeinschaftliche Lieferung) invoice, Netto happens to
equal Gesamt exactly (0% tax), which read as redundant next to the
exemption note, so the row was skipped there. Turns out that's wrong:
the ask was for Netto on every invoice this shop issues, B2B/exempt
included, for layout consistency — not conditional on the tax rate.
Same fix in both invoicePdf.tsx and correctionInvoicePdf.tsx (the
latter covers both Storno and Gutschrift via the same summary code).
Verified by rendering all three cases (normal 19% invoice, exempt B2B
invoice, Storno correction) and visually inspecting the output.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
InvoiceOrder/CorrectionInvoiceOrder gained an optional vatExempt flag
— items already carry 0% rate and de-grossed prices when true (the
caller's responsibility), this only controls display/XML mapping:
- Visual PDF: replaces the "enthält X% MwSt." annotation with
"Steuerfreie innergemeinschaftliche Lieferung (§4 Nr. 1b UStG)" on
both the invoice and its Storno/Gutschrift.
- EN16931 XML (buildEInvoiceData.ts): VAT category 'K' instead of 'S'
on every line/tax-subtotal, plus BT-120/BT-121 exemption reason
(VATEX-EU-IC) — but only on cac:TaxTotal's own TaxSubtotal.TaxCategory,
not on InvoiceLine/AllowanceCharge's TaxCategory, which
@e-invoice-eu/core's schema rejects those same two fields on (caught
locally before Mustang ever saw it — a fully-rendered PDF/A-3 with a
hand-inflated CII XML confirmed CategoryCode/ExemptionReasonCode/
ExemptionReason land correctly).
Also surfaced (not fixed, unrelated to this change): a seller with no
iban configured throws in @e-invoice-eu/core's UBL→CII conversion —
doesn't affect this shop's own production data (iban is always set),
noting for whoever touches this next.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The Netto/MwSt. rows were computed per tax rate with shipping and
discount already proportionally folded in (computeTaxBreakdown's
scale factor — tax-correct, since ancillary costs are apportioned
across rates), but Rabatt/Versand were ALSO shown as their own
separate rows on top. Adding up the visible rows never actually
matched the printed Gesamt — off by exactly the shipping (and any
discount) amount. Confirmed against a real production order
(#EP-0006-ZDX6's Stornorechnung).
Restructured both templates into a genuinely additive chain
(Zwischensumme → Rabatt → Versand → Gesamt, using the raw order
fields directly) with the per-rate tax now shown as an "enthält X%
MwSt." annotation below Gesamt — informational, not part of the sum.
Mirrors the pattern the website's own VatBreakdown component already
uses on /cart and /checkout.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- The "Bereits beglichen" confirmation is now plain green Text, not a
tinted pill/box — read as too heavy for a status note.
- Item table rows no longer alternate white/tinted (zebra striping);
every row now shares the same tinted background for a more uniform
table, on both the invoice and its Storno/Gutschrift corrections.
- InvoiceOrder/CorrectionInvoiceOrder gained optional companyName/
vatId, rendered in the "An" recipient block when present — the
buyer-side counterpart to the sellers' own fields already shown in
the footer. Both consuming repos need their own commit to actually
pass these through from Orders.companyName/vatId.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The previous placement put it as one more row inside summaryBox, right after Gesamt — the user wanted it visually below/outside the tinted card entirely. Now a sibling of summaryBox instead of a child, verified by rendering to PDF.
- Footer is centered, not left-aligned.
- Summary rows are grouped into visual clusters (Rabatt+Versand; each rate's own Netto/MwSt pair) with a bit of spacing between clusters, tight spacing within — makes it clearer which lines belong together.
- More space between the product image and the article text in each line item.
- "Bereits beglichen" badge moved from the top meta row to directly below "Gesamt", where it reads more naturally against the amount it confirms.
- Packstation number and Postnummer render as two separate lines instead of one "Packstation X · Postnummer Y" line.
Applied identically to both invoicePdf.tsx and correctionInvoicePdf.tsx (Stornorechnung/Gutschrift) so every generated invoice stays visually consistent — verified by rendering both to PDF and comparing.
The integer-cents pipeline rewrite (97a7f6a) dropped the /(1+rate/100) degross step when allocating discountAmount/shippingCost per rate group -- allowance/charge amounts came out ~19%/7% too large. Mustang's BR-CO-* checks couldn't catch this since they only verify internal self-consistency, not agreement with the original order data -- surfaced instead via an external e-Rechnung checker on a real order, where the resulting PayableRoundingAmount was >1 EUR instead of a normal few-cents residual. Fixed; the same real order's numbers now reconcile to within 1 cent of order.total.
Also fixes BR-CO-25 (a positive Amount due needs a due date or payment terms): original invoices paid immediately at checkout (Kreditkarte/PayPal, same isPaidImmediately() definition the visual PDF's "Bereits beglichen" badge uses) now declare PrepaidAmount = the full charged total, making PayableAmount 0 -- matching what the visual PDF already told a human reader, now machine-readable too. Bank-transfer originals and all correction invoices (Storno/Gutschrift, which represent a refund owed rather than an amount due) get an explicit PaymentTerms note instead, since this shop has no due-date policy modeled to cite.
The 1-cent drift between this file's per-rate breakdown and the original invoice's independently-stored order.total (checkout's own cart math) turned out to be a hard schematron error (BR-CO-16), not just the non-fatal "Arithmetical issue" warning it looked like from the log alone. EN16931 has a field for exactly this gap, cbc:PayableRoundingAmount (BT-114) — using it keeps both PayableAmount (the actually-charged total) and TaxInclusiveAmount (this breakdown's own correctly-summed total) intact and reconciling, verified by hand before pushing.
The previous fix (net not gross line amounts + AllowanceCharge entries) still drifted a cent on BR-CO-12/13/14: rounding a float sum once at the end doesn't equal the sum of independently-rounded parts. Every amount in the document is now built by summing the same already-rounded-to-cent integers that appear in the individual line/allowance/charge/tax entries, so every EN16931 "declared total = sum of its own parts" rule holds exactly by construction rather than approximately. Verified by hand against all five affected business rules (BR-CO-10/12/13/14, BR-S-08) before pushing.
taxBreakdown.ts's earlier additive rawGross/rawNet fields are reverted — this pipeline no longer depends on it at all, computing everything fresh from order.items/discountAmount/shippingCost.
Mustang's Phase 4 CI check flagged BR-CO-10/BR-CO-14/BR-S-08 once the Delivery-element fix let it get that far: invoice line amounts were being reported as this shop's normal gross (VAT-inclusive) prices instead of the net amounts EN16931 requires, and shipping/discount had no explicit cac:AllowanceCharge representation at all (silently folded into a scaled net total instead) — so the document totals didn't reconcile against the line items the way a validating AP system checks.
De-grosses each line's own unit price per its own VAT rate for LineExtensionAmount/PriceAmount, and adds explicit per-rate AllowanceCharge entries (proportionally allocated the same way computeTaxBreakdown's scale factor already works internally, just split back into its two components instead of one combined adjustment). taxBreakdown.ts gained two additive fields (rawGross/rawNet) for this; the visual PDF renderers are unaffected, they only ever read rate/net/tax/gross.
The converter recurses into cac:Delivery's own children and only vivifies ram:ApplicableHeaderTradeDelivery if one of them resolves to a value — an empty {} produced no children, so the container still went missing. Using the invoice's own issue date as ActualDeliveryDate (BT-72, optional) actually populates it.
Mustang's Phase 4 CI check (validate-einvoice.yml) caught this on its very first real run: @e-invoice-eu/core's UBL->CII conversion silently drops the whole Delivery container when cac:Delivery is absent from the UBL input, breaking CII's fixed Agreement/Delivery/Settlement element order and failing schema validation on every generated e-invoice (original, Storno, and Gutschrift alike).
Adds a Gitea Actions workflow that generates realistic e-invoice fixtures (multi-VAT-rate original invoice, Storno, Gutschrift) and validates them against the reference Mustang validator on every push, catching a broken EN16931 mapping before it reaches production instead of relying on manual spot-checks.
@e-invoice-eu/core pulls in Node-only dependencies (tmp/tmp-promise, for
a LibreOffice conversion path this package never actually takes) that
broke Next.js's client bundle the moment renderInvoiceEInvoice() was
re-exported from the main "." entry — that entry is reachable from a
Client Component (email-templates.ts's Live Preview import chain), so
the bundler tried pulling e-invoice-eu/core in client-side too.
New "@einfach-produktiv/invoicing/einvoice" subpath (package.json
"exports" map + src/einvoice/index.ts) holds only the e-invoice
generation functions — server-only code (API routes, Server Components,
Payload job handlers) imports from there instead. The main "." entry
keeps everything already browser-safe (computeTaxBreakdown, formatters,
InvoiceDocument for Live Preview's <PDFViewer>, the plain PDF renderers).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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>
Dropped by accident when bankDetails split into iban/bic — a bank name
has no fixed format to validate, so it stays free text, but it's still
useful context next to the account numbers in the invoice footer.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
InvoiceSeller.bankDetails becomes iban/bic (EN16931 wants discrete
PaymentMeans data, not a paragraph a human formatted by hand). The
footer's bank-details line also drops the misleading "(für Überweisung)"
qualifier — it was never actually conditional on the order's payment
method (only on whether the field was set at all), just worded as if it
were. Now shown whenever iban or bic is set, regardless of payment
method.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Consolidates code previously hand-duplicated between the einfach-produktiv
frontend and payload backend repos into one canonical implementation,
consumed as a git dependency by both instead of being kept in sync by eye.
While merging the two correction-invoice copies, found and fixed three
real drifts between them:
- variantName was silently dropped in the backend's emailed Stornorechnung/
Gutschrift, but present in the frontend's re-download copy
- the backend's correction-invoice footer wasn't position:fixed, unlike
the original invoice and the frontend's copy
- the backend used a numeric date format (03.07.2025) while the original
invoice and the frontend's copy both used a spelled-out month (03. Juli
2025) — a re-download didn't visually match what was emailed
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>