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 } : {}),
});
}