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
@@ -0,0 +1,73 @@
import { NextResponse } from "next/server";
import { getSessionCustomer, getCustomerOrderDetail } from "../../../../../lib/customerAuth";
import { getPaymentMethods, groupPaymentMethodsForCheckout } from "../../../../../lib/payload";
import { paymentProvider, isPaymentTestMode } from "../../../../../lib/payments";
const PAYLOAD_URL = process.env.PAYLOAD_URL || "https://payload.mk360.de";
const PAYMENT_WEBHOOK_SECRET = process.env.PAYMENT_WEBHOOK_SECRET || "";
// Lets a logged-in customer move an existing, still-unpaid Überweisung
// order onto a Stripe PaymentIntent instead of waiting on their own bank
// transfer — see the backend's switchPaymentToStripe.ts for the matching
// endpoint and why this needs a dedicated backend route rather than the
// generic customer-JWT order-PATCH path (paymentProvider/paymentStatus
// are system fields a customer JWT can never touch).
export async function POST(request: Request, { params }: { params: Promise<{ orderNumber: string }> }) {
const session = await getSessionCustomer();
if (!session) return NextResponse.json({ ok: false, reason: "Bitte zuerst einloggen." }, { status: 401 });
const { orderNumber } = await params;
const order = await getCustomerOrderDetail(session.token, session.customer.id, decodeURIComponent(orderNumber));
if (!order) return NextResponse.json({ ok: false, reason: "Bestellung nicht gefunden." }, { status: 404 });
// Same eligibility the backend endpoint re-checks authoritatively —
// checked here too for a friendly error instead of a bare 409.
if (order.paymentProvider !== "manual" || order.status !== "received") {
return NextResponse.json({ ok: false, reason: "Die Zahlungsart kann für diese Bestellung gerade nicht geändert werden." }, { status: 400 });
}
// Only offer this when a real Stripe payment method is actually active
// — same "Online-Zahlung" grouping the checkout itself uses, so this
// never presents an option that checkout wouldn't currently accept either.
const methods = groupPaymentMethodsForCheckout(await getPaymentMethods());
const hasStripeOption = methods.some((m) => m.provider === "stripe");
if (!hasStripeOption) {
return NextResponse.json({ ok: false, reason: "Aktuell steht keine Online-Zahlung zur Verfügung." }, { status: 400 });
}
let intent: { clientSecret: string; providerReference: string };
try {
intent = await paymentProvider.createPaymentIntent({
amountCents: Math.round(order.total * 100),
currency: "eur",
customerEmail: order.customerEmail,
description: `Bestellung ${order.orderNumber}`,
});
} catch (err) {
return NextResponse.json({ ok: false, reason: "Zahlung konnte nicht vorbereitet werden." }, { status: 500 });
}
const res = await fetch(`${PAYLOAD_URL}/api/orders/${order.id}/switch-payment-to-stripe`, {
method: "POST",
headers: { "Content-Type": "application/json", "x-payment-webhook-secret": PAYMENT_WEBHOOK_SECRET },
body: JSON.stringify({ providerReference: intent.providerReference }),
});
if (!res.ok) {
const data = await res.json().catch(() => null);
return NextResponse.json({ ok: false, reason: data?.reason ?? "Umstellung fehlgeschlagen." }, { status: 400 });
}
// Best-effort, same as checkout's own call — a failure here doesn't
// block the payment itself, only the confirm-payment webhook's metadata
// lookup, which the frontend's own webhook route already alerts on.
await paymentProvider.attachOrderMetadata(intent.providerReference, { orderId: String(order.id), orderNumber: order.orderNumber }).catch(() => {});
return NextResponse.json({
ok: true,
clientSecret: intent.clientSecret,
orderId: order.id,
orderNumber: order.orderNumber,
testMode: isPaymentTestMode,
...(isPaymentTestMode ? { providerReference: intent.providerReference } : {}),
});
}
+4 -1
View File
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server";
import type { CartItem } from "../../lib/cart";
import { getShippingMethods, getPaymentMethods, getCompanySettings } from "../../lib/payload";
import { getShippingMethods, getPaymentMethods, getCompanySettings, groupPaymentMethodsForCheckout } from "../../lib/payload";
import { validateDiscountCode, redeemDiscountCode } from "../../lib/discountServer";
import { createOrder } from "../../lib/orderServer";
import { getSessionCustomer, registerCustomer, setSessionCookie, type CustomerSummary } from "../../lib/customerAuth";
@@ -467,6 +467,9 @@ export async function POST(request: Request) {
discountCode: body.discountCode || null,
total,
isManualPayment: true,
// Only meaningful for the Vorkasse notice above — whether a
// switch to Kreditkarte/PayPal is even worth mentioning right now.
hasOnlinePaymentOption: groupPaymentMethodsForCheckout(paymentMethods).some((m) => m.provider === "stripe"),
},
body.email,
).catch((err) => {
+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>
</>
)}
@@ -0,0 +1,73 @@
"use client";
import { useState } from "react";
import { PaymentStep } from "../../../../checkout/components/PaymentStep";
// Shown only for a still-unpaid Überweisung order (see page.tsx's own
// eligibility check, mirroring api/account/orders/[orderNumber]/
// switch-to-stripe/route.ts's authoritative one) — lets a customer switch
// to Kreditkarte/PayPal instead of waiting on their own bank transfer.
// Reuses PaymentStep (the exact same Stripe collection UI checkout uses)
// once this endpoint hands back a clientSecret — the order already
// exists, this only changes how it gets paid.
export function SwitchPaymentButton({ orderNumber }: { orderNumber: string }) {
const [state, setState] = useState<
| { step: "idle" }
| { step: "loading" }
| { step: "error"; reason: string }
| { step: "paying"; clientSecret: string; orderId: number; testMode: boolean; providerReference?: string }
>({ step: "idle" });
async function start() {
setState({ step: "loading" });
try {
const res = await fetch(`/api/account/orders/${encodeURIComponent(orderNumber)}/switch-to-stripe`, {
method: "POST",
});
const data = await res.json();
if (!data.ok) {
setState({ step: "error", reason: data.reason || "Umstellung fehlgeschlagen." });
return;
}
setState({
step: "paying",
clientSecret: data.clientSecret,
orderId: data.orderId,
testMode: Boolean(data.testMode),
providerReference: data.providerReference,
});
} catch {
setState({ step: "error", reason: "Umstellung gerade nicht möglich." });
}
}
if (state.step === "paying") {
return (
<div className="flex flex-col gap-4 w-full border border-border rounded-md p-5">
<p className="font-semibold text-body-sm text-text-primary">Mit Kreditkarte/PayPal bezahlen</p>
<PaymentStep
clientSecret={state.clientSecret}
orderNumber={orderNumber}
orderId={state.orderId}
testMode={state.testMode}
providerReference={state.providerReference}
returnContext="account"
/>
</div>
);
}
return (
<div className="flex flex-col gap-2 items-start">
<button
type="button"
onClick={start}
disabled={state.step === "loading"}
className={`px-5 py-3 rounded-sm border border-border hover:border-brand font-bold text-body-sm text-text-primary transition-colors ${state.step === "loading" ? "opacity-70 pointer-events-none" : ""}`}
>
{state.step === "loading" ? "…" : "Zahlungsart ändern"}
</button>
{state.step === "error" && <p className="text-label text-red-600">{state.reason}</p>}
</div>
);
}
+16 -1
View File
@@ -7,11 +7,13 @@ import { Footer } from "../../../components/Footer";
import { VatBreakdown } from "../../../components/VatBreakdown";
import { formatPrice, formatDate } from "../../../lib/format";
import { getSessionCustomer, getCustomerOrderDetail, customerOrderAction } from "../../../lib/customerAuth";
import { getProductImagesByIds } from "../../../lib/payload";
import { getProductImagesByIds, getPaymentMethods, groupPaymentMethodsForCheckout } from "../../../lib/payload";
import { computeTaxBreakdown } from "@einfach-produktiv/invoicing";
import { buildTrackingUrl, CARRIER_LABELS } from "../../../lib/tracking";
import { OrderActionButton } from "./components/OrderActionButton";
import { SwitchPaymentButton } from "./components/SwitchPaymentButton";
import { OrderStatusBadge } from "../../components/OrderStatusBadge";
import { PaymentStatusBadge } from "../../components/PaymentStatusBadge";
// Dynamic (was a static "Bestelldetails" title despite this being a
// per-order route) — just formats the already-known order number into
@@ -47,6 +49,13 @@ export default async function KontoBestellungDetailPage({ params }: { params: Pr
const action = customerOrderAction(order.status);
const imagesByProductId = await getProductImagesByIds(order.items.map((item) => item.product));
const taxBreakdown = computeTaxBreakdown(order.items, order.subtotal, order.discountAmount, order.shippingCost);
// Same "Online-Zahlung" grouping/eligibility the switch-to-stripe route
// itself re-checks authoritatively — only offer the button when it
// would actually succeed.
const canSwitchPayment =
order.paymentProvider === "manual" &&
order.status === "received" &&
groupPaymentMethodsForCheckout(await getPaymentMethods()).some((m) => m.provider === "stripe");
return (
<>
@@ -73,8 +82,14 @@ export default async function KontoBestellungDetailPage({ params }: { params: Pr
<p className="text-label text-text-muted">Zahlungsart</p>
<p className="text-body-sm text-text-primary">{order.paymentMethodTitle}</p>
</div>
<div className="flex flex-col gap-1">
<p className="text-label text-text-muted">Zahlungsstatus</p>
<PaymentStatusBadge paymentStatus={order.paymentStatus} />
</div>
</div>
{canSwitchPayment && <SwitchPaymentButton orderNumber={order.orderNumber} />}
{order.trackingNumber && (
<div className="flex flex-col gap-1 w-full">
<p className="text-label text-text-muted">Sendungsverfolgung{order.carrier ? ` (${CARRIER_LABELS[order.carrier] ?? order.carrier})` : ""}</p>
@@ -0,0 +1,33 @@
// Same visual pattern as OrderStatusBadge — but answers a different
// question ("hat Stripe/die Buchhaltung eine Zahlung bestätigt?", not
// "wo im Fulfillment steht die Bestellung"). 'not_applicable' (an
// Überweisung order before payment is manually reconciled) reads as
// "offen", same as a still-'pending' Stripe order — the customer doesn't
// need to know the internal distinction between the two.
const LABEL: Record<string, string> = {
not_applicable: "Offen",
pending: "Offen",
paid: "Bezahlt",
failed: "Fehlgeschlagen",
refunded: "Erstattet",
partially_refunded: "Teilweise erstattet",
};
const STYLES: Record<string, string> = {
not_applicable: "bg-bg-muted text-text-muted",
pending: "bg-bg-muted text-text-muted",
paid: "bg-success-subtle text-success",
failed: "bg-red-50 text-red-600",
refunded: "bg-bg-muted text-text-light",
partially_refunded: "bg-orange-50 text-orange-600",
};
export function PaymentStatusBadge({ paymentStatus }: { paymentStatus: string }) {
return (
<span
className={`inline-flex items-center px-2.5 py-1 rounded-full text-label font-bold whitespace-nowrap ${STYLES[paymentStatus] ?? "bg-bg-muted text-text-muted"}`}
>
{LABEL[paymentStatus] ?? paymentStatus}
</span>
);
}
+4
View File
@@ -523,6 +523,10 @@ export async function getCustomerOrders(token: string, customerId: number, exclu
export type CustomerOrderDetail = CustomerOrder & {
id: number;
// 'manual' (Überweisung) vs 'stripe' (Kreditkarte/PayPal) — see
// api/account/orders/[orderNumber]/switch-to-stripe/route.ts, which only
// offers a payment-method switch for a still-'manual' order.
paymentProvider: "manual" | "stripe";
// '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.
+9 -2
View File
@@ -74,7 +74,7 @@ function escapeHtml(s: string): string {
// this template (16px) — not squeezed into the narrow Gesamtsumme table
// like an initial draft of the invoice version was before that got
// widened per feedback.
function vorkasseNotice(orderNumber: string, seller: CompanySettings | null): string {
function vorkasseNotice(orderNumber: string, seller: CompanySettings | null, hasOnlinePaymentOption: boolean): string {
const bankLine = seller && (seller.iban || seller.bic)
? [seller.bankName, seller.iban && `IBAN ${seller.iban}`, seller.bic && `BIC ${seller.bic}`].filter(Boolean).join(" · ")
: null;
@@ -85,6 +85,7 @@ function vorkasseNotice(orderNumber: string, seller: CompanySettings | null): st
<p style="margin:0 0 8px;">Bitte überweise den Rechnungsbetrag unter Angabe der Bestellnummer ${escapeHtml(orderNumber)} auf ${bankLine ? "folgende Bankverbindung:" : "die dir genannte Bankverbindung."}</p>
${bankLine ? `<p style="margin:0 0 12px;font-weight:700;color:${TEXT_PRIMARY};">${escapeHtml(bankLine)}</p>` : ""}
<p style="margin:0;">Deine Bestellung wird nach Zahlungseingang bearbeitet (in der Regel innerhalb von 12 Werktagen).</p>
${hasOnlinePaymentOption ? `<p style="margin:8px 0 0;">Zahlungsart geändert? Solange die Überweisung noch nicht bei uns eingegangen ist, kannst du in deinem Konto jederzeit auf Kreditkarte/PayPal umsteigen.</p>` : ""}
</td>
</tr>
</table>
@@ -215,6 +216,12 @@ export type OrderConfirmationData = {
// fragile thing a payment-methods rename already broke once this
// session (see @einfach-produktiv/invoicing's isPaidImmediately()).
isManualPayment: boolean;
// Only meaningful when isManualPayment is true — whether at least one
// Stripe-backed payment method is currently active, so the Vorkasse
// notice can mention the option to switch instead of promising it
// unconditionally. Set by the checkout route from the same
// getPaymentMethods() call it already makes.
hasOnlinePaymentOption?: boolean;
};
export const SAMPLE_ORDER: OrderConfirmationData = {
@@ -298,7 +305,7 @@ export function renderOrderConfirmationHtml(template: EmailTemplateContent, orde
</tr>
${taxRows}
</table>
${order.isManualPayment ? vorkasseNotice(order.orderNumber, seller) : ""}
${order.isManualPayment ? vorkasseNotice(order.orderNumber, seller, Boolean(order.hasOnlinePaymentOption)) : ""}
`;
return emailShell("✓", escapeHtml(template.heading), body, template.footerText, buildLegalFooterLines(seller));