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:
Marco
2026-07-25 12:03:04 +00:00
parent bb3f94d39e
commit 740b791e5e
19 changed files with 941 additions and 70 deletions
+4
View File
@@ -443,6 +443,10 @@ export async function getCustomerOrders(token: string, customerId: number): Prom
export type CustomerOrderDetail = CustomerOrder & {
id: number;
// 'not_applicable' for Überweisung orders (never gated); see
// spicy-leaping-pizza.md §1 — read by /api/checkout/status for the
// post-Stripe-redirect polling page.
paymentStatus: "not_applicable" | "pending" | "paid" | "failed" | "refunded" | "partially_refunded";
invoiceNumber: string | null;
invoiceIssuedAt: string | null;
correctionInvoiceNumber: string | null;
+9
View File
@@ -8,6 +8,15 @@ import type { CartItem } from "./cart";
// generated locally), read once by /bestellbestaetigung.
export const ORDER_KEY = "ep_last_order";
// Written for a gated payment method (Kreditkarte/PayPal) right before
// PaymentStep hands off to Stripe/the test-confirm flow — see
// spicy-leaping-pizza.md §3/§7. Same OrderSnapshot shape as ORDER_KEY,
// but this one is provisional: /checkout/verarbeitung only promotes it
// to ORDER_KEY once polling confirms the payment actually succeeded, so
// an abandoned/failed payment never leaves a confirmation-page-ready
// snapshot behind.
export const PENDING_ORDER_KEY = "ep_pending_order";
export type OrderSnapshot = {
items: CartItem[];
orderNumber: string;
+28 -2
View File
@@ -76,9 +76,28 @@ export type CreateOrderInput = {
discountCode: string | null;
discountAmount: number;
total: number;
// Gated-payment fields (see spicy-leaping-pizza.md §1/§3) — all three
// omitted for a manual/Überweisung order, which is exactly today's
// behavior (Orders.ts's own field defaults apply: status 'received',
// paymentProvider 'manual', paymentStatus 'not_applicable').
status?: "pending_payment";
paymentProvider?: "stripe";
paymentStatus?: "pending";
// Known before the order is created (Stripe generates a PaymentIntent id
// immediately, independent of any order existing yet) — persisted at
// creation time specifically so the expirePendingPayments cleanup job
// has something to reconcile against even if the webhook metadata
// round-trip (stripeProvider.attachOrderMetadata) never completes.
providerReference?: string;
};
export type CreatedOrder = { orderNumber: string; createdAt: string; invoiceNumber: string; invoiceIssuedAt: string };
export type CreatedOrder = {
id: number;
orderNumber: string;
createdAt: string;
invoiceNumber: string | null;
invoiceIssuedAt: string | null;
};
export async function createOrder(input: CreateOrderInput): Promise<CreatedOrder | null> {
const tenantId = await resolveTenantId();
@@ -138,6 +157,10 @@ export async function createOrder(input: CreateOrderInput): Promise<CreatedOrder
discountCode: input.discountCode,
discountAmount: input.discountAmount,
total: input.total,
...(input.status ? { status: input.status } : {}),
...(input.paymentProvider ? { paymentProvider: input.paymentProvider } : {}),
...(input.paymentStatus ? { paymentStatus: input.paymentStatus } : {}),
...(input.providerReference ? { providerReference: input.providerReference } : {}),
}),
});
@@ -146,8 +169,11 @@ export async function createOrder(input: CreateOrderInput): Promise<CreatedOrder
return null;
}
const data: { doc: { orderNumber: string; createdAt: string; invoiceNumber: string; invoiceIssuedAt: string } } = await res.json();
const data: {
doc: { id: number; orderNumber: string; createdAt: string; invoiceNumber: string | null; invoiceIssuedAt: string | null };
} = await res.json();
return {
id: data.doc.id,
orderNumber: data.doc.orderNumber,
createdAt: data.doc.createdAt,
invoiceNumber: data.doc.invoiceNumber,
+8 -1
View File
@@ -577,13 +577,19 @@ export async function getShippingSettings(): Promise<ShippingSettings> {
};
}
export type PaymentMethod = { id: number; title: string; icons: string[] };
// `provider` drives the checkout branch in app/api/checkout/route.ts —
// 'manual' (Überweisung) keeps today's immediate-order behavior, 'stripe'
// (Kreditkarte/PayPal) routes through the payment-intent/webhook-gated
// flow. Defaults to 'manual' below for any row created before this field
// existed, matching the Payload field's own default.
export type PaymentMethod = { id: number; title: string; icons: string[]; provider: "manual" | "stripe" };
type PayloadPaymentMethod = {
id: number;
title: string;
active: boolean;
icons: { icon: { url: string } | number | null }[];
provider?: "manual" | "stripe";
};
export async function getPaymentMethods(): Promise<PaymentMethod[]> {
@@ -611,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",
}));
}
+15
View File
@@ -0,0 +1,15 @@
import { stripeProvider } from "./stripeProvider";
import { mockProvider } from "./mockProvider";
import type { PaymentProvider } from "./types";
export * from "./types";
// Defaults to test mode whenever no real Stripe key is configured, so a
// fresh local checkout (or CI) never accidentally tries to call the real
// Stripe API — matches PAYMENT_TEST_MODE's documented default in the plan.
const TEST_MODE = process.env.PAYMENT_TEST_MODE
? process.env.PAYMENT_TEST_MODE === "true"
: !process.env.STRIPE_SECRET_KEY;
export const paymentProvider: PaymentProvider = TEST_MODE ? mockProvider : stripeProvider;
export const isPaymentTestMode = TEST_MODE;
+20
View File
@@ -0,0 +1,20 @@
import type { PaymentProvider, CreatePaymentIntentResult } from "./types";
// PAYMENT_TEST_MODE stand-in (plan §7) — no network call, no real Stripe
// account needed. The synthetic providerReference is still persisted on
// the order exactly like a real one, so the whole downstream pipeline
// (webhooks/stripe/test-confirm, confirm-payment, expirePendingPayments)
// runs unmodified against it.
async function createPaymentIntent(): Promise<CreatePaymentIntentResult> {
const fakeId = `pi_test_${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`;
return { clientSecret: `${fakeId}_secret_mock`, providerReference: fakeId };
}
async function attachOrderMetadata(): Promise<void> {
// No real PaymentIntent to attach metadata to — nothing to do. The
// test-confirm route (used instead of a real webhook in test mode)
// already receives the order's id directly from the client, so it
// never needs to resolve it via metadata the way the real webhook does.
}
export const mockProvider: PaymentProvider = { createPaymentIntent, attachOrderMetadata };
+62
View File
@@ -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;
}
}
+32
View File
@@ -0,0 +1,32 @@
// Provider-agnostic contract — see the approved payment plan
// (spicy-leaping-pizza.md §0/§7). Stripe is the only real implementation
// today (stripeProvider.ts); mockProvider.ts implements the same shape
// for PAYMENT_TEST_MODE so the checkout route never branches on which
// provider is active, only on whether one is configured at all.
export type CreatePaymentIntentInput = {
amountCents: number;
currency: string;
customerEmail: string;
description: string;
};
export type CreatePaymentIntentResult = {
clientSecret: string;
providerReference: string;
};
export type ProviderPaymentUpdate = {
providerReference: string;
paymentStatus: "paid" | "failed";
paidAt: string;
};
export interface PaymentProvider {
createPaymentIntent(input: CreatePaymentIntentInput): Promise<CreatePaymentIntentResult>;
// Best-effort, awaited but never fatal to checkout — lets the webhook
// handler resolve providerReference -> order without the frontend
// having to persist a second field via an update path that doesn't
// otherwise exist (see stripeProvider.ts's own comment).
attachOrderMetadata(providerReference: string, metadata: { orderId: string; orderNumber: string }): Promise<void>;
}