docs(04): create phase plan — Inventory, Expenses, and Financial Reports

Phase 04: 5 plans in 2 waves
- Wave 1: 04-01 (inventory event-ledger), 04-03 (expense tracking), 04-05 (financial reports) — parallel
- Wave 2: 04-02 (asset management), 04-04 (expense reports + audit trail) — sequential
- Ready for execution

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
kevin-asprec
2026-03-05 10:00:55 +08:00
parent d8bf8d52ac
commit b5f2f3946b
6 changed files with 1015 additions and 7 deletions

View File

@@ -93,14 +93,14 @@ Plans:
3. Staff can record an expense with amount, date, category, and vendor; the expense automatically creates a double-entry journal entry with a full audit trail showing who created it, when, and what source transaction it references
4. Admin can generate an Income Statement for any date range and a Balance Sheet as of any date, both derived entirely from journal entry lines
5. A Trial Balance can be generated and the totals of all debit balances equal all credit balances — the books are self-verifying
**Plans**: TBD
**Plans**: 5 plans
Plans:
- [ ] 04-01: Inventory event-ledger hardware item registration, immutable stock movement records (received, issued, returned, disposed), derived stock level computation (INV-01, INV-02, INV-03)
- [ ] 04-02: Asset management subscriber asset assignment, technician asset assignment, asset location history, lifecycle tracking (INV-04, INV-05, INV-06)
- [ ] 04-03: Expense tracking expense recording with category and vendor, expense category management, vendor management, expense-to-journal-entry posting (EXP-01, EXP-02, EXP-03, EXP-05)
- [ ] 04-04: Expense reports and audit trail — expense reports by category, vendor, and period; full audit trail on all journal entries (who, when, source reference) (EXP-04, ACCT-08)
- [ ] 04-05: Financial report engine Income Statement, Balance Sheet, Trial Balance derived from journal entry history; read-only report queries with proper date range filtering (ACCT-04, ACCT-05, ACCT-06)
- [ ] 04-01-PLAN.md — Inventory event-ledger: hardware item registration, immutable stock movements, derived stock levels, JE posting for receiving (INV-01, INV-02, INV-03)
- [ ] 04-02-PLAN.md — Asset management: subscriber/technician asset assignment, return with condition, admin-only disposal with write-off JE, location history (INV-04, INV-05, INV-06)
- [ ] 04-03-PLAN.md — Expense tracking: expense recording with category/vendor, expense category seeding, vendor CRUD, expense-to-JE posting (EXP-01, EXP-02, EXP-03, EXP-05)
- [ ] 04-04-PLAN.md — Expense reports and audit trail: reports by category/vendor/period, JE audit trail with creator and source reference (EXP-04, ACCT-08)
- [ ] 04-05-PLAN.md — Financial report engine: Trial Balance, Income Statement, Balance Sheet from JE history, account drill-down (ACCT-04, ACCT-05, ACCT-06)
---
@@ -135,5 +135,5 @@ Phases execute in numeric order: 1 → 2 → 3 → 4 → 5
| 1. Foundation | 5/5 | ✓ Complete | 2026-03-04 |
| 2. Subscriber and Billing Core | 5/5 | ✓ Complete | 2026-03-04 |
| 3. Operational Modules | 5/5 | ✓ Complete | 2026-03-05 |
| 4. Inventory, Expenses, and Financial Reports | 0/5 | Not started | - |
| 4. Inventory, Expenses, and Financial Reports | 0/5 | Planned | - |
| 5. Visibility and Client Portal | 0/5 | Not started | - |

View File

