Phase 02: Subscriber and Billing Core - 5 plans in 4 waves - Wave 1: 02-01 (COA), 02-03 (Subscribers) parallel - Wave 2: 02-02 (Journal Entry Service) - Wave 3: 02-04 (Billing Engine) - Wave 4: 02-05 (Payment Tracker) - Ready for execution Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
13 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | must_haves | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 02-subscriber-and-billing-core | 02 | execute | 2 |
|
|
true |
|
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.
<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/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)-
Add
JournalEntrymodel:- 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])
-
Add
JournalEntryLinemodel:- 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])
-
Update TENANT_SCOPED_MODELS in prisma-tenant.ts to include "journalEntry" and "journalEntryLine". Add query extension blocks following existing pattern.
-
Create src/lib/accounting/journal-entry-service.ts:
- Class
JournalEntryServicewith 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)
- Class
-
Run
npx prisma migrate dev --name add_journal_entry_modelsnpx prisma migrate status— no pending migrationsnpx prisma generatesucceedsnpx tsc --noEmit— clean JournalEntry and JournalEntryLine models in database. JournalEntryService exported with create, approve, reverse, getAccountBalance, getTrialBalance methods.
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 }.
-
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.
<success_criteria>
- 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 </success_criteria>