From bab2c916beeab48fb844b9f47211916a294f922c Mon Sep 17 00:00:00 2001 From: Marco Date: Sat, 25 Jul 2026 12:17:38 +0000 Subject: [PATCH] Consolidate Kreditkarte/PayPal into one "Online-Zahlung" checkout option MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both already route through the same Stripe PaymentIntent (automatic_payment_methods: enabled — Stripe's own recommended Payment Element pattern, letting Stripe itself decide which eligible method to show). Pre-selecting one of two identical-behind-the-scenes rows before the payment step was redundant friction, not a real choice. Collapses them into one option with a hint text explaining the actual instrument is picked on the next screen; Überweisung is unaffected. Also refines paymentMethodTitle from a neutral "Online-Zahlung" placeholder (snapshotted at order-creation time, before the customer has picked an instrument) to the real one Stripe reports, once payment confirms — carried through to both the stored order and the sessionStorage snapshot shown on /bestellbestaetigung. Co-Authored-By: Claude Sonnet 5 --- README.md | 38 ++++++++++++++-- app/api/checkout/route.ts | 12 ++++- app/api/checkout/status/route.ts | 12 ++++- app/api/webhooks/stripe/route.ts | 8 +++- app/checkout/components/CheckoutContent.tsx | 45 +++++++++++-------- .../verarbeitung/VerarbeitungContent.tsx | 9 +++- app/lib/payload.ts | 32 +++++++++++++ app/lib/payments/stripeProvider.ts | 23 ++++++++++ 8 files changed, 152 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 88bec46..835838d 100644 --- a/README.md +++ b/README.md @@ -308,6 +308,30 @@ exactly as before: no gateway involved, order goes straight to `received`. branch — `'manual'` (Überweisung) or `'stripe'` (Kreditkarte/PayPal). `app/lib/payload.ts`'s `getPaymentMethods()` exposes it; the checkout route re-resolves it server-side, never trusts a client-submitted value. +- **Checkout UI collapses Kreditkarte + PayPal into one "Online-Zahlung" + option** (`groupPaymentMethodsForCheckout()` in `app/lib/payload.ts`, + used by `CheckoutContent.tsx`). Both admin rows still exist and both + still need `provider: 'stripe'` — this is a display-layer grouping, not + a data change. Reasoning: the PaymentIntent is created with + `automatic_payment_methods: { enabled: true }` (Stripe's own recommended + Payment Element pattern — Stripe itself decides which eligible method to + show), so pre-selecting "Kreditkarte" vs. "PayPal" before that never + actually restricted anything; it was redundant friction, not a real + choice. The combined option shows a hint text ("die genaue Zahlungsart + wählst du im nächsten Schritt") so the consolidation reads as intentional, + not a missing option. Überweisung stays a separate, real option. +- **`paymentMethodTitle` is snapshotted as a neutral `"Online-Zahlung"`** + at order-creation time for the `stripe` branch (the customer hasn't + picked an instrument yet at that point) and **refined to the real one** + (`"Kreditkarte"`/`"PayPal"`) once Stripe reports it — + `resolveStripePaymentMethodLabel()` in `stripeProvider.ts` reads the + confirmed PaymentIntent's `payment_method.type` in the webhook route and + passes it to `confirm-payment` as an optional field. Best-effort: an + unresolved label just leaves the neutral title in place. The + `/checkout/verarbeitung` polling page also patches this into the + provisional `sessionStorage` snapshot before promoting it, so + `/bestellbestaetigung` shows the real instrument too, not the neutral + placeholder. - **`app/api/checkout/route.ts`, `provider === 'stripe'` branch**: creates a Stripe PaymentIntent *before* the order (`app/lib/payments/ stripeProvider.ts`) — its id is known immediately and gets persisted as @@ -337,10 +361,16 @@ exactly as before: no gateway involved, order goes straight to `received`. `{paymentStatus, providerReference, paidAt}`. Returns a non-2xx status on any internal failure so Stripe's own retry schedule (~3 days) provides resilience for free, rather than this app building its own retry queue. - That backend endpoint is what actually flips the order to `received`, - assigns the (until-then-deferred) invoice number, and sends the - confirmation email/invoice + the internal admin new-order notification — - see the backend repo's own README for that half. + That backend endpoint flips the order to `received`, assigns the + (until-then-deferred) invoice number, and queues the internal admin + new-order notification — see the backend repo's own README for that + half. It has no SMTP sender of its own, though: it returns a full order + snapshot in its response instead, and **this webhook route is what + actually sends the confirmation email + invoice PDF** + (`app/lib/payments/confirmPaymentEmail.ts`, only when the response isn't + `alreadyProcessed: true` — a repeat webhook delivery must never resend + it), mirroring exactly what the checkout route already does inline for + a manual/Überweisung order. - **`/checkout/verarbeitung`** (`VerarbeitungContent.tsx`) is the `return_url` target. Neither a client-side `confirmPayment()` success nor landing back from a PayPal redirect is trusted as proof of payment on its diff --git a/app/api/checkout/route.ts b/app/api/checkout/route.ts index 8ef4f93..86141ed 100644 --- a/app/api/checkout/route.ts +++ b/app/api/checkout/route.ts @@ -343,7 +343,15 @@ export async function POST(request: Request) { subtotal: finalSubtotal, shippingCost: finalShippingCost, shippingMethodTitle: shippingMethod.title, - paymentMethodTitle: paymentMethod.title, + // The checkout UI collapses Kreditkarte/PayPal into one "Online- + // Zahlung" pre-selection (see groupPaymentMethodsForCheckout) — the + // customer hasn't actually chosen an instrument yet at this point, + // Stripe's Payment Element does that next. Snapshotting the specific + // resolved row's title here would just record whichever row happened + // to be the group's representative id, not what was really picked. + // The webhook route refines this to the real instrument + // ("Kreditkarte"/"PayPal") once Stripe reports it, via confirm-payment. + paymentMethodTitle: requiresPayment ? "Online-Zahlung" : paymentMethod.title, discountCode: body.discountCode || null, discountAmount, total, @@ -476,7 +484,7 @@ export async function POST(request: Request) { } : {}), shippingCost: finalShippingCost, - paymentMethodTitle: paymentMethod.title, + paymentMethodTitle: requiresPayment ? "Online-Zahlung" : paymentMethod.title, discountCode: body.discountCode || null, discountAmount, vatExempt, diff --git a/app/api/checkout/status/route.ts b/app/api/checkout/status/route.ts index fc9033a..ab74e66 100644 --- a/app/api/checkout/status/route.ts +++ b/app/api/checkout/status/route.ts @@ -17,5 +17,15 @@ export async function GET(request: Request) { const order = await getCustomerOrderDetail(session.token, session.customer.id, orderNumber); if (!order) return NextResponse.json({ ok: false, reason: "Bestellung nicht gefunden." }, { status: 404 }); - return NextResponse.json({ ok: true, status: order.status, paymentStatus: order.paymentStatus }); + return NextResponse.json({ + ok: true, + status: order.status, + paymentStatus: order.paymentStatus, + // Refined from the checkout-time "Online-Zahlung" placeholder to the + // actual instrument (Kreditkarte/PayPal) once confirm-payment sets it + // — see resolveStripePaymentMethodLabel's own comment. Returned here + // so VerarbeitungContent can patch the pending sessionStorage snapshot + // before promoting it, so /bestellbestaetigung shows the real one. + paymentMethodTitle: order.paymentMethodTitle, + }); } diff --git a/app/api/webhooks/stripe/route.ts b/app/api/webhooks/stripe/route.ts index fccb7a6..2f7632d 100644 --- a/app/api/webhooks/stripe/route.ts +++ b/app/api/webhooks/stripe/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; import Stripe from "stripe"; -import { verifyStripeWebhookSignature } from "../../../lib/payments/stripeProvider"; +import { verifyStripeWebhookSignature, resolveStripePaymentMethodLabel } from "../../../lib/payments/stripeProvider"; import { sendConfirmedPaymentEmail, type ConfirmPaymentOrderSnapshot } from "../../../lib/payments/confirmPaymentEmail"; import { sendCriticalAlert } from "../../../lib/alertAdmin"; @@ -46,6 +46,11 @@ export async function POST(request: Request) { return NextResponse.json({ ok: false, reason: "orderId metadata missing" }, { status: 409 }); } + // Best-effort — see resolveStripePaymentMethodLabel's own comment. Only + // meaningful on the "paid" path; a failed payment never gets a + // paymentMethodTitle refinement (the order becomes 'cancelled' outright). + const paymentMethodTitle = paymentStatus === "paid" ? await resolveStripePaymentMethodLabel(intent) : undefined; + const res = await fetch(`${PAYLOAD_URL}/api/orders/${orderId}/confirm-payment`, { method: "POST", headers: { @@ -56,6 +61,7 @@ export async function POST(request: Request) { paymentStatus, providerReference, paidAt: new Date().toISOString(), + ...(paymentMethodTitle ? { paymentMethodTitle } : {}), }), }).catch((err) => { sendCriticalAlert("confirm-payment-Aufruf ans Backend fehlgeschlagen", { orderId, providerReference, error: String(err) }); diff --git a/app/checkout/components/CheckoutContent.tsx b/app/checkout/components/CheckoutContent.tsx index 8d47c03..d343d3e 100644 --- a/app/checkout/components/CheckoutContent.tsx +++ b/app/checkout/components/CheckoutContent.tsx @@ -1,6 +1,6 @@ "use client"; -import { forwardRef, useEffect, useRef, useState } from "react"; +import { forwardRef, useEffect, useMemo, useRef, useState } from "react"; import Link from "next/link"; import Image from "next/image"; import { useRouter } from "next/navigation"; @@ -22,6 +22,7 @@ import { normalizeVatId, isValidVatId } from "../../lib/vatId"; import { computeExemptTotals, destinationCountry, isExemptionEligibleCountry } from "../../lib/vatExemption"; import { validateEmailFormat } from "../../lib/email"; import type { ShippingMethod, ShippingCountry, PaymentMethod, TrustBadge, ShippingSettings } from "../../lib/payload"; +import { groupPaymentMethodsForCheckout } from "../../lib/payload"; import type { CustomerProfile } from "../../lib/customerAuth"; // Native HTML5 pattern validation (instant, no round-trip) mirroring the @@ -141,7 +142,12 @@ export function CheckoutContent({ // validateZip() call site. const plzDigitsMap: Record = Object.fromEntries(shippingCountries.map((c) => [c.name, c.plzDigits])); const [shippingMethodId, setShippingMethodId] = useState(shippingMethods[0]?.id ?? null); - const [paymentMethodId, setPaymentMethodId] = useState(paymentMethods[0]?.id ?? null); + // Kreditkarte/PayPal collapse into one "Online-Zahlung" option here — + // see groupPaymentMethodsForCheckout's own comment for why. The + // resulting id is still a real payment-methods row id, so everything + // downstream (submission, sessionStorage draft restore) is unaffected. + const paymentOptions = useMemo(() => groupPaymentMethodsForCheckout(paymentMethods), [paymentMethods]); + const [paymentMethodId, setPaymentMethodId] = useState(paymentOptions[0]?.id ?? null); const [versandOpen, setVersandOpen] = useState(false); const [purchaseError, setPurchaseError] = useState(null); const [purchasing, setPurchasing] = useState(false); @@ -1219,23 +1225,26 @@ export function CheckoutContent({ 3. Zahlungsart

- {paymentMethods.map((method) => ( -