9.9 KiB
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
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
-- New column
ALTER TABLE catalog_products
ADD COLUMN expiration_tracking_enabled boolean NOT NULL DEFAULT false;
2.3 Movements Extension: inventory_movements
-- 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
export interface InventoryLotRepository {
create(input: CreateLotCommand): Promise<InventoryLot>;
findById(id: string): Promise<InventoryLot | undefined>;
findByVariantId(variantId: string): Promise<InventoryLot[]>;
update(id: string, patch: Partial<{ quantity: number; expirationDate: Date }>): Promise<InventoryLot | undefined>;
delete(id: string): Promise<void>;
// Derived queries
findAvailableLots(variantId: string): Promise<InventoryLot[]>; // expiration > today, quantity > 0
findNearExpiry(variantId: string, withinDays: number): Promise<InventoryLot[]>;
findExpired(variantId: string): Promise<InventoryLot[]>;
}
3.2 New Service: LotService
export class LotService {
constructor(
private readonly lots: InventoryLotRepository,
private readonly products: ProductRepository, // read-only, to check policy
) {}
async createLot(cmd: CreateLotCommand): Promise<InventoryLot> {
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<number> {
const lots = await this.lots.findAvailableLots(variantId);
return lots.reduce((sum, lot) => sum + lot.quantity, 0);
}
async allocateLots(variantId: string, quantity: number): Promise<LotAllocation[]> {
// 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
export interface InventoryServicePort {
// existing — unchanged contract
checkAvailability(variantId: string, quantity: number): Promise<Availability>;
reserve(input: StockCommand): Promise<StockItem>;
release(input: StockCommand): Promise<StockItem>;
confirm(input: StockCommand): Promise<StockItem>;
setAvailable(input: SetAvailableStockCommand): Promise<StockItem>;
}
InventoryService (the default implementation) is extended internally to delegate to LotService when the product has expiration tracking enabled.
3.4 New Feature Flag Check
// 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[]toOrderLine - Populate at
InventoryService.confirm()time - Add read-only lot info to order detail API
5. FEFO Implementation
async allocateLots(variantId: string, quantity: number): Promise<LotAllocation[]> {
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:
async reserveFromLots(allocations: LotAllocation[]): Promise<void> {
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:
{
"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 < todayNEAR_EXPIRY: expiration_date <= today + FLAG_EXPIRY_WARNING_DAYSVALID: otherwise
6.3 POST /inventory/lots
Request:
{
"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 provided422 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.