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