# EXPIRATION TRACKING — DESIGN.md ## 1. Architecture ``` ┌──────────────────────────────────┐ │ Product (extended) │ │ expiration_tracking_enabled │ └──────────────┬───────────────────┘ │ determines behavior of ┌──────────────▼───────────────────┐ │ InventoryDomain │ │ │ │ variant-level stock (legacy) │ ← non-tracking products │ lot-level stock (new) │ ← tracking products │ └─ FEFO allocation │ │ └─ expired exclusion │ └──────────────┬───────────────────┘ │ InventoryServicePort ┌──────────────▼───────────────────┐ │ Checkout │ │ calls reserve() → gets lots │ │ NO lot querying │ └──────────────────────────────────┘ ``` ## 2. Database ### 2.1 New Table: `inventory_lots` ```sql CREATE TABLE inventory_lots ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), variant_id uuid NOT NULL REFERENCES catalog_variants(id) ON DELETE CASCADE, quantity integer NOT NULL DEFAULT 0 CHECK (quantity >= 0), expiration_date date -- NULL when product has expiration_tracking_enabled=false -- REQUIRED when product has expiration_tracking_enabled=true CONSTRAINT no_past_expiry CHECK ( expiration_date IS NULL OR expiration_date >= CURRENT_DATE ), created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now() ); CREATE INDEX inventory_lots_variant_id_idx ON inventory_lots(variant_id); CREATE INDEX inventory_lots_expiration_idx ON inventory_lots(expiration_date) WHERE expiration_date IS NOT NULL; ``` ### 2.2 Product Extension: `catalog_products` ```sql -- New column ALTER TABLE catalog_products ADD COLUMN expiration_tracking_enabled boolean NOT NULL DEFAULT false; ``` ### 2.3 Movements Extension: `inventory_movements` ```sql -- New operation types ALTER TABLE inventory_movements DROP CONSTRAINT IF EXISTS inventory_movements_operation_check; ALTER TABLE inventory_movements ADD CONSTRAINT inventory_movements_operation_check CHECK ( operation IN ( 'reserve', 'release', 'confirm', 'set_available', 'lot_create', 'lot_adjust', 'lot_delete' ) ); -- New columns (optional, for lot traceability) ALTER TABLE inventory_movements ADD COLUMN lot_id uuid REFERENCES inventory_lots(id) ON DELETE SET NULL; ``` ## 3. Domain Layer ### 3.1 New Port: `InventoryLotRepository` ```typescript export interface InventoryLotRepository { create(input: CreateLotCommand): Promise; findById(id: string): Promise; findByVariantId(variantId: string): Promise; update(id: string, patch: Partial<{ quantity: number; expirationDate: Date }>): Promise; delete(id: string): Promise; // Derived queries findAvailableLots(variantId: string): Promise; // expiration > today, quantity > 0 findNearExpiry(variantId: string, withinDays: number): Promise; findExpired(variantId: string): Promise; } ``` ### 3.2 New Service: `LotService` ```typescript export class LotService { constructor( private readonly lots: InventoryLotRepository, private readonly products: ProductRepository, // read-only, to check policy ) {} async createLot(cmd: CreateLotCommand): Promise { const product = await this.products.findByVariantId(cmd.variantId); const requiresExpiry = product?.expiration_tracking_enabled ?? false; if (requiresExpiry && !cmd.expirationDate) { throw new MissingExpirationDateError(cmd.variantId); } if (cmd.expirationDate && cmd.expirationDate < today()) { throw new PastExpirationDateError(cmd.expirationDate); } return this.lots.create(cmd); } async getAvailableStock(variantId: string): Promise { const lots = await this.lots.findAvailableLots(variantId); return lots.reduce((sum, lot) => sum + lot.quantity, 0); } async allocateLots(variantId: string, quantity: number): Promise { // FEFO: sort by expiration_date ASC, allocate from earliest const lots = await this.lots.findAvailableLots(variantId); // ... allocation logic } } export interface LotAllocation { lotId: string; allocatedQuantity: number; } ``` ### 3.3 Extended `InventoryServicePort` ```typescript export interface InventoryServicePort { // existing — unchanged contract checkAvailability(variantId: string, quantity: number): Promise; reserve(input: StockCommand): Promise; release(input: StockCommand): Promise; confirm(input: StockCommand): Promise; setAvailable(input: SetAvailableStockCommand): Promise; } ``` `InventoryService` (the default implementation) is extended internally to delegate to `LotService` when the product has expiration tracking enabled. ### 3.4 New Feature Flag Check ```typescript // In InventoryService.checkAvailability const featureEnabled = await this.flags.isEnabled('expiration_tracking'); if (!featureEnabled) { // existing variant-level behavior return this.repo.findByVariantId(variantId).then(toAvailability); } // With feature enabled: const product = await this.productRepo.findByVariantId(variantId); if (!product.expiration_tracking_enabled) { return this.repo.findByVariantId(variantId).then(toAvailability); } const available = await this.lotService.getAvailableStock(variantId); return { available: available > 0, availableQuantity: available }; ``` ## 4. Checkout Integration The checkout injects `InventoryServicePort`. With the feature flag off, behavior is unchanged. With it on, `InventoryService` internally uses FEFO lot allocation. No changes to `CheckoutService` are required. The `reservedVariantIds` in the checkout response are sufficient for current order tracking. If lot-level traceability is added later: - Add `lotIds: string[]` to `OrderLine` - Populate at `InventoryService.confirm()` time - Add read-only lot info to order detail API ## 5. FEFO Implementation ```typescript async allocateLots(variantId: string, quantity: number): Promise { const lots = await this.lots.findAvailableLots(variantId); // findAvailableLots already filters: expiration_date > today AND quantity > 0 // AND orders by expiration_date ASC (FEFO) const allocations: LotAllocation[] = []; let remaining = quantity; for (const lot of lots) { if (remaining <= 0) break; const take = Math.min(lot.quantity, remaining); allocations.push({ lotId: lot.id, allocatedQuantity: take }); remaining -= take; } if (remaining > 0) { throw new InsufficientStockError(variantId, quantity); } return allocations; } ``` Reservation then deducts from each lot in order: ```typescript async reserveFromLots(allocations: LotAllocation[]): Promise { for (const alloc of allocations) { await this.lots.adjustQuantity(alloc.lotId, -alloc.allocatedQuantity); } } ``` ## 6. API Routes ### 6.1 New Routes (registered in `build-app.ts`) ``` GET /inventory/lots (admin) — list lots with filters POST /inventory/lots (admin) — create lot GET /inventory/lots/:id (admin) — get lot PATCH /inventory/lots/:id (admin) — update lot DELETE /inventory/lots/:id (admin) — delete lot ``` Existing routes unchanged: ``` GET /inventory/:variantId/availability PUT /inventory/:variantId/stock POST /inventory/:variantId/reservations POST /inventory/:variantId/reservations/release POST /inventory/:variantId/reservations/confirm ``` ### 6.2 GET /inventory/lots Query params: ``` ?variant_id=uuid ?filter=expiring|expired|all|no-expiry ?limit=20 ?offset=0 ``` Response: ```json { "items": [ { "id": "uuid", "variantId": "uuid", "quantity": 20, "expirationDate": "2026-09-10", "status": "VALID", "createdAt": "2026-08-01T..." } ], "total": 42 } ``` Status is derived (not stored): - `EXPIRED`: expiration_date < today - `NEAR_EXPIRY`: expiration_date <= today + FLAG_EXPIRY_WARNING_DAYS - `VALID`: otherwise ### 6.3 POST /inventory/lots Request: ```json { "variantId": "uuid", "quantity": 20, "expirationDate": "2026-09-10" } ``` Response: `201 Created` with lot object. Errors: - `422 MISSING_EXPIRATION_DATE`: product has expiry tracking but no date provided - `422 PAST_EXPIRATION_DATE`: date is in the past ## 7. Audit Every lot mutation is logged via existing `AuditLogger`: ``` action: "lot.create" | "lot.adjust" | "lot.delete" target: "inventory_lot:{id}" metadata: { variantId, quantity, expirationDate, actor } ``` ## 8. Extension Points The `LotService` is a clean domain service. Future operations can be added without modifying `InventoryService`: ``` LotService.adjustQuantity(lotId, delta) LotService.mergeLots(sourceLotId, targetLotId) LotService.expireLot(lotId) → sets quantity to 0, keeps for audit ``` The `InventoryLotRepository` interface cleanly isolates persistence.