diff --git a/Dockerfile b/Dockerfile index 2f0eef1..293ffce 100644 --- a/Dockerfile +++ b/Dockerfile @@ -72,8 +72,13 @@ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static COPY --from=builder /app/prisma ./prisma COPY prisma.config.ts ./ COPY scripts ./scripts -COPY src/lib/db-adapter.ts ./src/lib/db-adapter.ts -COPY src/lib/password-breach-check.ts ./src/lib/password-breach-check.ts +# Das ganze lib-Verzeichnis statt einzelner Dateien: neben prisma/seed.ts +# laeuft im Container auch scripts/release-stale-orders.ts (CronJob, siehe +# k8s/07-backup-cronjob.yaml), das ueber lib/order-release.ts weitere Module +# nachzieht. Einzeln aufgezaehlt faellt so ein Lauf erst im Cluster mit +# "Cannot find module" auf. Die Dateien liegen nur herum — der Next.js-Server +# nutzt ausschliesslich seinen gebuendelten Standalone-Output. +COPY src/lib ./src/lib COPY --from=builder /app/src/generated ./src/generated # Migrations-/Seed-Pakete (prisma-CLI/tsx/dotenv) DIREKT ins selbe # node_modules wie der Next.js-Standalone-Output kopieren (ueberlagert dort diff --git a/README.md b/README.md index 9859a4b..04a8797 100644 --- a/README.md +++ b/README.md @@ -161,6 +161,24 @@ SEPA-Lastschrift und teils Klarna bestätigen die Zahlung **verzögert** (bei SE Für einen produktiven Webhook-Endpoint müssen alle vier Events abonniert werden; `stripe listen` leitet lokal ohnehin alle Events weiter. +> **`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. + +### Abgebrochener Checkout + +Eine Bestellung entsteht **vor** der Zahlung und reserviert dabei sofort Lagerbestand — sonst könnte zwischen Kaufentscheidung und Zahlung jemand anders dasselbe Glas kaufen. Der Preis dafür: jeder nicht zu Ende geführte Checkout hält Ware fest, die niemand bezahlt hat. Drei Wege geben sie wieder frei: + +| Wann | Was passiert | +|------|--------------| +| Klick auf „Abbrechen“ bei Stripe | `/checkout/cancel?ref=` schließt die Stripe-Sitzung und bucht den Bestand **sofort** zurück | +| Checkout einfach verlassen | Die Sitzung läuft nach **32 Minuten** ab, Stripe schickt `checkout.session.expired`, der Webhook bucht zurück | +| 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 | + +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 — Stripe lässt als Untergrenze 30 Minuten zu, die zwei Minuten Aufschlag sind Puffer gegen auseinanderlaufende Uhren. + +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. + ## Versandkosten Die Versandkosten richten sich **nicht** nach dem Bestellwert (kein Freibetrag), sondern nach einer im Admin-Bereich unter `/admin/versand` gepflegten Mengenstaffel — die Gesamtmenge aller Positionen im Warenkorb wird dabei zusammengezählt, mehrere verschiedene Produkte also gemeinsam bewertet statt einzeln berechnet. Ab Werk sind drei Staffeln angelegt (0–3 Stück: 7,00 €, 4–9 Stück: 8,50 €, 10–19 Stück: 11,00 €), die jederzeit im Admin-Bereich angepasst, gelöscht oder um weitere Stufen ergänzt werden können — praktisch, wenn sich die Kosten der Versanddienstleister ändern. Eine Staffel ohne "Bis"-Wert gilt als offene, oberste Stufe ("ab X Stück"). Sind gar keine Staffeln gepflegt, greift ein fester Sicherheits-Fallback von 4,90 €, statt den Checkout zu blockieren. Abholung in Altenmünster ist immer versandkostenfrei. Änderungen wirken sich sofort auf Warenkorb, Checkout und die öffentliche Seite `/versand-zahlung` aus. diff --git a/k8s/07-backup-cronjob.yaml b/k8s/07-backup-cronjob.yaml index 2f2ea72..d59cae2 100644 --- a/k8s/07-backup-cronjob.yaml +++ b/k8s/07-backup-cronjob.yaml @@ -157,3 +157,94 @@ spec: - name: invoices persistentVolumeClaim: claimName: wabenhain-invoices +--- +# Aufraeumlauf fuer liegengebliebene, nie bezahlte Bestellungen. +# +# WARUM: eine Bestellung entsteht VOR der Zahlung und reserviert dabei sofort +# Lagerbestand — sonst koennte zwischen Kaufentscheidung und Zahlung jemand +# anders dasselbe Glas kaufen. Zurueck kommt der Bestand normalerweise ueber +# zwei Wege: den Abbruch-Knopf bei Stripe (/checkout/cancel) und das Ereignis +# checkout.session.expired, das Stripe an den Webhook schickt. +# +# Dieser CronJob ist das Netz unter beiden. Er greift genau dann, wenn der +# Webhook nicht ankommt: das Ereignis ist im Stripe-Dashboard nicht abonniert, +# der Endpunkt war beim Zustellversuch nicht erreichbar, oder die Versuche +# sind abgelaufen. Ohne ihn bliebe der reservierte Bestand dauerhaft +# blockiert — und zwar unsichtbar, denn eine solche Bestellung sieht im +# Admin-Bereich aus wie jede andere offene. +# +# Gefragt wird immer Stripe, nie die Uhr allein: zurueckgebucht wird nur, was +# dort nachweislich abgelaufen ist (siehe scripts/release-stale-orders.ts). +# +# Der Lauf endet mit Exit-Code 1, wenn er eine Bestellung findet, die bei +# Stripe bezahlt ist, hier aber noch auf "NEU" steht. Das ist Absicht: dieser +# Fall bedeutet entgangenen Umsatz und gehoert als fehlgeschlagener Job +# sichtbar gemacht, nicht in eine Logzeile. +apiVersion: batch/v1 +kind: CronJob +metadata: + name: wabenhain-release-stale-orders + namespace: wabenhain +spec: + # Alle 30 Minuten. Die Stripe-Sitzung laeuft nach 32 Minuten ab, der Lauf + # betrachtet Bestellungen ab 45 Minuten Alter — haeufiger zu pruefen bringt + # deshalb nichts, seltener laesst Ware unnoetig lange liegen. + schedule: "*/30 * * * *" + concurrencyPolicy: Forbid + successfulJobsHistoryLimit: 3 + # Fehlgeschlagene Laeufe laenger aufheben: nur an ihnen laesst sich per + # "kubectl logs" nachlesen, welche Bestellung bezahlt, aber nicht + # verarbeitet ist. + failedJobsHistoryLimit: 5 + startingDeadlineSeconds: 600 + jobTemplate: + spec: + # Kein endloses Wiederholen: schlaegt der Lauf zweimal fehl, laeuft er + # ohnehin in 30 Minuten erneut. + backoffLimit: 2 + # Nach einer Stunde abbrechen — ein haengender Lauf wuerde sonst jeden + # weiteren blockieren (concurrencyPolicy: Forbid). + activeDeadlineSeconds: 3600 + template: + spec: + restartPolicy: OnFailure + # Gleiche Kennung wie der App-Container (Dockerfile: adduser --uid + # 1001 nextjs) — dasselbe Image, derselbe Nutzer. + securityContext: + runAsUser: 1001 + runAsGroup: 1001 + containers: + - name: release-stale-orders + # DASSELBE Image und derselbe Tag wie in 05-app-deployment.yaml. + # Beide zusammen aktualisieren: der Lauf nutzt denselben + # Prisma-Client wie die App und muss deshalb zum selben + # Datenbankschema passen. + image: git.encry.de/nick.rosenloecher/wabenhain:latest + imagePullPolicy: IfNotPresent + # Braucht DATABASE_URL, DATABASE_PROVIDER und STRIPE_SECRET_KEY + # — die stehen bereits in denselben beiden Quellen wie fuer die + # App, deshalb hier keine eigene Liste, die auseinanderlaufen + # koennte. + envFrom: + - configMapRef: + name: wabenhain-config + - secretRef: + name: wabenhain-secrets + command: ["/bin/sh", "-c"] + args: + - | + set -eu + # prisma-provider.mjs schreibt die provider-Zeile in + # prisma/schema.prisma anhand von DATABASE_PROVIDER — ohne + # diesen Schritt laedt der Prisma-Client den falschen + # Treiber, wenn die Installation auf MySQL laeuft. + node scripts/prisma-provider.mjs + exec tsx scripts/release-stale-orders.ts + resources: + requests: + cpu: 50m + memory: 128Mi + limits: + # Derselbe Wert wie beim App-Container: der Lauf laedt + # denselben Prisma-Client samt Treiber. + memory: 512Mi diff --git a/package.json b/package.json index 7288ea1..8b2c223 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,8 @@ "test": "vitest run", "test:watch": "vitest", "test:e2e": "playwright test", - "preflight": "tsx scripts/preflight.ts" + "preflight": "tsx scripts/preflight.ts", + "orders:release-stale": "tsx scripts/release-stale-orders.ts" }, "dependencies": { "@prisma/adapter-mariadb": "^7.10.0", diff --git a/prisma/migrations/20260918020000_order_cancel_token/migration.sql b/prisma/migrations/20260918020000_order_cancel_token/migration.sql new file mode 100644 index 0000000..405db5c --- /dev/null +++ b/prisma/migrations/20260918020000_order_cancel_token/migration.sql @@ -0,0 +1,16 @@ +-- Abbruch-Token fuer die cancel_url der Stripe-Sitzung. +-- +-- Eine Bestellung entsteht vor der Zahlung und reserviert dabei sofort +-- Lagerbestand. Brach die Kundschaft den Checkout ab, blieb dieser Bestand +-- bisher blockiert, bis Stripe die Sitzung fuer abgelaufen erklaerte — nach +-- Voreinstellung 24 Stunden spaeter. Der Token steht ausschliesslich in der +-- cancel_url und gibt den Bestand beim Abbruch sofort frei. +-- +-- Nullable, weil alle bereits bestehenden Bestellungen keinen haben; sie +-- laufen weiterhin ueber den Ablauf der Stripe-Sitzung. +-- AlterTable +ALTER TABLE "orders" ADD COLUMN "cancelToken" TEXT; + +-- CreateIndex +-- Eindeutig, damit ein Token immer genau eine Bestellung freigibt. +CREATE UNIQUE INDEX "orders_cancelToken_key" ON "orders"("cancelToken"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e838617..8096fe3 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -399,6 +399,14 @@ model Order { discountCodeId String? stripeCheckoutSessionId String? + /// Nicht erratbares Kennzeichen, das ausschliesslich in der cancel_url der + /// Stripe-Sitzung steht. Bricht die Kundschaft dort ab, landet sie auf + /// /checkout/cancel?ref=; erst dieser Token weist nach, dass der + /// Abbruch zu DIESER Bestellung gehoert, und gibt den reservierten Bestand + /// sofort wieder frei. Die Bestellnummer taugt dafuer nicht: sie steht in + /// jeder Bestaetigungsmail und auf jeder Rechnung, mit ihr koennte also + /// jemand die noch offene Zahlung einer fremden Person abbrechen. + cancelToken String? @unique notes String? /// Optionale persoenliche Nachricht bei Geschenkbestellungen — wird beim /// Verpacken von Hand beigelegt (siehe Admin-Bestelldetail). diff --git a/scripts/preflight.ts b/scripts/preflight.ts index bc12507..f34a295 100644 --- a/scripts/preflight.ts +++ b/scripts/preflight.ts @@ -24,6 +24,7 @@ import path from "node:path"; import { prisma } from "../src/lib/prisma"; import { getContentValue } from "../src/lib/content"; import { locales } from "../src/i18n/config"; +import { getStripeClient } from "../src/lib/stripe"; const blockers: string[] = []; const warnings: string[] = []; @@ -221,6 +222,68 @@ try { } } +/** + * Prueft, ob der Stripe-Webhook-Endpunkt die vier Ereignisse abonniert hat, + * auf die der Shop angewiesen ist. + * + * Das ist keine Formalie: fehlt checkout.session.expired, bleibt der beim + * Checkout reservierte Lagerbestand jedes abgebrochenen Kaufs dauerhaft + * blockiert. Fehlt checkout.session.completed, wird ueberhaupt keine Zahlung + * bestaetigt. Beides faellt im Betrieb erst auf, wenn Ware unverkaeuflich im + * System steht oder Geld eingezogen wurde, ohne dass eine Rechnung entstand — + * beides zu spaet. + */ +const REQUIRED_WEBHOOK_EVENTS = [ + "checkout.session.completed", + "checkout.session.async_payment_succeeded", + "checkout.session.async_payment_failed", + "checkout.session.expired", +] as const; + +async function checkStripeWebhookEvents(): Promise { + if (!isSet("STRIPE_SECRET_KEY")) return; // fehlt bereits als Blocker oben + + try { + const stripe = getStripeClient(); + const endpoints = await stripe.webhookEndpoints.list({ limit: 100 }); + const active = endpoints.data.filter((endpoint) => endpoint.status === "enabled"); + + // "*" abonniert alle Ereignisse — dann ist alles Noetige dabei. + const covered = new Set(); + for (const endpoint of active) { + for (const event of endpoint.enabled_events) covered.add(event); + } + + const missing = covered.has("*") + ? [] + : REQUIRED_WEBHOOK_EVENTS.filter((event) => !covered.has(event)); + + if (active.length === 0) { + warnings.push( + "Kein aktiver Stripe-Webhook-Endpunkt gefunden\n Ohne ihn wird keine Zahlung bestaetigt und kein abgebrochener Checkout aufgeraeumt. Unter https://dashboard.stripe.com/webhooks einen Endpunkt auf /api/checkout/webhook anlegen. Hinweis: mit einem eingeschraenkten Schluessel (rk_...) kann diese Pruefung die Liste moeglicherweise nicht sehen, obwohl ein Endpunkt existiert." + ); + return; + } + + must( + "Stripe-Webhook abonniert alle vier 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.` + ); + } catch (error: unknown) { + // Weder Blocker noch stilles Bestehen: der Schluessel darf die Liste + // vielleicht gar nicht lesen (eingeschraenkter Schluessel), und daraus + // laesst sich nichts ueber die tatsaechliche Konfiguration ableiten. + warnings.push( + `Stripe-Webhook-Ereignisse konnten NICHT geprueft werden\n ${firstMeaningfulLine( + error + )}. Im Dashboard unter https://dashboard.stripe.com/webhooks von Hand nachsehen, ob ${REQUIRED_WEBHOOK_EVENTS.join( + ", " + )} abonniert sind.` + ); + } +} + function report(): void { // --- Ausgabe --------------------------------------------------------------- const line = "─".repeat(72); @@ -249,4 +312,4 @@ console.log( ); } -void checkDatabaseContent().then(report); +void Promise.all([checkDatabaseContent(), checkStripeWebhookEvents()]).then(report); diff --git a/scripts/release-stale-orders.ts b/scripts/release-stale-orders.ts new file mode 100644 index 0000000..c8b669c --- /dev/null +++ b/scripts/release-stale-orders.ts @@ -0,0 +1,128 @@ +/** + * Aufraeumlauf fuer liegengebliebene, nie bezahlte Bestellungen. + * + * Eine Bestellung entsteht vor der Zahlung und reserviert dabei sofort + * Lagerbestand. Drei Wege geben ihn normalerweise wieder frei: + * + * 1. der Abbruch durch die Kundschaft (checkout/cancel), + * 2. das Ereignis checkout.session.expired von Stripe (Webhook), + * 3. dieser Lauf hier. + * + * Die ersten beiden sind die Regel, dieser hier ist das Netz darunter. Er + * faengt genau die Faelle ab, in denen Weg 2 nicht funktioniert: das Ereignis + * checkout.session.expired ist im Stripe-Dashboard nicht abonniert, der + * Endpunkt war beim Zustellversuch nicht erreichbar, oder die Zustellversuche + * sind abgelaufen. Ohne dieses Netz bliebe der reservierte Bestand dann fuer + * immer blockiert — unsichtbar, denn im Admin-Bereich sieht eine solche + * Bestellung aus wie jede andere offene. + * + * Gefragt wird immer Stripe, nie die Uhr allein: nur wenn die Sitzung dort + * nachweislich abgelaufen ist, wird zurueckgebucht. Ein Lauf, der nur nach + * dem Alter ginge, koennte eine gerade laufende Zahlung unter den Fuessen + * wegstornieren. + * + * Aufruf: pnpm orders:release-stale + * Im Cluster laeuft er als CronJob (siehe k8s/07-backup-cronjob.yaml). + */ +import { prisma } from "../src/lib/prisma"; +import { getStripeClient } from "../src/lib/stripe"; +import { releaseUnpaidOrder } from "../src/lib/order-release"; + +/** + * Mindestalter, bevor eine Bestellung ueberhaupt betrachtet wird. + * + * Die Stripe-Sitzung laeuft nach 32 Minuten ab (siehe + * CHECKOUT_SESSION_TTL_MINUTES in app/api/checkout/route.ts). 45 Minuten + * lassen dem regulaeren Weg — Ablauf bei Stripe, Ereignis, Webhook — genug + * Luft, seine Arbeit zuerst zu tun. + */ +const MIN_AGE_MINUTES = 45; + +/** Obergrenze je Lauf, damit ein Rueckstand die Datenbank nicht am Stueck belastet. */ +const MAX_ORDERS_PER_RUN = 200; + +async function main(): Promise { + const cutoff = new Date(Date.now() - MIN_AGE_MINUTES * 60 * 1000); + + 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" }, + take: MAX_ORDERS_PER_RUN, + }); + + if (candidates.length === 0) { + console.log("[release-stale-orders] Keine liegengebliebenen Bestellungen."); + return; + } + + console.log( + `[release-stale-orders] ${candidates.length} offene Bestellung(en) werden bei Stripe geprueft.` + ); + + const stripe = getStripeClient(); + let released = 0; + let stillOpen = 0; + let paid = 0; + let failed = 0; + + for (const order of candidates) { + try { + const session = await stripe.checkout.sessions.retrieve(order.stripeCheckoutSessionId!); + + if (session.status === "complete") { + // Bezahlt, aber der Status steht noch auf "NEU": der + // checkout.session.completed-Webhook ist nicht angekommen. Diesen Fall + // loest der Lauf hier bewusst NICHT auf — er wuerde Rechnung, + // Bestaetigungsmail und Gutscheinzaehlung ueberspringen. Stattdessen + // laut melden, damit die Zustellung im Stripe-Dashboard wiederholt + // werden kann. + paid += 1; + console.error( + `[release-stale-orders] Bestellung ${order.orderNumber} ist bei Stripe BEZAHLT, steht hier aber auf "NEU". Der Webhook ist nicht angekommen — bitte im Stripe-Dashboard erneut zustellen.` + ); + continue; + } + + if (session.status === "open") { + // Sitzung laeuft noch (moeglich bei Zahlungsarten mit langer Frist). + // Nicht anfassen. + stillOpen += 1; + continue; + } + + const result = await releaseUnpaidOrder(order.id, 0); + if (result) { + released += 1; + console.log( + `[release-stale-orders] ${result.orderNumber}: Bestand freigegeben (Sitzung abgelaufen).` + ); + } + } catch (error: unknown) { + // Eine einzelne fehlgeschlagene Abfrage darf den Lauf nicht beenden — + // die uebrigen Bestellungen sollen trotzdem geprueft werden. + failed += 1; + console.error(`[release-stale-orders] ${order.orderNumber} konnte nicht geprueft werden:`, error); + } + } + + console.log( + `[release-stale-orders] Fertig: ${released} freigegeben, ${stillOpen} noch offen, ${paid} bezahlt aber ohne Webhook, ${failed} fehlgeschlagen.` + ); + + // Ein bezahlter, aber nicht verarbeiteter Fall ist ein echter Missstand und + // soll den CronJob als fehlgeschlagen markieren, damit er im Cluster + // auffaellt statt nur in einem Logeintrag zu verschwinden. + if (paid > 0) process.exitCode = 1; +} + +main() + .catch((error: unknown) => { + console.error("[release-stale-orders] Abgebrochen:", error); + process.exitCode = 1; + }) + .finally(() => prisma.$disconnect()); diff --git a/src/app/api/checkout/route.ts b/src/app/api/checkout/route.ts index 0bbe096..0019d03 100644 --- a/src/app/api/checkout/route.ts +++ b/src/app/api/checkout/route.ts @@ -30,6 +30,24 @@ class DiscountCodeExhaustedError extends Error {} // ist nur die freundlichere Fehlermeldung fuer den haeufigeren Fall. const SERIALIZATION_CONFLICT_CODE = "P2034"; +/** + * Lebensdauer der Stripe-Sitzung. Stripe laesst zwischen 30 Minuten und 24 + * Stunden zu und nimmt ohne Angabe die 24 Stunden. + * + * Das ist hier die falsche Voreinstellung: die Bestellung reserviert beim + * Anlegen Lagerbestand, und erst wenn Stripe die Sitzung fuer abgelaufen + * erklaert, kommt dieser Bestand zurueck (checkout.session.expired, siehe + * api/checkout/webhook). Mit 24 Stunden haelt jeder nicht zu Ende gefuehrte + * Checkout Ware einen Tag lang fest — und wer das absichtlich tut, kann den + * Shop mit wenigen Klicks leerraeumen, ohne einen Cent zu zahlen. + * + * 32 statt 30 Minuten wegen der Untergrenze: zwischen dieser Zeile und dem + * Eintreffen der Anfrage bei Stripe vergehen Sekundenbruchteile, und die + * Uhren muessen nicht exakt gleich gehen. Genau 30 Minuten koennten deshalb + * als "weniger als 30" ankommen und die Sitzungserstellung scheitern lassen. + */ +const CHECKOUT_SESSION_TTL_MINUTES = 32; + function generateOrderNumber(): string { const year = new Date().getFullYear(); // Kryptografisch zufaellig statt Math.random(): nicht erratbar und @@ -237,6 +255,13 @@ export async function POST(request: Request) { const locale = await getLocale(); + // Steht gleich ausschliesslich in der cancel_url der Stripe-Sitzung und + // gibt den reservierten Bestand frei, sobald die Kundschaft dort abbricht + // (siehe checkout/cancel). 32 Byte, damit er nicht durchprobiert werden + // kann — mit einem ratbaren Wert liesse sich die offene Zahlung fremder + // Leute abbrechen. + const cancelToken = randomBytes(32).toString("hex"); + let order; try { // Bestellanlage und Bestandsreservierung atomar: das bedingte @@ -278,6 +303,7 @@ export async function POST(request: Request) { return tx.order.create({ data: { orderNumber: generateOrderNumber(), + cancelToken, customerId: customer?.id, locale, fulfillmentMethod: input.fulfillmentMethod, @@ -396,8 +422,14 @@ export async function POST(request: Request) { : []), ], ...(discounts ? { discounts } : {}), + // Ohne diese Angabe laeuft die Sitzung erst nach 24 Stunden ab und + // haelt so lange Lagerbestand fest (siehe CHECKOUT_SESSION_TTL_MINUTES). + expires_at: Math.floor(Date.now() / 1000) + CHECKOUT_SESSION_TTL_MINUTES * 60, success_url: `${origin}/checkout/success?order=${order.orderNumber}`, - cancel_url: `${origin}/checkout/cancel`, + // Der Token statt der Bestellnummer: die Bestellnummer steht in jeder + // Bestaetigungsmail und auf jeder Rechnung, mit ihr liesse sich also + // die noch offene Zahlung einer fremden Person abbrechen. + cancel_url: `${origin}/checkout/cancel?ref=${cancelToken}`, metadata: { orderId: order.id, orderNumber: order.orderNumber }, }); diff --git a/src/app/api/checkout/webhook/route.ts b/src/app/api/checkout/webhook/route.ts index 031cc9c..e36d809 100644 --- a/src/app/api/checkout/webhook/route.ts +++ b/src/app/api/checkout/webhook/route.ts @@ -3,7 +3,7 @@ import { getStripeClient } from "@/lib/stripe"; import { prisma } from "@/lib/prisma"; import { sendMail } from "@/lib/mail"; import { orderConfirmationEmail, paymentFailedEmail } from "@/lib/email-templates"; -import { restockOrderItems } from "@/lib/inventory"; +import { releaseUnpaidOrder } from "@/lib/order-release"; import { getBaseUrl } from "@/lib/url"; import { getInvoiceByNumber } from "@/lib/account-data"; import { generateInvoicePdf } from "@/lib/invoice-pdf"; @@ -183,6 +183,26 @@ async function fulfillOrder(session: Stripe.Checkout.Session): Promise { // (z.B. bereits storniert) — nichts weiter zu tun. if (!order) return; + // Abgleich zwischen dem, was wir berechnet haben, und dem, was Stripe + // tatsaechlich eingezogen hat. Beide Betraege stammen aus derselben Quelle + // (Positionspreise aus der Datenbank, Versand aus der Mengenstaffel, Rabatt + // als Coupon), sie MUESSEN uebereinstimmen. Tun sie es nicht, hat etwas + // ausserhalb dieses Codes mitgerechnet — am wahrscheinlichsten eine im + // Stripe-Dashboard eingeschaltete automatische Steuer oder eine dort + // gepflegte Versandoption. + // + // Die Bestellung bleibt trotzdem bezahlt, denn das Geld ist geflossen; sie + // hier abzulehnen wuerde eine echte Zahlung ins Leere laufen lassen. Aber + // die Abweichung muss auffallen: bei zu wenig eingezogenem Geld wird sonst + // stillschweigend mit Verlust versendet, bei zu viel weicht die Rechnung + // vom tatsaechlich belasteten Betrag ab — und die Rechnung ist ein + // Steuerdokument. + if (session.amount_total !== null && session.amount_total !== order.totalCents) { + console.error( + `[checkout] Betragsabweichung bei Bestellung ${order.orderNumber}: berechnet ${order.totalCents} Cent, von Stripe eingezogen ${session.amount_total} Cent (Session ${session.id}). Bitte pruefen, bevor die Ware das Haus verlaesst.` + ); + } + try { await prisma.payment.upsert({ where: { orderId: order.id }, @@ -297,42 +317,13 @@ async function failOrder(session: Stripe.Checkout.Session): Promise { const orderId = session.metadata?.orderId; if (!orderId) return; - const result = await prisma.$transaction(async (tx) => { - // Atomarer Claim (gleiches Muster wie oben bei fulfillOrder): ein - // getrenntes findUnique+update wuerde bei zwei parallel zugestellten - // "expired"/"async_payment_failed"-Webhooks fuer dieselbe Bestellung - // beide Kopien noch "NEU" lesen lassen, bevor eine von ihnen schreibt — - // restockOrderItems liefe dann zweimal und der Lagerbestand wuerde - // stillschweigend doppelt gutgeschrieben. - const claim = await tx.order.updateMany({ - where: { id: orderId, status: "NEU" }, - data: { status: "STORNIERT" }, - }); - if (claim.count === 0) return null; - - const previous = await tx.order.findUniqueOrThrow({ - where: { id: orderId }, - select: { - orderNumber: true, - email: true, - firstName: true, - locale: true, - items: { select: { productId: true, quantity: true } }, - }, - }); - - // Beim Checkout reservierten Bestand zurueckbuchen (gleiches Muster wie - // bei manueller Stornierung im Admin-Bereich, siehe admin/actions.ts). - await restockOrderItems(tx, previous.items); - - await tx.payment.upsert({ - where: { orderId }, - create: { orderId, status: "FAILED", amountCents: session.amount_total ?? 0 }, - update: { status: "FAILED" }, - }); - - return previous; - }); + // Stornieren, Bestand zurueckbuchen und Zahlung als fehlgeschlagen + // vermerken liegt in lib/order-release.ts, weil derselbe Vorgang auch beim + // Abbruch durch die Kundschaft (checkout/cancel) und im Aufraeumlauf + // (scripts/release-stale-orders.ts) gebraucht wird. Kommt der Webhook + // nach einem dieser beiden an, liefert der Helfer null — die Bestellung ist + // dann nicht mehr "NEU" und der Bestand laengst zurueckgebucht. + const result = await releaseUnpaidOrder(orderId, session.amount_total ?? 0); if (result) { const { subject, html, text } = await paymentFailedEmail({ diff --git a/src/app/checkout/cancel/page.tsx b/src/app/checkout/cancel/page.tsx index b593f02..5b57bb0 100644 --- a/src/app/checkout/cancel/page.tsx +++ b/src/app/checkout/cancel/page.tsx @@ -1,12 +1,115 @@ import type { Metadata } from "next"; +import { redirect } from "next/navigation"; import { Container, SectionHeading, LinkButton } from "@/components/ui"; +import { prisma } from "@/lib/prisma"; +import { getStripeClient } from "@/lib/stripe"; +import { releaseUnpaidOrder } from "@/lib/order-release"; +import { rateLimit, getClientIp } from "@/lib/rate-limit"; import { getLocale } from "@/i18n/locale"; import { getDictionary } from "@/i18n/get-dictionary"; -export const metadata: Metadata = { title: "Bestellung abgebrochen" }; +export const metadata: Metadata = { title: "Bestellung abgebrochen", robots: { index: false } }; +// Diese Seite aendert Daten (siehe releaseCheckout) und darf deshalb nie aus +// einem Cache beantwortet werden. +export const dynamic = "force-dynamic"; -export default async function CheckoutCancelPage() { +const CANCEL_ATTEMPTS_PER_WINDOW = 20; +const CANCEL_WINDOW_MS = 15 * 60 * 1000; + +/** + * Gibt den beim Checkout reservierten Lagerbestand sofort wieder frei, wenn + * die Kundschaft die Zahlung bei Stripe abbricht. + * + * Ohne das bliebe die Ware bis zum Ablauf der Stripe-Sitzung blockiert. Die + * Sitzung laeuft zwar nur noch 32 Minuten statt 24 Stunden (siehe + * api/checkout/route.ts), aber eine halbe Stunde ist immer noch eine halbe + * Stunde, in der das letzte Glas fuer niemanden kaufbar ist, obwohl die + * Person gerade ausdruecklich abgebrochen hat. + * + * Reihenfolge ist hier entscheidend: erst bei Stripe nachsehen, dann erst + * etwas zurueckbuchen. Wuerden wir umgekehrt vorgehen, koennte eine bereits + * bezahlte Bestellung storniert und ihr Bestand doppelt verkauft werden — + * etwa wenn jemand nach erfolgreicher Zahlung den Zurueck-Knopf benutzt und + * dabei wieder auf dieser Seite landet. + */ +async function releaseCheckout(cancelToken: string): Promise { + // Der Token hat 32 Byte und ist praktisch nicht zu erraten; die Bremse ist + // trotzdem sinnvoll, weil jeder Aufruf sonst eine Stripe-Abfrage ausloest. + const ip = await getClientIp(); + if (!rateLimit(`checkout-cancel:${ip}`, CANCEL_ATTEMPTS_PER_WINDOW, CANCEL_WINDOW_MS)) return; + + const order = await prisma.order.findUnique({ + where: { cancelToken }, + select: { id: true, orderNumber: true, status: true, stripeCheckoutSessionId: true }, + }); + + // Unbekannter Token oder die Bestellung ist laengst nicht mehr offen + // (bezahlt, storniert, versendet) — nichts zu tun. Bewusst ohne + // unterscheidbare Rueckmeldung nach aussen: die Seite sieht in jedem Fall + // gleich aus und verraet damit nicht, ob es den Token gibt. + if (!order || order.status !== "NEU") return; + + if (!order.stripeCheckoutSessionId) { + // Kann vorkommen, wenn das Speichern der Sitzungs-ID fehlgeschlagen ist + // (best-effort, siehe api/checkout/route.ts). Ohne sie laesst sich bei + // Stripe nicht pruefen, ob doch bezahlt wurde — also nichts zurueckbuchen + // und den Ablauf der Sitzung abwarten, der ueber den Webhook ohnehin + // greift. + console.warn( + `[checkout-cancel] Bestellung ${order.orderNumber} hat keine Stripe-Sitzung hinterlegt; Freigabe erst bei Ablauf der Sitzung.` + ); + return; + } + + try { + const stripe = getStripeClient(); + const session = await stripe.checkout.sessions.retrieve(order.stripeCheckoutSessionId); + + // "complete" heisst bezahlt oder in Bearbeitung — dann ist dieser + // Seitenaufruf kein Abbruch, sondern ein Zurueck-Klick nach erfolgreicher + // Zahlung. Finger weg. + if (session.status === "complete") return; + + // "open" heisst: noch bezahlbar. Erst die Sitzung schliessen, damit + // niemand die bereits freigegebene Ware im naechsten Moment doch noch + // bezahlt, und erst danach zurueckbuchen. + if (session.status === "open") { + await stripe.checkout.sessions.expire(order.stripeCheckoutSessionId); + } + + // Uebrig bleiben "open" (gerade geschlossen) und "expired" — in beiden + // Faellen kann nicht mehr bezahlt werden. Stripe schickt dazu ein + // checkout.session.expired-Ereignis; der Webhook findet die Bestellung + // dann nicht mehr im Status "NEU" und tut nichts weiter (siehe + // lib/order-release.ts). + const released = await releaseUnpaidOrder(order.id, 0); + if (released) { + console.info(`[checkout-cancel] Bestellung ${released.orderNumber} abgebrochen, Bestand freigegeben.`); + } + } catch (error: unknown) { + // Nicht weiterwerfen: die Person hat gerade abgebrochen und soll die + // Abbruchseite sehen, nicht einen Serverfehler. Der Bestand kommt dann + // spaetestens mit dem Ablauf der Sitzung zurueck. + console.error( + `[checkout-cancel] Bestellung ${order.orderNumber} konnte nicht freigegeben werden:`, + error + ); + } +} + +export default async function CheckoutCancelPage(props: PageProps<"/checkout/cancel">) { const t = getDictionary(await getLocale()); + const { ref } = await props.searchParams; + + if (typeof ref === "string" && ref) { + await releaseCheckout(ref); + // Token aus Adresszeile, Verlauf und Referer nehmen, sobald er seinen + // Zweck erfuellt hat. Er gibt zwar nur genau diese eine, gerade + // abgebrochene Bestellung frei, aber ein Geheimnis gehoert nicht laenger + // sichtbar herum, als es gebraucht wird. + redirect("/checkout/cancel"); + } + return (
@@ -19,6 +122,9 @@ export default async function CheckoutCancelPage() {

{t.checkoutCancelPage.text}

+

+ {t.checkoutCancelPage.released} +

{t.checkoutCancelPage.backToCart} diff --git a/src/i18n/dictionaries/de.ts b/src/i18n/dictionaries/de.ts index e4a9730..e3d3d2c 100644 --- a/src/i18n/dictionaries/de.ts +++ b/src/i18n/dictionaries/de.ts @@ -1340,6 +1340,7 @@ export const de: Dictionary = { eyebrow: "Abgebrochen", title: "Deine Bestellung wurde nicht abgeschlossen.", text: "Kein Problem, dein Warenkorb ist noch gefüllt. Du kannst den Bestellvorgang jederzeit erneut starten.", + released: "Die für dich zurückgelegte Ware steht wieder im Shop zur Verfügung.", backToCart: "Zurück zum Warenkorb", }, checkoutPage: { diff --git a/src/i18n/dictionaries/en.ts b/src/i18n/dictionaries/en.ts index 83cfa26..aeea6b2 100644 --- a/src/i18n/dictionaries/en.ts +++ b/src/i18n/dictionaries/en.ts @@ -1339,6 +1339,7 @@ export const en: Dictionary = { eyebrow: "Cancelled", title: "Your order was not completed.", text: "No problem, your cart is still full. You can restart the checkout process at any time.", + released: "The items reserved for you are available in the shop again.", backToCart: "Back to cart", }, checkoutPage: { diff --git a/src/i18n/dictionary-type.ts b/src/i18n/dictionary-type.ts index 6f383f5..2e5c7c2 100644 --- a/src/i18n/dictionary-type.ts +++ b/src/i18n/dictionary-type.ts @@ -1021,6 +1021,7 @@ export interface Dictionary { eyebrow: string; title: string; text: string; + released: string; backToCart: string; }; checkoutPage: { diff --git a/src/lib/order-release.ts b/src/lib/order-release.ts new file mode 100644 index 0000000..7c132b8 --- /dev/null +++ b/src/lib/order-release.ts @@ -0,0 +1,72 @@ +import { prisma } from "@/lib/prisma"; +import { restockOrderItems } from "@/lib/inventory"; + +export interface ReleasedOrder { + orderNumber: string; + email: string; + firstName: string; + locale: string; +} + +/** + * Gibt eine unbezahlte Bestellung wieder frei: Status auf "STORNIERT", der + * beim Checkout reservierte Lagerbestand zurueck ins Regal, Zahlung als + * "FAILED" vermerkt. + * + * Eine Bestellung entsteht BEVOR die Stripe-Sitzung geoeffnet wird, und sie + * reserviert dabei sofort Bestand — sonst koennte zwischen Kaufentscheidung + * und Zahlung jemand anders dasselbe Glas kaufen. Der Preis dafuer: jeder + * nicht zu Ende gefuehrte Checkout haelt Ware fest, die niemand bezahlt hat. + * Diese Funktion ist die Gegenbuchung dazu und wird von allen drei Wegen + * benutzt, auf denen das auffallen kann: dem Abbruch durch die Kundschaft, + * dem Ablauf der Stripe-Sitzung (Webhook) und dem Aufraeumlauf + * (scripts/release-stale-orders.ts). + * + * Der Statuswechsel ist ein einzelnes bedingtes UPDATE ("nur wenn noch NEU"). + * Ein getrenntes Lesen und Schreiben wuerde bei zwei gleichzeitigen Ausloesern + * — etwa Abbruch-Klick und Webhook im selben Moment — beide Seiten noch "NEU" + * lesen lassen, und der Bestand wuerde doppelt gutgeschrieben. Genau deshalb + * gibt diese Funktion beim zweiten Aufruf null zurueck: nicht als Fehler, + * sondern als Aussage "hat schon jemand anders erledigt". + * + * @returns die freigegebene Bestellung, oder null wenn sie nicht (mehr) im + * Status "NEU" war und deshalb nichts zu tun ist. + */ +export async function releaseUnpaidOrder( + orderId: string, + paidAmountCents: number +): Promise { + return prisma.$transaction(async (tx) => { + const claim = await tx.order.updateMany({ + where: { id: orderId, status: "NEU" }, + data: { status: "STORNIERT" }, + }); + if (claim.count === 0) return null; + + const previous = await tx.order.findUniqueOrThrow({ + where: { id: orderId }, + select: { + orderNumber: true, + email: true, + firstName: true, + locale: true, + items: { select: { productId: true, quantity: true } }, + }, + }); + + await restockOrderItems(tx, previous.items); + + await tx.payment.upsert({ + where: { orderId }, + create: { orderId, status: "FAILED", amountCents: paidAmountCents }, + update: { status: "FAILED" }, + }); + + return { + orderNumber: previous.orderNumber, + email: previous.email, + firstName: previous.firstName, + locale: previous.locale, + }; + }); +} diff --git a/src/lib/validation.test.ts b/src/lib/validation.test.ts index 48f81d9..ca455ce 100644 --- a/src/lib/validation.test.ts +++ b/src/lib/validation.test.ts @@ -84,6 +84,51 @@ describe("checkoutSchema", () => { const result = checkoutSchema.safeParse({ ...baseInput, discountCode: "SOMMER15" }); expect(result.success).toBe(true); }); + + // Die beiden Muster standen hier einmal als /^d{1,10}$/ bzw. /^d{6,10}$/ — + // ohne Backslash. Das traf nicht "Ziffer", sondern den Buchstaben d: jede + // echte Packstation- und Postnummer wurde abgewiesen, Lieferungen an eine + // Packstation waren damit vollstaendig unmoeglich. Der Fehler war aus dem + // Code nicht zu sehen, weil die Fehlermeldung daneben das Richtige sagte. + test("accepts a real packstation delivery", () => { + const result = checkoutSchema.safeParse({ + ...baseInput, + fulfillmentMethod: "VERSAND" as const, + packstationNumber: "145", + postNumber: "123456", + }); + expect(result.success).toBe(true); + }); + + test("rejects letters in the packstation and post number", () => { + expect( + checkoutSchema.safeParse({ + ...baseInput, + fulfillmentMethod: "VERSAND" as const, + packstationNumber: "abc", + postNumber: "123456", + }).success + ).toBe(false); + expect( + checkoutSchema.safeParse({ + ...baseInput, + fulfillmentMethod: "VERSAND" as const, + packstationNumber: "145", + postNumber: "dddddd", + }).success + ).toBe(false); + }); + + test("requires the post number for a packstation delivery", () => { + // Ohne sie laesst sich spaeter keine Versandmarke erstellen — die + // Bestellung waere angenommen, aber nicht zustellbar. + const result = checkoutSchema.safeParse({ + ...baseInput, + fulfillmentMethod: "VERSAND" as const, + packstationNumber: "145", + }); + expect(result.success).toBe(false); + }); }); describe("reviewSchema", () => { diff --git a/src/lib/validation.ts b/src/lib/validation.ts index 3680092..0ce52fb 100644 --- a/src/lib/validation.ts +++ b/src/lib/validation.ts @@ -178,13 +178,13 @@ export const checkoutSchema = z.object({ packstationNumber: z .string() .trim() - .regex(/^d{1,10}$/, "Die Packstation-Nummer besteht nur aus Ziffern.") + .regex(/^\d{1,10}$/, "Die Packstation-Nummer besteht nur aus Ziffern.") .optional(), /** DHL-Postnummer der empfangenden Person — bei Packstation-Lieferung Pflicht. */ postNumber: z .string() .trim() - .regex(/^d{6,10}$/, "Die DHL-Postnummer besteht aus 6 bis 10 Ziffern.") + .regex(/^\d{6,10}$/, "Die DHL-Postnummer besteht aus 6 bis 10 Ziffern.") .optional(), }) // DHL verlangt bei Lieferung an eine Packstation zwingend die Postnummer