@@ -0,0 +1,201 @@
---
phase: 04-inventory-expenses-and-financial-reports
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- prisma/schema.prisma
- src/lib/services/inventory-service.ts
- src/lib/accounting/chart-of-accounts.ts
- src/lib/tenant.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
autonomous: true
must_haves:
truths:
- "Staff can register a hardware item with type, model, serial number, purchase cost, purchase date, and warranty expiry"
- "Staff can record immutable stock movements (RECEIVED, ISSUED, RETURNED, DISPOSED, TRANSFERRED)"
- "Current stock levels are derived from movement history — no mutable quantity column"
- "Every RECEIVED movement posts a journal entry (DR 1200 Equipment Inventory, CR 2010 AP)"
- "Consumables (cables, connectors) are tracked by type+quantity batch; serialized items tracked individually"
artifacts:
- path: "prisma/schema.prisma"
provides: "InventoryItem, StockMovement, ItemType enums and models"
contains: "model InventoryItem"
- path: "src/lib/services/inventory-service.ts"
provides: "registerItem, recordMovement, getStockLevels, getItemMovements"
exports: ["InventoryService"]
- path: "src/lib/__tests__/inventory-service.test.ts"
provides: "Tests for registration, movements, stock derivation, JE posting"
min_lines: 100
key_links:
- from: "src/lib/services/inventory-service.ts"
to: "src/lib/accounting/journal-entry-service.ts"
via: "JournalEntryService.createEntry for RECEIVED movements"
pattern: "JournalEntryService\\.createEntry"
- from: "src/app/api/inventory/items/route.ts"
to: "src/lib/services/inventory-service.ts"
via: "API routes calling InventoryService methods"
pattern: "InventoryService\\."
---
<objective>
Build the inventory event-ledger foundation: hardware item registration with dual tracking (serialized + batch), immutable stock movement records, derived stock level computation, and automatic journal entry posting for receiving movements.
Purpose: This is the core inventory data model that all asset management (04-02) builds on. The immutable movement ledger is a locked architectural decision — no mutable quantity columns.
Output: InventoryItem/StockMovement schema, InventoryService with full CRUD+movements, API routes, and tests.
</objective>
<execution_context>
@C:\Users\KevinAsprec\.claude/get-shit-done/workflows/execute-plan.md
@C:\Users\KevinAsprec\.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/04-inventory-expenses-and-financial-reports/04-CONTEXT.md
@prisma/schema.prisma
@src/lib/accounting/journal-entry-service.ts
@src/lib/accounting/chart-of-accounts.ts
@src/lib/tenant.ts
@src/lib/services/collector-service.ts (reference for service pattern with JE posting)
</context>
<tasks>
<task type="auto">
<name>Task 1: Schema — InventoryItem, StockMovement models and enums</name>
<files>prisma/schema.prisma</files>
<action>
Add the following enums and models to the Prisma schema:
**Enums:**
- `ItemTrackingType`: `SERIALIZED`, `BATCH` — serialized items have unique serial numbers; batch items are tracked by quantity
- `ItemCondition`: `NEW`, `REFURBISHED`, `USED`, `DAMAGED` — condition at time of movement
- `MovementType`: `RECEIVED`, `ISSUED`, `RETURNED`, `DISPOSED`, `TRANSFERRED`
- `LocationType`: `WAREHOUSE`, `TECHNICIAN`, `SUBSCRIBER` — where the item is
**InventoryItem model:**
- id (UUID), tenantId (String), name (String), itemType (String — e.g., "Router", "ONU", "Cable", "Connector")
- model (String? — brand/model for serialized items), serialNumber (String? — null for batch items)
- trackingType (ItemTrackingType)
- purchaseCost (Decimal? @db.Decimal(10,2)), purchaseDate (DateTime?), warrantyExpiry (DateTime?)
- isActive (Boolean @default(true))
- createdAt, updatedAt
- Relation: movements StockMovement[]
- @@unique([tenantId, serialNumber]) — only enforced when serialNumber is not null (Prisma handles this: unique constraint on nullable field only applies to non-null values)
- @@index([tenantId]), @@index([tenantId, itemType]), @@index([tenantId, trackingType])
**StockMovement model:**
- id (UUID), tenantId (String)
- inventoryItemId (String) — FK to InventoryItem
- movementType (MovementType)
- quantity (Int @default(1)) — always 1 for serialized, variable for batch
- condition (ItemCondition? — condition at time of movement)
- fromLocationType (LocationType?), fromLocationId (String?) — null for RECEIVED
- toLocationType (LocationType?), toLocationId (String?) — null for DISPOSED
- notes (String?)
- journalEntryId (String?) — JE for RECEIVED movements
- performedById (String) — FK to User who recorded the movement
- performedBy relation to User
- createdAt DateTime @default(now()) — immutable, no updatedAt
- @@index([tenantId]), @@index([inventoryItemId]), @@index([tenantId, movementType])
Add `recordedMovements StockMovement[] @relation("MovementPerformedBy")` to User model.
Run `npx prisma generate` after schema changes. Do NOT run migrate yet (test will handle that).
</action>
<verify>npx prisma validate passes with no errors</verify>
<done>InventoryItem and StockMovement models exist in schema with all fields, enums, indexes, and relations</done>
</task>
<task type="auto">
<name>Task 2: InventoryService, API routes, migration, and tests</name>
<files>
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
</files>
<action>
**InventoryService** (`src/lib/services/inventory-service.ts`):
- Static class following existing service patterns (see collector-service.ts, payment-service.ts)
- `registerItem(tenantPrisma, tenantId, data)` — creates InventoryItem. Validates: serialNumber required if trackingType=SERIALIZED, serialNumber must be null/undefined for BATCH. Returns created item.
- `recordMovement(tenantPrisma, tenantId, data)` — creates immutable StockMovement. Validates:
- RECEIVED: toLocationType required (must be WAREHOUSE), fromLocationType must be null
- ISSUED: fromLocationType+toLocationType required
- RETURNED: fromLocationType+toLocationType required, toLocationType must be WAREHOUSE
- DISPOSED: fromLocationType required, toLocationType must be null
- TRANSFERRED: both from+to required
- For SERIALIZED items: quantity must be 1
- For RECEIVED movements: auto-create JE via JournalEntryService.createEntry (DR 1200 Equipment Inventory, CR 2010 Accounts Payable) using purchaseCost or movement amount. Source=SYSTEM, referenceType="StockMovement".
- `getStockLevels(tenantPrisma, filters?)` — derives current stock by aggregating movements:
- RECEIVED/RETURNED add to stock at toLocation
- ISSUED/TRANSFERRED remove from fromLocation, add to toLocation
- DISPOSED removes from fromLocation
- Group by itemType and location. Return array of {itemId?, itemType, locationName, locationType, quantity}
- For serialized items, return individual item status (current location derived from latest movement)
- `getItemMovements(tenantPrisma, itemId)` — returns chronological movement history for an item
- `listItems(tenantPrisma, filters?)` — list items with optional filters (itemType, trackingType, isActive)
**API Routes:**
- `POST /api/inventory/items` — register new item (ADMIN, OFFICE_STAFF)
- `GET /api/inventory/items` — list items with filters (ADMIN, OFFICE_STAFF, TECHNICIAN)
- `GET /api/inventory/items/[id]` — get item detail (ADMIN, OFFICE_STAFF, TECHNICIAN)
- `POST /api/inventory/items/[id]/movements` — record movement (ADMIN, OFFICE_STAFF)
- `GET /api/inventory/items/[id]/movements` — get item movement history (ADMIN, OFFICE_STAFF, TECHNICIAN)
- `GET /api/inventory/stock-levels` — get derived stock levels (ADMIN, OFFICE_STAFF)
All routes use withPermission() HOF pattern. Follow existing route patterns (e.g., collections route.ts).
**Migration:**
Apply migration using the Docker exec psql + prisma migrate resolve --applied pattern established in prior phases. Migration name: `add_inventory_models`.
**Tests** (`src/lib/__tests__/inventory-service.test.ts`):
- Register serialized item (with serial number)
- Register batch item (without serial number)
- Reject serialized item without serial number
- Record RECEIVED movement creates StockMovement + JE (verify JE: DR 1200, CR 2010)
- Record ISSUED movement (warehouse to technician)
- Record RETURNED movement (technician to warehouse)
- Record DISPOSED movement
- Derive stock levels from movement history (receive 10, issue 3 = 7 in warehouse)
- Serialized item: derive current location from latest movement
- Get movement history returns chronological order
Follow existing test patterns: createTenant for setup, explicit cleanup order in afterAll. Cleanup order: stockMovements -> inventoryItems -> journalEntryLines -> null reversesEntryId -> journalEntries -> accountingPeriods -> accounts -> users -> tenant.
</action>
<verify>npx jest inventory-service --verbose passes all tests</verify>
<done>InventoryService handles registration, all 5 movement types, stock level derivation, and JE posting for RECEIVED. All API routes respond correctly. All tests pass.</done>
</task>
</tasks>
<verification>
- `npx prisma validate` passes
- `npx jest inventory-service --verbose` — all tests pass
- Stock levels are derived (no quantity column on InventoryItem)
- RECEIVED movement creates balanced JE (DR 1200, CR 2010)
- Serialized items enforce serial number uniqueness per tenant
</verification>
<success_criteria>
- Staff can register hardware items (serialized with serial number, batch without)
- All 5 movement types (RECEIVED, ISSUED, RETURNED, DISPOSED, TRANSFERRED) create immutable records
- Stock levels derived from movement aggregation — no mutable quantity column exists
- RECEIVED movements auto-post journal entries to the ledger
- All tests pass
</success_criteria>
<output>
After completion, create `.planning/phases/04-inventory-expenses-and-financial-reports/04-01-SUMMARY.md`
</output>

View File

