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";