From 0e995884a73f079d32e384a59f87a4a3d814b612 Mon Sep 17 00:00:00 2001
From: Marco
Date: Thu, 30 Jul 2026 09:48:44 +0000
Subject: [PATCH] =?UTF-8?q?Allow=20switching=20an=20unpaid=20=C3=9Cberweis?=
=?UTF-8?q?ung=20order=20to=20Stripe=20payment?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
.../[orderNumber]/switch-to-stripe/route.ts | 73 +++++++++++++++++++
app/api/checkout/route.ts | 5 +-
app/checkout/components/PaymentStep.tsx | 44 +++++++++--
.../verarbeitung/VerarbeitungContent.tsx | 26 +++++--
.../components/SwitchPaymentButton.tsx | 73 +++++++++++++++++++
app/konto/bestellungen/[orderNumber]/page.tsx | 17 ++++-
app/konto/components/PaymentStatusBadge.tsx | 33 +++++++++
app/lib/customerAuth.ts | 4 +
app/lib/emailTemplates.ts | 11 ++-
9 files changed, 269 insertions(+), 17 deletions(-)
create mode 100644 app/api/account/orders/[orderNumber]/switch-to-stripe/route.ts
create mode 100644 app/konto/bestellungen/[orderNumber]/components/SwitchPaymentButton.tsx
create mode 100644 app/konto/components/PaymentStatusBadge.tsx
diff --git a/app/api/account/orders/[orderNumber]/switch-to-stripe/route.ts b/app/api/account/orders/[orderNumber]/switch-to-stripe/route.ts
new file mode 100644
index 0000000..9360cd2
--- /dev/null
+++ b/app/api/account/orders/[orderNumber]/switch-to-stripe/route.ts
@@ -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 } : {}),
+ });
+}
diff --git a/app/api/checkout/route.ts b/app/api/checkout/route.ts
index 088c5e4..2ce1939 100644
--- a/app/api/checkout/route.ts
+++ b/app/api/checkout/route.ts
@@ -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) => {
diff --git a/app/checkout/components/PaymentStep.tsx b/app/checkout/components/PaymentStep.tsx
index 2db9260..c5996e2 100644
--- a/app/checkout/components/PaymentStep.tsx
+++ b/app/checkout/components/PaymentStep.tsx
@@ -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 ;
+ return (
+
+ );
}
return (
-
+
);
}
-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(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);
diff --git a/app/checkout/verarbeitung/VerarbeitungContent.tsx b/app/checkout/verarbeitung/VerarbeitungContent.tsx
index feecefb..a6c996b 100644
--- a/app/checkout/verarbeitung/VerarbeitungContent.tsx
+++ b/app/checkout/verarbeitung/VerarbeitungContent.tsx
@@ -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(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
- 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."}
- Zurück zum Checkout
+ {isAccountContext ? "Zurück zur Bestellung" : "Zurück zum Checkout"}
>
)}
@@ -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.
- Zurück zum Checkout
+ {isAccountContext ? "Zurück zur Bestellung" : "Zurück zum Checkout"}
>
)}
diff --git a/app/konto/bestellungen/[orderNumber]/components/SwitchPaymentButton.tsx b/app/konto/bestellungen/[orderNumber]/components/SwitchPaymentButton.tsx
new file mode 100644
index 0000000..8ad6a8e
--- /dev/null
+++ b/app/konto/bestellungen/[orderNumber]/components/SwitchPaymentButton.tsx
@@ -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 (
+
+
Mit Kreditkarte/PayPal bezahlen
+
+
+ );
+ }
+
+ return (
+
+
+ {state.step === "error" &&
{state.reason}
}
+
+ );
+}
diff --git a/app/konto/bestellungen/[orderNumber]/page.tsx b/app/konto/bestellungen/[orderNumber]/page.tsx
index 67238b3..3927195 100644
--- a/app/konto/bestellungen/[orderNumber]/page.tsx
+++ b/app/konto/bestellungen/[orderNumber]/page.tsx
@@ -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
diff --git a/app/konto/components/PaymentStatusBadge.tsx b/app/konto/components/PaymentStatusBadge.tsx
new file mode 100644
index 0000000..570a203
--- /dev/null
+++ b/app/konto/components/PaymentStatusBadge.tsx
@@ -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 = {
+ not_applicable: "Offen",
+ pending: "Offen",
+ paid: "Bezahlt",
+ failed: "Fehlgeschlagen",
+ refunded: "Erstattet",
+ partially_refunded: "Teilweise erstattet",
+};
+
+const STYLES: Record = {
+ 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 (
+
+ {LABEL[paymentStatus] ?? paymentStatus}
+
+ );
+}
diff --git a/app/lib/customerAuth.ts b/app/lib/customerAuth.ts
index 8c5be61..4b8821c 100644
--- a/app/lib/customerAuth.ts
+++ b/app/lib/customerAuth.ts
@@ -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.
diff --git a/app/lib/emailTemplates.ts b/app/lib/emailTemplates.ts
index 4282469..bdcd6d7 100644
--- a/app/lib/emailTemplates.ts
+++ b/app/lib/emailTemplates.ts
@@ -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
Bitte überweise den Rechnungsbetrag unter Angabe der Bestellnummer ${escapeHtml(orderNumber)} auf ${bankLine ? "folgende Bankverbindung:" : "die dir genannte Bankverbindung."}
${bankLine ? `
${escapeHtml(bankLine)}
` : ""}
Deine Bestellung wird nach Zahlungseingang bearbeitet (in der Regel innerhalb von 1–2 Werktagen).
+ ${hasOnlinePaymentOption ? `
Zahlungsart geändert? Solange die Überweisung noch nicht bei uns eingegangen ist, kannst du in deinem Konto jederzeit auf Kreditkarte/PayPal umsteigen.
` : ""}
@@ -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
${taxRows}
- ${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));