diff --git a/src/app/admin/actions/discount-codes.ts b/src/app/admin/actions/discount-codes.ts index ec78c20..88c5353 100644 --- a/src/app/admin/actions/discount-codes.ts +++ b/src/app/admin/actions/discount-codes.ts @@ -82,7 +82,15 @@ export async function deleteDiscountCodeAction(formData: FormData): Promise const admin = await requireAdmin(); const orderId = str(formData, "orderId"); const status = str(formData, "status") as (typeof ORDER_STATUSES)[number]; - if (!ORDER_STATUSES.includes(status)) return; + const detailPath = `/admin/bestellungen/${orderId}`; + if (!ORDER_STATUSES.includes(status)) { + fail(detailPath, "Ungültiger Status."); + } const result = await prisma.$transaction(async (tx) => { const previous = await tx.order.findUnique({ @@ -41,12 +44,24 @@ export async function updateOrderStatusAction(formData: FormData): Promise }); if (!previous) return null; - await tx.order.update({ where: { id: orderId }, data: { status } }); - - // Beim Checkout reservierter Bestand wird bei Stornierung zurueckgebucht - // (genau einmal — nur beim Wechsel VON einem nicht-stornierten Status). - if (status === "STORNIERT" && previous.status !== "STORNIERT") { - await restockOrderItems(tx, previous.items); + if (status === "STORNIERT") { + // Atomarer Claim statt einfachem update(): verhindert, dass zwei + // schnell aufeinanderfolgende Stornierungs-Versuche (z.B. zwei offene + // Admin-Tabs auf derselben Bestellung) beide restockOrderItems + // ausloesen und den Lagerbestand doppelt gutschreiben. Ein getrenntes + // findUnique+update koennte das nicht verhindern, da beide Versuche + // noch denselben "nicht schon storniert"-Stand lesen wuerden, bevor + // einer von ihnen schreibt — die WHERE-Klausel im UPDATE selbst wird + // dagegen atomar gegen den aktuellen Zeilenstand ausgewertet. + const claim = await tx.order.updateMany({ + where: { id: orderId, status: { not: "STORNIERT" } }, + data: { status }, + }); + if (claim.count === 1) { + await restockOrderItems(tx, previous.items); + } + } else { + await tx.order.update({ where: { id: orderId }, data: { status } }); } let shipment: { carrier: string | null; trackingNumber: string | null } | null = null; @@ -76,6 +91,10 @@ export async function updateOrderStatusAction(formData: FormData): Promise return { ...previous, shipment }; }); + if (!result) { + fail(detailPath, "Die Bestellung wurde nicht gefunden — möglicherweise wurde sie zwischenzeitlich gelöscht."); + } + if (result) { await logAdminAction(admin, { action: "order.status_change", diff --git a/src/app/api/checkout/webhook/route.ts b/src/app/api/checkout/webhook/route.ts index da6e1cf..5e13ff2 100644 --- a/src/app/api/checkout/webhook/route.ts +++ b/src/app/api/checkout/webhook/route.ts @@ -90,16 +90,25 @@ async function fulfillOrder(session: Stripe.Checkout.Session): Promise { // Mal stillschweigend abweisen und Payment/Invoice/E-Mail blieben fuer // immer unvollstaendig, obwohl die Bestellung laengst bezahlt ist. const order = await prisma.$transaction(async (tx) => { - const existing = await tx.order.findUnique({ - where: { id: orderId }, - select: { status: true }, + // Atomarer Versuch, von "NEU" auf "BEZAHLT" zu wechseln: ein einzelnes + // UPDATE mit "status: NEU" in der WHERE-Klausel laesst bei zwei parallel + // laufenden Webhook-Zustellungen fuer dieselbe Bestellung garantiert nur + // eine davon gewinnen (count === 1) — anders als ein getrenntes + // findUnique+update, bei dem beide Transaktionen den alten Status noch + // lesen koennten, bevor eine von ihnen schreibt, und der Gutschein-Zaehler + // unten doppelt inkrementiert wuerde. + const claim = await tx.order.updateMany({ + where: { id: orderId, status: "NEU" }, + data: { status: "BEZAHLT" }, }); - if (!existing || (existing.status !== "NEU" && existing.status !== "BEZAHLT")) { - return null; - } + const firstTime = claim.count === 1; - if (existing.status === "NEU") { - await tx.order.update({ where: { id: orderId }, data: { status: "BEZAHLT" } }); + if (!firstTime) { + // Entweder ein Retry, der die Bestellung bereits "BEZAHLT" vorfindet + // (siehe Kommentar oben bei fulfillOrder), oder ein anderer Endstatus + // (z.B. "STORNIERT") — im zweiten Fall nichts weiter tun. + const existing = await tx.order.findUnique({ where: { id: orderId }, select: { status: true } }); + if (!existing || existing.status !== "BEZAHLT") return null; } const updated = await tx.order.findUniqueOrThrow({ @@ -107,10 +116,10 @@ async function fulfillOrder(session: Stripe.Checkout.Session): Promise { include: { items: true }, }); - // Nur beim allerersten Durchlauf zaehlen, nicht bei einem Retry, der die - // Bestellung bereits "BEZAHLT" vorfindet — sonst wuerde derselbe - // Gutschein bei jedem erneut zugestellten Webhook nochmal gezaehlt. - if (existing.status === "NEU" && updated.discountCodeId) { + // Nur beim Gewinner des obigen atomaren Claims zaehlen, nie bei einem + // Retry — sonst wuerde derselbe Gutschein bei jedem erneut zugestellten + // Webhook nochmal gezaehlt. + if (firstTime && updated.discountCodeId) { await tx.discountCode.update({ where: { id: updated.discountCodeId }, data: { usedCount: { increment: 1 } }, @@ -192,10 +201,21 @@ async function failOrder(session: Stripe.Checkout.Session): Promise { if (!orderId) return; const result = await prisma.$transaction(async (tx) => { - const previous = await tx.order.findUnique({ + // 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: { - status: true, orderNumber: true, email: true, firstName: true, @@ -203,12 +223,6 @@ async function failOrder(session: Stripe.Checkout.Session): Promise { items: { select: { productId: true, quantity: true } }, }, }); - // Nur von "NEU" aus stornieren: verhindert doppelte Bestandsrueckbuchung, - // falls Stripe denselben Webhook erneut zustellt, und laesst bereits - // bezahlte/stornierte Bestellungen unangetastet. - if (!previous || previous.status !== "NEU") return null; - - await tx.order.update({ where: { id: orderId }, data: { status: "STORNIERT" } }); // Beim Checkout reservierten Bestand zurueckbuchen (gleiches Muster wie // bei manueller Stornierung im Admin-Bereich, siehe admin/actions.ts). diff --git a/src/app/konto/actions/login.ts b/src/app/konto/actions/login.ts index 2db67c5..395677f 100644 --- a/src/app/konto/actions/login.ts +++ b/src/app/konto/actions/login.ts @@ -123,13 +123,19 @@ export async function verifyTwoFactorAction(formData: FormData): Promise { if (!authenticated && code.length > 0) { const codeHash = hashBackupCode(code.toUpperCase()); - const backupCode = await prisma.twoFactorBackupCode.findFirst({ + // Atomarer Claim statt findFirst+update: ein getrenntes Lesen und + // Schreiben liesse zwei parallele Anfragen mit demselben (z.B. + // abgefangenen) Backup-Code beide noch den "unbenutzt"-Stand sehen, + // bevor eine von ihnen schreibt — beide wuerden dann authentifiziert + // und die Einmal-Verwendbarkeit des Codes waere ausgehebelt. Die + // WHERE-Klausel im UPDATE selbst wird dagegen atomar gegen den + // aktuellen Zeilenstand ausgewertet, sodass hoechstens ein Aufruf + // "gewinnt" (count === 1). + const claim = await prisma.twoFactorBackupCode.updateMany({ where: { customerId: customer.id, codeHash, usedAt: null }, + data: { usedAt: new Date() }, }); - if (backupCode) { - await prisma.twoFactorBackupCode.update({ where: { id: backupCode.id }, data: { usedAt: new Date() } }); - authenticated = true; - } + authenticated = claim.count === 1; } if (!authenticated) {