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]>
502 lines
21 KiB
TypeScript
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."
|
|
);
|
|
}
|