From 8b5d5d095cdd49eeb4909ac509dfeb01ca357987 Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Mon, 7 Sep 2026 13:35:04 +0200 Subject: [PATCH] fix: harden payment/shipping paths against partial-failure edge cases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dispatched a read-only silent-failure audit (silent-failure-hunter agent) over the checkout/webhook/DHL/mail code paths, then verified each finding by hand before fixing. Four confirmed, real gaps: - Stripe webhook (fulfillOrder): Payment/Invoice creation and the confirmation email ran after the order's status flip to "BEZAHLT", outside any try/catch. If any of those three failed (DB blip), the handler threw uncaught -> Stripe retried -> the retry's status==="NEU" guard now found 0 rows and silently skipped the retry forever, leaving a paid order with no invoice, no payment record, and no confirmation email, with nothing logged. Fixed by allowing "BEZAHLT" as a valid retry entry point (idempotent upserts) and wrapping the post-transaction block in try/catch that logs loudly and re-throws (so Stripe still retries). - DHL label creation: the already-billed tracking number's DB write sat outside the try/catch that protects the (paid) DHL API call. A failed write there lost the tracking number with zero record of it, while the claim sentinel permanently blocked any retry through the UI. Fixed by wrapping the write, logging the tracking number for manual reconciliation, and deliberately leaving the claim in place (not resettable) to avoid triggering a second, separately billed label. - Checkout route: a DB write failure for the (currently unread, write-only) stripeCheckoutSessionId field discarded an already live, payable Stripe session and showed the customer a generic error, risking a duplicate order + stock reservation on retry. Made that specific write best-effort/non-fatal. - DHL fetch calls had no request timeout, so a hung (non-erroring) DHL API could block an admin action indefinitely. Added a 20s AbortSignal.timeout to both. Verified: tsc clean, 151/151 existing tests still pass. No dedicated DB-integration test harness exists yet for route/webhook handlers in this codebase (only pure-function unit tests) — building one is a separate, larger undertaking than this fix warrants. Co-Authored-By: Claude Sonnet 5 --- src/app/admin/actions/dhl.ts | 28 +++++- src/app/api/checkout/route.ts | 22 ++++- src/app/api/checkout/webhook/route.ts | 137 +++++++++++++++----------- src/lib/dhl.ts | 8 ++ 4 files changed, 130 insertions(+), 65 deletions(-) diff --git a/src/app/admin/actions/dhl.ts b/src/app/admin/actions/dhl.ts index 4c39f0d..4fa0ae8 100644 --- a/src/app/admin/actions/dhl.ts +++ b/src/app/admin/actions/dhl.ts @@ -131,11 +131,29 @@ export async function createDhlLabelAction(formData: FormData): Promise { fail(detailPath, `DHL-Versandmarke konnte nicht erstellt werden: ${message}`); } - await prisma.shipment.upsert({ - where: { orderId }, - create: { orderId, carrier: "DHL", trackingNumber: result.trackingNumber }, - update: { carrier: "DHL", trackingNumber: result.trackingNumber }, - }); + try { + await prisma.shipment.upsert({ + where: { orderId }, + create: { orderId, carrier: "DHL", trackingNumber: result.trackingNumber }, + update: { carrier: "DHL", trackingNumber: result.trackingNumber }, + }); + } catch (error: unknown) { + // Die DHL-Marke wurde bereits kostenpflichtig erstellt — geht die + // Sendungsnummer hier verloren, ist sie ueber die DHL-API nicht erneut + // abrufbar. Deshalb laut loggen (fuer manuelle Nacherfassung ueber das + // Bestellungs-Detail, siehe admin/actions/orders.ts) statt sie + // stillschweigend verschwinden zu lassen. Der Sentinel-Claim bleibt + // bewusst unangetastet, damit kein zweiter — erneut kostenpflichtiger — + // Versuch ueber die UI ausgeloest werden kann. + console.error( + `createDhlLabelAction: DHL-Sendungsnummer ${result.trackingNumber} fuer Bestellung ${order.orderNumber} (${orderId}) konnte nicht gespeichert werden — manuelle Nacherfassung noetig.`, + error + ); + fail( + detailPath, + "Die DHL-Versandmarke wurde erstellt, aber die Sendungsnummer konnte nicht gespeichert werden. Bitte Support kontaktieren, um sie manuell nachzutragen." + ); + } await logAdminAction(admin, { action: "order.dhl_label_created", diff --git a/src/app/api/checkout/route.ts b/src/app/api/checkout/route.ts index b14d130..70475be 100644 --- a/src/app/api/checkout/route.ts +++ b/src/app/api/checkout/route.ts @@ -337,10 +337,24 @@ export async function POST(request: Request) { metadata: { orderId: order.id, orderNumber: order.orderNumber }, }); - await prisma.order.update({ - where: { id: order.id }, - data: { stripeCheckoutSessionId: session.id }, - }); + // Best-effort: stripeCheckoutSessionId wird aktuell von keinem Lesepfad + // ausgewertet (fulfillOrder im Webhook identifiziert die Bestellung ueber + // session.metadata.orderId, nicht ueber dieses Feld) — schlaegt nur + // dieser Schreibzugriff fehl, waere es falsch, die bereits erstellte, + // echte und bezahlbare Stripe-Session deswegen zu verwerfen und der + // Kundschaft einen Fehler zu zeigen (Risiko: Retry legt eine zweite + // Bestellung samt zweiter Bestandsreservierung an). + try { + await prisma.order.update({ + where: { id: order.id }, + data: { stripeCheckoutSessionId: session.id }, + }); + } catch (error: unknown) { + console.error( + `Speichern von stripeCheckoutSessionId fuer Bestellung ${order.orderNumber} fehlgeschlagen (Session ${session.id} bleibt trotzdem gueltig):`, + error + ); + } return NextResponse.json({ url: session.url }); } catch (error: unknown) { diff --git a/src/app/api/checkout/webhook/route.ts b/src/app/api/checkout/webhook/route.ts index 46151af..da6e1cf 100644 --- a/src/app/api/checkout/webhook/route.ts +++ b/src/app/api/checkout/webhook/route.ts @@ -83,19 +83,34 @@ async function fulfillOrder(session: Stripe.Checkout.Session): Promise { // doppelt zaehlen, und verhindert, dass eine im Admin-Bereich bereits // stornierte (und zurueckgebuchte) Bestellung durch eine verspaetete // Zahlungsbestaetigung faelschlich wieder auf "BEZAHLT" gesetzt wird. + // "BEZAHLT" ist als Ausgangsstatus bewusst mit erlaubt (nicht nur "NEU"): + // schlaegt der Payment/Invoice/E-Mail-Block weiter unten fehl, nachdem der + // Status hier schon auf "BEZAHLT" stand, liefert Stripe denselben Webhook + // erneut aus — ohne diese Erweiterung wuerde der Guard diesen Retry jedes + // 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 result = await tx.order.updateMany({ - where: { id: orderId, status: "NEU" }, - data: { status: "BEZAHLT" }, + const existing = await tx.order.findUnique({ + where: { id: orderId }, + select: { status: true }, }); - if (result.count === 0) return null; + if (!existing || (existing.status !== "NEU" && existing.status !== "BEZAHLT")) { + return null; + } + + if (existing.status === "NEU") { + await tx.order.update({ where: { id: orderId }, data: { status: "BEZAHLT" } }); + } const updated = await tx.order.findUniqueOrThrow({ where: { id: orderId }, include: { items: true }, }); - if (updated.discountCodeId) { + // 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) { await tx.discountCode.update({ where: { id: updated.discountCodeId }, data: { usedCount: { increment: 1 } }, @@ -105,61 +120,71 @@ async function fulfillOrder(session: Stripe.Checkout.Session): Promise { return updated; }); - // Bereits verarbeitet (Webhook-Replay) oder Bestellung war nicht mehr - // im Status "NEU" (z.B. schon storniert) — nichts weiter zu tun. + // Bestellung existiert nicht (mehr) oder war weder "NEU" noch "BEZAHLT" + // (z.B. bereits storniert) — nichts weiter zu tun. if (!order) return; - await prisma.payment.upsert({ - where: { orderId: order.id }, - create: { - orderId: order.id, - stripePaymentIntentId: - typeof session.payment_intent === "string" ? session.payment_intent : null, - status: "SUCCEEDED", - amountCents: session.amount_total ?? order.totalCents, - paidAt: new Date(), - }, - update: { - status: "SUCCEEDED", - paidAt: new Date(), - }, - }); + try { + await prisma.payment.upsert({ + where: { orderId: order.id }, + create: { + orderId: order.id, + stripePaymentIntentId: + typeof session.payment_intent === "string" ? session.payment_intent : null, + status: "SUCCEEDED", + amountCents: session.amount_total ?? order.totalCents, + paidAt: new Date(), + }, + update: { + status: "SUCCEEDED", + paidAt: new Date(), + }, + }); - await prisma.invoice.upsert({ - where: { orderId: order.id }, - create: { - orderId: order.id, - invoiceNumber: `RE-${new Date().getFullYear()}-${randomBytes(4).toString("hex").toUpperCase()}`, + await prisma.invoice.upsert({ + where: { orderId: order.id }, + create: { + orderId: order.id, + invoiceNumber: `RE-${new Date().getFullYear()}-${randomBytes(4).toString("hex").toUpperCase()}`, + totalCents: order.totalCents, + }, + update: {}, + }); + + const { subject, html, text } = await orderConfirmationEmail({ + locale: isLocale(order.locale) ? order.locale : defaultLocale, + orderNumber: order.orderNumber, + firstName: order.firstName, + fulfillmentMethod: order.fulfillmentMethod, + items: order.items.map((item) => ({ + productName: item.productName, + quantity: item.quantity, + weightGrams: item.weightGrams, + totalCents: item.totalCents, + })), + subtotalCents: order.subtotalCents, + shippingCents: order.shippingCents, + discountCents: order.discountCents, totalCents: order.totalCents, - }, - update: {}, - }); - - // E-Mail-Fehler duerfen die Zahlungsbestaetigung selbst nicht scheitern - // lassen — Stripe wiederholt Webhooks bei Fehlerantworten, ein - // Mailserver-Ausfall soll das nicht auf die Bestellabwicklung durchschlagen. - const { subject, html, text } = await orderConfirmationEmail({ - locale: isLocale(order.locale) ? order.locale : defaultLocale, - orderNumber: order.orderNumber, - firstName: order.firstName, - fulfillmentMethod: order.fulfillmentMethod, - items: order.items.map((item) => ({ - productName: item.productName, - quantity: item.quantity, - weightGrams: item.weightGrams, - totalCents: item.totalCents, - })), - subtotalCents: order.subtotalCents, - shippingCents: order.shippingCents, - discountCents: order.discountCents, - totalCents: order.totalCents, - street: order.street, - zip: order.zip, - city: order.city, - giftMessage: order.giftMessage, - trackingUrl: `${getBaseUrl()}/bestellung-verfolgen?order=${encodeURIComponent(order.orderNumber)}&email=${encodeURIComponent(order.email)}`, - }); - await sendMail({ to: order.email, subject, html, text }); + street: order.street, + zip: order.zip, + city: order.city, + giftMessage: order.giftMessage, + trackingUrl: `${getBaseUrl()}/bestellung-verfolgen?order=${encodeURIComponent(order.orderNumber)}&email=${encodeURIComponent(order.email)}`, + }); + await sendMail({ to: order.email, subject, html, text }); + } catch (error: unknown) { + // Laut sichtbar machen (statt wie zuvor unbehandelt bis in den + // Next.js-Request-Handler durchzureichen) und weiterwerfen: der 500er + // an Stripe loest den naechsten automatischen Retry aus, der dank des + // "BEZAHLT"-Zweigs oben Payment/Invoice/E-Mail dann nachholen kann, + // statt sie fuer immer zu verlieren. + console.error( + `fulfillOrder: Payment/Invoice/Bestaetigungsmail fehlgeschlagen fuer Order ${order.id} (Stripe-Session ${session.id})`, + error + ); + throw error; + } } async function failOrder(session: Stripe.Checkout.Session): Promise { diff --git a/src/lib/dhl.ts b/src/lib/dhl.ts index eba90c6..26b0b4d 100644 --- a/src/lib/dhl.ts +++ b/src/lib/dhl.ts @@ -55,6 +55,12 @@ export class DhlApiError extends Error { // Konfiguration // --------------------------------------------------------------------------- +// Ohne Timeout wuerde eine haengende (nicht fehlerhafte, sondern einfach nie +// antwortende) DHL-API die aufrufende Admin-Server-Action unbegrenzt +// blockieren und dabei den DHL_CLAIM_SENTINEL-Claim (siehe admin/actions/dhl.ts) +// auf unbestimmte Zeit offen halten. +const DHL_REQUEST_TIMEOUT_MS = 20_000; + interface DhlConfig { apiKey: string; apiSecret: string; @@ -124,6 +130,7 @@ async function fetchAccessToken(config: DhlConfig): Promise { client_id: config.apiKey, client_secret: config.apiSecret, }), + signal: AbortSignal.timeout(DHL_REQUEST_TIMEOUT_MS), }); } catch (error: unknown) { const message = error instanceof Error ? error.message : "Unbekannter Netzwerkfehler"; @@ -291,6 +298,7 @@ export async function createDhlShipmentLabel( "dhl-api-key": config.apiKey, }, body: JSON.stringify(requestBody), + signal: AbortSignal.timeout(DHL_REQUEST_TIMEOUT_MS), }); } catch (error: unknown) { const message = error instanceof Error ? error.message : "Unbekannter Netzwerkfehler";