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
+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>;
}