Files
Wabenhain/src/lib/inventory.ts
T
Nick SzaunigandClaude Opus 5.5 515fca1c16
Docker Image bauen und veroeffentlichen / typecheck (push) Successful in 2m57s
Docker Image bauen und veroeffentlichen / lint (push) Successful in 3m7s
Docker Image bauen und veroeffentlichen / build-and-push (push) Canceled after 0s
Docker Image bauen und veroeffentlichen / test (push) Canceled after 1m22s
fix: Befunde aus Sicherheits- und Korrektheitsreview der Bienenpatenschaft
- Oeffentliche Kuendigung: exakte Identifikation, Limits je Vertrag und
  Adresse, Bestaetigung nur an die Kontoadresse mit Ruecknahme-Link (HMAC)
- Vorschau: Body-Grenze beim Lesen, Parallelitaets- und Minutenlimit, Cache
- Rabatte gelten nicht fuer Patenschaftspositionen
- Abo aktiv erst nach bestaetigter Zahlung, offene Abschluesse zaehlen
  zum Kontingent und werden nach 24 h aufgeraeumt, Sitzung 58 Minuten
- Konto-Loeschung nur ohne laufendes Abo, Urkunden-Dateien werden entfernt
- Abo-Lieferungen mit Zahlungszeile (DHL-Marke moeglich)
- Shop zeigt freien Bestand nach Abo-Reservierung
- Storno/volle Erstattung widerruft die Patenschaft (Status WIDERRUFEN)
- Umsatzsteuer der Patenschaft per VAT_RATE_PERCENT_PATENSCHAFT (19 %)
- Digitale Bestellungen archivieren sich, Kalendertage in Europe/Berlin,
  Monatsenden begrenzt, Datenauskunft um Patenschaften ergaenzt

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 15:09:32 +02:00

113 lines
4.7 KiB
TypeScript

import type { OrderStatus, Prisma, ReturnStatus } from "@/generated/prisma/client";
/**
* Muss beim Stornieren einer Bestellung die Ware zurueckgebucht werden?
*
* Nein, wenn eine Retoure zu dieser Bestellung bereits erstattet wurde:
* refundReturnRequestAction (admin/retouren/actions.ts) hat die Ware dann
* schon zurueckgebucht. Ein zweites Gutschreiben wuerde den Bestand um Glaeser
* erhoehen, die es nicht gibt — genau das ist bei einer Testbestellung
* passiert (Retoure erstattet, danach "STORNIERT" gesetzt: +1 Glas zu viel).
* Gegenstueck zu needsRestockOnDelete in order-delete.ts.
*/
export type InventoryEditPlan =
| { kind: "keep" }
| { kind: "set"; expected: number; quantity: number }
| { kind: "reload" };
/**
* Was soll das Speichern des Produkt-Formulars mit dem Lagerbestand tun?
*
* Das Formular schickt neben dem eingegebenen Bestand ("submitted") den
* Bestand mit, der beim Oeffnen angezeigt wurde ("loaded", verstecktes Feld).
* Frueher wurde der Formularwert immer fest geschrieben: wurde zwischen
* Oeffnen und Speichern ein Glas verkauft, kam es durch das Speichern
* wieder in den Bestand, ohne dass es je zurueckkam.
*
* - Feld unveraendert: Bestand nicht anfassen ("keep").
* - Feld geaendert: nur schreiben, solange der Bestand noch "loaded" ist
* ("set"; die Bedingung prueft updateProductAction atomar im UPDATE).
* - "loaded" fehlt oder ist ungueltig: neu laden lassen ("reload"), statt
* zu raten, welchen Stand die Imkerei vor Augen hatte.
*/
export function planInventoryEdit(input: { loaded: string; submitted: number }): InventoryEditPlan {
if (!/^\d+$/.test(input.loaded)) return { kind: "reload" };
const loaded = Number(input.loaded);
if (!Number.isSafeInteger(loaded)) return { kind: "reload" };
if (loaded === input.submitted) return { kind: "keep" };
return { kind: "set", expected: loaded, quantity: input.submitted };
}
export function needsRestockOnCancel(returnStatus: ReturnStatus | null): boolean {
return returnStatus !== "ERSTATTET";
}
/**
* Eine stornierte Bestellung wieder zu oeffnen braucht eine ausdrueckliche
* Bestaetigung: beim Stornieren wurde die Ware zurueckgebucht und die Zahlung
* erstattet. Frueher ging das mit einem Klick — und ohne Gegenbuchung. Ein
* erneutes Stornieren buchte die Ware dann ein ZWEITES Mal zurueck (so
* geschehen: +2 statt +1 Glas).
*/
export function needsConfirmToChangeStatus(from: OrderStatus, to: OrderStatus): boolean {
return from === "STORNIERT" && to !== "STORNIERT";
}
/**
* Wird beim Wiederoeffnen Ware vom Bestand abgezogen? Genau dann, wenn das
* Stornieren sie zurueckgebucht hatte (needsRestockOnCancel) — Buchung und
* Gegenbuchung bleiben so immer paarweise.
*/
export function needsReserveOnReopen(returnStatus: ReturnStatus | null): boolean {
return needsRestockOnCancel(returnStatus);
}
export class InsufficientStockError extends Error {
constructor(readonly productName: string) {
super(`Nicht genug Bestand fuer ${productName}`);
this.name = "InsufficientStockError";
}
}
/**
* Zieht die Ware einer Bestellung vom Bestand ab — atomar je Produkt und nur,
* wenn genug da ist (wie die Reservierung im Checkout). Reicht es fuer ein
* Produkt nicht, wirft InsufficientStockError; die umgebende Transaktion
* macht dann auch die schon abgezogenen Positionen rueckgaengig.
*/
export async function reserveOrderItems(
tx: Prisma.TransactionClient,
items: { productId: string; quantity: number; productName: string; product?: { category: string } | null }[]
): Promise<void> {
for (const item of items) {
// Patenschafts-Traegerprodukte fuehren keinen Bestand (eine Urkunde ist
// nie ausverkauft) — ohne diese Ausnahme scheiterte das Wiederoeffnen
// jeder Bestellung mit Patenschaft. Der Honig des Pakets ist eine eigene
// Position und wird normal reserviert.
if (item.product?.category === "PATENSCHAFT") continue;
const reserved = await tx.inventory.updateMany({
where: { productId: item.productId, quantity: { gte: item.quantity } },
data: { quantity: { decrement: item.quantity } },
});
if (reserved.count === 0) throw new InsufficientStockError(item.productName);
}
}
/**
* Bucht reservierten/verkauften Bestand zurueck — genutzt beim Stornieren
* einer Bestellung (admin/actions.ts, api/checkout/webhook/route.ts) und bei
* einer angenommenen Retoure (admin/retouren/actions.ts). Alle drei Stellen
* hatten dieselbe Schleife dupliziert; jetzt ein gemeinsamer Aufruf.
*/
export async function restockOrderItems(
tx: Prisma.TransactionClient,
items: { productId: string; quantity: number }[]
): Promise<void> {
for (const item of items) {
await tx.inventory.updateMany({
where: { productId: item.productId },
data: { quantity: { increment: item.quantity } },
});
}
}