"use server"; import { revalidatePath } from "next/cache"; import { prisma } from "@/lib/prisma"; import { logAdminAction } from "@/lib/audit-log"; import { sendMail } from "@/lib/mail"; import { shipmentEmail, pickupReadyEmail } from "@/lib/email-templates"; import { getCarrierTrackingUrl } from "@/lib/shipment-tracking"; import { isOrderStatusChangeAllowed, needsRestockOnCancel, restockOrderItems } from "@/lib/inventory"; import { checkOrderDeletable, needsRestockOnDelete } from "@/lib/order-delete"; import { getStripeClient } from "@/lib/stripe"; import { isLocale, defaultLocale } from "@/i18n/config"; import type { OrderStatus, PaymentStatus } from "@/generated/prisma/client"; import { requireAdmin, str, strOrNull, fail } from "./_shared"; import { setFlash, succeed, MAIL_FAILED_HINT } from "@/lib/flash"; const ORDER_STATUSES = [ "NEU", "BEZAHLT", "IN_VORBEREITUNG", "VERSANDBEREIT", "VERSENDET", "ABHOLBEREIT", "ABGEHOLT", "STORNIERT", ] as const; /** * Erledigt die Geldseite einer gerade ausgesprochenen Stornierung. * * Zwei Dinge, die eine Statusaenderung allein offen laesst: * * 1. WAR SCHON BEZAHLT — dann muss das Geld zurueck. Ohne diesen Schritt * stand die Bestellung als "storniert" da, die Ware lag wieder im Regal, * und der Betrag blieb trotzdem beim Shop. Erstattet wird der volle * Betrag; eine Teilerstattung ist ueber den Retouren-Bereich abzuwickeln, * wo sie hingehoert. * * 2. WAR NOCH OFFEN ("NEU") — dann ist die Stripe-Sitzung unter Umstaenden * noch bezahlbar. Sie muss geschlossen werden, sonst kann die Kundschaft * Minuten spaeter noch zahlen: der Webhook findet die Bestellung dann * nicht mehr im Status "NEU", bricht ab, und das Geld liegt bei Stripe, * ohne dass im Shop irgendetwas davon zu sehen waere. * * Fehler werden nicht geworfen, sondern als Klartext zurueckgegeben. Die * Stornierung ist an dieser Stelle schon geschrieben und die Ware * zurueckgebucht — sie deswegen abzubrechen wuerde nichts heilen. Was offen * bleibt, gehoert stattdessen sichtbar vor die Imkerei, damit sie es von Hand * erledigen kann. * * @returns leere Liste, wenn alles erledigt ist; sonst je offener Punkt einen * Satz fuer die Fehlermeldung im Admin-Bereich. */ async function settleCancelledOrder(order: { orderId: string; orderNumber: string; previousStatus: OrderStatus; stripeCheckoutSessionId: string | null; payment: { status: PaymentStatus; stripePaymentIntentId: string | null } | null; }): Promise { const warnings: string[] = []; // Nur eine tatsaechlich eingezogene Zahlung wird erstattet. "FAILED" gab es // nie zu erstatten, "REFUNDED" waere eine zweite Erstattung desselben // Betrags, und "PENDING" ist noch gar nicht abgeschlossen. if (order.payment?.status === "SUCCEEDED") { const paymentIntentId = order.payment.stripePaymentIntentId; if (!paymentIntentId) { warnings.push( "Zu dieser bezahlten Bestellung ist keine Stripe-Zahlung hinterlegt — die Erstattung muss von Hand angestossen werden." ); } else { try { const stripe = getStripeClient(); await stripe.refunds.create({ payment_intent: paymentIntentId }); await prisma.payment.update({ where: { orderId: order.orderId }, data: { status: "REFUNDED" }, }); } catch (error: unknown) { console.error( `[order-cancel] Erstattung fuer Bestellung ${order.orderNumber} (PaymentIntent ${paymentIntentId}) fehlgeschlagen:`, error ); warnings.push( "Die Erstattung ueber Stripe ist fehlgeschlagen — bitte im Stripe-Dashboard von Hand erstatten." ); } } } if (order.previousStatus === "NEU" && order.stripeCheckoutSessionId) { try { const stripe = getStripeClient(); const session = await stripe.checkout.sessions.retrieve(order.stripeCheckoutSessionId); // Nur eine offene Sitzung laesst sich schliessen. "complete" bedeutet, // dass in der Zwischenzeit doch bezahlt wurde — dann ist das hier ein // Wettlauf, den die Zahlung gewonnen hat, und er gehoert gemeldet statt // stillschweigend uebergangen. if (session.status === "open") { await stripe.checkout.sessions.expire(order.stripeCheckoutSessionId); } else if (session.status === "complete") { warnings.push( "Achtung: Diese Bestellung wurde bei Stripe zwischenzeitlich doch bezahlt. Bitte den Zahlungseingang pruefen und die Erstattung von Hand anstossen." ); } } catch (error: unknown) { console.error( `[order-cancel] Zahlungssitzung ${order.stripeCheckoutSessionId} der Bestellung ${order.orderNumber} konnte nicht geschlossen werden:`, error ); warnings.push( "Die offene Zahlungssitzung konnte nicht geschlossen werden — sie laeuft von selbst ab, bis dahin ist eine Zahlung noch moeglich." ); } } return warnings; } export async function updateOrderStatusAction(formData: FormData): Promise { const admin = await requireAdmin(); const orderId = str(formData, "orderId"); const status = str(formData, "status") as (typeof ORDER_STATUSES)[number]; const detailPath = `/admin/bestellungen/${orderId}`; if (!ORDER_STATUSES.includes(status)) { return fail(detailPath, "Ungültiger Status."); } const result = await prisma.$transaction(async (tx) => { const previous = await tx.order.findUnique({ where: { id: orderId }, select: { status: true, orderNumber: true, email: true, firstName: true, locale: true, stripeCheckoutSessionId: true, payment: { select: { status: true, stripePaymentIntentId: true } }, items: { select: { productId: true, quantity: true } }, returnRequest: { select: { status: true } }, }, }); if (!previous) return null; // Stornierte Bestellungen bleiben storniert (siehe // isOrderStatusChangeAllowed) — sonst bucht ein erneutes Stornieren die // Ware ein zweites Mal zurueck. if (!isOrderStatusChangeAllowed(previous.status, status)) { return { ...previous, shipment: null, cancelledNow: false, blocked: true }; } // Nur wahr, wenn DIESER Aufruf die Bestellung tatsaechlich storniert hat. // Steuert weiter unten Erstattung und Schliessen der Zahlungssitzung — // beides darf bei einem zweiten Klick auf eine bereits stornierte // Bestellung nicht noch einmal passieren. let cancelledNow = false; 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) { cancelledNow = true; if (needsRestockOnCancel(previous.returnRequest?.status ?? null)) { await restockOrderItems(tx, previous.items); } } } else { // Atomar gegen den aktuellen Zeilenstand: hat ein zweiter Tab die // Bestellung zwischen Lesen und Schreiben storniert, bleibt sie das. const moved = await tx.order.updateMany({ where: { id: orderId, status: { not: "STORNIERT" } }, data: { status }, }); if (moved.count === 0) { return { ...previous, shipment: null, cancelledNow: false, blocked: true }; } } let shipment: { carrier: string | null; trackingNumber: string | null } | null = null; if (status === "VERSENDET") { shipment = await tx.shipment.upsert({ where: { orderId }, create: { orderId, shippedAt: new Date() }, update: { shippedAt: new Date() }, select: { carrier: true, trackingNumber: true }, }); } if (status === "ABHOLBEREIT") { await tx.pickup.upsert({ where: { orderId }, create: { orderId, readyAt: new Date() }, update: { readyAt: new Date() }, }); } if (status === "ABGEHOLT") { await tx.pickup.upsert({ where: { orderId }, create: { orderId, pickedUpAt: new Date() }, update: { pickedUpAt: new Date() }, }); } return { ...previous, shipment, cancelledNow, blocked: false }; }); if (!result) { return fail(detailPath, "Die Bestellung wurde nicht gefunden — möglicherweise wurde sie zwischenzeitlich gelöscht."); } if (result.blocked) { return fail( detailPath, "Diese Bestellung ist storniert: die Ware ist zurückgebucht und die Zahlung erstattet. Sie lässt sich nicht wieder öffnen — soll die Ware doch raus, bitte eine neue Bestellung anlegen." ); } // undefined = keine E-Mail faellig; false = faellig, aber nicht versendet. let mailSent: boolean | undefined; if (result) { await logAdminAction(admin, { action: "order.status_change", entityType: "Order", entityId: orderId, detail: `${result.orderNumber}: ${result.status} → ${status}`, }); // Die Geldseite der Stornierung — bewusst NACH der Transaktion: ein // Stripe-Aufruf dauert ein Vielfaches einer Datenbankoperation und wuerde // die Zeilensperren auf Bestellung und Lagerbestand so lange halten. // // Bis hierher hat die Stornierung nur den Status gesetzt und die Ware // zurueckgebucht. Ohne die beiden folgenden Schritte blieb eine // Stornierung auf halbem Weg stehen: bei einer bezahlten Bestellung // behielt der Shop das Geld, bei einer noch offenen blieb die // Zahlungssitzung bezahlbar. if (result.cancelledNow) { const stornoWarnings = await settleCancelledOrder({ orderId, orderNumber: result.orderNumber, previousStatus: result.status, stripeCheckoutSessionId: result.stripeCheckoutSessionId, payment: result.payment, }); if (stornoWarnings.length > 0) { await logAdminAction(admin, { action: "order.cancel_incomplete", entityType: "Order", entityId: orderId, detail: `${result.orderNumber}: ${stornoWarnings.join(" | ")}`, }); // Die Stornierung selbst steht und wird NICHT zurueckgenommen — die // Ware ist bereits wieder im Bestand und die Absicht war eindeutig. // Aber der offene Rest muss auf dem Bildschirm landen und nicht nur // im Log, sonst haelt die Imkerei den Vorgang fuer abgeschlossen. return fail(detailPath, `Bestellung storniert, aber: ${stornoWarnings.join(" ")}`); } } // E-Mail-Benachrichtigung bei Versand/Abholbereitschaft — bewusst nach // der Transaktion, ein Mail-Ausfall darf die Statusaenderung nicht // rueckgaengig machen (sendMail wirft nie, siehe lib/mail.ts). const locale = isLocale(result.locale) ? result.locale : defaultLocale; if (status === "VERSENDET" && result.status !== "VERSENDET") { const carrier = result.shipment?.carrier ?? null; const trackingNumber = result.shipment?.trackingNumber ?? null; const { subject, html, text } = await shipmentEmail({ locale, orderNumber: result.orderNumber, firstName: result.firstName, carrier, trackingNumber, trackingUrl: getCarrierTrackingUrl(carrier, trackingNumber), }); mailSent = await sendMail({ to: result.email, subject, html, text }); } if (status === "ABHOLBEREIT" && result.status !== "ABHOLBEREIT") { const { subject, html, text } = await pickupReadyEmail({ locale, orderNumber: result.orderNumber, firstName: result.firstName, }); mailSent = await sendMail({ to: result.email, subject, html, text }); } } revalidatePath("/admin/bestellungen"); const done = `Status von ${result.orderNumber} gespeichert.`; if (mailSent === false) return fail(detailPath, `${done} Aber: ${MAIL_FAILED_HINT}`); await setFlash("success", mailSent ? `${done} Die Kundschaft wurde per E-Mail informiert.` : done); } /** * Loescht eine Bestellung endgueltig — aber nur dort, wo nie ein * Geschaeftsvorfall daraus geworden ist. * * WARUM NICHT JEDE BESTELLUNG: * Eine bezahlte Bestellung und die Rechnung dazu sind Buchungsbelege. § 147 * AO und § 257 HGB verlangen, dass sie jahrelang vorgelegt werden koennen. * Ein Loeschknopf darauf wuerde also nicht aufraeumen, sondern Unterlagen * vernichten, die im Zweifel das Finanzamt sehen will — und zwar ohne * Rueckgaengig. Wer eine bezahlte Bestellung loswerden will, storniert sie: * das erstattet das Geld, bucht die Ware zurueck und laesst den Beleg stehen. * * Erlaubt ist das Loeschen deshalb nur, wenn ALLE drei Bedingungen gelten: * - keine Zahlung erfasst (auch keine fehlgeschlagene — sie belegt einen * Zahlungsversuch), * - keine Rechnung erzeugt, * - Status NEU oder STORNIERT. * Das trifft genau auf abgebrochene Checkouts und Testbestellungen zu. * * LAGERBESTAND: bei NEU ist die Ware noch reserviert und wird beim Loeschen * zurueckgebucht. Bei STORNIERT ist das bereits passiert (siehe * updateOrderStatusAction) — dort noch einmal gutzuschreiben wuerde den * Bestand erhoehen, ohne dass je ein Glas zurueckkam. * * ZAHLUNGSSITZUNG: eine Bestellung im Status NEU kann eine offene * Stripe-Sitzung haben. Wird die Bestellung geloescht, ohne sie zu * schliessen, kann die Kundschaft Minuten spaeter noch bezahlen — der Webhook * findet dann keine Bestellung mehr, und das Geld liegt bei Stripe, ohne dass * im Shop irgendetwas davon zu sehen waere. Deshalb wird die Sitzung VOR dem * Loeschen geschlossen, und wenn das nicht sicher gelingt, wird gar nicht * geloescht. */ export async function deleteOrderAction(formData: FormData): Promise { const admin = await requireAdmin(); const orderId = str(formData, "orderId"); const detailPath = `/admin/bestellungen/${orderId}`; const order = await prisma.order.findUnique({ where: { id: orderId }, select: { status: true, orderNumber: true, totalCents: true, currency: true, email: true, stripeCheckoutSessionId: true, payment: { select: { status: true } }, invoice: { select: { invoiceNumber: true } }, items: { select: { productId: true, quantity: true } }, }, }); if (!order) { return fail("/admin/bestellungen", "Die Bestellung wurde nicht gefunden — möglicherweise ist sie bereits gelöscht."); } const verdict = checkOrderDeletable({ status: order.status, hasPayment: order.payment !== null, hasInvoice: order.invoice !== null, }); if (!verdict.deletable) { return fail(detailPath, { invoice: `Zu dieser Bestellung gehört die Rechnung ${order.invoice?.invoiceNumber}. Rechnungen sind aufbewahrungspflichtig und dürfen nicht gelöscht werden — storniere die Bestellung stattdessen.`, payment: "Zu dieser Bestellung ist ein Zahlungsvorgang erfasst. Sie belegt damit einen Geschäftsvorfall und darf nicht gelöscht werden — storniere sie stattdessen.", status: `Löschen ist nur bei Bestellungen im Status NEU oder STORNIERT möglich, diese steht auf ${order.status}. Storniere sie zuerst.`, }[verdict.reason]); } // Vor dem Loeschen, nicht danach: nach dem Loeschen gaebe es keine // Bestellung mehr, auf die sich eine noch eingehende Zahlung beziehen // koennte. if (order.status === "NEU" && order.stripeCheckoutSessionId) { try { const stripe = getStripeClient(); const session = await stripe.checkout.sessions.retrieve(order.stripeCheckoutSessionId); if (session.status === "open") { await stripe.checkout.sessions.expire(order.stripeCheckoutSessionId); } else if (session.status === "complete") { return fail( detailPath, "Diese Bestellung wurde bei Stripe zwischenzeitlich bezahlt und darf nicht gelöscht werden. Bitte den Zahlungseingang prüfen und die Bestellung stornieren." ); } } catch (error: unknown) { // redirect() aus fail() wirft intern eine Ausnahme, die Next.js // auswertet — sie darf hier nicht als Stripe-Fehler behandelt und // verschluckt werden. if (error instanceof Error && error.message === "NEXT_REDIRECT") throw error; console.error( `[order-delete] Zahlungssitzung ${order.stripeCheckoutSessionId} der Bestellung ${order.orderNumber} konnte nicht geprüft werden:`, error ); return fail( detailPath, "Die offene Zahlungssitzung konnte nicht geschlossen werden. Die Bestellung wurde deshalb NICHT gelöscht — bitte später erneut versuchen." ); } } const deleted = await prisma.$transaction(async (tx) => { // Die Bedingungen stehen bewusst noch einmal im DELETE selbst und nicht // nur in den Pruefungen oben: zwischen Lesen und Loeschen koennte der // Stripe-Webhook eine Zahlung eingetragen haben. Die WHERE-Klausel wird // atomar gegen den aktuellen Zeilenstand ausgewertet, die Pruefung oben // gegen einen Schnappschuss. const claim = await tx.order.deleteMany({ where: { id: orderId, status: { in: ["NEU", "STORNIERT"] }, payment: { is: null }, invoice: { is: null }, }, }); if (claim.count !== 1) return false; if (needsRestockOnDelete(order.status)) { await restockOrderItems(tx, order.items); } return true; }); if (!deleted) { return fail( detailPath, "Die Bestellung hat sich zwischenzeitlich geändert (vermutlich ist eine Zahlung eingegangen) und wurde nicht gelöscht. Bitte die Seite neu laden." ); } // Ausfuehrlicher als bei anderen Aktionen, und das mit Absicht: der // Datensatz ist weg, dieser Eintrag ist die einzige verbleibende Spur. await logAdminAction(admin, { action: "order.delete", entityType: "Order", entityId: orderId, detail: `${order.orderNumber} (Status ${order.status}, ${(order.totalCents / 100).toFixed(2)} ${order.currency}, ${order.email}, ${order.items.length} Position(en)) endgültig gelöscht`, }); revalidatePath("/admin/bestellungen"); return succeed("/admin/bestellungen", `Bestellung ${order.orderNumber} wurde gelöscht.`); } /** * Erfasst Versanddienstleister + Sendungsnummer fuer eine Bestellung — * unabhaengig vom Status setzbar, da die Nummer oft schon vor dem * eigentlichen Versandscan vom Versanddienstleister vergeben wird. Ist die * Bestellung bereits als VERSENDET markiert, wird die Sendungsverfolgung * per separater E-Mail nachgereicht statt nur in der ersten Versand-Mail zu * stehen (die zu diesem Zeitpunkt schon verschickt war). */ export async function updateShipmentTrackingAction(formData: FormData): Promise { const admin = await requireAdmin(); const orderId = str(formData, "orderId"); const carrier = strOrNull(formData, "carrier"); const trackingNumber = strOrNull(formData, "trackingNumber"); const order = await prisma.order.findUnique({ where: { id: orderId }, select: { orderNumber: true, email: true, firstName: true, status: true, locale: true }, }); if (!order) return fail("/admin/bestellungen", "Bestellung nicht gefunden."); await prisma.shipment.upsert({ where: { orderId }, create: { orderId, carrier, trackingNumber }, update: { carrier, trackingNumber }, }); await logAdminAction(admin, { action: "order.tracking_update", entityType: "Order", entityId: orderId, detail: `${order.orderNumber}: ${carrier ?? "—"} ${trackingNumber ?? ""}`.trim(), }); let mailSent: boolean | undefined; if (order.status === "VERSENDET" && carrier && trackingNumber) { const { subject, html, text } = await shipmentEmail({ locale: isLocale(order.locale) ? order.locale : defaultLocale, orderNumber: order.orderNumber, firstName: order.firstName, carrier, trackingNumber, trackingUrl: getCarrierTrackingUrl(carrier, trackingNumber), }); mailSent = await sendMail({ to: order.email, subject, html, text }); } revalidatePath("/admin/bestellungen"); revalidatePath(`/admin/bestellungen/${orderId}`); if (mailSent === false) { return fail(`/admin/bestellungen/${orderId}`, `Sendungsdaten gespeichert. Aber: ${MAIL_FAILED_HINT}`); } await setFlash( "success", mailSent ? "Sendungsdaten gespeichert und per E-Mail an die Kundschaft geschickt." : "Sendungsdaten gespeichert." ); }