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
@@ -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 },
|
||||
});
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in new issue
Block a user