Files
NetForge/.planning/phases/02-subscriber-and-billing-core/02-02-PLAN.md
kevin-asprec 9489b5c7bc docs(02): create phase plan
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>
2026-03-04 22:27:35 +08:00

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
02-01
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
true
truths artifacts key_links
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
path provides contains
prisma/schema.prisma JournalEntry, JournalEntryLine models model JournalEntry
path provides exports
src/lib/accounting/journal-entry-service.ts Sole gateway to the ledger — all financial events go through this service
JournalEntryService
path provides
src/app/api/accounting/journal-entries/route.ts GET (list) and POST (create manual) journal entry endpoints
path provides
src/app/api/accounting/accounts/[id]/balance/route.ts GET balance for an account derived from journal entry lines
from to via pattern
src/lib/accounting/journal-entry-service.ts prisma/schema.prisma creates JournalEntry + JournalEntryLine in transaction journalEntry.create
from to via pattern
src/lib/accounting/journal-entry-service.ts src/lib/accounting/accounting-period.ts checks isDateInClosedPeriod before posting isDateInClosedPeriod
from to via pattern
src/app/api/accounting/accounts/[id]/balance/route.ts prisma/schema.prisma SUM(debit) - SUM(credit) on JournalEntryLine grouped by accountId 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.

<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)
  1. 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])
  2. 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])
  3. Update TENANT_SCOPED_MODELS in prisma-tenant.ts to include "journalEntry" and "journalEntryLine". Add query extension blocks following existing pattern.

  4. 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)
  5. 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 }.

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

<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>
After completion, create `.planning/phases/02-subscriber-and-billing-core/02-02-SUMMARY.md`