Files
Wabenhain/src/app/admin/actions/orders.ts
T
Nick SzaunigandClaude Opus 5.5 dc1523aafd fix(orders): cancelled orders cannot be reopened (stock restocked twice)
After refund + cancellation the stock rose by 2 instead of 1. Cause:
the status form allowed moving an order out of STORNIERT. Cancelling
restocks the goods and refunds the payment; reopening did neither in
reverse, and the next cancellation passed the "not already cancelled"
claim again and restocked a second time (the refund was not repeated
because the payment was already REFUNDED, so only the stock showed it).

STORNIERT is now a final state:
- isOrderStatusChangeAllowed() in lib/inventory.ts next to the other
  cancel rules, with tests
- updateOrderStatusAction rejects the change up front with a clear
  message and moves non-cancel statuses with an atomic
  "status <> STORNIERT" update, so a cancellation from a second tab
  cannot be undone in between
- the order list and detail page no longer offer the status select for
  cancelled orders

Existing stock that was restocked twice has to be corrected by hand.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-27 00:45:31 +02:00

502 lines
21 KiB
TypeScript

"use server";
import { revalidatePath } from "next/cache";
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 { isOrderStatusChangeAllowed, needsRestockOnCancel, 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";
import { requireAdmin, str, strOrNull, fail } from "./_shared";
import { setFlash, succeed, MAIL_FAILED_HINT } from "@/lib/flash";
const ORDER_STATUSES = [
"NEU",
"BEZAHLT",
"IN_VORBEREITUNG",
"VERSANDBEREIT",
"VERSENDET",
"ABHOLBEREIT",
"ABGEHOLT",
"STORNIERT",
] as const;
/**
* Erledigt die Geldseite einer gerade ausgesprochenen Stornierung.
*
* Zwei Dinge, die eine Statusaenderung allein offen laesst:
*
* 1. WAR SCHON BEZAHLT — dann muss das Geld zurueck. Ohne diesen Schritt
* stand die Bestellung als "storniert" da, die Ware lag wieder im Regal,
* und der Betrag blieb trotzdem beim Shop. Erstattet wird der volle
* Betrag; eine Teilerstattung ist ueber den Retouren-Bereich abzuwickeln,
* wo sie hingehoert.
*
* 2. WAR NOCH OFFEN ("NEU") — dann ist die Stripe-Sitzung unter Umstaenden
* noch bezahlbar. Sie muss geschlossen werden, sonst kann die Kundschaft
* Minuten spaeter noch zahlen: der Webhook findet die Bestellung dann
* nicht mehr im Status "NEU", bricht ab, und das Geld liegt bei Stripe,
* ohne dass im Shop irgendetwas davon zu sehen waere.
*
* Fehler werden nicht geworfen, sondern als Klartext zurueckgegeben. Die
* Stornierung ist an dieser Stelle schon geschrieben und die Ware
* zurueckgebucht — sie deswegen abzubrechen wuerde nichts heilen. Was offen
* bleibt, gehoert stattdessen sichtbar vor die Imkerei, damit sie es von Hand
* erledigen kann.
*
* @returns leere Liste, wenn alles erledigt ist; sonst je offener Punkt einen
* Satz fuer die Fehlermeldung im Admin-Bereich.
*/
async function settleCancelledOrder(order: {
orderId: string;
orderNumber: string;
previousStatus: OrderStatus;
stripeCheckoutSessionId: string | null;
payment: { status: PaymentStatus; stripePaymentIntentId: string | null } | null;
}): Promise<string[]> {
const warnings: string[] = [];
// Nur eine tatsaechlich eingezogene Zahlung wird erstattet. "FAILED" gab es
// nie zu erstatten, "REFUNDED" waere eine zweite Erstattung desselben
// Betrags, und "PENDING" ist noch gar nicht abgeschlossen.
if (order.payment?.status === "SUCCEEDED") {
const paymentIntentId = order.payment.stripePaymentIntentId;
if (!paymentIntentId) {
warnings.push(
"Zu dieser bezahlten Bestellung ist keine Stripe-Zahlung hinterlegt — die Erstattung muss von Hand angestossen werden."
);
} else {
try {
const stripe = getStripeClient();
await stripe.refunds.create({ payment_intent: paymentIntentId });
await prisma.payment.update({
where: { orderId: order.orderId },
data: { status: "REFUNDED" },
});
} catch (error: unknown) {
console.error(
`[order-cancel] Erstattung fuer Bestellung ${order.orderNumber} (PaymentIntent ${paymentIntentId}) fehlgeschlagen:`,
error
);
warnings.push(
"Die Erstattung ueber Stripe ist fehlgeschlagen — bitte im Stripe-Dashboard von Hand erstatten."
);
}
}
}
if (order.previousStatus === "NEU" && order.stripeCheckoutSessionId) {
try {
const stripe = getStripeClient();
const session = await stripe.checkout.sessions.retrieve(order.stripeCheckoutSessionId);
// Nur eine offene Sitzung laesst sich schliessen. "complete" bedeutet,
// dass in der Zwischenzeit doch bezahlt wurde — dann ist das hier ein
// Wettlauf, den die Zahlung gewonnen hat, und er gehoert gemeldet statt
// stillschweigend uebergangen.
if (session.status === "open") {
await stripe.checkout.sessions.expire(order.stripeCheckoutSessionId);
} else if (session.status === "complete") {
warnings.push(
"Achtung: Diese Bestellung wurde bei Stripe zwischenzeitlich doch bezahlt. Bitte den Zahlungseingang pruefen und die Erstattung von Hand anstossen."
);
}
} catch (error: unknown) {
console.error(
`[order-cancel] Zahlungssitzung ${order.stripeCheckoutSessionId} der Bestellung ${order.orderNumber} konnte nicht geschlossen werden:`,
error
);
warnings.push(
"Die offene Zahlungssitzung konnte nicht geschlossen werden — sie laeuft von selbst ab, bis dahin ist eine Zahlung noch moeglich."
);
}
}
return warnings;
}
export async function updateOrderStatusAction(formData: FormData): Promise<void> {
const admin = await requireAdmin();
const orderId = str(formData, "orderId");
const status = str(formData, "status") as (typeof ORDER_STATUSES)[number];
const detailPath = `/admin/bestellungen/${orderId}`;
if (!ORDER_STATUSES.includes(status)) {
return fail(detailPath, "Ungültiger Status.");
}
const result = await prisma.$transaction(async (tx) => {
const previous = await tx.order.findUnique({
where: { id: orderId },
select: {
status: true,
orderNumber: true,
email: true,
firstName: true,
locale: true,
stripeCheckoutSessionId: true,
payment: { select: { status: true, stripePaymentIntentId: true } },
items: { select: { productId: true, quantity: true } },
returnRequest: { select: { status: true } },
},
});
if (!previous) return null;
// Stornierte Bestellungen bleiben storniert (siehe
// isOrderStatusChangeAllowed) — sonst bucht ein erneutes Stornieren die
// Ware ein zweites Mal zurueck.
if (!isOrderStatusChangeAllowed(previous.status, status)) {
return { ...previous, shipment: null, cancelledNow: false, blocked: true };
}
// Nur wahr, wenn DIESER Aufruf die Bestellung tatsaechlich storniert hat.
// Steuert weiter unten Erstattung und Schliessen der Zahlungssitzung —
// beides darf bei einem zweiten Klick auf eine bereits stornierte
// Bestellung nicht noch einmal passieren.
let cancelledNow = false;
if (status === "STORNIERT") {
// Atomarer Claim statt einfachem update(): verhindert, dass zwei
// schnell aufeinanderfolgende Stornierungs-Versuche (z.B. zwei offene
// Admin-Tabs auf derselben Bestellung) beide restockOrderItems
// ausloesen und den Lagerbestand doppelt gutschreiben. Ein getrenntes
// findUnique+update koennte das nicht verhindern, da beide Versuche
// noch denselben "nicht schon storniert"-Stand lesen wuerden, bevor
// einer von ihnen schreibt — die WHERE-Klausel im UPDATE selbst wird
// dagegen atomar gegen den aktuellen Zeilenstand ausgewertet.
const claim = await tx.order.updateMany({
where: { id: orderId, status: { not: "STORNIERT" } },
data: { status },
});
if (claim.count === 1) {
cancelledNow = true;
if (needsRestockOnCancel(previous.returnRequest?.status ?? null)) {
await restockOrderItems(tx, previous.items);
}
}
} else {
// Atomar gegen den aktuellen Zeilenstand: hat ein zweiter Tab die
// Bestellung zwischen Lesen und Schreiben storniert, bleibt sie das.
const moved = await tx.order.updateMany({
where: { id: orderId, status: { not: "STORNIERT" } },
data: { status },
});
if (moved.count === 0) {
return { ...previous, shipment: null, cancelledNow: false, blocked: true };
}
}
let shipment: { carrier: string | null; trackingNumber: string | null } | null = null;
if (status === "VERSENDET") {
shipment = await tx.shipment.upsert({
where: { orderId },
create: { orderId, shippedAt: new Date() },
update: { shippedAt: new Date() },
select: { carrier: true, trackingNumber: true },
});
}
if (status === "ABHOLBEREIT") {
await tx.pickup.upsert({
where: { orderId },
create: { orderId, readyAt: new Date() },
update: { readyAt: new Date() },
});
}
if (status === "ABGEHOLT") {
await tx.pickup.upsert({
where: { orderId },
create: { orderId, pickedUpAt: new Date() },
update: { pickedUpAt: new Date() },
});
}
return { ...previous, shipment, cancelledNow, blocked: false };
});
if (!result) {
return fail(detailPath, "Die Bestellung wurde nicht gefunden — möglicherweise wurde sie zwischenzeitlich gelöscht.");
}
if (result.blocked) {
return fail(
detailPath,
"Diese Bestellung ist storniert: die Ware ist zurückgebucht und die Zahlung erstattet. Sie lässt sich nicht wieder öffnen — soll die Ware doch raus, bitte eine neue Bestellung anlegen."
);
}
// undefined = keine E-Mail faellig; false = faellig, aber nicht versendet.
let mailSent: boolean | undefined;
if (result) {
await logAdminAction(admin, {
action: "order.status_change",
entityType: "Order",
entityId: orderId,
detail: `${result.orderNumber}: ${result.status} → ${status}`,
});
// Die Geldseite der Stornierung — bewusst NACH der Transaktion: ein
// Stripe-Aufruf dauert ein Vielfaches einer Datenbankoperation und wuerde
// die Zeilensperren auf Bestellung und Lagerbestand so lange halten.
//
// Bis hierher hat die Stornierung nur den Status gesetzt und die Ware
// zurueckgebucht. Ohne die beiden folgenden Schritte blieb eine
// Stornierung auf halbem Weg stehen: bei einer bezahlten Bestellung
// behielt der Shop das Geld, bei einer noch offenen blieb die
// Zahlungssitzung bezahlbar.
if (result.cancelledNow) {
const stornoWarnings = await settleCancelledOrder({
orderId,
orderNumber: result.orderNumber,
previousStatus: result.status,
stripeCheckoutSessionId: result.stripeCheckoutSessionId,
payment: result.payment,
});
if (stornoWarnings.length > 0) {
await logAdminAction(admin, {
action: "order.cancel_incomplete",
entityType: "Order",
entityId: orderId,
detail: `${result.orderNumber}: ${stornoWarnings.join(" | ")}`,
});
// Die Stornierung selbst steht und wird NICHT zurueckgenommen — die
// Ware ist bereits wieder im Bestand und die Absicht war eindeutig.
// Aber der offene Rest muss auf dem Bildschirm landen und nicht nur
// im Log, sonst haelt die Imkerei den Vorgang fuer abgeschlossen.
return fail(detailPath, `Bestellung storniert, aber: ${stornoWarnings.join(" ")}`);
}
}
// E-Mail-Benachrichtigung bei Versand/Abholbereitschaft — bewusst nach
// der Transaktion, ein Mail-Ausfall darf die Statusaenderung nicht
// rueckgaengig machen (sendMail wirft nie, siehe lib/mail.ts).
const locale = isLocale(result.locale) ? result.locale : defaultLocale;
if (status === "VERSENDET" && result.status !== "VERSENDET") {
const carrier = result.shipment?.carrier ?? null;
const trackingNumber = result.shipment?.trackingNumber ?? null;
const { subject, html, text } = await shipmentEmail({
locale,
orderNumber: result.orderNumber,
firstName: result.firstName,
carrier,
trackingNumber,
trackingUrl: getCarrierTrackingUrl(carrier, trackingNumber),
});
mailSent = await sendMail({ to: result.email, subject, html, text });
}
if (status === "ABHOLBEREIT" && result.status !== "ABHOLBEREIT") {
const { subject, html, text } = await pickupReadyEmail({
locale,
orderNumber: result.orderNumber,
firstName: result.firstName,
});
mailSent = await sendMail({ to: result.email, subject, html, text });
}
}
revalidatePath("/admin/bestellungen");
const done = `Status von ${result.orderNumber} gespeichert.`;
if (mailSent === false) return fail(detailPath, `${done} Aber: ${MAIL_FAILED_HINT}`);
await setFlash("success", mailSent ? `${done} Die Kundschaft wurde per E-Mail informiert.` : done);
}
/**
* 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) {
return 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) {
return 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") {
return 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
);
return 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) {
return 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");
return succeed("/admin/bestellungen", `Bestellung ${order.orderNumber} wurde gelöscht.`);
}
/**
* Erfasst Versanddienstleister + Sendungsnummer fuer eine Bestellung —
* unabhaengig vom Status setzbar, da die Nummer oft schon vor dem
* eigentlichen Versandscan vom Versanddienstleister vergeben wird. Ist die
* Bestellung bereits als VERSENDET markiert, wird die Sendungsverfolgung
* per separater E-Mail nachgereicht statt nur in der ersten Versand-Mail zu
* stehen (die zu diesem Zeitpunkt schon verschickt war).
*/
export async function updateShipmentTrackingAction(formData: FormData): Promise<void> {
const admin = await requireAdmin();
const orderId = str(formData, "orderId");
const carrier = strOrNull(formData, "carrier");
const trackingNumber = strOrNull(formData, "trackingNumber");
const order = await prisma.order.findUnique({
where: { id: orderId },
select: { orderNumber: true, email: true, firstName: true, status: true, locale: true },
});
if (!order) return fail("/admin/bestellungen", "Bestellung nicht gefunden.");
await prisma.shipment.upsert({
where: { orderId },
create: { orderId, carrier, trackingNumber },
update: { carrier, trackingNumber },
});
await logAdminAction(admin, {
action: "order.tracking_update",
entityType: "Order",
entityId: orderId,
detail: `${order.orderNumber}: ${carrier ?? "—"} ${trackingNumber ?? ""}`.trim(),
});
let mailSent: boolean | undefined;
if (order.status === "VERSENDET" && carrier && trackingNumber) {
const { subject, html, text } = await shipmentEmail({
locale: isLocale(order.locale) ? order.locale : defaultLocale,
orderNumber: order.orderNumber,
firstName: order.firstName,
carrier,
trackingNumber,
trackingUrl: getCarrierTrackingUrl(carrier, trackingNumber),
});
mailSent = await sendMail({ to: order.email, subject, html, text });
}
revalidatePath("/admin/bestellungen");
revalidatePath(`/admin/bestellungen/${orderId}`);
if (mailSent === false) {
return fail(`/admin/bestellungen/${orderId}`, `Sendungsdaten gespeichert. Aber: ${MAIL_FAILED_HINT}`);
}
await setFlash(
"success",
mailSent ? "Sendungsdaten gespeichert und per E-Mail an die Kundschaft geschickt." : "Sendungsdaten gespeichert."
);
}