feat: Bestellungen ohne Geschaeftsvorfall lassen sich im Admin loeschen
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m12s
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:
1 parent
256e823c4f
commit
c35d61bfd6
8 files changed
+318
-2
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
Reference in new issue
Block a user