Files
NetForge/.planning/phases/04-inventory-expenses-and-financial-reports/04-03-PLAN.md
kevin-asprec b5f2f3946b 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>
2026-03-05 10:00:55 +08:00

274 lines
12 KiB
Markdown

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