feat(stock): keep shop stock in sync with ApiCore, write sales back
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]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-05 21:16:54 +02:00
1 parent 641afa1ae4
commit 93b589ff04
39 files changed
+1400 -37

No files matched your search

+99 -2
View File
@@ -8,6 +8,7 @@ import {
getApiCoreConnection,
fetchApiCoreShopData,
isSellableStatus,
isManufactured,
ApiCoreConfigError,
ApiCoreRequestError,
type ApiCoreShopData,
@@ -23,10 +24,14 @@ import {
buildProductDescription,
deriveJarWeightGrams,
productSlugCandidates,
slugifyProductName,
harvestFactChanges,
batchFactChanges,
} from "@/lib/apicore-mapping";
import { requireAdmin } from "./_shared";
import { deliverPendingStockEvents } from "@/lib/apicore-stock/delivery";
import { reconcileApiCoreStock } from "@/lib/apicore-stock/reconcile";
import type { ProductCategory } from "@/generated/prisma/client";
/**
* Uebernimmt Stammdaten aus ApiCore (separates Imkerei-Verwaltungssystem) in
@@ -363,6 +368,73 @@ async function syncProducts(
* niemandem etwas, und der Fall bedeutet ohnehin, dass hier jemand
* hinschauen muss.
*/
const SHOP_CATEGORIES: readonly ProductCategory[] = ["HONIG", "KERZEN", "PROPOLIS", "SONSTIGES"];
function shopCategoryOf(article: ApiCoreProduct): ProductCategory {
const category = article.shopCategory as ProductCategory | undefined;
return category && SHOP_CATEGORIES.includes(category) && category !== "HONIG" ? category : "SONSTIGES";
}
/**
* Hergestellte Waren (Kerzen, Propolis-Tinkturen, Salben, ...): EIN
* Shop-Produkt je ApiCore-Artikel, nicht je Charge wie beim Honig. Kundinnen
* kaufen "Bienenwachskerze klein", nicht eine bestimmte Charge — welche Charge
* verkauft wird, entscheidet ApiCore (aelteste zuerst) und meldet es zurueck.
*
* Gleiche Regeln wie bei Honig-Entwuerfen: deaktiviert, ohne Preis, nie
* aktualisiert. Ein schon in Wabenhain gepflegtes Produkt verknuepft man
* im Admin ("ApiCore-Artikel verknuepfen") statt es neu anlegen zu lassen.
*/
async function syncManufacturedProducts(articles: ApiCoreProduct[]): Promise<ProductSyncCounts> {
const counts: ProductSyncCounts = { created: 0, skipped: 0, failed: 0, pending: 0, discontinued: 0 };
for (const article of articles.filter(isManufactured)) {
try {
const linked = await prisma.product.findUnique({
where: { apicoreProductId: article.id },
select: { id: true },
});
if (linked) {
counts.skipped++;
continue;
}
if (!isSellableStatus(article.status)) {
counts.discontinued++;
continue;
}
const base = slugifyProductName(article.name) || "produkt";
const sku = slugifyProductName(article.sku ?? "");
const slug = await firstFreeSlug(sku ? [base, `${base}-${sku}`] : [base]);
if (!slug) {
counts.failed++;
console.error(
`[apicore-sync] Für "${article.name}" ist keine freie Adresse frei — Produkt bitte von Hand anlegen und verknüpfen.`
);
continue;
}
await prisma.product.create({
data: {
slug,
apicoreProductId: article.id,
name: article.name,
description: buildProductDescription(article),
weightGrams: deriveJarWeightGrams(null, article),
category: shopCategoryOf(article),
priceCents: 0,
pricePerKgCents: 0,
isActive: false,
// Der Bestand kommt im Abgleich direkt danach aus ApiCore.
inventory: { create: { quantity: 0 } },
},
});
counts.created++;
} catch (error: unknown) {
counts.failed++;
console.error(`[apicore-sync] Artikel "${article.name}" konnte nicht als Produkt importiert werden: ${describeError(error)}`);
}
}
return counts;
}
async function firstFreeSlug(candidates: string[]): Promise<string | null> {
for (const slug of candidates) {
const taken = await prisma.product.findUnique({ where: { slug }, select: { id: true } });
@@ -531,8 +603,16 @@ export async function syncApiCoreAction(formData: FormData): Promise<void> {
`${batches.stockConflicts} Bestandskonflikte, ${batches.failed} fehlgeschlagen`,
});
// 3. Produkte — je Abfuellung ein Jahrgang, jetzt mit vorhandenen Chargen.
const products = await syncProducts(source.products, source.bottlings);
// 3. Produkte — Honig je Abfuellung ein Jahrgang, hergestellte Waren je Artikel.
const honeyProducts = await syncProducts(source.products, source.bottlings);
const manufacturedProducts = await syncManufacturedProducts(source.products);
const products: ProductSyncCounts = {
created: honeyProducts.created + manufacturedProducts.created,
skipped: honeyProducts.skipped + manufacturedProducts.skipped,
failed: honeyProducts.failed + manufacturedProducts.failed,
pending: honeyProducts.pending,
discontinued: honeyProducts.discontinued + manufacturedProducts.discontinued,
};
await logAdminAction(admin, {
action: "product.sync_apicore",
entityType: "Product",
@@ -541,6 +621,23 @@ export async function syncApiCoreAction(formData: FormData): Promise<void> {
`${products.pending} warten auf die Charge, ${products.discontinued} in ApiCore ausgelaufen`,
});
// 4. Bestand — offene Buchungen senden, dann alle verknuepften Produkte
// auf den ApiCore-Rest setzen. Scheitert das, bleibt der Sync trotzdem
// gueltig; der CronJob holt den Abgleich nach.
try {
const delivered = await deliverPendingStockEvents();
const reconciled = await reconcileApiCoreStock();
await logAdminAction(admin, {
action: "stock.sync_apicore",
entityType: "Inventory",
detail:
`${delivered.sent} Buchungen bestätigt, ${delivered.rejected} abgelehnt, ${delivered.failed} offen; ` +
`${reconciled.updated} Bestände abgeglichen, ${reconciled.missing} in ApiCore nicht gefunden`,
});
} catch (error: unknown) {
console.error(`[apicore-sync] Bestandsabgleich fehlgeschlagen: ${describeError(error)}`);
}
revalidatePath("/admin/ernten");
revalidatePath("/admin/chargen");
revalidatePath("/admin/produkte");
+14
View File
@@ -19,6 +19,8 @@ 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 { recordOrderReturn, recordOrderSale } from "@/lib/apicore-stock/stock-events";
import { scheduleApiCoreStockSync } from "@/lib/apicore-stock/schedule";
import { setFlash, succeed, MAIL_FAILED_HINT } from "@/lib/flash";
const ORDER_STATUSES = [
@@ -185,6 +187,10 @@ export async function updateOrderStatusAction(formData: FormData): Promise<void>
if (needsRestockOnCancel(previous.returnRequest?.status ?? null)) {
await restockOrderItems(tx, previous.items);
}
// War die Bestellung bezahlt, steht ein Verkauf in ApiCore — der
// wird zurueckgebucht. Unbezahlte Bestellungen haben keinen, dann
// passiert hier nichts.
await recordOrderReturn(tx, orderId);
}
} else if (reopening) {
// Gegenbuchung zur Stornierung: die dabei zurueckgebuchte Ware wird
@@ -202,6 +208,10 @@ export async function updateOrderStatusAction(formData: FormData): Promise<void>
// Wirft InsufficientStockError, wenn die Ware inzwischen verkauft ist —
// die Transaktion nimmt dann auch den Statuswechsel zurueck.
await reserveOrderItems(tx, previous.items);
// Eine bezahlte Bestellung ist damit wieder verkauft.
if (previous.payment?.status === "SUCCEEDED") {
await recordOrderSale(tx, orderId);
}
}
} else {
// Atomar gegen den aktuellen Zeilenstand: hat ein zweiter Tab die
@@ -245,6 +255,10 @@ export async function updateOrderStatusAction(formData: FormData): Promise<void>
throw error;
});
if (result && !("insufficientStock" in result)) {
scheduleApiCoreStockSync(result.items.map((item) => item.productId));
}
if (result && "insufficientStock" in result) {
return fail(
detailPath,
+37 -1
View File
@@ -10,6 +10,7 @@ import { planInventoryEdit } from "@/lib/inventory";
import type { Prisma } from "@/generated/prisma/client";
import { requireAdmin, fail, str, strOrNull, strArray } from "./_shared";
import { succeed } from "@/lib/flash";
import { scheduleApiCoreStockSync } from "@/lib/apicore-stock/schedule";
/**
* Ein Produkt darf nicht aktiv geschaltet werden, solange kein Preis
@@ -213,7 +214,25 @@ export async function updateProductAction(formData: FormData): Promise<void> {
const isActive = formData.get("isActive") === "on";
await assertPricedIfActive(`/admin/produkte/${id}`, isActive, numbers.priceCents);
const inventoryPlan = planInventoryEdit({ loaded: str(formData, "quantityLoaded"), submitted: numbers.quantity });
const current = await prisma.product.findUnique({
where: { id },
select: { apicoreBottlingId: true, apicoreProductId: true },
});
if (!current) return fail("/admin/produkte", "Produkt nicht gefunden.");
// Verknuepfung mit einem ApiCore-Artikel (nur hergestellte Waren; Honig
// haengt fest an seiner Abfuellung). Feld fehlt = unveraendert lassen.
const requestedLink = formData.has("apicoreProductId") ? strOrNull(formData, "apicoreProductId") : undefined;
const apicoreProductId = current.apicoreBottlingId ? current.apicoreProductId : requestedLink;
const linkChanged = apicoreProductId !== undefined && apicoreProductId !== current.apicoreProductId;
const stockFromApiCore = Boolean(current.apicoreBottlingId || (apicoreProductId ?? current.apicoreProductId));
// Bei verknuepften Produkten gehoert der Bestand ApiCore: ein Wert aus dem
// Formular wird nie geschrieben, auch wenn jemand das gesperrte Feld per
// Entwicklerwerkzeug aendert.
const inventoryPlan = stockFromApiCore
? ({ kind: "keep" } as const)
: planInventoryEdit({ loaded: str(formData, "quantityLoaded"), submitted: numbers.quantity });
if (inventoryPlan.kind === "reload") {
return fail(
`/admin/produkte/${id}`,
@@ -244,10 +263,27 @@ export async function updateProductAction(formData: FormData): Promise<void> {
vatRatePercent: numbers.vatRatePercent,
images: strArray(formData, "images").filter(isValidUploadPath),
isActive,
...(linkChanged ? { apicoreProductId } : {}),
},
});
}).catch((error: unknown) => {
if (typeof error === "object" && error !== null && "code" in error && error.code === "P2002") {
return "link-taken" as const;
}
throw error;
});
if (product === "link-taken") {
return fail(
`/admin/produkte/${id}`,
"Dieser ApiCore-Artikel ist bereits mit einem anderen Produkt verknüpft. Nichts wurde gespeichert."
);
}
if (linkChanged && apicoreProductId) {
// Bestand sofort aus ApiCore holen, nicht erst beim naechsten CronJob.
scheduleApiCoreStockSync([id]);
}
if (!product) {
const current = await prisma.inventory.findUnique({ where: { productId: id }, select: { quantity: true } });
return fail(
+10
View File
@@ -2,6 +2,7 @@ import Link from "next/link";
import { ApiCoreSyncPanel, ApiCoreSyncResult } from "@/components/apicore-sync-panel";
import { prisma } from "@/lib/prisma";
import { pendingReviewWhere } from "@/lib/review-status";
import { countApiCoreStockIssues } from "@/lib/apicore-stock/dashboard";
import { COUNTED_SALE_WHERE } from "@/lib/sales";
import { formatPrice } from "@/lib/format";
import { getLocale } from "@/i18n/locale";
@@ -259,6 +260,12 @@ export default async function AdminDashboardPage(props: PageProps<"/admin">) {
const revenueTrend = [...revenueByDay.entries()];
const maxDailyRevenue = Math.max(1, ...revenueTrend.map(([, v]) => v));
// Bestandsabgleich mit ApiCore (nur wenn eingerichtet).
const stockIssues = await countApiCoreStockIssues();
const stockOverdue = stockIssues.overdue;
const stockRejected = stockIssues.rejected;
const unlinkedProducts = stockIssues.unlinked;
const [admin, waitingOrders, waitingReturns, waitingReviews, waitingInquiries] = await Promise.all([
getCurrentAdmin(),
prisma.order.count({
@@ -273,6 +280,9 @@ export default async function AdminDashboardPage(props: PageProps<"/admin">) {
{ count: waitingReturns, href: "/admin/retouren", label: t.adminDashboardPage.toDoReturns(waitingReturns) },
{ count: waitingReviews, href: "/admin/bewertungen", label: t.adminDashboardPage.toDoReviews(waitingReviews) },
{ count: waitingInquiries, href: "/admin/anfragen", label: t.adminDashboardPage.toDoInquiries(waitingInquiries) },
{ count: stockRejected, href: "/admin/bestellungen", label: t.adminDashboardPage.toDoStockRejected(stockRejected) },
{ count: stockOverdue, href: "/admin", label: t.adminDashboardPage.toDoStockOverdue(stockOverdue) },
{ count: unlinkedProducts, href: "/admin/produkte", label: t.adminDashboardPage.toDoUnlinkedProducts(unlinkedProducts) },
].filter((item) => item.count > 0);
// Begruessung nach Tageszeit in Berliner Zeit (der Server laeuft in UTC).
+63
View File
@@ -9,9 +9,41 @@ import {
AdminFieldset,
AdminFormPanel,
AdminHint,
AdminSelect,
AdminSwitch,
AdminTextarea,
} from "@/components/admin-form";
import {
fetchApiCoreShopData,
getApiCoreConnection,
isManufactured,
type ApiCoreProduct,
} from "@/lib/apicore-client";
/**
* ApiCore-Artikel, mit denen sich dieses Produkt verknuepfen laesst: nur
* hergestellte Waren (Honig haengt an der Abfuellung) und nur solche, die
* kein anderes Produkt schon belegt. Ist ApiCore nicht erreichbar, bleibt
* die Verknuepfung, wie sie ist — die Seite selbst funktioniert weiter.
*/
async function linkableApiCoreArticles(
productId: string
): Promise<{ articles: ApiCoreProduct[]; error: string | null }> {
try {
const feed = await fetchApiCoreShopData(getApiCoreConnection());
const taken = await prisma.product.findMany({
where: { apicoreProductId: { not: null }, NOT: { id: productId } },
select: { apicoreProductId: true },
});
const takenIds = new Set(taken.map((row) => row.apicoreProductId));
return {
articles: feed.products.filter((article) => isManufactured(article) && !takenIds.has(article.id)),
error: null,
};
} catch (error: unknown) {
return { articles: [], error: error instanceof Error ? error.message : "Unbekannter Fehler" };
}
}
import { updateProductAction } from "../../actions";
import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary";
@@ -30,6 +62,8 @@ export default async function AdminProductEditPage(props: PageProps<"/admin/prod
tasteTags: asStringArray(rawProduct.tasteTags),
images: asStringArray(rawProduct.images),
};
const stockFromApiCore = Boolean(product.apicoreBottlingId || product.apicoreProductId);
const linkable = product.apicoreBottlingId ? null : await linkableApiCoreArticles(product.id);
return (
<div className="max-w-2xl">
@@ -93,8 +127,37 @@ export default async function AdminProductEditPage(props: PageProps<"/admin/prod
type="number"
defaultValue={String(product.inventory?.quantity ?? 0)}
required
readOnly={stockFromApiCore}
hint={stockFromApiCore ? te.stockFromApiCoreHint : undefined}
/>
</div>
{product.apicoreBottlingId ? (
<AdminHint>{te.apicoreHoneyLinked(product.apicoreBottlingId)}</AdminHint>
) : linkable?.error ? (
<>
<input type="hidden" name="apicoreProductId" value={product.apicoreProductId ?? ""} />
<AdminHint>{te.apicoreUnavailable(linkable.error)}</AdminHint>
</>
) : (
<AdminSelect
name="apicoreProductId"
label={te.apicoreLinkLabel}
defaultValue={product.apicoreProductId ?? ""}
hint={te.apicoreLinkHint}
>
<option value="">{te.apicoreNotLinked}</option>
{(linkable?.articles ?? []).map((article) => (
<option key={article.id} value={article.id}>
{article.name}
{article.sku ? ` (${article.sku})` : ""}
</option>
))}
{product.apicoreProductId &&
!(linkable?.articles ?? []).some((article) => article.id === product.apicoreProductId) ? (
<option value={product.apicoreProductId}>{product.apicoreProductId}</option>
) : null}
</AdminSelect>
)}
<div className="grid grid-cols-2 items-end gap-4">
<AdminField
name="priceCents"
+3 -2
View File
@@ -45,11 +45,12 @@ export default async function AdminProductNewPage() {
<option value="HONIG">{t.common.categories.HONIG}</option>
<option value="KERZEN">{t.common.categories.KERZEN}</option>
<option value="PROPOLIS">{t.common.categories.PROPOLIS}</option>
<option value="SONSTIGES">{t.common.categories.SONSTIGES}</option>
</AdminSelect>
{/* Ernte/Charge gibt es nur bei Honig. Ausgeblendet wird ueber :has()
ohne Client-JS; ohne :has()-Unterstuetzung bleibt alles sichtbar
und der Server ignoriert die Auswahl ohnehin (products.ts). */}
<div className="grid gap-4 sm:grid-cols-2 group-has-[option[value=KERZEN]:checked]/product:hidden group-has-[option[value=PROPOLIS]:checked]/product:hidden">
<div className="grid gap-4 sm:grid-cols-2 group-has-[option[value=KERZEN]:checked]/product:hidden group-has-[option[value=PROPOLIS]:checked]/product:hidden group-has-[option[value=SONSTIGES]:checked]/product:hidden">
<AdminSelect name="harvestId" label={tn.harvestLabel} defaultValue="">
<option value="">{tn.noneOption}</option>
{harvests.map((h) => (
@@ -72,7 +73,7 @@ export default async function AdminProductNewPage() {
<AdminFieldset
legend={tn.sectionTaste}
className="group-has-[option[value=KERZEN]:checked]/product:hidden group-has-[option[value=PROPOLIS]:checked]/product:hidden"
className="group-has-[option[value=KERZEN]:checked]/product:hidden group-has-[option[value=PROPOLIS]:checked]/product:hidden group-has-[option[value=SONSTIGES]:checked]/product:hidden"
>
<AdminField name="consistency" label={tn.consistencyLabel} placeholder={tn.consistencyPlaceholder} />
<div className="grid items-end gap-4 sm:grid-cols-3">
+6
View File
@@ -9,6 +9,8 @@ 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 {
@@ -114,7 +116,11 @@ export async function refundReturnRequestAction(formData: FormData): Promise<voi
// 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",
+82
View File
@@ -0,0 +1,82 @@
import { NextResponse } from "next/server";
import { after } from "next/server";
import { z } from "zod";
import { getClientIp, rateLimit } from "@/lib/rate-limit";
import { verifyApiCoreSignature } from "@/lib/apicore-stock/webhook-signature";
import { deliverPendingStockEvents } from "@/lib/apicore-stock/delivery";
import { reconcileApiCoreStock } from "@/lib/apicore-stock/reconcile";
/**
* Empfaengt ApiCore-Webhooks (Ereignis "stock.changed"): in ApiCore wurde
* entnommen, korrigiert, abgefuellt oder ein Shop-Verkauf gebucht.
*
* Der Webhook nennt nur, WAS sich geaendert hat (Abfuellungs- und
* Artikel-IDs), nie Zahlen. Die Zahlen holt der Abgleich selbst ueber die
* API — ein Webhook kann also keinen Bestand setzen, selbst wenn das Secret
* einmal in falsche Haende geriete. Fehlt ein Webhook, holt der CronJob den
* Abgleich spaetestens nach zehn Minuten nach.
*/
const WEBHOOKS_PER_MINUTE = 60;
const payloadSchema = z.object({
event: z.string(),
data: z
.object({
bottlingBatchIds: z.array(z.string()).default([]),
productIds: z.array(z.string()).default([]),
})
.partial()
.optional(),
});
export async function POST(request: Request): Promise<NextResponse> {
const ip = await getClientIp();
if (!rateLimit(`apicore-webhook:${ip}`, WEBHOOKS_PER_MINUTE, 60_000)) {
return NextResponse.json({ error: "Zu viele Anfragen" }, { status: 429 });
}
const secret = process.env.APICORE_WEBHOOK_SECRET?.trim();
if (!secret) {
return NextResponse.json({ error: "Webhook nicht eingerichtet" }, { status: 503 });
}
const body = await request.text();
const valid = verifyApiCoreSignature({
secret,
timestamp: request.headers.get("x-apicore-timestamp"),
deliveryId: request.headers.get("x-apicore-delivery"),
signature: request.headers.get("x-apicore-signature"),
body,
});
if (!valid) {
return NextResponse.json({ error: "Ungueltige Signatur" }, { status: 401 });
}
let parsed;
try {
parsed = payloadSchema.safeParse(JSON.parse(body));
} catch {
return NextResponse.json({ error: "Ungueltiger Inhalt" }, { status: 400 });
}
if (!parsed.success) {
return NextResponse.json({ error: "Ungueltiger Inhalt" }, { status: 400 });
}
// Andere Ereignisse (falls in ApiCore mit abonniert) werden quittiert und
// ignoriert, damit ApiCore sie nicht als Fehler protokolliert.
if (parsed.data.event !== "stock.changed") {
return NextResponse.json({ ok: true, ignored: parsed.data.event });
}
const apicoreBottlingIds = parsed.data.data?.bottlingBatchIds ?? [];
const apicoreProductIds = parsed.data.data?.productIds ?? [];
after(async () => {
try {
await deliverPendingStockEvents();
await reconcileApiCoreStock({ apicoreBottlingIds, apicoreProductIds });
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "Unbekannter Fehler";
console.error(`[apicore-webhook] Abgleich fehlgeschlagen, der CronJob holt ihn nach: ${message}`);
}
});
return NextResponse.json({ ok: true });
}
@@ -80,6 +80,18 @@ const db = {
vi.mock("@/lib/prisma", () => ({ prisma: db }));
// Bestandsabgleich mit ApiCore: hier nur, DASS die bezahlte Bestellung als
// Verkauf in die Warteschlange geht; die Buchungslogik selbst testet
// lib/apicore-stock.
const recordOrderSale = vi.fn(async () => 0);
const scheduleApiCoreStockSync = vi.fn();
vi.mock("@/lib/apicore-stock/stock-events", () => ({
recordOrderSale: (...args: unknown[]) => recordOrderSale(...(args as [])),
}));
vi.mock("@/lib/apicore-stock/schedule", () => ({
scheduleApiCoreStockSync: (...args: unknown[]) => scheduleApiCoreStockSync(...(args as [])),
}));
const { POST } = await import("./route");
const ORDER_ID = "order_1";
+10
View File
@@ -11,6 +11,8 @@ import { invoiceRelativePath, storeInvoicePdf } from "@/lib/invoice-storage";
import { formatInvoiceNumber } from "@/lib/invoice-number";
import { isLocale, defaultLocale } from "@/i18n/config";
import type Stripe from "stripe";
import { recordOrderSale } from "@/lib/apicore-stock/stock-events";
import { scheduleApiCoreStockSync } from "@/lib/apicore-stock/schedule";
/**
* Erzeugt das Rechnungs-PDF, legt es nach Jahr und Kunde sortiert ab und
@@ -323,6 +325,12 @@ async function fulfillOrder(session: Stripe.Checkout.Session): Promise<void> {
});
}
// Bezahlt = verkauft: die Ware in ApiCore abbuchen (Warteschlange, in
// derselben Transaktion wie der Statuswechsel). recordOrderSale bucht
// je Position hoechstens einen offenen Verkauf, ein erneut zugestellter
// Webhook legt also nichts doppelt an.
await recordOrderSale(tx, orderId);
return updated;
});
@@ -330,6 +338,8 @@ async function fulfillOrder(session: Stripe.Checkout.Session): Promise<void> {
// (z.B. bereits storniert) — nichts weiter zu tun.
if (!order) return;
scheduleApiCoreStockSync(order.items.map((item) => item.productId));
// Abgleich zwischen dem, was wir berechnet haben, und dem, was Stripe
// tatsaechlich eingezogen hat. Beide Betraege stammen aus derselben Quelle
// (Positionspreise aus der Datenbank, Versand aus der Mengenstaffel, Rabatt
+2 -1
View File
@@ -44,6 +44,7 @@ export default async function ShopPage(props: PageProps<"/shop">) {
{ value: "HONIG", label: t.common.categories.HONIG },
{ value: "KERZEN", label: t.common.categories.KERZEN },
{ value: "PROPOLIS", label: t.common.categories.PROPOLIS },
{ value: "SONSTIGES", label: t.common.categories.SONSTIGES },
] as const;
const sortOptions: { value: ProductSort | ""; label: string }[] = [
@@ -56,7 +57,7 @@ export default async function ShopPage(props: PageProps<"/shop">) {
const { q, kategorie, sortierung, verfuegbar } = await props.searchParams;
const query = typeof q === "string" ? q : undefined;
const category =
typeof kategorie === "string" && ["HONIG", "KERZEN", "PROPOLIS"].includes(kategorie)
typeof kategorie === "string" && ["HONIG", "KERZEN", "PROPOLIS", "SONSTIGES"].includes(kategorie)
? (kategorie as ProductCategory)
: undefined;
const sort =
+5 -1
View File
@@ -15,6 +15,7 @@ export function AdminField({
required = false,
min,
hint,
readOnly = false,
}: {
name: string;
label: string;
@@ -25,6 +26,8 @@ export function AdminField({
min?: number;
/** Kurzer Zusatz unter dem Feld, statt ihn in die Beschriftung zu packen. */
hint?: string;
/** Wird angezeigt und mitgeschickt, laesst sich aber nicht aendern. */
readOnly?: boolean;
}) {
return (
<label className="block text-sm">
@@ -36,7 +39,8 @@ export function AdminField({
placeholder={placeholder}
required={required}
min={min}
className={inputClass}
readOnly={readOnly}
className={readOnly ? `${inputClass} bg-ink/5 text-ink/70` : inputClass}
/>
{hint ? <span className="mt-1 block text-xs text-ink/65">{hint}</span> : null}
</label>
+1
View File
@@ -21,6 +21,7 @@ const categoryTone: Record<ProductCategory, "forest" | "cream" | "honey"> = {
HONIG: "honey",
KERZEN: "forest",
PROPOLIS: "cream",
SONSTIGES: "cream",
};
/**
+50 -25
View File
@@ -8,6 +8,7 @@ export const de: Dictionary = {
HONIG: "Honig",
KERZEN: "Bienenwachskerzen",
PROPOLIS: "Propolis",
SONSTIGES: "Weitere Bienenprodukte",
},
turnstile: {
loadError: "Die Sicherheitsprüfung konnte nicht geladen werden.",
@@ -16,7 +17,7 @@ export const de: Dictionary = {
},
pageOutro: {
ariaLabel: "Weiterlesen",
shopText: "Honig, Bienenwachskerzen und Propolis aus eigener Imkerei — solange die Charge reicht.",
shopText: "Honig, Bienenwachskerzen und Propolis aus eigener Imkerei, solange die Charge reicht.",
harvestsText: "Jede Ernte mit Datum, Standort und Charge, zurückverfolgbar bis ins Glas.",
originText: "Wo unsere Bienenvölker stehen und was die Landschaft um Altenmünster prägt.",
},
@@ -73,7 +74,7 @@ export const de: Dictionary = {
},
orderStatusBadge: {
// Nicht "Neu": dieser Status bedeutet, dass die Zahlung noch nicht
// bestaetigt ist — bei einem abgebrochenen Checkout bleibt sie es auch.
// bestaetigt ist, bei einem abgebrochenen Checkout bleibt sie es auch.
NEU: "Zahlung ausstehend",
BEZAHLT: "Bezahlt",
IN_VORBEREITUNG: "In Vorbereitung",
@@ -193,7 +194,7 @@ export const de: Dictionary = {
successMailSent: "Wir haben dir soeben eine Eingangsbestätigung mit dem Inhalt deiner Erklärung sowie Datum und Uhrzeit des Eingangs per E-Mail geschickt.",
successMailFailed: "Dein Widerruf ist wirksam bei uns eingegangen. Die Eingangsbestätigung per E-Mail konnte gerade nicht versendet werden; wir schicken sie dir schnellstmöglich nach. Bitte speichere diese Seite als Nachweis.",
receivedAtLabel: "Eingegangen am",
errorMessage: "Dein Widerruf konnte nicht gesendet werden. Bitte versuche es erneut oder schreib uns eine E-Mail — auch ein Widerruf per E-Mail oder Brief ist wirksam.",
errorMessage: "Dein Widerruf konnte nicht gesendet werden. Bitte versuche es erneut oder schreib uns eine E-Mail, auch ein Widerruf per E-Mail oder Brief ist wirksam.",
policyLink: "Zur Widerrufsbelehrung",
footerButton: "Vertrag widerrufen",
},
@@ -474,7 +475,7 @@ export const de: Dictionary = {
// Wortlaut aus der amtlichen Muster-Widerrufsbelehrung (Anlage 1 zu
// Art. 246a § 1 Abs. 2 EGBGB), bewusst unveraendert uebernommen.
// Ohne diese Information traegt nach § 357 Abs. 6 BGB der Verkaeufer
// die Ruecksendekosten — die Belehrung selbst ist die Voraussetzung
// die Ruecksendekosten; die Belehrung selbst ist die Voraussetzung
// dafuer, sie dem Kaeufer auferlegen zu duerfen. Die Ausnahme fuer
// mangelhafte Ware steht bewusst NICHT hier: das ist Gewaehrleistung
// (§ 439 BGB) und damit ein anderes Recht als der Widerruf; eine
@@ -511,7 +512,7 @@ export const de: Dictionary = {
pickupNote: "Alternativ kannst du deine Bestellung nach Absprache direkt bei uns in Altenmünster abholen. Dabei fallen keine Versandkosten an.",
regionHeading: "Liefergebiet",
regionTextDe: "Versand ist nur innerhalb Deutschlands möglich. Bestellungen mit einer Lieferadresse außerhalb Deutschlands können wir derzeit leider nicht annehmen. Ausgenommen sind außerdem Helgoland und Büsingen am Hochrhein: beide liegen außerhalb des EU-Zollgebiets, dorthin wäre jede Sendung zollpflichtig.",
regionTextEu: "Versand ist in die gesamte Europäische Union möglich — dorthin fällt kein Zoll an. Bestellungen mit einer Lieferadresse außerhalb der EU können wir derzeit leider nicht annehmen. Das gilt auch für Gebiete, die zwar zu einem EU-Land gehören, aber außerhalb des Zollgebiets liegen: Helgoland, Büsingen am Hochrhein, die Kanarischen Inseln, Ceuta und Melilla, die französischen Überseegebiete, Livigno, Campione d’Italia, Åland und den Berg Athos.",
regionTextEu: "Versand ist in die gesamte Europäische Union möglich, dorthin fällt kein Zoll an. Bestellungen mit einer Lieferadresse außerhalb der EU können wir derzeit leider nicht annehmen. Das gilt auch für Gebiete, die zwar zu einem EU-Land gehören, aber außerhalb des Zollgebiets liegen: Helgoland, Büsingen am Hochrhein, die Kanarischen Inseln, Ceuta und Melilla, die französischen Überseegebiete, Livigno, Campione d’Italia, Åland und den Berg Athos.",
paymentHeading: "Zahlungsmethoden",
paymentText: "Wir bieten Kreditkarte, SEPA-Lastschrift, Klarna, PayPal, Apple Pay und Google Pay über unseren Zahlungsdienstleister Stripe an. Eine Zahlung per Überweisung ist auf Anfrage möglich.",
deliveryTimeHeading: "Lieferzeit",
@@ -528,7 +529,7 @@ export const de: Dictionary = {
// im Code (lib/session.ts, lib/theme.ts, i18n/config.ts, lib/flash-core.ts,
// lib/checkout-release.ts, store/*, components/cookie-consent.tsx). Wer dort
// einen Namen, eine Laufzeit oder einen neuen Speicherort einfuehrt, muss
// ihn hier nachtragen — § 25 TDDDG verlangt die Angabe fuer jeden Zugriff
// ihn hier nachtragen, § 25 TDDDG verlangt die Angabe fuer jeden Zugriff
// auf das Endgeraet, nicht nur fuer Cookies. Gleiches gilt fuer en.ts.
sections: [
{
@@ -549,7 +550,7 @@ export const de: Dictionary = {
{
heading: "Überblick",
paragraphs: [
"Wir verwenden technisch notwendige Cookies und Speichereinträge und — nur mit deiner Einwilligung — einen einzigen weiteren Speichereintrag für die Liste zuletzt angesehener Produkte. Wir setzen keine Analyse-, Statistik-, Marketing- oder Tracking-Cookies ein, erstellen keine Nutzungsprofile und geben keine Daten zu Werbezwecken an Dritte weiter.",
"Wir verwenden technisch notwendige Cookies und Speichereinträge und nur mit deiner Einwilligung, einen einzigen weiteren Speichereintrag für die Liste zuletzt angesehener Produkte. Wir setzen keine Analyse-, Statistik-, Marketing- oder Tracking-Cookies ein, erstellen keine Nutzungsprofile und geben keine Daten zu Werbezwecken an Dritte weiter.",
"Die meisten der unten genannten Cookies entstehen erst, wenn du eine bestimmte Funktion nutzt (z. B. Anmeldung, Bezahlung, Sprachwechsel). Beim bloßen Aufrufen der Website wird kein Cookie gesetzt.",
],
},
@@ -838,7 +839,7 @@ export const de: Dictionary = {
statusRejected: "Abgelehnt",
reject: "Ablehnen",
rejectConfirm: "Diese Bewertung ablehnen? Sie wird nicht im Shop angezeigt und landet im Archiv.",
noPendingReviews: "Keine offenen Bewertungen — alles entschieden.",
noPendingReviews: "Keine offenen Bewertungen, alles entschieden.",
columnApproved: "Akzeptiert",
columnRejected: "Abgelehnt",
rejectedOn: (date) => `Abgelehnt am ${date}`,
@@ -1049,8 +1050,8 @@ export const de: Dictionary = {
glassCountLabel: "Anzahl Gläser",
remainingGlassesLabel: "Verbleibender Bestand",
imagesLabel: "Chargen-Bilder",
image1Label: "Bild 1 — linke Kachel auf der Chargenseite",
image2Label: "Bild 2 — rechte Kachel auf der Chargenseite (Standort)",
image1Label: "Bild 1, linke Kachel auf der Chargenseite",
image2Label: "Bild 2, rechte Kachel auf der Chargenseite (Standort)",
save: "Speichern",
},
trackOrderPage: {
@@ -1162,8 +1163,8 @@ export const de: Dictionary = {
locationLabel: "Standort",
descriptionLabel: "Beschreibung",
imagesLabel: "Erntebilder",
image1Label: "Bild 1 — Ernten-Übersicht und linke Kachel auf der Chargenseite",
image2Label: "Bild 2 — rechte Kachel auf der Chargenseite (Standort)",
image1Label: "Bild 1, Ernten-Übersicht und linke Kachel auf der Chargenseite",
image2Label: "Bild 2, rechte Kachel auf der Chargenseite (Standort)",
isPublishedLabel: "Öffentlich sichtbar",
isPublishedHint: "Steuert, ob diese Ernte auf der Website (Shop, /ernten) sichtbar ist.",
draftNotice: "Diese Ernte ist aktuell nur im Admin-Bereich sichtbar (z.B. aus ApiCore importiert). Bitte Angaben prüfen und danach veröffentlichen.",
@@ -1182,9 +1183,9 @@ export const de: Dictionary = {
resultProducts: (created, skipped, failed) =>
`Produkte: ${created} neue${created === 1 ? "r Jahrgang" : " Jahrgänge"}, ${skipped} übersprungen${failed > 0 ? `, ${failed} fehlgeschlagen (siehe Server-Log)` : ""}.`,
pending: (count) =>
`${count} Abfüllung${count === 1 ? "" : "en"} ohne passende Charge — meist ein Hinweis darauf, dass die zugehörige Ernte in ApiCore noch fehlt.`,
`${count} Abfüllung${count === 1 ? "" : "en"} ohne passende Charge, meist ein Hinweis darauf, dass die zugehörige Ernte in ApiCore noch fehlt.`,
discontinued: (count) =>
`${count} Abfüllung${count === 1 ? "" : "en"} gehör${count === 1 ? "t" : "en"} zu einem Artikel, den ApiCore nicht mehr führt — dafür wurde kein neuer Jahrgang angelegt. Bereits vorhandene bleiben im Verkauf.`,
`${count} Abfüllung${count === 1 ? "" : "en"} gehör${count === 1 ? "t" : "en"} zu einem Artikel, den ApiCore nicht mehr führt, dafür wurde kein neuer Jahrgang angelegt. Bereits vorhandene bleiben im Verkauf.`,
protectedDrafts: (count) =>
`${count} Ernte-Entw${count === 1 ? "urf wurde" : "ürfe wurden"} im Admin bereits bearbeitet: Sorte, Ort und Text bleiben, nur Datum und Jahr kommen aus ApiCore.`,
stockConflicts: (count) =>
@@ -1228,6 +1229,14 @@ export const de: Dictionary = {
intensityKraeftigLabel: "kräftig (1-5)",
weightLabel: "Gewicht (g)",
stockLabel: "Lagerbestand (Gläser)",
stockFromApiCoreHint:
"Kommt aus ApiCore (Rest abzüglich offener Bestellungen). Entnahmen und Inventur bitte in ApiCore buchen.",
apicoreLinkLabel: "ApiCore-Artikel",
apicoreNotLinked: "— nicht verknüpft (Bestand von Hand) —",
apicoreLinkHint:
"Verknüpft: der Bestand kommt aus ApiCore (Summe aller Herstellungschargen), Verkäufe und Rückbuchungen werden dorthin gemeldet.",
apicoreUnavailable: (message) => `ApiCore-Artikel konnten nicht geladen werden: ${message}`,
apicoreHoneyLinked: (bottlingId) => `Mit der ApiCore-Abfüllung ${bottlingId} verknüpft.`,
priceLabel: "Preis (Cent)",
pricePerKgLabel: "Grundpreis pro kg (Cent)",
vatRateLabel: "Umsatzsteuersatz (%, optional)",
@@ -1306,9 +1315,9 @@ export const de: Dictionary = {
statusOpen: "Offen",
statusProcessed: "Erledigt",
matched: "Bestellung zugeordnet",
unmatched: "Keine passende Bestellung — bitte prüfen",
unmatched: "Keine passende Bestellung, bitte prüfen",
mailSent: "Eingangsbestätigung versendet",
mailNotSent: "Eingangsbestätigung NICHT versendet — bitte manuell nachholen",
mailNotSent: "Eingangsbestätigung NICHT versendet, bitte manuell nachholen",
receivedAt: "Eingegangen",
processedOn: (date) => `Erledigt am ${date}`,
markProcessed: "Als erledigt markieren",
@@ -1402,11 +1411,11 @@ export const de: Dictionary = {
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.",
"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: {
reopenConfirm:
"Diese Bestellung ist storniert: Die Ware wurde zurückgebucht und eine bezahlte Summe erstattet. Beim Wiederöffnen wird die Ware erneut vom Bestand abgezogen — eine erneute Zahlung löst das NICHT aus, die musst du selbst einholen. Wirklich wieder öffnen?",
"Diese Bestellung ist storniert: Die Ware wurde zurückgebucht und eine bezahlte Summe erstattet. Beim Wiederöffnen wird die Ware erneut vom Bestand abgezogen, eine erneute Zahlung löst das NICHT aus, die musst du selbst einholen. Wirklich wieder öffnen?",
backToDashboard: "Zurück zum Dashboard",
title: "Bestellungen",
searchLabel: "Suche (Nummer, E-Mail, Nachname)",
@@ -1437,7 +1446,7 @@ export const de: Dictionary = {
uploadsNotPersistentHint: (uploadDir: string) =>
`Alle Bilder und Videos unter ${uploadDir} gehen beim nächsten Redeploy verloren. In Kubernetes muss das Volume "uploads" ein PersistentVolumeClaim sein (kubectl apply -f k8s/04-uploads-pvc.yaml und k8s/05-app-deployment.yaml), im Docker-Betrieb ein benanntes Volume. Details in k8s/README.md, Abschnitt "Bilder/Videos verschwinden nach jedem Redeploy".`,
missingUploadsHint:
"Die Datenbank kennt die Pfade noch, die Dateien selbst sind weg — im Shop erscheinen dort leere Flächen. Fast immer liegt das an einem nicht dauerhaften Upload-Verzeichnis: In Kubernetes muss das Volume \"uploads\" ein PersistentVolumeClaim sein (k8s/04-uploads-pvc.yaml), kein emptyDir, sonst geht es bei jedem Redeploy verloren. Nach dem Umstellen müssen die betroffenen Dateien einmalig neu hochgeladen werden.",
"Die Datenbank kennt die Pfade noch, die Dateien selbst sind weg, im Shop erscheinen dort leere Flächen. Fast immer liegt das an einem nicht dauerhaften Upload-Verzeichnis: In Kubernetes muss das Volume \"uploads\" ein PersistentVolumeClaim sein (k8s/04-uploads-pvc.yaml), kein emptyDir, sonst geht es bei jedem Redeploy verloren. Nach dem Umstellen müssen die betroffenen Dateien einmalig neu hochgeladen werden.",
title: "Dashboard",
statRevenueToday: "Umsatz heute",
statRevenueMonth: "Umsatz Monat",
@@ -1467,6 +1476,18 @@ export const de: Dictionary = {
toDoReturns: (count) => (count === 1 ? "1 neue Retoure" : `${count} neue Retouren`),
toDoReviews: (count) => (count === 1 ? "1 Bewertung wartet auf Freigabe" : `${count} Bewertungen warten auf Freigabe`),
toDoInquiries: (count) => (count === 1 ? "1 neue Firmenanfrage" : `${count} neue Firmenanfragen`),
toDoStockOverdue: (count) =>
count === 1
? "1 Bestandsbuchung seit über 15 Minuten nicht bei ApiCore angekommen"
: `${count} Bestandsbuchungen seit über 15 Minuten nicht bei ApiCore angekommen`,
toDoStockRejected: (count) =>
count === 1
? "1 Bestandsbuchung von ApiCore abgelehnt — bitte prüfen"
: `${count} Bestandsbuchungen von ApiCore abgelehnt — bitte prüfen`,
toDoUnlinkedProducts: (count) =>
count === 1
? "1 aktives Produkt noch nicht mit ApiCore verknüpft"
: `${count} aktive Produkte noch nicht mit ApiCore verknüpft`,
allDoneTitle: "Alles erledigt.",
allDoneText: "Gerade wartet nichts auf dich. Zeit für die Bienen.",
chartTooltip: (day, value) => `${day}: ${value}`,
@@ -1490,7 +1511,7 @@ export const de: Dictionary = {
statusRequired: "Pflicht",
statusOptional: "Optional",
warningStripeTest: "Testschlüssel aktiv — Bestellungen sehen erfolgreich aus, es fließt aber kein Geld.",
warningStripeMixed: "Geheimer und öffentlicher Schlüssel stammen aus verschiedenen Modi (test/live) — die Kasse funktioniert so nicht.",
warningStripeMixed: "Geheimer und öffentlicher Schlüssel stammen aus verschiedenen Modi (test/live), die Kasse funktioniert so nicht.",
integrationNames: {
stripe: "Stripe (Zahlung)",
stripeWebhook: "Stripe-Webhook (Zahlungsbestätigung)",
@@ -1533,12 +1554,12 @@ export const de: Dictionary = {
uploadError: "Der Upload ist fehlgeschlagen.",
uploadErrorStatus: (status) =>
status === 413
? `Die Datei wurde abgewiesen, weil sie zu groß ist (HTTP 413). Sie hat den Shop gar nicht erreicht — diese Grenze setzt der vorgeschaltete Proxy bzw. das CDN.`
? `Die Datei wurde abgewiesen, weil sie zu groß ist (HTTP 413). Sie hat den Shop gar nicht erreicht, diese Grenze setzt der vorgeschaltete Proxy bzw. das CDN.`
: status === 502
? `Der Server hat die Verarbeitung abgebrochen (HTTP 502). Das Backend hat die Verbindung fallen gelassen, ohne zu antworten — häufig, weil ihm der Arbeitsspeicher ausging. Bitte informiere die Technik und nenne diese Nummer.`
? `Der Server hat die Verarbeitung abgebrochen (HTTP 502). Das Backend hat die Verbindung fallen gelassen, ohne zu antworten, häufig, weil ihm der Arbeitsspeicher ausging. Bitte informiere die Technik und nenne diese Nummer.`
: status === 503 || status === 504 || status === 522 || status === 524
? `Der Server hat die Verarbeitung abgebrochen (HTTP ${status}). Das ist eine Zeitüberschreitung: die Verarbeitung dauerte länger, als der vorgeschaltete Proxy zulässt.`
: `Der Server hat unerwartet geantwortet (HTTP ${status}) — keine verwertbare Rückmeldung. Bitte informiere die Technik und nenne diese Nummer.`,
: `Der Server hat unerwartet geantwortet (HTTP ${status}), keine verwertbare Rückmeldung. Bitte informiere die Technik und nenne diese Nummer.`,
uploadErrorNetwork: "Die Verbindung zum Server wurde während des Uploads unterbrochen.",
},
adminVideoField: {
@@ -1554,7 +1575,7 @@ export const de: Dictionary = {
? `Der Server hat die Verarbeitung abgebrochen (HTTP 502). Das Backend hat die Verbindung fallen gelassen, ohne zu antworten — bei Videos meist, weil ihm beim Verarbeiten der Arbeitsspeicher ausging. Bitte informiere die Technik und nenne diese Nummer.`
: status === 503 || status === 504 || status === 522 || status === 524
? `Der Server hat die Verarbeitung abgebrochen (HTTP ${status}). Das ist eine Zeitüberschreitung: der Upload dauerte länger, als der vorgeschaltete Proxy zulässt. Ein kürzeres oder kleineres Video kommt in der Regel durch.`
: `Der Server hat unerwartet geantwortet (HTTP ${status}) — keine verwertbare Rückmeldung. Bitte informiere die Technik und nenne diese Nummer.`,
: `Der Server hat unerwartet geantwortet (HTTP ${status}), keine verwertbare Rückmeldung. Bitte informiere die Technik und nenne diese Nummer.`,
uploadErrorNetwork: "Die Verbindung zum Server wurde während des Uploads unterbrochen.",
},
adminShell: {
@@ -1739,7 +1760,7 @@ export const de: Dictionary = {
trackOrder: "Bestellung verfolgen",
openEyebrow: "Zahlung nicht abgeschlossen",
openTitle: "Das hat leider nicht geklappt.",
openText: "Die Zahlung wurde abgebrochen oder abgelehnt, es wurde nichts abgebucht. Deine Bestellung ist noch für dich reserviert — versuche es einfach erneut, gern auch mit einer anderen Zahlungsart.",
openText: "Die Zahlung wurde abgebrochen oder abgelehnt, es wurde nichts abgebucht. Deine Bestellung ist noch für dich reserviert, versuche es einfach erneut, gern auch mit einer anderen Zahlungsart.",
cancelPayment: "Bestellung abbrechen",
notCompletedEyebrow: "Nicht abgeschlossen",
notCompletedTitle: "Diese Bestellung wurde nicht bezahlt.",
@@ -1869,6 +1890,10 @@ export const de: Dictionary = {
"schonend verarbeitet",
"ohne synthetische Zusätze",
],
SONSTIGES: [
"aus eigener Imkerei in Altenmünster",
"nachvollziehbare Charge",
],
},
detailLabels: {
description: "Beschreibung",
+25
View File
@@ -8,6 +8,7 @@ export const en: Dictionary = {
HONIG: "Honey",
KERZEN: "Beeswax candles",
PROPOLIS: "Propolis",
SONSTIGES: "More bee products",
},
turnstile: {
loadError: "The security check could not be loaded.",
@@ -1202,6 +1203,14 @@ export const en: Dictionary = {
intensityKraeftigLabel: "intense (1-5)",
weightLabel: "Weight (g)",
stockLabel: "Inventory (jars)",
stockFromApiCoreHint:
"Comes from ApiCore (remaining stock minus open orders). Book withdrawals and stock counts in ApiCore.",
apicoreLinkLabel: "ApiCore article",
apicoreNotLinked: "— not linked (manual stock) —",
apicoreLinkHint:
"Linked: stock comes from ApiCore (sum of all production batches); sales and returns are reported there.",
apicoreUnavailable: (message) => `Could not load ApiCore articles: ${message}`,
apicoreHoneyLinked: (bottlingId) => `Linked to ApiCore bottling ${bottlingId}.`,
priceLabel: "Price (cents)",
pricePerKgLabel: "Base price per kg (cents)",
vatRateLabel: "VAT rate (%, optional)",
@@ -1441,6 +1450,18 @@ export const en: Dictionary = {
toDoReturns: (count) => (count === 1 ? "1 new return" : `${count} new returns`),
toDoReviews: (count) => (count === 1 ? "1 review awaiting approval" : `${count} reviews awaiting approval`),
toDoInquiries: (count) => (count === 1 ? "1 new business inquiry" : `${count} new business inquiries`),
toDoStockOverdue: (count) =>
count === 1
? "1 stock booking not delivered to ApiCore for over 15 minutes"
: `${count} stock bookings not delivered to ApiCore for over 15 minutes`,
toDoStockRejected: (count) =>
count === 1
? "1 stock booking rejected by ApiCore — please check"
: `${count} stock bookings rejected by ApiCore — please check`,
toDoUnlinkedProducts: (count) =>
count === 1
? "1 active product not linked to ApiCore yet"
: `${count} active products not linked to ApiCore yet`,
allDoneTitle: "All done.",
allDoneText: "Nothing is waiting for you right now. Time for the bees.",
chartTooltip: (day, value) => `${day}: ${value}`,
@@ -1843,6 +1864,10 @@ export const en: Dictionary = {
"gently processed",
"no synthetic additives",
],
SONSTIGES: [
"from our own apiary in Altenmünster",
"traceable batch",
],
},
detailLabels: {
description: "Description",
+11
View File
@@ -44,6 +44,7 @@ export interface Dictionary {
HONIG: string;
KERZEN: string;
PROPOLIS: string;
SONSTIGES: string;
};
siteDescription: string;
/** Sicherheitspruefung (Cloudflare Turnstile) konnte nicht geladen werden. */
@@ -783,6 +784,12 @@ export interface Dictionary {
intensityKraeftigLabel: string;
weightLabel: string;
stockLabel: string;
stockFromApiCoreHint: string;
apicoreLinkLabel: string;
apicoreNotLinked: string;
apicoreLinkHint: string;
apicoreUnavailable: (message: string) => string;
apicoreHoneyLinked: (bottlingId: string) => string;
priceLabel: string;
pricePerKgLabel: string;
vatRateLabel: string;
@@ -1011,6 +1018,9 @@ export interface Dictionary {
toDoReturns: (count: number) => string;
toDoReviews: (count: number) => string;
toDoInquiries: (count: number) => string;
toDoStockOverdue: (count: number) => string;
toDoStockRejected: (count: number) => string;
toDoUnlinkedProducts: (count: number) => string;
allDoneTitle: string;
allDoneText: string;
chartTooltip: (day: string, value: string) => string;
@@ -1373,6 +1383,7 @@ export interface Dictionary {
HONIG: string[];
KERZEN: string[];
PROPOLIS: string[];
SONSTIGES: string[];
};
detailLabels: {
description: string;
+103
View File
@@ -115,6 +115,12 @@ const productSchema = z.object({
weight: z.number().nullable().optional(),
salesUnit: z.string().nullable().optional(),
status: z.string(),
// Seit dem Bestandsabgleich: wo der Bestand liegt (Honig je Abfuellung,
// hergestellte Waren je Herstellungscharge), Shop-Kategorie und Rest.
// Optional, damit ein aelteres ApiCore weiter gelesen werden kann.
kind: z.string().optional(),
shopCategory: z.string().optional(),
remainingCount: z.number().nullable().optional(),
});
export type ApiCoreProduct = z.infer<typeof productSchema>;
@@ -136,6 +142,7 @@ const bottlingSchema = z.object({
// Glasgroesse in Gramm (siehe Einheiten-Hinweis an productSchema).
jarSize: z.number().nullable(),
jarCount: z.number(),
remainingCount: z.number().optional(),
// Eine Abfuellung muss in ApiCore keinem Artikel zugeordnet sein.
productId: z.string().nullable(),
harvestIds: z.array(z.string()),
@@ -159,17 +166,34 @@ export interface ApiCoreBottlingBatchWithHarvestId {
harvestSourceId: string | null;
}
const productionBatchSchema = z.object({
id: z.string(),
productId: z.string(),
lotNumber: z.string(),
quantity: z.number(),
remainingCount: z.number(),
});
export type ApiCoreProductionBatch = z.infer<typeof productionBatchSchema>;
const shopFeedSchema = z.object({
contractVersion: z.literal(SUPPORTED_SHOP_SYNC_CONTRACT),
harvests: z.array(harvestSchema),
products: z.array(productSchema),
bottlings: z.array(bottlingSchema),
productionBatches: z.array(productionBatchSchema).optional(),
});
export interface ApiCoreShopData {
harvests: ApiCoreHarvest[];
products: ApiCoreProduct[];
bottlings: ApiCoreBottlingBatchWithHarvestId[];
productionBatches: ApiCoreProductionBatch[];
}
/** Artikel, deren Bestand in ApiCore je Herstellungscharge liegt (Kerzen, Propolis, ...). */
export function isManufactured(product: ApiCoreProduct): boolean {
return product.kind === "MANUFACTURED";
}
function httpErrorMessage(status: number): string {
@@ -224,5 +248,84 @@ export async function fetchApiCoreShopData(connection: ApiCoreConnection): Promi
...bottling,
harvestSourceId: harvestIds[0] ?? null,
})),
productionBatches: parsed.data.productionBatches ?? [],
};
}
// ---------------------------------------------------------------------------
// Bestandsabgleich: Rest lesen, Verkaeufe und Rueckbuchungen buchen
// ---------------------------------------------------------------------------
const stockSchema = z.object({
bottlings: z.array(z.object({ id: z.string(), remainingCount: z.number() })),
products: z.array(z.object({ id: z.string(), remainingCount: z.number() })),
});
export type ApiCoreStock = z.infer<typeof stockSchema>;
/** Nur die Restbestaende — fuer den haeufigen Abgleich (GET /shop-sync/stock). */
export async function fetchApiCoreStock(connection: ApiCoreConnection): Promise<ApiCoreStock> {
let res: Response;
try {
res = await fetch(`${connection.baseUrl}/shop-sync/stock`, {
headers: { Authorization: `Bearer ${connection.apiKey}` },
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
} catch (error: unknown) {
throw new ApiCoreRequestError(`ApiCore unter ${connection.baseUrl} nicht erreichbar: ${describeFetchFailure(error)}`);
}
if (!res.ok) throw new ApiCoreRequestError(httpErrorMessage(res.status));
const parsed = stockSchema.safeParse(await res.json());
if (!parsed.success) throw new ApiCoreRequestError("ApiCore: unerwartete Antwortstruktur für Bestände.");
return parsed.data;
}
export interface ApiCoreStockRequest {
externalId: string;
kind: "SALE" | "RETURN";
bottlingBatchId?: string;
productId?: string;
quantity: number;
reversesExternalId?: string;
}
const movementResultSchema = z.object({
externalId: z.string(),
status: z.enum(["created", "duplicate", "rejected"]),
error: z.string().optional(),
allocations: z.array(z.object({ lotNumber: z.string(), quantity: z.number() })),
remainingCount: z.number().nullable(),
});
export type ApiCoreMovementResult = z.infer<typeof movementResultSchema>;
/**
* Bucht Verkaeufe und Rueckbuchungen in ApiCore (POST /shop-sync/movements,
* Recht shop-sync:write). Jede Anfrage traegt eine externalId — dieselbe
* Anfrage zweimal bucht ApiCore nur einmal.
*/
export async function postApiCoreStockMovements(
connection: ApiCoreConnection,
requests: ApiCoreStockRequest[]
): Promise<ApiCoreMovementResult[]> {
let res: Response;
try {
res = await fetch(`${connection.baseUrl}/shop-sync/movements`, {
method: "POST",
headers: { Authorization: `Bearer ${connection.apiKey}`, "Content-Type": "application/json" },
body: JSON.stringify({ requests }),
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
} catch (error: unknown) {
throw new ApiCoreRequestError(`ApiCore unter ${connection.baseUrl} nicht erreichbar: ${describeFetchFailure(error)}`);
}
if (res.status === 403) {
throw new ApiCoreRequestError(
"ApiCore verweigert das Buchen (HTTP 403): Der API-Schlüssel braucht in ApiCore zusätzlich die Berechtigung „Verkäufe und Rückbuchungen des Shops buchen“ (shop-sync:write)."
);
}
if (!res.ok) throw new ApiCoreRequestError(httpErrorMessage(res.status));
const parsed = z.object({ results: z.array(movementResultSchema) }).safeParse(await res.json());
if (!parsed.success) throw new ApiCoreRequestError("ApiCore: unerwartete Antwort auf Bestandsbuchungen.");
return parsed.data.results;
}
+28
View File
@@ -0,0 +1,28 @@
import { prisma } from "@/lib/prisma";
import { OVERDUE_AFTER_MS } from "./stock-math";
export interface ApiCoreStockIssues {
/** Seit ueber 15 Minuten nicht bei ApiCore angekommen. */
overdue: number;
/** Von ApiCore abgelehnt — braucht einen Menschen. */
rejected: number;
/** Aktive Produkte, deren Bestand noch von Hand gepflegt wird. */
unlinked: number;
}
/**
* Stoerungen des Bestandsabgleichs fuer das Admin-Dashboard. Ohne
* ApiCore-Konfiguration gibt es nichts zu melden.
*/
export async function countApiCoreStockIssues(now: Date = new Date()): Promise<ApiCoreStockIssues> {
const configured = Boolean(process.env.APICORE_API_URL?.trim() && process.env.APICORE_API_KEY?.trim());
if (!configured) return { overdue: 0, rejected: 0, unlinked: 0 };
const [overdue, rejected, unlinked] = await Promise.all([
prisma.apiCoreStockEvent.count({
where: { sentAt: null, rejectedAt: null, createdAt: { lt: new Date(now.getTime() - OVERDUE_AFTER_MS) } },
}),
prisma.apiCoreStockEvent.count({ where: { rejectedAt: { not: null } } }),
prisma.product.count({ where: { isActive: true, apicoreBottlingId: null, apicoreProductId: null } }),
]);
return { overdue, rejected, unlinked };
}
+121
View File
@@ -0,0 +1,121 @@
import { prisma } from "@/lib/prisma";
import {
ApiCoreConfigError,
getApiCoreConnection,
postApiCoreStockMovements,
type ApiCoreStockRequest,
} from "@/lib/apicore-client";
import { nextAttemptDelayMs } from "./stock-math";
/** Hoechstens so viele Buchungen je Aufruf — ApiCore nimmt bis zu 100. */
const BATCH_SIZE = 100;
export interface DeliveryResult {
sent: number;
rejected: number;
failed: number;
/** Wabenhain-Produkte, deren Bestand danach neu abgeglichen werden sollte. */
productIds: string[];
/** Fehlermeldung, wenn ApiCore nicht erreichbar war oder ablehnte. */
error?: string;
}
/**
* Sendet faellige Buchungen der Warteschlange an ApiCore.
*
* - Bestaetigt (neu gebucht oder schon bekannt): sentAt; beim Verkauf werden
* die von ApiCore zugeordneten Chargen an der Bestellposition gespeichert.
* - Abgelehnt (z. B. Abfuellung in ApiCore geloescht): rejectedAt, kein
* weiterer Versuch — das Dashboard zeigt es zur Klaerung.
* - Nicht erreichbar / HTTP-Fehler: naechster Versuch nach 1, 5, 15, 60 Min.
*
* Eine Rueckbuchung wartet, bis ihr Verkauf bestaetigt ist (ApiCore kennt
* sonst den zurueckzubuchenden Verkauf nicht) — es sei denn, beide gehen im
* selben Aufruf raus: ApiCore verarbeitet in Reihenfolge, der Verkauf steht
* vorn.
*/
export async function deliverPendingStockEvents(now: Date = new Date()): Promise<DeliveryResult> {
const result: DeliveryResult = { sent: 0, rejected: 0, failed: 0, productIds: [] };
let connection;
try {
connection = getApiCoreConnection();
} catch (error: unknown) {
if (error instanceof ApiCoreConfigError) return { ...result, error: error.message };
throw error;
}
const due = await prisma.apiCoreStockEvent.findMany({
where: { sentAt: null, rejectedAt: null, nextAttemptAt: { lte: now } },
orderBy: { createdAt: "asc" },
take: BATCH_SIZE,
include: { reverses: { select: { sentAt: true } } },
});
const inBatch = new Set(due.map((event) => event.id));
const ready = due.filter(
(event) => !event.reversesEventId || event.reverses?.sentAt || inBatch.has(event.reversesEventId)
);
if (ready.length === 0) return result;
const requests: ApiCoreStockRequest[] = ready.map((event) => ({
externalId: event.id,
kind: event.kind,
bottlingBatchId: event.apicoreBottlingId ?? undefined,
productId: event.apicoreProductId ?? undefined,
quantity: event.quantity,
reversesExternalId: event.reversesEventId ?? undefined,
}));
let responses;
try {
responses = await postApiCoreStockMovements(connection, requests);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "Unbekannter Fehler";
for (const event of ready) {
await prisma.apiCoreStockEvent.update({
where: { id: event.id },
data: {
attempts: { increment: 1 },
lastError: message,
nextAttemptAt: new Date(now.getTime() + nextAttemptDelayMs(event.attempts)),
},
});
}
return { ...result, failed: ready.length, error: message };
}
const byId = new Map(ready.map((event) => [event.id, event]));
for (const response of responses) {
const event = byId.get(response.externalId);
if (!event) continue;
result.productIds.push(event.productId);
if (response.status === "rejected") {
await prisma.apiCoreStockEvent.update({
where: { id: event.id },
data: { rejectedAt: now, lastError: response.error ?? "Von ApiCore abgelehnt" },
});
result.rejected++;
continue;
}
await prisma.$transaction(async (tx) => {
await tx.apiCoreStockEvent.update({
where: { id: event.id },
data: { sentAt: now, lastError: null },
});
if (event.kind === "SALE") {
await tx.orderItem.update({
where: { id: event.orderItemId },
data: {
apicoreAllocations: response.allocations.map((allocation) => ({
lotNumber: allocation.lotNumber,
quantity: allocation.quantity,
})),
},
});
}
});
result.sent++;
}
result.productIds = [...new Set(result.productIds)];
return result;
}
+137
View File
@@ -0,0 +1,137 @@
import { prisma } from "@/lib/prisma";
import {
ApiCoreConfigError,
fetchApiCoreStock,
getApiCoreConnection,
} from "@/lib/apicore-client";
import { pendingNet, shopQuantity } from "./stock-math";
const SERIALIZATION_CONFLICT_CODE = "P2034";
const MAX_ATTEMPTS = 4;
function isSerializationConflict(error: unknown): boolean {
return (
typeof error === "object" &&
error !== null &&
"code" in error &&
error.code === SERIALIZATION_CONFLICT_CODE
);
}
export interface ReconcileResult {
updated: number;
/** Verknuepft, aber in ApiCore nicht (mehr) vorhanden. */
missing: number;
error?: string;
}
export interface ReconcileFilter {
/** Nur diese Wabenhain-Produkte; ohne Angabe alle verknuepften. */
productIds?: string[];
/** Nur Produkte mit diesen ApiCore-Zielen (aus einem Webhook). */
apicoreBottlingIds?: string[];
apicoreProductIds?: string[];
}
/**
* Gleicht den Shop-Bestand verknuepfter Produkte mit ApiCore ab
* (Formel: lib/apicore-stock/stock-math.ts).
*
* Je Produkt eine eigene Transaktion mit "Serializable" — dieselbe Isolation
* wie der Checkout. Reserviert ein Checkout gleichzeitig, bricht die
* Datenbank eine der beiden ab, statt dass der Abgleich die Reservierung mit
* einem veralteten Wert ueberschreibt; abgebrochene Laeufe werden wiederholt.
*/
export async function reconcileApiCoreStock(filter: ReconcileFilter = {}): Promise<ReconcileResult> {
const result: ReconcileResult = { updated: 0, missing: 0 };
let connection;
try {
connection = getApiCoreConnection();
} catch (error: unknown) {
if (error instanceof ApiCoreConfigError) return { ...result, error: error.message };
throw error;
}
const byTarget =
filter.apicoreBottlingIds || filter.apicoreProductIds
? [
...(filter.apicoreBottlingIds?.length
? [{ apicoreBottlingId: { in: filter.apicoreBottlingIds } }]
: []),
...(filter.apicoreProductIds?.length
? [{ apicoreProductId: { in: filter.apicoreProductIds } }]
: []),
]
: [{ apicoreBottlingId: { not: null } }, { apicoreProductId: { not: null } }];
if (byTarget.length === 0) return result;
const products = await prisma.product.findMany({
where: { OR: byTarget, ...(filter.productIds ? { id: { in: filter.productIds } } : {}) },
select: { id: true, apicoreBottlingId: true, apicoreProductId: true },
});
if (products.length === 0) return result;
const stock = await fetchApiCoreStock(connection);
const bottlingRemaining = new Map(stock.bottlings.map((b) => [b.id, b.remainingCount]));
const productRemaining = new Map(stock.products.map((p) => [p.id, p.remainingCount]));
for (const product of products) {
const apiRemaining = product.apicoreBottlingId
? bottlingRemaining.get(product.apicoreBottlingId)
: productRemaining.get(product.apicoreProductId!);
if (apiRemaining === undefined) {
result.missing++;
continue;
}
await syncOneProduct(product.id, product.apicoreBottlingId, apiRemaining);
result.updated++;
}
return result;
}
async function syncOneProduct(
productId: string,
apicoreBottlingId: string | null,
apiRemaining: number
): Promise<void> {
for (let attempt = 1; ; attempt++) {
try {
await prisma.$transaction(
async (tx) => {
const pending = await tx.apiCoreStockEvent.findMany({
where: { productId, sentAt: null, rejectedAt: null },
select: { kind: true, quantity: true },
});
const reservedRows = await tx.orderItem.aggregate({
where: { productId, order: { status: "NEU" } },
_sum: { quantity: true },
});
const quantity = shopQuantity({
apiRemaining,
pending,
reserved: reservedRows._sum.quantity ?? 0,
});
await tx.inventory.upsert({
where: { productId },
create: { productId, quantity },
update: { quantity },
});
if (apicoreBottlingId) {
// Anzeige "noch verfuegbar" an Ernte und Charge: was es in
// Wirklichkeit noch gibt, ohne die Webshop-Reservierungen.
await tx.batch.updateMany({
where: { apicoreBottlingId },
data: { remainingGlasses: Math.max(0, apiRemaining + pendingNet(pending)) },
});
}
},
{ isolationLevel: "Serializable" }
);
return;
} catch (error: unknown) {
if (!isSerializationConflict(error) || attempt >= MAX_ATTEMPTS) throw error;
await new Promise((resolve) => setTimeout(resolve, 25 * 2 ** attempt * (1 + Math.random())));
}
}
}
+29
View File
@@ -0,0 +1,29 @@
import { after } from "next/server";
import { deliverPendingStockEvents } from "./delivery";
import { reconcileApiCoreStock } from "./reconcile";
/**
* Sendet offene Buchungen und gleicht die betroffenen Bestaende ab — nach
* der Antwort, damit weder Kundin noch Admin auf ApiCore warten. Scheitert
* es, holt der CronJob (scripts/apicore-stock-sync.ts) es spaetestens nach
* zehn Minuten nach; verloren geht nichts, die Buchung liegt in der
* Warteschlange.
*/
export function scheduleApiCoreStockSync(productIds: string[] = []): void {
const run = async () => {
try {
const delivered = await deliverPendingStockEvents();
const affected = [...new Set([...productIds, ...delivered.productIds])];
if (affected.length > 0) await reconcileApiCoreStock({ productIds: affected });
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "Unbekannter Fehler";
console.error(`[apicore-stock] Sofortabgleich fehlgeschlagen, der CronJob holt ihn nach: ${message}`);
}
};
try {
after(run);
} catch {
// Ausserhalb einer Anfrage (Skripte, Tests) gibt es kein after().
void run();
}
}
+89
View File
@@ -0,0 +1,89 @@
import type { Prisma } from "@/generated/prisma/client";
type Tx = Prisma.TransactionClient;
/**
* Schreibt die Bestandsbuchungen einer Bestellung in die Warteschlange
* (ApiCoreStockEvent) — immer innerhalb der Transaktion, die den Status
* aendert, damit Statuswechsel und Buchung nur gemeinsam passieren.
*
* Ob eine Rueckbuchung noetig ist, entscheidet die Warteschlange selbst, nicht
* der Bestellstatus: eine Position mit offenem (nicht zurueckgebuchtem)
* Verkauf bekommt eine Rueckbuchung, eine ohne nicht. So bucht eine
* Stornierung einer nie bezahlten Bestellung nichts, und eine erstattete
* Retoure, nach der die Bestellung noch storniert wird, nicht doppelt.
*
* Nur Positionen, deren Produkt mit ApiCore verknuepft ist (Abfuellung oder
* Artikel), werden gebucht. Eine Rueckbuchung geht immer an das Ziel des
* Verkaufs, auch wenn das Produkt inzwischen anders verknuepft ist.
*/
async function itemsWithStockState(tx: Tx, orderId: string) {
return tx.orderItem.findMany({
where: { orderId },
select: {
id: true,
quantity: true,
productId: true,
product: { select: { apicoreBottlingId: true, apicoreProductId: true } },
apicoreStockEvents: {
where: { kind: "SALE" },
select: {
id: true,
quantity: true,
apicoreBottlingId: true,
apicoreProductId: true,
reversedBy: { select: { id: true } },
},
},
},
});
}
/** Verbucht die Positionen einer gerade bezahlten Bestellung als Verkauf. */
export async function recordOrderSale(tx: Tx, orderId: string): Promise<number> {
const items = await itemsWithStockState(tx, orderId);
let created = 0;
for (const item of items) {
const { apicoreBottlingId, apicoreProductId } = item.product;
if (!apicoreBottlingId && !apicoreProductId) continue;
const hasOpenSale = item.apicoreStockEvents.some((sale) => sale.reversedBy.length === 0);
if (hasOpenSale) continue;
await tx.apiCoreStockEvent.create({
data: {
kind: "SALE",
orderItemId: item.id,
productId: item.productId,
apicoreBottlingId: apicoreBottlingId ?? null,
apicoreProductId: apicoreBottlingId ? null : apicoreProductId,
quantity: item.quantity,
},
});
created++;
}
return created;
}
/** Bucht jeden offenen Verkauf der Bestellung zurueck (Storno, Retoure). */
export async function recordOrderReturn(tx: Tx, orderId: string): Promise<number> {
const items = await itemsWithStockState(tx, orderId);
let created = 0;
for (const item of items) {
for (const sale of item.apicoreStockEvents) {
if (sale.reversedBy.length > 0) continue;
await tx.apiCoreStockEvent.create({
data: {
kind: "RETURN",
orderItemId: item.id,
productId: item.productId,
apicoreBottlingId: sale.apicoreBottlingId,
apicoreProductId: sale.apicoreProductId,
quantity: sale.quantity,
reversesEventId: sale.id,
},
});
created++;
}
}
return created;
}
+49
View File
@@ -0,0 +1,49 @@
import { describe, it, expect } from "vitest";
import { isOverdue, nextAttemptDelayMs, pendingNet, shopQuantity } from "./stock-math";
describe("shopQuantity", () => {
it("zieht unbestaetigte Verkaeufe und offene Reservierungen vom ApiCore-Rest ab", () => {
expect(
shopQuantity({ apiRemaining: 100, pending: [{ kind: "SALE", quantity: 2 }], reserved: 1 })
).toBe(97);
});
it("bleibt gleich, sobald ApiCore den Verkauf bestaetigt hat", () => {
expect(shopQuantity({ apiRemaining: 98, pending: [], reserved: 1 })).toBe(97);
});
it("zaehlt unbestaetigte Rueckbuchungen wieder hinzu", () => {
expect(
shopQuantity({ apiRemaining: 10, pending: [{ kind: "RETURN", quantity: 3 }], reserved: 0 })
).toBe(13);
});
it("wird nie negativ, auch wenn ApiCore unter den Reservierungen liegt", () => {
expect(shopQuantity({ apiRemaining: 1, pending: [], reserved: 4 })).toBe(0);
});
});
describe("pendingNet", () => {
it("rechnet Verkauf minus, Rueckbuchung plus", () => {
expect(
pendingNet([
{ kind: "SALE", quantity: 5 },
{ kind: "RETURN", quantity: 2 },
])
).toBe(-3);
});
});
describe("nextAttemptDelayMs", () => {
it("wartet 1, 5, 15, 60 Minuten und danach stuendlich", () => {
expect([0, 1, 2, 3, 4, 10].map((n) => nextAttemptDelayMs(n) / 60_000)).toEqual([1, 5, 15, 60, 60, 60]);
});
});
describe("isOverdue", () => {
it("meldet Buchungen erst nach 15 Minuten ohne Bestaetigung", () => {
const now = new Date("2026-10-02T12:00:00Z");
expect(isOverdue(new Date("2026-10-02T11:50:00Z"), now)).toBe(false);
expect(isOverdue(new Date("2026-10-02T11:40:00Z"), now)).toBe(true);
});
});
+61
View File
@@ -0,0 +1,61 @@
/**
* Reine Rechenregeln des Bestandsabgleichs mit ApiCore — ohne Datenbank,
* damit genau die Entscheidungen testbar sind, an denen Ueberverkauf haengt.
*
* ApiCore fuehrt den Restbestand je Charge. Der Shop-Bestand (Inventory) eines
* verknuepften Produkts ist daraus abgeleitet:
*
* Shop-Bestand = ApiCore-Rest
* + noch nicht bestaetigte Buchungen (Verkauf minus, Rueckbuchung plus)
* - in unbezahlten Bestellungen (NEU) reservierte Menge
*
* Beispiel: Rest 100, zwei Glaeser bezahlt aber noch nicht gemeldet, eines
* im Checkout reserviert -> 97. Nach der Bestaetigung: Rest 98, nichts offen
* -> weiterhin 97. Die Zahl springt also nie.
*/
export type StockEventKind = "SALE" | "RETURN";
export interface PendingStockEvent {
kind: StockEventKind;
quantity: number;
}
/** Vorzeichen einer Buchung aus Sicht des Bestands. */
export function eventDelta(event: PendingStockEvent): number {
return event.kind === "SALE" ? -event.quantity : event.quantity;
}
export function pendingNet(events: readonly PendingStockEvent[]): number {
return events.reduce((sum, event) => sum + eventDelta(event), 0);
}
/**
* Was der Shop noch verkaufen darf. Nie negativ: liegt ApiCore unter den
* offenen Reservierungen (z. B. Hofladen-Entnahme waehrend eines Checkouts),
* ist schlicht nichts mehr frei — die bestehenden Reservierungen bleiben
* davon unberuehrt.
*/
export function shopQuantity(input: {
apiRemaining: number;
pending: readonly PendingStockEvent[];
reserved: number;
}): number {
return Math.max(0, input.apiRemaining + pendingNet(input.pending) - input.reserved);
}
/** Wartezeiten zwischen Zustellversuchen: 1, 5, 15, 60 Minuten, danach stuendlich. */
const RETRY_DELAYS_MINUTES = [1, 5, 15, 60] as const;
export function nextAttemptDelayMs(attemptsSoFar: number): number {
const minutes =
RETRY_DELAYS_MINUTES[Math.min(attemptsSoFar, RETRY_DELAYS_MINUTES.length - 1)];
return minutes * 60 * 1000;
}
/** Ab wann eine unbestaetigte Buchung im Dashboard als Stoerung gilt. */
export const OVERDUE_AFTER_MS = 15 * 60 * 1000;
export function isOverdue(createdAt: Date, now: Date): boolean {
return now.getTime() - createdAt.getTime() > OVERDUE_AFTER_MS;
}
@@ -0,0 +1,48 @@
import { createHmac } from "node:crypto";
import { describe, it, expect } from "vitest";
import { verifyApiCoreSignature } from "./webhook-signature";
const SECRET = "whsec_test";
const NOW = 1_790_000_000;
function sign(timestamp: string, deliveryId: string, body: string, secret = SECRET): string {
return "sha256=" + createHmac("sha256", secret).update(`${timestamp}.${deliveryId}.${body}`).digest("hex");
}
const body = JSON.stringify({ event: "stock.changed", data: { bottlingBatchIds: ["b1"], productIds: [] } });
describe("verifyApiCoreSignature", () => {
it("akzeptiert eine korrekt signierte, frische Zustellung", () => {
const ts = String(NOW);
expect(
verifyApiCoreSignature({ secret: SECRET, timestamp: ts, deliveryId: "d1", signature: sign(ts, "d1", body), body, nowSeconds: NOW })
).toBe(true);
});
it("lehnt einen veraenderten Body ab", () => {
const ts = String(NOW);
expect(
verifyApiCoreSignature({ secret: SECRET, timestamp: ts, deliveryId: "d1", signature: sign(ts, "d1", body), body: body + " ", nowSeconds: NOW })
).toBe(false);
});
it("lehnt ein falsches Secret ab", () => {
const ts = String(NOW);
expect(
verifyApiCoreSignature({ secret: SECRET, timestamp: ts, deliveryId: "d1", signature: sign(ts, "d1", body, "other"), body, nowSeconds: NOW })
).toBe(false);
});
it("lehnt eine mehr als fuenf Minuten alte Zustellung ab (Replay)", () => {
const ts = String(NOW - 301);
expect(
verifyApiCoreSignature({ secret: SECRET, timestamp: ts, deliveryId: "d1", signature: sign(ts, "d1", body), body, nowSeconds: NOW })
).toBe(false);
});
it("lehnt fehlende Kopfzeilen ab", () => {
expect(
verifyApiCoreSignature({ secret: SECRET, timestamp: null, deliveryId: "d1", signature: "sha256=00", body, nowSeconds: NOW })
).toBe(false);
});
});
@@ -0,0 +1,37 @@
import { createHmac, timingSafeEqual } from "node:crypto";
/**
* Prueft die Signatur eines ApiCore-Webhooks.
*
* ApiCore signiert "<Zeitstempel>.<Zustell-ID>.<Body>" mit HMAC-SHA256 und
* schickt das Ergebnis als "X-ApiCore-Signature: sha256=<hex>" (siehe
* ApiCore WebhooksService.deliver). Der Zeitstempel ist mitsigniert; ein
* mitgeschnittener Aufruf laesst sich deshalb nach TOLERANCE_SECONDS nicht
* wiederverwenden.
*
* Selbst ein gueltiger Webhook setzt in Wabenhain keine Zahl — er loest nur
* einen Abgleich ueber die API aus. Ein gefaelschter Aufruf koennte also
* hoechstens einen ueberfluessigen Abgleich anstossen.
*/
export const TOLERANCE_SECONDS = 5 * 60;
export function verifyApiCoreSignature(input: {
secret: string;
timestamp: string | null;
deliveryId: string | null;
signature: string | null;
body: string;
nowSeconds?: number;
}): boolean {
const { secret, timestamp, deliveryId, signature, body } = input;
if (!secret || !timestamp || !deliveryId || !signature) return false;
if (!/^\d+$/.test(timestamp)) return false;
const now = input.nowSeconds ?? Math.floor(Date.now() / 1000);
if (Math.abs(now - Number(timestamp)) > TOLERANCE_SECONDS) return false;
const match = /^sha256=([0-9a-f]{64})$/.exec(signature);
if (!match) return false;
const expected = createHmac("sha256", secret).update(`${timestamp}.${deliveryId}.${body}`).digest();
const received = Buffer.from(match[1], "hex");
return received.length === expected.length && timingSafeEqual(received, expected);
}
+1
View File
@@ -69,6 +69,7 @@ const READ_INDIRECTLY: Record<string, string> = {
VAT_RATE_PERCENT_HONIG: "lib/vat.ts — berechneter Schluessel in CATEGORY_ENV_NAMES.",
VAT_RATE_PERCENT_KERZEN: "lib/vat.ts — berechneter Schluessel in CATEGORY_ENV_NAMES.",
VAT_RATE_PERCENT_PROPOLIS: "lib/vat.ts — berechneter Schluessel in CATEGORY_ENV_NAMES.",
VAT_RATE_PERCENT_SONSTIGES: "lib/vat.ts — berechneter Schluessel in CATEGORY_ENV_NAMES.",
};
/**
+1
View File
@@ -8,6 +8,7 @@ export const categoryLabels = {
HONIG: "Honig",
KERZEN: "Bienenwachskerzen",
PROPOLIS: "Propolis",
SONSTIGES: "Weitere Bienenprodukte",
} as const;
export const siteConfig = {
+1 -1
View File
@@ -164,7 +164,7 @@ export const adminProductIdentitySchema = z
.object({
name: z.string().trim().min(1, "Bitte einen Namen angeben."),
slug: z.string().trim().default(""),
category: z.enum(["HONIG", "KERZEN", "PROPOLIS"], { message: "Unbekannte Kategorie." }),
category: z.enum(["HONIG", "KERZEN", "PROPOLIS", "SONSTIGES"], { message: "Unbekannte Kategorie." }),
})
.transform((input) => ({ ...input, slug: toProductSlug(input.slug || input.name) }))
.refine((input) => input.slug.length > 0, {
+1
View File
@@ -99,6 +99,7 @@ const CATEGORY_ENV_NAMES = {
HONIG: "VAT_RATE_PERCENT_HONIG",
KERZEN: "VAT_RATE_PERCENT_KERZEN",
PROPOLIS: "VAT_RATE_PERCENT_PROPOLIS",
SONSTIGES: "VAT_RATE_PERCENT_SONSTIGES",
} as const;
export type VatCategory = keyof typeof CATEGORY_ENV_NAMES;