Document today's features in the README

Stock-capped add-to-cart, B2B checkout fields, VAT exemption
(innergemeinschaftliche Lieferung + VIES), blur-time checkout
validation, and the invoice-layout/e-invoicing status fixes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Marco
2026-07-23 19:19:36 +00:00
parent 6802636d1d
commit ba4d7b443f
+140 -31
View File
@@ -246,6 +246,19 @@ check against Payload's public API, unlike most content on this site.
`localStorage` under `ep_cart`, keyed by each product's `slug`. Still the
source of truth while browsing — the server-side mirror (see below) only
exists to carry a logged-in customer's cart across devices/browsers.
- **Add-to-cart is capped at actual remaining stock (fixed 2026-07-23)** —
previously only checked at checkout, so a shopper could add more of a
product than was actually in stock and only find out at the last step.
`Product`/its variants now carry a real `maxQty` (`app/lib/payload.ts`'s
`mapPayloadProduct()``null` when unlimited, i.e. backorder allowed or
inventory untracked; a deliberate, narrow exception to that function's
own "the public API has no reason to leak exact stock counts" comment,
since the add-to-cart controls genuinely need it). `AddToCartButton`/
`AddToCartInlineButton` disable (and show "Maximale Menge im Warenkorb")
once the cart already holds that many; `/cart`'s quantity `<select>`
caps its option range the same way instead of always offering a flat 19.
`api/checkout/route.ts`'s own stock re-check stays as the authoritative
server-side guard regardless.
- **`app/cart/components/RelatedProducts.tsx`** only ever suggests products
not already in the cart — it stopped falling back to re-suggesting an
already-in-cart product just to pad the grid out to 3 cards, so with a
@@ -525,21 +538,25 @@ inbox, not only in `/konto/bestellungen`.
color across the top) with the wordmark + "RECHNUNG" label, seller/buyer
addresses, invoice number/date/order-reference/USt-IdNr. shown as small
bordered "meta boxes" rather than a plain text row, a rounded/bordered
item table with alternating row shading, and a shaded summary card for
the totals — deliberately closer to the site's own card-based UI
language than a generic invoice template. The footer is pinned to the
bottom of the page (`position: absolute` + react-pdf's `fixed` prop),
not just wherever the content flow happens to end.
- **"Bereits beglichen" badge**: shown next to the meta boxes whenever
`order.paymentMethodTitle` is anything other than `"Überweisung"` (bank
transfer) — Kreditkarte and PayPal both settle at checkout, so the
invoice says so explicitly (`isPaidImmediately()`, in the shared
package's `invoicePdf.tsx` — "Überweisung" is the one method named
explicitly as the exception, rather than hardcoding a list of
item table, and a shaded summary card for the totals — deliberately
closer to the site's own card-based UI language than a generic invoice
template. The footer is pinned to the bottom of the page
(`position: absolute` + react-pdf's `fixed` prop), not just wherever the
content flow happens to end. Item rows share one uniform tinted
background now (fixed 2026-07-23) — no more alternating white/tinted
zebra striping.
- **"Bereits beglichen" confirmation**: shown next to the summary card
whenever `order.paymentMethodTitle` is anything other than
`"Überweisung"` (bank transfer) — Kreditkarte and PayPal both settle at
checkout, so the invoice says so explicitly (`isPaidImmediately()`, in
the shared package's `invoicePdf.tsx` — "Überweisung" is the one method
named explicitly as the exception, rather than hardcoding a list of
"immediate" titles that would need updating every time a new payment
method is added in Payload). This one genuinely is conditional on the
order's own payment method — unlike bank details below, which just used
to be worded as if it were.
method is added in Payload). Plain green text, not a tinted pill/badge
box (fixed 2026-07-23 — a colored background read as too heavy for what's
just a status note). This one genuinely is conditional on the order's
own payment method — unlike bank details below, which just used to be
worded as if it were.
- **Bank details**: `company-settings.bankName`/`.iban`/`.bic``iban`/
`bic` structured and independently format-validated (uppercased/trimmed
on save too, so "de123..." doesn't fail validation just for being
@@ -556,18 +573,34 @@ inbox, not only in `/konto/bestellungen`.
field was filled in) — the misleading wording got fixed instead of
adding the behavior it implied, since a card/PayPal customer might
still want the seller's bank details for other reasons (e.g. a refund).
- **Per-tax-rate summary**: line items are grouped by their own
snapshotted `taxRatePercent` (see the Payload README's "Per-product tax
rates" section) via the shared package's `computeTaxBreakdown()` (see
"VAT display" above) and the summary prints one plain "Netto" / "zzgl. X%
MwSt." pair per distinct rate actually present in that order — no `%`
after "Netto" itself anymore, since the rate is already stated on the
"zzgl." line directly below it. A plain single pair in the common case
(one rate for the whole order), a real multi-rate breakdown the moment a
product with a different rate is involved. The order-level discount/
shipping are distributed proportionally across each rate group before
computing net/tax, so the grouped totals still reconcile exactly to
`order.total`.
- **Summary layout — a genuinely additive chain (fixed 2026-07-23).** The
summary card now reads Zwischensumme → Rabatt (if any) → Versand → a
divider → Gesamt, using the order's own raw `subtotal`/`discountAmount`/
`shippingCost`/`total` fields directly — every row above the divider
actually sums to the number below it. A previous version showed
"Netto"/"zzgl. X% MwSt." rows computed via `computeTaxBreakdown()`,
which distributes discount/shipping proportionally across each tax-rate
group *before* computing net/tax (correct for the tax math itself, since
ancillary costs are legally apportioned across rates) — but the same
layout **also** printed Rabatt/Versand as their own separate rows on top,
so the visible rows never actually summed to the printed Gesamt (off by
exactly the shipping/discount amount — caught against a real production
order, `#EP-0006-ZDX6`'s Stornorechnung, where the gap was concrete and
reproducible, not a rounding nit). The per-rate breakdown still exists,
just relocated **below** Gesamt as an "enthält X% MwSt.: Y €" annotation
(one line per distinct rate, informational — not part of the additive
stack above it), the same "contained within, not an extra deduction"
framing `VatBreakdown.tsx` already used on `/cart`/`/checkout` (see "VAT
display" above). Applies to both the original invoice and its Storno/
Gutschrift, which has its own equivalent chain (Zwischensumme → the
discount reversal, "Rabatt (entfällt)", a positive add-back since the
original discount no longer applies once everything's undone → Versand →
Gesamt; a Gutschrift shows Gesamt alone, since it never reverses
shipping/discount in the first place — see the Payload README's "How a
Stornorechnung/Gutschrift relates to the original invoice"). When the
order is VAT-exempt (see "VAT exemption" below), this annotation becomes
"Steuerfreie innergemeinschaftliche Lieferung (§4 Nr. 1b UStG)" instead —
"enthält 0% MwSt.: 0,00 €" would be a meaningless thing to print.
- **Product thumbnails**: each item row shows a small product image —
resolved from the order-confirmation data's already-available
`imageUrl` for the checkout-time attachment, or via
@@ -583,6 +616,11 @@ inbox, not only in `/konto/bestellungen`.
instead of two ~45%-width ones) — otherwise unchanged, two columns as
before. `USt-IdNr.` no longer repeats in a header meta box — it already
lives in the footer, printing it twice was redundant.
- **Buyer B2B fields**: when the order has a `companyName`/`vatId` (see
"B2B checkout fields" below), the "An" block shows `companyName` as its
own line above the contact person's name, and `vatId` as its own line
below the address (labelled "USt-IdNr. …") — the buyer-side counterpart
to the seller's own VAT ID already shown in the footer.
- **`app/lib/invoiceData.ts`** (still local to this repo — a thin
server-only wrapper, not part of the shared package) — `generateInvoicePdf(order, seller)` /
`generateCorrectionInvoicePdf(kind, order, seller)`, the render
@@ -658,11 +696,82 @@ inbox, not only in `/konto/bestellungen`.
reference, per-item quantity/price, net subtotal per rate, tax rate +
amount per rate, gross total — all on the PDF, not just the summary the
confirmation email's HTML already shows.
- **E-invoicing (ZUGFeRD/EN16931) migration**in progress as of
2026-07-23. Everything above is still a plain PDF; the shared package's
README and the memory notes behind this project track the phased plan
(hybrid PDF/A-3 + embedded XML via `@e-invoice-eu/core`, applied to
*every* invoice, not just B2B) — not yet built as of this writing.
- **E-invoicing (ZUGFeRD/EN16931) migration — done, live in production
since 2026-07-23.** Every invoice generated above is actually a
Factur-X-EN16931 hybrid PDF/A-3 (the same visual PDF, plus an embedded
machine-readable `factur-x.xml`), for every order, not just B2B — see the
shared package's own README for the full phased build (`@e-invoice-eu/core`,
atomic invoice numbering, a self-hosted Mustang-CLI CI pipeline that
validates every push to that package against real EN16931/PDF-A-3
conformance rules).
### B2B checkout fields
Split out from the e-invoicing migration, built 2026-07-23 once that
shipped. Optional "Firma"/"USt-IdNr." fields sit right under Vorname/
Nachname in `/checkout`'s Card 1 — neither is required just because the
other is filled in (a sole proprietor might give a VAT ID with no separate
company name, and vice versa). Format-validated client- and server-side
(`app/lib/vatId.ts`'s `isValidVatId()`/`normalizeVatId()`, same EU-format
regex as `company-settings.vatId` on the backend), persisted through
`checkoutDraft.ts` like every other Card 1 field, and saved as a
`Customers` profile default (`/konto/profil`) that prefills future
checkouts — `Orders` keeps its own independent snapshot regardless, same
"a later profile edit must never rewrite what an order actually said"
reasoning as every other snapshot field. Shown on the invoice PDF's "An"
block (see "Invoice PDFs" above) and threaded through both invoice
download routes and the correction-invoice email.
### VAT exemption (innergemeinschaftliche Lieferung)
Built 2026-07-23. A cross-border EU B2B sale — this checkout's only
cross-border option is Österreich (Deutschland is domestic, Schweiz a
non-EU export with its own different exemption) — to a buyer whose VAT ID
a live lookup against the EU's public VIES service actually confirms is
registered gets zero-rated per §4 Nr. 1b UStG. Deliberately *not* based on
format-validity alone: an unverified VAT ID zero-rating an invoice is a
real compliance risk (if it later turns out unregistered, the seller
retroactively owes the VAT itself).
- **`app/lib/vies.ts`** — calls the European Commission's public VIES REST
API (`POST .../check-vat-number`, confirmed live 2026-07-23) directly,
server-side only.
- **`app/lib/vatExemption.ts`** — `destinationCountry()` picks the actual
place the goods ship to (the shipping-address override's country when
set, billing country otherwise — the exemption depends on where the
goods physically move, not necessarily the invoice address);
`computeExemptTotals()` de-grosses every item's price and the shipping
cost from their normal VAT-inclusive catalog figures to net, since the
whole point of the exemption is that the buyer pays less, not that this
shop quietly keeps the VAT portion as extra margin.
- **Checkout UX**: `app/api/checkout/validate-vat/route.ts` gives instant
feedback on the USt-IdNr. field's blur (only once the destination
actually qualifies — no point calling VIES for a Deutschland/Schweiz
order) — "✓ Bestätigt" flips the sidebar total to the exempt (de-grossed)
figures live, as a preview. `app/api/checkout/route.ts` re-runs the exact
same VIES check server-side at submit time regardless, as the actual
source of truth — the client-side result is never trusted. **VIES being
unreachable fails closed**: normal VAT applies, never a guessed
exemption — the checkout-time check is the one place in this feature
that fails closed (contrast the Payload backend's own
`company-settings.vatId` VIES check, which fails open, since that one
only needs to catch an admin's data-entry typo, not decide a tax rate).
- **Every other checkout field is now blur-validated too** (fixed
2026-07-23, alongside this feature) — inline red error text appears the
moment a field loses focus (required fields, email format, PLZ digit
count per country, Packstation/Postnummer digit count), not only when
the browser's native `pattern`/`required` validation kicks in at submit.
The native attributes stay in place as a fallback for any field somehow
never blurred (e.g. autofill).
- **Persistence**: `Orders.vatExempt`/`vatIdValidatedAt` (Payload backend)
record the outcome, decided once server-side, never editable in the
admin — see the Payload README's "B2B checkout & VAT exemption" section
for the full field/audit-trail reasoning and the invoice PDF/EN16931 XML
side of this feature.
- **`/bestellbestaetigung`** mirrors the same exempt-totals branch from the
persisted `OrderSnapshot.vatExempt` flag (it otherwise re-derives totals
live from the current catalog, which would show the wrong, VAT-inclusive
figures for an exempt order).
### Company Settings & Live Preview