Add Stripe payment processing (cards + PayPal) with a webhook-gated checkout flow

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 <noreply@anthropic.com>
This commit is contained in:
Marco
2026-07-25 12:03:04 +00:00
parent bb3f94d39e
commit 740b791e5e
19 changed files with 941 additions and 70 deletions
+85 -7
View File
@@ -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