7.2 KiB
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:
- Load lots for variant, ordered by expiration_date ASC
- Allocate from earliest-expiring first
- Return 409 if insufficient non-expired stock
- 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 < todayNEAR_EXPIRY: expiration_date <= today + warning_daysVALID: 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.