@@ -0,0 +1,171 @@
---
phase: 04-inventory-expenses-and-financial-reports
plan: 02
type: execute
wave: 2
depends_on: ["04-01"]
files_modified:
- prisma/schema.prisma
- src/lib/services/asset-service.ts
- src/lib/services/inventory-service.ts
- src/app/api/inventory/items/[id]/assign/route.ts
- src/app/api/inventory/items/[id]/return/route.ts
- src/app/api/inventory/items/[id]/dispose/route.ts
- src/app/api/inventory/items/[id]/history/route.ts
- src/lib/__tests__/asset-service.test.ts
autonomous: true
must_haves:
truths:
- "An asset can be assigned to a subscriber with condition tracking"
- "An asset can be assigned to a technician for field work"
- "An assigned asset shows full location history (chronological timeline)"
- "Returning an asset records condition at return time"
- "Disposal requires admin role — non-admin users get 403"
- "Disposal creates a write-off JE (DR 5030 Equipment Expense, CR 1200 Equipment Inventory)"
artifacts:
- path: "src/lib/services/asset-service.ts"
provides: "assignToSubscriber, assignToTechnician, returnAsset, disposeAsset, getAssetHistory"
exports: ["AssetService"]
- path: "src/lib/__tests__/asset-service.test.ts"
provides: "Tests for assignment, return, disposal, history, authorization"
min_lines: 80
key_links:
- from: "src/lib/services/asset-service.ts"
to: "src/lib/services/inventory-service.ts"
via: "recordMovement for ISSUED/RETURNED/DISPOSED movements"
pattern: "InventoryService\\.recordMovement"
- from: "src/lib/services/asset-service.ts"
to: "src/lib/accounting/journal-entry-service.ts"
via: "JournalEntryService.createEntry for disposal write-off"
pattern: "JournalEntryService\\.createEntry"
---
<objective>
Build asset lifecycle management on top of the inventory event-ledger: assign serialized items to subscribers and technicians, track condition at assignment and return, enforce admin-only disposal with write-off JE, and provide chronological location history.
Purpose: Enables ISP staff to track where every piece of equipment is — from warehouse to technician to subscriber and back. The location history is the key differentiator for accountability.
Output: AssetService with assign/return/dispose/history, API routes, and tests.
</objective>
<execution_context>
@C:\Users\KevinAsprec\.claude/get-shit-done/workflows/execute-plan.md
@C:\Users\KevinAsprec\.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/04-inventory-expenses-and-financial-reports/04-CONTEXT.md
@.planning/phases/04-inventory-expenses-and-financial-reports/04-01-SUMMARY.md
@prisma/schema.prisma
@src/lib/services/inventory-service.ts
@src/lib/accounting/journal-entry-service.ts
</context>
<tasks>
<task type="auto">
<name>Task 1: AssetService — assignment, return, disposal, and history</name>
<files>
src/lib/services/asset-service.ts
src/app/api/inventory/items/[id]/assign/route.ts
src/app/api/inventory/items/[id]/return/route.ts
src/app/api/inventory/items/[id]/dispose/route.ts
src/app/api/inventory/items/[id]/history/route.ts
</files>
<action>
**AssetService** (`src/lib/services/asset-service.ts`):
Static class building on InventoryService.recordMovement:
- `assignToSubscriber(tenantPrisma, tenantId, { itemId, subscriberId, condition, performedById, notes? })`:
- Validate item exists and is SERIALIZED (batch items cannot be individually assigned to subscribers)
- Determine current location from latest movement (must be in WAREHOUSE or with a TECHNICIAN — cannot assign from subscriber to subscriber)
- Call InventoryService.recordMovement with movementType=ISSUED, fromLocation=current location, toLocationType=SUBSCRIBER, toLocationId=subscriberId, condition
- Return the created movement
- `assignToTechnician(tenantPrisma, tenantId, { itemId, technicianUserId, condition, performedById, notes? })`:
- Validate item is SERIALIZED
- Current location must be WAREHOUSE
- Call InventoryService.recordMovement with movementType=ISSUED, fromLocationType=WAREHOUSE, toLocationType=TECHNICIAN, toLocationId=technicianUserId, condition
- `returnAsset(tenantPrisma, tenantId, { itemId, condition, performedById, notes? })`:
- Validate item is SERIALIZED
- Current location must be with SUBSCRIBER or TECHNICIAN (not already in warehouse)
- Call InventoryService.recordMovement with movementType=RETURNED, from=current location, toLocationType=WAREHOUSE, condition (captures condition at return — NEW, USED, DAMAGED, REFURBISHED)
- `disposeAsset(tenantPrisma, tenantId, { itemId, performedById, notes?, userRoles })`:
- Validate item is SERIALIZED
- Validate userRoles includes ADMIN — disposal requires admin approval per CONTEXT.md
- Current location must be WAREHOUSE (cannot dispose from field)
- Create write-off JE: DR 5030 Equipment Expense, CR 1200 Equipment Inventory for the item's purchaseCost. Source=SYSTEM, referenceType="StockMovement", description="Disposal write-off: {item.name} SN:{item.serialNumber}"
- Call InventoryService.recordMovement with movementType=DISPOSED, fromLocationType=WAREHOUSE, journalEntryId from JE
- `getAssetHistory(tenantPrisma, itemId)`:
- Fetch item with all movements ordered by createdAt ASC
- Return formatted timeline: each entry has { movementType, date, fromLocation (type+name), toLocation (type+name), condition, performedBy (user name), notes }
- Resolve location names: WAREHOUSE="Warehouse", SUBSCRIBER=subscriber name, TECHNICIAN=user name
- Helper: `getCurrentLocation(tenantPrisma, itemId)` — returns { locationType, locationId } from latest movement's to-fields (or null if disposed)
**API Routes:**
- `POST /api/inventory/items/[id]/assign` — body: { assigneeType: "SUBSCRIBER"|"TECHNICIAN", assigneeId, condition, notes? }. ADMIN, OFFICE_STAFF.
- `POST /api/inventory/items/[id]/return` — body: { condition, notes? }. ADMIN, OFFICE_STAFF.
- `POST /api/inventory/items/[id]/dispose` — body: { notes? }. ADMIN only.
- `GET /api/inventory/items/[id]/history` — returns chronological timeline. ADMIN, OFFICE_STAFF, TECHNICIAN.
Use withPermission() HOF and dynamic route handler pattern from prior phases.
</action>
<verify>npx prisma validate passes; API route files exist and export correct HTTP methods</verify>
<done>AssetService handles subscriber/technician assignment, return with condition, admin-only disposal with write-off JE, and chronological history</done>
</task>
<task type="auto">
<name>Task 2: Asset service tests</name>
<files>src/lib/__tests__/asset-service.test.ts</files>
<action>
Write comprehensive tests for AssetService:
**Setup:** createTenant, create admin user + office_staff user + technician user, create subscriber, create serialized InventoryItem, record initial RECEIVED movement (so item is in warehouse).
**Test cases:**
1. Assign item to subscriber — creates ISSUED movement with SUBSCRIBER location
2. Assign item to technician — creates ISSUED movement with TECHNICIAN location
3. Return item from subscriber — creates RETURNED movement back to WAREHOUSE with condition
4. Return item from technician — creates RETURNED movement back to WAREHOUSE
5. Reject assignment of item already with a subscriber (must return first)
6. Reject assignment of batch item to subscriber (only SERIALIZED allowed)
7. Dispose item — ADMIN role creates DISPOSED movement + write-off JE (verify DR 5030, CR 1200)
8. Reject disposal by non-admin (OFFICE_STAFF user gets authorization error)
9. Reject disposal of item not in warehouse
10. Get asset history — returns chronological timeline with resolved location names
11. Full lifecycle: RECEIVED -> ISSUED to tech -> RETURNED -> ISSUED to subscriber -> RETURNED -> DISPOSED — history shows all 6 entries
**Cleanup order:** stockMovements -> inventoryItems -> journalEntryLines -> null reversesEntryId -> journalEntries -> subscribers -> servicePlans -> accountingPeriods -> accounts -> users -> tenant
</action>
<verify>npx jest asset-service --verbose passes all tests</verify>
<done>All 11 test cases pass covering assignment, return, disposal authorization, history, and full lifecycle</done>
</task>
</tasks>
<verification>
- `npx jest asset-service --verbose` — all tests pass
- Subscriber assignment creates correct movement record
- Technician assignment creates correct movement record
- Disposal enforces admin-only and creates write-off JE
- History returns chronological timeline with location names
</verification>
<success_criteria>
- Assets can be assigned to subscribers and technicians
- Condition is captured at assignment and return
- Disposal requires admin role and posts write-off JE (DR 5030, CR 1200)
- Full location history available as chronological timeline
- All tests pass
</success_criteria>
<output>
After completion, create `.planning/phases/04-inventory-expenses-and-financial-reports/04-02-SUMMARY.md`
</output>

