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

+ Zahlung +

+

+ Bestellung {paymentStep.orderNumber} wurde angelegt — schließe jetzt die Zahlung ab. +

+ +
+ ); + } + return ( <> {/* Header — breadcrumb, stepper, title */} @@ -1154,23 +1225,26 @@ export function CheckoutContent({ 3. Zahlungsart

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