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 ( +
+ + {error &&

{error}

} + + + ); +} + +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

- {paymentMethods.map((method) => ( -