--- phase: 02-subscriber-and-billing-core plan: "02" type: execute wave: 2 depends_on: ["02-01"] files_modified: - prisma/schema.prisma - src/lib/accounting/journal-entry-service.ts - src/app/api/accounting/journal-entries/route.ts - src/app/api/accounting/journal-entries/[id]/reverse/route.ts - src/app/api/accounting/accounts/[id]/balance/route.ts - prisma/migrations/*_add_journal_entry_models/migration.sql - src/lib/__tests__/journal-entry-service.test.ts autonomous: true must_haves: truths: - "Every journal entry has debits equal to credits — unbalanced entries are rejected" - "Journal entries are immutable — no update or delete, only reversing entries" - "Account balances are computed by summing journal entry lines, not stored" - "Entries cannot be posted to a closed accounting period" - "Admin can create manual journal entries (e.g., cash-on-hand to bank transfer)" - "Manual journal entries support maker-checker: one person creates, another approves" artifacts: - path: "prisma/schema.prisma" provides: "JournalEntry, JournalEntryLine models" contains: "model JournalEntry" - path: "src/lib/accounting/journal-entry-service.ts" provides: "Sole gateway to the ledger — all financial events go through this service" exports: ["JournalEntryService"] - path: "src/app/api/accounting/journal-entries/route.ts" provides: "GET (list) and POST (create manual) journal entry endpoints" - path: "src/app/api/accounting/accounts/[id]/balance/route.ts" provides: "GET balance for an account derived from journal entry lines" key_links: - from: "src/lib/accounting/journal-entry-service.ts" to: "prisma/schema.prisma" via: "creates JournalEntry + JournalEntryLine in transaction" pattern: "journalEntry\\.create" - from: "src/lib/accounting/journal-entry-service.ts" to: "src/lib/accounting/accounting-period.ts" via: "checks isDateInClosedPeriod before posting" pattern: "isDateInClosedPeriod" - from: "src/app/api/accounting/accounts/[id]/balance/route.ts" to: "prisma/schema.prisma" via: "SUM(debit) - SUM(credit) on JournalEntryLine grouped by accountId" pattern: "aggregate|groupBy" --- Build the JournalEntryService — the sole gateway to the accounting ledger. Every financial event in the system (invoice generation, payment recording, expense logging) must go through this service. Enforces debit=credit balance, immutability, closed-period protection, and supports reversing entries for corrections. Manual journal entries use maker-checker workflow. Purpose: This is the accounting engine. Without it, no financial transaction can be recorded. The billing engine (02-04) and payment tracker (02-05) depend on this service to post their journal entries. Output: JournalEntry and JournalEntryLine models, JournalEntryService with create/reverse/getBalance, manual entry API with maker-checker, balance derivation API. @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/02-subscriber-and-billing-core/02-CONTEXT.md @.planning/phases/02-subscriber-and-billing-core/02-01-SUMMARY.md @prisma/schema.prisma @src/lib/accounting/chart-of-accounts.ts @src/lib/accounting/accounting-period.ts @src/lib/prisma-tenant.ts @src/lib/middleware/authorize.ts Task 1: JournalEntry models + JournalEntryService prisma/schema.prisma src/lib/accounting/journal-entry-service.ts src/lib/prisma-tenant.ts prisma/migrations/*_add_journal_entry_models/migration.sql 1. Add enums to prisma/schema.prisma: - `JournalEntryStatus`: DRAFT, PENDING_APPROVAL, APPROVED, POSTED, REVERSED - `JournalEntrySource`: SYSTEM (auto-generated), MANUAL (user-created) 2. Add `JournalEntry` model: - id (uuid), tenantId (String), entryNumber (String — auto-generated sequential per tenant, e.g., "JE-2026-0001"), date (DateTime — the accounting date, not necessarily createdAt), description (String), source (JournalEntrySource), status (JournalEntryStatus, default POSTED for system entries, DRAFT for manual), referenceType (String? — e.g., "Payment", "Invoice"), referenceId (String? — ID of the source record), reversesEntryId (String? — self-relation, points to entry being reversed), reversedByEntryId (String? — points to the reversing entry), createdById (String, relation to User), approvedById (String?, relation to User — for maker-checker), approvedAt (DateTime?), createdAt, updatedAt - @@index([tenantId]), @@index([tenantId, date]), @@index([referenceType, referenceId]) 3. Add `JournalEntryLine` model: - id (uuid), tenantId (String), journalEntryId (String, relation to JournalEntry), accountId (String, relation to Account), debit (Decimal, precision 15 scale 2, default 0), credit (Decimal, precision 15 scale 2, default 0), description (String? — line-level memo), createdAt - Constraint: exactly one of debit or credit should be non-zero per line (enforced in service, not DB) - @@index([tenantId]), @@index([accountId]), @@index([journalEntryId]) 4. Update TENANT_SCOPED_MODELS in prisma-tenant.ts to include "journalEntry" and "journalEntryLine". Add query extension blocks following existing pattern. 5. Create src/lib/accounting/journal-entry-service.ts: - Class `JournalEntryService` with static methods (stateless, receives tenantPrisma): a. `createEntry({ tenantPrisma, date, description, lines, source, referenceType?, referenceId?, createdById })`: - Validate lines: sum of debits MUST equal sum of credits (use Decimal.js or multiply by 100 for comparison). Throw if unbalanced. - Validate: at least 2 lines required - Validate: each line has either debit > 0 OR credit > 0, not both - Validate: all accountIds exist and belong to tenant - Check isDateInClosedPeriod — throw if period is closed - Generate entryNumber: query max entryNumber for tenant+year, increment. Format: "JE-{YYYY}-{NNNN}" - For SYSTEM source: status = POSTED (auto-approved) - For MANUAL source: status = DRAFT (needs approval) - Create JournalEntry + all JournalEntryLines in a single transaction - Return the created entry with lines b. `approveEntry({ tenantPrisma, entryId, approvedById })`: - Find entry, verify status is DRAFT or PENDING_APPROVAL - If createdById === approvedById: allow (self-approve for single-person operations per CONTEXT.md) - Update status to POSTED, set approvedById and approvedAt - Return updated entry c. `reverseEntry({ tenantPrisma, entryId, reversedById, date?, description? })`: - Find original entry with lines - Verify not already reversed (reversedByEntryId is null) - Create a new JournalEntry with source SYSTEM, status POSTED, reversesEntryId = original.id - Swap debits and credits on all lines - Update original entry: set reversedByEntryId, status REVERSED - All in one transaction - Return the reversing entry d. `getAccountBalance({ tenantPrisma, accountId, asOfDate? })`: - Sum all POSTED journal entry lines for the account (filter by date <= asOfDate if provided) - For DEBIT normal balance accounts: balance = sum(debit) - sum(credit) - For CREDIT normal balance accounts: balance = sum(credit) - sum(debit) - Return { accountId, balance, asOfDate } e. `getTrialBalance({ tenantPrisma, asOfDate? })`: - For each account, compute balance using getAccountBalance logic - Return array of { accountId, accountCode, accountName, debitBalance, creditBalance } - Total debits MUST equal total credits (self-verifying) 6. Run `npx prisma migrate dev --name add_journal_entry_models` - `npx prisma migrate status` — no pending migrations - `npx prisma generate` succeeds - `npx tsc --noEmit` — clean JournalEntry and JournalEntryLine models in database. JournalEntryService exported with create, approve, reverse, getAccountBalance, getTrialBalance methods. Task 2: Journal entry API routes + comprehensive tests src/app/api/accounting/journal-entries/route.ts src/app/api/accounting/journal-entries/[id]/route.ts src/app/api/accounting/journal-entries/[id]/approve/route.ts src/app/api/accounting/journal-entries/[id]/reverse/route.ts src/app/api/accounting/accounts/[id]/balance/route.ts src/lib/__tests__/journal-entry-service.test.ts 1. Create API routes: a. GET /api/accounting/journal-entries — list journal entries for tenant. withPermission("read", "Account"). Supports query params: ?startDate=&endDate=&status=&source=. Returns entries with lines, ordered by date desc. b. POST /api/accounting/journal-entries — create manual journal entry. withPermission("manage", "Account") (Admin only). Accepts { date, description, lines: [{ accountId, debit, credit, description? }] }. Calls JournalEntryService.createEntry with source=MANUAL. Returns created entry. c. GET /api/accounting/journal-entries/[id] — get single entry with lines. withPermission("read", "Account"). d. POST /api/accounting/journal-entries/[id]/approve — approve a manual entry. withPermission("manage", "Account"). Calls JournalEntryService.approveEntry. Returns updated entry. e. POST /api/accounting/journal-entries/[id]/reverse — reverse an entry. withPermission("manage", "Account"). Accepts optional { date, description }. Calls JournalEntryService.reverseEntry. Returns the reversing entry. f. GET /api/accounting/accounts/[id]/balance — get derived balance for an account. withPermission("read", "Account"). Accepts ?asOfDate= query param. Calls JournalEntryService.getAccountBalance. Returns { accountId, balance, asOfDate }. 2. Write comprehensive tests in src/lib/__tests__/journal-entry-service.test.ts: Core enforcement tests: - Balanced entry (debit=credit) creates successfully - Unbalanced entry (debit!=credit) throws error - Entry with < 2 lines throws error - Entry with both debit and credit on same line throws error - Entry in closed period throws error - Entry in open period succeeds Immutability tests: - JournalEntry cannot be updated (service has no update method) - JournalEntry cannot be deleted (service has no delete method) Reversing entry tests: - Reverse creates new entry with swapped debits/credits - Reverse marks original as REVERSED - Cannot reverse an already-reversed entry - Reversing entry references original via reversesEntryId Balance derivation tests: - Account balance computed from entry lines (not stored) - Balance respects normal balance direction (DEBIT vs CREDIT accounts) - Balance with asOfDate filters correctly - Trial balance: total debits = total credits Maker-checker tests: - Manual entry created with status DRAFT - System entry created with status POSTED - Approve changes DRAFT to POSTED - Self-approve allowed (single-person operation) - Cannot approve already-POSTED entry Entry numbering: - First entry of year gets JE-{YYYY}-0001 - Subsequent entries increment correctly Run: `npx vitest run src/lib/__tests__/journal-entry-service.test.ts` - `npx vitest run src/lib/__tests__/journal-entry-service.test.ts` — all tests pass - `npx tsc --noEmit` — clean - POST /api/accounting/journal-entries with balanced lines returns 201 - POST /api/accounting/journal-entries with unbalanced lines returns 400 - GET /api/accounting/accounts/{id}/balance returns derived balance JournalEntryService is the sole gateway to the ledger. Balanced entries enforced, immutability enforced, reversing entries work, maker-checker for manual entries works, account balances derived from journal lines. All tests pass. - `npx vitest run` — all existing + new tests pass - `npx tsc --noEmit` — clean - Create balanced manual entry via API -> success (status DRAFT) - Approve entry -> status changes to POSTED - Create unbalanced entry -> 400 error - Reverse a posted entry -> original marked REVERSED, new reversing entry created - Get account balance -> sum of journal lines, no stored value - JournalEntry and JournalEntryLine models in database - JournalEntryService enforces debit=credit on every entry - No update/delete methods exist on journal entries - Reversing entries correctly swap debits/credits - Account balances derived via SUM, never stored - Closed period blocks new entries - Manual entries use maker-checker (with self-approve option) - All tests pass After completion, create `.planning/phases/02-subscriber-and-billing-core/02-02-SUMMARY.md`