Files
Wabenhain/src/app/checkout/checkout-form.tsx
T
Nick SzaunigandClaude Opus 5 1fa8dc9ce2 fix: Bestell- und Abrechnungsprozess vor Go-Live abdichten
Fuenf Luecken zwischen Checkout, Zahlung und Stornierung geschlossen.

1. Bestellung ohne Stripe-Sitzung blieb dauerhaft haengen
   Schlug stripe.checkout.sessions.create fehl, blieb die Bestellung auf
   "NEU" ohne stripeCheckoutSessionId stehen. Damit griff kein einziger
   Aufraeumweg: der Abbruch-Klick braucht die Sitzungs-ID, der CronJob
   filterte solche Bestellungen sogar aktiv heraus, und ein Stripe-Event
   kann es ohne Sitzung nicht geben. Der reservierte Lagerbestand blieb
   fuer immer blockiert, und die Oberflaeche schickte die Kundschaft dabei
   auf die Erfolgsseite ("wir melden uns") — obwohl es keinen Weg gab,
   diese Bestellung je zu bezahlen.

   Die Route loest die Bestellung jetzt selbst auf (Bestand zurueck,
   Status STORNIERT mit Notiz) und antwortet mit einem ehrlichen Fehler,
   der ausdruecklich sagt, dass nichts abgebucht wurde. Laesst sich die
   Sitzungs-ID nicht speichern, wird die Sitzung sofort wieder
   geschlossen und derselbe Weg gegangen — eine "NEU"-Bestellung ohne
   Sitzungs-ID kann so nicht mehr entstehen. Der CronJob raeumt
   Altbestaende trotzdem ab (Filter entfernt).

2. Kaufabbrueche standen als Bestellungen im Kundenkonto
   getOrdersForCustomer lieferte auch den Status "NEU" aus. Ein gerade
   abgebrochener Kauf stand damit bis zu 45 Minuten lang mit
   Bestellnummer und Summe in der Liste und sah aus wie ein zustande
   gekommener Kauf. "NEU" wird dort jetzt ausgeblendet; in der
   Bestellverfolgung bleibt die Bestellung auffindbar, heisst aber
   "Zahlung ausstehend" statt "Neu".

3. Admin-Storno einer bezahlten Bestellung erstattete kein Geld
   Status wurde gesetzt, Ware zurueckgebucht, der Betrag blieb beim Shop.
   Der Storno erstattet jetzt ueber die Stripe Refunds API und setzt
   Payment.status auf REFUNDED.

4. Admin-Storno schloss die offene Stripe-Sitzung nicht
   Die Kundschaft konnte danach noch bezahlen; der Webhook fand die
   Bestellung nicht mehr auf "NEU" und brach still ab. Die Sitzung wird
   jetzt geschlossen, und der Webhook protokolliert eine Zahlung auf eine
   nicht mehr offene Bestellung laut, statt sie zu verschweigen.

5. Erstattungen und Rueckbuchungen kamen im Shop nicht an
   charge.refunded und charge.dispute.created werden jetzt verarbeitet:
   eine volle Erstattung aus dem Dashboard setzt Payment auf REFUNDED,
   eine Teilerstattung wird protokolliert, eine Rueckbuchung landet als
   Vermerk an der Bestellung. preflight prueft beide Abonnements mit.

Scheitert ein Stripe-Aufruf beim Storno, wird die Stornierung nicht
zurueckgenommen — der offene Punkt erscheint als Fehlermeldung im
Admin-Bereich und als order.cancel_incomplete im Audit-Log.

tsc: 0 Fehler. vitest: 294/294 gruen (ein Regressionstest fuer den
502-Fall neu). eslint: sauber.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-18 16:57:18 +02:00

