feat: Bestellungen ohne Geschaeftsvorfall lassen sich im Admin loeschen
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m12s

Stornieren gab es bereits, Loeschen nicht — abgebrochene Checkouts und
Testbestellungen blieben dauerhaft in der Liste stehen.

Loeschbar ist bewusst nur, was nie ein Geschaeftsvorfall war: keine Zahlung
erfasst, keine Rechnung erzeugt, Status NEU oder STORNIERT. Eine bezahlte
Bestellung und ihre Rechnung sind Buchungsbelege (§ 147 AO, § 257 HGB) und
muessen jahrelang vorgelegt werden koennen; ein Loeschknopf darauf wuerde
nicht aufraeumen, sondern Unterlagen vernichten. Dafuer gibt es den Storno:
er erstattet das Geld, bucht die Ware zurueck und laesst den Beleg stehen.

Die Regel steht in lib/order-delete.ts als reine Logik ohne Datenbankzugriff
— dieselbe Trennung wie bei shipping-calc.ts. Server Action und Admin-Seite
teilen sie sich, statt die Bedingung zweimal zu formulieren und irgendwann
auseinanderzulaufen, und sie laesst sich ohne DATABASE_URL testen (13 neue
Faelle, u.a. dass bei Rechnung UND Zahlung die Rechnung als Grund gemeldet
wird).

Drei Dinge, die ein blosses delete() falsch gemacht haette:

- LAGERBESTAND: bei NEU liegt die Ware reserviert und wird zurueckgebucht,
  bei STORNIERT ist das beim Stornieren bereits geschehen. Dort erneut
  gutzuschreiben wuerde den Bestand erhoehen, ohne dass je ein Glas zurueckkam.
- ZAHLUNGSSITZUNG: eine Bestellung im Status NEU kann eine offene
  Stripe-Sitzung haben. Wird sie geloescht, ohne die Sitzung zu schliessen,
  kann Minuten spaeter noch bezahlt werden — der Webhook findet dann keine
  Bestellung mehr, und das Geld liegt bei Stripe, ohne dass im Shop etwas
  davon zu sehen waere. Die Sitzung wird deshalb VOR dem Loeschen geschlossen;
  gelingt das nicht sicher, wird gar nicht geloescht. Ist bei Stripe
  zwischenzeitlich bezahlt worden, verweigert die Aktion das Loeschen.
- WETTLAUF: die Bedingungen stehen zusaetzlich in der WHERE-Klausel des
  DELETE. Zwischen Pruefung und Loeschen koennte der Webhook eine Zahlung
  eingetragen haben; die Pruefung sieht nur einen Schnappschuss, die
  WHERE-Klausel wird atomar gegen den aktuellen Zeilenstand ausgewertet.

Der Audit-Log-Eintrag ist ausfuehrlicher als bei anderen Aktionen und traegt
Bestellnummer, Status, Betrag, E-Mail und Positionsanzahl — der Datensatz ist
danach weg, dieser Eintrag ist die einzige verbleibende Spur.

Die Oberflaeche zeigt den Knopf am Seitenende in einem abgesetzten Bereich,
damit er nicht neben dem Statusfeld liegt, mit Browser-Rueckfrage ueber
ConfirmForm. Ist die Bestellung nicht loeschbar, steht dort statt des Knopfes
der Grund und der Verweis auf den Storno. Die Bestellliste zeigt jetzt auch
Erfolgsmeldungen, nicht nur Fehler.

tsc: 0 Fehler. vitest: 372/372 gruen. eslint: 0 Warnungen. next build: gruen.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5 committed 2026-09-22 02:05:43 +02:00
1 parent 256e823c4f
commit c35d61bfd6
8 files changed
+318 -2

No files matched your search

+145
View File
@@ -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<void>
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<void> {
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