Files
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

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
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
true
truths artifacts key_links
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)
path provides contains
prisma/schema.prisma Vendor, ExpenseCategory, Expense models and ExpenseStatus enum model Expense
path provides exports
src/lib/services/expense-service.ts createExpense, approveExpense, postExpense, listExpenses
ExpenseService
path provides exports
src/lib/services/vendor-service.ts createVendor, updateVendor, listVendors
VendorService
path provides min_lines
src/lib/__tests__/expense-service.test.ts Tests for expense CRUD, approval workflow, JE posting, category seeding 100
from to via pattern
src/lib/services/expense-service.ts src/lib/accounting/journal-entry-service.ts JournalEntryService.createEntry when expense posts JournalEntryService.createEntry
from to via pattern
src/lib/tenant.ts prisma.expenseCategory.createMany Expense category seeding in createTenant transaction expenseCategory.createMany
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.

<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 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. npx prisma validate passes; tenant.ts compiles without errors Vendor, ExpenseCategory, Expense models exist; expense categories pre-seeded in createTenant; COA has 31 accounts

Task 2: VendorService, ExpenseService, API routes, migration, and tests 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 **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 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>
After completion, create `.planning/phases/04-inventory-expenses-and-financial-reports/04-03-SUMMARY.md`