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