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

+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;