Files
kevin-asprec c60c22b080 docs(02-02): complete JournalEntryService plan
Tasks completed: 2/2
- Task 1: JournalEntry models + JournalEntryService
- Task 2: Journal entry API routes + comprehensive tests

SUMMARY: .planning/phases/02-subscriber-and-billing-core/02-02-SUMMARY.md
2026-03-04 23:25:42 +08:00

10 KiB

phase, plan, subsystem, tags, requires, provides, affects, tech-stack, key-files, key-decisions, patterns-established, duration, completed
phase plan subsystem tags requires provides affects tech-stack key-files key-decisions patterns-established duration completed
02-subscriber-and-billing-core 02 database
prisma
postgresql
double-entry-accounting
journal-entries
ledger
maker-checker
phase provides
02-01 Chart of Accounts (Account model, AccountingPeriod model, isDateInClosedPeriod)
phase provides
01-03 withTenantContext() multi-tenancy middleware, TENANT_SCOPED_MODELS pattern
phase provides
01-04 withPermission() HOF for RBAC enforcement on API routes
JournalEntry and JournalEntryLine Prisma models with migration
JournalEntryService — sole gateway to the accounting ledger
Journal entry REST API (list, create, approve, reverse)
Account balance derivation API (GET /accounts/[id]/balance)
36 integration tests covering all ledger invariants
02-04 (billing engine — uses JournalEntryService.createEntry to post invoice JEs)
02-05 (payment tracker — uses JournalEntryService.createEntry to post payment JEs)
future phases that need trial balance or account balance queries
added patterns
Sole gateway pattern: JournalEntryService is the ONLY code that writes to JournalEntry/JournalEntryLine
Closure params pattern: dynamic route handlers use closure over withPermission HOF (same as periods/close)
Integer cents validation: debit/credit balance checked in integer cents (Math.round(n*100)) to avoid float errors
Isolated balance testing: use startDate+asOfDate to scope balance queries to test-year to avoid cross-test contamination
Tenant scoping in transactions: tenantId passed explicitly in $transaction callbacks (raw client, no extension)
created modified
prisma/migrations/20260304150817_add_journal_entry_models/migration.sql
src/lib/accounting/journal-entry-service.ts
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
prisma/schema.prisma
src/lib/prisma-tenant.ts
JournalEntry self-referential reversal relation uses reversesEntryId @unique — one entry can only reverse one other
tenantId injected explicitly inside $transaction callbacks — raw tx client doesn't have the extension active
startDate parameter added to getAccountBalance — enables date-range scoped queries (not just all-time or asOfDate)
Integer cents comparison for debit=credit validation — Math.round(n*100) avoids floating point drift with decimal amounts
SYSTEM source entries auto-post (status=POSTED), MANUAL entries start as DRAFT for maker-checker workflow
Self-approval allowed per CONTEXT.md — single-person ISP operations are the common case
Balance derivation uses journalEntryLine.aggregate with nested journalEntry status filter — no stored balance fields
Sole gateway: all financial events flow through JournalEntryService.createEntry, never direct DB writes
Immutability: service has no updateEntry or deleteEntry methods — only reverseEntry for corrections
Trial balance self-verification: sum(debitBalance) must equal sum(creditBalance) across all accounts
16min 2026-03-04

Phase 2 Plan 02: JournalEntryService Summary

Double-entry accounting engine with debit=credit enforcement, immutable entries, reversals, maker-checker workflow, and balance derivation — the sole gateway to the ISP financial ledger

Performance

  • Duration: 16 min
  • Started: 2026-03-04T15:06:43Z
  • Completed: 2026-03-04T15:23:19Z
  • Tasks: 2/2
  • Files modified: 10

Accomplishments

  • JournalEntry and JournalEntryLine models in PostgreSQL with migration. Self-referential reversal relation, maker-checker fields (createdById, approvedById, approvedAt), full audit trail.
  • JournalEntryService enforces: debit=credit on every entry (integer cents comparison), minimum 2 lines, exclusive debit/credit per line, no entries in closed accounting periods, immutability (no update/delete methods), SYSTEM entries auto-post, MANUAL entries use DRAFT+approve workflow.
  • Reversing entries atomically swap debits/credits, mark original REVERSED, and create new POSTED entry in single transaction.
  • Account balance derived via SUM(debit/credit) on journalEntryLine grouped by accountId and filtered by POSTED status — never stored.
  • Trial balance self-verifies: total debit balances always equal total credit balances.
  • 36 integration tests + 198 total test suite (all passing).

Task Commits

  1. Task 1: JournalEntry models + JournalEntryService - 837b7f1 (feat)
  2. Task 2: Journal entry API routes + comprehensive tests - 30ec936 (feat)