View File

@@ -0,0 +1,273 @@
---
phase: 04-inventory-expenses-and-financial-reports
plan: 03
type: execute
wave: 1
depends_on: []
files_modified:
- prisma/schema.prisma
- src/lib/services/expense-service.ts
- src/lib/services/vendor-service.ts
- src/lib/tenant.ts
- src/app/api/expenses/route.ts
- src/app/api/expenses/[id]/route.ts
- src/app/api/expenses/[id]/approve/route.ts
- src/app/api/expenses/categories/route.ts
- src/app/api/expenses/categories/[id]/route.ts
- src/app/api/vendors/route.ts
- src/app/api/vendors/[id]/route.ts
- src/lib/__tests__/expense-service.test.ts
autonomous: true
must_haves:
truths:
- "Staff can record an expense with amount, date, category, vendor, and description"
- "Expense categories are pre-seeded at tenant creation and admin can add/edit custom categories"
- "Vendors are managed entities with CRUD operations"
- "Posted expense automatically creates a balanced JE (DR expense account, CR 2010 AP or 1010 Cash)"
- "Optional approval workflow: DRAFT -> APPROVED -> POSTED when enabled; immediate post when disabled"
- "Single file attachment per expense (receipt image or PDF path stored)"
artifacts:
- path: "prisma/schema.prisma"
provides: "Vendor, ExpenseCategory, Expense models and ExpenseStatus enum"
contains: "model Expense"
- path: "src/lib/services/expense-service.ts"
provides: "createExpense, approveExpense, postExpense, listExpenses"
exports: ["ExpenseService"]
- path: "src/lib/services/vendor-service.ts"
provides: "createVendor, updateVendor, listVendors"
exports: ["VendorService"]
- path: "src/lib/__tests__/expense-service.test.ts"
provides: "Tests for expense CRUD, approval workflow, JE posting, category seeding"
min_lines: 100
key_links:
- from: "src/lib/services/expense-service.ts"
to: "src/lib/accounting/journal-entry-service.ts"
via: "JournalEntryService.createEntry when expense posts"
pattern: "JournalEntryService\\.createEntry"
- from: "src/lib/tenant.ts"
to: "prisma.expenseCategory.createMany"
via: "Expense category seeding in createTenant transaction"
pattern: "expenseCategory\\.createMany"
---
<objective>
Build expense tracking with vendor management, category management (pre-seeded at tenant creation), optional approval workflow, and automatic journal entry posting on expense approval/post.
Purpose: Completes the "what's been spent" visibility for ISP owners. Every expense hits the double-entry ledger automatically, ensuring the financial reports in 04-05 include all costs.
Output: Vendor, ExpenseCategory, Expense schema; VendorService, ExpenseService; API routes; expense category seeding; tests.
</objective>
<execution_context>
@C:\Users\KevinAsprec\.claude/get-shit-done/workflows/execute-plan.md
@C:\Users\KevinAsprec\.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/04-inventory-expenses-and-financial-reports/04-CONTEXT.md
@prisma/schema.prisma
@src/lib/accounting/journal-entry-service.ts
@src/lib/accounting/chart-of-accounts.ts
@src/lib/tenant.ts
@src/lib/services/payment-service.ts (reference for JE-posting service pattern)
</context>
<tasks>
<task type="auto">
<name>Task 1: Schema — Vendor, ExpenseCategory, Expense models + expense category seeding</name>
<files>
prisma/schema.prisma
src/lib/tenant.ts
src/lib/accounting/chart-of-accounts.ts
</files>
<action>
**Enums:**
- `ExpenseStatus`: `DRAFT`, `APPROVED`, `POSTED`, `VOIDED`
- `ExpensePaymentMethod`: `CASH`, `BANK_TRANSFER`, `CHECK` — how the expense was paid
**Vendor model:**
- id (UUID), tenantId (String)
- name (String), contactPerson (String?), phone (String?), email (String?), address (String?)
- servicesProvided (String? — freetext description of what vendor provides)
- isActive (Boolean @default(true))
- createdAt, updatedAt
- expenses Expense[]
- @@unique([tenantId, name])
- @@index([tenantId])
**ExpenseCategory model:**
- id (UUID), tenantId (String)
- name (String), description (String?)
- accountCode (String — maps to COA expense account, e.g., "5040" for bandwidth. This links the category to a specific expense account for JE posting)
- isSystemCategory (Boolean @default(false) — true for pre-seeded categories, cannot be deleted)
- isActive (Boolean @default(true))
- createdAt, updatedAt
- expenses Expense[]
- @@unique([tenantId, name])
- @@index([tenantId])
**Expense model:**
- id (UUID), tenantId (String)
- expenseNumber (String — auto-generated "EXP-NNNN")
- categoryId (String) — FK to ExpenseCategory
- vendorId (String?) — FK to Vendor (optional)
- amount (Decimal @db.Decimal(10,2))
- expenseDate (DateTime)
- description (String)
- paymentMethod (ExpensePaymentMethod)
- status (ExpenseStatus @default(DRAFT))
- attachmentPath (String? — file path for receipt image/PDF)
- journalEntryId (String? — set when expense is posted)
- createdById (String) — FK to User
- approvedById (String?) — FK to User
- approvedAt (DateTime?)
- postedAt (DateTime?)
- voidedAt (DateTime?)
- createdAt, updatedAt
- Relations: category ExpenseCategory, vendor Vendor?, createdBy User, approvedBy User?
- @@unique([tenantId, expenseNumber])
- @@index([tenantId]), @@index([tenantId, categoryId]), @@index([tenantId, vendorId]), @@index([tenantId, status])
Add User relations: `createdExpenses Expense[] @relation("ExpenseCreatedBy")`, `approvedExpenses Expense[] @relation("ExpenseApprovedBy")`
**COA additions** (chart-of-accounts.ts):
Add these expense sub-accounts under 5000 if not already present:
- 5080 "Fuel and Transportation" (parentCode: "5000", EXPENSE, DEBIT) — common Philippine ISP expense
- 5085 "Rent Expense" (parentCode: "5000", EXPENSE, DEBIT)
This brings the COA to 31 accounts.
**Expense category seeding** (tenant.ts):
Inside the createTenant $transaction, after ticket categories, seed default expense categories:
```
[
{ name: "Internet Bandwidth", accountCode: "5040", isSystemCategory: true },
{ name: "Equipment & Supplies", accountCode: "5030", isSystemCategory: true },
{ name: "Salary & Wages", accountCode: "5010", isSystemCategory: true },
{ name: "Technician Compensation", accountCode: "5020", isSystemCategory: true },
{ name: "Office Supplies", accountCode: "5050", isSystemCategory: true },
{ name: "Utilities", accountCode: "5060", isSystemCategory: true },
{ name: "Fuel & Transportation", accountCode: "5080", isSystemCategory: true },
{ name: "Rent", accountCode: "5085", isSystemCategory: true },
{ name: "Other", accountCode: "5090", isSystemCategory: true },
]
```
Use tx.expenseCategory.createMany with tenantId injected.
Run `npx prisma generate` after schema changes.
</action>
<verify>npx prisma validate passes; tenant.ts compiles without errors</verify>
<done>Vendor, ExpenseCategory, Expense models exist; expense categories pre-seeded in createTenant; COA has 31 accounts</done>
</task>
<task type="auto">
<name>Task 2: VendorService, ExpenseService, API routes, migration, and tests</name>
<files>
src/lib/services/vendor-service.ts
src/lib/services/expense-service.ts
src/app/api/expenses/route.ts
src/app/api/expenses/[id]/route.ts
src/app/api/expenses/[id]/approve/route.ts
src/app/api/expenses/categories/route.ts
src/app/api/expenses/categories/[id]/route.ts
src/app/api/vendors/route.ts
src/app/api/vendors/[id]/route.ts
src/lib/__tests__/expense-service.test.ts
</files>
<action>
**VendorService** (`src/lib/services/vendor-service.ts`):
Static class:
- `createVendor(tenantPrisma, data)` — create vendor, validate unique name per tenant
- `updateVendor(tenantPrisma, vendorId, data)` — update name, contact, etc.
- `listVendors(tenantPrisma, { isActive? })` — list with optional filter
- `getVendor(tenantPrisma, vendorId)` — single vendor detail
**ExpenseService** (`src/lib/services/expense-service.ts`):
Static class:
- `createExpense(tenantPrisma, tenantId, data)`:
- Auto-generate expenseNumber (pattern: "EXP-NNNN" sequential per tenant — same approach as invoice/ticket numbering)
- Validate category exists and is active
- Validate vendor exists if vendorId provided
- If approval workflow disabled (default for now — no TenantSettings field needed, just default to immediate post): create as DRAFT, then immediately call postExpense
- If approval workflow enabled: create as DRAFT only
- Return created expense
- `approveExpense(tenantPrisma, tenantId, { expenseId, approvedById })`:
- Validate status is DRAFT
- Update status to APPROVED, set approvedById and approvedAt
- Then call postExpense
- `postExpense(tenantPrisma, tenantId, { expenseId, postedById })`:
- Validate status is DRAFT or APPROVED
- Look up the expense's category.accountCode to find the DR account
- Determine CR account: if paymentMethod is CASH -> 1010 Cash on Hand; if BANK_TRANSFER -> 1020 Cash in Bank; if CHECK -> 1020 Cash in Bank
- Create JE via JournalEntryService.createEntry: DR {category expense account}, CR {cash account}. Amount = expense.amount. Source=SYSTEM, referenceType="Expense", referenceId=expense.id, description="Expense: {expense.description}"
- Update expense: status=POSTED, journalEntryId, postedAt=now()
- `voidExpense(tenantPrisma, tenantId, { expenseId, voidedById })`:
- Only POSTED expenses can be voided
- Reverse the JE via JournalEntryService.reverseEntry
- Update expense: status=VOIDED, voidedAt=now()
- `listExpenses(tenantPrisma, filters?)` — filter by status, categoryId, vendorId, date range
- `getExpense(tenantPrisma, expenseId)` — detail with category, vendor, createdBy includes
**API Routes:**
- `POST /api/expenses` — create expense (ADMIN, OFFICE_STAFF). Body: { categoryId, vendorId?, amount, expenseDate, description, paymentMethod, attachmentPath? }
- `GET /api/expenses` — list expenses (ADMIN, OFFICE_STAFF). Query: status, categoryId, vendorId, startDate, endDate
- `GET /api/expenses/[id]` — expense detail (ADMIN, OFFICE_STAFF)
- `POST /api/expenses/[id]/approve` — approve expense (ADMIN only)
- `GET /api/expenses/categories` — list categories (ADMIN, OFFICE_STAFF)
- `POST /api/expenses/categories` — create category (ADMIN). Body: { name, description?, accountCode }
- `PUT /api/expenses/categories/[id]` — update category (ADMIN). Cannot change isSystemCategory.
- `GET /api/vendors` — list vendors (ADMIN, OFFICE_STAFF)
- `POST /api/vendors` — create vendor (ADMIN, OFFICE_STAFF). Body: { name, contactPerson?, phone?, email?, address?, servicesProvided? }
- `GET /api/vendors/[id]` — vendor detail (ADMIN, OFFICE_STAFF)
- `PUT /api/vendors/[id]` — update vendor (ADMIN, OFFICE_STAFF)
**Migration:**
Apply via Docker exec psql + prisma migrate resolve --applied. Migration name: `add_expense_vendor_models`.
**Tests** (`src/lib/__tests__/expense-service.test.ts`):
1. Expense categories seeded at tenant creation (verify 9 default categories exist)
2. Create vendor with CRUD operations
3. Create expense with valid category and vendor — status transitions to POSTED with JE
4. Verify posted expense JE: DR correct expense account (from category.accountCode), CR 1010 (cash)
5. Create expense with BANK_TRANSFER — CR account is 1020
6. Void expense — JE reversed, status=VOIDED
7. Reject void on non-POSTED expense
8. Create custom expense category
9. Cannot delete system expense category
10. List expenses with filters (by category, vendor, date range)
Cleanup order: expenses -> vendors -> expenseCategories (non-system) -> journalEntryLines -> null reversesEntryId -> journalEntries -> accountingPeriods -> accounts -> ticketCategories -> expenseCategories (system) -> users -> tenant
</action>
<verify>npx jest expense-service --verbose passes all tests</verify>
<done>VendorService and ExpenseService handle full CRUD, approval workflow, JE posting, and voiding. All API routes respond correctly. All tests pass.</done>
</task>
</tasks>
<verification>
- `npx prisma validate` passes
- `npx jest expense-service --verbose` — all tests pass
- Expense creates JE with correct DR/CR accounts
- Expense categories seeded at tenant creation (9 defaults)
- Vendor CRUD works
</verification>
<success_criteria>
- Staff can record expenses with amount, date, category, vendor, and description
- Expense categories pre-seeded (bandwidth, equipment, salary, etc.) and admin can add custom
- Vendors are managed entities
- Every posted expense creates a balanced JE
- All tests pass
</success_criteria>
<output>
After completion, create `.planning/phases/04-inventory-expenses-and-financial-reports/04-03-SUMMARY.md`
</output>

