fix: abgebrochener Checkout gibt die reservierte Ware sofort wieder frei

Eine Bestellung entsteht vor der Zahlung und reserviert dabei sofort
Lagerbestand — sonst könnte zwischen Kaufentscheidung und Zahlung jemand
anders dasselbe Glas kaufen. Bricht die Kundschaft bei Stripe ab, passierte
danach: nichts. Die Seite /checkout/cancel war reiner Text, die Bestellung
blieb auf "NEU", und der Bestand kam erst zurück, wenn Stripe die Sitzung für
abgelaufen erklärte — nach Voreinstellung 24 Stunden später.

Das ist nicht nur unschön, es ist ausnutzbar. Zehn Checkouts pro Stunde und
IP, bis zu zwanzig Stück je Position, fünfzig Positionen: damit lässt sich der
Shop leerräumen, ohne einen Cent zu zahlen, und das Lager bleibt einen Tag
lang blockiert.

Drei Schichten dagegen, jede für sich wirksam:

1. Die Stripe-Sitzung läuft nach 32 statt 24 Stunden ab (expires_at). Stripe
   lässt als Untergrenze 30 Minuten zu; die zwei Minuten Aufschlag sind Puffer
   gegen auseinanderlaufende Uhren, sonst kann die Erstellung als "weniger als
   30 Minuten" abgewiesen werden.

2. Der Abbruch-Knopf bucht sofort zurück. Die cancel_url trägt dazu einen
   eigenen Token (Order.cancelToken, 32 Byte), nicht die Bestellnummer: die
   steht in jeder Bestätigungsmail und auf jeder Rechnung, mit ihr ließe sich
   also die offene Zahlung einer fremden Person abbrechen. Der Token wird
   nach Gebrauch aus der Adresszeile entfernt.

   {CHECKOUT_SESSION_ID} wäre der naheliegende Weg gewesen, ist aber nur für
   success_url und return_url dokumentiert, nicht für cancel_url — darauf
   baut hier nichts.

3. Ein CronJob prüft alle 30 Minuten nach, was liegengeblieben ist. Er greift
   genau dann, wenn Schicht 2 nicht greifen kann: checkout.session.expired
   ist im Dashboard nicht abonniert, der Endpunkt war nicht erreichbar, die
   Zustellversuche sind abgelaufen. Ohne ihn bliebe der Bestand dauerhaft
   blockiert, und zwar unsichtbar — eine solche Bestellung sieht im Admin
   aus wie jede andere offene.

Gefragt wird an jeder Stelle zuerst Stripe, nie die Uhr allein. Ist die
Sitzung dort "complete", war der Aufruf kein Abbruch, sondern ein
Zurück-Klick nach erfolgreicher Zahlung — dann bleibt alles unangetastet.
Umgekehrt vorzugehen hieße, eine bezahlte Bestellung zu stornieren und ihre
Ware ein zweites Mal zu verkaufen.

Findet der Aufräumlauf eine Bestellung, die bei Stripe bezahlt ist, hier aber
noch auf "NEU" steht, rührt er sie nicht an: das nachträglich selbst zu
erledigen hieße, Rechnung, Bestätigungsmail und Gutscheinzählung zu
überspringen. Er meldet den Fall und endet mit Exit-Code 1, damit er als
fehlgeschlagener Job auffällt statt in einer Logzeile zu verschwinden.

Das Stornieren samt Rückbuchung liegt jetzt einmal in lib/order-release.ts
statt verteilt. Der Statuswechsel ist ein einzelnes bedingtes UPDATE ("nur
wenn noch NEU"), damit Abbruch-Klick und Webhook im selben Moment den Bestand
nicht doppelt gutschreiben.

Weiter in diesem Zug:

- Betragsabgleich im Webhook: weicht session.amount_total von der eigenen
  Summe ab, hat etwas außerhalb dieses Codes mitgerechnet (am ehesten eine im
  Dashboard eingeschaltete automatische Steuer). Die Bestellung bleibt
  bezahlt — das Geld ist geflossen —, aber die Abweichung wird laut geloggt.
  Sonst wird bei zu wenig eingezogenem Geld stillschweigend mit Verlust
  versendet, und bei zu viel weicht die Rechnung vom belasteten Betrag ab.

- preflight prüft, ob der Webhook-Endpunkt alle vier Ereignisse abonniert
  hat. Ein eingeschränkter Schlüssel darf die Liste womöglich nicht lesen;
  dann meldet die Prüfung "ungeprüft" statt "in Ordnung".

- Packstation-Bestellungen waren vollständig kaputt: die Muster für
  Packstation- und Postnummer lauteten /^d{1,10}$/ und /^d{6,10}$/. Ohne
  Backslash trifft das nicht "Ziffer", sondern den Buchstaben d — jede echte
  Nummer wurde abgewiesen, gültig wären nur Eingaben wie "dddddd" gewesen.
  Der Fehler war beim Lesen kaum zu sehen, weil die Fehlermeldung daneben das
  Richtige sagte. Drei Tests halten das jetzt fest.

- Das Laufzeit-Image bekommt src/lib vollständig statt zweier einzelner
  Dateien: der CronJob zieht über order-release.ts weitere Module nach, und
  einzeln aufgezählt fällt so etwas erst im Cluster mit "Cannot find module"
  auf.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5 committed 2026-09-18 02:07:22 +02:00
1 parent e525db84ef
commit a3f91b83fc
17 files changed
+625 -46

No files matched your search

+7 -2
View File
@@ -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
+18
View File
@@ -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=<Token>` 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.
+91
View File
@@ -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
+2 -1
View File
@@ -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",
@@ -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");
+8
View File
@@ -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=<token>; 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).
+64 -1
View File
@@ -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<void> {
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<string>();
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);
+128
View File
@@ -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<void> {
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());
+33 -1
View File
@@ -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 },
});
+28 -37
View File
@@ -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<void> {
// (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<void> {
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({
+108 -2
View File
@@ -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<void> {
// 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 (
<div className="py-24">
<Container className="max-w-xl text-center">
@@ -19,6 +122,9 @@ export default async function CheckoutCancelPage() {
<p className="mt-6 text-ink/70">
{t.checkoutCancelPage.text}
</p>
<p className="mt-3 text-sm text-ink/60">
{t.checkoutCancelPage.released}
</p>
<LinkButton href="/warenkorb" className="mt-10">
{t.checkoutCancelPage.backToCart}
</LinkButton>
+1
View File
@@ -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: {
+1
View File
@@ -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: {
+1
View File
@@ -1021,6 +1021,7 @@ export interface Dictionary {
eyebrow: string;
title: string;
text: string;
released: string;
backToCart: string;
};
checkoutPage: {
+72
View File
@@ -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<ReleasedOrder | null> {
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,
};
});
}
+45
View File
@@ -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", () => {
+2 -2
View File
@@ -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