Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 3m40s
ApiCore now owns the remaining stock per batch; the shop sells from it and reports every paid sale and every return back. - Outbox apicore_stock_events, written in the same transaction as the status change: paid -> SALE, cancelled-after-payment / refunded return -> RETURN onto the batches of the sale, reopened paid order -> SALE. Whether a return is due is decided by the open sales in the outbox, so unpaid orders never book and nothing is booked twice. - Delivery right after the response (next/server after()) plus CronJob wabenhain-apicore-stock every 10 minutes with 1/5/15/60 min backoff; ApiCore deduplicates by event id. Rejected bookings stop and show up on the dashboard. - Reconcile: shop stock = ApiCore remaining - unconfirmed bookings - reservations of unpaid orders, in a serializable transaction like the checkout. Triggered after bookings, by the signed ApiCore webhook stock.changed (ids only, numbers are fetched) and by the sync button. - Manufactured goods (candles, propolis products, ...): one shop product per ApiCore article via Product.apicoreProductId, drafts created by the sync, existing products linkable in the product form; new category SONSTIGES. Stock field is read-only for linked products. - OrderItem.apicoreAllocations keeps which lots were sold (recalls). Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
144 lines
6.4 KiB
TypeScript
144 lines
6.4 KiB
TypeScript
"use server";
|
|
|
|
import { fail, succeed, MAIL_FAILED_HINT } from "@/lib/flash";
|
|
import { revalidatePath } from "next/cache";
|
|
import { prisma } from "@/lib/prisma";
|
|
import { requireAdmin } from "@/app/admin/actions";
|
|
import { getStripeClient } from "@/lib/stripe";
|
|
import { logAdminAction } from "@/lib/audit-log";
|
|
import { sendMail } from "@/lib/mail";
|
|
import { returnAcceptedEmail, returnRejectedEmail, returnRefundedEmail } from "@/lib/email-templates";
|
|
import { restockOrderItems } from "@/lib/inventory";
|
|
import { recordOrderReturn } from "@/lib/apicore-stock/stock-events";
|
|
import { scheduleApiCoreStockSync } from "@/lib/apicore-stock/schedule";
|
|
import { isLocale, defaultLocale } from "@/i18n/config";
|
|
|
|
function str(formData: FormData, key: string): string {
|
|
return String(formData.get(key) ?? "").trim();
|
|
}
|
|
|
|
|
|
export async function updateReturnRequestStatusAction(formData: FormData): Promise<void> {
|
|
const admin = await requireAdmin();
|
|
const id = str(formData, "id");
|
|
const status = str(formData, "status");
|
|
if (!["ANGENOMMEN", "ABGELEHNT"].includes(status)) return fail("/admin/retouren", "Ungültiger Status.");
|
|
|
|
const returnRequest = await prisma.returnRequest.findUnique({
|
|
where: { id },
|
|
select: { status: true },
|
|
});
|
|
// Nur von "ANGEFRAGT" aus entscheiden — verhindert, dass eine bereits
|
|
// erstattete oder abgelehnte Retoure nachtraeglich ueberschrieben wird.
|
|
if (!returnRequest || returnRequest.status !== "ANGEFRAGT") {
|
|
return fail("/admin/retouren", "Diese Retoure wurde bereits entschieden oder existiert nicht mehr.");
|
|
}
|
|
|
|
const updated = await prisma.returnRequest.update({
|
|
where: { id },
|
|
data: { status: status as "ANGENOMMEN" | "ABGELEHNT" },
|
|
include: { order: { select: { orderNumber: true, email: true, firstName: true, locale: true } } },
|
|
});
|
|
|
|
await logAdminAction(admin, {
|
|
action: "return_request.status_change",
|
|
entityType: "ReturnRequest",
|
|
entityId: id,
|
|
detail: `${updated.order.orderNumber}: ${status}`,
|
|
});
|
|
|
|
const locale = isLocale(updated.order.locale) ? updated.order.locale : defaultLocale;
|
|
const { subject, html, text } =
|
|
status === "ANGENOMMEN"
|
|
? await returnAcceptedEmail({ locale, orderNumber: updated.order.orderNumber, firstName: updated.order.firstName })
|
|
: await returnRejectedEmail({ locale, orderNumber: updated.order.orderNumber, firstName: updated.order.firstName });
|
|
const mailSent = await sendMail({ to: updated.order.email, subject, html, text });
|
|
|
|
revalidatePath("/admin/retouren");
|
|
const done = `Retoure ${updated.order.orderNumber} ${status === "ANGENOMMEN" ? "angenommen" : "abgelehnt"}.`;
|
|
if (!mailSent) return fail("/admin/retouren", `${done} Aber: ${MAIL_FAILED_HINT}`);
|
|
return succeed("/admin/retouren", `${done} Die Kundschaft wurde per E-Mail informiert.`);
|
|
}
|
|
|
|
export async function refundReturnRequestAction(formData: FormData): Promise<void> {
|
|
const admin = await requireAdmin();
|
|
const id = str(formData, "id");
|
|
|
|
const returnRequest = await prisma.returnRequest.findUnique({
|
|
where: { id },
|
|
include: {
|
|
order: {
|
|
include: { items: true, payment: { select: { stripePaymentIntentId: true } } },
|
|
},
|
|
},
|
|
});
|
|
if (!returnRequest || returnRequest.status !== "ANGENOMMEN") {
|
|
return fail("/admin/retouren", "Diese Retoure kann nicht (mehr) erstattet werden.");
|
|
}
|
|
|
|
const paymentIntentId = returnRequest.order.payment?.stripePaymentIntentId;
|
|
if (!paymentIntentId) {
|
|
return fail("/admin/retouren", "Keine Stripe-Zahlung zu dieser Bestellung hinterlegt — bitte manuell erstatten.");
|
|
}
|
|
|
|
// Status ATOMAR und VOR dem Stripe-Aufruf auf ERSTATTET setzen (bedingt auf
|
|
// den noch erwarteten "ANGENOMMEN"-Stand) statt erst danach: der
|
|
// findUnique-Check allein ist nicht atomar — bei zwei schnell
|
|
// aufeinanderfolgenden Klicks (Doppelklick, zwei offene Tabs) koennten
|
|
// sonst beide Aufrufe die Pruefung bestehen und zwei echte
|
|
// Stripe-Erstattungen ausloesen. Schlaegt der Stripe-Aufruf unten fehl,
|
|
// wird der Status wieder zurueckgesetzt, damit die Erstattung erneut
|
|
// versucht werden kann.
|
|
//
|
|
// Eine stornierte Bestellung ist ausgeschlossen: updateOrderStatusAction
|
|
// hat beim Stornieren die Ware schon zurueckgebucht und die Zahlung
|
|
// erstattet — hier noch einmal gutzuschreiben erhoehte den Bestand um
|
|
// Glaeser, die es nicht gibt (Gegenstueck zu needsRestockOnCancel).
|
|
if (returnRequest.order.status === "STORNIERT") {
|
|
return fail("/admin/retouren", "Die Bestellung wurde bereits storniert — Ware und Zahlung sind schon zurückgebucht.");
|
|
}
|
|
const claimed = await prisma.returnRequest.updateMany({
|
|
where: { id, status: "ANGENOMMEN", order: { status: { not: "STORNIERT" } } },
|
|
data: { status: "ERSTATTET" },
|
|
});
|
|
if (claimed.count === 0) return fail("/admin/retouren", "Diese Retoure wird bereits erstattet.");
|
|
|
|
const stripe = getStripeClient();
|
|
try {
|
|
await stripe.refunds.create({ payment_intent: paymentIntentId });
|
|
} catch (error: unknown) {
|
|
await prisma.returnRequest.update({ where: { id }, data: { status: "ANGENOMMEN" } });
|
|
throw error;
|
|
}
|
|
|
|
await prisma.$transaction(async (tx) => {
|
|
await tx.payment.update({ where: { orderId: returnRequest.orderId }, data: { status: "REFUNDED" } });
|
|
// Zurueckgesendete Ware wieder als verfuegbaren Bestand fuehren, analog
|
|
// zur Stornierung im Checkout-Webhook.
|
|
await restockOrderItems(tx, returnRequest.order.items);
|
|
// Die zurueckgesendete Ware geht auch in ApiCore zurueck auf die
|
|
// Chargen, aus denen sie verkauft wurde.
|
|
await recordOrderReturn(tx, returnRequest.orderId);
|
|
});
|
|
scheduleApiCoreStockSync(returnRequest.order.items.map((item) => item.productId));
|
|
|
|
await logAdminAction(admin, {
|
|
action: "return_request.refund",
|
|
entityType: "ReturnRequest",
|
|
entityId: id,
|
|
detail: returnRequest.order.orderNumber,
|
|
});
|
|
|
|
const { subject, html, text } = await returnRefundedEmail({
|
|
locale: isLocale(returnRequest.order.locale) ? returnRequest.order.locale : defaultLocale,
|
|
orderNumber: returnRequest.order.orderNumber,
|
|
firstName: returnRequest.order.firstName,
|
|
});
|
|
const mailSent = await sendMail({ to: returnRequest.order.email, subject, html, text });
|
|
|
|
revalidatePath("/admin/retouren");
|
|
const done = `Retoure ${returnRequest.order.orderNumber} erstattet — der Betrag geht über Stripe zurück.`;
|
|
if (!mailSent) return fail("/admin/retouren", `${done} Aber: ${MAIL_FAILED_HINT}`);
|
|
return succeed("/admin/retouren", `${done} Die Kundschaft wurde per E-Mail informiert.`);
|
|
}
|