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

+53
View File
@@ -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<OrderStatus>(["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);
});
});
+49
View File
@@ -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";
}