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 @@
"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>
);
}