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:
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user