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
@@ -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>
);
}
+20
View File
@@ -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>
);
}