--- phase: 04-inventory-expenses-and-financial-reports plan: 01 subsystem: inventory tags: [inventory, stock-movements, event-ledger, journal-entries, dual-tracking] dependency-graph: requires: [02-01, 02-02] provides: [InventoryItem-model, StockMovement-model, InventoryService, inventory-api-routes] affects: [04-02] tech-stack: added: [] patterns: [immutable-event-ledger, derived-stock-levels, dual-tracking-serialized-batch] key-files: created: - prisma/migrations/20260305021700_add_inventory_models/migration.sql - src/lib/services/inventory-service.ts - src/app/api/inventory/items/route.ts - src/app/api/inventory/items/[id]/route.ts - src/app/api/inventory/items/[id]/movements/route.ts - src/app/api/inventory/stock-levels/route.ts - src/lib/__tests__/inventory-service.test.ts modified: - prisma/schema.prisma - src/lib/casl/permissions.ts decisions: - id: inv-tracking-type summary: "Dual tracking: SERIALIZED (serial number required, qty=1) vs BATCH (no serial, variable qty)" - id: inv-je-received-only summary: "Only RECEIVED movements auto-post JEs (DR 1200, CR 2010); ISSUED/RETURNED/DISPOSED/TRANSFERRED do not" - id: inv-casl-subject summary: "CASL subject is 'Inventory' (existing type), not 'InventoryItem' — OFFICE_STAFF gets manage Inventory" metrics: duration: "16 min" completed: "2026-03-05" --- # Phase 4 Plan 1: Inventory Event-Ledger Foundation Summary Immutable stock movement ledger with dual tracking (serialized by serial number, batch by quantity), derived stock level computation, and automatic JE posting for receiving movements. ## What Was Built ### Schema (Task 1) - 4 new enums: `ItemTrackingType` (SERIALIZED/BATCH), `ItemCondition` (NEW/REFURBISHED/USED/DAMAGED), `MovementType` (RECEIVED/ISSUED/RETURNED/DISPOSED/TRANSFERRED), `LocationType` (WAREHOUSE/TECHNICIAN/SUBSCRIBER) - `InventoryItem` model: dual tracking, serial number uniqueness per tenant (nullable unique), purchaseCost/purchaseDate/warrantyExpiry - `StockMovement` model: immutable (no updatedAt), journalEntryId for RECEIVED, performedBy relation to User - Indexes on tenantId, itemType, trackingType, inventoryItemId, movementType ### InventoryService (Task 2) - `registerItem`: creates items, validates SERIALIZED requires serial number, BATCH rejects serial number - `recordMovement`: validates per-movement-type rules, enforces qty=1 for SERIALIZED, auto-posts JE for RECEIVED (DR 1200 Equipment Inventory, CR 2010 Accounts Payable) - `getStockLevels`: derives stock from movement aggregation (RECEIVED/RETURNED add, ISSUED/TRANSFERRED remove+add, DISPOSED remove), filters zero-quantity entries - `getItemMovements`: chronological history with performedBy details - `listItems`: filterable by itemType, trackingType, isActive - `getItem`: single item with recent movements ### API Routes - `POST /api/inventory/items` — register new item (ADMIN, OFFICE_STAFF) - `GET /api/inventory/items` — list items with filters - `GET /api/inventory/items/[id]` — item detail - `POST /api/inventory/items/[id]/movements` — record movement - `GET /api/inventory/items/[id]/movements` — movement history - `GET /api/inventory/stock-levels` — derived stock levels ### CASL Permissions - OFFICE_STAFF: `can("manage", "Inventory")` added - TECHNICIAN: existing `can("read", "Inventory")` preserved ### Tests (13 passing) - Registration: serialized with serial, batch without, reject serialized without serial, reject batch with serial - Movements: RECEIVED with JE (DR 1200, CR 2010), ISSUED, RETURNED, DISPOSED, reject SERIALIZED qty>1 - Stock derivation: batch receive 10 issue 3 = 7, serialized latest location - History: chronological order - List: itemType filter ## Decisions Made | Decision | Rationale | |----------|-----------| | Only RECEIVED movements create JEs | Other movement types (ISSUED, RETURNED, etc.) are internal transfers that don't affect AP. Asset reclassification JEs for ISSUED/DISPOSED will be added in 04-02 if needed | | CASL subject "Inventory" not "InventoryItem" | Reuses existing subject type from types.ts. OFFICE_STAFF gets full manage access | | Stock levels computed in JS not SQL | Follows existing pattern (collector balances, outstanding reports). Acceptable for ISP scale | ## Deviations from Plan ### Auto-fixed Issues **1. [Rule 2 - Missing Critical] OFFICE_STAFF inventory CASL permission** - **Found during:** Task 2 (API route creation) - **Issue:** OFFICE_STAFF had no Inventory permissions in CASL, would fail withPermission() checks - **Fix:** Added `can("manage", "Inventory")` to OFFICE_STAFF role - **Files modified:** src/lib/casl/permissions.ts - **Commit:** a742f70 **2. [Rule 3 - Blocking] CASL subject mismatch** - **Found during:** Task 2 (API route creation) - **Issue:** Plan specified "InventoryItem" as withPermission subject but types.ts only has "Inventory" - **Fix:** Used "Inventory" subject in all API routes - **Files modified:** All API route files - **Commit:** a742f70 ## Next Phase Readiness 04-02 (Asset Lifecycle) can build on: - InventoryItem and StockMovement models are stable - InventoryService.recordMovement handles all 5 movement types - Stock level derivation ready for assignment tracking (ISSUED to subscriber) - JE posting pattern established (can extend for disposal write-offs) ### Cleanup Order for Tests stockMovements -> inventoryItems -> journalEntryLines -> null reversesEntryId -> journalEntries -> accountingPeriods -> accounts -> users -> tenant