# 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<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`

```typescript
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`

```typescript
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

```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<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:
```typescript
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:
```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.
