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:
@@ -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>;
|
||||
}
|
||||
Reference in New Issue
Block a user