fix: Race Conditions bei Gutscheincode-Einloesung und Retouren-Erstattung
- Checkout: Gutscheincode-Nutzungslimit wird jetzt innerhalb derselben Serializable-Transaktion wie die Bestellanlage frisch nachgeprueft, statt nur einmalig vorab. Zwei gleichzeitige Checkouts mit demselben maxUses:1-Code konnten sich sonst beide durchsetzen. - Admin/Retouren: Erstattungsstatus wird jetzt atomar (bedingtes Update, ANGENOMMEN -> ERSTATTET) VOR dem Stripe-Aufruf gesetzt statt danach. Ein Doppelklick auf "Erstatten" konnte sonst zwei echte Stripe-Erstattungen ausloesen. Bei einem fehlschlagenden Stripe-Aufruf wird der Status wieder zurueckgesetzt, damit erneut versucht werden kann. Beide Luecken kamen aus dem Security-Assessment dieser Session. Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
1 parent
b6db9254a9
commit
c59913b75b
2 files changed
+107
-38
No files matched your search
@@ -68,8 +68,6 @@ export async function refundReturnRequestAction(formData: FormData): Promise<voi
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
// Nur eine angenommene Retoure kann erstattet werden — verhindert
|
|
||||||
// Doppel-Erstattung, falls dieselbe Aktion mehrfach ausgeloest wird.
|
|
||||||
if (!returnRequest || returnRequest.status !== "ANGENOMMEN") return;
|
if (!returnRequest || returnRequest.status !== "ANGENOMMEN") return;
|
||||||
|
|
||||||
const paymentIntentId = returnRequest.order.payment?.stripePaymentIntentId;
|
const paymentIntentId = returnRequest.order.payment?.stripePaymentIntentId;
|
||||||
@@ -77,11 +75,29 @@ export async function refundReturnRequestAction(formData: FormData): Promise<voi
|
|||||||
fail("/admin/retouren", "Keine Stripe-Zahlung zu dieser Bestellung hinterlegt — bitte manuell erstatten.");
|
fail("/admin/retouren", "Keine Stripe-Zahlung zu dieser Bestellung hinterlegt — bitte manuell erstatten.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Status ATOMAR und VOR dem Stripe-Aufruf auf ERSTATTET setzen (bedingt auf
|
||||||
|
// den noch erwarteten "ANGENOMMEN"-Stand) statt erst danach: der
|
||||||
|
// findUnique-Check allein ist nicht atomar — bei zwei schnell
|
||||||
|
// aufeinanderfolgenden Klicks (Doppelklick, zwei offene Tabs) koennten
|
||||||
|
// sonst beide Aufrufe die Pruefung bestehen und zwei echte
|
||||||
|
// Stripe-Erstattungen ausloesen. Schlaegt der Stripe-Aufruf unten fehl,
|
||||||
|
// wird der Status wieder zurueckgesetzt, damit die Erstattung erneut
|
||||||
|
// versucht werden kann.
|
||||||
|
const claimed = await prisma.returnRequest.updateMany({
|
||||||
|
where: { id, status: "ANGENOMMEN" },
|
||||||
|
data: { status: "ERSTATTET" },
|
||||||
|
});
|
||||||
|
if (claimed.count === 0) return;
|
||||||
|
|
||||||
const stripe = getStripeClient();
|
const stripe = getStripeClient();
|
||||||
await stripe.refunds.create({ payment_intent: paymentIntentId });
|
try {
|
||||||
|
await stripe.refunds.create({ payment_intent: paymentIntentId });
|
||||||
|
} catch (error: unknown) {
|
||||||
|
await prisma.returnRequest.update({ where: { id }, data: { status: "ANGENOMMEN" } });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
await prisma.$transaction(async (tx) => {
|
await prisma.$transaction(async (tx) => {
|
||||||
await tx.returnRequest.update({ where: { id }, data: { status: "ERSTATTET" } });
|
|
||||||
await tx.payment.update({ where: { orderId: returnRequest.orderId }, data: { status: "REFUNDED" } });
|
await tx.payment.update({ where: { orderId: returnRequest.orderId }, data: { status: "REFUNDED" } });
|
||||||
// Zurueckgesendete Ware wieder als verfuegbaren Bestand fuehren, analog
|
// Zurueckgesendete Ware wieder als verfuegbaren Bestand fuehren, analog
|
||||||
// zur Stornierung im Checkout-Webhook.
|
// zur Stornierung im Checkout-Webhook.
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { randomBytes } from "node:crypto";
|
import { randomBytes } from "node:crypto";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { Prisma } from "@/generated/prisma/client";
|
||||||
import { getStripeClient } from "@/lib/stripe";
|
import { getStripeClient } from "@/lib/stripe";
|
||||||
import { checkoutSchema } from "@/lib/validation";
|
import { checkoutSchema } from "@/lib/validation";
|
||||||
import { getProductsByIds } from "@/lib/data";
|
import { getProductsByIds } from "@/lib/data";
|
||||||
@@ -14,6 +15,18 @@ import { getBaseUrl } from "@/lib/url";
|
|||||||
import { getLocale } from "@/i18n/locale";
|
import { getLocale } from "@/i18n/locale";
|
||||||
|
|
||||||
class OutOfStockError extends Error {}
|
class OutOfStockError extends Error {}
|
||||||
|
// Der fruehe pendingUses-Check weiter unten liest ausserhalb jeder Transaktion
|
||||||
|
// — zwei zeitgleiche Checkouts mit demselben maxUses:1-Code koennten ihn
|
||||||
|
// beide bestehen, bevor eine der beiden Bestellungen tatsaechlich angelegt
|
||||||
|
// ist (TOCTOU). Dieser Fehlertyp markiert den verbindlichen Re-Check direkt
|
||||||
|
// vor dem Anlegen der Bestellung (s.u., innerhalb der Serializable-Transaktion).
|
||||||
|
class DiscountCodeExhaustedError extends Error {}
|
||||||
|
// Postgres/MySQL brechen bei "Serializable"-Isolation eine von zwei
|
||||||
|
// widerspruechlichen, gleichzeitigen Transaktionen mit einem
|
||||||
|
// Serialisierungsfehler ab, statt still beide durchzulassen — das ist der
|
||||||
|
// eigentliche Schutz gegen die Race-Condition, der explizite Re-Check oben
|
||||||
|
// ist nur die freundlichere Fehlermeldung fuer den haeufigeren Fall.
|
||||||
|
const SERIALIZATION_CONFLICT_CODE = "P2034";
|
||||||
|
|
||||||
function generateOrderNumber(): string {
|
function generateOrderNumber(): string {
|
||||||
const year = new Date().getFullYear();
|
const year = new Date().getFullYear();
|
||||||
@@ -173,43 +186,71 @@ export async function POST(request: Request) {
|
|||||||
// updateMany (quantity >= bestellt) verhindert Ueberverkauf auch bei
|
// updateMany (quantity >= bestellt) verhindert Ueberverkauf auch bei
|
||||||
// gleichzeitigen Bestellungen — schlaegt eine Position fehl, wird die
|
// gleichzeitigen Bestellungen — schlaegt eine Position fehl, wird die
|
||||||
// gesamte Transaktion (inkl. bereits reservierter Positionen) verworfen.
|
// gesamte Transaktion (inkl. bereits reservierter Positionen) verworfen.
|
||||||
order = await prisma.$transaction(async (tx) => {
|
order = await prisma.$transaction(
|
||||||
for (const item of orderItemsData) {
|
async (tx) => {
|
||||||
const reserved = await tx.inventory.updateMany({
|
for (const item of orderItemsData) {
|
||||||
where: { productId: item.productId, quantity: { gte: item.quantity } },
|
const reserved = await tx.inventory.updateMany({
|
||||||
data: { quantity: { decrement: item.quantity } },
|
where: { productId: item.productId, quantity: { gte: item.quantity } },
|
||||||
});
|
data: { quantity: { decrement: item.quantity } },
|
||||||
if (reserved.count === 0) {
|
});
|
||||||
throw new OutOfStockError(item.productName);
|
if (reserved.count === 0) {
|
||||||
|
throw new OutOfStockError(item.productName);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return tx.order.create({
|
// Verbindlicher Re-Check direkt vor dem Anlegen der Bestellung: der
|
||||||
data: {
|
// fruehe pendingUses-Check oben ist nur ein schneller Vorabfilter
|
||||||
orderNumber: generateOrderNumber(),
|
// (bessere Fehlermeldung fuer den Normalfall), dieser hier — innerhalb
|
||||||
customerId: customer?.id,
|
// derselben Serializable-Transaktion wie die Bestellanlage, mit einem
|
||||||
locale,
|
// frischen Datenbank-Stand statt der aeusseren (potenziell leicht
|
||||||
fulfillmentMethod: input.fulfillmentMethod,
|
// veralteten) Momentaufnahme — ist der tatsaechlich verbindliche
|
||||||
email: input.email,
|
// Schutz gegen zwei gleichzeitige Checkouts mit demselben knapp
|
||||||
firstName: input.firstName,
|
// begrenzten Code.
|
||||||
lastName: input.lastName,
|
if (discountCode) {
|
||||||
phone: input.phone,
|
const freshCode = await tx.discountCode.findUnique({ where: { id: discountCode.id } });
|
||||||
street: input.street ?? "",
|
if (freshCode && freshCode.maxUses !== null) {
|
||||||
zip: input.zip ?? "",
|
const pendingUses = await tx.order.count({
|
||||||
city: input.city ?? "",
|
where: { discountCodeId: freshCode.id, status: "NEU" },
|
||||||
country: input.country,
|
});
|
||||||
subtotalCents,
|
if (freshCode.usedCount + pendingUses >= freshCode.maxUses) {
|
||||||
shippingCents,
|
throw new DiscountCodeExhaustedError();
|
||||||
discountCents,
|
}
|
||||||
discountCodeId: discountCode?.id,
|
}
|
||||||
totalCents,
|
}
|
||||||
giftMessage: input.giftMessage || null,
|
|
||||||
items: {
|
return tx.order.create({
|
||||||
create: orderItemsData,
|
data: {
|
||||||
|
orderNumber: generateOrderNumber(),
|
||||||
|
customerId: customer?.id,
|
||||||
|
locale,
|
||||||
|
fulfillmentMethod: input.fulfillmentMethod,
|
||||||
|
email: input.email,
|
||||||
|
firstName: input.firstName,
|
||||||
|
lastName: input.lastName,
|
||||||
|
phone: input.phone,
|
||||||
|
street: input.street ?? "",
|
||||||
|
zip: input.zip ?? "",
|
||||||
|
city: input.city ?? "",
|
||||||
|
country: input.country,
|
||||||
|
subtotalCents,
|
||||||
|
shippingCents,
|
||||||
|
discountCents,
|
||||||
|
discountCodeId: discountCode?.id,
|
||||||
|
totalCents,
|
||||||
|
giftMessage: input.giftMessage || null,
|
||||||
|
items: {
|
||||||
|
create: orderItemsData,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
});
|
||||||
});
|
},
|
||||||
});
|
// Serializable statt der DB-Voreinstellung ("Read Committed"): erst
|
||||||
|
// dadurch erkennt die Datenbank selbst den Konflikt zwischen zwei
|
||||||
|
// gleichzeitigen Transaktionen, die beide denselben knapp begrenzten
|
||||||
|
// Gutscheincode einloesen wollen, und bricht eine davon zuverlaessig ab
|
||||||
|
// (statt beide auf Basis veralteter Zaehlerstaende durchzulassen).
|
||||||
|
{ isolationLevel: "Serializable" }
|
||||||
|
);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
if (error instanceof OutOfStockError) {
|
if (error instanceof OutOfStockError) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -219,6 +260,18 @@ export async function POST(request: Request) {
|
|||||||
{ status: 409 }
|
{ status: 409 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (error instanceof DiscountCodeExhaustedError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Der Gutscheincode ist ungültig, abgelaufen oder nicht mehr einlösbar." },
|
||||||
|
{ status: 409 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === SERIALIZATION_CONFLICT_CODE) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Kurzzeitig zu viele gleichzeitige Bestellungen. Bitte versuche es in ein paar Sekunden erneut." },
|
||||||
|
{ status: 409 }
|
||||||
|
);
|
||||||
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user