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
- 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
+{state.reason}
} +Zahlungsart
{order.paymentMethodTitle}
+Zahlungsstatus
+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: RecordBitte ü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));