--- 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" --- 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. @C:\Users\KevinAsprec\.claude/get-shit-done/workflows/execute-plan.md @C:\Users\KevinAsprec\.claude/get-shit-done/templates/summary.md @.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 - 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 After completion, create `.planning/phases/04-inventory-expenses-and-financial-reports/04-03-SUMMARY.md`