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
+66 -1
View File
@@ -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 */}
+142
View File
@@ -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>
);
}