diff --git a/src/app/admin/actions/orders.ts b/src/app/admin/actions/orders.ts index b4ab5da..f447b15 100644 --- a/src/app/admin/actions/orders.ts +++ b/src/app/admin/actions/orders.ts @@ -1,12 +1,14 @@ "use server"; import { revalidatePath } from "next/cache"; +import { redirect } from "next/navigation"; 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 { 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"; @@ -270,6 +272,149 @@ export async function updateOrderStatusAction(formData: FormData): Promise revalidatePath("/admin/bestellungen"); } +/** + * 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) { + 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) { + 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") { + 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 + ); + 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) { + 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"); + redirect( + `/admin/bestellungen?message=${encodeURIComponent(`Bestellung ${order.orderNumber} wurde gelöscht.`)}` + ); +} + /** * Erfasst Versanddienstleister + Sendungsnummer fuer eine Bestellung — * unabhaengig vom Status setzbar, da die Nummer oft schon vor dem diff --git a/src/app/admin/bestellungen/[id]/page.tsx b/src/app/admin/bestellungen/[id]/page.tsx index 3338e1a..7ba48de 100644 --- a/src/app/admin/bestellungen/[id]/page.tsx +++ b/src/app/admin/bestellungen/[id]/page.tsx @@ -3,7 +3,14 @@ import Link from "next/link"; import { prisma } from "@/lib/prisma"; import { BackLink } from "@/components/back-link"; import { formatPrice, formatDate } from "@/lib/format"; -import { updateOrderStatusAction, updateShipmentTrackingAction, createDhlLabelAction } from "../../actions"; +import { + updateOrderStatusAction, + updateShipmentTrackingAction, + createDhlLabelAction, + deleteOrderAction, +} from "../../actions"; +import { ConfirmForm } from "@/components/confirm-form"; +import { checkOrderDeletable } from "@/lib/order-delete"; import { getLocale } from "@/i18n/locale"; import { getDictionary } from "@/i18n/get-dictionary"; import { isDhlConfigured } from "@/lib/dhl"; @@ -27,6 +34,14 @@ export default async function AdminOrderDetailPage(props: PageProps<"/admin/best }); if (!order) notFound(); const dhlConfigured = isDhlConfigured(); + // Dieselbe Regel, die deleteOrderAction serverseitig durchsetzt — geteilt + // ueber lib/order-delete.ts, damit Anzeige und Durchsetzung nicht + // auseinanderlaufen koennen. + const isDeletable = checkOrderDeletable({ + status: order.status, + hasPayment: order.payment !== null, + hasInvoice: order.invoice !== null, + }).deletable; return (
@@ -260,6 +275,35 @@ export default async function AdminOrderDetailPage(props: PageProps<"/admin/best )}
+ + {/* Dieselben drei Bedingungen prueft deleteOrderAction noch einmal + serverseitig. Das hier ist reine Anzeige: ein Knopf, den man nicht + sieht, ist keine Sicherheitsgrenze (siehe requireAdmin in + actions/_shared.ts). Steht am Seitenende, damit man nicht aus + Versehen daraufklickt, waehrend man oben den Status setzt. */} +
+

+ {t.adminOrderDetailPage.deleteHeading} +

+ {isDeletable ? ( + <> +

+ {t.adminOrderDetailPage.deleteHint} +

+ + + + + + ) : ( +

{t.adminOrderDetailPage.deleteBlocked}

+ )} +
); } diff --git a/src/app/admin/bestellungen/page.tsx b/src/app/admin/bestellungen/page.tsx index fab5abd..6a26d5f 100644 --- a/src/app/admin/bestellungen/page.tsx +++ b/src/app/admin/bestellungen/page.tsx @@ -12,7 +12,7 @@ import { getDictionary } from "@/i18n/get-dictionary"; export default async function AdminOrdersPage(props: PageProps<"/admin/bestellungen">) { const t = getDictionary(await getLocale()); const statusLabels = t.orderStatusBadge; - const { q, status, error } = await props.searchParams; + const { q, status, error, message } = await props.searchParams; const query = typeof q === "string" ? q.trim() : ""; const statusFilter = typeof status === "string" && status in statusLabels ? (status as OrderStatus) : undefined; @@ -41,6 +41,9 @@ export default async function AdminOrdersPage(props: PageProps<"/admin/bestellun {/* Die DHL-Aktionen leiten bei einer nicht mehr auffindbaren Bestellung hierher zurueck (admin/actions/dhl.ts). Ohne diese Ausgabe sah das aus wie ein folgenloser Klick. */} + {message ? ( +

{message}

+ ) : null} {error ? (

{error}

) : null} diff --git a/src/i18n/dictionaries/de.ts b/src/i18n/dictionaries/de.ts index 44c1fdb..415043e 100644 --- a/src/i18n/dictionaries/de.ts +++ b/src/i18n/dictionaries/de.ts @@ -1136,6 +1136,14 @@ export const de: Dictionary = { dhlCreateButton: "Versandmarke erstellen", dhlAlreadyCreated: (trackingNumber) => `DHL-Versandmarke bereits erstellt: Sendungsnummer ${trackingNumber}`, dhlNotConfigured: "DHL ist noch nicht konfiguriert, siehe .env (DHL_API_KEY, DHL_API_SECRET, DHL_GKP_USERNAME, DHL_GKP_PASSWORD, DHL_ACCOUNT_NUMBER).", + deleteHeading: "Bestellung löschen", + deleteHint: + "Diese Bestellung ist nie zu einem Geschäftsvorfall geworden: keine Zahlung, keine Rechnung. Sie kann deshalb rückstandslos entfernt werden. Reservierte Ware wird dabei wieder dem Bestand gutgeschrieben.", + deleteButton: "Endgültig löschen", + deleteConfirm: (orderNumber) => + `Bestellung ${orderNumber} endgültig löschen? Das lässt sich nicht rückgängig machen.`, + deleteBlocked: + "Diese Bestellung kann nicht gelöscht werden — es hängt eine Zahlung oder eine Rechnung daran, oder sie ist bereits in Bearbeitung. Rechnungen und Zahlungsbelege sind aufbewahrungspflichtig. Storniere die Bestellung stattdessen: das erstattet das Geld, bucht die Ware zurück und lässt den Beleg stehen.", }, adminOrdersPage: { backToDashboard: "Zurück zum Dashboard", diff --git a/src/i18n/dictionaries/en.ts b/src/i18n/dictionaries/en.ts index 2777bfb..920533d 100644 --- a/src/i18n/dictionaries/en.ts +++ b/src/i18n/dictionaries/en.ts @@ -1123,6 +1123,14 @@ export const en: Dictionary = { dhlCreateButton: "Create shipping label", dhlAlreadyCreated: (trackingNumber) => `DHL shipping label already created: tracking number ${trackingNumber}`, dhlNotConfigured: "DHL isn't configured yet, see .env (DHL_API_KEY, DHL_API_SECRET, DHL_GKP_USERNAME, DHL_GKP_PASSWORD, DHL_ACCOUNT_NUMBER).", + deleteHeading: "Delete order", + deleteHint: + "This order never became a business transaction: no payment, no invoice. It can therefore be removed without a trace. Reserved goods are credited back to stock.", + deleteButton: "Delete permanently", + deleteConfirm: (orderNumber) => + `Permanently delete order ${orderNumber}? This cannot be undone.`, + deleteBlocked: + "This order cannot be deleted — a payment or an invoice is attached to it, or it is already being processed. Invoices and payment records are subject to statutory retention. Cancel the order instead: that refunds the money, returns the goods to stock and keeps the record.", }, adminOrdersPage: { backToDashboard: "Back to dashboard", diff --git a/src/i18n/dictionary-type.ts b/src/i18n/dictionary-type.ts index e90f285..5e47fa5 100644 --- a/src/i18n/dictionary-type.ts +++ b/src/i18n/dictionary-type.ts @@ -798,6 +798,12 @@ export interface Dictionary { invoiceHeading: string; downloadPdf: string; noInvoiceYet: string; + /** Bereich zum endgueltigen Loeschen einer Bestellung (siehe deleteOrderAction). */ + deleteHeading: string; + deleteHint: string; + deleteButton: string; + deleteConfirm: (orderNumber: string) => string; + deleteBlocked: string; trackingHeading: string; carrierLabel: string; carrierPlaceholder: string; diff --git a/src/lib/order-delete.test.ts b/src/lib/order-delete.test.ts new file mode 100644 index 0000000..f9da646 --- /dev/null +++ b/src/lib/order-delete.test.ts @@ -0,0 +1,53 @@ +import { describe, test, expect } from "vitest"; +import { checkOrderDeletable, needsRestockOnDelete } from "./order-delete"; +import type { OrderStatus } from "@/generated/prisma/client"; + +const base = { status: "NEU" as OrderStatus, hasPayment: false, hasInvoice: false }; + +describe("checkOrderDeletable", () => { + test("erlaubt das Loeschen einer unbezahlten Bestellung im Status NEU", () => { + expect(checkOrderDeletable(base)).toEqual({ deletable: true }); + }); + + test("erlaubt das Loeschen einer stornierten Bestellung ohne Zahlung und Rechnung", () => { + expect(checkOrderDeletable({ ...base, status: "STORNIERT" })).toEqual({ deletable: true }); + }); + + test("verweigert das Loeschen, sobald eine Rechnung existiert", () => { + expect(checkOrderDeletable({ ...base, hasInvoice: true })).toEqual({ + deletable: false, + reason: "invoice", + }); + }); + + test("verweigert das Loeschen, sobald ein Zahlungsvorgang erfasst ist", () => { + expect(checkOrderDeletable({ ...base, hasPayment: true })).toEqual({ + deletable: false, + reason: "payment", + }); + }); + + test("nennt die Rechnung als Grund, wenn Rechnung UND Zahlung vorliegen", () => { + expect(checkOrderDeletable({ ...base, hasPayment: true, hasInvoice: true })).toEqual({ + deletable: false, + reason: "invoice", + }); + }); + + test.each(["BEZAHLT", "IN_VORBEREITUNG", "VERSANDBEREIT", "VERSENDET", "ABHOLBEREIT", "ABGEHOLT"])( + "verweigert das Loeschen im Status %s, auch ohne Zahlung und Rechnung", + (status) => { + expect(checkOrderDeletable({ ...base, status })).toEqual({ deletable: false, reason: "status" }); + } + ); +}); + +describe("needsRestockOnDelete", () => { + test("bucht bei NEU zurueck — die Ware liegt dort noch reserviert", () => { + expect(needsRestockOnDelete("NEU")).toBe(true); + }); + + test("bucht bei STORNIERT NICHT zurueck — das ist beim Stornieren bereits geschehen", () => { + expect(needsRestockOnDelete("STORNIERT")).toBe(false); + }); +}); diff --git a/src/lib/order-delete.ts b/src/lib/order-delete.ts new file mode 100644 index 0000000..ff76d63 --- /dev/null +++ b/src/lib/order-delete.ts @@ -0,0 +1,49 @@ +import type { OrderStatus } from "@/generated/prisma/client"; + +/** + * Darf diese Bestellung endgueltig geloescht werden? + * + * Reine Logik ohne Datenbankzugriff — dieselbe Trennung wie bei + * shipping-calc.ts: so laesst sich die Regel ohne DATABASE_URL testen, und + * Server Action und Admin-Oberflaeche koennen sie sich teilen, statt jede + * fuer sich dieselbe Bedingung zu formulieren und irgendwann + * auseinanderzulaufen. + * + * Hintergrund der Regel: eine bezahlte Bestellung und die Rechnung dazu sind + * Buchungsbelege (§ 147 AO, § 257 HGB) und muessen jahrelang vorgelegt werden + * koennen. Loeschbar ist deshalb nur, was nie ein Geschaeftsvorfall war — + * abgebrochene Checkouts und Testbestellungen. + */ +export type OrderDeletable = + | { deletable: true } + | { deletable: false; reason: "invoice" | "payment" | "status" }; + +/** Status, in denen noch kein Bearbeitungsschritt stattgefunden hat. */ +const DELETABLE_STATUSES: OrderStatus[] = ["NEU", "STORNIERT"]; + +export function checkOrderDeletable(order: { + status: OrderStatus; + /** Irgendein Zahlungsvorgang erfasst — auch ein fehlgeschlagener belegt einen Zahlungsversuch. */ + hasPayment: boolean; + hasInvoice: boolean; +}): OrderDeletable { + // Reihenfolge bestimmt, welcher Grund gemeldet wird: die Rechnung ist der + // schwerwiegendste Einwand und gehoert zuerst genannt, auch wenn zusaetzlich + // eine Zahlung vorliegt. + if (order.hasInvoice) return { deletable: false, reason: "invoice" }; + if (order.hasPayment) return { deletable: false, reason: "payment" }; + if (!DELETABLE_STATUSES.includes(order.status)) return { deletable: false, reason: "status" }; + return { deletable: true }; +} + +/** + * Ist bei dieser Bestellung noch Ware fuer den Bestand reserviert? + * + * Bei NEU liegt die Ware zurueckgelegt, beim Loeschen muss sie zurueck in den + * Bestand. Bei STORNIERT ist das bereits geschehen (updateOrderStatusAction + * bucht beim Stornieren zurueck) — noch einmal gutzuschreiben wuerde den + * Bestand erhoehen, ohne dass je ein Glas zurueckkam. + */ +export function needsRestockOnDelete(status: OrderStatus): boolean { + return status === "NEU"; +}