diff --git a/README.md b/README.md
index 15e7ece..e4a6cc9 100644
--- a/README.md
+++ b/README.md
@@ -50,8 +50,15 @@ header). `SMTP_USER`/`SMTP_PASSWORD`
(no safe default — required for `app/lib/alertAdmin.ts`'s critical-failure
alerts and resend-verification emails; **does not** need to match anything
on the Payload side — this app's SMTP connection is deliberately
-independent, see the "Monitoring & alerting" section). Set in Coolify's
-app settings for production, not in a committed `.env`.
+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
@@ -281,7 +288,8 @@ check against Payload's public API, unlike most content on this site.
its `orderNumber`/`orderDateIso` now come back from that Payload create
call, not generated client-side.
- **Order confirmation email** is sent from `/api/checkout/route.ts`
- right after a successful `createOrder()` — fire-and-forget
+ right after a successful `createOrder()` for Überweisung orders only —
+ fire-and-forget
(`app/lib/orderEmail.ts`'s `sendOrderConfirmationEmail()`), never blocks
or fails the checkout response itself; a send failure alerts admin
instead (`sendCriticalAlert`, lower severity than the "order not
@@ -290,10 +298,114 @@ check against Payload's public API, unlike most content on this site.
`email-templates` collection — 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.
-- Still not built: real payment processing — the checkout button is
- labelled "zahlungspflichtig" but nothing actually captures a payment
- yet. See `project_backend_checkout_plan` in the assistant's own memory.
+ 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`'s `provider` field** (Payload, admin-only) drives the
+ branch — `'manual'` (Überweisung) or `'stripe'` (Kreditkarte/PayPal).
+ `app/lib/payload.ts`'s `getPaymentMethods()` 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()` in `app/lib/payload.ts`,
+ used by `CheckoutContent.tsx`). Both admin rows still exist and both
+ still need `provider: 'stripe'` — this is a display-layer grouping, not
+ a data change. Reasoning: the PaymentIntent is created with
+ `automatic_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.
+- **`paymentMethodTitle` is snapshotted as a neutral `"Online-Zahlung"`**
+ at order-creation time for the `stripe` branch (the customer hasn't
+ picked an instrument yet at that point) and **refined to the real one**
+ (`"Kreditkarte"`/`"PayPal"`) once Stripe reports it —
+ `resolveStripePaymentMethodLabel()` in `stripeProvider.ts` reads the
+ confirmed PaymentIntent's `payment_method.type` in the webhook route and
+ passes it to `confirm-payment` as an optional field. Best-effort: an
+ unresolved label just leaves the neutral title in place. The
+ `/checkout/verarbeitung` polling page also patches this into the
+ provisional `sessionStorage` snapshot before promoting it, so
+ `/bestellbestaetigung` shows 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 own `providerReference` field 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
+ with `status: '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.tsx`** renders in place of the
+ address form once `/api/checkout` returns `requiresPayment: true` —
+ Stripe's `PaymentElement` (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 via
+ `return_url=/checkout/verarbeitung?orderNumber=...`.
+- **`app/api/webhooks/stripe/route.ts`** — the real inbound webhook.
+ Verifies `stripe-signature` against `STRIPE_WEBHOOK_SECRET`, reads the
+ **raw** body (never `.json()` — the signature is computed over the exact
+ bytes), handles `payment_intent.succeeded`/`.payment_failed`, and calls
+ the backend's `POST /api/orders/:id/confirm-payment` (guarded by
+ `PAYMENT_WEBHOOK_SECRET`, a secret distinct from `ORDER_SERVICE_SECRET`
+ on 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 to `received`, 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't
+ `alreadyProcessed: true` — a repeat webhook delivery must never resend
+ it), mirroring exactly what the checkout route already does inline for
+ a manual/Überweisung order.
+- **`/checkout/verarbeitung`** (`VerarbeitungContent.tsx`) is the
+ `return_url` target. Neither a client-side `confirmPayment()` 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) until `paymentStatus` flips to `paid`, then promotes the
+ provisional `sessionStorage` snapshot (`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. On `failed`/`cancelled` it 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.
### VAT display
@@ -949,15 +1061,26 @@ unsynced.
- **`app/lib/brevo.ts`** — the only thing that talks to Brevo.
`upsertNewsletterContact(email, source)` calls Brevo's
- `POST /v3/contacts` with `updateEnabled: true` (204 for both a new and
- an existing contact — no special-casing needed) and a
- `BREVO_LIST_ID`-scoped list membership. `source` (`"checkout"` |
+ **double opt-in** endpoint, `POST /contacts/doubleOptinConfirmation`
+ (switched 2026-07-25 from the plain `POST /v3/contacts` single-opt-in
+ upsert this originally shipped with) — this only ever *requests* a
+ subscription; Brevo sends a confirmation email (the template at
+ `BREVO_DOUBLE_OPTIN_TEMPLATE_ID`, configured as the list's Double Opt-in
+ template in Brevo's own UI) and only actually adds the contact to
+ `BREVO_LIST_ID` once they click through. `source` (`"checkout"` |
`"newsletter-page"` | `"newsletter-modal"` | `"newsletter-hero"` |
`"challenge"`) is stored as the contact's `OPT_IN_SOURCE` attribute 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 upsert (no error at all, just never stored) rather
- than rejecting the request.
+ 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-confirmed`
+ via `BREVO_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/verarbeitung`
+ which polls actual payment status.
- **`app/lib/useNewsletterSignup.ts`** — the shared email/consent/submit
state + on-blur validation + refocus-on-invalid-submit behind all four
forms (same "state of the art, simple" input-quality bar as checkout's
@@ -983,7 +1106,10 @@ unsynced.
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_ID` set in the deployment environment
- — confirmed live end-to-end 2026-07-23.
+ — confirmed live end-to-end 2026-07-23. As of the double-opt-in switch,
+ also needs `BREVO_DOUBLE_OPTIN_TEMPLATE_ID` (no safe default — every
+ signup silently no-ops without it) and optionally
+ `BREVO_DOI_REDIRECT_URL`.
## Orders & customer accounts
diff --git a/app/api/checkout/route.ts b/app/api/checkout/route.ts
index dfcad22..86141ed 100644
--- a/app/api/checkout/route.ts
+++ b/app/api/checkout/route.ts
@@ -12,6 +12,7 @@ import { normalizeVatId, isValidVatId } from "../../lib/vatId";
import { checkVatIdViaVies } from "../../lib/vies";
import { computeExemptTotals, destinationCountry, isExemptionEligibleCountry } from "../../lib/vatExemption";
import { upsertNewsletterContact } from "../../lib/brevo";
+import { paymentProvider, isPaymentTestMode } from "../../lib/payments";
// Plain float arithmetic on money (quantity × unitPrice summed across
// lines, a percent discount, subtracting/adding those together) drifts
@@ -281,6 +282,35 @@ export async function POST(request: Request) {
const finalShippingCost = exemptTotals?.shippingCost ?? shippingCost;
const total = roundMoney(Math.max(0, finalSubtotal - discountAmount) + finalShippingCost);
+ // Gated-payment branch (Kreditkarte/PayPal today) — see
+ // spicy-leaping-pizza.md §3. The PaymentIntent is created BEFORE the
+ // order so its id can be persisted onto the order at creation time
+ // (providerReference), rather than needing a second authenticated
+ // update call that doesn't otherwise exist from this service. Stripe
+ // generates a PaymentIntent id independent of any order existing yet.
+ const requiresPayment = paymentMethod.provider === "stripe";
+ let providerReference: string | undefined;
+ let clientSecret: string | undefined;
+ if (requiresPayment) {
+ try {
+ const intent = await paymentProvider.createPaymentIntent({
+ amountCents: Math.round(total * 100),
+ currency: "eur",
+ customerEmail: body.email,
+ description: `einfach produktiv Bestellung — ${body.firstName} ${body.lastName}`,
+ });
+ providerReference = intent.providerReference;
+ clientSecret = intent.clientSecret;
+ } catch (err) {
+ sendCriticalAlert("Zahlung konnte nicht vorbereitet werden", {
+ customerEmail: body.email,
+ total,
+ error: String(err),
+ });
+ return NextResponse.json({ ok: false, reason: "Die Zahlung konnte gerade nicht vorbereitet werden." }, { status: 500 });
+ }
+ }
+
const order = await createOrder({
customerId: customer.id,
customerFirstName: body.firstName,
@@ -313,10 +343,21 @@ export async function POST(request: Request) {
subtotal: finalSubtotal,
shippingCost: finalShippingCost,
shippingMethodTitle: shippingMethod.title,
- paymentMethodTitle: paymentMethod.title,
+ // The checkout UI collapses Kreditkarte/PayPal into one "Online-
+ // Zahlung" pre-selection (see groupPaymentMethodsForCheckout) — the
+ // customer hasn't actually chosen an instrument yet at this point,
+ // Stripe's Payment Element does that next. Snapshotting the specific
+ // resolved row's title here would just record whichever row happened
+ // to be the group's representative id, not what was really picked.
+ // The webhook route refines this to the real instrument
+ // ("Kreditkarte"/"PayPal") once Stripe reports it, via confirm-payment.
+ paymentMethodTitle: requiresPayment ? "Online-Zahlung" : paymentMethod.title,
discountCode: body.discountCode || null,
discountAmount,
total,
+ ...(requiresPayment
+ ? { status: "pending_payment" as const, paymentProvider: "stripe" as const, paymentStatus: "pending" as const, providerReference }
+ : {}),
});
if (!order) {
// The worst-case failure in this whole flow: the customer went
@@ -334,68 +375,91 @@ export async function POST(request: Request) {
return NextResponse.json({ ok: false, reason: "Bestellung konnte nicht gespeichert werden." }, { status: 500 });
}
- // Fire-and-forget — a failed confirmation email must never undo an
- // already-successful order or block the response the customer is
- // waiting on. Lower severity than the "order lost" alert above (the
- // order itself is safe either way), but still worth knowing about, since
- // it's the one thing that would otherwise fail completely silently.
- sendOrderConfirmationEmail(
- {
- orderNumber: order.orderNumber,
- createdAt: order.createdAt,
- invoiceNumber: order.invoiceNumber,
- invoiceIssuedAt: order.invoiceIssuedAt,
- customerFirstName: body.firstName,
- customerLastName: body.lastName,
- companyName: body.companyName || undefined,
- vatId: normalizedVatId,
- vatExempt,
- kleinunternehmer,
- deliveryMethod: body.deliveryMethod,
- street: body.street,
- packstationNumber: body.packstationNumber,
- postNumber: body.postNumber,
- zip: body.zip,
- city: body.city,
- country: body.country,
- hasDifferentShippingAddress: Boolean(body.hasDifferentShippingAddress),
- shippingFirstName: body.shippingFirstName,
- shippingLastName: body.shippingLastName,
- shippingDeliveryMethod: body.shippingDeliveryMethod,
- shippingStreet: body.shippingStreet,
- shippingPackstationNumber: body.shippingPackstationNumber,
- shippingPostNumber: body.shippingPostNumber,
- shippingZip: body.shippingZip,
- shippingCity: body.shippingCity,
- shippingCountry: body.shippingCountry,
- paymentMethodTitle: paymentMethod.title,
- items: items.map((i) => ({
- productName: i.productName,
- quantity: i.quantity,
- unitPrice: i.unitPrice,
- imageUrl: i.imageUrl,
- taxRatePercent: i.taxRatePercent,
- bundleContents: i.bundleContents,
- variantName: i.variantName,
- })),
- subtotal: finalSubtotal,
- shippingCost: finalShippingCost,
- discountAmount,
- discountCode: body.discountCode || null,
- total,
- },
- body.email,
- ).catch((err) => {
- sendCriticalAlert("Bestätigungs-Mail konnte nicht gesendet werden", {
- orderNumber: order.orderNumber,
- customerEmail: body.email,
- error: String(err),
+ if (requiresPayment && providerReference) {
+ // Best-effort — see stripeProvider.attachOrderMetadata's own comment.
+ // Not fatal: the order's own `providerReference` field (already
+ // persisted above) remains the source of truth for the
+ // expirePendingPayments cleanup job either way; this only speeds up
+ // the webhook's fast path.
+ await paymentProvider.attachOrderMetadata(providerReference, { orderId: String(order.id), orderNumber: order.orderNumber }).catch((err) => {
+ sendCriticalAlert("Zahlungsmetadaten konnten nicht verknüpft werden", {
+ orderNumber: order.orderNumber,
+ providerReference,
+ error: String(err),
+ });
});
- });
+ }
+
+ // Deferred for gated payment methods (Kreditkarte/PayPal) until the
+ // webhook confirms payment — see spicy-leaping-pizza.md §3/§4. Sent
+ // from the backend's confirm-payment endpoint instead, at that point.
+ // Unchanged for Überweisung: fires immediately, exactly as before.
+ if (!requiresPayment) {
+ // Fire-and-forget — a failed confirmation email must never undo an
+ // already-successful order or block the response the customer is
+ // waiting on. Lower severity than the "order lost" alert above (the
+ // order itself is safe either way), but still worth knowing about, since
+ // it's the one thing that would otherwise fail completely silently.
+ sendOrderConfirmationEmail(
+ {
+ orderNumber: order.orderNumber,
+ createdAt: order.createdAt,
+ invoiceNumber: order.invoiceNumber as string,
+ invoiceIssuedAt: order.invoiceIssuedAt as string,
+ customerFirstName: body.firstName,
+ customerLastName: body.lastName,
+ companyName: body.companyName || undefined,
+ vatId: normalizedVatId,
+ vatExempt,
+ kleinunternehmer,
+ deliveryMethod: body.deliveryMethod,
+ street: body.street,
+ packstationNumber: body.packstationNumber,
+ postNumber: body.postNumber,
+ zip: body.zip,
+ city: body.city,
+ country: body.country,
+ hasDifferentShippingAddress: Boolean(body.hasDifferentShippingAddress),
+ shippingFirstName: body.shippingFirstName,
+ shippingLastName: body.shippingLastName,
+ shippingDeliveryMethod: body.shippingDeliveryMethod,
+ shippingStreet: body.shippingStreet,
+ shippingPackstationNumber: body.shippingPackstationNumber,
+ shippingPostNumber: body.shippingPostNumber,
+ shippingZip: body.shippingZip,
+ shippingCity: body.shippingCity,
+ shippingCountry: body.shippingCountry,
+ paymentMethodTitle: paymentMethod.title,
+ items: items.map((i) => ({
+ productName: i.productName,
+ quantity: i.quantity,
+ unitPrice: i.unitPrice,
+ imageUrl: i.imageUrl,
+ taxRatePercent: i.taxRatePercent,
+ bundleContents: i.bundleContents,
+ variantName: i.variantName,
+ })),
+ subtotal: finalSubtotal,
+ shippingCost: finalShippingCost,
+ discountAmount,
+ discountCode: body.discountCode || null,
+ total,
+ },
+ body.email,
+ ).catch((err) => {
+ sendCriticalAlert("Bestätigungs-Mail konnte nicht gesendet werden", {
+ orderNumber: order.orderNumber,
+ customerEmail: body.email,
+ error: String(err),
+ });
+ });
+ }
// Fire-and-forget, same reasoning as the confirmation email above — a
// failed marketing sync is not worth failing checkout over, and doesn't
// even need a critical alert (nothing customer-facing depends on it).
+ // Not gated on payment confirmation — a newsletter signup intent isn't
+ // an order-fulfillment concern, unlike the confirmation email/invoice.
if (body.newsletterOptIn) {
upsertNewsletterContact(body.email, "checkout").catch(() => {});
}
@@ -403,9 +467,24 @@ export async function POST(request: Request) {
return NextResponse.json({
ok: true,
orderNumber: order.orderNumber,
+ orderId: order.id,
orderDateIso: order.createdAt,
+ ...(requiresPayment
+ ? {
+ requiresPayment: true as const,
+ clientSecret,
+ testMode: isPaymentTestMode,
+ // Only surfaced in test mode — PaymentStep's "Testzahlung"
+ // buttons need it to call the test-confirm route directly,
+ // since there's no real Stripe redirect to carry it back
+ // through. A real PaymentIntent id isn't secret (only its
+ // client_secret is), but there's no reason to expose it to the
+ // client outside test mode either.
+ ...(isPaymentTestMode ? { providerReference } : {}),
+ }
+ : {}),
shippingCost: finalShippingCost,
- paymentMethodTitle: paymentMethod.title,
+ paymentMethodTitle: requiresPayment ? "Online-Zahlung" : paymentMethod.title,
discountCode: body.discountCode || null,
discountAmount,
vatExempt,
diff --git a/app/api/checkout/status/route.ts b/app/api/checkout/status/route.ts
new file mode 100644
index 0000000..ab74e66
--- /dev/null
+++ b/app/api/checkout/status/route.ts
@@ -0,0 +1,31 @@
+import { NextResponse } from "next/server";
+import { getSessionCustomer, getCustomerOrderDetail } from "../../../lib/customerAuth";
+
+// Polled by /checkout/verarbeitung after a Payment Element redirect
+// returns — see spicy-leaping-pizza.md §3. Requires the customer's own
+// session (checkout is "Konto Pflicht", so one always exists by the time
+// this page is reachable) rather than accepting a bare orderNumber, so a
+// guessed/leaked order number can't be used to probe another customer's
+// payment status.
+export async function GET(request: Request) {
+ const session = await getSessionCustomer();
+ if (!session) return NextResponse.json({ ok: false, reason: "Bitte zuerst einloggen." }, { status: 401 });
+
+ const orderNumber = new URL(request.url).searchParams.get("orderNumber");
+ if (!orderNumber) return NextResponse.json({ ok: false, reason: "orderNumber fehlt." }, { status: 400 });
+
+ const order = await getCustomerOrderDetail(session.token, session.customer.id, orderNumber);
+ if (!order) return NextResponse.json({ ok: false, reason: "Bestellung nicht gefunden." }, { status: 404 });
+
+ return NextResponse.json({
+ ok: true,
+ status: order.status,
+ paymentStatus: order.paymentStatus,
+ // Refined from the checkout-time "Online-Zahlung" placeholder to the
+ // actual instrument (Kreditkarte/PayPal) once confirm-payment sets it
+ // — see resolveStripePaymentMethodLabel's own comment. Returned here
+ // so VerarbeitungContent can patch the pending sessionStorage snapshot
+ // before promoting it, so /bestellbestaetigung shows the real one.
+ paymentMethodTitle: order.paymentMethodTitle,
+ });
+}
diff --git a/app/api/webhooks/stripe/route.ts b/app/api/webhooks/stripe/route.ts
new file mode 100644
index 0000000..2f7632d
--- /dev/null
+++ b/app/api/webhooks/stripe/route.ts
@@ -0,0 +1,90 @@
+import { NextResponse } from "next/server";
+import Stripe from "stripe";
+import { verifyStripeWebhookSignature, resolveStripePaymentMethodLabel } from "../../../lib/payments/stripeProvider";
+import { sendConfirmedPaymentEmail, type ConfirmPaymentOrderSnapshot } from "../../../lib/payments/confirmPaymentEmail";
+import { sendCriticalAlert } from "../../../lib/alertAdmin";
+
+const PAYLOAD_URL = process.env.PAYLOAD_URL || "https://payload.mk360.de";
+const PAYMENT_WEBHOOK_SECRET = process.env.PAYMENT_WEBHOOK_SECRET || "";
+
+// Real Stripe webhook — see spicy-leaping-pizza.md §4. Never reachable in
+// PAYMENT_TEST_MODE in practice (no real Stripe account sends events
+// here then), but left unconditional rather than gated on the env var —
+// an invalid/missing signature already fails closed on its own.
+export async function POST(request: Request) {
+ // Raw body only — request.json() would consume/reparse the stream and
+ // Stripe's signature is computed over the exact original bytes.
+ const rawBody = await request.text();
+ const signature = request.headers.get("stripe-signature");
+ if (!signature) return NextResponse.json({ ok: false }, { status: 400 });
+
+ const event = verifyStripeWebhookSignature(rawBody, signature);
+ if (!event) return NextResponse.json({ ok: false, reason: "invalid signature" }, { status: 400 });
+
+ if (event.type !== "payment_intent.succeeded" && event.type !== "payment_intent.payment_failed") {
+ // Stripe sends many event types we don't act on (e.g.
+ // payment_intent.created, charge.*) — ack them so Stripe stops
+ // retrying something we were never going to process.
+ return NextResponse.json({ ok: true, ignored: event.type });
+ }
+
+ const intent = event.data.object as Stripe.PaymentIntent;
+ const providerReference = intent.id;
+ const orderId = intent.metadata?.orderId;
+ const paymentStatus = event.type === "payment_intent.succeeded" ? "paid" : "failed";
+
+ if (!orderId) {
+ // stripeProvider.attachOrderMetadata (called right after order
+ // creation in /api/checkout) failed to complete for this
+ // PaymentIntent — the order's own `providerReference` field is still
+ // the source of truth and expirePendingPayments will reconcile it
+ // eventually, but that's a multi-hour fallback, not instant. Alert
+ // now rather than silently relying on the cleanup job.
+ sendCriticalAlert("Stripe-Webhook ohne orderId-Metadaten", { providerReference, paymentStatus, eventType: event.type });
+ // Non-2xx so Stripe retries — a later retry might land after the
+ // metadata attach (which races the checkout response) has caught up.
+ return NextResponse.json({ ok: false, reason: "orderId metadata missing" }, { status: 409 });
+ }
+
+ // Best-effort — see resolveStripePaymentMethodLabel's own comment. Only
+ // meaningful on the "paid" path; a failed payment never gets a
+ // paymentMethodTitle refinement (the order becomes 'cancelled' outright).
+ const paymentMethodTitle = paymentStatus === "paid" ? await resolveStripePaymentMethodLabel(intent) : undefined;
+
+ const res = await fetch(`${PAYLOAD_URL}/api/orders/${orderId}/confirm-payment`, {
+ method: "POST",
+ headers: {
+ "x-payment-webhook-secret": PAYMENT_WEBHOOK_SECRET,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ paymentStatus,
+ providerReference,
+ paidAt: new Date().toISOString(),
+ ...(paymentMethodTitle ? { paymentMethodTitle } : {}),
+ }),
+ }).catch((err) => {
+ sendCriticalAlert("confirm-payment-Aufruf ans Backend fehlgeschlagen", { orderId, providerReference, error: String(err) });
+ return null;
+ });
+
+ if (!res || !res.ok) {
+ // Non-2xx on purpose — lets Stripe's own retry schedule (~3 days)
+ // provide resilience instead of building an internal retry queue.
+ return NextResponse.json({ ok: false }, { status: 502 });
+ }
+
+ const data: { ok: boolean; alreadyProcessed?: boolean; order?: ConfirmPaymentOrderSnapshot } = await res.json();
+
+ // Fire-and-forget, same reasoning as the checkout route's own send: a
+ // failed confirmation email must never turn an already-successful
+ // payment confirmation into a non-2xx response (that would make Stripe
+ // retry a webhook we've already fully processed). `alreadyProcessed`/
+ // missing `order` means this is a repeat delivery — see confirmPayment.ts's
+ // own comment on why the email must not be sent twice.
+ if (data.order && !data.alreadyProcessed) {
+ void sendConfirmedPaymentEmail(data.order);
+ }
+
+ return NextResponse.json({ ok: true });
+}
diff --git a/app/api/webhooks/stripe/test-confirm/route.ts b/app/api/webhooks/stripe/test-confirm/route.ts
new file mode 100644
index 0000000..caf2a88
--- /dev/null
+++ b/app/api/webhooks/stripe/test-confirm/route.ts
@@ -0,0 +1,55 @@
+import { NextResponse } from "next/server";
+import { isPaymentTestMode } from "../../../../lib/payments";
+import { sendConfirmedPaymentEmail, type ConfirmPaymentOrderSnapshot } from "../../../../lib/payments/confirmPaymentEmail";
+import { sendCriticalAlert } from "../../../../lib/alertAdmin";
+
+const PAYLOAD_URL = process.env.PAYLOAD_URL || "https://payload.mk360.de";
+const PAYMENT_WEBHOOK_SECRET = process.env.PAYMENT_WEBHOOK_SECRET || "";
+
+// Test-mode stand-in for the real Stripe webhook — see
+// spicy-leaping-pizza.md §7. Drives the exact same backend confirm-payment
+// endpoint the real webhook calls, just without a real Stripe event/
+// signature (there is none to verify in test mode). Hard-gated: must
+// 404 whenever PAYMENT_TEST_MODE isn't explicitly on, so this can never
+// become an unauthenticated "mark any order paid" endpoint in production.
+export async function POST(request: Request) {
+ if (!isPaymentTestMode) {
+ return NextResponse.json({ ok: false }, { status: 404 });
+ }
+
+ const body = await request.json().catch(() => null);
+ const orderId = body?.orderId;
+ const providerReference = body?.providerReference;
+ const paymentStatus = body?.paymentStatus === "failed" ? "failed" : "paid";
+ if (!orderId || !providerReference) {
+ return NextResponse.json({ ok: false, reason: "orderId und providerReference erforderlich." }, { status: 400 });
+ }
+
+ const res = await fetch(`${PAYLOAD_URL}/api/orders/${orderId}/confirm-payment`, {
+ method: "POST",
+ headers: {
+ "x-payment-webhook-secret": PAYMENT_WEBHOOK_SECRET,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ paymentStatus, providerReference, paidAt: new Date().toISOString() }),
+ }).catch((err) => {
+ sendCriticalAlert("Test-confirm-Aufruf ans Backend fehlgeschlagen", { orderId, providerReference, error: String(err) });
+ return null;
+ });
+
+ if (!res || !res.ok) {
+ return NextResponse.json({ ok: false, reason: "Backend hat die Testzahlung nicht bestätigt." }, { status: 502 });
+ }
+
+ const data: { ok: boolean; alreadyProcessed?: boolean; order?: ConfirmPaymentOrderSnapshot } = await res.json();
+
+ // Same email-send as the real webhook route — see its own comment and
+ // confirmPaymentEmail.ts. Reproduces today's "immediate confirmation"
+ // behavior on a test click, exercising the real send path rather than a
+ // separate short-circuit.
+ if (data.order && !data.alreadyProcessed) {
+ void sendConfirmedPaymentEmail(data.order);
+ }
+
+ return NextResponse.json({ ok: true });
+}
diff --git a/app/checkout/components/CheckoutContent.tsx b/app/checkout/components/CheckoutContent.tsx
index bf7003c..d343d3e 100644
--- a/app/checkout/components/CheckoutContent.tsx
+++ b/app/checkout/components/CheckoutContent.tsx
@@ -1,6 +1,6 @@
"use client";
-import { forwardRef, useEffect, useRef, useState } from "react";
+import { forwardRef, useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link";
import Image from "next/image";
import { useRouter } from "next/navigation";
@@ -14,13 +14,15 @@ import { Reveal } from "../../components/Reveal";
import { VersandModal } from "../../components/VersandModal";
import { VatBreakdown } from "../../components/VatBreakdown";
import { CheckoutSteps } from "../../components/CheckoutSteps";
-import { ORDER_KEY, type OrderSnapshot } from "../../lib/order";
+import { ORDER_KEY, PENDING_ORDER_KEY, type OrderSnapshot } from "../../lib/order";
+import { PaymentStep } from "./PaymentStep";
import { dispatchAuthChanged } from "../../lib/auth";
import { readCheckoutDraft, writeCheckoutDraft, clearCheckoutDraft } from "../../lib/checkoutDraft";
import { normalizeVatId, isValidVatId } from "../../lib/vatId";
import { computeExemptTotals, destinationCountry, isExemptionEligibleCountry } from "../../lib/vatExemption";
import { validateEmailFormat } from "../../lib/email";
import type { ShippingMethod, ShippingCountry, PaymentMethod, TrustBadge, ShippingSettings } from "../../lib/payload";
+import { groupPaymentMethodsForCheckout } from "../../lib/payload";
import type { CustomerProfile } from "../../lib/customerAuth";
// Native HTML5 pattern validation (instant, no round-trip) mirroring the
@@ -140,10 +142,26 @@ export function CheckoutContent({
// validateZip() call site.
const plzDigitsMap: Record = Object.fromEntries(shippingCountries.map((c) => [c.name, c.plzDigits]));
const [shippingMethodId, setShippingMethodId] = useState(shippingMethods[0]?.id ?? null);
- const [paymentMethodId, setPaymentMethodId] = useState(paymentMethods[0]?.id ?? null);
+ // Kreditkarte/PayPal collapse into one "Online-Zahlung" option here —
+ // see groupPaymentMethodsForCheckout's own comment for why. The
+ // resulting id is still a real payment-methods row id, so everything
+ // downstream (submission, sessionStorage draft restore) is unaffected.
+ const paymentOptions = useMemo(() => groupPaymentMethodsForCheckout(paymentMethods), [paymentMethods]);
+ const [paymentMethodId, setPaymentMethodId] = useState(paymentOptions[0]?.id ?? null);
const [versandOpen, setVersandOpen] = useState(false);
const [purchaseError, setPurchaseError] = useState(null);
const [purchasing, setPurchasing] = useState(false);
+ // Set once /api/checkout returns `requiresPayment: true` (Kreditkarte/
+ // PayPal) — see spicy-leaping-pizza.md §3. Replaces the form with
+ // PaymentStep instead of navigating away immediately, since the order
+ // isn't actually confirmed yet at this point.
+ const [paymentStep, setPaymentStep] = useState<{
+ clientSecret: string;
+ orderNumber: string;
+ orderId: number;
+ testMode: boolean;
+ providerReference?: string;
+ } | null>(null);
const [showLogin, setShowLogin] = useState(false);
// Scroll target for the submit-time emailExists fallback below — the
// common case (blur-triggered, see handleEmailBlur) needs no scroll at
@@ -579,6 +597,32 @@ export function CheckoutContent({
vatExempt: Boolean(data.vatExempt),
kleinunternehmer: Boolean(data.kleinunternehmer),
};
+
+ if (data.requiresPayment) {
+ // Order exists in Payload now (status 'pending_payment'), but
+ // nothing is confirmed yet — the sessionStorage snapshot, cart
+ // clear, and navigation to /bestellbestaetigung all wait for
+ // /checkout/verarbeitung to see a confirmed payment (see that
+ // page's own comment). A cancelled/failed payment must leave the
+ // cart intact so the customer can just retry.
+ try {
+ window.sessionStorage.setItem(PENDING_ORDER_KEY, JSON.stringify(snapshot));
+ } catch {
+ // Same private-browsing fallback as the confirmed-order path
+ // below — /checkout/verarbeitung falls back to its own empty
+ // state if this didn't persist.
+ }
+ setPaymentStep({
+ clientSecret: data.clientSecret,
+ orderNumber: data.orderNumber,
+ orderId: data.orderId,
+ testMode: Boolean(data.testMode),
+ providerReference: data.providerReference,
+ });
+ setPurchasing(false);
+ return;
+ }
+
try {
window.sessionStorage.setItem(ORDER_KEY, JSON.stringify(snapshot));
} catch {
@@ -620,6 +664,33 @@ export function CheckoutContent({
);
}
+ // Order already exists in Payload (status 'pending_payment') — this
+ // replaces the address/cart form with the actual payment UI rather than
+ // navigating away, since nothing is confirmed yet. See PaymentStep's own
+ // comment and spicy-leaping-pizza.md §3.
+ if (paymentStep) {
+ return (
+
+
+ Zahlung
+
+
+ Bestellung {paymentStep.orderNumber} wurde angelegt — schließe jetzt die Zahlung ab.
+