Compare commits

..

1 Commits

Author SHA1 Message Date
Marco cb4c9d77f2 Fix icon circle collapsing narrow in strict HTML-attribute-stripping renderers
The width="56" height="56" HTML attributes on the icon badge <td> weren't
mirrored in its inline style — renderers that ignore/strip HTML
width/height (Brevo's own editor among them, per a real report) shrink
the cell to fit the glyph instead of staying a 56px circle. Add explicit
width:56px;height:56px to the style too.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 14:48:45 +00:00
22 changed files with 112 additions and 1264 deletions
+13 -139
View File
@@ -50,15 +50,8 @@ 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). `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`.
independent, see the "Monitoring & alerting" section). Set in Coolify's
app settings for production, not in a committed `.env`.
## Pages
@@ -288,8 +281,7 @@ 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()` for Überweisung orders only —
fire-and-forget
right after a successful `createOrder()` — 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
@@ -298,114 +290,10 @@ 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. 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.
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.
### VAT display
@@ -1061,26 +949,15 @@ unsynced.
- **`app/lib/brevo.ts`** — the only thing that talks to Brevo.
`upsertNewsletterContact(email, source)` calls Brevo's
**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"` |
`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"` |
`"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 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.
drops it on every upsert (no error at all, just never stored) rather
than rejecting the request.
- **`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
@@ -1106,10 +983,7 @@ 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. 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`.
— confirmed live end-to-end 2026-07-23.
## Orders & customer accounts
+59 -138
View File
@@ -12,7 +12,6 @@ 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
@@ -282,35 +281,6 @@ 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,
@@ -343,21 +313,10 @@ export async function POST(request: Request) {
subtotal: finalSubtotal,
shippingCost: finalShippingCost,
shippingMethodTitle: shippingMethod.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,
paymentMethodTitle: 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
@@ -375,91 +334,68 @@ export async function POST(request: Request) {
return NextResponse.json({ ok: false, reason: "Bestellung konnte nicht gespeichert werden." }, { status: 500 });
}
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),
});
// 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),
});
}
// 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(() => {});
}
@@ -467,24 +403,9 @@ 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: requiresPayment ? "Online-Zahlung" : paymentMethod.title,
paymentMethodTitle: paymentMethod.title,
discountCode: body.discountCode || null,
discountAmount,
vatExempt,
-31
View File
@@ -1,31 +0,0 @@
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
@@ -1,90 +0,0 @@
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 });
}
@@ -1,55 +0,0 @@
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 });
}
+19 -93
View File
@@ -1,6 +1,6 @@
"use client";
import { forwardRef, useEffect, useMemo, useRef, useState } from "react";
import { forwardRef, useEffect, useRef, useState } from "react";
import Link from "next/link";
import Image from "next/image";
import { useRouter } from "next/navigation";
@@ -14,15 +14,13 @@ import { Reveal } from "../../components/Reveal";
import { VersandModal } from "../../components/VersandModal";
import { VatBreakdown } from "../../components/VatBreakdown";
import { CheckoutSteps } from "../../components/CheckoutSteps";
import { ORDER_KEY, PENDING_ORDER_KEY, type OrderSnapshot } from "../../lib/order";
import { PaymentStep } from "./PaymentStep";
import { ORDER_KEY, type OrderSnapshot } from "../../lib/order";
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
@@ -142,26 +140,10 @@ 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);
// 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 [paymentMethodId, setPaymentMethodId] = useState<number | null>(paymentMethods[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
@@ -597,32 +579,6 @@ 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 {
@@ -664,33 +620,6 @@ 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 */}
@@ -1225,26 +1154,23 @@ export function CheckoutContent({
3. Zahlungsart
</p>
{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>
{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>
))}
</span>
{method.hint && <span className="pl-8 text-label text-text-muted">{method.hint}</span>}
</label>
))}
-142
View File
@@ -1,142 +0,0 @@
"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>
);
}
@@ -1,145 +0,0 @@
"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
@@ -1,20 +0,0 @@
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>
);
}
+16 -33
View File
@@ -1,20 +1,10 @@
// 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";
// 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";
export type BrevoSyncResult = { ok: true } | { ok: false; reason: string };
@@ -29,14 +19,12 @@ export async function upsertNewsletterContact(
): Promise<BrevoSyncResult> {
const apiKey = process.env.BREVO_API_KEY;
const listId = process.env.BREVO_LIST_ID;
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." };
if (!apiKey || !listId) {
return { ok: false, reason: "BREVO_API_KEY/BREVO_LIST_ID nicht konfiguriert." };
}
const redirectionUrl = process.env.BREVO_DOI_REDIRECT_URL || "https://einfach-produktiv.mk360.de/newsletter-confirmed";
try {
const res = await fetch(BREVO_DOUBLE_OPTIN_URL, {
const res = await fetch(BREVO_API_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
@@ -44,21 +32,16 @@ export async function upsertNewsletterContact(
},
body: JSON.stringify({
email,
includeListIds: [Number(listId)],
templateId: Number(templateId),
redirectionUrl,
listIds: [Number(listId)],
updateEnabled: true,
attributes: { OPT_IN_SOURCE: source },
}),
signal: AbortSignal.timeout(8000),
});
// 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 };
// 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 };
const body = await res.json().catch(() => null);
return { ok: false, reason: body?.message ?? `Brevo antwortete mit ${res.status}` };
} catch (err) {
-4
View File
@@ -443,10 +443,6 @@ 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,15 +8,6 @@ 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;
+2 -28
View File
@@ -76,28 +76,9 @@ 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 = {
id: number;
orderNumber: string;
createdAt: string;
invoiceNumber: string | null;
invoiceIssuedAt: string | null;
};
export type CreatedOrder = { orderNumber: string; createdAt: string; invoiceNumber: string; invoiceIssuedAt: string };
export async function createOrder(input: CreateOrderInput): Promise<CreatedOrder | null> {
const tenantId = await resolveTenantId();
@@ -157,10 +138,6 @@ 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 } : {}),
}),
});
@@ -169,11 +146,8 @@ export async function createOrder(input: CreateOrderInput): Promise<CreatedOrder
return null;
}
const data: {
doc: { id: number; orderNumber: string; createdAt: string; invoiceNumber: string | null; invoiceIssuedAt: string | null };
} = await res.json();
const data: { doc: { orderNumber: string; createdAt: string; invoiceNumber: string; invoiceIssuedAt: string } } = await res.json();
return {
id: data.doc.id,
orderNumber: data.doc.orderNumber,
createdAt: data.doc.createdAt,
invoiceNumber: data.doc.invoiceNumber,
+1 -40
View File
@@ -577,19 +577,13 @@ export async function getShippingSettings(): Promise<ShippingSettings> {
};
}
// `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" };
export type PaymentMethod = { id: number; title: string; icons: string[] };
type PayloadPaymentMethod = {
id: number;
title: string;
active: boolean;
icons: { icon: { url: string } | number | null }[];
provider?: "manual" | "stripe";
};
export async function getPaymentMethods(): Promise<PaymentMethod[]> {
@@ -617,42 +611,9 @@ 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
@@ -1,31 +0,0 @@
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
@@ -1,15 +0,0 @@
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
@@ -1,20 +0,0 @@
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
@@ -1,85 +0,0 @@
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
@@ -1,32 +0,0 @@
// 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
@@ -1,66 +0,0 @@
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 />
</>
);
}
+1 -44
View File
@@ -12,14 +12,11 @@
"@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",
"stripe": "^22.3.2"
"react-dom": "19.2.4"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
@@ -3333,29 +3330,6 @@
"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",
@@ -11311,23 +11285,6 @@
"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",
+1 -4
View File
@@ -14,14 +14,11 @@
"@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",
"stripe": "^22.3.2"
"react-dom": "19.2.4"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",