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:
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { ORDER_KEY, PENDING_ORDER_KEY } from "../../lib/order";
|
||||
import { clearCart } from "../../lib/cart";
|
||||
import { clearDiscount } from "../../lib/discount";
|
||||
import { clearCheckoutDraft } from "../../lib/checkoutDraft";
|
||||
import { dispatchAuthChanged } from "../../lib/auth";
|
||||
|
||||
const POLL_INTERVAL_MS = 1500;
|
||||
const POLL_TIMEOUT_MS = 15000;
|
||||
|
||||
// The Payment Element's return_url target (see PaymentStep.tsx) — reached
|
||||
// after a card confirms client-side or a PayPal redirect completes.
|
||||
// Neither of those is trustworthy proof of payment on its own (see
|
||||
// spicy-leaping-pizza.md §3's own reasoning: a closed tab mid-PayPal-
|
||||
// redirect looks identical to success from here) — this page polls the
|
||||
// order's actual `paymentStatus`, which only the webhook-driven
|
||||
// confirm-payment endpoint ever sets, and only promotes the pending
|
||||
// sessionStorage snapshot to the confirmed one once that's true.
|
||||
export function VerarbeitungContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const orderNumber = searchParams.get("orderNumber");
|
||||
const [state, setState] = useState<"polling" | "timeout" | "failed" | "error">(orderNumber ? "polling" : "error");
|
||||
const startedAt = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!orderNumber) return;
|
||||
startedAt.current = Date.now();
|
||||
let cancelled = false;
|
||||
|
||||
async function poll() {
|
||||
try {
|
||||
const res = await fetch(`/api/checkout/status?orderNumber=${encodeURIComponent(orderNumber!)}`, { cache: "no-store" });
|
||||
const data = await res.json();
|
||||
if (cancelled) return;
|
||||
if (!data.ok) {
|
||||
setState("error");
|
||||
return;
|
||||
}
|
||||
if (data.paymentStatus === "paid") {
|
||||
try {
|
||||
const pending = window.sessionStorage.getItem(PENDING_ORDER_KEY);
|
||||
if (pending) {
|
||||
window.sessionStorage.setItem(ORDER_KEY, pending);
|
||||
window.sessionStorage.removeItem(PENDING_ORDER_KEY);
|
||||
}
|
||||
} catch {
|
||||
// Same private-browsing fallback as everywhere else this
|
||||
// sessionStorage snapshot is written — /bestellbestaetigung
|
||||
// has its own empty state.
|
||||
}
|
||||
clearCart();
|
||||
clearDiscount();
|
||||
clearCheckoutDraft();
|
||||
dispatchAuthChanged();
|
||||
router.push("/bestellbestaetigung");
|
||||
return;
|
||||
}
|
||||
if (data.paymentStatus === "failed" || data.status === "cancelled") {
|
||||
setState("failed");
|
||||
return;
|
||||
}
|
||||
if (startedAt.current != null && Date.now() - startedAt.current > POLL_TIMEOUT_MS) {
|
||||
setState("timeout");
|
||||
return;
|
||||
}
|
||||
setTimeout(poll, POLL_INTERVAL_MS);
|
||||
} catch {
|
||||
if (!cancelled) setState("error");
|
||||
}
|
||||
}
|
||||
|
||||
poll();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [orderNumber]);
|
||||
|
||||
return (
|
||||
<main className="flex flex-col flex-1 items-center justify-center gap-6 py-24 px-[var(--layout-padding-x)] text-center">
|
||||
{state === "polling" && (
|
||||
<>
|
||||
<p className="text-h-feature font-semibold text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>
|
||||
Zahlung wird bestätigt…
|
||||
</p>
|
||||
<p className="text-body text-text-muted">Einen Moment bitte, das dauert normalerweise nur wenige Sekunden.</p>
|
||||
</>
|
||||
)}
|
||||
{state === "timeout" && (
|
||||
<>
|
||||
<p className="text-h-feature font-semibold text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>
|
||||
Das dauert etwas länger
|
||||
</p>
|
||||
<p className="text-body text-text-muted max-w-md">
|
||||
Deine Zahlung wird noch verarbeitet. Sobald sie bestätigt ist, schicken wir dir eine Bestätigungs-E-Mail — du musst hier nicht warten.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{state === "failed" && (
|
||||
<>
|
||||
<p className="text-h-feature font-semibold text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>
|
||||
Zahlung fehlgeschlagen
|
||||
</p>
|
||||
<p className="text-body text-text-muted max-w-md">
|
||||
Deine Zahlung konnte nicht abgeschlossen werden. Dein Warenkorb ist noch vorhanden — du kannst es gerne erneut versuchen.
|
||||
</p>
|
||||
<Link
|
||||
href="/checkout"
|
||||
className="flex items-center gap-2 px-7 py-4 rounded-sm bg-brand text-h4 font-bold text-text-primary hover:bg-brand-hover active:scale-[0.97] transition-all"
|
||||
>
|
||||
Zurück zum Checkout
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
{state === "error" && (
|
||||
<>
|
||||
<p className="text-h-feature font-semibold text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>
|
||||
Status konnte nicht geladen werden
|
||||
</p>
|
||||
<p className="text-body text-text-muted max-w-md">
|
||||
Falls die Zahlung erfolgreich war, erhältst du in Kürze eine Bestätigungs-E-Mail. Andernfalls kannst du es erneut versuchen.
|
||||
</p>
|
||||
<Link
|
||||
href="/checkout"
|
||||
className="flex items-center gap-2 px-7 py-4 rounded-sm bg-brand text-h4 font-bold text-text-primary hover:bg-brand-hover active:scale-[0.97] transition-all"
|
||||
>
|
||||
Zurück zum Checkout
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Suspense } from "react";
|
||||
import { VerarbeitungContent } from "./VerarbeitungContent";
|
||||
|
||||
// robots: noindex — transactional page, same reasoning as /checkout itself.
|
||||
export const metadata: Metadata = {
|
||||
title: "Zahlung wird bestätigt",
|
||||
robots: { index: false, follow: true },
|
||||
};
|
||||
|
||||
export default function VerarbeitungPage() {
|
||||
// useSearchParams (reading ?orderNumber=) requires a Suspense boundary
|
||||
// in the App Router — this page has no meaningful loading state of its
|
||||
// own beyond what VerarbeitungContent already renders.
|
||||
return (
|
||||
<Suspense>
|
||||
<VerarbeitungContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user