# einfach-produktiv — Frontend Next.js frontend for [einfach-produktiv.mk360.de](https://einfach-produktiv.mk360.de), Coolify-managed and deployed from this repo (`git.mk360.de/Marco/einfach-produktiv`). For the VPS-wide infrastructure this app runs on (Caddy, Coolify, Gitea, the shared Payload instance), see `~/dev/README.md` — this file only covers what's specific to this project. ## Stack - **Next.js 16.2.9** (App Router, `output: "standalone"` for a small Docker image — see `AGENTS.md` before touching anything version-specific, this Next.js release differs from older training-data conventions) - **React 19.2.4**, **Tailwind CSS 4**, **Motion** for animation - **`@react-pdf/renderer`** for invoice PDF generation (see "Invoice PDFs" below) — no headless-browser dependency - No local database — all editable content (and now orders/customer accounts) lives in the shared Payload CMS at `payload.mk360.de` (see below). Customer auth is Payload's own (a second, separate `auth: true` collection there, `customers` — not this app's own user store), bridged via an httpOnly session cookie this app mints itself; see "Orders & customer accounts" below. ## Getting started ```bash npm install npm run dev ``` Open [http://localhost:3000](http://localhost:3000). `npm run build && npm run start` reproduces the production build locally — do this before pushing, since Coolify builds with `--no-cache` and a failed build only surfaces there otherwise. **Environment variables:** `PAYLOAD_URL` (defaults to `https://payload.mk360.de` if unset, see `app/lib/payload.ts`). `PAYLOAD_PREVIEW_SECRET` (no safe default — required for Live Preview, see below; must match the value set on the Payload backend). `NEXT_PUBLIC_PAYLOAD_URL` (optional, defaults to the same `https://payload.mk360.de` — only needed if the client-side Live Preview components should ever point somewhere else). `DISCOUNT_SERVICE_SECRET` (no safe default — required for discount codes to validate/redeem at all; must match the value set on the Payload backend). `ORDER_SERVICE_SECRET` (no safe default — required for `/api/checkout` to persist an order in Payload at all, for `/api/account/verify-email` to look up a customer by their verification token, and for `getCompanySettings()` to read the `company-settings` collection (seller data for invoice PDFs); must match the value set on the Payload backend — also used there for the same header). `SMTP_USER`/`SMTP_PASSWORD` (no safe default — required for `app/lib/alertAdmin.ts`'s critical-failure alerts and resend-verification emails; **does not** need to match anything on the Payload side — this app's SMTP connection is deliberately independent, see the "Monitoring & alerting" section). Set in Coolify's app settings for production, not in a committed `.env`. ## Pages | Route | Purpose | |---|---| | `/` | Home — hero, product spotlight, tools grid, trust row | | `/shop` | Product grid (all active products for this tenant) | | `/blog`, `/blog/[slug]` | Blog overview + post detail | | `/cart` | Cart (localStorage-backed, see below) | | `/checkout` | Shipping + payment method selection, order summary | | `/bestellbestaetigung` | Order confirmation — reads the one-time snapshot `/checkout` wrote | | `/konto/login` | Customer login | | `/konto/bestellungen`, `/konto/bestellungen/[orderNumber]` | Order history + order detail (own orders only) | | `/konto/profil` | Profile/address editing + password change | | `/challenge` | "Mini-Challenge" tool | | `/todo-cards` | "Todo-Karten" tool | | `/newsletter` | Newsletter signup | | `/versand` | Shipping policy (timeframes, costs) | | `/impressum`, `/datenschutz`, `/agb`, `/widerruf` | Legal pages (from Payload, see `legal-pages` below) | ## Content backend: Payload CMS This app is one **tenant** in a shared, multi-tenant Payload instance also used by other projects on the same VPS (see `docker/payload/` in the infra repo). All content queries go through `app/lib/payload.ts`, which hardcodes `TENANT_SLUG = "einfach-produktiv"` and filters every request with `where[tenant.slug][equals]=einfach-produktiv` — the tenant field itself is injected automatically into every collection below by Payload's `multiTenantPlugin`, not defined in this app. `/api/products` is this app's own same-origin proxy route (`app/api/products/route.ts`) in front of `getProducts()` — used by client components (cart, related products) that need the catalog reactively, so they don't talk to Payload's API directly and reuse Next.js's fetch cache instead of an extra round trip. ### Collections used by this tenant Most reads are public (`access.read: () => true`); writes are always admin-gated in the Payload admin UI at `payload.mk360.de/admin`. A growing subset below is **not** public-read at all (`discount-codes`, `orders`, `customers`, `number-ranges`, `company-settings`) — each row says so and explains what does have access instead. | Collection (slug) | Used for | Key fields | |---|---|---| | `products` | `/shop` grid, homepage spotlight, cart, checkout | `name`, `slug` (cart item id — **not** Payload's numeric id, so existing localStorage carts survive catalog changes), `description`, `price`, `compareAtPrice` (optional strikethrough), `image`, `detailHref`, `sortOrder`, `active` (hides a product from the shop grid/spotlight/related-products only — cart/checkout/its own detail page still resolve it regardless, see Discount codes section below for the same opt-in-filtering principle), `spotlight` + `spotlightEyebrow`/`spotlightHeadline`/`spotlightText`/`spotlightImage` (homepage "Neu im Shop" section — falls back to `image` if no dedicated spotlight image is set; forced onto the sole active product when exactly 1 exists, see `getSpotlightProduct()`), `taxRatePercent` (optional per-product VAT override, see "Product bundles & per-product tax rates" below), `bundleItems` (optional — makes this product a bundle) | | `discount-codes` | Cart discount input (`/cart`, display-only on `/checkout`) | `code`, `type` (`percent`/`fixed`), `value`, `validFrom`/`validUntil`, `minOrderValue`, `maxRedemptions`, `redemptionCount` (server-incremented only), `active`. **Not public-read** — see Discount codes section below | | `posts` | `/blog`, `/blog/[slug]` | `title`, `slug`, `category` (relation to `categories`), `excerpt`, `thumbnail`, `content` (richText), `readTime` (auto-calculated on save from word count), `featured` (shown as the `/blog` hero post; most-recently-published wins if several are marked), `publishedAt`, `quoteLabel` (label + icon + underline shown next to every blockquote in `content`, default `"Merke dir:"` — leave empty to hide that framing, the blockquote text itself still renders), `relatedProduct` (optional relation to `products`, powers the "Passend dazu" card at the end of the post — leave empty to hide that card, or empty if the linked product has no `detailHref`) | | `categories` | Blog post categorization | `name`, `slug` (unique per tenant, not globally) | | `legal-pages` | `/impressum`, `/datenschutz`, `/agb`, `/widerruf` | `type` (`impressum`/`datenschutz`/`agb`/`widerruf`, one doc per type per tenant), `title`, `content` (richText), `attachment` (optional file, e.g. the Muster-Widerrufsformular PDF) | | `trust-badges` | Horizontal "Schneller Versand / Versandkostenfrei / Mit Liebe verpackt" row — shown on `/shop`, `/cart`, `/checkout`, `/widerruf`, `/agb`, 404 | `title`, `description` (supports `{{lieferzeit}}`/`{{kostenfreiab}}` placeholders, resolved by the frontend from `shipping-settings`/`shipping-methods` at render time — not by Payload itself), `icon`, `sortOrder` | | `cart-trust-badges` | Sidebar bullets on `/cart` (title only) and `/checkout` (title + description) — deliberately a separate collection from `trust-badges` so the two pages can't drift into showing different claims | `title`, `description`, `icon`, `sortOrder` | | `shipping-methods` | `/checkout` shipping selection | `title` (no day-range in the title text — that lives in `shipping-settings` now, keeping both in one title used to drift), `description`, `price`, `freeShippingThreshold` (per-method, optional — leave empty for a method that should never be free, e.g. Express), `active` (inactive methods are hidden, not shown disabled), `sortOrder` | | `shipping-settings` | Delivery-time disclosure shown on `/shop`, homepage spotlight, ToDo-Karten, `/cart`, `/checkout`, `/versand` (Art. 246a §1 Abs.1 Nr.8 EGBGB requires this visible before checkout) | `handlingDaysMin`/`handlingDaysMax` (processing time before it ships), `transitDaysMin`/`transitDaysMax` (carrier time) — the app derives the combined total itself. One row per tenant. | | `payment-methods` | `/checkout` payment selection | `title`, `icons` (array — e.g. 3 logos for "Kreditkarte"), `active`, `sortOrder` | | `werkzeuge-cards` | Homepage "Meine Werkzeuge" 3-card grid | `title`, `description`, `icon`, `ctaLabel`, `ctaHref`, `sortOrder` | | `testimonials` | Customer testimonial grids on `/todo-cards`, `/newsletter`, `/challenge` | `quote`, `name`, `role`, `avatar`, `page` (`todo-cards`/`newsletter`/`challenge` — which page's grid this appears in), `sortOrder`. The single-quote "photo band" testimonials on `/not-found` and `/bestellbestaetigung` are a different shape (no avatar/role) and stay hardcoded, not part of this collection. | | `media` | Shared upload collection backing every `image`/`icon`/`thumbnail`/`attachment` field above | `alt` (required for images), `title` (optional display name for download links) | | `orders` | Persisted checkout orders, `/konto/bestellungen*` | `orderNumber`, `invoiceNumber`/`invoiceIssuedAt`, `correctionInvoiceNumber`/`correctionInvoiceIssuedAt` (see "Invoice PDFs" below), `status` (`received`/`processing`/`shipped`/`delivered`/`cancelled`/`return_requested`/`returned` — the first 4 maintained by hand in the admin, no carrier API; the rest see "Order cancellation & returns"), `returnReason` (captured from the customer on a return request), full address/items (each with a snapshotted `taxRatePercent`/`bundleContents`)/totals at order time. **Not public-read** — created only via `ORDER_SERVICE_SECRET`, read/updated by admin or the order's own customer | | `customers` | Storefront accounts — register/login/order-history, a second `auth: true` collection separate from the Payload admin's own `users` login | `customerNumber`, `firstName`/`lastName`/`email`, one default address, `cart` (server-side mirror), `emailVerified` (non-blocking). **Not public-read** — see "Orders & customer accounts" below | | `number-ranges` | Admin-configurable prefix + running counter for customer/order/invoice/correction-invoice numbers — one row per tenant | `customerPrefix`/`customerNext`/`customerPadding`, `orderPrefix`/`orderNext`/`orderPadding`, `invoicePrefix`/`invoiceNext`/`invoicePadding`, `correctionInvoicePrefix`/`correctionInvoiceNext`/`correctionInvoicePadding` (Stornorechnung/Gutschrift — its own gapless sequence, not the same counter as `invoice*`, see "Invoice PDFs" below). **Admin-only**, no frontend read at all — internal to the two `beforeChange` hooks that assign these numbers | | `email-templates` | Editable subject/heading/body/footer for all 6 transactional emails this shop sends (see "Email templates & Live Preview" and "Status-change emails" below) | `type` (`order-confirmation`/`password-reset`/`order-shipped`/`order-cancelled`/`order-return-requested`/`order-returned`), `subject`, `heading`, `bodyText`, `footerText`. Public-read, has a Live Preview button | | `company-settings` | Structured business data for invoice PDFs *and* every email's legal footer (Anbieterkennzeichnung, see "Invoice PDFs" below) — one row per tenant, own **Company** admin group (not Commerce — this is business identity, not a storefront concern) | `sellerName`/`sellerStreet`/`sellerZip`/`sellerCity`/`sellerCountry`/`sellerEmail`, `vatId`, `taxRatePercent` (admin-editable, not hardcoded), `bankName`/`iban`/`bic` (`iban`/`bic` format-validated + uppercase-normalized; `bankName` stays free text — replaced a single free-text `bankDetails` field). **Not public-read** — admin or `ORDER_SERVICE_SECRET`. Has a Live Preview button — see "Company Settings & Live Preview" below | All of the above except `company-settings`, `media`, `users`, `tenants` are grouped in the Payload admin sidebar under **Commerce** (`products`, `discount-codes`, `orders`, `customers`, `number-ranges`, `email-templates`, `shipping-methods`, `shipping-settings`, `payment-methods`, `trust-badges`, `cart-trust-badges`) or **Content** (`posts`, `categories`, `legal-pages`, `werkzeuge-cards`, `testimonials`). `company-settings` sits in its own **Company** group; `media`/`users`/`tenants` sit under **Platform** — `users` and `tenants` are hidden from non-super-admins' nav entirely, and every tenant-scoped collection's own "assigned tenant" field is hidden from non-super-admins in the edit view (though not yet the list view's column — see the infra README's Payload CMS section for why that one's a harder fix). **What an admin can actually configure without a code deploy, at a glance:** product/shipping/payment catalog data and `active` toggles (incl. per-product tax-rate overrides and defining a product as a bundle), discount codes, all page content (blog/legal/testimonials/trust badges), delivery-time disclosure (`shipping-settings`), order/customer/invoice numbering schemes (`number-ranges`), all 6 email wordings (`email-templates`, with Live Preview), and invoice seller data, bank details, and VAT rate (`company-settings` — also what every email's legal footer is sourced from). What still requires a code change: adding a new *field* to any collection (needs a migration), payment processing itself (not built), and anything structural in `orders`/`customers` beyond `status`/`returnReason` and the profile fields already exposed on `/konto/profil`. Adding a *new field* to any collection above requires editing the collection file in `docker/payload/src/collections/` and a migration, which does need a deploy of the Payload service. ### Live Preview `posts`, `legal-pages`, and `testimonials` support Payload's Live Preview — opening a document in the Payload admin shows this app's real rendered page in an iframe, updating as you type, no save required. `email-templates` also has Live Preview, but against a synthetic page + sample data rather than one of these three's own real page — different enough to cover separately, see "Email templates & Live Preview" further below. - **`app/api/preview/route.ts`** — validates `PAYLOAD_PREVIEW_SECRET` (matching value required on the Payload side too, or this route 401s) and a `path`, enables Next.js Draft Mode, then redirects into the real page. This is the link Payload's `livePreview.url` resolvers point at (see `docker/payload/src/lib/previewUrl.ts` in the infra repo) — never the page directly. - Each supported page checks `draftMode().isEnabled` and renders a `"use client"` Live-Preview-aware component instead of the plain static one only when it's `true` — ordinary visitors are never in Draft Mode, so they always get the plain version with zero extra client JS: - `app/components/LiveRichText.tsx` — the 4 legal pages' body content (their headings/sidebars are hardcoded per page, not CMS-sourced, so `content` is the only field worth live-previewing there) - `app/blog/[slug]/components/LivePostContent.tsx` — title/excerpt/ thumbnail/body of a blog post (the author bio card, "Weiterlesen" card, and Footer stay static — they either aren't post-specific or are about a *different* post, not the one open in the admin) - `app/components/LiveTestimonialsGrid.tsx` — the one testimonial currently open in the admin, merged by `id` into the rest of that page's already-fetched grid (Live Preview is inherently single-document, but this page renders several at once) - All three use `@payloadcms/live-preview-react`'s `useLivePreview` hook and reuse the same mapping functions (`mapPayloadPost`, `mapPayloadTestimonial`, exported from `app/lib/payload.ts`) the plain server-side fetchers use, so the two code paths can't silently drift apart. - `app/lib/payload.ts` deliberately never imports `next/headers` itself — callers (the Server Component pages) call `draftMode()` themselves and pass the result in as a plain `{ draft: boolean }` option. Importing `next/headers` anywhere in that module breaks the production build the moment a `"use client"` component (which also needs this file's mapping functions/types) tries to bundle it — a real RSC-boundary regression hit once already when adding this feature. ## Discount codes Applied in `/cart` only (`/checkout` displays the already-applied result, no second input) — real server-side validation, not just a client-side check against Payload's public API, unlike most content on this site. - **Manual input field on `/cart`** (`CartContent.tsx`) — a text field + "Anwenden" button, shown whenever no code is currently applied *and* Payload actually has at least one active code right now (`hasActiveDiscountCode()` in `discountServer.ts`, `where[active][equals]=true`, ISR-cached 60s — no point offering an open field that could never validate against anything). Once applied, the field is replaced by a read-only result + "Entfernen" link, shown regardless of that check (an already-applied code, e.g. from an older session, still needs somewhere to display even if no *other* code happens to be active right now). `?code=SAVE10` on the `/cart` URL still auto-applies once on arrival (a `useEffect` reading `useSearchParams()` — requires `/cart`'s `page.tsx` to wrap `CartContent` in ``, a Next.js requirement for any `useSearchParams()` consumer) regardless of `hasActiveDiscountCode()` too, so a marketing link still works without the shopper typing anything; if that auto-apply fails, the error shows even without the manual field present. - **`app/lib/discountServer.ts`** (server-only, imported exclusively by the two route handlers below — never by a `"use client"` component, same reasoning as Live Preview's `next/headers` lesson above) talks to Payload's `discount-codes` collection using an `x-discount-service-secret` header (`DISCOUNT_SERVICE_SECRET`), since that collection isn't public-read. - **`app/api/discount/validate/route.ts`** — read-only check (active / validity window / minimum order value / remaining redemptions), called when a shopper clicks "Anwenden" in the cart. - **`app/api/discount/redeem/route.ts`** — re-validates, then increments the collection's `redemptionCount`. Called exactly once, from `CheckoutContent.tsx`'s `handlePurchase()`, right before the `OrderSnapshot` is written — a code that expired or hit its redemption cap between being applied in the cart and the actual purchase click fails the purchase with an inline error instead of silently completing. - **`app/lib/discount.ts`** mirrors `lib/cart.ts`'s exact `localStorage` + `useSyncExternalStore` pattern, so the applied code survives the `/cart` → `/checkout` navigation the same way the cart itself does. - **`app/lib/cartTotals.ts`** — `computeSubtotal()`/`computeCartTotals()`, factored out of what used to be independently-duplicated subtotal/ savings/total math in `CartContent.tsx`, `CheckoutContent.tsx`, and `BestellbestaetigungContent.tsx`; now also folds in the discount amount (clamped so a total can never go negative). `OrderSnapshot` persists the applied `discountCode`/`discountAmount` so the confirmation page shows what actually happened, not a fresh re-derivation. - Known, accepted limitation: the redeem route's read-then-increment isn't atomic against a true concurrent race on a capped code's very last redemption — not worth custom atomic SQL at this shop's traffic level. ## Cart & checkout - **Cart** (`app/lib/cart.ts`) is entirely client-side, stored in `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 `` above the button when their `variants` prop is non-empty, defaulting to the first *in-stock* variant. All five call sites already fetch the full product server-side (`getProducts()`/`getProductBySlug()`/`getSpotlightProduct()`), so `product.variants` and `product.outOfStock` are simply passed straight through — no separate data-fetch needed for `AddToCartButton`'s two pages. **Out-of-stock UI**: `app/lib/payload.ts`'s `isOutOfStock()` derives `Product.outOfStock` (and each `variants[].outOfStock`) from `trackInventory`/`stock`/`allowBackorder` — true only when inventory is tracked, backorders aren't allowed, and `stock <= 0`. Both add-to-cart buttons disable themselves and show "Ausverkauft" for whichever variant is currently selected (or the plain product, when there are no variants); `ProductGrid.tsx` additionally shows an "Ausverkauft" badge (replacing any discount/low-stock badge — a sold-out product has nothing else useful to show) once *every* variant of a product is out — one sold-out variant among several just reads as such in the picker itself, not as a misleading blanket badge. **Low-stock warning**: `app/lib/payload.ts`'s `isLowStock()` derives `Product.lowStock` (and each `variants[].lowStock`) from `trackInventory`/ `stock`/`lowStockThreshold` the same way `outOfStock` is derived — true only when inventory is tracked, stock is above zero (out-of-stock has its own distinct badge, the two never combine), and at/below the product's own `lowStockThreshold`. Neither raw `stock` nor `lowStockThreshold` is exposed in the public `Product` type, only this derived boolean — the public API has no reason to leak exact counts. Shown as a "Nur noch wenige verfügbar" **text line** (`text-warning`, the same `--color-warning` token in `globals.css`, distinct from the brand-colored discount badge) next to the price on `ProductGrid.tsx`/`ProductSpotlight.tsx`/`RelatedProducts.tsx`/ todo-cards' `TodoKartenHero.tsx` and `Pricing.tsx` (both — the page has two independent purchase CTAs, hero and pricing panel, each with their own price/delivery block) and — variant-specific, not "any variant low" — under the product name in `CartContent.tsx`'s own cart line items, plus the existing `"(nur noch wenige)"` variant-select suffix in `AddToCartButton`/`AddToCartInlineButton`. The image-overlaid top-left pill (`position: absolute`, outside layout flow) now shows only Ausverkauft/discount — low-stock moved off the image into text on request, since a shopper scanning product photos for "-20%" style badges reads a long low-stock sentence pinned to the image as clutter, and a customer already in the cart with that product had no low-stock signal there at all before this. The earlier version of this text line (removed once, see git history) broke equal-height card alignment in `ProductGrid.tsx`/ `RelatedProducts.tsx` by only rendering when `lowStock` was true, so cards with/without the line ended up different heights; this version always renders the line's slot (`min-h-[1.05rem]`, empty when not low-stock) so every card in a row reserves the same space regardless of state — `ProductGrid.tsx` additionally still has its `flex-1` spacer pinning the add-to-cart button to the same Y as before, so the reserved height is belt-and-suspenders there, but load-bearing in `RelatedProducts.tsx`, which has no such spacer. Only "Ausverkauft" still wins outright over the discount pill, since it replaces it. **Pricing**: `app/lib/cartTotals.ts`'s `effectivePrice(entry, product)` — a selected variant's `priceOverride` wins over the base `product.price` (falling back to it when unset or no variant selected). Every cart/ checkout/order-confirmation total (`computeSubtotal`, `computeCartTotals`, and each page's own per-line price display in `CartContent.tsx`, `CheckoutContent.tsx`, `BestellbestaetigungContent.tsx`) goes through this instead of reading `product.price` directly. The checkout route (`/api/checkout/route.ts`) re-validates the requested variant server-side too — same "never trust the client" reasoning as price re-derivation generally: a `line.variant` naming something that doesn't exist on that product (removed, or a tampered request) fails the whole checkout rather than silently falling back to the base price. It also re-checks stock at that same point — depth-in-defense, not just the disabled button UI above — rejecting the order when the resolved product/variant has `trackInventory` on, `allowBackorder` off, and less `stock` than the requested quantity. **Snapshotting**: `orders.items[].variantName` captures which variant was picked at order time (same "snapshot, not a live relationship" reasoning as `bundleContents`) — shown as a parenthetical next to the product name on the order-confirmation email, both invoice PDF types, and the order-detail page. The server-side cart mirror (`Customers.cart[].variantName`, synced via `/api/account/cart`) carries the same field so a variant selection survives a login/logout cycle, not just the current session. See the Payload README's "Inventory & product variants" section for the backend data model (`products.variants`, stock bookkeeping) this all builds on. ### Tracking numbers `orders.carrier`/`trackingNumber` (admin-entered in Payload, no carrier API) show as a clickable link on `/konto/bestellungen/[orderNumber]` when set — `app/lib/tracking.ts`'s `buildTrackingUrl(carrier, trackingNumber)` mirrors the Payload backend's own copy of this file byte-for-byte close (same carrier set/URL patterns, kept in sync by hand, no shared package between the two deployments) so the link an admin sees generated in the `order-shipped` email matches exactly what a customer sees here. Falls back to plain (non-linked) text for `carrier: 'other'`, which has no known URL pattern. ### Product bundles & per-product tax rates Both resolved server-side in `/api/checkout/route.ts`, at the same point prices are already being re-derived from live Payload data (never trusted from the client): - **Tax rate**: `product.taxRatePercent ?? companySettings.taxRatePercent` — a product's own override if set, otherwise the tenant-wide default from `company-settings` (fetched alongside the product catalog, `Promise.all([fetchProductsBySlug(), getCompanySettings()])`). Snapshotted onto `orders.items[].taxRatePercent` at order creation — see the Payload README's "Per-product tax rates" section for why this has to be a snapshot, not a live lookup. - **Bundles**: `describeBundleContents()` resolves a product's `bundleItems` (Payload relationship, populated via `fetchProductsBySlug()`'s `depth: 2` fetch — one level deeper than the `depth: 1` `image` alone needs, since `bundleItems.product` is a relationship nested inside an array field) into a plain string like `"2× ToDo-Karten, 1× Wochenplaner"`, snapshotted onto `orders.items[].bundleContents`. A bundle is otherwise just a regular product everywhere else in this app — same cart/checkout/ pricing code path, no special-casing needed, since it's just a product with an extra field (see the Payload README's "Product bundles" section for why it's modeled that way instead of a separate collection). ## Invoice PDFs Generated **synchronously at checkout** and attached to the order confirmation email — not just an on-demand download — per an explicit product decision that a customer should always have the invoice in their inbox, not only in `/konto/bestellungen`. - **`@einfach-produktiv/invoicing`** — a small standalone package (`git.mk360.de/Marco/einfach-produktiv-invoicing`, public repo, no secrets in it) holding every invoice/correction-invoice renderer plus `computeTaxBreakdown()` (see "VAT display" above) and shared formatters, consumed here **and** by the Payload backend as a git dependency (`"@einfach-produktiv/invoicing": "git+https://git.mk360.de/Marco/einfach-produktiv-invoicing.git#main"` in `package.json`). Ships raw TS/TSX source, no build step of its own — this app's `next.config.ts` lists it under `transpilePackages` so this app's own bundler compiles it, same as first-party code, the same pattern a monorepo tool like Turborepo uses for internal packages without actually needing a monorepo. Before this package existed (2026-07-23), the correction-invoice renderer was hand-duplicated between this repo and the backend, "kept in sync by eye" — that already caused three real, customer-visible drifts (a silently dropped `variantName`, a footer that wasn't `position: fixed`, a numeric vs. spelled-out date format) before the dedup, see that package's own README for the specifics. - **`InvoiceDocument`** — a `@react-pdf/renderer` `Document`, not HTML-to-PDF or a headless browser (Puppeteer/Chromium would be a heavier footprint on a VPS already running several other containers). Built-in Helvetica rather than a registered web font — this renders inside a fire-and-forget checkout step, and a font-fetch failure there would be one more way to silently lose the attachment for no real design benefit; brand color/spacing still carries the visual identity via `StyleSheet`. - **Layout**: a header separated by a bold brand-colored rule (not a filled color band — a plain line reads cleaner than a solid block of 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, 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). 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 lowercase — same normalization `discount-codes.code` already used), `bankName` stays free text since there's no fixed format to validate a bank's display name against. Not a single free-text `bankDetails` textarea anymore, as of the 2026-07-23 e-invoicing migration's Phase 2 — EN16931 wants discrete PaymentMeans data. When `iban` or `bic` is set, the footer prints "Bankverbindung: [Bankname ·] IBAN … · BIC …" — **always**, regardless of the order's payment method. It used to say "Bankverbindung (für Überweisung): …", which read as conditional on paying by bank transfer specifically, but never actually was (the display was only ever gated on whether the 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). - **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 `getProductImagesByIds()` (see "VAT display" above) for the on-demand re-download route, since a stored order item only snapshots a numeric product id, not an image URL. - **Bundle contents**: an item row for a bundle product also shows the small muted `bundleContents` sub-line snapshotted at order time (see the Payload README's "Product bundles" section). - **Shipping address**: when `order.hasDifferentShippingAddress` is set (see "Optional deviating shipping address" above), a third "Lieferadresse" address block joins Von/An (three ~30%-width columns 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 entrypoints every caller below goes through; both just call straight into `@einfach-produktiv/invoicing`'s `renderInvoicePdf()`/ `renderCorrectionInvoicePdf()`. `seller` (`company-settings` data) is passed in rather than fetched inside these functions, so a caller that also needs it for something else in the same request (e.g. `orderEmail.ts`'s legal email footer, see "Legal footer (Anbieterkennzeichnung) on every email" below) fetches it once via `getSellerForInvoice()`, not twice. - **Original invoice — called from two places, same render function:** `app/lib/orderEmail.ts` (checkout attachment — a PDF-generation failure here does **not** sink the confirmation email itself, it just sends without the attachment and alerts admin) and `app/api/account/orders/[orderNumber]/invoice/route.ts` (GET, customer's own order only, "Rechnung herunterladen" on `/konto/bestellungen/[orderNumber]`) — a re-download always matches what was originally emailed, since `invoiceNumber`/`invoiceIssuedAt` are assigned exactly once, server-side, at order creation (Payload's `orders.ts` `beforeChange` hook — see the Payload README) and never regenerated. - **Correction invoice (Stornorechnung/Gutschrift) — same "no file storage" approach.** The *real* document is generated once, Payload-side, the moment an order reaches `cancelled`/`returned` (see the Payload README's "How a Stornorechnung/Gutschrift relates to the original invoice" section for the full legal/mechanical reasoning) and attached to that status email. This repo's own `/konto/bestellungen/[orderNumber]` "Stornorechnung/Gutschrift herunterladen" button (`app/api/account/orders/[orderNumber]/correction-invoice/route.ts`) calls the exact same `renderCorrectionInvoicePdf()` from `@einfach-produktiv/invoicing` the backend used to generate the original — not a ported approximation anymore (that used to be a separate, hand-duplicated copy; see the shared package's README) — so a re-download is now structurally guaranteed to match what was emailed, not just guaranteed by careful manual syncing. No PDF is ever persisted to disk/S3/Media: `correctionInvoiceNumber`/`correctionInvoiceIssuedAt` are immutable once set (Payload's `beforeChange` hook), so re-rendering from the order's own stored data always reproduces the identical document — the underlying data is already durable in Postgres, and deterministic regeneration needs no cleanup or storage cost, same reasoning already applied to the original invoice. Also has product thumbnails (same resolution approach as the original invoice) and, for a Stornorechnung specifically, an explicit "Versand" summary line — it was previously only folded silently into the tax-rate groups' scaled gross amounts, with no line stating how much of the reversed total was shipping. A Gutschrift never shows this line, since it never reverses shipping in the first place (see the reasoning below). - **Numbering**: `invoiceNumber` and `correctionInvoiceNumber` each come from their own gapless counter on Payload's `number-ranges` collection (`invoicePrefix`/`Next`/`Padding` vs. `correctionInvoicePrefix`/`Next`/ `Padding` — a Stornorechnung/Gutschrift used to draw from the *same* counter as ordinary invoices; split into its own sequence as of 2026-07-23). Both are assigned via a single atomic `UPDATE ... RETURNING` against Postgres (Payload's backend `numberRange.ts`), not a read-then-write across two separate calls — see the Payload README's "Number ranges" section for why that distinction actually matters for §14 UStG. - **`company-settings`** (Payload collection, structured seller data — name/address/`vatId`/`taxRatePercent`/`bankName`/`iban`/`bic`) is fetched via `getCompanySettings()`/`getSellerForInvoice()`, authenticated the same way as order creation (`x-order-service-secret` header, `ORDER_SERVICE_SECRET`) since it's not public-read (holds bank details) but does need to be reachable from this app's own server-side code, not just from inside Payload's admin. **Currently seeded with placeholder data** ("Björn Wendt", "Musterstraße 12", USt-IdNr. "DE123456789", a placeholder IBAN/BIC) mirroring the Impressum's own placeholder content — real business details need to be entered in the Payload admin before an invoice generated from this is legally valid. `taxRatePercent` is deliberately a configurable admin field, not a hardcoded `19` in the renderer, per an explicit decision to keep the VAT rate editable without a code change. - §14 UStG line items: seller/buyer address, invoice number + date, order 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 — 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. - **VAT-ID *validity* and the exemption *decision* are two separate questions (fixed 2026-07-23)** — a first version only ever called VIES when the destination already qualified for the exemption (Österreich), so a garbage VAT ID on a domestic order (e.g. `"ED123456789"` — not even a real country code) sailed through with just a format check, and a genuinely valid German/Swiss VAT ID got no confirmation either. Now `app/api/checkout/validate-vat/route.ts`/`app/api/checkout/route.ts` check any format-valid VAT ID against VIES regardless of destination (data quality — worth knowing whether it's real at all, same reasoning as `company-settings.vatId`'s own check below) — the exemption itself still only applies when the destination is *also* Österreich. A validated German VAT ID never zero-rates a domestic sale, no matter how real it is. - **Checkout UX**: the USt-IdNr. field's blur gives instant feedback for any country — "✓ USt-IdNr. bestätigt" (plus "— Lieferung wird steuerfrei berechnet." only when the destination actually qualifies) 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 on the exemption**: normal VAT applies, never a guessed exemption (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. `vatIdValidatedAt` is set for *any* VIES-confirmed VAT ID (data-quality audit trail), independently of whether `vatExempt` is also true — 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. - **Every fixed-length numeric field now hard-caps input length too** (fixed 2026-07-23) — PLZ (`maxLength` = the selected country's own digit count), USt-IdNr. (`14`), Packstationnummer (`3`)/Postnummer (`10`, on the backend) all already had `pattern`/`validate` format checks, but nothing stopped the browser from accepting more characters than could ever pass. Same "as simple/state-of-the-art as possible" input-quality pass as the blur-validation fix above. - **`/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 `company-settings` has a Live Preview button too, like `email-templates` — but instead of an HTML page, it's a **live, in-browser rendered PDF**: opening the document in the Payload admin shows the actual invoice layout updating as the admin edits `sellerName`/address/`taxRatePercent`/ `bankName`/`iban`/`bic`, no save required. - **`app/company-settings-preview/page.tsx`** + **`components/LiveCompanySettingsPreviewClient.tsx`** — same entrypoint pattern as `/email-preview/[type]` (Draft Mode via `app/api/preview/route.ts`), but no `[type]` segment — there's only one kind of document here, unlike the 6 email types. **Actually gated on `draftMode().isEnabled`** (calls `notFound()` otherwise), unlike `/email-preview` — this data includes a real bank IBAN/address once filled in, not just marketing email copy, so it must not render for an unauthenticated visitor who happens to find the URL. - **`@react-pdf/renderer`'s ``** (not `renderToBuffer()`) is what makes this a *live* preview rather than a static download — it's a browser-only component that renders a `Document` straight into an embedded PDF viewer `