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"; "use server";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { logAdminAction } from "@/lib/audit-log"; import { logAdminAction } from "@/lib/audit-log";
import { sendMail } from "@/lib/mail"; import { sendMail } from "@/lib/mail";
import { shipmentEmail, pickupReadyEmail } from "@/lib/email-templates"; import { shipmentEmail, pickupReadyEmail } from "@/lib/email-templates";
import { getCarrierTrackingUrl } from "@/lib/shipment-tracking"; import { getCarrierTrackingUrl } from "@/lib/shipment-tracking";
import { restockOrderItems } from "@/lib/inventory"; import { restockOrderItems } from "@/lib/inventory";
import { checkOrderDeletable, needsRestockOnDelete } from "@/lib/order-delete";
import { getStripeClient } from "@/lib/stripe"; import { getStripeClient } from "@/lib/stripe";
import { isLocale, defaultLocale } from "@/i18n/config"; import { isLocale, defaultLocale } from "@/i18n/config";
import type { OrderStatus, PaymentStatus } from "@/generated/prisma/client"; import type { OrderStatus, PaymentStatus } from "@/generated/prisma/client";
@@ -270,6 +272,149 @@ export async function updateOrderStatusAction(formData: FormData): Promise<void>
revalidatePath("/admin/bestellungen"); 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 — * Erfasst Versanddienstleister + Sendungsnummer fuer eine Bestellung —
* unabhaengig vom Status setzbar, da die Nummer oft schon vor dem * unabhaengig vom Status setzbar, da die Nummer oft schon vor dem
+45 -1
View File
@@ -3,7 +3,14 @@ import Link from "next/link";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { BackLink } from "@/components/back-link"; import { BackLink } from "@/components/back-link";
import { formatPrice, formatDate } from "@/lib/format"; 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 { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary"; import { getDictionary } from "@/i18n/get-dictionary";
import { isDhlConfigured } from "@/lib/dhl"; import { isDhlConfigured } from "@/lib/dhl";
@@ -27,6 +34,14 @@ export default async function AdminOrderDetailPage(props: PageProps<"/admin/best
}); });
if (!order) notFound(); if (!order) notFound();
const dhlConfigured = isDhlConfigured(); 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 ( return (
<div className="max-w-3xl"> <div className="max-w-3xl">
@@ -260,6 +275,35 @@ export default async function AdminOrderDetailPage(props: PageProps<"/admin/best
)} )}
</div> </div>
</div> </div>
{/* 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. */}
<div className="mt-10 border border-red-200 bg-red-50/40 p-6">
<h2 className="text-xs tracking-wide-caps text-red-700 mb-3">
{t.adminOrderDetailPage.deleteHeading}
</h2>
{isDeletable ? (
<>
<p className="text-sm text-ink/70 leading-relaxed mb-4">
{t.adminOrderDetailPage.deleteHint}
</p>
<ConfirmForm
action={deleteOrderAction}
confirmMessage={t.adminOrderDetailPage.deleteConfirm(order.orderNumber)}
>
<input type="hidden" name="orderId" value={order.id} />
<button type="submit" className="bg-red-700 text-cream px-4 py-2 text-sm">
{t.adminOrderDetailPage.deleteButton}
</button>
</ConfirmForm>
</>
) : (
<p className="text-sm text-ink/70 leading-relaxed">{t.adminOrderDetailPage.deleteBlocked}</p>
)}
</div>
</div> </div>
); );
} }
+4 -1
View File
@@ -12,7 +12,7 @@ import { getDictionary } from "@/i18n/get-dictionary";
export default async function AdminOrdersPage(props: PageProps<"/admin/bestellungen">) { export default async function AdminOrdersPage(props: PageProps<"/admin/bestellungen">) {
const t = getDictionary(await getLocale()); const t = getDictionary(await getLocale());
const statusLabels = t.orderStatusBadge; 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 query = typeof q === "string" ? q.trim() : "";
const statusFilter = typeof status === "string" && status in statusLabels ? (status as OrderStatus) : undefined; 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 {/* Die DHL-Aktionen leiten bei einer nicht mehr auffindbaren Bestellung
hierher zurueck (admin/actions/dhl.ts). Ohne diese Ausgabe sah das hierher zurueck (admin/actions/dhl.ts). Ohne diese Ausgabe sah das
aus wie ein folgenloser Klick. */} aus wie ein folgenloser Klick. */}
{message ? (
<p className="mb-6 text-sm text-forest bg-forest/5 border border-forest/20 px-4 py-3">{message}</p>
) : null}
{error ? ( {error ? (
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p> <p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
) : null} ) : null}
+8
View File
@@ -1136,6 +1136,14 @@ export const de: Dictionary = {
dhlCreateButton: "Versandmarke erstellen", dhlCreateButton: "Versandmarke erstellen",
dhlAlreadyCreated: (trackingNumber) => `DHL-Versandmarke bereits erstellt: Sendungsnummer ${trackingNumber}`, 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).", 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: { adminOrdersPage: {
backToDashboard: "Zurück zum Dashboard", backToDashboard: "Zurück zum Dashboard",
+8
View File
@@ -1123,6 +1123,14 @@ export const en: Dictionary = {
dhlCreateButton: "Create shipping label", dhlCreateButton: "Create shipping label",
dhlAlreadyCreated: (trackingNumber) => `DHL shipping label already created: tracking number ${trackingNumber}`, 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).", 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: { adminOrdersPage: {
backToDashboard: "Back to dashboard", backToDashboard: "Back to dashboard",
+6
View File
@@ -798,6 +798,12 @@ export interface Dictionary {
invoiceHeading: string; invoiceHeading: string;
downloadPdf: string; downloadPdf: string;
noInvoiceYet: 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; trackingHeading: string;
carrierLabel: string; carrierLabel: string;
carrierPlaceholder: string; carrierPlaceholder: string;
+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";
}