diff --git a/README.md b/README.md index 75c31c9..4dd1724 100644 --- a/README.md +++ b/README.md @@ -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.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. @@ -172,11 +177,26 @@ Eine Bestellung entsteht **vor** der Zahlung und reserviert dabei sofort Lagerbe | Klick auf „Abbrechen“ bei Stripe | `/checkout/cancel?ref=` 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 | | 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. 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/` 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 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. diff --git a/scripts/preflight.ts b/scripts/preflight.ts index f34a295..7c39aeb 100644 --- a/scripts/preflight.ts +++ b/scripts/preflight.ts @@ -238,6 +238,13 @@ const REQUIRED_WEBHOOK_EVENTS = [ "checkout.session.async_payment_succeeded", "checkout.session.async_payment_failed", "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; async function checkStripeWebhookEvents(): Promise { @@ -266,7 +273,7 @@ async function checkStripeWebhookEvents(): Promise { } must( - "Stripe-Webhook abonniert alle vier benoetigten Ereignisse", + `Stripe-Webhook abonniert alle ${REQUIRED_WEBHOOK_EVENTS.length} benoetigten Ereignisse`, 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.` ); diff --git a/scripts/release-stale-orders.ts b/scripts/release-stale-orders.ts index 19236aa..f7e36dc 100644 --- a/scripts/release-stale-orders.ts +++ b/scripts/release-stale-orders.ts @@ -44,11 +44,15 @@ const MAX_ORDERS_PER_RUN = 200; async function main(): Promise { 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({ where: { status: "NEU", createdAt: { lt: cutoff }, - stripeCheckoutSessionId: { not: null }, }, select: { id: true, orderNumber: true, stripeCheckoutSessionId: true }, orderBy: { createdAt: "asc" }, @@ -72,7 +76,25 @@ async function main(): Promise { for (const order of candidates) { 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") { // Bezahlt, aber der Status steht noch auf "NEU": der diff --git a/src/app/admin/actions/orders.ts b/src/app/admin/actions/orders.ts index b2ecf00..b4ab5da 100644 --- a/src/app/admin/actions/orders.ts +++ b/src/app/admin/actions/orders.ts @@ -7,7 +7,9 @@ import { sendMail } from "@/lib/mail"; import { shipmentEmail, pickupReadyEmail } from "@/lib/email-templates"; import { getCarrierTrackingUrl } from "@/lib/shipment-tracking"; import { restockOrderItems } from "@/lib/inventory"; +import { getStripeClient } from "@/lib/stripe"; import { isLocale, defaultLocale } from "@/i18n/config"; +import type { OrderStatus, PaymentStatus } from "@/generated/prisma/client"; import { requireAdmin, str, strOrNull, fail } from "./_shared"; const ORDER_STATUSES = [ @@ -21,6 +23,99 @@ const ORDER_STATUSES = [ "STORNIERT", ] 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 { + 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 { const admin = await requireAdmin(); const orderId = str(formData, "orderId"); @@ -39,11 +134,19 @@ export async function updateOrderStatusAction(formData: FormData): Promise email: true, firstName: true, locale: true, + stripeCheckoutSessionId: true, + payment: { select: { status: true, stripePaymentIntentId: true } }, items: { select: { productId: true, quantity: true } }, }, }); 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") { // Atomarer Claim statt einfachem update(): verhindert, dass zwei // schnell aufeinanderfolgende Stornierungs-Versuche (z.B. zwei offene @@ -58,6 +161,7 @@ export async function updateOrderStatusAction(formData: FormData): Promise data: { status }, }); if (claim.count === 1) { + cancelledNow = true; await restockOrderItems(tx, previous.items); } } else { @@ -88,7 +192,7 @@ export async function updateOrderStatusAction(formData: FormData): Promise }); } - return { ...previous, shipment }; + return { ...previous, shipment, cancelledNow }; }); if (!result) { @@ -103,6 +207,39 @@ export async function updateOrderStatusAction(formData: FormData): Promise 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 // der Transaktion, ein Mail-Ausfall darf die Statusaenderung nicht // rueckgaengig machen (sendMail wirft nie, siehe lib/mail.ts). diff --git a/src/app/api/checkout/route.ts b/src/app/api/checkout/route.ts index 0019d03..4b7faa5 100644 --- a/src/app/api/checkout/route.ts +++ b/src/app/api/checkout/route.ts @@ -12,6 +12,7 @@ import { verifyTurnstileToken } from "@/lib/turnstile"; import { getShippingCostCents } from "@/lib/shipping"; import { isDiscountCodeValid, calculateDiscountCents } from "@/lib/discount-calc"; import { ensureBulkDiscountCode } from "@/lib/bulk-discount"; +import { releaseUnpaidOrder } from "@/lib/order-release"; import { getBaseUrl } from "@/lib/url"; import { vatRateForProduct } from "@/lib/vat"; import { getLocale } from "@/i18n/locale"; @@ -433,13 +434,20 @@ export async function POST(request: Request) { metadata: { orderId: order.id, orderNumber: order.orderNumber }, }); - // Best-effort: stripeCheckoutSessionId wird aktuell von keinem Lesepfad - // ausgewertet (fulfillOrder im Webhook identifiziert die Bestellung ueber - // session.metadata.orderId, nicht ueber dieses Feld) — schlaegt nur - // dieser Schreibzugriff fehl, waere es falsch, die bereits erstellte, - // echte und bezahlbare Stripe-Session deswegen zu verwerfen und der - // Kundschaft einen Fehler zu zeigen (Risiko: Retry legt eine zweite - // Bestellung samt zweiter Bestandsreservierung an). + // KEIN Best-effort mehr: ohne hinterlegte Sitzungs-ID laesst sich die + // Bestellung auf keinem Weg mehr aufloesen. Der Abbruch-Klick + // (checkout/cancel) braucht sie, um bei Stripe nachzusehen, und der + // Aufraeumlauf (scripts/release-stale-orders.ts) findet ohne sie keine + // Sitzung, deren Ablauf er pruefen koennte. Die Bestellung bliebe als + // "NEU" stehen und haette den reservierten Lagerbestand dauerhaft + // 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 { await prisma.order.update({ where: { id: order.id }, @@ -447,25 +455,67 @@ export async function POST(request: Request) { }); } catch (error: unknown) { 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 ); + // 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 }); } 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 // zurueckgeben — sie kann interne Konfigurations-/Kontodetails enthalten // und dieser Endpunkt ist ohne Authentifizierung erreichbar. const message = error instanceof Error ? error.message : "Unbekannter Fehler"; 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( { error: - "Die Zahlungsabwicklung ist aktuell nicht verfügbar. Deine Bestellung wurde vorgemerkt, wir melden uns bei dir.", - orderNumber: order.orderNumber, + "Die Zahlung konnte nicht gestartet werden — es wurde nichts abgebucht. Bitte versuche es in ein paar Minuten erneut oder melde dich bei uns.", }, { status: 502 } ); diff --git a/src/app/api/checkout/webhook/route.ts b/src/app/api/checkout/webhook/route.ts index 5408e47..e42f7e1 100644 --- a/src/app/api/checkout/webhook/route.ts +++ b/src/app/api/checkout/webhook/route.ts @@ -62,9 +62,14 @@ async function storeInvoiceDocument(invoiceNumber: string): Promise { + 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 { + 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 { const orderId = session.metadata?.orderId; if (!orderId) return; @@ -164,9 +283,29 @@ async function fulfillOrder(session: Stripe.Checkout.Session): Promise { if (!firstTime) { // Entweder ein Retry, der die Bestellung bereits "BEZAHLT" vorfindet // (siehe Kommentar oben bei fulfillOrder), oder ein anderer Endstatus - // (z.B. "STORNIERT") — im zweiten Fall nichts weiter tun. - const existing = await tx.order.findUnique({ where: { id: orderId }, select: { status: true } }); - if (!existing || existing.status !== "BEZAHLT") return null; + // (z.B. "STORNIERT"). + const existing = await tx.order.findUnique({ + 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({ diff --git a/src/app/checkout/checkout-form.test.tsx b/src/app/checkout/checkout-form.test.tsx index cfcde76..c7af346 100644 --- a/src/app/checkout/checkout-form.test.tsx +++ b/src/app/checkout/checkout-form.test.tsx @@ -1,5 +1,5 @@ -import { describe, test, expect, beforeEach, vi } from "vitest"; -import { render, screen } from "@testing-library/react"; +import { describe, test, expect, beforeEach, afterEach, vi } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { CheckoutForm } from "./checkout-form"; 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 { 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", () => ({ - useRouter: () => ({ push: vi.fn() }), + useRouter: () => ({ push: routerPush }), })); +const fetchMock = vi.fn(); + const tiers: ShippingTierLike[] = [{ minQuantity: 0, maxQuantity: null, priceCents: 700 }]; const bulkDiscountTiers: BulkDiscountTierLike[] = []; @@ -36,6 +41,13 @@ function renderCheckout(items: CartItem[]) { beforeEach(() => { useCartStore.setState({ items: [], isOpen: false }); + routerPush.mockClear(); + fetchMock.mockReset(); + vi.stubGlobal("fetch", fetchMock); +}); + +afterEach(() => { + vi.unstubAllGlobals(); }); describe("CheckoutForm", () => { @@ -55,4 +67,39 @@ describe("CheckoutForm", () => { expect(screen.queryByText("7,00 €")).not.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), "erika@example.org"); + 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(); + }); }); diff --git a/src/app/checkout/checkout-form.tsx b/src/app/checkout/checkout-form.tsx index e48b134..ee97725 100644 --- a/src/app/checkout/checkout-form.tsx +++ b/src/app/checkout/checkout-form.tsx @@ -1,7 +1,6 @@ "use client"; import { useEffect, useRef, useState, type FormEvent, type HTMLAttributes } from "react"; -import { useRouter } from "next/navigation"; import { Container, SectionHeading, Button } from "@/components/ui"; import { TrustBadges } from "@/components/trust-badges"; import { useCartStore, cartTotalCents, cartItemCount } from "@/store/cart-store"; @@ -61,7 +60,6 @@ export function CheckoutForm({ const hasHydrated = useCartStore((s) => s.hasHydrated); const subtotal = cartTotalCents(items); const totalQuantity = cartItemCount(items); - const router = useRouter(); // Der Checkout schickt JSON statt eines Formulars, deshalb das Widget mit // Token-Rueckgabe statt eines versteckten Feldes. @@ -179,10 +177,11 @@ export function CheckoutForm({ const data = await response.json(); if (!response.ok) { - if (data.orderNumber) { - router.push(`/checkout/success?order=${data.orderNumber}&pending=1`); - return; - } + // 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; diff --git a/src/i18n/dictionaries/de.ts b/src/i18n/dictionaries/de.ts index e3d3d2c..24df63f 100644 --- a/src/i18n/dictionaries/de.ts +++ b/src/i18n/dictionaries/de.ts @@ -56,7 +56,9 @@ export const de: Dictionary = { viewCart: "Warenkorb ansehen", }, 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", IN_VORBEREITUNG: "In Vorbereitung", VERSANDBEREIT: "Versandbereit", diff --git a/src/i18n/dictionaries/en.ts b/src/i18n/dictionaries/en.ts index aeea6b2..e7e6bbd 100644 --- a/src/i18n/dictionaries/en.ts +++ b/src/i18n/dictionaries/en.ts @@ -56,7 +56,9 @@ export const en: Dictionary = { viewCart: "View cart", }, 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", IN_VORBEREITUNG: "Preparing", VERSANDBEREIT: "Ready to ship", diff --git a/src/lib/account-data.ts b/src/lib/account-data.ts index 6ed37d6..606286d 100644 --- a/src/lib/account-data.ts +++ b/src/lib/account-data.ts @@ -10,9 +10,30 @@ export type OrderWithDetails = Order & { 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 { return prisma.order.findMany({ - where: { customerId }, + where: { customerId, status: { not: "NEU" } }, include: { items: { include: { product: { select: { slug: true } } } }, shipment: true,