From 740b791e5e92db1c28d91241c9b979904eedc2f7 Mon Sep 17 00:00:00 2001
From: Marco
Date: Sat, 25 Jul 2026 12:03:04 +0000
Subject: [PATCH 1/6] Add Stripe payment processing (cards + PayPal) with a
webhook-gated checkout flow
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Checkout now branches on payment-methods.provider: Überweisung stays
immediate/unchanged, Kreditkarte/PayPal creates a pending_payment order,
mounts Stripe's Payment Element, and defers invoice/email to a webhook-
verified confirm-payment call once the backend actually confirms payment.
Includes a PAYMENT_TEST_MODE mock provider so the whole gated pipeline is
exercisable locally without a real Stripe account.
Co-Authored-By: Claude Sonnet 5
---
README.md | 92 ++++++++-
app/api/checkout/route.ts | 185 ++++++++++++------
app/api/checkout/status/route.ts | 21 ++
app/api/webhooks/stripe/route.ts | 71 +++++++
app/api/webhooks/stripe/test-confirm/route.ts | 44 +++++
app/checkout/components/CheckoutContent.tsx | 67 ++++++-
app/checkout/components/PaymentStep.tsx | 142 ++++++++++++++
.../verarbeitung/VerarbeitungContent.tsx | 138 +++++++++++++
app/checkout/verarbeitung/page.tsx | 20 ++
app/lib/customerAuth.ts | 4 +
app/lib/order.ts | 9 +
app/lib/orderServer.ts | 30 ++-
app/lib/payload.ts | 9 +-
app/lib/payments/index.ts | 15 ++
app/lib/payments/mockProvider.ts | 20 ++
app/lib/payments/stripeProvider.ts | 62 ++++++
app/lib/payments/types.ts | 32 +++
package-lock.json | 45 ++++-
package.json | 5 +-
19 files changed, 941 insertions(+), 70 deletions(-)
create mode 100644 app/api/checkout/status/route.ts
create mode 100644 app/api/webhooks/stripe/route.ts
create mode 100644 app/api/webhooks/stripe/test-confirm/route.ts
create mode 100644 app/checkout/components/PaymentStep.tsx
create mode 100644 app/checkout/verarbeitung/VerarbeitungContent.tsx
create mode 100644 app/checkout/verarbeitung/page.tsx
create mode 100644 app/lib/payments/index.ts
create mode 100644 app/lib/payments/mockProvider.ts
create mode 100644 app/lib/payments/stripeProvider.ts
create mode 100644 app/lib/payments/types.ts
diff --git a/README.md b/README.md
index 15e7ece..88bec46 100644
--- a/README.md
+++ b/README.md
@@ -50,8 +50,11 @@ 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. Set in Coolify's app settings for production, not in a
+committed `.env`.
## Pages
@@ -281,7 +284,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 +294,84 @@ 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.
+- **`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 is what actually flips the order to `received`,
+ assigns the (until-then-deferred) invoice number, and sends the
+ confirmation email/invoice + the internal admin new-order notification —
+ see the backend repo's own README for that half.
+- **`/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
diff --git a/app/api/checkout/route.ts b/app/api/checkout/route.ts
index dfcad22..8ef4f93 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,
@@ -317,6 +347,9 @@ export async function POST(request: Request) {
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 +367,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,7 +459,22 @@ 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,
discountCode: body.discountCode || null,
diff --git a/app/api/checkout/status/route.ts b/app/api/checkout/status/route.ts
new file mode 100644
index 0000000..fc9033a
--- /dev/null
+++ b/app/api/checkout/status/route.ts
@@ -0,0 +1,21 @@
+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 });
+}
diff --git a/app/api/webhooks/stripe/route.ts b/app/api/webhooks/stripe/route.ts
new file mode 100644
index 0000000..51740ba
--- /dev/null
+++ b/app/api/webhooks/stripe/route.ts
@@ -0,0 +1,71 @@
+import { NextResponse } from "next/server";
+import Stripe from "stripe";
+import { verifyStripeWebhookSignature } from "../../../lib/payments/stripeProvider";
+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 });
+ }
+
+ 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("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 });
+ }
+
+ 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..26232a9
--- /dev/null
+++ b/app/api/webhooks/stripe/test-confirm/route.ts
@@ -0,0 +1,44 @@
+import { NextResponse } from "next/server";
+import { isPaymentTestMode } from "../../../../lib/payments";
+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 });
+ }
+
+ return NextResponse.json({ ok: true });
+}
diff --git a/app/checkout/components/CheckoutContent.tsx b/app/checkout/components/CheckoutContent.tsx
index bf7003c..8d47c03 100644
--- a/app/checkout/components/CheckoutContent.tsx
+++ b/app/checkout/components/CheckoutContent.tsx
@@ -14,7 +14,8 @@ 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";
@@ -144,6 +145,17 @@ export function CheckoutContent({
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 +591,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 +658,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.
+
+
+
+ );
+ }
+
return (
<>
{/* Header — breadcrumb, stepper, title */}
diff --git a/app/checkout/components/PaymentStep.tsx b/app/checkout/components/PaymentStep.tsx
new file mode 100644
index 0000000..9dcc8fa
--- /dev/null
+++ b/app/checkout/components/PaymentStep.tsx
@@ -0,0 +1,142 @@
+"use client";
+
+import { useState } from "react";
+import { useRouter } from "next/navigation";
+import { loadStripe, type Stripe } from "@stripe/stripe-js";
+import { Elements, PaymentElement, useElements, useStripe } from "@stripe/react-stripe-js";
+
+// Loaded once at module scope (not per-render) — same reasoning as any
+// other client-side SDK singleton. Never called at all in test mode
+// (mounted conditionally below), so an unset publishable key there is
+// harmless.
+let stripePromise: Promise | null = null;
+function getStripe(): Promise {
+ if (!stripePromise) {
+ stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY || "");
+ }
+ return stripePromise;
+}
+
+type Props = {
+ clientSecret: string;
+ orderNumber: string;
+ orderId: number;
+ testMode: boolean;
+ /** Only present in test mode — see api/checkout/route.ts's own comment. */
+ providerReference?: string;
+};
+
+// Rendered by CheckoutContent once /api/checkout returns
+// `requiresPayment: true` (Kreditkarte/PayPal) — see
+// spicy-leaping-pizza.md §3/§7. The order already exists in Payload at
+// this point (status 'pending_payment'); this step only collects/confirms
+// the actual payment, it doesn't create anything.
+export function PaymentStep({ clientSecret, orderNumber, orderId, testMode, providerReference }: Props) {
+ if (testMode) {
+ return ;
+ }
+ return (
+
+
+
+ );
+}
+
+function StripePaymentForm({ orderNumber }: { orderNumber: string }) {
+ const stripe = useStripe();
+ const elements = useElements();
+ const [submitting, setSubmitting] = useState(false);
+ const [error, setError] = useState(null);
+
+ async function handlePay(e: React.FormEvent) {
+ e.preventDefault();
+ if (!stripe || !elements) return;
+ setSubmitting(true);
+ setError(null);
+ // Redirect-based (PayPal always redirects; cards may need a
+ // 3-D-Secure redirect too) — confirmation itself is never trusted
+ // client-side, see /checkout/verarbeitung's own comment. `if_required`
+ // would skip the redirect for methods that don't need one, but the
+ // return_url page's polling handles both cases identically either way,
+ // so there's no benefit to branching here.
+ const { error: confirmError } = await stripe.confirmPayment({
+ elements,
+ confirmParams: {
+ return_url: `${window.location.origin}/checkout/verarbeitung?orderNumber=${encodeURIComponent(orderNumber)}`,
+ },
+ });
+ // Only reached for immediate client-side failures (e.g. invalid card
+ // number) — a redirect on success/pending never returns here at all.
+ if (confirmError) {
+ setError(confirmError.message ?? "Die Zahlung konnte nicht bestätigt werden.");
+ setSubmitting(false);
+ }
+ }
+
+ return (
+
+ );
+}
+
+function TestPaymentButtons({ orderNumber, orderId, providerReference }: { orderNumber: string; orderId: number; providerReference: string }) {
+ const router = useRouter();
+ const [submitting, setSubmitting] = useState<"paid" | "failed" | null>(null);
+ const [error, setError] = useState(null);
+
+ async function confirm(paymentStatus: "paid" | "failed") {
+ setSubmitting(paymentStatus);
+ setError(null);
+ try {
+ const res = await fetch("/api/webhooks/stripe/test-confirm", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ orderId, providerReference, paymentStatus }),
+ });
+ const data = await res.json();
+ if (!data.ok) {
+ setError(data.reason || "Testzahlung fehlgeschlagen.");
+ setSubmitting(null);
+ return;
+ }
+ router.push(`/checkout/verarbeitung?orderNumber=${encodeURIComponent(orderNumber)}`);
+ } catch {
+ setError("Testzahlung konnte nicht ausgeführt werden.");
+ setSubmitting(null);
+ }
+ }
+
+ return (
+
+
PAYMENT_TEST_MODE aktiv — kein echtes Stripe-Konto verbunden.
+ {error &&
{error}
}
+
+
+
+
+
+ );
+}
diff --git a/app/checkout/verarbeitung/VerarbeitungContent.tsx b/app/checkout/verarbeitung/VerarbeitungContent.tsx
new file mode 100644
index 0000000..bdb8b1d
--- /dev/null
+++ b/app/checkout/verarbeitung/VerarbeitungContent.tsx
@@ -0,0 +1,138 @@
+"use client";
+
+import { useEffect, useRef, useState } from "react";
+import { useRouter, useSearchParams } from "next/navigation";
+import Link from "next/link";
+import { ORDER_KEY, PENDING_ORDER_KEY } from "../../lib/order";
+import { clearCart } from "../../lib/cart";
+import { clearDiscount } from "../../lib/discount";
+import { clearCheckoutDraft } from "../../lib/checkoutDraft";
+import { dispatchAuthChanged } from "../../lib/auth";
+
+const POLL_INTERVAL_MS = 1500;
+const POLL_TIMEOUT_MS = 15000;
+
+// The Payment Element's return_url target (see PaymentStep.tsx) — reached
+// after a card confirms client-side or a PayPal redirect completes.
+// Neither of those is trustworthy proof of payment on its own (see
+// spicy-leaping-pizza.md §3's own reasoning: a closed tab mid-PayPal-
+// redirect looks identical to success from here) — this page polls the
+// order's actual `paymentStatus`, which only the webhook-driven
+// confirm-payment endpoint ever sets, and only promotes the pending
+// sessionStorage snapshot to the confirmed one once that's true.
+export function VerarbeitungContent() {
+ const router = useRouter();
+ const searchParams = useSearchParams();
+ const orderNumber = searchParams.get("orderNumber");
+ const [state, setState] = useState<"polling" | "timeout" | "failed" | "error">(orderNumber ? "polling" : "error");
+ const startedAt = useRef(null);
+
+ useEffect(() => {
+ if (!orderNumber) return;
+ startedAt.current = Date.now();
+ let cancelled = false;
+
+ async function poll() {
+ try {
+ const res = await fetch(`/api/checkout/status?orderNumber=${encodeURIComponent(orderNumber!)}`, { cache: "no-store" });
+ const data = await res.json();
+ if (cancelled) return;
+ if (!data.ok) {
+ setState("error");
+ return;
+ }
+ if (data.paymentStatus === "paid") {
+ try {
+ const pending = window.sessionStorage.getItem(PENDING_ORDER_KEY);
+ if (pending) {
+ window.sessionStorage.setItem(ORDER_KEY, pending);
+ window.sessionStorage.removeItem(PENDING_ORDER_KEY);
+ }
+ } catch {
+ // Same private-browsing fallback as everywhere else this
+ // sessionStorage snapshot is written — /bestellbestaetigung
+ // has its own empty state.
+ }
+ clearCart();
+ clearDiscount();
+ clearCheckoutDraft();
+ dispatchAuthChanged();
+ router.push("/bestellbestaetigung");
+ return;
+ }
+ if (data.paymentStatus === "failed" || data.status === "cancelled") {
+ setState("failed");
+ return;
+ }
+ if (startedAt.current != null && Date.now() - startedAt.current > POLL_TIMEOUT_MS) {
+ setState("timeout");
+ return;
+ }
+ setTimeout(poll, POLL_INTERVAL_MS);
+ } catch {
+ if (!cancelled) setState("error");
+ }
+ }
+
+ poll();
+ return () => {
+ cancelled = true;
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [orderNumber]);
+
+ return (
+
+ {state === "polling" && (
+ <>
+
+ Zahlung wird bestätigt…
+
+
Einen Moment bitte, das dauert normalerweise nur wenige Sekunden.
+ >
+ )}
+ {state === "timeout" && (
+ <>
+
+ Das dauert etwas länger
+
+
+ Deine Zahlung wird noch verarbeitet. Sobald sie bestätigt ist, schicken wir dir eine Bestätigungs-E-Mail — du musst hier nicht warten.
+
+ >
+ )}
+ {state === "failed" && (
+ <>
+
+ Zahlung fehlgeschlagen
+
+
+ Deine Zahlung konnte nicht abgeschlossen werden. Dein Warenkorb ist noch vorhanden — du kannst es gerne erneut versuchen.
+
+
+ Zurück zum Checkout
+
+ >
+ )}
+ {state === "error" && (
+ <>
+
+ Status konnte nicht geladen werden
+
+
+ Falls die Zahlung erfolgreich war, erhältst du in Kürze eine Bestätigungs-E-Mail. Andernfalls kannst du es erneut versuchen.
+
+
+ Zurück zum Checkout
+
+ >
+ )}
+
+ );
+}
diff --git a/app/checkout/verarbeitung/page.tsx b/app/checkout/verarbeitung/page.tsx
new file mode 100644
index 0000000..29a3ca3
--- /dev/null
+++ b/app/checkout/verarbeitung/page.tsx
@@ -0,0 +1,20 @@
+import type { Metadata } from "next";
+import { Suspense } from "react";
+import { VerarbeitungContent } from "./VerarbeitungContent";
+
+// robots: noindex — transactional page, same reasoning as /checkout itself.
+export const metadata: Metadata = {
+ title: "Zahlung wird bestätigt",
+ robots: { index: false, follow: true },
+};
+
+export default function VerarbeitungPage() {
+ // useSearchParams (reading ?orderNumber=) requires a Suspense boundary
+ // in the App Router — this page has no meaningful loading state of its
+ // own beyond what VerarbeitungContent already renders.
+ return (
+
+
+
+ );
+}
diff --git a/app/lib/customerAuth.ts b/app/lib/customerAuth.ts
index 0896a92..bdd9874 100644
--- a/app/lib/customerAuth.ts
+++ b/app/lib/customerAuth.ts
@@ -443,6 +443,10 @@ export async function getCustomerOrders(token: string, customerId: number): Prom
export type CustomerOrderDetail = CustomerOrder & {
id: number;
+ // 'not_applicable' for Überweisung orders (never gated); see
+ // spicy-leaping-pizza.md §1 — read by /api/checkout/status for the
+ // post-Stripe-redirect polling page.
+ paymentStatus: "not_applicable" | "pending" | "paid" | "failed" | "refunded" | "partially_refunded";
invoiceNumber: string | null;
invoiceIssuedAt: string | null;
correctionInvoiceNumber: string | null;
diff --git a/app/lib/order.ts b/app/lib/order.ts
index 914a359..7a38cf6 100644
--- a/app/lib/order.ts
+++ b/app/lib/order.ts
@@ -8,6 +8,15 @@ import type { CartItem } from "./cart";
// generated locally), read once by /bestellbestaetigung.
export const ORDER_KEY = "ep_last_order";
+// Written for a gated payment method (Kreditkarte/PayPal) right before
+// PaymentStep hands off to Stripe/the test-confirm flow — see
+// spicy-leaping-pizza.md §3/§7. Same OrderSnapshot shape as ORDER_KEY,
+// but this one is provisional: /checkout/verarbeitung only promotes it
+// to ORDER_KEY once polling confirms the payment actually succeeded, so
+// an abandoned/failed payment never leaves a confirmation-page-ready
+// snapshot behind.
+export const PENDING_ORDER_KEY = "ep_pending_order";
+
export type OrderSnapshot = {
items: CartItem[];
orderNumber: string;
diff --git a/app/lib/orderServer.ts b/app/lib/orderServer.ts
index ccdebe6..d0313f8 100644
--- a/app/lib/orderServer.ts
+++ b/app/lib/orderServer.ts
@@ -76,9 +76,28 @@ export type CreateOrderInput = {
discountCode: string | null;
discountAmount: number;
total: number;
+ // Gated-payment fields (see spicy-leaping-pizza.md §1/§3) — all three
+ // omitted for a manual/Überweisung order, which is exactly today's
+ // behavior (Orders.ts's own field defaults apply: status 'received',
+ // paymentProvider 'manual', paymentStatus 'not_applicable').
+ status?: "pending_payment";
+ paymentProvider?: "stripe";
+ paymentStatus?: "pending";
+ // Known before the order is created (Stripe generates a PaymentIntent id
+ // immediately, independent of any order existing yet) — persisted at
+ // creation time specifically so the expirePendingPayments cleanup job
+ // has something to reconcile against even if the webhook metadata
+ // round-trip (stripeProvider.attachOrderMetadata) never completes.
+ providerReference?: string;
};
-export type CreatedOrder = { orderNumber: string; createdAt: string; invoiceNumber: string; invoiceIssuedAt: string };
+export type CreatedOrder = {
+ id: number;
+ orderNumber: string;
+ createdAt: string;
+ invoiceNumber: string | null;
+ invoiceIssuedAt: string | null;
+};
export async function createOrder(input: CreateOrderInput): Promise {
const tenantId = await resolveTenantId();
@@ -138,6 +157,10 @@ export async function createOrder(input: CreateOrderInput): Promise {
};
}
-export type PaymentMethod = { id: number; title: string; icons: string[] };
+// `provider` drives the checkout branch in app/api/checkout/route.ts —
+// 'manual' (Überweisung) keeps today's immediate-order behavior, 'stripe'
+// (Kreditkarte/PayPal) routes through the payment-intent/webhook-gated
+// flow. Defaults to 'manual' below for any row created before this field
+// existed, matching the Payload field's own default.
+export type PaymentMethod = { id: number; title: string; icons: string[]; provider: "manual" | "stripe" };
type PayloadPaymentMethod = {
id: number;
title: string;
active: boolean;
icons: { icon: { url: string } | number | null }[];
+ provider?: "manual" | "stripe";
};
export async function getPaymentMethods(): Promise {
@@ -611,6 +617,7 @@ export async function getPaymentMethods(): Promise {
icons: (doc.icons ?? [])
.map((row) => (typeof row.icon === "object" && row.icon ? row.icon.url : null))
.filter((url): url is string => Boolean(url)),
+ provider: doc.provider ?? "manual",
}));
}
diff --git a/app/lib/payments/index.ts b/app/lib/payments/index.ts
new file mode 100644
index 0000000..7ad53de
--- /dev/null
+++ b/app/lib/payments/index.ts
@@ -0,0 +1,15 @@
+import { stripeProvider } from "./stripeProvider";
+import { mockProvider } from "./mockProvider";
+import type { PaymentProvider } from "./types";
+
+export * from "./types";
+
+// Defaults to test mode whenever no real Stripe key is configured, so a
+// fresh local checkout (or CI) never accidentally tries to call the real
+// Stripe API — matches PAYMENT_TEST_MODE's documented default in the plan.
+const TEST_MODE = process.env.PAYMENT_TEST_MODE
+ ? process.env.PAYMENT_TEST_MODE === "true"
+ : !process.env.STRIPE_SECRET_KEY;
+
+export const paymentProvider: PaymentProvider = TEST_MODE ? mockProvider : stripeProvider;
+export const isPaymentTestMode = TEST_MODE;
diff --git a/app/lib/payments/mockProvider.ts b/app/lib/payments/mockProvider.ts
new file mode 100644
index 0000000..8d7f8e9
--- /dev/null
+++ b/app/lib/payments/mockProvider.ts
@@ -0,0 +1,20 @@
+import type { PaymentProvider, CreatePaymentIntentResult } from "./types";
+
+// PAYMENT_TEST_MODE stand-in (plan §7) — no network call, no real Stripe
+// account needed. The synthetic providerReference is still persisted on
+// the order exactly like a real one, so the whole downstream pipeline
+// (webhooks/stripe/test-confirm, confirm-payment, expirePendingPayments)
+// runs unmodified against it.
+async function createPaymentIntent(): Promise {
+ const fakeId = `pi_test_${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`;
+ return { clientSecret: `${fakeId}_secret_mock`, providerReference: fakeId };
+}
+
+async function attachOrderMetadata(): Promise {
+ // No real PaymentIntent to attach metadata to — nothing to do. The
+ // test-confirm route (used instead of a real webhook in test mode)
+ // already receives the order's id directly from the client, so it
+ // never needs to resolve it via metadata the way the real webhook does.
+}
+
+export const mockProvider: PaymentProvider = { createPaymentIntent, attachOrderMetadata };
diff --git a/app/lib/payments/stripeProvider.ts b/app/lib/payments/stripeProvider.ts
new file mode 100644
index 0000000..24a727d
--- /dev/null
+++ b/app/lib/payments/stripeProvider.ts
@@ -0,0 +1,62 @@
+import Stripe from "stripe";
+import type { PaymentProvider, CreatePaymentIntentInput, CreatePaymentIntentResult } from "./types";
+
+// Server-only — never imported from a "use client" file. Same
+// process.env-at-point-of-use convention as vies.ts/brevo.ts (no
+// throwing on a missing key; an unset STRIPE_SECRET_KEY just makes every
+// call fail at request time, which is the expected state whenever
+// PAYMENT_TEST_MODE is on and this module is never actually invoked).
+const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY || "";
+
+let client: Stripe | null = null;
+function getClient(): Stripe {
+ if (!client) client = new Stripe(STRIPE_SECRET_KEY);
+ return client;
+}
+
+async function createPaymentIntent(input: CreatePaymentIntentInput): Promise {
+ // automatic_payment_methods lets Stripe itself decide card vs. PayPal
+ // vs. any other method active on this account/region — one PaymentIntent
+ // covers both required methods, per the plan's provider choice (Payment
+ // Element, not per-method Checkout Sessions).
+ const intent = await getClient().paymentIntents.create({
+ amount: input.amountCents,
+ currency: input.currency,
+ receipt_email: input.customerEmail,
+ description: input.description,
+ automatic_payment_methods: { enabled: true },
+ });
+ if (!intent.client_secret) throw new Error("Stripe did not return a client_secret");
+ return { clientSecret: intent.client_secret, providerReference: intent.id };
+}
+
+// Called right after the order is persisted in Payload (see
+// app/api/checkout/route.ts) — the PaymentIntent has to exist before the
+// order can reference its id (providerReference), so metadata pointing
+// the other way (PaymentIntent -> order) can only be attached in a
+// second call, not at creation. This is what lets
+// app/api/webhooks/stripe/route.ts resolve an incoming
+// `payment_intent.*` event back to a specific Payload order without a
+// separate, unauthenticated-from-Stripe's-side lookup endpoint.
+//
+// Awaited but non-fatal to checkout on failure (see the call site) — the
+// order and its own `providerReference` field are already the source of
+// truth for admin/cleanup-job reconciliation; this metadata only matters
+// for the webhook's fast path.
+async function attachOrderMetadata(providerReference: string, metadata: { orderId: string; orderNumber: string }): Promise {
+ await getClient().paymentIntents.update(providerReference, { metadata });
+}
+
+export const stripeProvider: PaymentProvider = { createPaymentIntent, attachOrderMetadata };
+
+// Only used by the real webhook route (never through the PaymentProvider
+// interface — signature verification is inherently Stripe-shaped, no
+// other provider exists to share this contract with yet).
+export function verifyStripeWebhookSignature(rawBody: string, signatureHeader: string): Stripe.Event | null {
+ const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET || "";
+ try {
+ return getClient().webhooks.constructEvent(rawBody, signatureHeader, webhookSecret);
+ } catch {
+ return null;
+ }
+}
diff --git a/app/lib/payments/types.ts b/app/lib/payments/types.ts
new file mode 100644
index 0000000..8a73d90
--- /dev/null
+++ b/app/lib/payments/types.ts
@@ -0,0 +1,32 @@
+// Provider-agnostic contract — see the approved payment plan
+// (spicy-leaping-pizza.md §0/§7). Stripe is the only real implementation
+// today (stripeProvider.ts); mockProvider.ts implements the same shape
+// for PAYMENT_TEST_MODE so the checkout route never branches on which
+// provider is active, only on whether one is configured at all.
+
+export type CreatePaymentIntentInput = {
+ amountCents: number;
+ currency: string;
+ customerEmail: string;
+ description: string;
+};
+
+export type CreatePaymentIntentResult = {
+ clientSecret: string;
+ providerReference: string;
+};
+
+export type ProviderPaymentUpdate = {
+ providerReference: string;
+ paymentStatus: "paid" | "failed";
+ paidAt: string;
+};
+
+export interface PaymentProvider {
+ createPaymentIntent(input: CreatePaymentIntentInput): Promise;
+ // Best-effort, awaited but never fatal to checkout — lets the webhook
+ // handler resolve providerReference -> order without the frontend
+ // having to persist a second field via an update path that doesn't
+ // otherwise exist (see stripeProvider.ts's own comment).
+ attachOrderMetadata(providerReference: string, metadata: { orderId: string; orderNumber: string }): Promise;
+}
diff --git a/package-lock.json b/package-lock.json
index d7c022e..e9a1901 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -12,11 +12,14 @@
"@payloadcms/live-preview-react": "^3.85.2",
"@payloadcms/richtext-lexical": "^3.85.2",
"@react-pdf/renderer": "^4.5.1",
+ "@stripe/react-stripe-js": "^6.8.0",
+ "@stripe/stripe-js": "^9.12.0",
"motion": "^12.42.2",
"next": "16.2.9",
"nodemailer": "^9.0.3",
"react": "19.2.4",
- "react-dom": "19.2.4"
+ "react-dom": "19.2.4",
+ "stripe": "^22.3.2"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
@@ -3330,6 +3333,29 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@stripe/react-stripe-js": {
+ "version": "6.8.0",
+ "resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-6.8.0.tgz",
+ "integrity": "sha512-nRrPkos00CmUeqXHxtJkXmSbl9/6ybGI4jVebzsiA6QaE5A4iw9dn1C4hUx2tBmQQV2e6pm2aLkPYov4hdta2w==",
+ "license": "MIT",
+ "dependencies": {
+ "prop-types": "^15.7.2"
+ },
+ "peerDependencies": {
+ "@stripe/stripe-js": ">=9.5.0 <10.0.0",
+ "react": ">=16.8.0 <20.0.0",
+ "react-dom": ">=16.8.0 <20.0.0"
+ }
+ },
+ "node_modules/@stripe/stripe-js": {
+ "version": "9.12.0",
+ "resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-9.12.0.tgz",
+ "integrity": "sha512-wCUYZNYo7E/6B3Rv2i/g/Rmj2mTiOX6HEwWYUWUuNUKwEhzTo/SXuQ1IoNo3mknWIHUQyqdakv1Nl2SiYPrCrw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.16"
+ }
+ },
"node_modules/@swc/helpers": {
"version": "0.5.23",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz",
@@ -11285,6 +11311,23 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/stripe": {
+ "version": "22.3.2",
+ "resolved": "https://registry.npmjs.org/stripe/-/stripe-22.3.2.tgz",
+ "integrity": "sha512-O13QOvgEIQvDlTy6Ubb5kB980wpbhmoZNsgCXKILjCMZS67f+bW+6w99k3gnSi/N1lkryoj1WYdpGT5Wc5edjg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@types/node": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
+ }
+ },
"node_modules/strtok3": {
"version": "10.3.5",
"resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.5.tgz",
diff --git a/package.json b/package.json
index 05c1906..9ca58d9 100644
--- a/package.json
+++ b/package.json
@@ -14,11 +14,14 @@
"@payloadcms/live-preview-react": "^3.85.2",
"@payloadcms/richtext-lexical": "^3.85.2",
"@react-pdf/renderer": "^4.5.1",
+ "@stripe/react-stripe-js": "^6.8.0",
+ "@stripe/stripe-js": "^9.12.0",
"motion": "^12.42.2",
"next": "16.2.9",
"nodemailer": "^9.0.3",
"react": "19.2.4",
- "react-dom": "19.2.4"
+ "react-dom": "19.2.4",
+ "stripe": "^22.3.2"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
From 4e2294203112fc63d8664b415cd1b32cb8b00cc4 Mon Sep 17 00:00:00 2001
From: Marco
Date: Sat, 25 Jul 2026 12:07:26 +0000
Subject: [PATCH 2/6] Send the customer confirmation email from the payment
webhook
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The webhook route previously assumed the backend's confirm-payment
endpoint sent the customer confirmation email; the backend assumed the
opposite. Net effect: a successful Stripe payment never triggered any
confirmation email. Consume the order snapshot confirm-payment now
returns and send it from here, matching what the checkout route already
does for a manual/Überweisung order.
Co-Authored-By: Claude Sonnet 5
---
app/api/webhooks/stripe/route.ts | 13 ++++++++
app/api/webhooks/stripe/test-confirm/route.ts | 11 +++++++
app/lib/payments/confirmPaymentEmail.ts | 31 +++++++++++++++++++
3 files changed, 55 insertions(+)
create mode 100644 app/lib/payments/confirmPaymentEmail.ts
diff --git a/app/api/webhooks/stripe/route.ts b/app/api/webhooks/stripe/route.ts
index 51740ba..fccb7a6 100644
--- a/app/api/webhooks/stripe/route.ts
+++ b/app/api/webhooks/stripe/route.ts
@@ -1,6 +1,7 @@
import { NextResponse } from "next/server";
import Stripe from "stripe";
import { verifyStripeWebhookSignature } 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";
@@ -67,5 +68,17 @@ export async function POST(request: Request) {
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
index 26232a9..caf2a88 100644
--- a/app/api/webhooks/stripe/test-confirm/route.ts
+++ b/app/api/webhooks/stripe/test-confirm/route.ts
@@ -1,5 +1,6 @@
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";
@@ -40,5 +41,15 @@ export async function POST(request: Request) {
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/lib/payments/confirmPaymentEmail.ts b/app/lib/payments/confirmPaymentEmail.ts
new file mode 100644
index 0000000..4f99418
--- /dev/null
+++ b/app/lib/payments/confirmPaymentEmail.ts
@@ -0,0 +1,31 @@
+import { sendOrderConfirmationEmail, type OrderConfirmationEmailData } from "../orderEmail";
+import { sendCriticalAlert } from "../alertAdmin";
+
+// The `order` snapshot returned by the backend's confirm-payment endpoint
+// (see docker/payload's src/lib/endpoints/confirmPayment.ts) — matches
+// OrderConfirmationEmailData minus `customerEmail`, which is passed
+// separately to sendOrderConfirmationEmail. Backend has no SMTP-based
+// order-confirmation sender of its own (only the 4 status-change
+// templates), so it returns everything needed here instead of the
+// frontend needing an authenticated order-read path it doesn't otherwise
+// have (ORDER_SERVICE_SECRET only ever authorizes *creating* an order).
+export type ConfirmPaymentOrderSnapshot = OrderConfirmationEmailData & { customerEmail: string };
+
+// Called from both the real Stripe webhook route and its PAYMENT_TEST_MODE
+// test-confirm sibling, right after confirm-payment reports success (and
+// NOT `alreadyProcessed: true` — a repeat delivery must never resend
+// this). Mirrors exactly what app/api/checkout/route.ts already does for
+// a manual/Überweisung order today, just triggered from the payment
+// webhook instead of the checkout request itself for gated methods.
+export async function sendConfirmedPaymentEmail(order: ConfirmPaymentOrderSnapshot): Promise {
+ const { customerEmail, ...emailData } = order;
+ try {
+ await sendOrderConfirmationEmail(emailData, customerEmail);
+ } catch (err) {
+ sendCriticalAlert("Bestätigungs-Mail konnte nach Zahlungsbestätigung nicht gesendet werden", {
+ orderNumber: order.orderNumber,
+ customerEmail,
+ error: String(err),
+ });
+ }
+}
From bab2c916beeab48fb844b9f47211916a294f922c Mon Sep 17 00:00:00 2001
From: Marco
Date: Sat, 25 Jul 2026 12:17:38 +0000
Subject: [PATCH 3/6] Consolidate Kreditkarte/PayPal into one "Online-Zahlung"
checkout option
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Both already route through the same Stripe PaymentIntent
(automatic_payment_methods: enabled — Stripe's own recommended Payment
Element pattern, letting Stripe itself decide which eligible method to
show). Pre-selecting one of two identical-behind-the-scenes rows before
the payment step was redundant friction, not a real choice. Collapses
them into one option with a hint text explaining the actual instrument
is picked on the next screen; Überweisung is unaffected.
Also refines paymentMethodTitle from a neutral "Online-Zahlung"
placeholder (snapshotted at order-creation time, before the customer has
picked an instrument) to the real one Stripe reports, once payment
confirms — carried through to both the stored order and the
sessionStorage snapshot shown on /bestellbestaetigung.
Co-Authored-By: Claude Sonnet 5
---
README.md | 38 ++++++++++++++--
app/api/checkout/route.ts | 12 ++++-
app/api/checkout/status/route.ts | 12 ++++-
app/api/webhooks/stripe/route.ts | 8 +++-
app/checkout/components/CheckoutContent.tsx | 45 +++++++++++--------
.../verarbeitung/VerarbeitungContent.tsx | 9 +++-
app/lib/payload.ts | 32 +++++++++++++
app/lib/payments/stripeProvider.ts | 23 ++++++++++
8 files changed, 152 insertions(+), 27 deletions(-)
diff --git a/README.md b/README.md
index 88bec46..835838d 100644
--- a/README.md
+++ b/README.md
@@ -308,6 +308,30 @@ exactly as before: no gateway involved, order goes straight to `received`.
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
@@ -337,10 +361,16 @@ exactly as before: no gateway involved, order goes straight to `received`.
`{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 is what actually flips the order to `received`,
- assigns the (until-then-deferred) invoice number, and sends the
- confirmation email/invoice + the internal admin new-order notification —
- see the backend repo's own README for that half.
+ 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
diff --git a/app/api/checkout/route.ts b/app/api/checkout/route.ts
index 8ef4f93..86141ed 100644
--- a/app/api/checkout/route.ts
+++ b/app/api/checkout/route.ts
@@ -343,7 +343,15 @@ 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,
@@ -476,7 +484,7 @@ export async function POST(request: Request) {
}
: {}),
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
index fc9033a..ab74e66 100644
--- a/app/api/checkout/status/route.ts
+++ b/app/api/checkout/status/route.ts
@@ -17,5 +17,15 @@ export async function GET(request: Request) {
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 });
+ 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
index fccb7a6..2f7632d 100644
--- a/app/api/webhooks/stripe/route.ts
+++ b/app/api/webhooks/stripe/route.ts
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server";
import Stripe from "stripe";
-import { verifyStripeWebhookSignature } from "../../../lib/payments/stripeProvider";
+import { verifyStripeWebhookSignature, resolveStripePaymentMethodLabel } from "../../../lib/payments/stripeProvider";
import { sendConfirmedPaymentEmail, type ConfirmPaymentOrderSnapshot } from "../../../lib/payments/confirmPaymentEmail";
import { sendCriticalAlert } from "../../../lib/alertAdmin";
@@ -46,6 +46,11 @@ export async function POST(request: Request) {
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: {
@@ -56,6 +61,7 @@ export async function POST(request: Request) {
paymentStatus,
providerReference,
paidAt: new Date().toISOString(),
+ ...(paymentMethodTitle ? { paymentMethodTitle } : {}),
}),
}).catch((err) => {
sendCriticalAlert("confirm-payment-Aufruf ans Backend fehlgeschlagen", { orderId, providerReference, error: String(err) });
diff --git a/app/checkout/components/CheckoutContent.tsx b/app/checkout/components/CheckoutContent.tsx
index 8d47c03..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";
@@ -22,6 +22,7 @@ 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
@@ -141,7 +142,12 @@ 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);
@@ -1219,23 +1225,26 @@ export function CheckoutContent({
3. Zahlungsart