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

Zahlungsart

{order.paymentMethodTitle}

+
+

Zahlungsstatus

+ +
+ {canSwitchPayment && } + {order.trackingNumber && (

Sendungsverfolgung{order.carrier ? ` (${CARRIER_LABELS[order.carrier] ?? order.carrier})` : ""}

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));