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