View File

@@ -0,0 +1,162 @@
---
phase: 04-inventory-expenses-and-financial-reports
plan: 04
type: execute
wave: 2
depends_on: ["04-03"]
files_modified:
- src/lib/services/expense-report-service.ts
- src/lib/services/audit-trail-service.ts
- src/app/api/reports/expenses/route.ts
- src/app/api/reports/expenses/by-vendor/route.ts
- src/app/api/accounting/journal-entries/[id]/audit/route.ts
- src/lib/__tests__/expense-report-service.test.ts
autonomous: true
must_haves:
truths:
- "Admin can generate expense reports filtered by category, vendor, and date range"
- "Expense report shows totals per category and per vendor for the period"
- "Every journal entry has a full audit trail: who created it, when, what source transaction"
- "Audit trail shows referenceType and referenceId linking JE back to source (Invoice, Payment, Expense, StockMovement, Collection, Remittance)"
artifacts:
- path: "src/lib/services/expense-report-service.ts"
provides: "getExpensesByCategory, getExpensesByVendor, getExpenseSummary"
exports: ["ExpenseReportService"]
- path: "src/lib/services/audit-trail-service.ts"
provides: "getJournalEntryAudit, getAuditTrailForEntity"
exports: ["AuditTrailService"]
- path: "src/lib/__tests__/expense-report-service.test.ts"
provides: "Tests for expense reports and audit trail"
min_lines: 60
key_links:
- from: "src/lib/services/expense-report-service.ts"
to: "prisma.expense"
via: "Aggregation queries on expense records"
pattern: "expense\\.(groupBy|findMany|aggregate)"
- from: "src/lib/services/audit-trail-service.ts"
to: "prisma.journalEntry"
via: "Query JE with createdBy, approvedBy, referenceType"
pattern: "journalEntry\\.find"
---
<objective>
Build expense reporting (by category, vendor, period) and the journal entry audit trail that links every accounting entry back to its source transaction.
Purpose: Gives ISP owners spending visibility (where money goes) and auditors the ability to trace any ledger entry to its origin. ACCT-08 (audit trail) applies to ALL journal entries, not just expense ones.
Output: ExpenseReportService, AuditTrailService, report API routes, tests.
</objective>
<execution_context>
@C:\Users\KevinAsprec\.claude/get-shit-done/workflows/execute-plan.md
@C:\Users\KevinAsprec\.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/04-inventory-expenses-and-financial-reports/04-CONTEXT.md
@.planning/phases/04-inventory-expenses-and-financial-reports/04-03-SUMMARY.md
@prisma/schema.prisma
@src/lib/accounting/journal-entry-service.ts
@src/lib/services/expense-service.ts
</context>
<tasks>
<task type="auto">
<name>Task 1: ExpenseReportService and AuditTrailService</name>
<files>
src/lib/services/expense-report-service.ts
src/lib/services/audit-trail-service.ts
src/app/api/reports/expenses/route.ts
src/app/api/reports/expenses/by-vendor/route.ts
src/app/api/accounting/journal-entries/[id]/audit/route.ts
</files>
<action>
**ExpenseReportService** (`src/lib/services/expense-report-service.ts`):
Static class:
- `getExpensesByCategory(tenantPrisma, { startDate, endDate })`:
- Query POSTED expenses grouped by categoryId within date range
- Return: array of { categoryId, categoryName, totalAmount (Decimal), expenseCount (number) }
- Order by totalAmount DESC
- `getExpensesByVendor(tenantPrisma, { startDate, endDate, vendorId? })`:
- Query POSTED expenses grouped by vendorId within date range
- Optional vendorId filter for single-vendor detail
- Return: array of { vendorId, vendorName, totalAmount, expenseCount }
- Include a "No Vendor" bucket for expenses without vendorId
- Order by totalAmount DESC
- `getExpenseSummary(tenantPrisma, { startDate, endDate })`:
- Return combined report: { totalExpenses (Decimal), byCategory: [...], byVendor: [...], expenseCount }
- Calls the two methods above and computes grand total
**AuditTrailService** (`src/lib/services/audit-trail-service.ts`):
Static class:
- `getJournalEntryAudit(tenantPrisma, entryId)`:
- Fetch JE with include: createdBy (select id, email, firstName, lastName), approvedBy, lines with account details
- Return: { entryNumber, date, description, source, status, referenceType, referenceId, createdBy: {name, email}, createdAt, approvedBy?: {name, email}, approvedAt?, lines: [{account code+name, debit, credit}] }
- This provides ACCT-08: who created it, when, what source transaction
- `getAuditTrailForEntity(tenantPrisma, { referenceType, referenceId })`:
- Fetch all JEs where referenceType and referenceId match
- Returns array of JE audit records — enables "show all journal entries for this invoice" or "for this payment"
- Includes reversing entries (where reversesEntryId links to matching JEs)
**API Routes:**
- `GET /api/reports/expenses` — expense summary by category. Query: startDate, endDate. ADMIN only.
- `GET /api/reports/expenses/by-vendor` — expense summary by vendor. Query: startDate, endDate, vendorId?. ADMIN only.
- `GET /api/accounting/journal-entries/[id]/audit` — full audit trail for a JE. ADMIN, OFFICE_STAFF.
All routes use withPermission() HOF.
</action>
<verify>API route files exist and export correct HTTP methods</verify>
<done>ExpenseReportService provides category and vendor expense reports; AuditTrailService links every JE to its creator and source transaction</done>
</task>
<task type="auto">
<name>Task 2: Tests for expense reports and audit trail</name>
<files>src/lib/__tests__/expense-report-service.test.ts</files>
<action>
**Setup:** createTenant, create admin user, create 2 vendors, create expenses across 3 categories (use 2 different expense categories from the 9 seeded defaults + 1 custom category). Create expenses linked to different vendors. Ensure expenses are POSTED (so JEs exist).
**Test cases:**
1. Expense report by category — returns correct totals per category for date range
2. Expense report by category excludes DRAFT/VOIDED expenses (only POSTED counted)
3. Expense report by vendor — returns correct totals per vendor
4. Expense report by vendor includes "No Vendor" bucket for unlinked expenses
5. Expense summary — combines category and vendor views with grand total
6. Date range filtering works (expenses outside range excluded)
7. JE audit trail — fetch audit for a posted expense's JE: shows createdBy user, source=SYSTEM, referenceType="Expense", referenceId=expense.id
8. Audit trail for entity — fetch all JEs for an expense (original + void reversal if voided)
9. JE audit trail on a payment JE (from Phase 2) — verify referenceType="Payment" is correctly returned (proves ACCT-08 works across all JE sources, not just expenses)
Cleanup order: expenses -> vendors -> expenseCategories (custom only) -> journalEntryLines -> null reversesEntryId -> journalEntries -> invoiceLines -> invoices -> subscribers -> servicePlans -> accountingPeriods -> accounts -> ticketCategories -> expenseCategories (system) -> users -> tenant
</action>
<verify>npx jest expense-report-service --verbose passes all tests</verify>
<done>Expense reports correctly aggregate by category/vendor with date filtering. Audit trail shows creator, timestamp, and source reference for any JE. All tests pass.</done>
</task>
</tasks>
<verification>
- `npx jest expense-report-service --verbose` — all tests pass
- Expense report by category shows correct totals
- Expense report by vendor shows correct totals
- JE audit trail includes who/when/source for all JE types (not just expenses)
</verification>
<success_criteria>
- Admin can generate expense reports by category and vendor for any date range
- Reports show totals and counts, excluding non-POSTED expenses
- Every journal entry (Invoice, Payment, Expense, Collection, Remittance, StockMovement) has traceable audit trail
- All tests pass
</success_criteria>
<output>
After completion, create `.planning/phases/04-inventory-expenses-and-financial-reports/04-04-SUMMARY.md`
</output>

