'back-in-stock' was added to the Payload-side email-templates type but never wired into this repo's own EmailTemplateType union or the Live Preview page's VALID_TYPES/fallback-heading maps — every other type is registered in three places, this one was only in one, so opening its Live Preview 404'd. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
einfach-produktiv — Frontend
Next.js frontend for 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 — seeAGENTS.mdbefore 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/rendererfor 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, separateauth: truecollection there,customers— not this app's own user store), bridged via an httpOnly session cookie this app mints itself; see "Orders & customer accounts" below.
Shared modules
@einfach-produktiv/invoicing (a separate repo, consumed as a git
dependency — see "Invoice PDFs" below) is where framework-agnostic logic
shared with the Payload backend lives, so it's written once instead of
maintained as two independently-drifting copies. Beyond invoice/e-invoice
PDF generation and tax-breakdown math, it also holds VIES VAT-ID checking,
VAT-ID format validation, PLZ digit-count validation, and shipping-carrier
tracking-label logic — all previously hand-duplicated app/lib/*.ts files
in this repo, now imported from the shared package instead. See that
package's own README for what's in it and why each piece was unified.
Getting started
npm install
npm run dev
Open 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). STRIPE_SECRET_KEY,
STRIPE_WEBHOOK_SECRET, NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY,
PAYMENT_WEBHOOK_SECRET, PAYMENT_TEST_MODE — see "Payment processing
(Stripe)" below. BREVO_API_KEY, BREVO_LIST_ID,
BREVO_DOUBLE_OPTIN_TEMPLATE_ID (no safe default — required for
newsletter signups to trigger a confirmation email at all),
BREVO_DOI_REDIRECT_URL (optional, defaults to /newsletter-confirmed) — see
"Newsletter signup" below. 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, /konto/registrieren |
Customer login / standalone registration |
/konto/bestellungen, /konto/bestellungen/[orderNumber] |
Order history + order detail (own orders only) |
/konto/profil |
Profile/address editing + password change |
/konto/merkliste |
Wishlist (when wishlistEnabled, see "Feature toggles" below) |
/challenge |
"Mini-Challenge" tool |
/todo-cards |
"Todo-Karten" tool |
/die-sieben |
"Die Sieben" — free monthly ritual (evergreen concept page only; the WordPress original's monthly-changing edition/archive/template has no content model in this codebase yet, see app/die-sieben/page.tsx's own comment) |
/newsletter |
Newsletter signup |
/versand |
Shipping policy (timeframes, costs) |
/impressum, /datenschutz, /agb, /widerruf |
Legal pages (from Payload, see legal-pages below) |
Responsive design
The site uses a fluid, clamp()-based responsive system (app/lib/fluid.ts,
tokens hand-authored into app/globals.css's @theme/:root blocks) that
scales continuously between a 640px floor and a 1440px ceiling — most
components need no structural breakpoint at all. Where a genuine
structural reflow is needed (grid-to-stack, flex-direction switches), the
site uses a single sm: (640px) breakpoint, with a documented handful of
exceptions still gated on lg: (1024px) for real fixed-width content:
the Cart/Checkout two-column split, the legal-page table-of-contents
sidebar, the newsletter/todo-cards hero and benefits sections,
Footer.tsx's legal-links row, and TrustRow.tsx.
Below lg: (1024px), the Navbar's hamburger opens a fullscreen nav panel
(circular reveal animation, a sibling of <header> rather than a child so
the header's own backdrop-blur doesn't break the panel's
fixed-to-viewport positioning) instead of an inline dropdown; the
wishlist/search icons are hidden below sm: (640px) so Account + Cart
stay the only always-visible icons on narrow phones (see "Feature
toggles" below). Navbar.tsx itself keeps its own separate md:/lg:
3-tier scheme for its nav links — horizontal nav-link overflow is a
different failure mode than the vertical grid/flex reflows the rest of
the site handles with sm:.
SEO & structured data
Every page can carry real per-page metadata — /blog/[slug]'s
generateMetadata() reads a post's own seoTitle/seoDescription/seoImage
(see the posts collection below); app/layout.tsx's generateMetadata()
reads getSeoSettings() (ISR-cached, backed by company-settings) for
the site-wide fallback.
JSON-LD structured data (app/lib/structuredData.ts, no headless
external validation step, just direct grepping of the rendered
application/ld+json script) is emitted for the pages Google gives rich
results for: an Organization schema site-wide in the root layout (from
getCompanySettings(), excluding sensitive fields like iban/bic,
with a stable @id that other schemas link back to rather than repeating
the full object), a Product schema on /todo-cards (the one page with
its own dedicated single-product URL — deliberately not on /shop's
grid, since most products there have no individual detail page to point
a Product's url at), and a BlogPosting schema on every
/blog/[slug] page.
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), categories (optional, hasMany relation to the backend's product-categories collection — own collection, not shared with blog's categories, so a "Karten" product category and a same-named blog category never collide; not required, so an untagged product still shows everywhere, just matches every /shop category filter instead of being excluded — see Product.categories' own comment in lib/payload.ts), 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), sku (optional, even without variants — variants carry their own; snapshotted onto each order item at checkout and shown as "Art.-Nr." on the invoice PDF, the order-confirmation email, and /konto/bestellungen/[orderNumber]) |
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, categories (hasMany relation to categories — a post can have several, joined with ", " wherever rendered), excerpt, thumbnail, content (richText, supports custom Lexical blocks — Bild/Bildergalerie/Video/Zitat — rendered via app/components/RichText.tsx's @payloadcms/richtext-lexical/react renderer with custom JSXConverters), 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), seoTitle/seoDescription/seoImage (each falls back to title/excerpt/thumbnail when empty, consumed by /blog/[slug]'s generateMetadata()) |
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). Each page's "Stand: …" line is derived from this doc's own updatedAt (formatMonthYear() in app/lib/format.ts), not a hand-typed string — it updates automatically the moment an admin edits the content, no separate field to remember |
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 10 transactional emails this shop sends (see "Email templates & Live Preview" and "Status-change emails" below) | type (order-confirmation/password-reset/payment-method-switched/order-shipped/order-cancelled/order-return-requested/order-returned/order-tracking-added/order-tracking-corrected/order-delivered), 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 10 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— validatesPAYLOAD_PREVIEW_SECRET(matching value required on the Payload side too, or this route 401s) and apath, enables Next.js Draft Mode, then redirects into the real page. This is the link Payload'slivePreview.urlresolvers point at (seedocker/payload/src/lib/previewUrl.tsin the infra repo) — never the page directly.- Each supported page checks
draftMode().isEnabledand renders a"use client"Live-Preview-aware component instead of the plain static one only when it'strue— 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, socontentis 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 byidinto 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'suseLivePreviewhook and reuse the same mapping functions (mapPayloadPost,mapPayloadTestimonial, exported fromapp/lib/payload.ts) the plain server-side fetchers use, so the two code paths can't silently drift apart. app/lib/payload.tsdeliberately never importsnext/headersitself — callers (the Server Component pages) calldraftMode()themselves and pass the result in as a plain{ draft: boolean }option. Importingnext/headersanywhere 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()indiscountServer.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=SAVE10on the/cartURL still auto-applies once on arrival (auseEffectreadinguseSearchParams()— requires/cart'spage.tsxto wrapCartContentin<Suspense>, a Next.js requirement for anyuseSearchParams()consumer) regardless ofhasActiveDiscountCode()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'snext/headerslesson above) talks to Payload'sdiscount-codescollection using anx-discount-service-secretheader (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'sredemptionCount. Called exactly once, fromCheckoutContent.tsx'shandlePurchase(), right before theOrderSnapshotis 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.tsmirrorslib/cart.ts's exactlocalStorage+useSyncExternalStorepattern, so the applied code survives the/cart→/checkoutnavigation 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 inCartContent.tsx,CheckoutContent.tsx, andBestellbestaetigungContent.tsx; now also folds in the discount amount (clamped so a total can never go negative).OrderSnapshotpersists the applieddiscountCode/discountAmountso 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 inlocalStorageunderep_cart, keyed by each product'sslug. 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.
Product/its variants carry a realmaxQty(app/lib/payload.ts'smapPayloadProduct()—nullwhen 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/AddToCartInlineButtondisable (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.tsxonly 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 small catalog it can render fewer cards (down to 1, centered in the 12-column grid) rather than recommending something already added./checkout's "Jetzt kaufen" always goes throughPOST /api/checkout. That route re-prices the entire cart server-side from Payload's live product data (never trusts client-submitted prices), re-validates+redeems a discount code exactly once, registers a new account inline if nobody's logged in yet ("Konto Pflicht" — see below), and only then creates the order in Payload'sorderscollection viaapp/lib/orderServer.ts.subtotal/discountAmount/totalare each rounded to 2 decimals (roundMoney()) right before being persisted — plain float arithmetic on a summed/percent-discounted cart drifts into values like84.30000000000001, invisible wherever a display already ran the number throughformatPrice()'stoFixed(2), but stored as-is otherwise and visible raw in the Payload admin's plain number field fortotal.app/lib/order.ts'sOrderSnapshotis still written tosessionStoragefor/bestellbestaetigungto read once, but itsorderNumber/orderDateIsonow come back from that Payload create call, not generated client-side.- Order confirmation email is sent from
/api/checkout/route.tsright after a successfulcreateOrder()for Überweisung orders only — fire-and-forget (app/lib/orderEmail.ts'ssendOrderConfirmationEmail()), never blocks or fails the checkout response itself; a send failure alerts admin instead (sendCriticalAlert, lower severity than the "order not persisted" alert, since the order itself is safe either way). Content comes from the publishedorder-confirmationrow in Payload'semail-templatescollection — see "Email templates & Live Preview" below for how that's edited/previewed. As of the invoice PDF feature (see below), this same send also carries the order's invoice PDF as an attachment. Kreditkarte/PayPal orders defer this until payment is confirmed — see "Payment processing (Stripe)" below.
Payment processing (Stripe)
Real payment capture for Kreditkarte/PayPal, via Stripe's Payment Element
(one integration covers both — see the approved plan this was built from,
spicy-leaping-pizza.md, for the full design rationale). Überweisung stays
exactly as before: no gateway involved, order goes straight to received.
payment-methods'sproviderfield (Payload, admin-only) drives the branch —'manual'(Überweisung) or'stripe'(Kreditkarte/PayPal).app/lib/payload.ts'sgetPaymentMethods()exposes it; the checkout route re-resolves it server-side, never trusts a client-submitted value.- Checkout UI collapses Kreditkarte + PayPal into one "Online-Zahlung"
option (
groupPaymentMethodsForCheckout()inapp/lib/payload.ts, used byCheckoutContent.tsx). Both admin rows still exist and both still needprovider: 'stripe'— this is a display-layer grouping, not a data change. Reasoning: the PaymentIntent is created withautomatic_payment_methods: { enabled: true }(Stripe's own recommended Payment Element pattern — Stripe itself decides which eligible method to show), so pre-selecting "Kreditkarte" vs. "PayPal" before that never actually restricted anything; it was redundant friction, not a real choice. The combined option shows a hint text ("die genaue Zahlungsart wählst du im nächsten Schritt") so the consolidation reads as intentional, not a missing option. Überweisung stays a separate, real option. paymentMethodTitleis snapshotted as a neutral"Online-Zahlung"at order-creation time for thestripebranch (the customer hasn't picked an instrument yet at that point) and refined to the real one ("Kreditkarte"/"PayPal") once Stripe reports it —resolveStripePaymentMethodLabel()instripeProvider.tsreads the confirmed PaymentIntent'spayment_method.typein the webhook route and passes it toconfirm-paymentas an optional field. Best-effort: an unresolved label just leaves the neutral title in place. The/checkout/verarbeitungpolling page also patches this into the provisionalsessionStoragesnapshot before promoting it, so/bestellbestaetigungshows the real instrument too, not the neutral placeholder.app/api/checkout/route.ts,provider === 'stripe'branch: creates a Stripe PaymentIntent before the order (app/lib/payments/ stripeProvider.ts) — its id is known immediately and gets persisted as the order's ownproviderReferencefield at creation time, so the backend's abandonment-cleanup job can reconcile with Stripe later even if nothing else about this flow ever completes. The order is created withstatus: 'pending_payment',paymentStatus: 'pending'— no invoice number yet, no confirmation email yet (both deferred to the webhook-driven confirm-payment step, on the backend). Right after, a best-effort (awaited, non-fatal) call attaches{orderId, orderNumber}as PaymentIntent metadata (attachOrderMetadata) — this is what lets the webhook resolve an incoming Stripe event back to a specific Payload order.app/checkout/components/PaymentStep.tsxrenders in place of the address form once/api/checkoutreturnsrequiresPayment: true— Stripe'sPaymentElement(real mode) or a "Testzahlung erfolgreich / fehlgeschlagen" button pair (test mode, see below). A card confirms in-place; PayPal (and 3-D-Secure challenges) redirect out and back viareturn_url=/checkout/verarbeitung?orderNumber=....app/api/webhooks/stripe/route.ts— the real inbound webhook. Verifiesstripe-signatureagainstSTRIPE_WEBHOOK_SECRET, reads the raw body (never.json()— the signature is computed over the exact bytes), handlespayment_intent.succeeded/.payment_failed, and calls the backend'sPOST /api/orders/:id/confirm-payment(guarded byPAYMENT_WEBHOOK_SECRET, a secret distinct fromORDER_SERVICE_SECRETon purpose — least privilege, it can only hit this one action) with{paymentStatus, providerReference, paidAt}. Returns a non-2xx status on any internal failure so Stripe's own retry schedule (~3 days) provides resilience for free, rather than this app building its own retry queue. That backend endpoint flips the order toreceived, assigns the (until-then-deferred) invoice number, and queues the internal admin new-order notification — see the backend repo's own README for that half. It has no SMTP sender of its own, though: it returns a full order snapshot in its response instead, and this webhook route is what actually sends the confirmation email + invoice PDF (app/lib/payments/confirmPaymentEmail.ts, only when the response isn'talreadyProcessed: true— a repeat webhook delivery must never resend it), mirroring exactly what the checkout route already does inline for a manual/Überweisung order. Product photos in that snapshot'sitems[].imageUrlneed no frontend change to work —ConfirmPaymentOrderSnapshot/OrderConfirmationItemalready typed the field; the backend just wasn't populating it (fixed there, see its own README — neededdepth: 2soitem.product.imageresolves to a realMediadoc)./checkout/verarbeitung(VerarbeitungContent.tsx) is thereturn_urltarget. Neither a client-sideconfirmPayment()success nor landing back from a PayPal redirect is trusted as proof of payment on its own (a closed tab mid-redirect looks identical to success from here) — this page polls/api/checkout/status?orderNumber=...(session-scoped, so a guessed order number can't be used to probe someone else's payment status) untilpaymentStatusflips topaid, then promotes the provisionalsessionStoragesnapshot (PENDING_ORDER_KEY, written right before handing off to Stripe) to the real one (ORDER_KEY), clears the cart, and redirects to/bestellbestaetigung— exactly the same sessionStorage mechanism Überweisung orders already used, just populated a step later. Onfailed/cancelledit shows a retry message with the cart left intact (never cleared until payment actually succeeds); on a slow-to-arrive webhook it times out after ~15s with a "we'll email you" message rather than polling forever.
Local testing without a real Stripe account — PAYMENT_TEST_MODE
(defaults on whenever STRIPE_SECRET_KEY is unset, so a fresh npm run dev
never accidentally calls the real Stripe API): app/lib/payments/index.ts
swaps in mockProvider.ts instead of stripeProvider.ts — same interface,
so the checkout route and everything downstream of it runs unmodified.
PaymentStep.tsx shows "Testzahlung erfolgreich"/"Testzahlung
fehlgeschlagen" buttons instead of the real Payment Element; clicking one
calls app/api/webhooks/stripe/test-confirm/route.ts, which skips
signature verification (there's no real Stripe event to verify) and calls
the exact same backend confirm-payment endpoint the real webhook does —
so clicking "erfolgreich" exercises the entire real pipeline (deferred
invoice numbering, gated email, idempotency) end to end, it's only the
Stripe API call itself that's faked. That test-confirm route hard-404s
whenever PAYMENT_TEST_MODE isn't explicitly true, so it can never become
a reachable "mark any order paid" endpoint in production.
Stripe is live in production for Kreditkarte. payment-methods rows:
Kreditkarte → provider: 'stripe', active; PayPal → provider: 'stripe',
prepared but not yet activated on the Stripe account itself;
"Überweisung (Vorkasse)" → provider: 'manual'; "Sofortüberweisung"
prepared as provider: 'stripe' but active: false pending a logo (see
the backend's own README). STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET,
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY, and PAYMENT_WEBHOOK_SECRET are set
in both Coolify and the backend's docker/.env (the backend's own copy of
STRIPE_SECRET_KEY is used only for stripeRefund.ts, Storno/Gutschrift
refunds). Going from test-mode keys (sk_test_...) to live-mode keys
(sk_live_...) is a straight swap of the same three Coolify variables
plus a new live-mode webhook endpoint (new whsec_...) — no code change.
VAT display
Every price shown storefront-wide says "inkl. X% MwSt." with the actual
resolved rate (app/lib/cartTotals.ts's effectiveTaxRate(product, defaultRate) — a product's own taxRatePercent override if set,
otherwise the tenant default from company-settings), not a generic
"inkl. MwSt." disclosure — getDefaultTaxRatePercent() in
app/lib/payload.ts is a separate, ISR-cached (60s) fetch of just that one
number, deliberately not getCompanySettings() itself (that one is
cache: "no-store" for its invoice-generation callers, where always-fresh
bank details matter; the display rate only needs the same freshness every
other public catalog fetch already has).
A cart/checkout/order-confirmation total additionally shows the actual
€ amount of VAT included, not just a percentage — computeTaxBreakdown(),
imported from @einfach-produktiv/invoicing (a shared package consumed
by both this repo and the Payload backend as a git dependency — see
"Invoice PDFs" below; it used to be an independently-duplicated
groupByTaxRate() inside invoicePdf.tsx/correctionInvoicePdf.tsx,
both of which have since moved into that package too)
groups line items by their effective rate and reports each group's actual
tax amount; app/components/VatBreakdown.tsx renders a single "enthält
X% MwSt.: Y €" line when the cart/order has one rate, or one line per rate
when it spans more than one. Used on /cart, /checkout, /bestellbestaetigung,
the order-confirmation email (emailTemplates.ts's renderOrderConfirmationHtml,
which already carried per-item taxRatePercent but didn't render it
before), and both account order pages (see "Orders & customer accounts"
below) — the account order list additionally shows up to 4 product
thumbnails per order row (getProductImagesByIds() in app/lib/payload.ts,
a plain product-id → image-url lookup separate from the slug-keyed
catalog, since an order only ever snapshots a numeric product id).
None of this renders at all for a Kleinunternehmer tenant — see "Kleinunternehmerregelung" below for the full list of touched spots and why some read a live setting and others a persisted per-order snapshot.
Vorkasse instruction in the confirmation email. The invoice PDF
already shows this (see @einfach-produktiv/invoicing's own
unpaidNoticeText), but a customer often only glances at the email body
itself, not the attached PDF. OrderConfirmationData's required
isManualPayment: boolean field is explicitly set by each caller
(checkout route's manual branch: true; the Stripe webhook path:
always false, since only a paid Stripe order ever reaches that send at
all), deliberately not derived from paymentMethodTitle inside
emailTemplates.ts itself — that string ("Online-Zahlung", "Kreditkarte",
"Überweisung (Vorkasse)", ...) is exactly the kind of thing a
payment-methods rename could silently break (see isPaidImmediately() in
the invoicing package). When isManualPayment is true, vorkasseNotice()
renders a full-width block (own row, margin-top: 20px — not squeezed
into the Gesamtsumme table) naming the bank details
(CompanySettings.bankName/iban/bic) and the "processed within 1–2
business days after payment received" note.
Checkout state persistence
app/lib/checkoutDraft.ts — localStorage under ep_checkout_draft,
plain read/write functions (not useSyncExternalStore like cart.ts/
discount.ts: CheckoutContent is this draft's only reader, no
cross-component subscription to keep in sync). Every address-card field
(name, email, delivery method, street/Packstation, PLZ/Ort/Land, the
shipping-address-override fields below, newsletter opt-in) plus the
selected shipping/payment method is now a controlled input backed by this
draft, restored on mount (a useEffect-deferred read, same SSR/hydration-
mismatch avoidance as BestellbestaetigungContent's own sessionStorage
read) and cleared on a completed purchase. password is deliberately
excluded — stays a plain uncontrolled, unpersisted input.
Optional deviating shipping address
"1. Rechnungsadresse" always collects a plain street address now — no
Lieferart (Lieferadresse/Packstation) toggle there anymore, since a
Packstation isn't a valid billing address for an invoice. A separate
checkbox ("Abweichende Lieferadresse verwenden") reveals a second address
section with its own delivery-method toggle (own name + delivery method +
street/Packstation/PLZ/Ort/Land, plus optional Firma and
Kontakt-E-Mail/Telefon handed to the shipping carrier only, never used for
customer communication — that stays the account email) — that's the only
place Packstation delivery is offered at all. When used, the order's
original address fields stay the billing address (used for the
invoice's "An" block regardless), and the shipping*-prefixed fields
(hasDifferentShippingAddress,
shippingFirstName/shippingLastName/shippingDeliveryMethod/
shippingStreet/shippingPackstationNumber/shippingPostNumber/
shippingZip/shippingCity/shippingCountry/shippingCompany/
shippingContactEmail/shippingContactPhone — mirrored 1:1 on the
Payload orders collection, see the Payload README; there's no
shippingVatId — VAT ID is a billing-only concept, deliberately not
mirrored) determine where the order actually ships. /api/checkout/route.ts
validates the override the same way it already validated the primary
address (required fields, street-xor-Packstation depending on the chosen
delivery method). The invoice PDF shows a third "Lieferadresse" address
block alongside Von/An when set (see "Invoice PDFs" below);
/konto/bestellungen/[orderNumber] shows both addresses too, relabeling
the first one "Rechnungsadresse" instead of "Lieferadresse" only once
there's an actual second address to distinguish it from. /konto/profil
can store this same deviating-shipping-address shape (same checkbox
pattern, same fields) as a saved default, prefilling checkout's own
section for a returning customer instead of always starting blank.
DHL checkout integrations
Three checkout-facing pieces, each backed by the Payload backend's
tenant-configurable dhl-settings and gracefully absent (the underlying
calls 404) whenever a tenant hasn't activated the matching DHL
sub-integration — see docker/payload's src/lib/shipping/README.md for
the backend side.
app/lib/shippingDhl.ts— a thin client for the backend's two public DHL endpoints (Postnummer validation, DataFactory address autocomplete); its own small copy, not part of the shared invoicing package.app/api/checkout/validate-dhl-postnumber/route.tsandapp/api/checkout/autocomplete-address/route.ts— Next.js API routes proxying to the backend, sinceCheckoutContent.tsxis a Client Component and DHL credentials are tenant-specific (live in Payload) — the browser never talks to Payload's DHL endpoints directly.app/checkout/components/AddressAutocomplete.tsxwraps the billing and shipping street inputs with a debounced DHL DataFactory suggestion dropdown; selecting a suggestion also fills zip/city.- Postnummer live validation —
CheckoutContent.tsx'shandlePostNumberBlur(same shape/status-state pattern as the VAT-ID blur check) checks the Packstation Postnummer against DHL on blur, once the existing format check (6–10 digits) already passes. - Return-label download —
/konto/bestellungen/[orderNumber]shows a "Retourenschein herunterladen" link once the backend has generated a DHL return label for that order (order.dhlReturnLabelMedia, resolved viagetMediaUrlById()inapp/lib/payload.ts).
Destination countries (Payload-configurable)
Both country <select>s (billing, and the shipping-address override)
are fed by getShippingCountries() (app/lib/payload.ts) reading
Payload's shipping-countries collection (name, plzDigits,
active, sortOrder) — not a hardcoded array anymore. plzDigits also
drives PLZ's own maxLength/pattern validation (validateZip() in
CheckoutContent.tsx builds a country → digit count map from this
list), so an admin adding a country in Payload doesn't need a frontend
deploy to make it selectable, and the PLZ format check automatically
matches whatever digit count that country's row specifies. Seeded with
Deutschland (5 digits) and Österreich (4) — matching what this checkout
already offered before this became configurable. Adding a country here
(e.g. Schweiz) makes it immediately selectable at checkout; it does
not by itself add any customs/export-invoice handling or affect VAT
exemption eligibility (see "VAT exemption" below — that's still a
separate, deliberately-not-Payload-configurable legal decision).
Product variants
A cart line's identity is (id, variant) together, not id alone —
app/lib/cart.ts's CartItem gained an optional variant?: string field
(the selected products.variants[].name), and every function that used to
match a line by id (addToCart/removeFromCart/setQuantity) now
matches by both via a shared sameLine() helper, so two lines for the
same product with different variants stay genuinely separate entries
instead of merging or clobbering each other. variant undefined on both
sides (the common no-variants case) still matches by simple equality —
every pre-existing call site that never passes a variant keeps working
unchanged.
Where a variant gets picked: both AddToCartInlineButton
(/shop, /cart's related-products grid) and AddToCartButton (the
marketing-page-specific one on /todo-cards' Hero + Pricing panel and the
homepage spotlight) render a <select> 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 — @einfach-produktiv/invoicing's buildTrackingUrl(carrier, trackingNumber)/
CARRIER_LABELS (imported from the package's main barrel) are shared with
the Payload backend, 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 fromcompany-settings(fetched alongside the product catalog,Promise.all([fetchProductsBySlug(), getCompanySettings()])). Snapshotted ontoorders.items[].taxRatePercentat 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'sbundleItems(Payload relationship, populated viafetchProductsBySlug()'sdepth: 2fetch — one level deeper than thedepth: 1imagealone needs, sincebundleItems.productis a relationship nested inside an array field) into a plain string like"2× ToDo-Karten, 1× Wochenplaner", snapshotted ontoorders.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 pluscomputeTaxBreakdown()(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"inpackage.json). Ships raw TS/TSX source, no build step of its own — this app'snext.config.tslists it undertranspilePackagesso 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 — see that package's own README for the full renderer/formatter surface.InvoiceDocument— a@react-pdf/rendererDocument, 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 viaStyleSheet.- 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'sfixedprop), not just wherever the content flow happens to end. Item rows share one uniform tinted background, no alternating white/tinted zebra striping. - "Bereits beglichen" confirmation: shown next to the summary card
whenever
order.paymentMethodTitleis anything other than"Überweisung"(bank transfer) — Kreditkarte and PayPal both settle at checkout, so the invoice says so explicitly (isPaidImmediately(), in the shared package'sinvoicePdf.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). Rendered as plain green text, not a tinted pill/badge box. This one is genuinely conditional on the order's own payment method — unlike the bank-details line below, which prints unconditionally. - Bank details:
company-settings.bankName/.iban/.bic—iban/bicstructured and independently format-validated (uppercased/trimmed on save too, so "de123..." doesn't fail validation just for being lowercase — same normalizationdiscount-codes.codealready used),bankNamestays free text since there's no fixed format to validate a bank's display name against — structured, discrete PaymentMeans data rather than a single free-text field, per EN16931. Whenibanorbicis set, the footer prints "Bankverbindung: [Bankname ·] IBAN … · BIC …" always, regardless of the order's payment method — a card/PayPal customer might still want the seller's bank details for other reasons (e.g. a refund). - Summary layout is a genuinely additive chain. The summary card
reads Zwischensumme → Rabatt (if any) → Versand → a divider → Gesamt,
using the order's own raw
subtotal/discountAmount/shippingCost/totalfields directly — every row above the divider actually sums to the number below it. The per-rate tax breakdown (fromcomputeTaxBreakdown(), which distributes discount/shipping proportionally across each tax-rate group before computing net/tax, per how ancillary costs are legally apportioned across rates) sits 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" framingVatBreakdown.tsxuses 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 reads "Steuerfreie innergemeinschaftliche Lieferung (§4 Nr. 1b UStG)" instead. - Netto row: a dedicated "Netto" row sits between Gesamt and the "enthält X% MwSt." annotation on every invoice this shop issues (original, Storno, Gutschrift alike) — businesses read this directly for their own input-tax deduction instead of computing Gesamt minus MwSt by hand. Shown unconditionally, including on VAT-exempt orders (net and Gesamt happen to be the same figure there).
- Product thumbnails: each item row shows a small product image —
resolved from the order-confirmation data's already-available
imageUrlfor the checkout-time attachment, or viagetProductImagesByIds()(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
bundleContentssub-line snapshotted at order time (see the Payload README's "Product bundles" section). - Shipping address: when
order.hasDifferentShippingAddressis 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 showscompanyNameas its own line above the contact person's name, andvatIdas 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'srenderInvoicePdf()/renderCorrectionInvoicePdf().seller(company-settingsdata) 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 viagetSellerForInvoice(), 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) andapp/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, sinceinvoiceNumber/invoiceIssuedAtare assigned exactly once, server-side, at order creation (Payload'sorders.tsbeforeChangehook — 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 samerenderCorrectionInvoicePdf()from@einfach-produktiv/invoicingthe 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/correctionInvoiceIssuedAtare immutable once set (Payload'sbeforeChangehook), 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:
invoiceNumberandcorrectionInvoiceNumbereach come from their own gapless counter on Payload'snumber-rangescollection (invoicePrefix/Next/Paddingvs.correctionInvoicePrefix/Next/Padding— a Stornorechnung/Gutschrift has its own sequence, distinct from ordinary invoices). Both are assigned via a single atomicUPDATE ... RETURNINGagainst Postgres (Payload's backendnumberRange.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 viagetCompanySettings()/getSellerForInvoice(), authenticated the same way as order creation (x-order-service-secretheader,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.taxRatePercentis deliberately a configurable admin field, not a hardcoded19in 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). 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
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
(@einfach-produktiv/invoicing'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)
A cross-border EU B2B sale — Österreich is the
eligible destination (isExemptionEligibleCountry() in
app/lib/vatExemption.ts, hardcoded, deliberately not read from the
Payload-configurable shipping-countries list above — eligibility is a
legal decision, not a shipping-logistics one, so an admin adding a new
destination country can't accidentally also grant it a VAT 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).
@einfach-produktiv/invoicing/vies(server-only entrypoint) —checkVatIdViaVies()calls the European Commission's public VIES REST API (POST .../check-vat-number) 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.
app/api/checkout/validate-vat/route.ts/app/api/checkout/route.tscheck any format-valid VAT ID against VIES regardless of destination (data quality — worth knowing whether it's real at all, same reasoning ascompany-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.tsre-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 owncompany-settings.vatIdVIES check, which fails open, since that one only needs to catch an admin's data-entry typo, not decide a tax rate). On an unconfirmed VAT ID, the field re-focuses so the customer's attention returns there without also selecting the whole existing value — a stray keystroke while just glancing at the error shouldn't wipe out what was already typed. This same "re-focus the failing field" behavior applies generically to any checkout field that fails its own blur validation, not just this one — see the bullet below. - Every other checkout field is blur-validated too — 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/requiredvalidation kicks in at submit. The native attributes stay in place as a fallback for any field somehow never blurred (e.g. autofill).setFieldError(name, message, refocusEl?)(CheckoutContent.tsx) takes an optional element to refocus whenever the message is non-empty — every field's ownonBlurpassese.target, so a field that fails validation gets focus put right back on it generically, not just the USt-IdNr. special case above. - Persistence:
Orders.vatExempt/vatIdValidatedAt(Payload backend) record the outcome, decided once server-side, never editable in the admin.vatIdValidatedAtis set for any VIES-confirmed VAT ID (data-quality audit trail), independently of whethervatExemptis 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 also hard-caps input length — PLZ
(
maxLength= the selected country's own digit count), USt-IdNr. (14), Packstationnummer (3)/Postnummer (10, on the backend), on top of their ownpattern/validateformat checks. /bestellbestaetigungmirrors the same exempt-totals branch from the persistedOrderSnapshot.vatExemptflag (it otherwise re-derives totals live from the current catalog, which would show the wrong, VAT-inclusive figures for an exempt order).
Kleinunternehmerregelung (§19 UStG)
company-settings.kleinunternehmer — a standing per-tenant setting
(Payload backend), not a per-order decision like the VAT exemption above
— when on, this tenant never charges VAT on anything, domestic or
cross-border. See the Payload README's own "Kleinunternehmerregelung"
section for the field/collection side; this one covers the frontend.
api/checkout/route.tsforces every item'staxRatePercentto0whengetCompanySettings().kleinunternehmeris on — deliberately without de-grossingunitPricethe wayvatExemptabove does (that exemption zero-rates what would otherwise be a positive-rate charge, so de-grossing means the buyer pays less; a Kleinunternehmer never charged VAT on the sale to begin with, so the catalog gross price already is the actual net charge — confirmed with the user as the intended business decision, not an engineering default). The VIES lookup/isExemptionEligibleCountry()check is skipped entirely in this branch too — there's no VAT for the intra-community rule to exempt either. Snapshotted onto the new order asOrders.kleinunternehmer(mirrorsvatExempt's own snapshot reasoning — see the Payload README).- Storefront "inkl. X% MwSt." hints — four spots read the live
setting (
getKleinunternehmer()inapp/lib/payload.ts, same ISR-cached 60s freshness asgetDefaultTaxRatePercent()) and drop the MwSt. clause entirely when it's on, since there's no order yet at that point to snapshot from:ProductGrid.tsx(shop grid),RelatedProducts.tsx(cart's upsell row),Pricing.tsx/TodoKartenHero.tsx(ToDo-Karten landing page),ProductSpotlight.tsx(homepage).Pricing.tsx/ProductSpotlight.tsxkeep "zzgl. Versand" on its own when the MwSt. clause drops; the other two had no such trailing clause to preserve. - Every already-placed-order display reads the persisted snapshot
instead —
OrderSnapshot.kleinunternehmer(app/lib/order.ts, written intosessionStorageat checkout, read byBestellbestaetigungContent.tsx) andCustomerOrderDetail. kleinunternehmer(app/lib/customerAuth.ts, read by/konto/bestellungen/[orderNumber]) — never the live company-settings value, for the identical "don't retroactively rewrite an already-issued invoice's tax treatment" reasonvatExemptalready established. Both pages replace the per-item "inkl. X% MwSt." hint and theVatBreakdownsummary with "Gemäß § 19 UStG wird keine Umsatzsteuer berechnet." — taking precedence over thevatExemptnote wherever both would otherwise apply.CheckoutContent.tsx's live VIES-exemption preview is also gated off (!kleinunternehmer && ...) so a Kleinunternehmer tenant never shows a misleading "wird steuerfrei berechnet" preview for VAT that was never going to be charged either way. - On-demand invoice/Stornorechnung/Gutschrift downloads
(
api/account/orders/[orderNumber]/invoice/route.tsand itscorrection-invoicesibling) threadorder.kleinunternehmerthrough to@einfach-produktiv/invoicing's renderers the same way they already threadvatExempt. app/company-settings-preview's Live Preview merges the live-editedkleinunternehmercheckbox onto the fixedSAMPLE_INVOICE_ORDERbefore rendering (kleinunternehmerlives onInvoiceOrder, notInvoiceSeller— see the invoicing package's own README on why), so an admin sees the §19 UStG notice appear/disappear live as they toggle the field, without this preview needing its own separate mechanism.
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 viaapp/api/preview/route.ts), but no[type]segment — there's only one kind of document here, unlike the 10 email types. Actually gated ondraftMode().isEnabled(callsnotFound()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<PDFViewer>(notrenderToBuffer()) is what makes this a live preview rather than a static download — it's a browser-only component that renders aDocumentstraight into an embedded PDF viewer<iframe>, re-rendering whenever its props change. Paired withuseLivePreview()'s livedata(same postMessage mechanism as the email templates preview), editing a field in the admin re-renders the actual PDF in real time — no server round-trip per keystroke. Dynamically imported with{ ssr: false }(next/dynamic) since it touches the DOM directly; the HTML-string email previews elsewhere don't need that since they're justdangerouslySetInnerHTML.- Renders
InvoiceDocument(exported from@einfach-produktiv/invoicingspecifically for this — everywhere else only the asyncrenderInvoicePdf()buffer-generator is used) against a fixedSAMPLE_INVOICE_ORDER(also exported from that same package) — same "no real document to preview against generically" reasoning asemail-templates' ownSAMPLE_ORDER. - No draft/published distinction here, unlike
email-templates:company-settingshas no content-versioning concept, it's just the current row — the page's initial (pre-postMessage) fetch is the same live datagetCompanySettings()always returns.
Newsletter signup & Brevo sync
Four separate signup entry points across the site — the shared
Newsletter panel (app/components/Newsletter.tsx, reused on Home and
/newsletter), NewsletterModal.tsx (the Navbar's "Newsletter" CTA),
/newsletter's own inline hero form
(app/newsletter/components/WeeklyImpulsesHero.tsx), and /challenge's
EmailCapture (app/challenge/components/EmailCapture.tsx) — plus
checkout's newsletterOptIn checkbox — all sync to Brevo's contact list.
app/lib/brevo.ts— the only thing that talks to Brevo.upsertNewsletterContact(email, source)calls Brevo's double opt-in endpoint,POST /contacts/doubleOptinConfirmation— this only ever requests a subscription; Brevo sends a confirmation email (the template atBREVO_DOUBLE_OPTIN_TEMPLATE_ID, configured as the list's Double Opt-in template in Brevo's own UI) and only actually adds the contact toBREVO_LIST_IDonce they click through.source("checkout"|"newsletter-page"|"newsletter-modal"|"newsletter-hero"|"challenge") is stored as the contact'sOPT_IN_SOURCEattribute for segmentation — that attribute has to already exist on the Brevo account (POST /v3/contacts/attributes/normal/OPT_IN_SOURCE) or Brevo silently drops it on every request (no error at all, just never stored) rather than rejecting the request.redirectionUrl(where Brevo sends the contact after they click confirm) defaults to/newsletter-confirmedviaBREVO_DOI_REDIRECT_URL— a static confirmation page (app/newsletter-confirmed/page.tsx), same visual language as/bestellbestaetigung(brand-tinted circular checkmark, serif display heading, thin brand divider). No query params to read — Brevo's redirect carries nothing this page needs, unlike/checkout/verarbeitungwhich polls actual payment status.- Already-subscribed detection —
doubleOptinConfirmationitself gives no way to tell a brand-new signup apart from an already-confirmed contact re-submitting the form (verified directly: calling it twice for the same confirmed contact returns the identical201both times, just silently resends the confirmation mail). SoupsertNewsletterContact()checks first viaGET /v3/contacts/{email}— a contact'slistIdson Brevo is only populated once double opt-in actually confirms, never for a merely-requested one, so its presence is a reliable signal. If already subscribed: skips the resend entirely and returns{ ok: true, alreadySubscribed: true }instead. The check fails open (any error → proceed to the normal signup flow) — it's a UX nicety, never a reason to block a real signup. Routed through the existing error state, not a success variant — the form stays visible with a small red note below it, exactly like every other inline validation error (useNewsletterSignup.tssetsstatus: "error",error: "Diese E-Mail-Adresse ist schon für unseren Newsletter angemeldet."). This error clears on the next interaction (handleEmailChange/handleConsentChangeboth call a sharedclearSubmitError()), matching standard form-validation behavior instead of sitting there until the next submit. app/lib/useNewsletterSignup.ts— the shared email/consent/submit state + on-blur validation + refocus-on-invalid-submit behind all four forms. Each form keeps its own markup/visual style (Newsletter's panel layout,/challenge's hardcoded-hex-color palette, etc.) — only the logic is shared, not a one-size-fits-all component; the real-success message text (successMessage) also lives here rather than hardcoded per form./api/newsletter/subscribe/route.tslogsupsertNewsletterContact()'s real failurereasonserver-side (console.error) while keeping the customer-facing error generic ("Anmeldung ist fehlgeschlagen...") — never leaks Brevo's internal error text, but stays diagnosable from the server logs (e.g. a misconfiguredBREVO_LIST_ID).app/lib/email.ts—isValidEmail()/validateEmailFormat(), the single plain-email-format check shared by every newsletter form and checkout's own email field.app/api/newsletter/subscribe/route.ts— validates email-format + consent server-side too (never trusts the client alone), then callsupsertNewsletterContact().- Checkout's sync (
app/api/checkout/route.ts) is fire-and-forget alongside the order-confirmation email — a failed marketing sync must never fail checkout, and isn't worth a critical alert either (nothing customer-facing depends on it). - This app never sends marketing/campaign mail itself — only transactional (order confirmation, password reset, status updates). Whatever automation Brevo has configured on the list (a "Welcome Flow" etc.) runs entirely on Brevo's own side once a contact lands there; Brevo's Automation workflows aren't exposed via their public REST API at all, so that piece can only be built/inspected in Brevo's own UI, not from this codebase.
- Needs
BREVO_API_KEY/BREVO_LIST_IDset in the deployment environment, plusBREVO_DOUBLE_OPTIN_TEMPLATE_ID(no safe default — every signup silently no-ops without it) and optionallyBREVO_DOI_REDIRECT_URL. NewsletterModal.tsx's left photo stretches (items-stretch,md:aspect-auto) to match the right column's height — the emailError/already-subscribed messages are always rendered with a reservedmin-h-[1.05rem](never conditionally mounted), so toggling them never changes the column's height, and the photo never resizes along with it.
Orders & customer accounts
An account is required to buy — there is no guest checkout. Registration
happens inline in /checkout's "1. Rechnungsadresse" card (a password
field appears there when nobody's logged in), or standalone via
/konto/registrieren (RegisterForm.tsx, reusing the same
/api/account/register route) for a visitor who wants an account
independent of a purchase (e.g. to use the wishlist, see below). There's
no persistent "already a customer? log in" prompt shown to every
logged-out visitor regardless of relevance — instead, the checkout email
field's onBlur calls /api/account/check-email
(checkEmailExists() in customerAuth.ts, service-secret authenticated —
Customers isn't public-read) and only then swaps Card 1's own
"Passwort (für dein neues Konto)" field out for an inline login prompt
(password field + "Einloggen" button), rendered right under the same
email field the shopper just typed into rather than asking for it a
second time in a separate field — a useEffect scrolls it into view if
needed. handleSubmit's own emailExists handling (see "Checkout
registration collisions" below) is the fallback for the case this check
was skipped or raced. LoginForm.tsx honors an optional ?redirect=
param (used by the wishlist's login-gate, see below), landing back on
whatever page sent the visitor to log in instead of always
/konto/bestellungen.
app/lib/customerAuth.ts(server-only) is the single place that talks to Payload'scustomerscollection — a second, fully separateauth: truecollection from any admin login, existing purely for this storefront's own accounts. Payload issues a JWT on register/login; this app never relies on Payload's own auth cookie (different origin —einfach-produktiv.mk360.devspayload.mk360.de) and instead mints its own httpOnlyep_customer_tokencookie holding that JWT, forwarded as anAuthorization: JWT <token>header on every subsequent Payload call.app/api/account/*— thin route handlers aroundcustomerAuth.ts:register,login,logout,me,orders(list),profile(GET/PATCH incl. the one saved default address),password(verifies the current password via a real login attempt before changing it, doesn't just trust the caller),cart(GET/POST, see below),verify-email,resend-verification,delete,export(see "Email verification" and "GDPR self-service" below),forgot-password,reset-password(see "Password reset" below),orders/[orderNumber](PATCH — cancel/return-request, see "Order cancellation & returns" below), andorders/[orderNumber]/invoice(GET — invoice PDF download, see "Invoice PDFs" below).app/konto/components/KontoShell.tsxis the shared shell for every logged-in/konto/*page (bestellungen, merkliste, profil) —AccountNav.tsx(persistent sidebar onsm:+, a horizontal tab bar below it) plus the<main>/<Footer>wrapping used to be duplicated per page, with the equivalent of the nav buried as plain links at the bottom of the orders list (unreachable once that list got long). Each page still owns its own auth-check/redirect/data-fetching and renders its own heading; right under that heading it rendersAccountIdentity.tsx("Eingeloggt als …, Kundennummer …") so that line looks identical on every page withoutKontoShellitself needing to know each page's title.AccountNavalso renders the "Abmelden" button (logout, folded into the nav rather than living at the bottom of a specific page)./konto/bestellungenlists a customer's own orders — a per-card CSS Grid (grid grid-cols-2 min-[500px]:grid-cols-4, so every card's Bestellnummer/Datum/Artikel/Status/Zahlungsstatus/Gesamtbetrag fields line up at identical x-positions regardless of label width, dropping to 2 columns below 500px), status shown as a coloredOrderStatusBadge.tsxplus aPaymentStatusBadge.tsx(Offen/Bezahlt/…, in the same red/subtle-background style as a failed fulfillment status when unpaid); optional status/paymentStatus/year filters (OrderFilters.tsx, 3CustomSelectdropdowns, URL-search-param-driven, collapsed behind a "Filter" toggle belowsm:) when the tenant'sorderFilterEnabledtoggle is on (see "Feature toggles" below)./konto/bestellungen/[orderNumber]shows one order's full detail (items, address, totals,status, plus companyName/VAT-ID and a VAT-exemption note when the order has them). A "Zahlungsart ändern" button appears when an order is still unpaid Überweisung and an active Stripe payment method exists — reuses the same StripePaymentStep//checkout/verarbeitungpolling flow checkout itself uses (via areturnContext/contextprop so confirmation lands back on the order page instead of/bestellbestaetigung), and sendssendPaymentSwitchedEmail()(lib/orderEmail.ts) instead of the usual order-confirmation mail once payment confirms — same shape as the status-change emails, but re-attaches a freshly generated invoice PDF (sameinvoiceNumber, never re-issued) now marked "Bereits beglichen" instead of showing the Vorkasse notice.status(received→processing→shipped→delivered, pluscancelled/return_requested/returned) is maintained by hand in the Payload admin for the shipping states — no shipping-carrier API integration.- A
cancelledStripe order with noinvoiceNumbernever shows up here — that's apending_paymentorder whose payment failed or timed out (see the backend'sexpirePendingPayments/confirmPayment.ts), not a real Storno (which is always of an already-received, already- invoiced order, so it always has aninvoiceNumber). From the customer's point of view a payment that never went through was never really an order, sogetCustomerOrders/getCustomerOrderDetail(app/lib/customerAuth.ts) filter these out by default — the row still exists in Payload for admin/audit purposes (shown there as "Zahlung fehlgeschlagen", see the backend's own README), just not surfaced to the customer.getCustomerOrderDetailtakes this as an optional 4th param, defaultingfalse—/api/checkout/status/route.ts's post-payment polling deliberately calls it unfiltered, since that flow needs to keep seeing exactly this order (to show "Zahlung fehlgeschlagen, bitte erneut versuchen") for the one case this filter would otherwise hide./api/account/export/route.ts's GDPR export also opts out (false) — a legal completeness export can't silently drop rows. Navbar.tsx'sAccountLink(account icon, always visible in the header itself — not duplicated inside the mobile fullscreen menu, see "Mobile navigation" below) is the only always-reachable way into/konto/*— added after discovering there previously wasn't one:/checkout's own login toggle only renders once the cart already has items (its empty-cart state is an early return with no such toggle), and/bestellbestaetigung's "Meine Bestellungen ansehen" link only exists after a completed order. A returning customer with an empty cart and no recent order had no way to reach the login page at all before this. Fetches auth state client-side via/api/account/me(not through the server-rendered root layout) specifically soapp/layout.tsx— otherwise static/ISR-cacheable — doesn't get forced into per-request dynamic rendering just to know one icon's href; briefly shows the logged-out state on first paint until that fetch resolves. Re-fetches on anep-auth-changedwindowevent (app/lib/auth.ts'sdispatchAuthChanged(), called by every login/logout/checkout-registration call site) — the icon otherwise never noticed a login/logout until a hard reload, sincerouter.refresh()only re-runs Server Components, not an already-mounted Client Component's effects, and this Navbar lives in the root layout and never unmounts across navigations. The icon itself also gets a small brand-colored underline while logged in — same visual language as the desktop nav links' active-state indicator — since the icon alone doesn't otherwise signal session state at a glance.
Feature toggles
A handful of features are gated behind CompanySettings booleans
(Payload admin → Company Settings → Features tab): wishlistEnabled,
searchEnabled, shopFilterEnabled, blogFilterEnabled,
orderFilterEnabled. Each stays entirely invisible in the frontend —
no icon, no filter UI, no route reachable — until its toggle is switched
on; if a feature "isn't showing," check that setting first.
Wishlist
Backend wishlist-items collection (customer + product + optional
variant, unique per combination, customer-scoped access — see the
Payload backend's own README). Frontend: useWishlist.ts (a fetch-based
hook with an optimistic toggle + a window event so every
WishlistButton/the Navbar badge stay in sync — not localStorage-backed
like the cart, since a wishlist needs a logged-in customer to mean
anything), WishlistButton.tsx (heart icon, redirects to login with
?redirect= if logged out), the Navbar's heart icon + count badge
(hidden below sm: — Account+Cart are the only always-visible icons on
true mobile, to avoid crowding the nav), and /konto/merkliste
(MerklisteGrid.tsx, a Client Component grid so removing an item
disappears immediately). Product carries a numericId field (the raw
Payload id) alongside its slug-based id, since wishlist-items.product
is a real numeric relationship, unlike the cart/checkout's slug-keyed
"commerce id".
Buying a wishlisted item never auto-removes it — a customer often
wishlists something specifically to buy it again (gifts, repurchases).
Instead /konto/merkliste marks it: dimmed image + a "Gekauft am [date]"
badge (replacing the "Ausverkauft" badge when both would apply). Removal
stays entirely manual via the same WishlistButton.
WishlistItem.purchasedAt (app/lib/customerAuth.ts) is never persisted
on the backend collection — it's derived fresh on every getWishlist()
call by cross-referencing the customer's own orders
(getPurchasedVariantMap()), matched on the exact (product, variant)
pair, excluding cancelled/returned orders.
Search & filters
Instant search — /api/search runs plain Payload
where[...][contains] queries (Postgres ILIKE) against Products +
Posts, not a dedicated search index; SearchOverlay.tsx (debounced
250ms, grouped results) plus a Navbar search icon (same hidden sm:
treatment as the wishlist icon).
Filters are URL-search-param-driven everywhere (shareable/bookmarkable,
no client-side-only state): /konto/bestellungen's status/paymentStatus/year
dropdowns (OrderFilters.tsx, CustomSelect.tsx); /blog's category
toggle chips (BlogCategoryFilter.tsx — a Client Component using
useRouter() + useTransition(), chips disabled while a navigation is
pending so rapid clicks can't fire overlapping RSC navigations that
commit out of order); /shop's left sidebar (lg:+) / filter bar
(<lg, both rendered by ProductGrid.tsx) — a dual-handle price range
slider (PriceRangeFilter.tsx, drag-to-filter, commits on release not
per drag-frame), category checkboxes (CategoryFilter.tsx, union match
— a product with no category tagged still matches every filter rather
than disappearing, see Product.categories' own comment in
lib/payload.ts), and a "Nur verfügbare Produkte" availability toggle
(same component). Sidebar styled to match AccountNav.tsx's label/
spacing treatment. Both /blog and /shop's grids key their
RevealGroup on the actually-rendered item set — whileInView only
fires once per component instance, and a filter change re-renders the
same page in place rather than remounting it, so without the key a
freshly filtered list could mount into an already-settled RevealGroup
and stay stuck at opacity: 0 (reported as the grid "going blank"
after filtering).
CustomSelect.tsx (app/components/) is a fully custom-styled
dropdown (own trigger + role="listbox" panel, keyboard nav,
click-outside-to-close) used wherever a native <select>'s un-stylable
options popup would look off-brand — also used for checkout's two
country pickers.
Mobile navigation
Below lg (1024px), the hamburger opens a fullscreen panel
(Navbar.tsx, motion.div from the motion/react package already used
elsewhere in this app for NewsletterModal/VersandModal) — not an
in-flow accordion pushed under the header like before. A circular
clip-path reveal (circle(0vmax at 100% 0%) → circle(150vmax at 100% 0%), vmax rather than % so full coverage holds regardless of aspect
ratio) expands from the hamburger's own top-right corner, sweeping toward
the opposite corner last. Nav links fade/rise in with a per-item stagger
once the reveal has visibly opened up. The panel is a sibling of
<header>, not a child — mobileOpen gives the header its own
backdrop-blur, which would make it a new CSS containing block for any
position: fixed descendant and break the panel's fixed-to-viewport
positioning (same class of bug documented on NewsletterModal). No
login/account CTA inside the panel — that's reachable via the account icon
in the header itself, which stays visible above the panel throughout.
- Checkout registration collisions: if the email typed into Card 1
during inline registration already belongs to an existing account,
Payload's create call fails —
registerCustomer()incustomerAuth.tsdetects this specifically (emailExists: trueon the returnedAuthResult, inferred from the field flagged in Payload's validation error, since Payload's own message text doesn't distinguish "duplicate" from other email-field failures) rather than just surfacing a generic error.CheckoutContent.tsx'shandleSubmitreacts by switchingshowLoginon (same inline prompt described above — reuses Card 1's own email field, nothing to pre-fill) and scrolling it into view via auseEffect, instead of leaving the customer stuck with an error and no obvious next step.handleLogin()itself posts the liveemailfield value, not a separateloginEmailstate — there's only ever one email input on this form now. /konto/profiledits name + the one saved default address (deliberately a single address, not a full address book — see the assistant's memory note on optionally expanding this later), changes the password, shows the email-verification banner, and has the GDPR export/delete section. Its "Land"<select>shares the same Payload-configurable country list/checkoutreads (ProfileForm.tsxtakes ashippingCountriesprop,page.tsxfetchesgetShippingCountries()), so a country added/removed in the admin reaches both places.- Cart sync:
app/components/CartSync.tsx(mounted once inapp/layout.tsx) watches the local cart viauseCart()and debounce-POSTs it to/api/account/carton every change; the route 401s (silently, by design) when nobody's logged in. On login (LoginForm.tsx, andCheckoutContent.tsx's inline toggle),mergeServerCartIntoLocal()(app/lib/cart.ts) folds whatever was saved server-side into the local cart by quantity — CartSync's own effect then pushes the merged result back up on its own, so there's no separate explicit "save after merge" call. - Still out of scope: a full address book (single default address only — see the assistant's memory note), single-currency.
Rate limiting
app/lib/rateLimit.ts — an in-memory, per-IP sliding-window limiter
(checkRateLimit(key, {limit, windowMs})), deliberately no Redis: this
app runs as a single Coolify container, so a plain Map is enough and
needs no new infra. Resets on redeploy/restart — acceptable at this
shop's traffic level; revisit with a shared store if this ever scales to
multiple instances. Applied (keyed by X-Forwarded-For, which Caddy
already sets) to /api/account/register, /api/account/login,
/api/account/password, and /api/account/resend-verification.
This complements, not replaces, Payload's own per-account login
lockout (customers.auth, maxLoginAttempts: 5 / lockTime: 10min,
Payload defaults — see the Payload README's "Login rate limiting"
section) — that stops brute-forcing one known email, this stops an IP
spraying attempts across many, or hammering registration.
Session refresh
proxy.ts (project root — Next.js 16 renamed middleware.ts to
proxy.ts; see node_modules/next/dist/docs/01-app/03-api-reference/03-file-conventions/proxy.md
if this ever looks wrong against older docs/training data). Runs on
/checkout, /konto/*, /api/account/*. Decodes (not verifies — Payload
verifies for real on every actual API call) the ep_customer_token
cookie's JWT exp claim; if less than 15 minutes remain, silently calls
Payload's built-in POST /api/customers/refresh-token and swaps in the
refreshed token. Net effect: an actively-browsing customer never gets
logged out mid-session, but someone who walks away is logged out within
~2h of their last request (Payload's tokenExpiration default, unchanged
on the Payload side).
Email verification
Non-blocking by design — see the Payload README's customers.emailVerified
section for why this is a custom flag rather than Payload's built-in
auth.verify: true (short version: that would hard-block login for a
brand-new customer trying to finish the purchase they just registered
mid-checkout for). The initial email is sent by Payload itself (an
afterChange hook on customers, fires on create). /konto/profil's
VerificationBanner.tsx shows a non-blocking "bitte bestätigen" hint with
a resend link when !profile.emailVerified; resending
(/api/account/resend-verification) is sent directly from this app
instead (app/lib/alertAdmin.ts's sendVerificationEmail() — same
Hostinger SMTP, no Payload hook to piggyback on for a plain field update).
Password reset
Unlike email verification, this needed no custom flag — forgotPassword
doesn't block login, so it's Payload's built-in flow as-is (see the
Payload README's customers.auth.forgotPassword section), just with the
email content/destination swapped so the link points here instead of the
Payload admin. /konto/passwort-vergessen (ForgotPasswordForm.tsx) →
POST /api/account/forgot-password → always responds {ok:true}
regardless of whether the email exists (same anti-enumeration reasoning as
Payload's own operation — the route must not leak a different response
shape for "no such account", see its own comment). /konto/passwort-zuruecksetzen?token=...
(ResetPasswordForm.tsx, token read server-side from searchParams —
avoids needing a <Suspense> boundary, unlike the ?code= cart case
above which genuinely needs client-side useSearchParams()) →
POST /api/account/reset-password → Payload logs the customer in on a
successful reset (returns the same {token, user} shape as login), so the
session cookie is set immediately, no separate login step. LoginForm.tsx
links to /konto/passwort-vergessen.
Email templates & Live Preview
All 10 transactional emails (order confirmation, password reset, the
payment-method-switched email, and the 7 order-status-change types below)
read their subject/heading/body/footer wording from Payload's email-templates
collection — editable in the admin without a deploy, with a Live Preview
button using the exact same mechanism as Posts/LegalPages/Testimonials
(useLivePreview() from @payloadcms/live-preview-react, already a
dependency here for LivePostContent.tsx).
Live Preview only reliably updates when popped out into its own
browser tab/window, not in the embedded admin panel. Root cause: the
preview goes through buildPreviewUrl() on the Payload side, which hits
/api/preview on this frontend's own origin to enable Next.js Draft
Mode via a cookie — but admin (payload.mk360.de) and frontend
(einfach-produktiv.mk360.de) are different origins, so from inside the
admin's embedded <iframe> that's a cross-site/third-party context.
Modern browsers (Safari by default, Chrome/Firefox increasingly)
block or partition cookies set inside a cross-site iframe regardless of
which domain actually issued them, so the Draft Mode cookie doesn't
reliably persist there. Once popped into its own window it's a top-level
navigation, not a third-party context, so the cookie sets normally and
everything works. Affects every Live-Preview-enabled collection in this
system (Posts/LegalPages/Testimonials too), not just email templates —
a structural consequence of running admin and frontend on separate
domains, not something fixable at the collection-config level. A real
fix would mean serving both under one domain (reverse proxy path) rather
than two subdomains.
app/lib/emailTemplates.ts— pure string-building functions (renderOrderConfirmationHtml(),renderPasswordResetHtml(),renderOrderStatusHtml()), no server-only or client-only imports. Used both server-side for the real send (orderEmail.ts) and client-side for the Live Preview page — same function, same inputs, so a Live Preview edit and the real sent email are guaranteed to render identically for order-confirmation and payment-method-switched, the only two of the 10 actually sent from this repo (password-reset and all 7 order-lifecycle types are sent by Payload itself, using its own inline templates — see that repo's README for why — so their Live Previews approximate rather than pixel-match). Inline-styled HTML (<table>layout,styleattributes, no Tailwind/<style>block) — most email clients strip external/embedded CSS./email-preview/[type]/page.tsx— entered exclusively from Payload's admin iframe (EmailTemplates.ts'sadmin.livePreview.url), never a real visitor destination (noindex). Always reads withdraft: trueso an unsaved admin edit shows immediately. Renders against sample data (SAMPLE_ORDER/SAMPLE_ORDER_MANUALinemailTemplates.ts, toggleable — the two fixtures cover both the paid and the Vorkasse/Überweisung branches) — unlike the other three Live Preview targets, there's no "current" real order/reset-link to preview against generically. Renders the email HTML inside an<iframe srcDoc>rather thandangerouslySetInnerHTMLdirectly into the page, so the email's own<body>...</body>fragment gets its own real document context instead of nesting invalid HTML inside this page's<body>.- The real send always reads the published template
(
getEmailTemplate()inapp/lib/payload.ts,draftunset) — a Live Preview edit never affects a live customer email until actually saved. activetoggle — each row has anactivecheckbox (backendEmailTemplates.ts); off suppresses the send entirely, checked here fororder-confirmation(orderEmail.ts'ssendOrderConfirmationEmail()) before the hardcoded default-wording fallback ever applies. See the backend repo's own README ("Email templates: active/inactive toggle") for the full picture, including thepassword-resetexception (Payload's coreforgotPasswordoperation has no hook to actually cancel that send).npx payload run src/seed-email-templates.ts(Payload repo) seeds defaults for all 10 rows — deliberately on-brand and a little playful ("Bestellt!" / "Kein Drama." / "Unterwegs!" / "Storniert." / "Alles klar." / "Alles erledigt.", not generic transactional-email boilerplate), matching this site's voice elsewhere (see e.g. the testimonial copy). Two intentional exceptions to that voice: the email-verification mail (Payload-side, plain functional copy — see that README) and the Stornorechnung/Gutschrift PDFs (formal legal documents, no brand voice by design). Editable in the admin afterward regardless.sendOrderConfirmationEmail()also has a hardcoded fallback for the rare case a fresh install's order arrives before that seed has run.emailShell()'s visual design deliberately echoes/bestellbestaetigung(the on-screen order confirmation page) rather than reading as a generic transactional email: same warm cream background/brand color asglobals.css's--color-*tokens (hardcoded here as literal hex — email clients don't resolvevar()either), a circular brand-tinted icon (✓ for order-confirmation, ✉ for password-reset) echoing that page's own success-icon treatment, a thin brand-colored divider under the heading, and a Georgia/serif heading font as the closest reliably-available approximation of the site's Playfair Display (most email clients strip@font-face/external font requests, so an actual web font isn't an option here). Not shared code with the React page — this is plain inline-styled HTML built for email-client compatibility (nested<table>s, no flexbox) — just matched by eye.- Footer carries a full legal Anbieterkennzeichnung, not just a brand
line.
emailShell()takes afooterLines: string[]array built bybuildLegalFooterLines(seller)—sellerName,sellerStreet,sellerZip/sellerCity(+sellerCountryif not Germany),E-Mail: sellerEmail, andUSt-IdNr.: vatIdwhen set — the same admin-editablecompany-settingsfields the invoice PDFs already use, rather than a literal"einfach produktiv · admin@mk360.de"string.orderEmail.tsandalertAdmin.ts(both the resend-verification mail and the plain-text critical-alert mail) all fetchcompany-settingsonce (getSellerForInvoice()) and pass thesellerobject straight into the render functions, which callbuildLegalFooterLines()themselves — one place composes the footer, not each call site. The Payload-side sends (password-reset, the 7 order-lifecycle emails, the initial verification email) get the equivalent treatment via that repo's ownsrc/lib/sellerInfo.ts'sbuildLegalFooterLines()/getSellerFooterLines()— see that repo's README for its own copy of this section. Live Preview passesseller: null, which falls back toDEFAULT_LEGAL_FOOTER_LINES(a placeholder Anbieterkennzeichnung) since there's no real order/tenant context there to fetch against. Notecompany-settingscurrently has no Handelsregister court/number or Geschäftsführer field — fine for a sole proprietorship, but would need adding if the business becomes a registered legal form (GmbH etc.), see that collection's own field list above. Fromdisplay name is dynamic (seller.sellerName), the address itself staysadmin@mk360.de.Reply-Tois set toseller.sellerEmailso a customer's reply actually reaches the seller regardless of the From address. The address isn't also switched tosellerEmailbecause that domain isn't confirmed SPF-authorized on the Hostinger account backingadmin@mk360.deyet — doing so without that confirmation risks order-confirmation/verification mail landing in spam or bouncing outright. BothorderEmail.tsandalertAdmin.ts'ssendVerificationEmailset this the same way;sendCriticalAlert(internal, admin@mk360.de to itself) doesn't need it.
GDPR self-service
/konto/profil's "Konto & Daten" section:
- Export (
/api/account/export, GET) — profile + every order's full detail as one downloadable JSON (Content-Disposition: attachment). Genuinely complete, not a summary — Art. 20 data portability. - Delete (
/api/account/delete, POST, password re-verified via a real login attempt first) — deletes thecustomersdocument. Past orders are not touched:orders.customerisON DELETE SET NULLin Payload, so an order keeps its own name/address/items snapshot (already stored independently for exactly this kind of reason) for tax-retention purposes (§147 AO / GDPR Art. 17(3)(b) explicitly permits this) — only the account/login itself disappears. The UI says this explicitly before deleting, not as a surprise afterward.
Order cancellation & returns
/konto/bestellungen/[orderNumber] shows one self-service button when
applicable: "Bestellung stornieren" while status === 'received', or
"Rücksendung anfragen" while status is 'shipped' or 'delivered'
(customerOrderAction() in customerAuth.ts decides which, if any).
Posts to /api/account/orders/[orderNumber] (PATCH), which re-checks the
transition is still valid (friendlier error than a bare 403 if it's gone
stale — two tabs open, order shipped in the meantime) before calling
requestOrderStatusChange().
Requesting a return opens an inline form (OrderActionButton.tsx), not
just a confirm dialog — partial returns are supported: a quantity
input per order line (0 up to that line's ordered quantity) plus a
required reason textarea. At least one line must have a nonzero quantity
to submit. Cancel stays a plain window.confirm() — it's a lower-stakes,
whole-order-only action (before shipping, often just a change of mind),
no quantity picker or reason needed there.
The route (/api/account/orders/[orderNumber] PATCH) reconstructs the
order's full items array before sending it to Payload — only the
requested lines' returnQuantity differs from what's already stored,
every other field (product/price/tax rate/etc.) is passed through
unchanged. This isn't optional: Payload's array field expects every
required sub-field present on each row, and the field-lock hook (see
below) specifically checks that only returnQuantity changed — a sparse
{ returnQuantity: 2 }-only patch would fail both.
The real security boundary is in Payload, not here: orders.access.update
already scoped a customer's JWT to their own order, but with no
field-level restriction — before this stage, a logged-in customer could in
principle PATCH any field of their own order (total, items,
anything), just because nothing in the frontend had ever exercised that
path yet. Orders.ts's beforeChange hook rejects a customer-authenticated
update unless the change is limited to status (plus returnReason and
each item's returnQuantity, bounded 0..quantity, alongside a
return_requested transition), via an allowed transition. See the
Payload README's own writeup for the full detail — including
orderUpdateValidation.ts, where this logic now lives as a unit-tested
pure function.
No hard 14-day return-window check in code (no separately tracked delivery
date exists yet) — relies on the existing /widerruf legal text plus
manual admin review. No automatic refund (no payment provider exists yet)
— a return/cancellation request is just captured structurally instead of
arriving by email/phone; the admin still processes it by hand in the
Payload admin.
Reaching status: 'cancelled' or status: 'returned' also auto-generates
a Stornorechnung/Gutschrift correction-invoice PDF, attached to
that status's customer email (Payload-side, see the Payload README's "How
a Stornorechnung/Gutschrift relates to the original invoice" section —
this is not frontend code). Stornorechnung is always a full reversal
(cancellation is always pre-shipping, whole order, shipping included).
Gutschrift reflects only the returned quantities — full or partial —
excludes shipping (already delivered by the time a return is
possible) and never reprorates the original discount (confirmed
policy, not a default: the discount stays with whatever's kept). Either
way, this is a document only, not a money movement: an actual refund
still has to happen manually, since no payment provider exists yet to
capture or reverse a real charge.
Status-change emails
Several orders lifecycle events trigger a customer email — the
status transitions shipped/cancelled/return_requested/returned/
delivered, plus a tracking number being added or corrected
(order-tracking-added/order-tracking-corrected) — not
processing/received, which aren't customer-actionable. Sent
entirely from Payload, not this repo —
orders.ts's afterChange hook there compares doc.status to
previousDoc.status and fires regardless of who made the change (an admin
setting shipped/returned in the Payload admin, or the customer's own
self-service cancel/return-request above land on the exact same hook). See
the Payload README's orders.ts section for the actual send logic,
including the Stornorechnung/Gutschrift attachment on cancelled/returned.
This repo's only involvement is the Live Preview approximation — same
established gap as password-reset (see below): renderOrderStatusHtml()
in app/lib/emailTemplates.ts and the corresponding entries in
/email-preview/[type]'s VALID_TYPES exist purely so an admin editing
any of these order-lifecycle rows in the email-templates collection
sees a reasonable preview — the actual sent HTML is Payload's own
src/lib/emailShell.ts render, not this file's.
Monitoring & alerting
Base uptime (is the site/Payload reachable at all) is already covered by
existing Uptime Kuma HTTP monitors with email alerting (monitor.mk360.de
— see ~/dev/README.md's Kuma section) and isn't part of this app. What's
new here is the one failure mode Kuma structurally can't see: the site is
up, a customer completes checkout, and the order still doesn't get
persisted (createOrder() returns null in /api/checkout/route.ts).
That path calls app/lib/alertAdmin.ts's sendCriticalAlert() — its own,
independent SMTP connection (same Hostinger account, but not routed
through Payload, since Payload being the actual problem is one of the
scenarios this needs to still report on). Fire-and-forget, its own
try/catch, never blocks or fails the actual error response the customer
sees.
/api/health (GET) — checks Payload's public API is actually reachable
(3s timeout), not just that this page rendered; added as a Kuma HTTP
monitor in the existing "Content & API" group (~/dev/README.md's
documented sqlite3-insert method, Kuma 1.x has no REST API for this).
/shop itself also has its own Kuma HTTP monitor ("einfach-produktiv Shop
(Produkte, Varianten, Lagerbestand)", same "Content & API" group) — added
once the shop grid started doing real work at render time (fullyOutOfStock
across a product's variants, effectivePrice()), not just listing static
content; /api/health alone only proves Payload is reachable, not that this
specific page still renders. The Payload jobs queue's own failure monitor
(/api/health/jobs, hasError: true in the last 24h) already covers all
five scheduled jobs generically by task-agnostic query — the four added this
session (low-stock digest, stale-unverified-accounts report, weekly revenue
report, expired-discount-code cleanup) needed no monitor changes of their
own; see the Payload README's "Jobs Queue" section.
Tests
npm run test:unit (Vitest, node environment, no jsdom/Next.js runtime
needed) — no test infrastructure existed in this repo before; started with
the pure logic most likely to silently produce wrong numbers on a live
order, not attempted exhaustive coverage:
app/lib/__tests__/cartTotals.test.ts— subtotal/discount/shipping math (computeSubtotal,computeCartTotals), incl. the fixed-discount clamp andcompareAtPrice-based savings display being independent of the discount-code math.app/lib/__tests__/bundleContents.test.ts—describeBundleContents(), extracted out ofapp/api/checkout/route.tsinto its own module (app/lib/bundleContents.ts) specifically so it's importable from a test — Next.jsroute.tsfiles only allow HTTP-method (+ a few config) exports, not arbitrary named ones.
The original invoice's isPaidImmediately()/groupByTaxRate() and the
Gutschrift/Stornorechnung money math (resolveLineItems(),
groupByTaxRate()) both live in @einfach-produktiv/invoicing now (see
"Invoice PDFs" above), not in this repo — their tests moved with them
into that package's own src/__tests__/, run via that package's own
vitest, not this repo's test:unit. The Payload-side orders.ts
field-lock security logic is still tested in the Payload backend's
own test:unit — see that repo's README's own "Tests" section, since
that's where that logic actually lives.
Deployment
- Dockerfile: 3-stage build (
deps→builder→runner) with BuildKit cache mounts, producing a ~100 MB standalone image. This is the referenceDockerfilecopied when scaffolding new projects on this VPS (see the infra repo's new-project workflow). - Deploys via
git push→ Gitea webhook → Caddy/deploy/einfach-produktivbridge → Coolify API → rebuild + restart. Full mechanics in~/dev/README.md.
Related design source
~/dev/einfach-produktiv/mockups/— Figma-stage mockup PNGs~/dev/einfach-produktiv/styleguide.md— design tokens (colors, type, spacing)~/dev/einfach-produktiv/einfachproduktiv_figma_prompt_guide_v3.md— Figma rebuild prompt guide