Allow switching an unpaid Überweisung order to Stripe payment

New "Zahlungsart ändern" button on the account order detail page,
shown for a still-'received', still-manual (Überweisung) order when an
active Stripe payment method exists. Reuses PaymentStep (the same Stripe
Payment Element checkout uses) and /checkout/verarbeitung's polling logic
(both now take a returnContext prop/param to land back on the order page
instead of clearing the cart and redirecting to /bestellbestaetigung).

Also:
- Payment status badge (Offen/Bezahlt/...) next to the existing Zahlungsart
  display on the order detail page.
- A one-line mention of the switch option in the Vorkasse unpaid notice
  in the order-confirmation email, shown only when a Stripe option is
  actually active (hasOnlinePaymentOption).
- CustomerOrderDetail gained paymentProvider (was missing from the type
  entirely, even though the field already existed on the order).

Backend counterpart: docker/payload's switchPaymentToStripeEndpoint.
This commit is contained in:
Marco
2026-07-30 09:48:44 +00:00
parent 8c843c0ac1
commit 0e995884a7
9 changed files with 269 additions and 17 deletions
+36 -8
View File
@@ -24,25 +24,43 @@ type Props = {
testMode: boolean;
/** Only present in test mode — see api/checkout/route.ts's own comment. */
providerReference?: string;
/** Where /checkout/verarbeitung sends the customer once payment is
* confirmed — "checkout" (default) clears the cart/draft and lands on
* /bestellbestaetigung, exactly like today. "account" is used by the
* account order detail page's "Zahlungsart ändern" flow (an existing,
* already-confirmed order — nothing to clear, and /bestellbestaetigung
* would be the wrong destination): lands back on that same order's
* page instead. See VerarbeitungContent.tsx's own branching on this. */
returnContext?: "checkout" | "account";
};
// 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) {
// the actual payment, it doesn't create anything. Also reused as-is by
// the account order detail page's payment-method-switch flow (see
// returnContext above) — the Stripe collection UI itself is identical
// either way, only the post-payment destination differs.
export function PaymentStep({ clientSecret, orderNumber, orderId, testMode, providerReference, returnContext = "checkout" }: Props) {
if (testMode) {
return <TestPaymentButtons orderNumber={orderNumber} orderId={orderId} providerReference={providerReference ?? ""} />;
return (
<TestPaymentButtons
orderNumber={orderNumber}
orderId={orderId}
providerReference={providerReference ?? ""}
returnContext={returnContext}
/>
);
}
return (
<Elements stripe={getStripe()} options={{ clientSecret }}>
<StripePaymentForm orderNumber={orderNumber} />
<StripePaymentForm orderNumber={orderNumber} returnContext={returnContext} />
</Elements>
);
}
function StripePaymentForm({ orderNumber }: { orderNumber: string }) {
function StripePaymentForm({ orderNumber, returnContext }: { orderNumber: string; returnContext: "checkout" | "account" }) {
const stripe = useStripe();
const elements = useElements();
const [submitting, setSubmitting] = useState(false);
@@ -62,7 +80,7 @@ function StripePaymentForm({ orderNumber }: { orderNumber: string }) {
const { error: confirmError } = await stripe.confirmPayment({
elements,
confirmParams: {
return_url: `${window.location.origin}/checkout/verarbeitung?orderNumber=${encodeURIComponent(orderNumber)}`,
return_url: `${window.location.origin}/checkout/verarbeitung?orderNumber=${encodeURIComponent(orderNumber)}&context=${returnContext}`,
},
});
// Only reached for immediate client-side failures (e.g. invalid card
@@ -88,7 +106,17 @@ function StripePaymentForm({ orderNumber }: { orderNumber: string }) {
);
}
function TestPaymentButtons({ orderNumber, orderId, providerReference }: { orderNumber: string; orderId: number; providerReference: string }) {
function TestPaymentButtons({
orderNumber,
orderId,
providerReference,
returnContext,
}: {
orderNumber: string;
orderId: number;
providerReference: string;
returnContext: "checkout" | "account";
}) {
const router = useRouter();
const [submitting, setSubmitting] = useState<"paid" | "failed" | null>(null);
const [error, setError] = useState<string | null>(null);
@@ -108,7 +136,7 @@ function TestPaymentButtons({ orderNumber, orderId, providerReference }: { order
setSubmitting(null);
return;
}
router.push(`/checkout/verarbeitung?orderNumber=${encodeURIComponent(orderNumber)}`);
router.push(`/checkout/verarbeitung?orderNumber=${encodeURIComponent(orderNumber)}&context=${returnContext}`);
} catch {
setError("Testzahlung konnte nicht ausgeführt werden.");
setSubmitting(null);
@@ -24,6 +24,11 @@ export function VerarbeitungContent() {
const router = useRouter();
const searchParams = useSearchParams();
const orderNumber = searchParams.get("orderNumber");
// "account" — the payment-method-switch flow on an existing order's
// account page (see PaymentStep.tsx's own returnContext comment).
// Defaults to "checkout" for a bare/missing param, same as before this
// branch existed.
const isAccountContext = searchParams.get("context") === "account";
const [state, setState] = useState<"polling" | "timeout" | "failed" | "error">(orderNumber ? "polling" : "error");
const startedAt = useRef<number | null>(null);
@@ -42,6 +47,15 @@ export function VerarbeitungContent() {
return;
}
if (data.paymentStatus === "paid") {
if (isAccountContext) {
// Nothing to clear — this order was already placed (as
// Überweisung) and confirmed long before this switch, there's
// no cart/discount/draft snapshot involved. Land back on the
// same order instead of /bestellbestaetigung, which would
// read as a brand-new purchase.
router.push(`/konto/bestellungen/${encodeURIComponent(orderNumber!)}`);
return;
}
try {
const pending = window.sessionStorage.getItem(PENDING_ORDER_KEY);
if (pending) {
@@ -114,13 +128,15 @@ export function VerarbeitungContent() {
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.
{isAccountContext
? "Die Zahlung konnte nicht abgeschlossen werden. Deine Bestellung bleibt unverändert — du kannst es jederzeit erneut versuchen."
: "Deine Zahlung konnte nicht abgeschlossen werden. Dein Warenkorb ist noch vorhanden — du kannst es gerne erneut versuchen."}
</p>
<Link
href="/checkout"
href={isAccountContext ? `/konto/bestellungen/${encodeURIComponent(orderNumber ?? "")}` : "/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
{isAccountContext ? "Zurück zur Bestellung" : "Zurück zum Checkout"}
</Link>
</>
)}
@@ -133,10 +149,10 @@ export function VerarbeitungContent() {
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"
href={isAccountContext ? `/konto/bestellungen/${encodeURIComponent(orderNumber ?? "")}` : "/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
{isAccountContext ? "Zurück zur Bestellung" : "Zurück zum Checkout"}
</Link>
</>
)}