Merge Stripe payment integration + Brevo double opt-in newsletter

- Real payment capture for Kreditkarte/PayPal via Stripe's Payment
  Element, webhook-gated order confirmation, PAYMENT_TEST_MODE for
  local testing without a real Stripe account.
- Newsletter signup switched from single to double opt-in
  (POST /contacts/doubleOptinConfirmation), plus /newsletter-confirmed
  as the post-confirmation landing page.

Backend counterpart already deployed and verified (confirm-payment
endpoint live, payment-methods provider field set on Kreditkarte/PayPal).
STRIPE_SECRET_KEY/STRIPE_WEBHOOK_SECRET/NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
still unset in Coolify — PAYMENT_TEST_MODE auto-engages until then, so
checkout is safe to test without a real Stripe account.
BREVO_DOUBLE_OPTIN_TEMPLATE_ID is set.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Marco
2026-07-25 15:25:52 +00:00
22 changed files with 1264 additions and 112 deletions
+139 -13
View File
@@ -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
+138 -59
View File
@@ -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,
+31
View File
@@ -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,
});
}
+90
View File
@@ -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 });
}
@@ -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 });
}
+93 -19
View File
@@ -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<string, number> = Object.fromEntries(shippingCountries.map((c) => [c.name, c.plzDigits]));
const [shippingMethodId, setShippingMethodId] = useState<number | null>(shippingMethods[0]?.id ?? null);
const [paymentMethodId, setPaymentMethodId] = useState<number | null>(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<number | null>(paymentOptions[0]?.id ?? null);
const [versandOpen, setVersandOpen] = useState(false);
const [purchaseError, setPurchaseError] = useState<string | null>(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 (
<Reveal className="flex flex-col gap-8 items-start pt-8 pb-16 px-[var(--layout-padding-x)] w-full max-w-xl mx-auto">
<p
className="font-semibold text-h-feature text-text-primary"
style={{ fontFamily: "var(--font-lora)" }}
>
Zahlung
</p>
<p className="text-body-sm text-text-muted">
Bestellung {paymentStep.orderNumber} wurde angelegt schließe jetzt die Zahlung ab.
</p>
<PaymentStep
clientSecret={paymentStep.clientSecret}
orderNumber={paymentStep.orderNumber}
orderId={paymentStep.orderId}
testMode={paymentStep.testMode}
providerReference={paymentStep.providerReference}
/>
</Reveal>
);
}
return (
<>
{/* Header — breadcrumb, stepper, title */}
@@ -1154,23 +1225,26 @@ export function CheckoutContent({
3. Zahlungsart
</p>
{paymentMethods.map((method) => (
<label key={method.id} className="flex items-center gap-3 w-full cursor-pointer">
<input
type="radio"
name="payment"
checked={paymentMethodId === method.id}
onChange={() => setPaymentMethodId(method.id)}
className="size-5 shrink-0 accent-brand"
/>
<span className="flex-1 text-body-sm text-text-primary">{method.title}</span>
<span className="flex items-center gap-2 shrink-0">
{method.icons.map((icon, i) => (
<div key={i} className="relative h-5 w-8 shrink-0">
<Image src={icon} alt="" fill sizes="32px" className="object-contain" />
</div>
))}
{paymentOptions.map((method) => (
<label key={method.id} className="flex flex-col gap-1 w-full cursor-pointer">
<span className="flex items-center gap-3 w-full">
<input
type="radio"
name="payment"
checked={paymentMethodId === method.id}
onChange={() => setPaymentMethodId(method.id)}
className="size-5 shrink-0 accent-brand"
/>
<span className="flex-1 text-body-sm text-text-primary">{method.title}</span>
<span className="flex items-center gap-2 shrink-0">
{method.icons.map((icon, i) => (
<div key={i} className="relative h-5 w-8 shrink-0">
<Image src={icon} alt="" fill sizes="32px" className="object-contain" />
</div>
))}
</span>
</span>
{method.hint && <span className="pl-8 text-label text-text-muted">{method.hint}</span>}
</label>
))}
+142
View File
@@ -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<Stripe | null> | null = null;
function getStripe(): Promise<Stripe | null> {
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 <TestPaymentButtons orderNumber={orderNumber} orderId={orderId} providerReference={providerReference ?? ""} />;
}
return (
<Elements stripe={getStripe()} options={{ clientSecret }}>
<StripePaymentForm orderNumber={orderNumber} />
</Elements>
);
}
function StripePaymentForm({ orderNumber }: { orderNumber: string }) {
const stripe = useStripe();
const elements = useElements();
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(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 (
<form onSubmit={handlePay} className="flex flex-col gap-4">
<PaymentElement />
{error && <p className="text-sm text-red-600">{error}</p>}
<button
type="submit"
disabled={!stripe || submitting}
className="rounded-full bg-brand-primary px-6 py-3 text-white font-semibold disabled:opacity-50"
>
{submitting ? "Wird bearbeitet…" : "Jetzt bezahlen"}
</button>
</form>
);
}
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<string | null>(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 (
<div className="flex flex-col gap-3 rounded-xl border border-dashed border-amber-500 bg-amber-50 p-4">
<p className="text-sm font-semibold text-amber-800">PAYMENT_TEST_MODE aktiv kein echtes Stripe-Konto verbunden.</p>
{error && <p className="text-sm text-red-600">{error}</p>}
<div className="flex gap-3">
<button
type="button"
onClick={() => confirm("paid")}
disabled={submitting !== null}
className="rounded-full bg-green-600 px-5 py-2 text-white font-semibold disabled:opacity-50"
>
{submitting === "paid" ? "Wird bestätigt…" : "Testzahlung erfolgreich"}
</button>
<button
type="button"
onClick={() => confirm("failed")}
disabled={submitting !== null}
className="rounded-full bg-red-600 px-5 py-2 text-white font-semibold disabled:opacity-50"
>
{submitting === "failed" ? "Wird bestätigt…" : "Testzahlung fehlgeschlagen"}
</button>
</div>
</div>
);
}
@@ -0,0 +1,145 @@
"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<number | null>(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) {
// Patch in the real instrument (Kreditkarte/PayPal) now
// that it's known — the pending snapshot was written at
// checkout submission time with the neutral "Online-
// Zahlung" placeholder, before the customer had actually
// picked one on the Payment Element.
const snapshot = JSON.parse(pending);
if (data.paymentMethodTitle) snapshot.paymentMethodTitle = data.paymentMethodTitle;
window.sessionStorage.setItem(ORDER_KEY, JSON.stringify(snapshot));
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 (
<main className="flex flex-col flex-1 items-center justify-center gap-6 py-24 px-[var(--layout-padding-x)] text-center">
{state === "polling" && (
<>
<p className="text-h-feature font-semibold text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>
Zahlung wird bestätigt
</p>
<p className="text-body text-text-muted">Einen Moment bitte, das dauert normalerweise nur wenige Sekunden.</p>
</>
)}
{state === "timeout" && (
<>
<p className="text-h-feature font-semibold text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>
Das dauert etwas länger
</p>
<p className="text-body text-text-muted max-w-md">
Deine Zahlung wird noch verarbeitet. Sobald sie bestätigt ist, schicken wir dir eine Bestätigungs-E-Mail du musst hier nicht warten.
</p>
</>
)}
{state === "failed" && (
<>
<p className="text-h-feature font-semibold text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>
Zahlung fehlgeschlagen
</p>
<p className="text-body text-text-muted max-w-md">
Deine Zahlung konnte nicht abgeschlossen werden. Dein Warenkorb ist noch vorhanden du kannst es gerne erneut versuchen.
</p>
<Link
href="/checkout"
className="flex items-center gap-2 px-7 py-4 rounded-sm bg-brand text-h4 font-bold text-text-primary hover:bg-brand-hover active:scale-[0.97] transition-all"
>
Zurück zum Checkout
</Link>
</>
)}
{state === "error" && (
<>
<p className="text-h-feature font-semibold text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>
Status konnte nicht geladen werden
</p>
<p className="text-body text-text-muted max-w-md">
Falls die Zahlung erfolgreich war, erhältst du in Kürze eine Bestätigungs-E-Mail. Andernfalls kannst du es erneut versuchen.
</p>
<Link
href="/checkout"
className="flex items-center gap-2 px-7 py-4 rounded-sm bg-brand text-h4 font-bold text-text-primary hover:bg-brand-hover active:scale-[0.97] transition-all"
>
Zurück zum Checkout
</Link>
</>
)}
</main>
);
}
+20
View File
@@ -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 (
<Suspense>
<VerarbeitungContent />
</Suspense>
);
}
+33 -16
View File
@@ -1,10 +1,20 @@
// Server-only — syncs newsletter opt-ins to Brevo's Contacts API. Brevo
// owns everything downstream of that (list membership, unsubscribe links,
// and whatever Welcome Flow automation is configured on the list in
// Brevo's own UI — that automation isn't manageable via their public API
// at all, only contacts/lists are). This app never sends marketing mail
// itself; it only ever hands Brevo the contact + consent.
const BREVO_API_URL = "https://api.brevo.com/v3/contacts";
// Server-only — syncs newsletter opt-ins to Brevo's Contacts API via the
// double-opt-in endpoint: this only ever *requests* a subscription, it
// does not add the contact to the real list itself — Brevo sends the
// confirmation email (the template at BREVO_DOUBLE_OPTIN_TEMPLATE_ID,
// configured as this list's Double Opt-in template in Brevo's own UI) and
// only adds the contact to BREVO_LIST_ID once they click through. This
// app never sends marketing mail itself, and — as of this switch — never
// even directly grants list membership; it only ever hands Brevo the
// contact + consent-to-be-asked. Everything after that (the confirmation
// email itself, the post-confirmation Welcome Flow automation) is
// configured in Brevo's own UI, not manageable via their public API.
//
// Previously called the plain `POST /v3/contacts` upsert (single
// opt-in — added straight to the list, no confirmation click required).
// Switched 2026-07-25 per explicit request once the confirmation-email
// template existed to point templateId at.
const BREVO_DOUBLE_OPTIN_URL = "https://api.brevo.com/v3/contacts/doubleOptinConfirmation";
export type BrevoSyncResult = { ok: true } | { ok: false; reason: string };
@@ -19,12 +29,14 @@ export async function upsertNewsletterContact(
): Promise<BrevoSyncResult> {
const apiKey = process.env.BREVO_API_KEY;
const listId = process.env.BREVO_LIST_ID;
if (!apiKey || !listId) {
return { ok: false, reason: "BREVO_API_KEY/BREVO_LIST_ID nicht konfiguriert." };
const templateId = process.env.BREVO_DOUBLE_OPTIN_TEMPLATE_ID;
if (!apiKey || !listId || !templateId) {
return { ok: false, reason: "BREVO_API_KEY/BREVO_LIST_ID/BREVO_DOUBLE_OPTIN_TEMPLATE_ID nicht konfiguriert." };
}
const redirectionUrl = process.env.BREVO_DOI_REDIRECT_URL || "https://einfach-produktiv.mk360.de/newsletter-confirmed";
try {
const res = await fetch(BREVO_API_URL, {
const res = await fetch(BREVO_DOUBLE_OPTIN_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
@@ -32,16 +44,21 @@ export async function upsertNewsletterContact(
},
body: JSON.stringify({
email,
listIds: [Number(listId)],
updateEnabled: true,
includeListIds: [Number(listId)],
templateId: Number(templateId),
redirectionUrl,
attributes: { OPT_IN_SOURCE: source },
}),
signal: AbortSignal.timeout(8000),
});
// 204 for both a fresh contact and an existing one (updateEnabled
// above merges the list membership onto the existing contact instead
// of erroring).
if (res.ok || res.status === 204) return { ok: true };
// 201 Created is this endpoint's success status (unlike the plain
// contacts upsert this replaced, which used 204). A contact who's
// already confirmed-and-subscribed re-submitting the form is not
// treated as an error either — Brevo resends the confirmation email
// in that case rather than erroring, which is an acceptable no-op
// resend from this app's point of view (matches the previous
// endpoint's "always succeeds for an existing contact too" behavior).
if (res.ok || res.status === 201) return { ok: true };
const body = await res.json().catch(() => null);
return { ok: false, reason: body?.message ?? `Brevo antwortete mit ${res.status}` };
} catch (err) {
+4
View File
@@ -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;
+9
View File
@@ -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;
+28 -2
View File
@@ -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<CreatedOrder | null> {
const tenantId = await resolveTenantId();
@@ -138,6 +157,10 @@ export async function createOrder(input: CreateOrderInput): Promise<CreatedOrder
discountCode: input.discountCode,
discountAmount: input.discountAmount,
total: input.total,
...(input.status ? { status: input.status } : {}),
...(input.paymentProvider ? { paymentProvider: input.paymentProvider } : {}),
...(input.paymentStatus ? { paymentStatus: input.paymentStatus } : {}),
...(input.providerReference ? { providerReference: input.providerReference } : {}),
}),
});
@@ -146,8 +169,11 @@ export async function createOrder(input: CreateOrderInput): Promise<CreatedOrder
return null;
}
const data: { doc: { orderNumber: string; createdAt: string; invoiceNumber: string; invoiceIssuedAt: string } } = await res.json();
const data: {
doc: { id: number; orderNumber: string; createdAt: string; invoiceNumber: string | null; invoiceIssuedAt: string | null };
} = await res.json();
return {
id: data.doc.id,
orderNumber: data.doc.orderNumber,
createdAt: data.doc.createdAt,
invoiceNumber: data.doc.invoiceNumber,
+40 -1
View File
@@ -577,13 +577,19 @@ export async function getShippingSettings(): Promise<ShippingSettings> {
};
}
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<PaymentMethod[]> {
@@ -611,9 +617,42 @@ export async function getPaymentMethods(): Promise<PaymentMethod[]> {
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",
}));
}
export type CheckoutPaymentOption = PaymentMethod & { hint?: string };
// Kreditkarte and PayPal both resolve to `provider: 'stripe'` today, and
// both end up on the exact same Stripe PaymentIntent
// (`automatic_payment_methods: { enabled: true }` — Stripe's own
// recommended Payment Element pattern lets Stripe itself decide which
// eligible method to show, rather than the older per-method
// Checkout-Session split). Pre-selecting one of two identical-behind-the-
// scenes rows before the payment step is therefore no longer a real
// choice, just redundant friction — so this collapses every active
// `stripe` row into one "Online-Zahlung" option (representative id =
// the first such row's, since app/api/checkout/route.ts only branches on
// `provider`, never on which specific stripe row was picked) with a hint
// explaining that the actual instrument is chosen on the next screen.
// `manual` rows (Überweisung) pass through unchanged — one real gateway
// there, one option, nothing to collapse.
export function groupPaymentMethodsForCheckout(methods: PaymentMethod[]): CheckoutPaymentOption[] {
const manual = methods.filter((m) => m.provider !== "stripe");
const stripeMethods = methods.filter((m) => m.provider === "stripe");
if (stripeMethods.length === 0) return manual;
const combinedIcons = Array.from(new Set(stripeMethods.flatMap((m) => m.icons)));
const online: CheckoutPaymentOption = {
id: stripeMethods[0].id,
title: "Online-Zahlung",
icons: combinedIcons,
provider: "stripe",
hint: "Kreditkarte, PayPal & weitere Methoden — die genaue Zahlungsart wählst du im nächsten Schritt.",
};
return [...manual, online];
}
export type WerkzeugeCard = {
id: number;
title: string;
+31
View File
@@ -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<void> {
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),
});
}
}
+15
View File
@@ -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;
+20
View File
@@ -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<CreatePaymentIntentResult> {
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<void> {
// 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 };
+85
View File
@@ -0,0 +1,85 @@
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<CreatePaymentIntentResult> {
// 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<void> {
await getClient().paymentIntents.update(providerReference, { metadata });
}
export const stripeProvider: PaymentProvider = { createPaymentIntent, attachOrderMetadata };
const PAYMENT_METHOD_LABELS: Record<string, string> = { card: "Kreditkarte", paypal: "PayPal" };
// Called only by the real webhook route on `payment_intent.succeeded` —
// the checkout route snapshots a neutral "Online-Zahlung" title at order
// creation (see its own comment: the customer hasn't chosen an instrument
// yet at that point, Stripe's Payment Element does that next), this
// resolves the actual one once Stripe reports it so the order/invoice/
// confirmation email reflect what was really used, not a placeholder.
// Best-effort: an unresolvable label just leaves the neutral title in
// place (confirmPayment.ts only overwrites paymentMethodTitle when this
// returns something), it doesn't fail the payment confirmation itself.
export async function resolveStripePaymentMethodLabel(intent: Stripe.PaymentIntent): Promise<string | undefined> {
const pm = intent.payment_method;
const pmId = typeof pm === "string" ? pm : pm?.id;
if (!pmId) return undefined;
try {
const resolved = pm && typeof pm === "object" ? pm : await getClient().paymentMethods.retrieve(pmId);
return PAYMENT_METHOD_LABELS[resolved.type] ?? resolved.type;
} catch {
return undefined;
}
}
// 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;
}
}
+32
View File
@@ -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<CreatePaymentIntentResult>;
// 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<void>;
}
+66
View File
@@ -0,0 +1,66 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Reveal } from "../components/Reveal";
import { Footer } from "../components/Footer";
import { TrustRow } from "../components/TrustRow";
// robots: noindex — transactional landing page (Brevo's double opt-in
// redirectionUrl target, see app/lib/brevo.ts's BREVO_DOI_REDIRECT_URL),
// same reasoning as /bestellbestaetigung and /checkout: nothing here is
// meant to be found via search, only reached via the confirmation link.
export const metadata: Metadata = {
title: "Newsletter bestätigt",
description: "Deine Newsletter-Anmeldung bei einfach produktiv ist bestätigt.",
robots: {
index: false,
follow: true,
},
};
// Static — Brevo's confirmation click lands here with no query params to
// read, so unlike /bestellbestaetigung (which hydrates a sessionStorage
// order snapshot) or /checkout/verarbeitung (which polls payment status),
// this page has nothing to fetch or wait on. Same visual language as
// those two: warm bg-bg-base, brand-tinted circular icon, serif display
// heading, thin brand divider — see BestellbestaetigungContent.tsx for
// the pattern this mirrors.
export default function NewsletterConfirmedPage() {
return (
<>
<main className="flex flex-col flex-1 bg-bg-base">
<Reveal className="flex flex-col gap-4 items-center text-center pt-24 pb-16 px-[var(--layout-padding-x)] w-full">
<div className="flex items-center justify-center size-14 rounded-full bg-brand/10 text-brand shrink-0">
<svg viewBox="0 0 24 24" className="size-6" fill="none" aria-hidden="true">
<path d="M5 13.5 9.5 18 19 7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
<p
className="font-semibold text-display text-text-primary"
style={{ fontFamily: "var(--font-playfair)" }}
>
Bestätigt!
</p>
<p
className="font-semibold text-h3 text-text-primary"
style={{ fontFamily: "var(--font-lora)" }}
>
Du bist jetzt Teil unseres Newsletters.
</p>
<div className="h-[0.125rem] w-8 bg-brand" />
<p className="text-body text-text-muted max-w-[28rem] pt-2">
Schön, dass du dabei bist! Ab jetzt bekommst du hin und wieder Impulse, neue Produkte
und kleine Erinnerungen von uns, damit dein Alltag ein bisschen leichter wird.
</p>
<Link
href="/shop"
className="inline-flex items-center justify-center py-4 px-8 mt-4 rounded-sm bg-brand hover:bg-brand-hover active:scale-[0.97] transition-all font-bold text-body text-text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-bg-base"
>
Jetzt stöbern
</Link>
</Reveal>
<TrustRow />
</main>
<Footer />
</>
);
}
+44 -1
View File
@@ -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",
+4 -1
View File
@@ -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",