Files Created/Modified

  • prisma/schema.prisma - Added JournalEntryStatus/JournalEntrySource enums, JournalEntry model, JournalEntryLine model; Account and User back-relations
  • prisma/migrations/20260304150817_add_journal_entry_models/migration.sql - DB migration
  • src/lib/prisma-tenant.ts - Added journalEntry and journalEntryLine to TENANT_SCOPED_MODELS with full query extension blocks
  • src/lib/accounting/journal-entry-service.ts - JournalEntryService: createEntry, approveEntry, reverseEntry, getAccountBalance (startDate+asOfDate), getTrialBalance
  • src/app/api/accounting/journal-entries/route.ts - GET (list with filters) + POST (create manual)
  • src/app/api/accounting/journal-entries/[id]/route.ts - GET single entry
  • src/app/api/accounting/journal-entries/[id]/approve/route.ts - POST approve
  • src/app/api/accounting/journal-entries/[id]/reverse/route.ts - POST reverse
  • src/app/api/accounting/accounts/[id]/balance/route.ts - GET derived balance
  • src/lib/__tests__/journal-entry-service.test.ts - 36 integration tests

Decisions Made

  • JournalEntry self-referential relation uses reversesEntryId @unique — Prisma requires @unique for one-to-one self-relations. This correctly models that one entry can only reverse one other entry.
  • tenantId passed explicitly inside $transaction callbacks — The raw tx client from prisma.$transaction() doesn't carry the withTenantContext() extension. Must pass tenantId explicitly in create data within transactions.
  • startDate added to getAccountBalance — Plan specified only asOfDate (all entries up to date). Added startDate to enable date-range queries, which is needed for proper test isolation and will be useful for period-scoped reporting.
  • Integer cents for balance validationMath.round(n * 100) avoids floating point drift. 100.1 * 100 = 10009.9999... in JS; cents comparison is exact.
  • Self-approval allowed — CONTEXT.md notes most ISPs are single-person operations. Maker-checker exists for multi-person orgs; blocking self-approval would break single-admin ISPs.

Deviations from Plan

Auto-fixed Issues

1. [Rule 1 - Bug] Prisma one-to-one self-relation requires @unique on FK field

  • Found during: Task 1 (JournalEntry models)
  • Issue: Prisma rejected schema with error: "A one-to-one relation must use unique fields on the defining side" for reversesEntryId
  • Fix: Added @unique to reversesEntryId field. This is semantically correct: one entry can reverse at most one other entry.
  • Files modified: prisma/schema.prisma
  • Verification: npx prisma generate succeeded after fix
  • Committed in: 837b7f1 (Task 1 commit)

2. [Rule 2 - Missing Critical] Added startDate parameter to getAccountBalance

  • Found during: Task 2 (balance test design)
  • Issue: Tests using asOfDate alone couldn't isolate to a specific year because prior test entries (using same accounts but different dates) were included in the aggregate. Without startDate, balance tests would be non-deterministic.
  • Fix: Added optional startDate: Date to GetAccountBalanceInput and dateConditions object in aggregate query. The API balance endpoint also exposes ?startDate= query param.
  • Files modified: src/lib/accounting/journal-entry-service.ts, src/app/api/accounting/accounts/[id]/balance/route.ts
  • Verification: Balance tests using startDate + asOfDate produce exact expected values (700, 800, 1500, 0)
  • Committed in: 30ec936 (Task 2 commit)

3. [Rule 1 - Bug] afterAll test cleanup needed explicit ordering due to FK constraints

  • Found during: Task 2 (test run, cleanup failure)
  • Issue: prisma.tenant.delete() failed with FK constraint on JournalEntry_createdById_fkey. Cascade from tenant didn't handle journal entry self-reference cleanly.
  • Fix: afterAll deletes in order: (1) journalEntryLine.deleteMany, (2) journalEntry.updateMany (null out reversesEntryId), (3) journalEntry.deleteMany, (4) tenant.delete.
  • Files modified: src/lib/tests/journal-entry-service.test.ts
  • Verification: afterAll completes without errors, DB cleaned up
  • Committed in: 30ec936 (Task 2 commit)

Total deviations: 3 auto-fixed (1 Prisma schema bug, 1 missing critical feature for correctness, 1 test cleanup bug) Impact on plan: All fixes necessary for correctness. The startDate addition enhances the API (not scope creep — it's needed for period-scoped balance reporting in future phases).

Issues Encountered

  • Prisma $transaction callback receives raw PrismaClient (not extended). This is a known Prisma architectural constraint. Solution: pass tenantId explicitly in data objects inside transactions. This is already the pattern used in tenant.ts for COA seeding.
  • Decimal toString() omits trailing zeros (e.g., "100.5" not "100.50"). Tests updated to use toNumber() for numeric comparison instead.

User Setup Required

None - no external service configuration required.

Next Phase Readiness

  • JournalEntryService is complete and ready for 02-04 (BillingEngine) and 02-05 (PaymentTracker) to call
  • Both billing and payment services must call JournalEntryService.createEntry({ source: SYSTEM, ... }) — never write to JournalEntry directly
  • Account IDs needed: billing will use AR (1100), Revenue (4010); payments will use Cash (1010/1020), AR (1100), Subscriber Credits (1150)
  • Closed period protection is active — any attempt to post to a closed period will throw

Phase: 02-subscriber-and-billing-core Completed: 2026-03-04