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]>
This commit is contained in:
Nick SzaunigandClaude Opus 5 committed 2026-09-18 16:57:18 +02:00
1 parent dda0a66c6c
commit 1fa8dc9ce2
11 files changed
+481 -35

No files matched your search

+21 -1
View File
@@ -159,7 +159,12 @@ SEPA-Lastschrift und teils Klarna bestätigen die Zahlung **verzögert** (bei SE
- `checkout.session.async_payment_failed` storniert die Bestellung, bucht reservierten Bestand zurück und informiert die Kundschaft per E-Mail, falls der Zahlungseinzug fehlschlägt. - `checkout.session.async_payment_failed` storniert die Bestellung, bucht reservierten Bestand zurück und informiert die Kundschaft per E-Mail, falls der Zahlungseinzug fehlschlägt.
- `checkout.session.expired` behandelt eine von der Kundschaft einfach verlassene (nicht bezahlte) Checkout-Sitzung anders als einen fehlgeschlagenen Einzug: der reservierte Bestand geht zurück ins Regal und die Bestellung wird **gelöscht**, nicht storniert. Es gab keine Zahlung, keine Bestätigung und keine Rechnung — also auch nichts aufzubewahren. Eine stornierte Bestellung stehen zu lassen würde nur verwirren: im Admin-Bereich sähe jeder weggeklickte Warenkorb aus wie ein echter, gescheiterter Verkauf. - `checkout.session.expired` behandelt eine von der Kundschaft einfach verlassene (nicht bezahlte) Checkout-Sitzung anders als einen fehlgeschlagenen Einzug: der reservierte Bestand geht zurück ins Regal und die Bestellung wird **gelöscht**, nicht storniert. Es gab keine Zahlung, keine Bestätigung und keine Rechnung — also auch nichts aufzubewahren. Eine stornierte Bestellung stehen zu lassen würde nur verwirren: im Admin-Bereich sähe jeder weggeklickte Warenkorb aus wie ein echter, gescheiterter Verkauf.
Für einen produktiven Webhook-Endpoint müssen alle vier Events abonniert werden; `stripe listen` leitet lokal ohnehin alle Events weiter. Zwei weitere Events betreffen Geld, das den Shop wieder verlässt, **ohne dass hier jemand etwas angeklickt hat**:
- `charge.refunded` hält eine Erstattung fest, die von Hand im Stripe-Dashboard ausgelöst wurde. Ohne dieses Abonnement stand die Zahlung im Shop weiter auf `SUCCEEDED` und jede Auswertung zählte einen Umsatz mit, den es nicht mehr gab. Eine **Teilerstattung** setzt den Status bewusst *nicht* auf `REFUNDED` (der Restbetrag ist weiterhin eingezogen), sondern wird nur protokolliert. Der Bestellstatus und der Lagerbestand bleiben in beiden Fällen unangetastet: ob eine Erstattung Storno, Retoure oder Kulanz bedeutet, steht am Event nicht.
- `charge.dispute.created` vermerkt eine Rückbuchung (Chargeback) in den Notizen der Bestellung und schreibt sie ins Server-Log. Die Stellungnahme gegenüber Stripe ist **fristgebunden** — vorher war eine Rückbuchung ausschließlich im Stripe-Dashboard zu sehen.
Für einen produktiven Webhook-Endpoint müssen alle sechs Events abonniert werden; `stripe listen` leitet lokal ohnehin alle Events weiter. `pnpm preflight` prüft das Abonnement.
> **`checkout.session.expired` ist nicht optional.** Ohne dieses Abonnement bleibt der beim Checkout reservierte Bestand jedes abgebrochenen Kaufs dauerhaft blockiert. Falls es doch einmal fehlt oder der Endpunkt nicht erreichbar war, fängt der CronJob `wabenhain-release-stale-orders` (siehe unten) das ab. > **`checkout.session.expired` ist nicht optional.** Ohne dieses Abonnement bleibt der beim Checkout reservierte Bestand jedes abgebrochenen Kaufs dauerhaft blockiert. Falls es doch einmal fehlt oder der Endpunkt nicht erreichbar war, fängt der CronJob `wabenhain-release-stale-orders` (siehe unten) das ab.
@@ -172,11 +177,26 @@ Eine Bestellung entsteht **vor** der Zahlung und reserviert dabei sofort Lagerbe
| Klick auf „Abbrechen“ bei Stripe | `/checkout/cancel?ref=<Token>` schließt die Stripe-Sitzung, bucht den Bestand **sofort** zurück und löscht die Bestellung | | Klick auf „Abbrechen“ bei Stripe | `/checkout/cancel?ref=<Token>` schließt die Stripe-Sitzung, bucht den Bestand **sofort** zurück und löscht die Bestellung |
| Checkout einfach verlassen | Die Sitzung läuft nach **32 Minuten** ab, Stripe schickt `checkout.session.expired`, der Webhook bucht zurück und löscht | | Checkout einfach verlassen | Die Sitzung läuft nach **32 Minuten** ab, Stripe schickt `checkout.session.expired`, der Webhook bucht zurück und löscht |
| Webhook kommt nicht an | Der CronJob `wabenhain-release-stale-orders` prüft alle 30 Minuten bei Stripe nach und bucht zurück, was dort abgelaufen ist | | Webhook kommt nicht an | Der CronJob `wabenhain-release-stale-orders` prüft alle 30 Minuten bei Stripe nach und bucht zurück, was dort abgelaufen ist |
| Stripe-Sitzung lässt sich gar nicht erst anlegen | `api/checkout/route.ts` bucht den Bestand sofort zurück und hinterlässt die Bestellung als **STORNIERT** mit Notiz — sie war nie bezahlbar |
Der letzte Fall war eine Lücke: schlug `stripe.checkout.sessions.create` fehl, blieb die Bestellung auf `NEU` **ohne** `stripeCheckoutSessionId` stehen. Damit griff keiner der drei Wege darüber — der Abbruch-Klick braucht die Sitzungs-ID, der CronJob ebenso, und ein Stripe-Event kann es ohne Sitzung nicht geben. Der reservierte Bestand blieb dauerhaft blockiert, und die Oberfläche schickte die Kundschaft dabei auf die Erfolgsseite („wir melden uns"), obwohl es keinen Weg gab, diese Bestellung je zu bezahlen. Heute wird stattdessen ein Fehler angezeigt, der ausdrücklich sagt, dass nichts abgebucht wurde. Lässt sich die Sitzungs-ID nach dem Anlegen nicht speichern, wird die Sitzung sofort wieder geschlossen und dieselbe Auflösung ausgelöst — eine Bestellung auf `NEU` ohne Sitzungs-ID kann so nicht mehr entstehen. Der CronJob räumt Altbestände aus der Zeit davor trotzdem ab.
**Solange die Zahlung nicht bestätigt ist, erscheint die Bestellung nicht im Kundenkonto.** `getOrdersForCustomer` blendet den Status `NEU` aus (`lib/account-data.ts`). Vorher stand ein gerade abgebrochener Kauf dort bis zu 45 Minuten lang mit Bestellnummer und Summe in der Liste und sah aus wie ein zustande gekommener Kauf. Bezahlte Bestellungen sind davon nicht betroffen — der Webhook setzt sie binnen Sekunden auf `BEZAHLT`. In der Bestellverfolgung (`/bestellung-verfolgen`, auch für Gastbestellungen) bleibt eine `NEU`-Bestellung auffindbar, trägt dort aber die Kennzeichnung **„Zahlung ausstehend"** statt eines nichtssagenden „Neu".
**Ein abgebrochener Checkout hinterlässt keine Bestellung.** Weder im Admin-Bereich noch im Kundenkonto bleibt etwas stehen — mit der Bestellung verschwinden auch ihre Positionen (`onDelete: Cascade`). Aufbewahrungspflichten entstehen dabei nicht: ohne Zahlung gibt es kein Geschäft und keine Rechnung. Die Ausnahme ist `checkout.session.async_payment_failed`: dort wurde tatsächlich eine Zahlung versucht und ist gescheitert, die Kundschaft rechnet mit einer Lieferung. Diese Bestellung bleibt als **STORNIERT** bestehen und die Kundschaft bekommt eine E-Mail dazu. **Ein abgebrochener Checkout hinterlässt keine Bestellung.** Weder im Admin-Bereich noch im Kundenkonto bleibt etwas stehen — mit der Bestellung verschwinden auch ihre Positionen (`onDelete: Cascade`). Aufbewahrungspflichten entstehen dabei nicht: ohne Zahlung gibt es kein Geschäft und keine Rechnung. Die Ausnahme ist `checkout.session.async_payment_failed`: dort wurde tatsächlich eine Zahlung versucht und ist gescheitert, die Kundschaft rechnet mit einer Lieferung. Diese Bestellung bleibt als **STORNIERT** bestehen und die Kundschaft bekommt eine E-Mail dazu.
Die **32 Minuten** sind eine bewusste Abweichung von Stripes Voreinstellung (24 Stunden, `expires_at` in `api/checkout/route.ts`). Mit einem Tag Reservierungsdauer könnte jemand den Shop mit wenigen Klicks leerräumen, ohne einen Cent zu zahlen. Kürzer geht nicht: **Stripe lässt als Untergrenze 30 Minuten zu**, die zwei Minuten Aufschlag sind Puffer gegen auseinanderlaufende Uhren. Die **32 Minuten** sind eine bewusste Abweichung von Stripes Voreinstellung (24 Stunden, `expires_at` in `api/checkout/route.ts`). Mit einem Tag Reservierungsdauer könnte jemand den Shop mit wenigen Klicks leerräumen, ohne einen Cent zu zahlen. Kürzer geht nicht: **Stripe lässt als Untergrenze 30 Minuten zu**, die zwei Minuten Aufschlag sind Puffer gegen auseinanderlaufende Uhren.
### Stornierung im Admin-Bereich
Setzt die Imkerei eine Bestellung unter `/admin/bestellungen/<id>` auf **STORNIERT**, passiert dreierlei in dieser Reihenfolge (`admin/actions/orders.ts`):
1. Status setzen und Lagerbestand zurückbuchen — atomar in einer Transaktion, damit zwei offene Tabs den Bestand nicht doppelt gutschreiben.
2. **War die Bestellung bezahlt, wird der volle Betrag über die Stripe Refunds API erstattet** und `Payment.status` auf `REFUNDED` gesetzt. Vorher unterblieb das: die Bestellung galt als storniert, die Ware lag wieder im Regal, und das Geld blieb beim Shop. Teilerstattungen laufen weiterhin über den Retouren-Bereich, wo sie hingehören.
3. **War die Bestellung noch `NEU`, wird die offene Stripe-Sitzung geschlossen.** Sonst konnte die Kundschaft Minuten später noch bezahlen — der Webhook fand die Bestellung dann nicht mehr auf `NEU`, brach ab, und das Geld lag bei Stripe, ohne dass im Shop irgendetwas davon zu sehen war.
Die Stripe-Aufrufe laufen bewusst **nach** der Transaktion: ein Netzaufruf dauert ein Vielfaches einer Datenbankoperation und würde die Sperren auf Bestellung und Lagerbestand so lange halten. Schlägt einer davon fehl, wird die Stornierung **nicht** zurückgenommen — die Ware ist bereits wieder im Bestand und die Absicht war eindeutig. Stattdessen erscheint der offene Punkt als Fehlermeldung im Admin-Bereich („bitte im Stripe-Dashboard von Hand erstatten") und als Eintrag `order.cancel_incomplete` im Audit-Log.
Der **Abbruch-Token** (`Order.cancelToken`, 32 Byte) steht ausschließlich in der `cancel_url` und wird nach Gebrauch aus der Adresszeile entfernt. Die Bestellnummer taugt dafür nicht: sie steht in jeder Bestätigungsmail und auf jeder Rechnung, mit ihr ließe sich also die noch offene Zahlung einer fremden Person abbrechen. Vor dem Zurückbuchen wird immer erst bei Stripe nachgesehen — ist die Sitzung dort `complete`, war der Aufruf kein Abbruch, sondern ein Zurück-Klick nach erfolgreicher Zahlung, und es passiert nichts. Der **Abbruch-Token** (`Order.cancelToken`, 32 Byte) steht ausschließlich in der `cancel_url` und wird nach Gebrauch aus der Adresszeile entfernt. Die Bestellnummer taugt dafür nicht: sie steht in jeder Bestätigungsmail und auf jeder Rechnung, mit ihr ließe sich also die noch offene Zahlung einer fremden Person abbrechen. Vor dem Zurückbuchen wird immer erst bei Stripe nachgesehen — ist die Sitzung dort `complete`, war der Aufruf kein Abbruch, sondern ein Zurück-Klick nach erfolgreicher Zahlung, und es passiert nichts.
Der Aufräumlauf lässt sich auch von Hand anstoßen: `pnpm orders:release-stale`. Er bucht nur zurück, was bei Stripe nachweislich abgelaufen ist, nie nach Alter allein. Findet er eine Bestellung, die bei Stripe **bezahlt** ist, hier aber noch auf „NEU“ steht, rührt er sie nicht an, meldet sie und endet mit Exit-Code 1 — dieser Fall bedeutet, dass ein `completed`-Webhook verlorengegangen ist, und er gehört als fehlgeschlagener Job sichtbar gemacht statt in eine Logzeile. Der Aufräumlauf lässt sich auch von Hand anstoßen: `pnpm orders:release-stale`. Er bucht nur zurück, was bei Stripe nachweislich abgelaufen ist, nie nach Alter allein. Findet er eine Bestellung, die bei Stripe **bezahlt** ist, hier aber noch auf „NEU“ steht, rührt er sie nicht an, meldet sie und endet mit Exit-Code 1 — dieser Fall bedeutet, dass ein `completed`-Webhook verlorengegangen ist, und er gehört als fehlgeschlagener Job sichtbar gemacht statt in eine Logzeile.
+8 -1
View File
@@ -238,6 +238,13 @@ const REQUIRED_WEBHOOK_EVENTS = [
"checkout.session.async_payment_succeeded", "checkout.session.async_payment_succeeded",
"checkout.session.async_payment_failed", "checkout.session.async_payment_failed",
"checkout.session.expired", "checkout.session.expired",
// Geld, das den Shop wieder verlaesst, ohne dass hier jemand etwas
// angeklickt hat: eine Erstattung von Hand im Stripe-Dashboard und eine
// Rueckbuchung durch die Bank. Ohne diese beiden Ereignisse steht die
// Zahlung im Shop weiter als erfolgreich da, und eine fristgebundene
// Rueckbuchung faellt nur auf, wenn jemand ins Dashboard sieht.
"charge.refunded",
"charge.dispute.created",
] as const; ] as const;
async function checkStripeWebhookEvents(): Promise<void> { async function checkStripeWebhookEvents(): Promise<void> {
@@ -266,7 +273,7 @@ async function checkStripeWebhookEvents(): Promise<void> {
} }
must( must(
"Stripe-Webhook abonniert alle vier benoetigten Ereignisse", `Stripe-Webhook abonniert alle ${REQUIRED_WEBHOOK_EVENTS.length} benoetigten Ereignisse`,
missing.length === 0, missing.length === 0,
`Nicht abonniert: ${missing.join(", ")}. Besonders checkout.session.expired: ohne dieses Ereignis bleibt der Lagerbestand jedes abgebrochenen Checkouts blockiert. Unter https://dashboard.stripe.com/webhooks am Endpunkt nachtragen.` `Nicht abonniert: ${missing.join(", ")}. Besonders checkout.session.expired: ohne dieses Ereignis bleibt der Lagerbestand jedes abgebrochenen Checkouts blockiert. Unter https://dashboard.stripe.com/webhooks am Endpunkt nachtragen.`
); );
+24 -2
View File
@@ -44,11 +44,15 @@ const MAX_ORDERS_PER_RUN = 200;
async function main(): Promise<void> { async function main(): Promise<void> {
const cutoff = new Date(Date.now() - MIN_AGE_MINUTES * 60 * 1000); const cutoff = new Date(Date.now() - MIN_AGE_MINUTES * 60 * 1000);
// Bewusst OHNE Filter auf stripeCheckoutSessionId: genau die Bestellungen
// ohne hinterlegte Sitzung waren frueher das Loch in diesem Netz. Sie
// wurden hier uebergangen, der Abbruch-Klick kam ohne Sitzungs-ID ebenfalls
// nicht weiter, und ein Ereignis von Stripe konnte es ohne Sitzung gar
// nicht geben — ihr reservierter Lagerbestand blieb dauerhaft blockiert.
const candidates = await prisma.order.findMany({ const candidates = await prisma.order.findMany({
where: { where: {
status: "NEU", status: "NEU",
createdAt: { lt: cutoff }, createdAt: { lt: cutoff },
stripeCheckoutSessionId: { not: null },
}, },
select: { id: true, orderNumber: true, stripeCheckoutSessionId: true }, select: { id: true, orderNumber: true, stripeCheckoutSessionId: true },
orderBy: { createdAt: "asc" }, orderBy: { createdAt: "asc" },
@@ -72,7 +76,25 @@ async function main(): Promise<void> {
for (const order of candidates) { for (const order of candidates) {
try { try {
const session = await stripe.checkout.sessions.retrieve(order.stripeCheckoutSessionId!); if (!order.stripeCheckoutSessionId) {
// Seit api/checkout/route.ts die Sitzung wieder schliesst, wenn sich
// ihre ID nicht speichern laesst, sollte es das nicht mehr geben. Es
// bleibt aber der Fall, dass auch dieses Schliessen fehlschlug, und
// Altbestaende aus der Zeit davor. Beide sind aelter als MIN_AGE_MINUTES
// und damit aelter als CHECKOUT_SESSION_TTL_MINUTES: eine Sitzung, die
// es noch gaebe, waere zu diesem Zeitpunkt in jedem Fall abgelaufen.
// Der Bestand kann also gefahrlos zurueck.
const result = await releaseUnpaidOrder(order.id, "delete");
if (result) {
released += 1;
console.warn(
`[release-stale-orders] ${result.orderNumber}: Bestand freigegeben, Vormerkung entfernt — zu dieser Bestellung war KEINE Stripe-Sitzung hinterlegt, sie war also nie bezahlbar.`
);
}
continue;
}
const session = await stripe.checkout.sessions.retrieve(order.stripeCheckoutSessionId);
if (session.status === "complete") { if (session.status === "complete") {
// Bezahlt, aber der Status steht noch auf "NEU": der // Bezahlt, aber der Status steht noch auf "NEU": der
+138 -1
View File
@@ -7,7 +7,9 @@ import { sendMail } from "@/lib/mail";
import { shipmentEmail, pickupReadyEmail } from "@/lib/email-templates"; import { shipmentEmail, pickupReadyEmail } from "@/lib/email-templates";
import { getCarrierTrackingUrl } from "@/lib/shipment-tracking"; import { getCarrierTrackingUrl } from "@/lib/shipment-tracking";
import { restockOrderItems } from "@/lib/inventory"; import { restockOrderItems } from "@/lib/inventory";
import { getStripeClient } from "@/lib/stripe";
import { isLocale, defaultLocale } from "@/i18n/config"; import { isLocale, defaultLocale } from "@/i18n/config";
import type { OrderStatus, PaymentStatus } from "@/generated/prisma/client";
import { requireAdmin, str, strOrNull, fail } from "./_shared"; import { requireAdmin, str, strOrNull, fail } from "./_shared";
const ORDER_STATUSES = [ const ORDER_STATUSES = [
@@ -21,6 +23,99 @@ const ORDER_STATUSES = [
"STORNIERT", "STORNIERT",
] as const; ] as const;
/**
* Erledigt die Geldseite einer gerade ausgesprochenen Stornierung.
*
* Zwei Dinge, die eine Statusaenderung allein offen laesst:
*
* 1. WAR SCHON BEZAHLT — dann muss das Geld zurueck. Ohne diesen Schritt
* stand die Bestellung als "storniert" da, die Ware lag wieder im Regal,
* und der Betrag blieb trotzdem beim Shop. Erstattet wird der volle
* Betrag; eine Teilerstattung ist ueber den Retouren-Bereich abzuwickeln,
* wo sie hingehoert.
*
* 2. WAR NOCH OFFEN ("NEU") — dann ist die Stripe-Sitzung unter Umstaenden
* noch bezahlbar. Sie muss geschlossen werden, sonst kann die Kundschaft
* Minuten spaeter noch zahlen: der Webhook findet die Bestellung dann
* nicht mehr im Status "NEU", bricht ab, und das Geld liegt bei Stripe,
* ohne dass im Shop irgendetwas davon zu sehen waere.
*
* Fehler werden nicht geworfen, sondern als Klartext zurueckgegeben. Die
* Stornierung ist an dieser Stelle schon geschrieben und die Ware
* zurueckgebucht — sie deswegen abzubrechen wuerde nichts heilen. Was offen
* bleibt, gehoert stattdessen sichtbar vor die Imkerei, damit sie es von Hand
* erledigen kann.
*
* @returns leere Liste, wenn alles erledigt ist; sonst je offener Punkt einen
* Satz fuer die Fehlermeldung im Admin-Bereich.
*/
async function settleCancelledOrder(order: {
orderId: string;
orderNumber: string;
previousStatus: OrderStatus;
stripeCheckoutSessionId: string | null;
payment: { status: PaymentStatus; stripePaymentIntentId: string | null } | null;
}): Promise<string[]> {
const warnings: string[] = [];
// Nur eine tatsaechlich eingezogene Zahlung wird erstattet. "FAILED" gab es
// nie zu erstatten, "REFUNDED" waere eine zweite Erstattung desselben
// Betrags, und "PENDING" ist noch gar nicht abgeschlossen.
if (order.payment?.status === "SUCCEEDED") {
const paymentIntentId = order.payment.stripePaymentIntentId;
if (!paymentIntentId) {
warnings.push(
"Zu dieser bezahlten Bestellung ist keine Stripe-Zahlung hinterlegt — die Erstattung muss von Hand angestossen werden."
);
} else {
try {
const stripe = getStripeClient();
await stripe.refunds.create({ payment_intent: paymentIntentId });
await prisma.payment.update({
where: { orderId: order.orderId },
data: { status: "REFUNDED" },
});
} catch (error: unknown) {
console.error(
`[order-cancel] Erstattung fuer Bestellung ${order.orderNumber} (PaymentIntent ${paymentIntentId}) fehlgeschlagen:`,
error
);
warnings.push(
"Die Erstattung ueber Stripe ist fehlgeschlagen — bitte im Stripe-Dashboard von Hand erstatten."
);
}
}
}
if (order.previousStatus === "NEU" && order.stripeCheckoutSessionId) {
try {
const stripe = getStripeClient();
const session = await stripe.checkout.sessions.retrieve(order.stripeCheckoutSessionId);
// Nur eine offene Sitzung laesst sich schliessen. "complete" bedeutet,
// dass in der Zwischenzeit doch bezahlt wurde — dann ist das hier ein
// Wettlauf, den die Zahlung gewonnen hat, und er gehoert gemeldet statt
// stillschweigend uebergangen.
if (session.status === "open") {
await stripe.checkout.sessions.expire(order.stripeCheckoutSessionId);
} else if (session.status === "complete") {
warnings.push(
"Achtung: Diese Bestellung wurde bei Stripe zwischenzeitlich doch bezahlt. Bitte den Zahlungseingang pruefen und die Erstattung von Hand anstossen."
);
}
} catch (error: unknown) {
console.error(
`[order-cancel] Zahlungssitzung ${order.stripeCheckoutSessionId} der Bestellung ${order.orderNumber} konnte nicht geschlossen werden:`,
error
);
warnings.push(
"Die offene Zahlungssitzung konnte nicht geschlossen werden — sie laeuft von selbst ab, bis dahin ist eine Zahlung noch moeglich."
);
}
}
return warnings;
}
export async function updateOrderStatusAction(formData: FormData): Promise<void> { export async function updateOrderStatusAction(formData: FormData): Promise<void> {
const admin = await requireAdmin(); const admin = await requireAdmin();
const orderId = str(formData, "orderId"); const orderId = str(formData, "orderId");
@@ -39,11 +134,19 @@ export async function updateOrderStatusAction(formData: FormData): Promise<void>
email: true, email: true,
firstName: true, firstName: true,
locale: true, locale: true,
stripeCheckoutSessionId: true,
payment: { select: { status: true, stripePaymentIntentId: true } },
items: { select: { productId: true, quantity: true } }, items: { select: { productId: true, quantity: true } },
}, },
}); });
if (!previous) return null; if (!previous) return null;
// Nur wahr, wenn DIESER Aufruf die Bestellung tatsaechlich storniert hat.
// Steuert weiter unten Erstattung und Schliessen der Zahlungssitzung —
// beides darf bei einem zweiten Klick auf eine bereits stornierte
// Bestellung nicht noch einmal passieren.
let cancelledNow = false;
if (status === "STORNIERT") { if (status === "STORNIERT") {
// Atomarer Claim statt einfachem update(): verhindert, dass zwei // Atomarer Claim statt einfachem update(): verhindert, dass zwei
// schnell aufeinanderfolgende Stornierungs-Versuche (z.B. zwei offene // schnell aufeinanderfolgende Stornierungs-Versuche (z.B. zwei offene
@@ -58,6 +161,7 @@ export async function updateOrderStatusAction(formData: FormData): Promise<void>
data: { status }, data: { status },
}); });
if (claim.count === 1) { if (claim.count === 1) {
cancelledNow = true;
await restockOrderItems(tx, previous.items); await restockOrderItems(tx, previous.items);
} }
} else { } else {
@@ -88,7 +192,7 @@ export async function updateOrderStatusAction(formData: FormData): Promise<void>
}); });
} }
return { ...previous, shipment }; return { ...previous, shipment, cancelledNow };
}); });
if (!result) { if (!result) {
@@ -103,6 +207,39 @@ export async function updateOrderStatusAction(formData: FormData): Promise<void>
detail: `${result.orderNumber}: ${result.status} → ${status}`, detail: `${result.orderNumber}: ${result.status} → ${status}`,
}); });
// Die Geldseite der Stornierung — bewusst NACH der Transaktion: ein
// Stripe-Aufruf dauert ein Vielfaches einer Datenbankoperation und wuerde
// die Zeilensperren auf Bestellung und Lagerbestand so lange halten.
//
// Bis hierher hat die Stornierung nur den Status gesetzt und die Ware
// zurueckgebucht. Ohne die beiden folgenden Schritte blieb eine
// Stornierung auf halbem Weg stehen: bei einer bezahlten Bestellung
// behielt der Shop das Geld, bei einer noch offenen blieb die
// Zahlungssitzung bezahlbar.
if (result.cancelledNow) {
const stornoWarnings = await settleCancelledOrder({
orderId,
orderNumber: result.orderNumber,
previousStatus: result.status,
stripeCheckoutSessionId: result.stripeCheckoutSessionId,
payment: result.payment,
});
if (stornoWarnings.length > 0) {
await logAdminAction(admin, {
action: "order.cancel_incomplete",
entityType: "Order",
entityId: orderId,
detail: `${result.orderNumber}: ${stornoWarnings.join(" | ")}`,
});
// Die Stornierung selbst steht und wird NICHT zurueckgenommen — die
// Ware ist bereits wieder im Bestand und die Absicht war eindeutig.
// Aber der offene Rest muss auf dem Bildschirm landen und nicht nur
// im Log, sonst haelt die Imkerei den Vorgang fuer abgeschlossen.
fail(detailPath, `Bestellung storniert, aber: ${stornoWarnings.join(" ")}`);
}
}
// E-Mail-Benachrichtigung bei Versand/Abholbereitschaft — bewusst nach // E-Mail-Benachrichtigung bei Versand/Abholbereitschaft — bewusst nach
// der Transaktion, ein Mail-Ausfall darf die Statusaenderung nicht // der Transaktion, ein Mail-Ausfall darf die Statusaenderung nicht
// rueckgaengig machen (sendMail wirft nie, siehe lib/mail.ts). // rueckgaengig machen (sendMail wirft nie, siehe lib/mail.ts).
+62 -12
View File
@@ -12,6 +12,7 @@ import { verifyTurnstileToken } from "@/lib/turnstile";
import { getShippingCostCents } from "@/lib/shipping"; import { getShippingCostCents } from "@/lib/shipping";
import { isDiscountCodeValid, calculateDiscountCents } from "@/lib/discount-calc"; import { isDiscountCodeValid, calculateDiscountCents } from "@/lib/discount-calc";
import { ensureBulkDiscountCode } from "@/lib/bulk-discount"; import { ensureBulkDiscountCode } from "@/lib/bulk-discount";
import { releaseUnpaidOrder } from "@/lib/order-release";
import { getBaseUrl } from "@/lib/url"; import { getBaseUrl } from "@/lib/url";
import { vatRateForProduct } from "@/lib/vat"; import { vatRateForProduct } from "@/lib/vat";
import { getLocale } from "@/i18n/locale"; import { getLocale } from "@/i18n/locale";
@@ -433,13 +434,20 @@ export async function POST(request: Request) {
metadata: { orderId: order.id, orderNumber: order.orderNumber }, metadata: { orderId: order.id, orderNumber: order.orderNumber },
}); });
// Best-effort: stripeCheckoutSessionId wird aktuell von keinem Lesepfad // KEIN Best-effort mehr: ohne hinterlegte Sitzungs-ID laesst sich die
// ausgewertet (fulfillOrder im Webhook identifiziert die Bestellung ueber // Bestellung auf keinem Weg mehr aufloesen. Der Abbruch-Klick
// session.metadata.orderId, nicht ueber dieses Feld) — schlaegt nur // (checkout/cancel) braucht sie, um bei Stripe nachzusehen, und der
// dieser Schreibzugriff fehl, waere es falsch, die bereits erstellte, // Aufraeumlauf (scripts/release-stale-orders.ts) findet ohne sie keine
// echte und bezahlbare Stripe-Session deswegen zu verwerfen und der // Sitzung, deren Ablauf er pruefen koennte. Die Bestellung bliebe als
// Kundschaft einen Fehler zu zeigen (Risiko: Retry legt eine zweite // "NEU" stehen und haette den reservierten Lagerbestand dauerhaft
// Bestellung samt zweiter Bestandsreservierung an). // blockiert — unbemerkt, weil sie im Admin-Bereich wie jede andere
// offene Bestellung aussieht.
//
// Schlaegt das Speichern fehl, wird deshalb die gerade erzeugte Sitzung
// sofort wieder geschlossen und die Bestellung aufgeloest (aeusseres
// catch). Dass dabei eine bezahlbare Sitzung verlorengeht, ist folgenlos:
// ihre URL steht ausschliesslich in der Antwort, die wir daraufhin NICHT
// ausliefern — niemand ausser diesem Prozess hat sie je gesehen.
try { try {
await prisma.order.update({ await prisma.order.update({
where: { id: order.id }, where: { id: order.id },
@@ -447,25 +455,67 @@ export async function POST(request: Request) {
}); });
} catch (error: unknown) { } catch (error: unknown) {
console.error( console.error(
`Speichern von stripeCheckoutSessionId fuer Bestellung ${order.orderNumber} fehlgeschlagen (Session ${session.id} bleibt trotzdem gueltig):`, `Speichern von stripeCheckoutSessionId fuer Bestellung ${order.orderNumber} fehlgeschlagen — Session ${session.id} wird geschlossen und die Bestellung aufgeloest:`,
error error
); );
// Eigenes try/catch: scheitert auch das Schliessen, bleibt der
// urspruengliche Fehler der wichtigere. Die Sitzung laeuft dann
// spaetestens nach CHECKOUT_SESSION_TTL_MINUTES von selbst ab.
try {
await stripe.checkout.sessions.expire(session.id);
} catch (expireError: unknown) {
console.error(`Schliessen der Session ${session.id} fehlgeschlagen:`, expireError);
}
throw error;
} }
return NextResponse.json({ url: session.url }); return NextResponse.json({ url: session.url });
} catch (error: unknown) { } catch (error: unknown) {
// Bestellung bleibt als "NEU" erhalten, damit sie im Admin-Bereich manuell
// nachbearbeitet werden kann, auch wenn Stripe (noch) nicht konfiguriert ist.
// Die Stripe-Fehlermeldung nur serverseitig loggen, nicht an den Client // Die Stripe-Fehlermeldung nur serverseitig loggen, nicht an den Client
// zurueckgeben — sie kann interne Konfigurations-/Kontodetails enthalten // zurueckgeben — sie kann interne Konfigurations-/Kontodetails enthalten
// und dieser Endpunkt ist ohne Authentifizierung erreichbar. // und dieser Endpunkt ist ohne Authentifizierung erreichbar.
const message = error instanceof Error ? error.message : "Unbekannter Fehler"; const message = error instanceof Error ? error.message : "Unbekannter Fehler";
console.error(`Checkout-Session fuer Bestellung ${order.orderNumber} fehlgeschlagen:`, message); console.error(`Checkout-Session fuer Bestellung ${order.orderNumber} fehlgeschlagen:`, message);
// Diese Bestellung ist nie bezahlbar geworden: es gibt keine Sitzung, zu
// der die Kundschaft geschickt werden koennte, und damit auch kein
// Ereignis von Stripe, das den reservierten Bestand je wieder freigeben
// wuerde. Er muss deshalb hier zurueck — sonst fehlt die Ware dauerhaft
// im Bestand, ohne dass irgendwo ein Fehler sichtbar waere.
//
// "cancel" statt "delete": die Bestellung bleibt als "STORNIERT" mit
// Notiz stehen. Anders als ein weggeklickter Warenkorb ist das ein
// Vorgang, dem die Imkerei nachgehen will — hier wollte jemand kaufen
// und ist an der Zahlungsanbindung gescheitert.
try {
const released = await releaseUnpaidOrder(order.id, "cancel");
if (released) {
await prisma.order.update({
where: { id: order.id },
data: {
notes:
"Automatisch storniert: Die Stripe-Zahlungssitzung konnte nicht erstellt werden, die Bestellung war nie bezahlbar. Der Lagerbestand wurde zurueckgebucht.",
},
});
}
} catch (releaseError: unknown) {
// Laut, aber nicht weiterwerfen: die Kundschaft bekommt unten ohnehin
// eine Fehlermeldung, und ein zusaetzlicher 500er wuerde nur die
// Ursache verdecken. Der Aufraeumlauf greift als zweites Netz.
console.error(
`Bestellung ${order.orderNumber} konnte nach dem Stripe-Fehler nicht aufgeloest werden — Lagerbestand bleibt vorerst reserviert:`,
releaseError
);
}
// Bewusst OHNE orderNumber: frueher schickte die Oberflaeche daraufhin auf
// die Erfolgsseite ("wir melden uns"). Das war eine Zusage, die niemand
// einloesen konnte — es gab keine Sitzung, keinen Zahlungsweg und keine
// Moeglichkeit, die Bestellung nachtraeglich zu bezahlen.
return NextResponse.json( return NextResponse.json(
{ {
error: error:
"Die Zahlungsabwicklung ist aktuell nicht verfügbar. Deine Bestellung wurde vorgemerkt, wir melden uns bei dir.", "Die Zahlung konnte nicht gestartet werden — es wurde nichts abgebucht. Bitte versuche es in ein paar Minuten erneut oder melde dich bei uns.",
orderNumber: order.orderNumber,
}, },
{ status: 502 } { status: 502 }
); );
+145 -6
View File
@@ -62,9 +62,14 @@ async function storeInvoiceDocument(invoiceNumber: string): Promise<Buffer | nul
/** /**
* Verarbeitet Stripe-Webhook-Events. In Stripe muss ein Webhook-Endpoint auf * Verarbeitet Stripe-Webhook-Events. In Stripe muss ein Webhook-Endpoint auf
* /api/checkout/webhook mit den Events "checkout.session.completed", * /api/checkout/webhook mit den Events "checkout.session.completed",
* "checkout.session.async_payment_succeeded", "checkout.session.async_payment_failed" * "checkout.session.async_payment_succeeded", "checkout.session.async_payment_failed",
* und "checkout.session.expired" angelegt und STRIPE_WEBHOOK_SECRET gesetzt * "checkout.session.expired", "charge.refunded" und "charge.dispute.created"
* werden. Die beiden async-Events sind fuer Zahlungsmethoden mit verzoegerter * angelegt und STRIPE_WEBHOOK_SECRET gesetzt
* werden. Die beiden letzten betreffen Geld, das den Shop wieder verlaesst,
* ohne dass hier jemand etwas angeklickt haette — eine Erstattung von Hand im
* Stripe-Dashboard und eine Rueckbuchung durch die Bank. Beide waren bisher
* ausschliesslich im Stripe-Dashboard zu sehen (siehe recordRefund und
* recordDispute). Die beiden async-Events sind fuer Zahlungsmethoden mit verzoegerter
* Bestaetigung (SEPA-Lastschrift, teils Klarna) noetig — dort ist die * Bestaetigung (SEPA-Lastschrift, teils Klarna) noetig — dort ist die
* Checkout-Sitzung bereits abgeschlossen, aber die Zahlung selbst noch nicht * Checkout-Sitzung bereits abgeschlossen, aber die Zahlung selbst noch nicht
* (siehe fulfillOrder/failOrder unten). "expired" faengt den Fall ab, dass * (siehe fulfillOrder/failOrder unten). "expired" faengt den Fall ab, dass
@@ -129,9 +134,123 @@ export async function POST(request: Request) {
await failOrder(session, "delete"); await failOrder(session, "delete");
} }
if (event.type === "charge.refunded") {
await recordRefund(event.data.object as Stripe.Charge);
}
if (event.type === "charge.dispute.created") {
await recordDispute(event.data.object as Stripe.Dispute);
}
return NextResponse.json({ received: true }); return NextResponse.json({ received: true });
} }
/** Die PaymentIntent-ID eines Stripe-Objekts, gleich ob sie als ID oder als eingebettetes Objekt kommt. */
function paymentIntentIdOf(value: string | { id: string } | null | undefined): string | null {
if (!value) return null;
return typeof value === "string" ? value : value.id;
}
/**
* Haelt eine Erstattung fest, die AUSSERHALB dieser Anwendung ausgeloest
* wurde — typischerweise von Hand im Stripe-Dashboard.
*
* Der Weg ueber den Retouren-Bereich und die Stornierung im Admin-Bereich
* schreiben den Zahlungsstatus selbst mit. Eine Erstattung direkt bei Stripe
* tat das bisher nicht: die Zahlung stand im Shop weiter auf "SUCCEEDED", und
* jede spaetere Auswertung zaehlte einen Umsatz mit, den es nicht mehr gab.
*
* Der Bestellstatus bleibt unangetastet. Ob eine Erstattung eine Stornierung
* bedeutet, eine Retoure oder eine Kulanzgutschrift bei gelieferter Ware,
* steht an diesem Ereignis nicht — und Ware automatisch in den Bestand
* zurueckzubuchen, die vielleicht nie zurueckkommt, waere schlimmer als sie
* von Hand einzubuchen.
*/
async function recordRefund(charge: Stripe.Charge): Promise<void> {
const paymentIntentId = paymentIntentIdOf(charge.payment_intent);
if (!paymentIntentId) return;
const payment = await prisma.payment.findFirst({
where: { stripePaymentIntentId: paymentIntentId },
select: { id: true, status: true, order: { select: { orderNumber: true } } },
});
if (!payment) {
console.warn(
`[checkout] Erstattung zu PaymentIntent ${paymentIntentId} erhalten, zu der es hier keine Zahlung gibt — nichts zu tun.`
);
return;
}
// "refunded" ist nur bei einer VOLLSTAENDIGEN Erstattung wahr. Eine
// Teilerstattung darf die Zahlung nicht auf "REFUNDED" setzen: der
// Restbetrag ist weiterhin eingezogen, und eine Auswertung wuerde ihn sonst
// ganz abschreiben.
if (!charge.refunded) {
console.warn(
`[checkout] Teilerstattung bei Bestellung ${payment.order.orderNumber}: ${charge.amount_refunded} von ${charge.amount} Cent. Der Zahlungsstatus bleibt "${payment.status}" — bitte die Rechnung von Hand korrigieren.`
);
return;
}
if (payment.status === "REFUNDED") return;
await prisma.payment.update({ where: { id: payment.id }, data: { status: "REFUNDED" } });
console.info(
`[checkout] Bestellung ${payment.order.orderNumber}: vollstaendige Erstattung ueber ${charge.amount_refunded} Cent vermerkt.`
);
}
/**
* Vermerkt eine Rueckbuchung (Chargeback) an der Bestellung.
*
* Eine Rueckbuchung hat eine Frist: Stripe verlangt die Stellungnahme
* innerhalb weniger Tage, sonst gilt sie als verloren. Bisher kam davon im
* Shop nichts an — sie war ausschliesslich im Stripe-Dashboard zu sehen, und
* nur wer dort nachsieht, haette sie rechtzeitig bemerkt.
*
* Automatisch entschieden wird hier nichts: Status und Lagerbestand bleiben,
* wie sie sind. Der Vermerk landet in den Notizen der Bestellung, wo er im
* Admin-Bereich neben allem anderen zu dieser Bestellung steht.
*/
async function recordDispute(dispute: Stripe.Dispute): Promise<void> {
const paymentIntentId = paymentIntentIdOf(dispute.payment_intent);
if (!paymentIntentId) return;
const payment = await prisma.payment.findFirst({
where: { stripePaymentIntentId: paymentIntentId },
select: { orderId: true, order: { select: { orderNumber: true, notes: true } } },
});
if (!payment) {
console.error(
`[checkout] RUECKBUCHUNG zu PaymentIntent ${paymentIntentId} erhalten, zu der es hier keine Zahlung gibt (Dispute ${dispute.id}). Bitte im Stripe-Dashboard pruefen.`
);
return;
}
console.error(
`[checkout] RUECKBUCHUNG bei Bestellung ${payment.order.orderNumber}: ${dispute.amount} Cent, Grund "${dispute.reason}" (Dispute ${dispute.id}). Fristgebundene Stellungnahme im Stripe-Dashboard noetig.`
);
const note = `[${new Date().toISOString()}] Rueckbuchung ueber ${dispute.amount} Cent, Grund "${dispute.reason}" (Stripe-Dispute ${dispute.id}). Stellungnahme im Stripe-Dashboard ist fristgebunden.`;
try {
await prisma.order.update({
where: { id: payment.orderId },
// Anhaengen statt ersetzen: bestehende Notizen der Imkerei duerfen
// dabei nicht verlorengehen.
data: { notes: payment.order.notes ? `${payment.order.notes}\n${note}` : note },
});
} catch (error: unknown) {
// Nicht weiterwerfen: der Logeintrag oben ist die wichtigere Meldung, und
// ein 500er wuerde Stripe nur zu Wiederholungen veranlassen, die denselben
// Vermerk erneut anhaengen.
console.error(
`[checkout] Rueckbuchungs-Vermerk an Bestellung ${payment.order.orderNumber} konnte nicht gespeichert werden:`,
error
);
}
}
async function fulfillOrder(session: Stripe.Checkout.Session): Promise<void> { async function fulfillOrder(session: Stripe.Checkout.Session): Promise<void> {
const orderId = session.metadata?.orderId; const orderId = session.metadata?.orderId;
if (!orderId) return; if (!orderId) return;
@@ -164,9 +283,29 @@ async function fulfillOrder(session: Stripe.Checkout.Session): Promise<void> {
if (!firstTime) { if (!firstTime) {
// Entweder ein Retry, der die Bestellung bereits "BEZAHLT" vorfindet // Entweder ein Retry, der die Bestellung bereits "BEZAHLT" vorfindet
// (siehe Kommentar oben bei fulfillOrder), oder ein anderer Endstatus // (siehe Kommentar oben bei fulfillOrder), oder ein anderer Endstatus
// (z.B. "STORNIERT") — im zweiten Fall nichts weiter tun. // (z.B. "STORNIERT").
const existing = await tx.order.findUnique({ where: { id: orderId }, select: { status: true } }); const existing = await tx.order.findUnique({
if (!existing || existing.status !== "BEZAHLT") return null; where: { id: orderId },
select: { status: true, orderNumber: true },
});
if (!existing) {
// Die Bestellung ist weg, das Geld ist da. Moeglich, wenn eine als
// abgelaufen aufgeloeste Bestellung danach doch noch bezahlt wurde.
console.error(
`[checkout] Zahlung eingegangen fuer eine nicht mehr vorhandene Bestellung (Order ${orderId}, Session ${session.id}). Der Betrag liegt bei Stripe und muss von Hand erstattet oder zugeordnet werden.`
);
return null;
}
if (existing.status !== "BEZAHLT") {
// Frueher ein stilles "return null": die Zahlung war eingezogen, im
// Shop entstand aber weder Rechnung noch Bestaetigung noch ein
// Logeintrag. Genau der Fall, der niemandem auffaellt, bis sich die
// Kundschaft meldet.
console.error(
`[checkout] Zahlung eingegangen fuer Bestellung ${existing.orderNumber}, die auf "${existing.status}" steht (Session ${session.id}). Es wird nichts ausgeliefert und nichts berechnet — bitte pruefen und gegebenenfalls erstatten.`
);
return null;
}
} }
const updated = await tx.order.findUniqueOrThrow({ const updated = await tx.order.findUniqueOrThrow({
+50 -3
View File
@@ -1,5 +1,5 @@
import { describe, test, expect, beforeEach, vi } from "vitest"; import { describe, test, expect, beforeEach, afterEach, vi } from "vitest";
import { render, screen } from "@testing-library/react"; import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { CheckoutForm } from "./checkout-form"; import { CheckoutForm } from "./checkout-form";
import { useCartStore, type CartItem } from "@/store/cart-store"; import { useCartStore, type CartItem } from "@/store/cart-store";
@@ -7,10 +7,15 @@ import { I18nProvider } from "@/i18n/i18n-provider";
import type { ShippingTierLike } from "@/lib/shipping-calc"; import type { ShippingTierLike } from "@/lib/shipping-calc";
import type { BulkDiscountTierLike } from "@/lib/bulk-discount-calc"; import type { BulkDiscountTierLike } from "@/lib/bulk-discount-calc";
// Geteilte Instanz statt eines frischen vi.fn() je Aufruf: nur so laesst sich
// pruefen, dass NICHT navigiert wurde.
const routerPush = vi.fn();
vi.mock("next/navigation", () => ({ vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn() }), useRouter: () => ({ push: routerPush }),
})); }));
const fetchMock = vi.fn();
const tiers: ShippingTierLike[] = [{ minQuantity: 0, maxQuantity: null, priceCents: 700 }]; const tiers: ShippingTierLike[] = [{ minQuantity: 0, maxQuantity: null, priceCents: 700 }];
const bulkDiscountTiers: BulkDiscountTierLike[] = []; const bulkDiscountTiers: BulkDiscountTierLike[] = [];
@@ -36,6 +41,13 @@ function renderCheckout(items: CartItem[]) {
beforeEach(() => { beforeEach(() => {
useCartStore.setState({ items: [], isOpen: false }); useCartStore.setState({ items: [], isOpen: false });
routerPush.mockClear();
fetchMock.mockReset();
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
}); });
describe("CheckoutForm", () => { describe("CheckoutForm", () => {
@@ -55,4 +67,39 @@ describe("CheckoutForm", () => {
expect(screen.queryByText("7,00 €")).not.toBeInTheDocument(); expect(screen.queryByText("7,00 €")).not.toBeInTheDocument();
expect(screen.getByText("kostenlos")).toBeInTheDocument(); expect(screen.getByText("kostenlos")).toBeInTheDocument();
}); });
/**
* Regression: Antwortet /api/checkout mit 502 (Stripe nicht erreichbar),
* schickte das Formular frueher auf die Erfolgsseite mit dem Hinweis "wir
* melden uns". Es gab zu dieser Bestellung aber gar keine Zahlungssitzung —
* sie war auf keinem Weg bezahlbar, und die Seite versprach etwas, das
* niemand einloesen konnte.
*/
test("shows the server error instead of the success page when the payment session cannot be created", async () => {
const userEventInstance = userEvent.setup();
const serverError =
"Die Zahlung konnte nicht gestartet werden — es wurde nichts abgebucht. Bitte versuche es in ein paar Minuten erneut oder melde dich bei uns.";
fetchMock.mockResolvedValue({
ok: false,
status: 502,
json: async () => ({ error: serverError }),
} as Response);
renderCheckout([item()]);
await userEventInstance.type(screen.getByLabelText(/vorname/i), "Erika");
await userEventInstance.type(screen.getByLabelText(/nachname/i), "Mustermann");
await userEventInstance.type(screen.getByLabelText(/e-mail/i), "[email protected]");
await userEventInstance.type(screen.getByLabelText(/straße|strasse/i), "Wiesenweg 1");
await userEventInstance.type(screen.getByLabelText(/plz|postleitzahl/i), "86450");
await userEventInstance.type(screen.getByLabelText(/ort|stadt/i), "Altenmünster");
await userEventInstance.click(screen.getByRole("button", { name: /kostenpflichtig bestellen/i }));
await waitFor(() => expect(screen.getByText(serverError)).toBeInTheDocument());
// Das Formular navigiert inzwischen gar nicht mehr — diese Zusicherung
// faellt erst dann wieder auf, wenn jemand die Weiterleitung auf die
// Erfolgsseite erneut einbaut. Genau dafuer steht sie hier.
expect(routerPush).not.toHaveBeenCalled();
});
}); });
+5 -6
View File
@@ -1,7 +1,6 @@
"use client"; "use client";
import { useEffect, useRef, useState, type FormEvent, type HTMLAttributes } from "react"; import { useEffect, useRef, useState, type FormEvent, type HTMLAttributes } from "react";
import { useRouter } from "next/navigation";
import { Container, SectionHeading, Button } from "@/components/ui"; import { Container, SectionHeading, Button } from "@/components/ui";
import { TrustBadges } from "@/components/trust-badges"; import { TrustBadges } from "@/components/trust-badges";
import { useCartStore, cartTotalCents, cartItemCount } from "@/store/cart-store"; import { useCartStore, cartTotalCents, cartItemCount } from "@/store/cart-store";
@@ -61,7 +60,6 @@ export function CheckoutForm({
const hasHydrated = useCartStore((s) => s.hasHydrated); const hasHydrated = useCartStore((s) => s.hasHydrated);
const subtotal = cartTotalCents(items); const subtotal = cartTotalCents(items);
const totalQuantity = cartItemCount(items); const totalQuantity = cartItemCount(items);
const router = useRouter();
// Der Checkout schickt JSON statt eines Formulars, deshalb das Widget mit // Der Checkout schickt JSON statt eines Formulars, deshalb das Widget mit
// Token-Rueckgabe statt eines versteckten Feldes. // Token-Rueckgabe statt eines versteckten Feldes.
@@ -179,10 +177,11 @@ export function CheckoutForm({
const data = await response.json(); const data = await response.json();
if (!response.ok) { if (!response.ok) {
if (data.orderNumber) { // Auch der 502er (Stripe nicht erreichbar) landet hier als normale
router.push(`/checkout/success?order=${data.orderNumber}&pending=1`); // Fehlermeldung. Frueher fuehrte er auf die Erfolgsseite mit dem
return; // 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); setError(data.error ?? t.checkoutPage.genericError);
setSubmitting(false); setSubmitting(false);
return; return;
+3 -1
View File
@@ -56,7 +56,9 @@ export const de: Dictionary = {
viewCart: "Warenkorb ansehen", viewCart: "Warenkorb ansehen",
}, },
orderStatusBadge: { orderStatusBadge: {
NEU: "Neu", // Nicht "Neu": dieser Status bedeutet, dass die Zahlung noch nicht
// bestaetigt ist — bei einem abgebrochenen Checkout bleibt sie es auch.
NEU: "Zahlung ausstehend",
BEZAHLT: "Bezahlt", BEZAHLT: "Bezahlt",
IN_VORBEREITUNG: "In Vorbereitung", IN_VORBEREITUNG: "In Vorbereitung",
VERSANDBEREIT: "Versandbereit", VERSANDBEREIT: "Versandbereit",
+3 -1
View File
@@ -56,7 +56,9 @@ export const en: Dictionary = {
viewCart: "View cart", viewCart: "View cart",
}, },
orderStatusBadge: { orderStatusBadge: {
NEU: "New", // Not "New": this status means the payment has not been confirmed yet —
// after an abandoned checkout it never will be.
NEU: "Awaiting payment",
BEZAHLT: "Paid", BEZAHLT: "Paid",
IN_VORBEREITUNG: "Preparing", IN_VORBEREITUNG: "Preparing",
VERSANDBEREIT: "Ready to ship", VERSANDBEREIT: "Ready to ship",
+22 -1
View File
@@ -10,9 +10,30 @@ export type OrderWithDetails = Order & {
returnRequest: ReturnRequest | null; returnRequest: ReturnRequest | null;
}; };
/**
* Bestellungen eines Kontos — OHNE die noch unbezahlten.
*
* Eine Bestellung entsteht bereits beim Start des Checkouts und steht bis zur
* bestaetigten Zahlung auf "NEU". Die allermeisten dieser Datensaetze sind
* keine Bestellungen, sondern abgebrochene Kaufversuche: geschlossener Tab,
* Zurueck-Knopf, weggelegtes Handy. Sie verschwinden von selbst wieder (siehe
* lib/order-release.ts), aber bis dahin vergehen je nach Weg bis zu 45
* Minuten.
*
* In dieser Zeit standen sie hier wie vollwertige Bestellungen in der Liste —
* mit Bestellnummer, Summe und Statuskennzeichen. Wer den Kauf gerade
* abgebrochen hatte, fand ihn also im Konto trotzdem als Bestellung wieder
* und musste annehmen, dass doch etwas zustande gekommen ist.
*
* Bezahlte Bestellungen sind davon nicht betroffen: der Webhook setzt sie
* binnen Sekunden auf "BEZAHLT", danach erscheinen sie hier wie gewohnt.
* "STORNIERT" bleibt bewusst sichtbar — dort gab es einen Vorgang, den die
* Kundschaft nachvollziehen koennen muss (fehlgeschlagener Einzug, Storno
* durch die Imkerei).
*/
export async function getOrdersForCustomer(customerId: string): Promise<OrderWithDetails[]> { export async function getOrdersForCustomer(customerId: string): Promise<OrderWithDetails[]> {
return prisma.order.findMany({ return prisma.order.findMany({
where: { customerId }, where: { customerId, status: { not: "NEU" } },
include: { include: {
items: { include: { product: { select: { slug: true } } } }, items: { include: { product: { select: { slug: true } } } },
shipment: true, shipment: true,