Consolidate Kreditkarte/PayPal into one "Online-Zahlung" checkout option

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 <noreply@anthropic.com>
This commit is contained in:
Marco
2026-07-25 12:17:38 +00:00
parent 4e22942031
commit bab2c916be
8 changed files with 152 additions and 27 deletions
+34 -4
View File
@@ -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
+10 -2
View File
@@ -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,
+11 -1
View File
@@ -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,
});
}
+7 -1
View File
@@ -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) });
+27 -18
View File
@@ -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<string, number> = Object.fromEntries(shippingCountries.map((c) => [c.name, c.plzDigits]));
const [shippingMethodId, setShippingMethodId] = useState<number | null>(shippingMethods[0]?.id ?? null);
const [paymentMethodId, setPaymentMethodId] = useState<number | null>(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<number | null>(paymentOptions[0]?.id ?? null);
const [versandOpen, setVersandOpen] = useState(false);
const [purchaseError, setPurchaseError] = useState<string | null>(null);
const [purchasing, setPurchasing] = useState(false);
@@ -1219,23 +1225,26 @@ export function CheckoutContent({
3. Zahlungsart
</p>
{paymentMethods.map((method) => (
<label key={method.id} className="flex items-center gap-3 w-full cursor-pointer">
<input
type="radio"
name="payment"
checked={paymentMethodId === method.id}
onChange={() => setPaymentMethodId(method.id)}
className="size-5 shrink-0 accent-brand"
/>
<span className="flex-1 text-body-sm text-text-primary">{method.title}</span>
<span className="flex items-center gap-2 shrink-0">
{method.icons.map((icon, i) => (
<div key={i} className="relative h-5 w-8 shrink-0">
<Image src={icon} alt="" fill sizes="32px" className="object-contain" />
</div>
))}
{paymentOptions.map((method) => (
<label key={method.id} className="flex flex-col gap-1 w-full cursor-pointer">
<span className="flex items-center gap-3 w-full">
<input
type="radio"
name="payment"
checked={paymentMethodId === method.id}
onChange={() => setPaymentMethodId(method.id)}
className="size-5 shrink-0 accent-brand"
/>
<span className="flex-1 text-body-sm text-text-primary">{method.title}</span>
<span className="flex items-center gap-2 shrink-0">
{method.icons.map((icon, i) => (
<div key={i} className="relative h-5 w-8 shrink-0">
<Image src={icon} alt="" fill sizes="32px" className="object-contain" />
</div>
))}
</span>
</span>
{method.hint && <span className="pl-8 text-label text-text-muted">{method.hint}</span>}
</label>
))}
@@ -45,7 +45,14 @@ export function VerarbeitungContent() {
try {
const pending = window.sessionStorage.getItem(PENDING_ORDER_KEY);
if (pending) {
window.sessionStorage.setItem(ORDER_KEY, pending);
// Patch in the real instrument (Kreditkarte/PayPal) now
// that it's known — the pending snapshot was written at
// checkout submission time with the neutral "Online-
// Zahlung" placeholder, before the customer had actually
// picked one on the Payment Element.
const snapshot = JSON.parse(pending);
if (data.paymentMethodTitle) snapshot.paymentMethodTitle = data.paymentMethodTitle;
window.sessionStorage.setItem(ORDER_KEY, JSON.stringify(snapshot));
window.sessionStorage.removeItem(PENDING_ORDER_KEY);
}
} catch {
+32
View File
@@ -621,6 +621,38 @@ export async function getPaymentMethods(): Promise<PaymentMethod[]> {
}));
}
export type CheckoutPaymentOption = PaymentMethod & { hint?: string };
// Kreditkarte and PayPal both resolve to `provider: 'stripe'` today, and
// both end up on the exact same Stripe PaymentIntent
// (`automatic_payment_methods: { enabled: true }` — Stripe's own
// recommended Payment Element pattern lets Stripe itself decide which
// eligible method to show, rather than the older per-method
// Checkout-Session split). Pre-selecting one of two identical-behind-the-
// scenes rows before the payment step is therefore no longer a real
// choice, just redundant friction — so this collapses every active
// `stripe` row into one "Online-Zahlung" option (representative id =
// the first such row's, since app/api/checkout/route.ts only branches on
// `provider`, never on which specific stripe row was picked) with a hint
// explaining that the actual instrument is chosen on the next screen.
// `manual` rows (Überweisung) pass through unchanged — one real gateway
// there, one option, nothing to collapse.
export function groupPaymentMethodsForCheckout(methods: PaymentMethod[]): CheckoutPaymentOption[] {
const manual = methods.filter((m) => m.provider !== "stripe");
const stripeMethods = methods.filter((m) => m.provider === "stripe");
if (stripeMethods.length === 0) return manual;
const combinedIcons = Array.from(new Set(stripeMethods.flatMap((m) => m.icons)));
const online: CheckoutPaymentOption = {
id: stripeMethods[0].id,
title: "Online-Zahlung",
icons: combinedIcons,
provider: "stripe",
hint: "Kreditkarte, PayPal & weitere Methoden — die genaue Zahlungsart wählst du im nächsten Schritt.",
};
return [...manual, online];
}
export type WerkzeugeCard = {
id: number;
title: string;
+23
View File
@@ -49,6 +49,29 @@ async function attachOrderMetadata(providerReference: string, metadata: { orderI
export const stripeProvider: PaymentProvider = { createPaymentIntent, attachOrderMetadata };
const PAYMENT_METHOD_LABELS: Record<string, string> = { card: "Kreditkarte", paypal: "PayPal" };
// Called only by the real webhook route on `payment_intent.succeeded` —
// the checkout route snapshots a neutral "Online-Zahlung" title at order
// creation (see its own comment: the customer hasn't chosen an instrument
// yet at that point, Stripe's Payment Element does that next), this
// resolves the actual one once Stripe reports it so the order/invoice/
// confirmation email reflect what was really used, not a placeholder.
// Best-effort: an unresolvable label just leaves the neutral title in
// place (confirmPayment.ts only overwrites paymentMethodTitle when this
// returns something), it doesn't fail the payment confirmation itself.
export async function resolveStripePaymentMethodLabel(intent: Stripe.PaymentIntent): Promise<string | undefined> {
const pm = intent.payment_method;
const pmId = typeof pm === "string" ? pm : pm?.id;
if (!pmId) return undefined;
try {
const resolved = pm && typeof pm === "object" ? pm : await getClient().paymentMethods.retrieve(pmId);
return PAYMENT_METHOD_LABELS[resolved.type] ?? resolved.type;
} catch {
return undefined;
}
}
// Only used by the real webhook route (never through the PaymentProvider
// interface — signature verification is inherently Stripe-shaped, no
// other provider exists to share this contract with yet).