# EXPIRATION TRACKING — SPEC.md ## 1. Concept & Vision MercadoDeVida sells perishable goods (fresh food, supplements, cosmetics) that require expiration tracking. The platform must distinguish between **lot-level inventory** (which can expire) and **variant-level inventory** (which already exists). Expiration dates belong to inventory lots, not products. Products only declare whether they require expiration tracking. Expired lots remain visible for audit/waste tracking but contribute zero sellable units. ## 2. Design Principles - **LOT OWNS EXPIRATION**: A product does not expire — a specific inventory arrival (lot) expires. - **BACKEND IS AUTHORITATIVE**: Admin UX improvements are not authoritative. The backend validates all expiration requirements. - **NO BREAKING CHANGE**: Products without expiration tracking behave exactly as before. Migration default is `expiration_tracking_enabled = false`. - **MODULAR**: Expiration logic lives in Inventory domain, not in Checkout. Checkout calls `InventoryService.reserve()` which applies expiration rules internally. - **AUDITABLE**: All lot mutations are tracked. Expired lots are never silently deleted. ## 3. Domain Model ### 3.1 Product — Extension ``` Product.expiration_tracking_enabled: boolean default: false note: products created before migration default to false ``` This boolean determines whether a product's inventory lots must carry expiration dates. It is **not** a computed status — admin configures it per product. ### 3.2 InventoryLot — New Entity ``` InventoryLot id: uuid (PK) variant_id: uuid (FK → catalog_variants.id) quantity: integer (>= 0) expiration_date: date (nullable; required when expiration_tracking_enabled = true) created_at: timestamptz updated_at: timestamptz ``` Each lot represents a single inventory arrival for a variant with its own expiration date. ``` inventory_lots (table) CONSTRAINT expiration_date_future_or_null CHECK (expiration_date IS NULL OR expiration_date >= CURRENT_DATE) ``` ### 3.3 Availability Calculation For a variant with expiration tracking: ``` sum(quantity) over lots where expiration_date > today ``` Expired lots (expiration_date < today) contribute zero units. ### 3.4 Reservation Strategy (FEFO) For expiration-tracking variants, available lots are allocated **FEFO** (First Expired First Out): ``` lots ordered by expiration_date ASC → reserve from earliest-expiring lot first → when exhausted, move to next ``` For non-expiration-tracking variants: existing variant-level behavior is preserved. ## 4. Expiration Policy ``` Product.expiration_tracking_enabled = false → existing variant-level stock (inventory_stock table) → no expiration dates required → no FEFO → backward compatible Product.expiration_tracking_enabled = true → new lot-level inventory (inventory_lots table) → expiration_date required on receive → FEFO allocation → expired lots excluded from available stock ``` ## 5. Checkout Integration Checkout calls `InventoryService.reserve(variantId, quantity)` — it does NOT query lots directly. The Inventory domain applies: 1. Load lots for variant, ordered by expiration_date ASC 2. Allocate from earliest-expiring first 3. Return 409 if insufficient non-expired stock 4. Return reserved lot IDs for order traceability (optional, see Section 7) This preserves existing `InventoryServicePort` contract. A new port method `reserve(variantId, quantity, {fefo: true})` can be introduced without breaking existing callers. ## 6. Order Line Traceability (Optional) If lot-level traceability is needed in orders: ``` OrderLine.lotIds: uuid[] (optional) populated at confirm time used for: waste reports, supplier claims, recall handling ``` This is NOT required for MVP. Document it as a future task if: - Regulatory requirements emerge - Supplier quality claims need lot evidence - Recall workflows are added ## 7. Admin Requirements ### 7.1 Product Editor — General Tab Add checkbox: ``` ☐ Product has expiration-controlled inventory ``` When disabled: no expiration UI shown. When enabled: inventory section shows lot management. ### 7.2 Inventory — Lot View For expiration-tracking variants, replace/extend the existing stock table with: | Lot | Quantity | Expiration | Status | |-----|----------|-------------|--------| | auto | 10 | 10/09/2026 | NEAR_EXPIRY | | auto | 35 | 15/11/2026 | VALID | | auto | 4 | 01/08/2026 | EXPIRED | Statuses computed (not stored): - `EXPIRED`: expiration_date < today - `NEAR_EXPIRY`: expiration_date <= today + warning_days - `VALID`: otherwise ### 7.3 Admin Inventory Filters ``` /admin/inventory?filter=expiring /admin/inventory?filter=expired /admin/inventory?filter=all /admin/inventory?filter=no-expiry ``` Extends existing `/admin/inventory` — no separate route. ### 7.4 Admin Dashboard (Secondary) As separate task, not MVP scope: - Widget: "X expired lots" + "X lots expiring within 7 days" - Link to filtered inventory view ## 8. Expiry Warning Threshold System-wide configuration via `FLAG_EXPIRY_WARNING_DAYS` (env var, default 7). Per-product configuration is NOT implemented in MVP. Introduce if business requirement emerges. ## 9. Notifications (Future) Out of MVP scope but documented: - Cron job identifies near-expiry lots daily - Sends alert to admin email - Generates waste report ## 10. API Contracts ### Product (extended) ``` GET /products/:id → includes expiration_tracking_enabled: boolean PATCH /products/:id body: { expiration_tracking_enabled?: boolean } → updates product policy → if toggled ON: no migration of existing stock (admin receives instruction) → if toggled OFF: existing lots remain visible but no new lots require expiry ``` ### Inventory Lots ``` GET /inventory/lots?variant_id=X&filter=expiring|expired|all → list lots for variant with status derived POST /inventory/lots body: { variant_id, quantity, expiration_date } → creates new lot → 422 if variant requires expiry but expiration_date missing → 422 if expiration_date is in the past PATCH /inventory/lots/:id body: { quantity?, expiration_date? } → updates lot → 422 if expiration_date in past DELETE /inventory/lots/:id → removes lot (audit logged) ``` ### Inventory Availability (extended) ``` GET /inventory/:variantId/availability → existing behavior → for expiration-tracking variants: sums non-expired lot quantities → for non-tracking: existing variant-level sum ``` ### Inventory Reserve (extended) ``` POST /inventory/:variantId/reservations → existing behavior → for expiration-tracking variants: FEFO allocation from non-expired lots → returns lot allocation info (new field in response) ``` ## 11. Migration Strategy See `MIGRATION.md`. ## 12. Out of Scope - Lot-level supplier tracking (lot_number, supplier_id, cost, received_at) - Automatic lot expiration notifications - Per-product expiry warning threshold - Order line lot traceability - Public storefront expiration display (no customer requirement) - Lot-level pricing - Partial lot reservations across multiple lots (future) ## 13. Feature Flag ``` expiration_tracking default: false (off) enables: lot model, FEFO logic, expiration admin UI ``` Flip to true after migration completes. ## 14. Acceptance Criteria See `TESTS.md`.