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:
@@ -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 1–9.
|
||||
`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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user