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>
12 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | must_haves | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 04-inventory-expenses-and-financial-reports | 03 | execute | 1 |
|
true |
|
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.
<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>
@.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) Task 1: Schema — Vendor, ExpenseCategory, Expense models + expense category seeding prisma/schema.prisma src/lib/tenant.ts src/lib/accounting/chart-of-accounts.ts **Enums:** - `ExpenseStatus`: `DRAFT`, `APPROVED`, `POSTED`, `VOIDED` - `ExpensePaymentMethod`: `CASH`, `BANK_TRANSFER`, `CHECK` — how the expense was paidVendor 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.
npx prisma validate passes; tenant.ts compiles without errors
Vendor, ExpenseCategory, Expense models exist; expense categories pre-seeded in createTenant; COA has 31 accounts
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, endDateGET /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):
- Expense categories seeded at tenant creation (verify 9 default categories exist)
- Create vendor with CRUD operations
- Create expense with valid category and vendor — status transitions to POSTED with JE
- Verify posted expense JE: DR correct expense account (from category.accountCode), CR 1010 (cash)
- Create expense with BANK_TRANSFER — CR account is 1020
- Void expense — JE reversed, status=VOIDED
- Reject void on non-POSTED expense
- Create custom expense category
- Cannot delete system expense category
- 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 npx jest expense-service --verbose passes all tests VendorService and ExpenseService handle full CRUD, approval workflow, JE posting, and voiding. All API routes respond correctly. All tests pass.
- `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<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>