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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user