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:
1 parent
e525db84ef
commit
a3f91b83fc
17 files changed
+625
-46
No files matched your search
+64
-1
@@ -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);
|
||||
@@ -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());
|
||||
Reference in new issue
Block a user