561 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useEffect, useRef, useState, type FormEvent, type HTMLAttributes } from "react";
import { Container, SectionHeading, Button } from "@/components/ui";
import { TrustBadges } from "@/components/trust-badges";
import { useCartStore, cartTotalCents, cartItemCount } from "@/store/cart-store";
import CheckoutLoading from "./loading";
import { formatPrice } from "@/lib/format";
import { resolveShippingCostCents, type ShippingTierLike } from "@/lib/shipping-calc";
import { resolveBulkDiscount, type BulkDiscountTierLike } from "@/lib/bulk-discount-calc";
import { useI18n } from "@/i18n/i18n-provider";
import { TurnstileWidget } from "@/components/turnstile-widget";
type Fulfillment = "VERSAND" | "ABHOLUNG";
/** Hausanschrift oder DHL-Packstation — nur bei fulfillment === "VERSAND" relevant. */
type DeliveryTarget = "ADRESSE" | "PACKSTATION";
/** Ein Treffer der DHL-Standortsuche, wie ihn /api/shop/packstationen liefert. */
interface Packstation {
number: string;
name: string;
street: string;
zip: string;
city: string;
distanceMeters: number | null;
}
export interface CheckoutInitialValues {
firstName: string;
lastName: string;
email: string;
phone: string;
street: string;
zip: string;
city: string;
}
export function CheckoutForm({
initialValues,
tiers,
bulkDiscountTiers,
packstationSearchAvailable = false,
turnstileSiteKey,
}: {
initialValues?: CheckoutInitialValues;
tiers: ShippingTierLike[];
bulkDiscountTiers: BulkDiscountTierLike[];
/**
* Nur wenn DHL_API_KEY gesetzt ist, kann die Standortsuche ueberhaupt
* antworten — sonst wird die Packstation-Option gar nicht erst angeboten,
* statt Kundschaft in eine Suche laufen zu lassen, die immer fehlschlaegt.
*/
packstationSearchAvailable?: boolean;
/** Leer, wenn Turnstile nicht konfiguriert ist — dann entfaellt das Widget. */
turnstileSiteKey?: string;
}) {
const { t, locale } = useI18n();
const items = useCartStore((s) => s.items);
const hasHydrated = useCartStore((s) => s.hasHydrated);
const subtotal = cartTotalCents(items);
const totalQuantity = cartItemCount(items);
// Der Checkout schickt JSON statt eines Formulars, deshalb das Widget mit
// Token-Rueckgabe statt eines versteckten Feldes.
const [turnstileToken, setTurnstileToken] = useState<string | null>(null);
const [fulfillment, setFulfillment] = useState<Fulfillment>("VERSAND");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isGift, setIsGift] = useState(false);
const [deliveryTarget, setDeliveryTarget] = useState<DeliveryTarget>("ADRESSE");
const [packstationZip, setPackstationZip] = useState("");
const [packstationResults, setPackstationResults] = useState<Packstation[] | null>(null);
const [packstationSearching, setPackstationSearching] = useState(false);
const [packstationError, setPackstationError] = useState<string | null>(null);
const [selectedPackstation, setSelectedPackstation] = useState<Packstation | null>(null);
const usesPackstation = fulfillment === "VERSAND" && deliveryTarget === "PACKSTATION";
async function searchPackstations() {
setPackstationSearching(true);
setPackstationError(null);
setPackstationResults(null);
try {
const response = await fetch(
`/api/shop/packstationen?plz=${encodeURIComponent(packstationZip.trim())}`
);
const data = await response.json();
if (!response.ok) {
setPackstationError(typeof data.error === "string" ? data.error : t.checkoutPage.packstationNoResults);
return;
}
setPackstationResults(Array.isArray(data.packstationen) ? data.packstationen : []);
} catch {
// fetch() wirft nur bei Netzwerkabbruch — ein Fehlerstatus landet oben.
setPackstationError(t.checkoutPage.packstationNoResults);
} finally {
setPackstationSearching(false);
}
}
const errorRef = useRef<HTMLParagraphElement>(null);
// Fokus aktiv auf die Fehlermeldung verschieben statt sie nur farblich
// einzublenden — sonst bleibt der Fokus auf dem "Bestellen"-Button stehen
// und Screenreader-Nutzer:innen erfahren beim fehlgeschlagenen, geldwerten
// Checkout-Absenden nichts vom Fehler (role="alert" allein wird von
// manchen Screenreadern bei neu eingefuegten Elementen unzuverlaessig
// vorgelesen, Fokusverschiebung ist der robustere Mechanismus).
useEffect(() => {
if (error) errorRef.current?.focus();
}, [error]);
// Live sichtbar statt erst nach der Weiterleitung zu Stripe — reagiert auf
// die Versand/Abholung-Auswahl, damit die Gesamtsumme immer stimmt.
const shipping =
fulfillment === "ABHOLUNG" ? 0 : resolveShippingCostCents(tiers, totalQuantity);
const activeBulkDiscount = resolveBulkDiscount(bulkDiscountTiers, totalQuantity);
const bulkDiscountCents = activeBulkDiscount
? Math.round((subtotal * activeBulkDiscount.percent) / 100)
: 0;
const total = subtotal - bulkDiscountCents + shipping;
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
// Vor dem Absenden abfangen, statt den Server einen 400er zurueckgeben zu
// lassen: ohne Auswahl gaebe es keine Lieferadresse.
if (usesPackstation && !selectedPackstation) {
setError(t.checkoutPage.packstationChooseHint);
return;
}
setSubmitting(true);
setError(null);
const formData = new FormData(event.currentTarget);
const payload = {
email: String(formData.get("email") ?? ""),
firstName: String(formData.get("firstName") ?? ""),
lastName: String(formData.get("lastName") ?? ""),
phone: String(formData.get("phone") ?? "") || undefined,
fulfillmentMethod: fulfillment,
// Bei einer Packstation kommen Anschriftsfelder nicht aus dem Formular,
// sondern aus dem gewaehlten Standort. "street" traegt dabei bewusst den
// lesbaren Packstation-Namen, damit Rechnung, Bestaetigungsmail und
// Admin-Ansicht unveraendert etwas Sinnvolles zeigen; die technisch
// noetigen Werte stehen in packstationNumber/postNumber.
street: usesPackstation
? selectedPackstation
? `Packstation ${selectedPackstation.number}`
: undefined
: String(formData.get("street") ?? "") || undefined,
zip: usesPackstation
? selectedPackstation?.zip
: String(formData.get("zip") ?? "") || undefined,
city: usesPackstation
? selectedPackstation?.city
: String(formData.get("city") ?? "") || undefined,
country: "DE",
packstationNumber: usesPackstation ? selectedPackstation?.number : undefined,
postNumber: usesPackstation
? String(formData.get("postNumber") ?? "").trim() || undefined
: undefined,
items: items.map((item) => ({ productId: item.productId, quantity: item.quantity })),
discountCode: String(formData.get("discountCode") ?? "").trim() || undefined,
giftMessage: isGift ? String(formData.get("giftMessage") ?? "").trim() || undefined : undefined,
turnstileToken: turnstileToken ?? undefined,
};
try {
const response = await fetch("/api/checkout", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const data = await response.json();
if (!response.ok) {
// Auch der 502er (Stripe nicht erreichbar) landet hier als normale
// Fehlermeldung. Frueher fuehrte er auf die Erfolgsseite mit dem
// Hinweis "wir melden uns" — eine Bestellung ohne Zahlungssitzung ist
// aber auf keinem Weg bezahlbar, und die Seite versprach damit etwas,
// das niemand einloesen konnte (siehe api/checkout/route.ts).
setError(data.error ?? t.checkoutPage.genericError);
setSubmitting(false);
return;
}
window.location.href = data.url;
} catch {
setError(t.checkoutPage.genericError);
setSubmitting(false);
}
}
// Der Cart-Store haelt seine Positionen nur im Browser (persist/localStorage)
// und liest sie erst nach dem ersten Client-Render nach. Ohne diese Weiche
// wuerde jede:r Kund:in mit gefuelltem Warenkorb kurz die "leer"-Meldung
// sehen, bevor auf das echte Formular umgeschaltet wird — ein grosser,
// messbarer Layout-Sprung (siehe CLS) und verwirrend fuer echte Kaeufer:innen.
if (!hasHydrated) {
return <CheckoutLoading />;
}
if (items.length === 0) {
return (
<div className="py-20">
<Container>
<SectionHeading eyebrow={t.checkoutPage.eyebrow} title={t.checkoutPage.emptyCart} />
</Container>
</div>
);
}
return (
<div className="py-16">
<Container className="grid lg:grid-cols-3 gap-14">
<form onSubmit={handleSubmit} className="lg:col-span-2 space-y-8">
<SectionHeading eyebrow={t.checkoutPage.eyebrow} title={t.checkoutPage.yourDetails} />
<div className="grid sm:grid-cols-2 gap-4">
<Field
name="firstName"
label={t.checkoutPage.firstName}
defaultValue={initialValues?.firstName}
autoComplete="given-name"
required
/>
<Field
name="lastName"
label={t.checkoutPage.lastName}
defaultValue={initialValues?.lastName}
autoComplete="family-name"
required
/>
</div>
<div className="grid sm:grid-cols-2 gap-4">
<Field
name="email"
label={t.checkoutPage.email}
type="email"
defaultValue={initialValues?.email}
autoComplete="email"
required
/>
<Field
name="phone"
label={t.checkoutPage.phoneOptional}
type="tel"
inputMode="tel"
defaultValue={initialValues?.phone}
autoComplete="tel"
/>
</div>
<div>
<p className="text-sm text-ink/70 mb-3">{t.checkoutPage.fulfillmentLabel}</p>
<div className="flex gap-4">
{(["VERSAND", "ABHOLUNG"] as const).map((option) => (
<button
type="button"
key={option}
onClick={() => setFulfillment(option)}
className={`flex-1 border px-4 py-3 text-sm text-left transition-colors ${
fulfillment === option
? "border-forest bg-forest text-cream"
: "border-ink/20 text-ink/70"
}`}
>
{option === "VERSAND" ? t.checkoutPage.shippingOption : t.checkoutPage.pickupOption}
</button>
))}
</div>
{fulfillment === "VERSAND" ? (
<p className="mt-2 text-xs text-ink/70">
{t.checkoutPage.deliveryTimeNote}
</p>
) : null}
</div>
{fulfillment === "VERSAND" && packstationSearchAvailable ? (
<div className="flex gap-3">
{(["ADRESSE", "PACKSTATION"] as const).map((option) => (
<button
key={option}
type="button"
onClick={() => setDeliveryTarget(option)}
aria-pressed={deliveryTarget === option}
className={`flex-1 border px-4 py-3 text-sm transition-colors ${
deliveryTarget === option
? "border-forest bg-forest text-cream"
: "border-ink/20 hover:border-forest"
}`}
>
{option === "ADRESSE"
? t.checkoutPage.deliveryTargetAddress
: t.checkoutPage.deliveryTargetPackstation}
</button>
))}
</div>
) : null}
{fulfillment === "VERSAND" && !usesPackstation ? (
<div className="space-y-4">
<Field
name="street"
label={t.checkoutPage.streetLabel}
defaultValue={initialValues?.street}
autoComplete="street-address"
required
/>
<div className="grid sm:grid-cols-2 gap-4">
<Field
name="zip"
label={t.checkoutPage.zipLabel}
defaultValue={initialValues?.zip}
autoComplete="postal-code"
inputMode="numeric"
required
/>
<Field
name="city"
label={t.checkoutPage.cityLabel}
defaultValue={initialValues?.city}
autoComplete="address-level2"
required
/>
</div>
</div>
) : null}
{usesPackstation ? (
<div className="space-y-4 border border-ink/15 p-4">
<p className="text-xs text-ink/70">{t.checkoutPage.packstationIntro}</p>
{selectedPackstation ? (
<div className="flex items-start justify-between gap-4 border border-forest/40 bg-forest/5 p-3">
<div className="text-sm">
<p className="text-xs tracking-wide-caps text-ink/70">
{t.checkoutPage.packstationSelectedLabel}
</p>
<p className="mt-1 text-forest">{selectedPackstation.name}</p>
<p className="text-ink/70">
{selectedPackstation.street ? `${selectedPackstation.street}, ` : ""}
{selectedPackstation.zip} {selectedPackstation.city}
</p>
</div>
<button
type="button"
onClick={() => setSelectedPackstation(null)}
className="shrink-0 text-xs text-ink/70 underline"
>
{t.checkoutPage.packstationChange}
</button>
</div>
) : (
<>
<div className="flex items-end gap-3">
<label className="block flex-1 text-sm">
<span className="text-ink/70">{t.checkoutPage.packstationZipLabel}</span>
<input
value={packstationZip}
onChange={(event) => setPackstationZip(event.target.value)}
inputMode="numeric"
maxLength={5}
autoComplete="postal-code"
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
/>
</label>
<button
type="button"
onClick={searchPackstations}
disabled={packstationSearching || packstationZip.trim().length !== 5}
className="border border-ink/20 bg-background px-4 py-2 text-sm hover:border-forest disabled:opacity-50"
>
{packstationSearching
? t.checkoutPage.packstationSearching
: t.checkoutPage.packstationSearchButton}
</button>
</div>
{packstationError ? (
<p className="text-xs text-red-700">{packstationError}</p>
) : null}
{packstationResults?.length === 0 ? (
<p className="text-xs text-ink/70">{t.checkoutPage.packstationNoResults}</p>
) : null}
{packstationResults && packstationResults.length > 0 ? (
<ul className="space-y-2">
{packstationResults.map((packstation) => (
<li key={packstation.number}>
<button
type="button"
onClick={() => setSelectedPackstation(packstation)}
className="w-full border border-ink/20 px-3 py-2 text-left text-sm hover:border-forest"
>
<span className="text-forest">{packstation.name}</span>
<span className="block text-xs text-ink/70">
{packstation.street ? `${packstation.street}, ` : ""}
{packstation.zip} {packstation.city}
{packstation.distanceMeters !== null
? ` — ${t.checkoutPage.packstationDistance(
Math.round(packstation.distanceMeters)
)}`
: ""}
</span>
</button>
</li>
))}
</ul>
) : null}
</>
)}
<Field
name="postNumber"
label={t.checkoutPage.postNumberLabel}
inputMode="numeric"
autoComplete="off"
required
/>
<p className="text-xs text-ink/70">{t.checkoutPage.postNumberHint}</p>
</div>
) : null}
<Field
name="discountCode"
label={t.checkoutPage.discountCodeLabel}
placeholder={t.checkoutPage.discountCodePlaceholder}
autoComplete="off"
/>
<div>
<label className="flex items-center gap-2 text-sm text-ink/70 cursor-pointer">
<input
type="checkbox"
checked={isGift}
onChange={(event) => setIsGift(event.target.checked)}
className="h-4 w-4 border-ink/20 accent-forest"
/>
{t.checkoutPage.isGiftLabel}
</label>
{isGift ? (
<label className="block text-sm mt-3">
<span className="text-ink/70">{t.checkoutPage.giftMessageLabel}</span>
<textarea
name="giftMessage"
maxLength={500}
rows={3}
placeholder={t.checkoutPage.giftMessagePlaceholder}
className="mt-1 w-full border border-ink/20 bg-background px-4 py-3 focus:outline-none focus:border-forest resize-none"
/>
</label>
) : null}
</div>
{error ? (
<p ref={errorRef} role="alert" tabIndex={-1} className="text-sm text-red-700 outline-none">
{error}
</p>
) : null}
{turnstileSiteKey ? (
<div className="mb-4">
<TurnstileWidget siteKey={turnstileSiteKey} onVerify={setTurnstileToken} />
</div>
) : null}
<Button type="submit" disabled={submitting} className="w-full sm:w-auto">
{submitting ? t.checkoutPage.submitting : t.checkoutPage.placeOrder}
</Button>
<p className="text-xs text-ink/65">
{t.checkoutPage.paymentMethodsNote}
</p>
</form>
<aside className="border border-ink/10 p-8 h-fit lg:sticky lg:top-24">
<h2 className="text-lg text-forest mb-6">{t.checkoutPage.yourOrderHeading}</h2>
<ul className="space-y-3 text-sm">
{items.map((item) => (
<li key={item.productId} className="flex justify-between">
<span>
{item.quantity} × {item.name}
</span>
<span>{formatPrice(item.priceCents * item.quantity, "EUR", locale)}</span>
</li>
))}
</ul>
<div className="mt-6 pt-4 border-t border-ink/10 space-y-2 text-sm">
<div className="flex justify-between text-ink/70">
<span>{t.cart.subtotal}</span>
<span>{formatPrice(subtotal, "EUR", locale)}</span>
</div>
<div className="flex justify-between text-ink/70">
<span>
{fulfillment === "VERSAND"
? t.checkoutPage.shippingWithCount(totalQuantity)
: t.checkoutPage.shippingPickup}
</span>
<span>{shipping === 0 ? t.cartPage.free : formatPrice(shipping, "EUR", locale)}</span>
</div>
{activeBulkDiscount ? (
<div className="flex justify-between text-forest">
<span>
{t.cartPage.discountLabel} ({activeBulkDiscount.percent}%)
</span>
<span>-{formatPrice(bulkDiscountCents, "EUR", locale)}</span>
</div>
) : null}
</div>
<div className="mt-3 pt-3 border-t border-ink/10 flex justify-between text-base text-forest">
<span>{t.cartPage.total}</span>
<span>{formatPrice(total, "EUR", locale)}</span>
</div>
<TrustBadges className="mt-6 pt-6 border-t border-ink/10" />
</aside>
</Container>
</div>
);
}
function Field({
name,
label,
type = "text",
inputMode,
defaultValue,
placeholder,
autoComplete,
required = false,
}: {
name: string;
label: string;
type?: string;
/** Zeigt auf Mobilgeraeten die passende Bildschirmtastatur (z.B. Ziffernblock fuer die PLZ). */
inputMode?: HTMLAttributes<HTMLInputElement>["inputMode"];
defaultValue?: string;
placeholder?: string;
/** Browser-/Mobil-Autofill fuer Adress- und Kontaktfelder (siehe MDN autofill-Tokens) — ohne das muss jede Bestellung komplett von Hand eingetippt werden. */
autoComplete?: string;
required?: boolean;
}) {
return (
<label className="block text-sm">
<span className="text-ink/70">{label}</span>
<input
name={name}
type={type}
inputMode={inputMode}
defaultValue={defaultValue}
placeholder={placeholder}
autoComplete={autoComplete}
required={required}
className="mt-1 w-full border border-ink/20 bg-background px-4 py-3 focus:outline-none focus:border-forest"
/>
</label>
);
}