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:
Nick SzaunigandClaude Sonnet 5 committed 2026-09-03 23:20:38 +02:00
1 parent b6db9254a9
commit c59913b75b
2 files changed
+107 -38

No files matched your search

+20 -4
View File
@@ -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;
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.");
}
// 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();
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 tx.returnRequest.update({ where: { id }, data: { status: "ERSTATTET" } });
await tx.payment.update({ where: { orderId: returnRequest.orderId }, data: { status: "REFUNDED" } });
// Zurueckgesendete Ware wieder als verfuegbaren Bestand fuehren, analog
// zur Stornierung im Checkout-Webhook.