View File

@@ -0,0 +1,201 @@
---
phase: 04-inventory-expenses-and-financial-reports
plan: 05
type: execute
wave: 1
depends_on: []
files_modified:
- src/lib/services/financial-report-service.ts
- src/app/api/reports/trial-balance/route.ts
- src/app/api/reports/income-statement/route.ts
- src/app/api/reports/balance-sheet/route.ts
- src/app/api/reports/accounts/[id]/entries/route.ts
- src/lib/__tests__/financial-report-service.test.ts
autonomous: true
must_haves:
truths:
- "Trial Balance totals of all debit balances equal all credit balances — books are self-verifying"
- "Income Statement shows revenue minus expenses for any date range"
- "Balance Sheet shows assets = liabilities + equity as of any date"
- "All three reports derived entirely from journal entry lines — no stored balances"
- "Drill-down: clicking an account shows the underlying journal entries for that account in the period"
artifacts:
- path: "src/lib/services/financial-report-service.ts"
provides: "getTrialBalance, getIncomeStatement, getBalanceSheet, getAccountEntries"
exports: ["FinancialReportService"]
- path: "src/lib/__tests__/financial-report-service.test.ts"
provides: "Tests for all 3 reports with balanced verification"
min_lines: 120
key_links:
- from: "src/lib/services/financial-report-service.ts"
to: "src/lib/accounting/journal-entry-service.ts"
via: "Uses getTrialBalance and getAccountBalance for data"
pattern: "JournalEntryService\\.(getTrialBalance|getAccountBalance)"
- from: "src/lib/services/financial-report-service.ts"
to: "prisma.journalEntryLine"
via: "Direct queries for income statement and balance sheet aggregation"
pattern: "journalEntryLine\\.(groupBy|findMany)"
---
<objective>
Build the financial report engine: Trial Balance, Income Statement, and Balance Sheet — all derived entirely from journal entry history. Includes drill-down capability to view underlying entries per account.
Purpose: This is the capstone of the accounting system. ISP owners can verify their books balance (Trial Balance), see profitability (Income Statement), and see financial position (Balance Sheet). All from the same JE data that every other module has been posting to.
Output: FinancialReportService with 3 report types + drill-down, API routes, comprehensive tests.
</objective>
<execution_context>
@C:\Users\KevinAsprec\.claude/get-shit-done/workflows/execute-plan.md
@C:\Users\KevinAsprec\.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/04-inventory-expenses-and-financial-reports/04-CONTEXT.md
@prisma/schema.prisma
@src/lib/accounting/journal-entry-service.ts
@src/lib/accounting/chart-of-accounts.ts
</context>
<tasks>
<task type="auto">
<name>Task 1: FinancialReportService — Trial Balance, Income Statement, Balance Sheet</name>
<files>
src/lib/services/financial-report-service.ts
src/app/api/reports/trial-balance/route.ts
src/app/api/reports/income-statement/route.ts
src/app/api/reports/balance-sheet/route.ts
src/app/api/reports/accounts/[id]/entries/route.ts
</files>
<action>
**FinancialReportService** (`src/lib/services/financial-report-service.ts`):
Static class. All reports query POSTED journal entry lines only.
- `getTrialBalance(tenantPrisma, { asOfDate? })`:
- Delegates to JournalEntryService.getTrialBalance (already implemented)
- Enhances result with: accountType field per line, computed totalDebits and totalCredits
- Return: { asOfDate, lines: TrialBalanceLine[], totalDebits: Decimal, totalCredits: Decimal, isBalanced: boolean }
- isBalanced = totalDebits equals totalCredits (compare using integer cents to avoid floating point)
- `getIncomeStatement(tenantPrisma, { startDate, endDate })`:
- Query JE lines for REVENUE accounts (4xxx) and EXPENSE accounts (5xxx) within date range
- For each account: compute net balance = sum(credit) - sum(debit) for revenue accounts (normal CREDIT), sum(debit) - sum(credit) for expense accounts (normal DEBIT)
- Group by account, organized into sections:
- Revenue section: accounts where accountType=REVENUE, ordered by code. Show each account + subtotal.
- Expense section: accounts where accountType=EXPENSE, ordered by code. Show each account + subtotal.
- Net income = total revenue - total expenses
- Return: { startDate, endDate, revenue: { accounts: [{code, name, balance}], total }, expenses: { accounts: [{code, name, balance}], total }, netIncome }
- Only include leaf accounts (exclude category headers like 4000, 5000) — filter by: account has no children, OR use the convention that header codes end in "000"
- `getBalanceSheet(tenantPrisma, { asOfDate })`:
- Query JE lines for ASSET, LIABILITY, and EQUITY accounts as of asOfDate (all entries where date <= asOfDate)
- For each account: compute balance based on normalBalance direction
- Group into three sections:
- Assets: accountType=ASSET, each account + subtotal
- Liabilities: accountType=LIABILITY, each account + subtotal
- Equity: accountType=EQUITY, each account + subtotal. Include computed "Net Income" line (revenue - expenses as of date) added to equity section.
- Verify: totalAssets = totalLiabilities + totalEquity (including net income)
- Return: { asOfDate, assets: { accounts: [...], total }, liabilities: { accounts: [...], total }, equity: { accounts: [...], total, netIncome }, totalAssets, totalLiabilitiesAndEquity, isBalanced }
- Only include leaf accounts with non-zero balances
- `getAccountEntries(tenantPrisma, { accountId, startDate?, endDate? })`:
- Fetch all POSTED JE lines for the given account within date range
- Include the parent JE details: entryNumber, date, description, source, referenceType
- Return: { accountCode, accountName, entries: [{ entryNumber, date, description, debit, credit, runningBalance, referenceType }] }
- Running balance computed in order of date ASC, then createdAt ASC for same-date entries
- This is the drill-down capability per CONTEXT.md
**API Routes:**
- `GET /api/reports/trial-balance` — query: asOfDate? (ISO string). ADMIN only.
- `GET /api/reports/income-statement` — query: startDate, endDate (ISO strings). ADMIN only.
- `GET /api/reports/balance-sheet` — query: asOfDate (ISO string). ADMIN only.
- `GET /api/reports/accounts/[id]/entries` — query: startDate?, endDate?. ADMIN, OFFICE_STAFF.
All routes use withPermission() HOF. Dates parsed from query string ISO format.
</action>
<verify>API route files exist and export correct HTTP methods; TypeScript compiles</verify>
<done>FinancialReportService produces Trial Balance, Income Statement, Balance Sheet, and drill-down entries — all derived from JE lines</done>
</task>
<task type="auto">
<name>Task 2: Financial report tests — comprehensive verification of all 3 reports</name>
<files>src/lib/__tests__/financial-report-service.test.ts</files>
<action>
**Setup:** createTenant, create admin + office_staff users, create subscriber, create service plan. Then create known financial transactions that produce verifiable report numbers:
1. Generate an invoice (DR 1100 AR, CR 4010 Revenue) for 1000.00
2. Record a payment (DR 1010 Cash, CR 1100 AR) for 1000.00
3. Create and post an expense for bandwidth (DR 5040, CR 1010 Cash) for 300.00
4. Create and post an expense for fuel (DR 5080 or 5090, CR 1010 Cash) for 100.00
This gives known balances:
- Revenue: 1000 (4010)
- Expenses: 400 total (5040=300, 5080/5090=100)
- Net income: 600
- Cash: 1000 received - 300 - 100 = 600 (1010)
- AR: 1000 - 1000 = 0
NOTE: To create expenses in tests, you need ExpenseCategory and Vendor models from 04-03. If 04-03 is not yet complete (wave 1 parallel), use JournalEntryService.createEntry directly to simulate expense JEs. This keeps 04-05 independent. Create manual SYSTEM JEs with referenceType="Expense" to simulate.
**Test cases:**
*Trial Balance:*
1. Trial Balance — totalDebits equals totalCredits (isBalanced=true)
2. Trial Balance — each account shows correct debit or credit balance
3. Trial Balance with asOfDate filter — excludes entries after the date
*Income Statement:*
4. Income Statement — revenue section shows 4010 Subscription Revenue = 1000
5. Income Statement — expense section shows correct expense accounts
6. Income Statement — netIncome = revenue total - expense total = 600
7. Income Statement — excludes header accounts (4000, 5000 not in report lines)
8. Income Statement — date range filtering (entries outside range excluded)
*Balance Sheet:*
9. Balance Sheet — totalAssets = totalLiabilities + totalEquity (isBalanced=true)
10. Balance Sheet — assets section shows Cash on Hand = 600, AR = 0 (or omitted if zero)
11. Balance Sheet — equity section includes computed Net Income line
12. Balance Sheet — asOfDate filtering works
*Drill-down:*
13. Account entries drill-down — for Cash on Hand (1010): shows payment credit, expense debits with running balance
14. Account entries — includes entryNumber, description, referenceType for each line
*Edge cases:*
15. Empty tenant (no JEs) — Trial Balance returns all accounts with zero balances, isBalanced=true
16. All reports return only leaf accounts (no category headers)
Cleanup order: invoiceLines -> invoices -> journalEntryLines -> null reversesEntryId -> journalEntries -> subscribers -> servicePlans -> tenantSettings -> accountingPeriods -> accounts -> ticketCategories -> users -> tenant
**Important:** Use JournalEntryService.createEntry directly to create test JEs. This avoids dependency on ExpenseService (which is in 04-03, a parallel wave-1 plan). The financial reports read from JE lines regardless of what created them.
</action>
<verify>npx jest financial-report-service --verbose passes all tests</verify>
<done>Trial Balance proves books balance. Income Statement shows correct revenue/expenses/net income. Balance Sheet balances (A=L+E). Drill-down shows entries per account. All tests pass.</done>
</task>
</tasks>
<verification>
- `npx jest financial-report-service --verbose` — all tests pass
- Trial Balance: totalDebits === totalCredits
- Income Statement: netIncome = revenue - expenses
- Balance Sheet: totalAssets === totalLiabilitiesAndEquity
- Drill-down shows entries with running balance per account
- All reports use only POSTED JE lines
</verification>
<success_criteria>
- Trial Balance totals balance (debits = credits) — self-verifying books
- Income Statement shows revenue minus expenses for any date range
- Balance Sheet shows assets = liabilities + equity as of any date
- All three reports derived entirely from journal entry lines
- Drill-down capability returns underlying entries for any account
- All tests pass
</success_criteria>
<output>
After completion, create `.planning/phases/04-inventory-expenses-and-financial-reports/04-05-SUMMARY.md`
</output>