Add Stripe payment processing (cards + PayPal) with a webhook-gated checkout flow
Checkout now branches on payment-methods.provider: Überweisung stays immediate/unchanged, Kreditkarte/PayPal creates a pending_payment order, mounts Stripe's Payment Element, and defers invoice/email to a webhook- verified confirm-payment call once the backend actually confirms payment. Includes a PAYMENT_TEST_MODE mock provider so the whole gated pipeline is exercisable locally without a real Stripe account. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -50,8 +50,11 @@ header). `SMTP_USER`/`SMTP_PASSWORD`
|
||||
(no safe default — required for `app/lib/alertAdmin.ts`'s critical-failure
|
||||
alerts and resend-verification emails; **does not** need to match anything
|
||||
on the Payload side — this app's SMTP connection is deliberately
|
||||
independent, see the "Monitoring & alerting" section). Set in Coolify's
|
||||
app settings for production, not in a committed `.env`.
|
||||
independent, see the "Monitoring & alerting" section). `STRIPE_SECRET_KEY`,
|
||||
`STRIPE_WEBHOOK_SECRET`, `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY`,
|
||||
`PAYMENT_WEBHOOK_SECRET`, `PAYMENT_TEST_MODE` — see "Payment processing
|
||||
(Stripe)" below. Set in Coolify's app settings for production, not in a
|
||||
committed `.env`.
|
||||
|
||||
## Pages
|
||||
|
||||
@@ -281,7 +284,8 @@ check against Payload's public API, unlike most content on this site.
|
||||
its `orderNumber`/`orderDateIso` now come back from that Payload create
|
||||
call, not generated client-side.
|
||||
- **Order confirmation email** is sent from `/api/checkout/route.ts`
|
||||
right after a successful `createOrder()` — fire-and-forget
|
||||
right after a successful `createOrder()` for Überweisung orders only —
|
||||
fire-and-forget
|
||||
(`app/lib/orderEmail.ts`'s `sendOrderConfirmationEmail()`), never blocks
|
||||
or fails the checkout response itself; a send failure alerts admin
|
||||
instead (`sendCriticalAlert`, lower severity than the "order not
|
||||
@@ -290,10 +294,84 @@ check against Payload's public API, unlike most content on this site.
|
||||
`email-templates` collection — see "Email templates & Live Preview"
|
||||
below for how that's edited/previewed. As of the invoice PDF feature
|
||||
(see below), this same send also carries the order's invoice PDF as an
|
||||
attachment.
|
||||
- Still not built: real payment processing — the checkout button is
|
||||
labelled "zahlungspflichtig" but nothing actually captures a payment
|
||||
yet. See `project_backend_checkout_plan` in the assistant's own memory.
|
||||
attachment. Kreditkarte/PayPal orders defer this until payment is
|
||||
confirmed — see "Payment processing (Stripe)" below.
|
||||
|
||||
### Payment processing (Stripe)
|
||||
|
||||
Real payment capture for Kreditkarte/PayPal, via Stripe's Payment Element
|
||||
(one integration covers both — see the approved plan this was built from,
|
||||
`spicy-leaping-pizza.md`, for the full design rationale). Überweisung stays
|
||||
exactly as before: no gateway involved, order goes straight to `received`.
|
||||
|
||||
- **`payment-methods`'s `provider` field** (Payload, admin-only) drives the
|
||||
branch — `'manual'` (Überweisung) or `'stripe'` (Kreditkarte/PayPal).
|
||||
`app/lib/payload.ts`'s `getPaymentMethods()` exposes it; the checkout
|
||||
route re-resolves it server-side, never trusts a client-submitted value.
|
||||
- **`app/api/checkout/route.ts`, `provider === 'stripe'` branch**: creates
|
||||
a Stripe PaymentIntent *before* the order (`app/lib/payments/
|
||||
stripeProvider.ts`) — its id is known immediately and gets persisted as
|
||||
the order's own `providerReference` field at creation time, so the
|
||||
backend's abandonment-cleanup job can reconcile with Stripe later even
|
||||
if nothing else about this flow ever completes. The order is created
|
||||
with `status: 'pending_payment'`, `paymentStatus: 'pending'` — no
|
||||
invoice number yet, no confirmation email yet (both deferred to the
|
||||
webhook-driven confirm-payment step, on the backend). Right after, a
|
||||
best-effort (awaited, non-fatal) call attaches `{orderId, orderNumber}`
|
||||
as PaymentIntent metadata (`attachOrderMetadata`) — this is what lets
|
||||
the webhook resolve an incoming Stripe event back to a specific Payload
|
||||
order.
|
||||
- **`app/checkout/components/PaymentStep.tsx`** renders in place of the
|
||||
address form once `/api/checkout` returns `requiresPayment: true` —
|
||||
Stripe's `PaymentElement` (real mode) or a "Testzahlung erfolgreich /
|
||||
fehlgeschlagen" button pair (test mode, see below). A card confirms
|
||||
in-place; PayPal (and 3-D-Secure challenges) redirect out and back via
|
||||
`return_url=/checkout/verarbeitung?orderNumber=...`.
|
||||
- **`app/api/webhooks/stripe/route.ts`** — the real inbound webhook.
|
||||
Verifies `stripe-signature` against `STRIPE_WEBHOOK_SECRET`, reads the
|
||||
**raw** body (never `.json()` — the signature is computed over the exact
|
||||
bytes), handles `payment_intent.succeeded`/`.payment_failed`, and calls
|
||||
the backend's `POST /api/orders/:id/confirm-payment` (guarded by
|
||||
`PAYMENT_WEBHOOK_SECRET`, a secret distinct from `ORDER_SERVICE_SECRET`
|
||||
on purpose — least privilege, it can only hit this one action) with
|
||||
`{paymentStatus, providerReference, paidAt}`. Returns a non-2xx status on
|
||||
any internal failure so Stripe's own retry schedule (~3 days) provides
|
||||
resilience for free, rather than this app building its own retry queue.
|
||||
That backend endpoint is what actually flips the order to `received`,
|
||||
assigns the (until-then-deferred) invoice number, and sends the
|
||||
confirmation email/invoice + the internal admin new-order notification —
|
||||
see the backend repo's own README for that half.
|
||||
- **`/checkout/verarbeitung`** (`VerarbeitungContent.tsx`) is the
|
||||
`return_url` target. Neither a client-side `confirmPayment()` success nor
|
||||
landing back from a PayPal redirect is trusted as proof of payment on its
|
||||
own (a closed tab mid-redirect looks identical to success from here) —
|
||||
this page polls `/api/checkout/status?orderNumber=...` (session-scoped,
|
||||
so a guessed order number can't be used to probe someone else's payment
|
||||
status) until `paymentStatus` flips to `paid`, then promotes the
|
||||
provisional `sessionStorage` snapshot (`PENDING_ORDER_KEY`, written right
|
||||
before handing off to Stripe) to the real one (`ORDER_KEY`), clears the
|
||||
cart, and redirects to `/bestellbestaetigung` — exactly the same
|
||||
sessionStorage mechanism Überweisung orders already used, just populated
|
||||
a step later. On `failed`/`cancelled` it shows a retry message with the
|
||||
cart left intact (never cleared until payment actually succeeds); on a
|
||||
slow-to-arrive webhook it times out after ~15s with a "we'll email you"
|
||||
message rather than polling forever.
|
||||
|
||||
**Local testing without a real Stripe account** — `PAYMENT_TEST_MODE`
|
||||
(defaults on whenever `STRIPE_SECRET_KEY` is unset, so a fresh `npm run dev`
|
||||
never accidentally calls the real Stripe API): `app/lib/payments/index.ts`
|
||||
swaps in `mockProvider.ts` instead of `stripeProvider.ts` — same interface,
|
||||
so the checkout route and everything downstream of it runs unmodified.
|
||||
`PaymentStep.tsx` shows "Testzahlung erfolgreich"/"Testzahlung
|
||||
fehlgeschlagen" buttons instead of the real Payment Element; clicking one
|
||||
calls `app/api/webhooks/stripe/test-confirm/route.ts`, which skips
|
||||
signature verification (there's no real Stripe event to verify) and calls
|
||||
the exact same backend `confirm-payment` endpoint the real webhook does —
|
||||
so clicking "erfolgreich" exercises the *entire* real pipeline (deferred
|
||||
invoice numbering, gated email, idempotency) end to end, it's only the
|
||||
Stripe API call itself that's faked. That test-confirm route hard-404s
|
||||
whenever `PAYMENT_TEST_MODE` isn't explicitly true, so it can never become
|
||||
a reachable "mark any order paid" endpoint in production.
|
||||
|
||||
### VAT display
|
||||
|
||||
|
||||
+128
-57
@@ -12,6 +12,7 @@ import { normalizeVatId, isValidVatId } from "../../lib/vatId";
|
||||
import { checkVatIdViaVies } from "../../lib/vies";
|
||||
import { computeExemptTotals, destinationCountry, isExemptionEligibleCountry } from "../../lib/vatExemption";
|
||||
import { upsertNewsletterContact } from "../../lib/brevo";
|
||||
import { paymentProvider, isPaymentTestMode } from "../../lib/payments";
|
||||
|
||||
// Plain float arithmetic on money (quantity × unitPrice summed across
|
||||
// lines, a percent discount, subtracting/adding those together) drifts
|
||||
@@ -281,6 +282,35 @@ export async function POST(request: Request) {
|
||||
const finalShippingCost = exemptTotals?.shippingCost ?? shippingCost;
|
||||
const total = roundMoney(Math.max(0, finalSubtotal - discountAmount) + finalShippingCost);
|
||||
|
||||
// Gated-payment branch (Kreditkarte/PayPal today) — see
|
||||
// spicy-leaping-pizza.md §3. The PaymentIntent is created BEFORE the
|
||||
// order so its id can be persisted onto the order at creation time
|
||||
// (providerReference), rather than needing a second authenticated
|
||||
// update call that doesn't otherwise exist from this service. Stripe
|
||||
// generates a PaymentIntent id independent of any order existing yet.
|
||||
const requiresPayment = paymentMethod.provider === "stripe";
|
||||
let providerReference: string | undefined;
|
||||
let clientSecret: string | undefined;
|
||||
if (requiresPayment) {
|
||||
try {
|
||||
const intent = await paymentProvider.createPaymentIntent({
|
||||
amountCents: Math.round(total * 100),
|
||||
currency: "eur",
|
||||
customerEmail: body.email,
|
||||
description: `einfach produktiv Bestellung — ${body.firstName} ${body.lastName}`,
|
||||
});
|
||||
providerReference = intent.providerReference;
|
||||
clientSecret = intent.clientSecret;
|
||||
} catch (err) {
|
||||
sendCriticalAlert("Zahlung konnte nicht vorbereitet werden", {
|
||||
customerEmail: body.email,
|
||||
total,
|
||||
error: String(err),
|
||||
});
|
||||
return NextResponse.json({ ok: false, reason: "Die Zahlung konnte gerade nicht vorbereitet werden." }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
const order = await createOrder({
|
||||
customerId: customer.id,
|
||||
customerFirstName: body.firstName,
|
||||
@@ -317,6 +347,9 @@ export async function POST(request: Request) {
|
||||
discountCode: body.discountCode || null,
|
||||
discountAmount,
|
||||
total,
|
||||
...(requiresPayment
|
||||
? { status: "pending_payment" as const, paymentProvider: "stripe" as const, paymentStatus: "pending" as const, providerReference }
|
||||
: {}),
|
||||
});
|
||||
if (!order) {
|
||||
// The worst-case failure in this whole flow: the customer went
|
||||
@@ -334,68 +367,91 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ ok: false, reason: "Bestellung konnte nicht gespeichert werden." }, { status: 500 });
|
||||
}
|
||||
|
||||
// Fire-and-forget — a failed confirmation email must never undo an
|
||||
// already-successful order or block the response the customer is
|
||||
// waiting on. Lower severity than the "order lost" alert above (the
|
||||
// order itself is safe either way), but still worth knowing about, since
|
||||
// it's the one thing that would otherwise fail completely silently.
|
||||
sendOrderConfirmationEmail(
|
||||
{
|
||||
orderNumber: order.orderNumber,
|
||||
createdAt: order.createdAt,
|
||||
invoiceNumber: order.invoiceNumber,
|
||||
invoiceIssuedAt: order.invoiceIssuedAt,
|
||||
customerFirstName: body.firstName,
|
||||
customerLastName: body.lastName,
|
||||
companyName: body.companyName || undefined,
|
||||
vatId: normalizedVatId,
|
||||
vatExempt,
|
||||
kleinunternehmer,
|
||||
deliveryMethod: body.deliveryMethod,
|
||||
street: body.street,
|
||||
packstationNumber: body.packstationNumber,
|
||||
postNumber: body.postNumber,
|
||||
zip: body.zip,
|
||||
city: body.city,
|
||||
country: body.country,
|
||||
hasDifferentShippingAddress: Boolean(body.hasDifferentShippingAddress),
|
||||
shippingFirstName: body.shippingFirstName,
|
||||
shippingLastName: body.shippingLastName,
|
||||
shippingDeliveryMethod: body.shippingDeliveryMethod,
|
||||
shippingStreet: body.shippingStreet,
|
||||
shippingPackstationNumber: body.shippingPackstationNumber,
|
||||
shippingPostNumber: body.shippingPostNumber,
|
||||
shippingZip: body.shippingZip,
|
||||
shippingCity: body.shippingCity,
|
||||
shippingCountry: body.shippingCountry,
|
||||
paymentMethodTitle: paymentMethod.title,
|
||||
items: items.map((i) => ({
|
||||
productName: i.productName,
|
||||
quantity: i.quantity,
|
||||
unitPrice: i.unitPrice,
|
||||
imageUrl: i.imageUrl,
|
||||
taxRatePercent: i.taxRatePercent,
|
||||
bundleContents: i.bundleContents,
|
||||
variantName: i.variantName,
|
||||
})),
|
||||
subtotal: finalSubtotal,
|
||||
shippingCost: finalShippingCost,
|
||||
discountAmount,
|
||||
discountCode: body.discountCode || null,
|
||||
total,
|
||||
},
|
||||
body.email,
|
||||
).catch((err) => {
|
||||
sendCriticalAlert("Bestätigungs-Mail konnte nicht gesendet werden", {
|
||||
orderNumber: order.orderNumber,
|
||||
customerEmail: body.email,
|
||||
error: String(err),
|
||||
if (requiresPayment && providerReference) {
|
||||
// Best-effort — see stripeProvider.attachOrderMetadata's own comment.
|
||||
// Not fatal: the order's own `providerReference` field (already
|
||||
// persisted above) remains the source of truth for the
|
||||
// expirePendingPayments cleanup job either way; this only speeds up
|
||||
// the webhook's fast path.
|
||||
await paymentProvider.attachOrderMetadata(providerReference, { orderId: String(order.id), orderNumber: order.orderNumber }).catch((err) => {
|
||||
sendCriticalAlert("Zahlungsmetadaten konnten nicht verknüpft werden", {
|
||||
orderNumber: order.orderNumber,
|
||||
providerReference,
|
||||
error: String(err),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Deferred for gated payment methods (Kreditkarte/PayPal) until the
|
||||
// webhook confirms payment — see spicy-leaping-pizza.md §3/§4. Sent
|
||||
// from the backend's confirm-payment endpoint instead, at that point.
|
||||
// Unchanged for Überweisung: fires immediately, exactly as before.
|
||||
if (!requiresPayment) {
|
||||
// Fire-and-forget — a failed confirmation email must never undo an
|
||||
// already-successful order or block the response the customer is
|
||||
// waiting on. Lower severity than the "order lost" alert above (the
|
||||
// order itself is safe either way), but still worth knowing about, since
|
||||
// it's the one thing that would otherwise fail completely silently.
|
||||
sendOrderConfirmationEmail(
|
||||
{
|
||||
orderNumber: order.orderNumber,
|
||||
createdAt: order.createdAt,
|
||||
invoiceNumber: order.invoiceNumber as string,
|
||||
invoiceIssuedAt: order.invoiceIssuedAt as string,
|
||||
customerFirstName: body.firstName,
|
||||
customerLastName: body.lastName,
|
||||
companyName: body.companyName || undefined,
|
||||
vatId: normalizedVatId,
|
||||
vatExempt,
|
||||
kleinunternehmer,
|
||||
deliveryMethod: body.deliveryMethod,
|
||||
street: body.street,
|
||||
packstationNumber: body.packstationNumber,
|
||||
postNumber: body.postNumber,
|
||||
zip: body.zip,
|
||||
city: body.city,
|
||||
country: body.country,
|
||||
hasDifferentShippingAddress: Boolean(body.hasDifferentShippingAddress),
|
||||
shippingFirstName: body.shippingFirstName,
|
||||
shippingLastName: body.shippingLastName,
|
||||
shippingDeliveryMethod: body.shippingDeliveryMethod,
|
||||
shippingStreet: body.shippingStreet,
|
||||
shippingPackstationNumber: body.shippingPackstationNumber,
|
||||
shippingPostNumber: body.shippingPostNumber,
|
||||
shippingZip: body.shippingZip,
|
||||
shippingCity: body.shippingCity,
|
||||
shippingCountry: body.shippingCountry,
|
||||
paymentMethodTitle: paymentMethod.title,
|
||||
items: items.map((i) => ({
|
||||
productName: i.productName,
|
||||
quantity: i.quantity,
|
||||
unitPrice: i.unitPrice,
|
||||
imageUrl: i.imageUrl,
|
||||
taxRatePercent: i.taxRatePercent,
|
||||
bundleContents: i.bundleContents,
|
||||
variantName: i.variantName,
|
||||
})),
|
||||
subtotal: finalSubtotal,
|
||||
shippingCost: finalShippingCost,
|
||||
discountAmount,
|
||||
discountCode: body.discountCode || null,
|
||||
total,
|
||||
},
|
||||
body.email,
|
||||
).catch((err) => {
|
||||
sendCriticalAlert("Bestätigungs-Mail konnte nicht gesendet werden", {
|
||||
orderNumber: order.orderNumber,
|
||||
customerEmail: body.email,
|
||||
error: String(err),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Fire-and-forget, same reasoning as the confirmation email above — a
|
||||
// failed marketing sync is not worth failing checkout over, and doesn't
|
||||
// even need a critical alert (nothing customer-facing depends on it).
|
||||
// Not gated on payment confirmation — a newsletter signup intent isn't
|
||||
// an order-fulfillment concern, unlike the confirmation email/invoice.
|
||||
if (body.newsletterOptIn) {
|
||||
upsertNewsletterContact(body.email, "checkout").catch(() => {});
|
||||
}
|
||||
@@ -403,7 +459,22 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
orderNumber: order.orderNumber,
|
||||
orderId: order.id,
|
||||
orderDateIso: order.createdAt,
|
||||
...(requiresPayment
|
||||
? {
|
||||
requiresPayment: true as const,
|
||||
clientSecret,
|
||||
testMode: isPaymentTestMode,
|
||||
// Only surfaced in test mode — PaymentStep's "Testzahlung"
|
||||
// buttons need it to call the test-confirm route directly,
|
||||
// since there's no real Stripe redirect to carry it back
|
||||
// through. A real PaymentIntent id isn't secret (only its
|
||||
// client_secret is), but there's no reason to expose it to the
|
||||
// client outside test mode either.
|
||||
...(isPaymentTestMode ? { providerReference } : {}),
|
||||
}
|
||||
: {}),
|
||||
shippingCost: finalShippingCost,
|
||||
paymentMethodTitle: paymentMethod.title,
|
||||
discountCode: body.discountCode || null,
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSessionCustomer, getCustomerOrderDetail } from "../../../lib/customerAuth";
|
||||
|
||||
// Polled by /checkout/verarbeitung after a Payment Element redirect
|
||||
// returns — see spicy-leaping-pizza.md §3. Requires the customer's own
|
||||
// session (checkout is "Konto Pflicht", so one always exists by the time
|
||||
// this page is reachable) rather than accepting a bare orderNumber, so a
|
||||
// guessed/leaked order number can't be used to probe another customer's
|
||||
// payment status.
|
||||
export async function GET(request: Request) {
|
||||
const session = await getSessionCustomer();
|
||||
if (!session) return NextResponse.json({ ok: false, reason: "Bitte zuerst einloggen." }, { status: 401 });
|
||||
|
||||
const orderNumber = new URL(request.url).searchParams.get("orderNumber");
|
||||
if (!orderNumber) return NextResponse.json({ ok: false, reason: "orderNumber fehlt." }, { status: 400 });
|
||||
|
||||
const order = await getCustomerOrderDetail(session.token, session.customer.id, orderNumber);
|
||||
if (!order) return NextResponse.json({ ok: false, reason: "Bestellung nicht gefunden." }, { status: 404 });
|
||||
|
||||
return NextResponse.json({ ok: true, status: order.status, paymentStatus: order.paymentStatus });
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import Stripe from "stripe";
|
||||
import { verifyStripeWebhookSignature } from "../../../lib/payments/stripeProvider";
|
||||
import { sendCriticalAlert } from "../../../lib/alertAdmin";
|
||||
|
||||
const PAYLOAD_URL = process.env.PAYLOAD_URL || "https://payload.mk360.de";
|
||||
const PAYMENT_WEBHOOK_SECRET = process.env.PAYMENT_WEBHOOK_SECRET || "";
|
||||
|
||||
// Real Stripe webhook — see spicy-leaping-pizza.md §4. Never reachable in
|
||||
// PAYMENT_TEST_MODE in practice (no real Stripe account sends events
|
||||
// here then), but left unconditional rather than gated on the env var —
|
||||
// an invalid/missing signature already fails closed on its own.
|
||||
export async function POST(request: Request) {
|
||||
// Raw body only — request.json() would consume/reparse the stream and
|
||||
// Stripe's signature is computed over the exact original bytes.
|
||||
const rawBody = await request.text();
|
||||
const signature = request.headers.get("stripe-signature");
|
||||
if (!signature) return NextResponse.json({ ok: false }, { status: 400 });
|
||||
|
||||
const event = verifyStripeWebhookSignature(rawBody, signature);
|
||||
if (!event) return NextResponse.json({ ok: false, reason: "invalid signature" }, { status: 400 });
|
||||
|
||||
if (event.type !== "payment_intent.succeeded" && event.type !== "payment_intent.payment_failed") {
|
||||
// Stripe sends many event types we don't act on (e.g.
|
||||
// payment_intent.created, charge.*) — ack them so Stripe stops
|
||||
// retrying something we were never going to process.
|
||||
return NextResponse.json({ ok: true, ignored: event.type });
|
||||
}
|
||||
|
||||
const intent = event.data.object as Stripe.PaymentIntent;
|
||||
const providerReference = intent.id;
|
||||
const orderId = intent.metadata?.orderId;
|
||||
const paymentStatus = event.type === "payment_intent.succeeded" ? "paid" : "failed";
|
||||
|
||||
if (!orderId) {
|
||||
// stripeProvider.attachOrderMetadata (called right after order
|
||||
// creation in /api/checkout) failed to complete for this
|
||||
// PaymentIntent — the order's own `providerReference` field is still
|
||||
// the source of truth and expirePendingPayments will reconcile it
|
||||
// eventually, but that's a multi-hour fallback, not instant. Alert
|
||||
// now rather than silently relying on the cleanup job.
|
||||
sendCriticalAlert("Stripe-Webhook ohne orderId-Metadaten", { providerReference, paymentStatus, eventType: event.type });
|
||||
// Non-2xx so Stripe retries — a later retry might land after the
|
||||
// metadata attach (which races the checkout response) has caught up.
|
||||
return NextResponse.json({ ok: false, reason: "orderId metadata missing" }, { status: 409 });
|
||||
}
|
||||
|
||||
const res = await fetch(`${PAYLOAD_URL}/api/orders/${orderId}/confirm-payment`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-payment-webhook-secret": PAYMENT_WEBHOOK_SECRET,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
paymentStatus,
|
||||
providerReference,
|
||||
paidAt: new Date().toISOString(),
|
||||
}),
|
||||
}).catch((err) => {
|
||||
sendCriticalAlert("confirm-payment-Aufruf ans Backend fehlgeschlagen", { orderId, providerReference, error: String(err) });
|
||||
return null;
|
||||
});
|
||||
|
||||
if (!res || !res.ok) {
|
||||
// Non-2xx on purpose — lets Stripe's own retry schedule (~3 days)
|
||||
// provide resilience instead of building an internal retry queue.
|
||||
return NextResponse.json({ ok: false }, { status: 502 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { isPaymentTestMode } from "../../../../lib/payments";
|
||||
import { sendCriticalAlert } from "../../../../lib/alertAdmin";
|
||||
|
||||
const PAYLOAD_URL = process.env.PAYLOAD_URL || "https://payload.mk360.de";
|
||||
const PAYMENT_WEBHOOK_SECRET = process.env.PAYMENT_WEBHOOK_SECRET || "";
|
||||
|
||||
// Test-mode stand-in for the real Stripe webhook — see
|
||||
// spicy-leaping-pizza.md §7. Drives the exact same backend confirm-payment
|
||||
// endpoint the real webhook calls, just without a real Stripe event/
|
||||
// signature (there is none to verify in test mode). Hard-gated: must
|
||||
// 404 whenever PAYMENT_TEST_MODE isn't explicitly on, so this can never
|
||||
// become an unauthenticated "mark any order paid" endpoint in production.
|
||||
export async function POST(request: Request) {
|
||||
if (!isPaymentTestMode) {
|
||||
return NextResponse.json({ ok: false }, { status: 404 });
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => null);
|
||||
const orderId = body?.orderId;
|
||||
const providerReference = body?.providerReference;
|
||||
const paymentStatus = body?.paymentStatus === "failed" ? "failed" : "paid";
|
||||
if (!orderId || !providerReference) {
|
||||
return NextResponse.json({ ok: false, reason: "orderId und providerReference erforderlich." }, { status: 400 });
|
||||
}
|
||||
|
||||
const res = await fetch(`${PAYLOAD_URL}/api/orders/${orderId}/confirm-payment`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-payment-webhook-secret": PAYMENT_WEBHOOK_SECRET,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ paymentStatus, providerReference, paidAt: new Date().toISOString() }),
|
||||
}).catch((err) => {
|
||||
sendCriticalAlert("Test-confirm-Aufruf ans Backend fehlgeschlagen", { orderId, providerReference, error: String(err) });
|
||||
return null;
|
||||
});
|
||||
|
||||
if (!res || !res.ok) {
|
||||
return NextResponse.json({ ok: false, reason: "Backend hat die Testzahlung nicht bestätigt." }, { status: 502 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -14,7 +14,8 @@ import { Reveal } from "../../components/Reveal";
|
||||
import { VersandModal } from "../../components/VersandModal";
|
||||
import { VatBreakdown } from "../../components/VatBreakdown";
|
||||
import { CheckoutSteps } from "../../components/CheckoutSteps";
|
||||
import { ORDER_KEY, type OrderSnapshot } from "../../lib/order";
|
||||
import { ORDER_KEY, PENDING_ORDER_KEY, type OrderSnapshot } from "../../lib/order";
|
||||
import { PaymentStep } from "./PaymentStep";
|
||||
import { dispatchAuthChanged } from "../../lib/auth";
|
||||
import { readCheckoutDraft, writeCheckoutDraft, clearCheckoutDraft } from "../../lib/checkoutDraft";
|
||||
import { normalizeVatId, isValidVatId } from "../../lib/vatId";
|
||||
@@ -144,6 +145,17 @@ export function CheckoutContent({
|
||||
const [versandOpen, setVersandOpen] = useState(false);
|
||||
const [purchaseError, setPurchaseError] = useState<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 +591,32 @@ export function CheckoutContent({
|
||||
vatExempt: Boolean(data.vatExempt),
|
||||
kleinunternehmer: Boolean(data.kleinunternehmer),
|
||||
};
|
||||
|
||||
if (data.requiresPayment) {
|
||||
// Order exists in Payload now (status 'pending_payment'), but
|
||||
// nothing is confirmed yet — the sessionStorage snapshot, cart
|
||||
// clear, and navigation to /bestellbestaetigung all wait for
|
||||
// /checkout/verarbeitung to see a confirmed payment (see that
|
||||
// page's own comment). A cancelled/failed payment must leave the
|
||||
// cart intact so the customer can just retry.
|
||||
try {
|
||||
window.sessionStorage.setItem(PENDING_ORDER_KEY, JSON.stringify(snapshot));
|
||||
} catch {
|
||||
// Same private-browsing fallback as the confirmed-order path
|
||||
// below — /checkout/verarbeitung falls back to its own empty
|
||||
// state if this didn't persist.
|
||||
}
|
||||
setPaymentStep({
|
||||
clientSecret: data.clientSecret,
|
||||
orderNumber: data.orderNumber,
|
||||
orderId: data.orderId,
|
||||
testMode: Boolean(data.testMode),
|
||||
providerReference: data.providerReference,
|
||||
});
|
||||
setPurchasing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
window.sessionStorage.setItem(ORDER_KEY, JSON.stringify(snapshot));
|
||||
} catch {
|
||||
@@ -620,6 +658,33 @@ export function CheckoutContent({
|
||||
);
|
||||
}
|
||||
|
||||
// Order already exists in Payload (status 'pending_payment') — this
|
||||
// replaces the address/cart form with the actual payment UI rather than
|
||||
// navigating away, since nothing is confirmed yet. See PaymentStep's own
|
||||
// comment and spicy-leaping-pizza.md §3.
|
||||
if (paymentStep) {
|
||||
return (
|
||||
<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 */}
|
||||
|
||||
@@ -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,138 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { ORDER_KEY, PENDING_ORDER_KEY } from "../../lib/order";
|
||||
import { clearCart } from "../../lib/cart";
|
||||
import { clearDiscount } from "../../lib/discount";
|
||||
import { clearCheckoutDraft } from "../../lib/checkoutDraft";
|
||||
import { dispatchAuthChanged } from "../../lib/auth";
|
||||
|
||||
const POLL_INTERVAL_MS = 1500;
|
||||
const POLL_TIMEOUT_MS = 15000;
|
||||
|
||||
// The Payment Element's return_url target (see PaymentStep.tsx) — reached
|
||||
// after a card confirms client-side or a PayPal redirect completes.
|
||||
// Neither of those is trustworthy proof of payment on its own (see
|
||||
// spicy-leaping-pizza.md §3's own reasoning: a closed tab mid-PayPal-
|
||||
// redirect looks identical to success from here) — this page polls the
|
||||
// order's actual `paymentStatus`, which only the webhook-driven
|
||||
// confirm-payment endpoint ever sets, and only promotes the pending
|
||||
// sessionStorage snapshot to the confirmed one once that's true.
|
||||
export function VerarbeitungContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const orderNumber = searchParams.get("orderNumber");
|
||||
const [state, setState] = useState<"polling" | "timeout" | "failed" | "error">(orderNumber ? "polling" : "error");
|
||||
const startedAt = useRef<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) {
|
||||
window.sessionStorage.setItem(ORDER_KEY, pending);
|
||||
window.sessionStorage.removeItem(PENDING_ORDER_KEY);
|
||||
}
|
||||
} catch {
|
||||
// Same private-browsing fallback as everywhere else this
|
||||
// sessionStorage snapshot is written — /bestellbestaetigung
|
||||
// has its own empty state.
|
||||
}
|
||||
clearCart();
|
||||
clearDiscount();
|
||||
clearCheckoutDraft();
|
||||
dispatchAuthChanged();
|
||||
router.push("/bestellbestaetigung");
|
||||
return;
|
||||
}
|
||||
if (data.paymentStatus === "failed" || data.status === "cancelled") {
|
||||
setState("failed");
|
||||
return;
|
||||
}
|
||||
if (startedAt.current != null && Date.now() - startedAt.current > POLL_TIMEOUT_MS) {
|
||||
setState("timeout");
|
||||
return;
|
||||
}
|
||||
setTimeout(poll, POLL_INTERVAL_MS);
|
||||
} catch {
|
||||
if (!cancelled) setState("error");
|
||||
}
|
||||
}
|
||||
|
||||
poll();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [orderNumber]);
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
+8
-1
@@ -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,6 +617,7 @@ 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",
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,62 @@
|
||||
import Stripe from "stripe";
|
||||
import type { PaymentProvider, CreatePaymentIntentInput, CreatePaymentIntentResult } from "./types";
|
||||
|
||||
// Server-only — never imported from a "use client" file. Same
|
||||
// process.env-at-point-of-use convention as vies.ts/brevo.ts (no
|
||||
// throwing on a missing key; an unset STRIPE_SECRET_KEY just makes every
|
||||
// call fail at request time, which is the expected state whenever
|
||||
// PAYMENT_TEST_MODE is on and this module is never actually invoked).
|
||||
const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY || "";
|
||||
|
||||
let client: Stripe | null = null;
|
||||
function getClient(): Stripe {
|
||||
if (!client) client = new Stripe(STRIPE_SECRET_KEY);
|
||||
return client;
|
||||
}
|
||||
|
||||
async function createPaymentIntent(input: CreatePaymentIntentInput): Promise<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 };
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
Generated
+44
-1
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user