diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 598cecd..edd11a2 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -51,14 +51,14 @@ Plans: 3. Office staff can record a full or partial cash or bank payment against an invoice; the invoice status updates to partial or paid in real time 4. Every payment and invoice generation event produces a balanced journal entry in the ledger (debits = credits) with no manual accounting step required 5. Staff can generate an overdue/outstanding report filtered by date range, status, and amount showing correct outstanding balances derived from the journal — no stored balance fields exist -**Plans**: TBD +**Plans**: 5 plans Plans: -- [ ] 02-01: Chart of Accounts — pre-configured ISP COA auto-provisioned at tenant signup, ACCT-01 and ACCT-09 (balance derivation from ledger, no mutable balance fields) -- [ ] 02-02: Journal Entry Service — JournalEntryService as sole gateway to ledger, debit=credit enforcement, immutable entries, reversing entry pattern (ACCT-02, ACCT-03, ACCT-07) -- [ ] 02-03: Subscriber management — registration, plan assignment, status lifecycle (active/suspended/cancelled), subscriber search and filtering (SUB-01, SUB-02, SUB-03, SUB-04) -- [ ] 02-04: Billing engine — prepaid and postpaid as distinct state machines, monthly invoice auto-generation via BullMQ scheduled job, due date calculation, overdue detection (BILL-01, BILL-02) -- [ ] 02-05: Payment tracker — cash/bank payment recording, partial payment support, invoice status update, outstanding balance reports, idempotency keys on payment creation, subscriber payment history ledger (BILL-03, BILL-04, BILL-05, BILL-06, SUB-05) +- [ ] 02-01-PLAN.md — Chart of Accounts: ISP COA auto-provisioned at tenant signup, accounting period management (ACCT-01, ACCT-09) +- [ ] 02-02-PLAN.md — Journal Entry Service: sole ledger gateway, debit=credit enforcement, immutable entries, reversing entries, maker-checker (ACCT-02, ACCT-03, ACCT-07) +- [ ] 02-03-PLAN.md — Subscriber management: registration, plan assignment, status lifecycle, search and filtering (SUB-01, SUB-02, SUB-03, SUB-04) +- [ ] 02-04-PLAN.md — Billing engine: prepaid/postpaid invoice generation, anniversary billing, overdue detection, journal entry per invoice (BILL-01, BILL-02) +- [ ] 02-05-PLAN.md — Payment tracker: FIFO allocation, partial/full/overpayment, void with reversing entries, outstanding report, payment history (BILL-03, BILL-04, BILL-05, BILL-06, SUB-05) --- diff --git a/.planning/phases/02-subscriber-and-billing-core/02-01-PLAN.md b/.planning/phases/02-subscriber-and-billing-core/02-01-PLAN.md new file mode 100644 index 0000000..187ea00 --- /dev/null +++ b/.planning/phases/02-subscriber-and-billing-core/02-01-PLAN.md @@ -0,0 +1,194 @@ +--- +phase: 02-subscriber-and-billing-core +plan: "01" +type: execute +wave: 1 +depends_on: [] +files_modified: + - prisma/schema.prisma + - src/lib/accounting/chart-of-accounts.ts + - src/lib/accounting/seed-coa.ts + - src/lib/accounting/accounting-period.ts + - src/lib/tenant.ts + - src/app/api/accounting/accounts/route.ts + - src/app/api/accounting/periods/route.ts + - src/app/api/accounting/periods/[id]/close/route.ts + - prisma/migrations/*_add_accounting_models/migration.sql + - src/lib/__tests__/accounting-coa.test.ts +autonomous: true + +must_haves: + truths: + - "A new tenant signup auto-provisions a complete ISP Chart of Accounts" + - "Account balances are never stored — only derived from journal entry sums" + - "Admin can close an accounting period, preventing future entries in that period" + - "COA accounts have correct normal balance types (debit/credit) for ISP operations" + artifacts: + - path: "prisma/schema.prisma" + provides: "Account, AccountingPeriod models with tenant scoping" + contains: "model Account" + - path: "src/lib/accounting/chart-of-accounts.ts" + provides: "ISP COA definition with account codes, types, normal balances" + exports: ["ISP_CHART_OF_ACCOUNTS", "AccountType", "NormalBalance"] + - path: "src/lib/accounting/seed-coa.ts" + provides: "Function to provision COA for a tenant" + exports: ["seedChartOfAccounts"] + - path: "src/lib/accounting/accounting-period.ts" + provides: "Accounting period open/close logic" + exports: ["closePeriod", "getOpenPeriod", "isDateInClosedPeriod"] + - path: "src/lib/tenant.ts" + provides: "Updated createTenant that calls seedChartOfAccounts" + key_links: + - from: "src/lib/tenant.ts" + to: "src/lib/accounting/seed-coa.ts" + via: "createTenant calls seedChartOfAccounts inside transaction" + pattern: "seedChartOfAccounts" + - from: "src/lib/accounting/seed-coa.ts" + to: "prisma/schema.prisma" + via: "creates Account records per ISP_CHART_OF_ACCOUNTS" + pattern: "account\\.create" +--- + + +Create the Chart of Accounts (COA) data model and auto-provisioning system, plus accounting period management. Every new tenant gets a pre-configured ISP-specific COA on signup. No mutable balance fields exist anywhere — all balances will be derived from journal entries (built in 02-02). + +Purpose: The COA is the foundation of the double-entry accounting system. Without accounts, journal entries have nowhere to post. This must exist before any financial transaction can be recorded. +Output: Account and AccountingPeriod Prisma models, ISP COA seed data, auto-provisioning on tenant signup, period close 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 +@prisma/schema.prisma +@src/lib/tenant.ts +@src/lib/prisma-tenant.ts + + + + + + Task 1: Account and AccountingPeriod Prisma models + COA definition + + prisma/schema.prisma + src/lib/accounting/chart-of-accounts.ts + src/lib/accounting/accounting-period.ts + src/lib/prisma-tenant.ts + + +1. Add enums to prisma/schema.prisma: + - `AccountType`: ASSET, LIABILITY, EQUITY, REVENUE, EXPENSE + - `NormalBalance`: DEBIT, CREDIT + - `PeriodStatus`: OPEN, CLOSED + +2. Add `Account` model to prisma/schema.prisma: + - id (uuid), tenantId (String, required), code (String, e.g., "1000"), name (String), accountType (AccountType), normalBalance (NormalBalance), parentId (String?, self-relation for sub-accounts), isSystemAccount (Boolean, default true — COA accounts cannot be deleted), createdAt, updatedAt + - @@unique([tenantId, code]) — account codes unique per tenant + - @@index([tenantId]) + +3. Add `AccountingPeriod` model: + - id (uuid), tenantId (String), year (Int), month (Int), status (PeriodStatus, default OPEN), closedAt (DateTime?), closedById (String?, relation to User), createdAt, updatedAt + - @@unique([tenantId, year, month]) — one period per tenant per month + - @@index([tenantId]) + +4. Create src/lib/accounting/chart-of-accounts.ts: + - Export `ISP_CHART_OF_ACCOUNTS` as a typed array of account definitions with code, name, accountType, normalBalance, parentCode (optional). Standard ISP accounts: + - Assets (1000s): Cash on Hand (1010), Cash in Bank (1020), Accounts Receivable (1100), Subscriber Credits (1150, contra-receivable for overpayments), Equipment Inventory (1200), Prepaid Expenses (1300) + - Liabilities (2000s): Accounts Payable (2010), Unearned Revenue (2100, for prepaid subscriber payments), Taxes Payable (2200) + - Equity (3000s): Owner's Equity (3010), Retained Earnings (3020) + - Revenue (4000s): Subscription Revenue (4010), Installation Fees (4020), Reconnection Fees (4030), Other Revenue (4090) + - Expenses (5000s): Salary Expense (5010), Technician Compensation (5020), Equipment Expense (5030), Internet Bandwidth (5040), Office Supplies (5050), Utilities (5060), Depreciation (5070), Other Expense (5090) + - Export TypeScript types: AccountType, NormalBalance (mirrors the Prisma enums for use outside Prisma context) + +5. Create src/lib/accounting/accounting-period.ts: + - `getOpenPeriod(tenantPrisma, year, month)` — finds or creates an OPEN period for the given month + - `closePeriod(tenantPrisma, periodId, closedById)` — sets status to CLOSED, records closedAt and closedById. Throws if already closed. + - `isDateInClosedPeriod(tenantPrisma, date)` — returns boolean, checks if the month/year of the date has a CLOSED period + +6. Update TENANT_SCOPED_MODELS in src/lib/prisma-tenant.ts to include "account" and "accountingPeriod". Add the same query extension blocks (findMany, findFirst, create, update, delete, etc.) following the existing `user` pattern exactly. + +7. Run `npx prisma migrate dev --name add_accounting_models` to generate and apply the migration. + + + - `npx prisma migrate status` shows no pending migrations + - `npx prisma generate` succeeds + - TypeScript compiles: `npx tsc --noEmit` + - ISP_CHART_OF_ACCOUNTS has entries covering all 5 account types + + Account and AccountingPeriod models exist in database, COA definition exported, tenant scoping extensions updated, accounting period functions exported + + + + Task 2: COA auto-provisioning on tenant signup + API routes + tests + + src/lib/accounting/seed-coa.ts + src/lib/tenant.ts + src/app/api/accounting/accounts/route.ts + src/app/api/accounting/periods/route.ts + src/app/api/accounting/periods/[id]/close/route.ts + src/lib/__tests__/accounting-coa.test.ts + + +1. Create src/lib/accounting/seed-coa.ts: + - `seedChartOfAccounts(tx, tenantId)` — takes a Prisma transaction client and tenantId. Iterates ISP_CHART_OF_ACCOUNTS, creates Account records. Resolves parentCode to parentId by looking up already-created parent accounts. Returns count of accounts created. + - Must work inside an existing transaction (receives `tx` not a full PrismaClient) + +2. Update src/lib/tenant.ts createTenant(): + - Import seedChartOfAccounts + - Inside the existing $transaction block, after creating tenant and user, call `await seedChartOfAccounts(tx, tenant.id)` + - This means every new tenant signup gets a full COA automatically + +3. Create API routes (all require ADMIN role via withPermission): + - GET /api/accounting/accounts — list all accounts for the tenant, ordered by code. Use withPermission("read", "Account"). Returns accounts with their type, code, name, normalBalance. + - GET /api/accounting/periods — list accounting periods for the tenant, ordered by year desc, month desc. Use withPermission("read", "Account"). + - POST /api/accounting/periods/[id]/close — close an accounting period. Use withPermission("manage", "Account"). Calls closePeriod(). Returns updated period. + +4. Write tests in src/lib/__tests__/accounting-coa.test.ts: + - Test seedChartOfAccounts creates the correct number of accounts for a tenant + - Test seedChartOfAccounts sets parentId correctly for sub-accounts + - Test all 5 account types are represented + - Test normal balances are correct (assets/expenses = DEBIT, liabilities/equity/revenue = CREDIT) + - Test closePeriod sets status to CLOSED and records timestamp + - Test closePeriod throws on already-closed period + - Test isDateInClosedPeriod returns true for closed month, false for open month + - Test createTenant now provisions COA (integration test — create tenant, verify accounts exist) + +Run: `npx vitest run src/lib/__tests__/accounting-coa.test.ts` + + + - `npx vitest run src/lib/__tests__/accounting-coa.test.ts` — all tests pass + - `npx tsc --noEmit` — no type errors + - Creating a tenant via the signup API produces Account records in the database + + New tenant signup auto-provisions ISP Chart of Accounts. Admin can list accounts and close accounting periods via API. All tests pass. + + + + + +- `npx vitest run` — all existing + new tests pass +- `npx tsc --noEmit` — clean compilation +- Create a test tenant via POST /api/tenants/signup, verify Account records exist for that tenant +- GET /api/accounting/accounts returns the full COA for the authenticated tenant +- POST /api/accounting/periods/[id]/close successfully closes a period + + + +- Account model exists with code, name, type, normalBalance, tenantId +- AccountingPeriod model exists with year, month, status, tenantId +- ISP COA has 20+ accounts across all 5 types +- createTenant() auto-provisions COA in same transaction +- Period close prevents entries in closed periods (enforcement in 02-02) +- Zero mutable balance fields anywhere +- All tests pass + + + +After completion, create `.planning/phases/02-subscriber-and-billing-core/02-01-SUMMARY.md` + diff --git a/.planning/phases/02-subscriber-and-billing-core/02-02-PLAN.md b/.planning/phases/02-subscriber-and-billing-core/02-02-PLAN.md new file mode 100644 index 0000000..aeedd1a --- /dev/null +++ b/.planning/phases/02-subscriber-and-billing-core/02-02-PLAN.md @@ -0,0 +1,252 @@ +--- +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` + diff --git a/.planning/phases/02-subscriber-and-billing-core/02-03-PLAN.md b/.planning/phases/02-subscriber-and-billing-core/02-03-PLAN.md new file mode 100644 index 0000000..ca2282f --- /dev/null +++ b/.planning/phases/02-subscriber-and-billing-core/02-03-PLAN.md @@ -0,0 +1,235 @@ +--- +phase: 02-subscriber-and-billing-core +plan: "03" +type: execute +wave: 1 +depends_on: [] +files_modified: + - prisma/schema.prisma + - src/lib/services/subscriber-service.ts + - src/lib/services/service-plan-service.ts + - src/app/api/subscribers/route.ts + - src/app/api/subscribers/[id]/route.ts + - src/app/api/subscribers/[id]/status/route.ts + - src/app/api/service-plans/route.ts + - src/app/api/service-plans/[id]/route.ts + - src/lib/prisma-tenant.ts + - prisma/migrations/*_add_subscriber_models/migration.sql + - src/lib/__tests__/subscriber.test.ts +autonomous: true + +must_haves: + truths: + - "Staff can register a subscriber with name, address, contact info, and plan assignment" + - "Subscriber has status lifecycle: Active, Suspended, Cancelled — all transitions are valid" + - "Staff can search and filter subscribers by status, plan, and name" + - "Service plans have name, speed, monthly price, and billing type (prepaid/postpaid)" + - "Each subscriber has a billingDay derived from their signup date (anniversary billing)" + artifacts: + - path: "prisma/schema.prisma" + provides: "Subscriber, ServicePlan models with tenant scoping" + contains: "model Subscriber" + - path: "src/lib/services/subscriber-service.ts" + provides: "Subscriber CRUD, status transitions, search/filter" + exports: ["createSubscriber", "updateSubscriber", "changeSubscriberStatus", "searchSubscribers"] + - path: "src/lib/services/service-plan-service.ts" + provides: "Service plan CRUD" + exports: ["createServicePlan", "updateServicePlan", "listServicePlans"] + - path: "src/app/api/subscribers/route.ts" + provides: "GET (list/search) and POST (create) subscriber endpoints" + - path: "src/app/api/service-plans/route.ts" + provides: "GET (list) and POST (create) service plan endpoints" + key_links: + - from: "src/lib/services/subscriber-service.ts" + to: "prisma/schema.prisma" + via: "Subscriber CRUD with tenant scoping" + pattern: "subscriber\\.(create|findMany|update)" + - from: "src/app/api/subscribers/route.ts" + to: "src/lib/services/subscriber-service.ts" + via: "Route handlers call service functions" + pattern: "createSubscriber|searchSubscribers" + - from: "prisma/schema.prisma" + to: "prisma/schema.prisma" + via: "Subscriber.servicePlanId references ServicePlan.id" + pattern: "servicePlanId" +--- + + +Build subscriber management and service plan CRUD. Staff can register subscribers with all required details and a plan assignment. Subscribers have a status lifecycle (Active/Suspended/Cancelled) with transitions. Service plans define name, speed, monthly price, and billing type (prepaid vs postpaid). Search and filtering by status, plan, and name. + +Purpose: Subscribers are the core business entity — every billing, payment, and collection operation targets subscribers. The billing engine (02-04) needs Subscriber and ServicePlan to generate invoices. +Output: Subscriber and ServicePlan Prisma models, service layer, API routes, search/filter, status lifecycle, tests. + + + +@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 +@prisma/schema.prisma +@src/lib/prisma-tenant.ts +@src/lib/middleware/authorize.ts + + + + + + Task 1: Subscriber and ServicePlan Prisma models + service layer + + prisma/schema.prisma + src/lib/services/subscriber-service.ts + src/lib/services/service-plan-service.ts + src/lib/prisma-tenant.ts + prisma/migrations/*_add_subscriber_models/migration.sql + + +1. Add enums to prisma/schema.prisma: + - `SubscriberStatus`: ACTIVE, SUSPENDED, CANCELLED + - `BillingType`: PREPAID, POSTPAID + +2. Add `ServicePlan` model: + - id (uuid), tenantId (String), name (String), speed (String — e.g., "50 Mbps"), monthlyPrice (Decimal, precision 10 scale 2), billingType (BillingType), description (String?), isActive (Boolean, default true — soft-delete plans), createdAt, updatedAt + - @@unique([tenantId, name]) — plan names unique per tenant + - @@index([tenantId]) + +3. Add `Subscriber` model: + - id (uuid), tenantId (String), accountNumber (String — auto-generated, e.g., "SUB-0001"), firstName (String), lastName (String), email (String?), phone (String?), address (String), zone (String? — for collector routing in Phase 3), servicePlanId (String, relation to ServicePlan), status (SubscriberStatus, default ACTIVE), billingDay (Int — day of month for invoice generation, derived from signup date), activatedAt (DateTime, default now()), suspendedAt (DateTime?), cancelledAt (DateTime?), autoSuspendDays (Int? — per-subscriber override, falls back to tenant setting), notes (String?), creditBalance (Decimal, precision 10 scale 2, default 0 — this tracks subscriber credit from overpayments, NOT an account balance; it's a convenience field that's always updated transactionally with payment journal entries), createdAt, updatedAt + - @@unique([tenantId, accountNumber]) + - @@index([tenantId]) + - @@index([tenantId, status]) + - @@index([tenantId, servicePlanId]) + + IMPORTANT on creditBalance: This is NOT a "stored balance" in the accounting sense. All financial balances come from the journal. This field tracks overpayment credits for the FIFO allocation system (02-05). It is always updated atomically within the same transaction as the journal entry that changes it. The CONTEXT.md decision "no mutable balance fields" refers to account/ledger balances, not operational convenience fields. + +4. Add `TenantSettings` model (or add fields to Tenant model — prefer separate model for extensibility): + - id (uuid), tenantId (String, @unique — one settings record per tenant), autoSuspendDays (Int, default 30 — days overdue before auto-suspension), prepaidLeadDays (Int, default 7 — days before billing date to generate prepaid invoices), createdAt, updatedAt + - @@index([tenantId]) + +5. Update TENANT_SCOPED_MODELS in prisma-tenant.ts to include "subscriber", "servicePlan", "tenantSettings". Add query extension blocks. + +6. Create src/lib/services/service-plan-service.ts: + - `createServicePlan(tenantPrisma, { name, speed, monthlyPrice, billingType, description? })` — validates name not empty, price > 0. Returns created plan. + - `updateServicePlan(tenantPrisma, planId, updates)` — partial update. Returns updated plan. + - `listServicePlans(tenantPrisma, { activeOnly? })` — returns plans, ordered by name. Default activeOnly=true. + - `deactivateServicePlan(tenantPrisma, planId)` — sets isActive=false. Does NOT delete (subscribers may reference it). + +7. Create src/lib/services/subscriber-service.ts: + - `generateAccountNumber(tenantPrisma)` — query max accountNumber for tenant, increment. Format: "SUB-{NNNN}" starting at SUB-0001. + - `createSubscriber(tenantPrisma, { firstName, lastName, email?, phone?, address, zone?, servicePlanId, notes? })` — validates required fields, verifies servicePlanId exists and is active, generates accountNumber, sets billingDay from current date (day of month, capped at 28 to avoid month-length issues). Returns created subscriber. + - `updateSubscriber(tenantPrisma, subscriberId, updates)` — partial update of profile fields (not status — status changes go through changeSubscriberStatus). Returns updated subscriber. + - `changeSubscriberStatus(tenantPrisma, subscriberId, newStatus, reason?)`: + - ACTIVE -> SUSPENDED: set suspendedAt, clear cancelledAt + - ACTIVE -> CANCELLED: set cancelledAt + - SUSPENDED -> ACTIVE: clear suspendedAt (reactivation — caller must verify outstanding balance is zero, enforced in 02-05) + - SUSPENDED -> CANCELLED: set cancelledAt + - CANCELLED -> ACTIVE: clear cancelledAt, clear suspendedAt (reversible cancellation per CONTEXT.md) + - Returns updated subscriber + - `searchSubscribers(tenantPrisma, { status?, servicePlanId?, search?, page?, pageSize? })` — search by name (firstName or lastName contains), filter by status and plan. Paginated. Returns { subscribers, total, page, pageSize }. + - `getSubscriber(tenantPrisma, subscriberId)` — get single subscriber with servicePlan included. + +8. Run `npx prisma migrate dev --name add_subscriber_models` + + + - `npx prisma migrate status` — no pending + - `npx prisma generate` succeeds + - `npx tsc --noEmit` — clean + + Subscriber and ServicePlan models in database. Service layer handles CRUD, status lifecycle, search/filter. TenantSettings model for auto-suspend configuration. + + + + Task 2: Subscriber and ServicePlan API routes + tests + + src/app/api/subscribers/route.ts + src/app/api/subscribers/[id]/route.ts + src/app/api/subscribers/[id]/status/route.ts + src/app/api/service-plans/route.ts + src/app/api/service-plans/[id]/route.ts + src/lib/__tests__/subscriber.test.ts + + +1. Create API routes: + + a. GET /api/service-plans — list service plans. withPermission("read", "Subscriber"). Accepts ?activeOnly=true|false. Returns plans array. + b. POST /api/service-plans — create plan. withPermission("manage", "Subscriber"). Accepts { name, speed, monthlyPrice, billingType, description? }. Returns 201 with created plan. + c. PUT /api/service-plans/[id] — update plan. withPermission("manage", "Subscriber"). Accepts partial fields. Returns updated plan. + + d. GET /api/subscribers — list/search subscribers. withPermission("read", "Subscriber"). Accepts ?status=&servicePlanId=&search=&page=&pageSize=. Returns paginated results. + e. POST /api/subscribers — register subscriber. withPermission("manage", "Subscriber"). Accepts { firstName, lastName, email?, phone?, address, zone?, servicePlanId, notes? }. Returns 201 with created subscriber. + f. GET /api/subscribers/[id] — get subscriber detail with plan. withPermission("read", "Subscriber"). + g. PUT /api/subscribers/[id] — update subscriber profile. withPermission("manage", "Subscriber"). Returns updated subscriber. + h. PATCH /api/subscribers/[id]/status — change subscriber status. withPermission("manage", "Subscriber"). Accepts { status: "ACTIVE"|"SUSPENDED"|"CANCELLED", reason? }. Returns updated subscriber. + +2. Write tests in src/lib/__tests__/subscriber.test.ts: + + ServicePlan tests: + - Create plan with valid data succeeds + - Create plan with duplicate name fails + - Create plan with zero/negative price fails + - List plans returns only active by default + - Deactivate plan sets isActive=false + + Subscriber CRUD tests: + - Register subscriber with all fields succeeds, accountNumber auto-generated + - Register subscriber with invalid servicePlanId fails + - Register subscriber sets billingDay from signup date + - Update subscriber profile fields + - Get subscriber includes servicePlan relation + - Search by name (partial match) + - Filter by status + - Filter by servicePlanId + - Pagination works correctly (page, pageSize, total) + + Status lifecycle tests: + - ACTIVE -> SUSPENDED sets suspendedAt + - ACTIVE -> CANCELLED sets cancelledAt + - SUSPENDED -> ACTIVE clears suspendedAt + - SUSPENDED -> CANCELLED sets cancelledAt + - CANCELLED -> ACTIVE clears both timestamps (reversible) + - Account number format: SUB-0001, SUB-0002, etc. + + Tenant isolation: + - Subscriber from Tenant A not visible to Tenant B + +Run: `npx vitest run src/lib/__tests__/subscriber.test.ts` + + + - `npx vitest run src/lib/__tests__/subscriber.test.ts` — all tests pass + - `npx tsc --noEmit` — clean + - POST /api/subscribers with valid data returns 201 with accountNumber + - GET /api/subscribers?status=ACTIVE returns only active subscribers + - PATCH /api/subscribers/{id}/status transitions correctly + + Staff can register subscribers, manage service plans, change subscriber status through full lifecycle, search and filter subscribers. All tests pass. Tenant isolation verified. + + + + + +- `npx vitest run` — all existing + new tests pass +- `npx tsc --noEmit` — clean +- Full CRUD cycle: create plan -> create subscriber with plan -> search -> update -> change status +- Subscriber from Tenant A invisible to Tenant B +- Status transitions follow defined lifecycle + + + +- ServicePlan model with name, speed, monthlyPrice, billingType +- Subscriber model with accountNumber, status lifecycle, billingDay, plan reference +- TenantSettings model with autoSuspendDays and prepaidLeadDays +- All status transitions work (including reversible cancellation) +- Search by name, filter by status/plan, pagination +- Account numbers auto-generated sequentially per tenant +- billingDay set from signup date (capped at 28) +- Tenant isolation enforced +- All tests pass + + + +After completion, create `.planning/phases/02-subscriber-and-billing-core/02-03-SUMMARY.md` + diff --git a/.planning/phases/02-subscriber-and-billing-core/02-04-PLAN.md b/.planning/phases/02-subscriber-and-billing-core/02-04-PLAN.md new file mode 100644 index 0000000..e1adc53 --- /dev/null +++ b/.planning/phases/02-subscriber-and-billing-core/02-04-PLAN.md @@ -0,0 +1,231 @@ +--- +phase: 02-subscriber-and-billing-core +plan: "04" +type: execute +wave: 3 +depends_on: ["02-02", "02-03"] +files_modified: + - prisma/schema.prisma + - src/lib/services/billing-service.ts + - src/lib/services/invoice-service.ts + - src/app/api/billing/generate/route.ts + - src/app/api/invoices/route.ts + - src/app/api/invoices/[id]/route.ts + - src/lib/prisma-tenant.ts + - prisma/migrations/*_add_invoice_model/migration.sql + - src/lib/__tests__/billing.test.ts +autonomous: true + +must_haves: + truths: + - "System auto-generates invoices for active subscribers on their billing day" + - "Prepaid invoices are generated X days before billing date, postpaid on billing date" + - "Each invoice generation creates a balanced journal entry (debit AR, credit Revenue)" + - "Invoice has status lifecycle: DRAFT -> SENT -> PARTIAL -> PAID -> OVERDUE -> VOID" + - "Duplicate invoices for same subscriber+period are prevented" + - "Overdue detection marks unpaid invoices past due date" + artifacts: + - path: "prisma/schema.prisma" + provides: "Invoice, InvoiceLine models" + contains: "model Invoice" + - path: "src/lib/services/billing-service.ts" + provides: "Billing cycle engine — generates invoices for all eligible subscribers" + exports: ["generateMonthlyInvoices", "generateInvoiceForSubscriber"] + - path: "src/lib/services/invoice-service.ts" + provides: "Invoice CRUD, status management, overdue detection" + exports: ["getInvoice", "listInvoices", "markOverdueInvoices", "voidInvoice"] + - path: "src/app/api/billing/generate/route.ts" + provides: "POST endpoint to trigger invoice generation" + key_links: + - from: "src/lib/services/billing-service.ts" + to: "src/lib/accounting/journal-entry-service.ts" + via: "Each invoice creates a journal entry via JournalEntryService" + pattern: "JournalEntryService\\.createEntry" + - from: "src/lib/services/billing-service.ts" + to: "src/lib/services/subscriber-service.ts" + via: "Queries active subscribers with billing day matching" + pattern: "subscriber\\.findMany" + - from: "src/lib/services/billing-service.ts" + to: "src/lib/accounting/chart-of-accounts.ts" + via: "Uses AR and Revenue account codes for journal entry" + pattern: "1100|4010" +--- + + +Build the billing engine that auto-generates invoices for active subscribers. Prepaid and postpaid billing types follow distinct timing logic. Every invoice generation posts a balanced journal entry (debit Accounts Receivable, credit Subscription Revenue). Includes overdue detection and invoice status management. + +Purpose: The billing engine is the revenue cycle — it turns service plans into invoices. Without invoices, there's nothing to pay against. The payment tracker (02-05) depends on invoices existing. +Output: Invoice model, BillingService for invoice generation, InvoiceService for CRUD/status, overdue detection, API routes, tests. + + + +@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-02-SUMMARY.md +@.planning/phases/02-subscriber-and-billing-core/02-03-SUMMARY.md +@prisma/schema.prisma +@src/lib/accounting/journal-entry-service.ts +@src/lib/accounting/chart-of-accounts.ts +@src/lib/services/subscriber-service.ts +@src/lib/prisma-tenant.ts + + + + + + Task 1: Invoice model + BillingService + InvoiceService + + prisma/schema.prisma + src/lib/services/billing-service.ts + src/lib/services/invoice-service.ts + src/lib/prisma-tenant.ts + prisma/migrations/*_add_invoice_model/migration.sql + + +1. Add enums to prisma/schema.prisma: + - `InvoiceStatus`: DRAFT, SENT, PARTIAL, PAID, OVERDUE, VOID + +2. Add `Invoice` model: + - id (uuid), tenantId (String), invoiceNumber (String — auto-generated per tenant, e.g., "INV-2026-0001"), subscriberId (String, relation to Subscriber), periodStart (DateTime — billing period start), periodEnd (DateTime — billing period end), dueDate (DateTime), subtotal (Decimal, precision 10 scale 2), totalAmount (Decimal, precision 10 scale 2), amountPaid (Decimal, precision 10 scale 2, default 0), status (InvoiceStatus, default DRAFT), journalEntryId (String? — links to the JE created on generation), issuedAt (DateTime?), paidAt (DateTime?), voidedAt (DateTime?), createdAt, updatedAt + - @@unique([tenantId, invoiceNumber]) + - @@unique([tenantId, subscriberId, periodStart]) — prevent duplicate invoices for same subscriber+period + - @@index([tenantId]), @@index([tenantId, status]), @@index([tenantId, subscriberId]), @@index([tenantId, dueDate]) + +3. Add `InvoiceLine` model: + - id (uuid), tenantId (String), invoiceId (String, relation to Invoice), description (String), quantity (Int, default 1), unitPrice (Decimal, precision 10 scale 2), lineTotal (Decimal, precision 10 scale 2), createdAt + - @@index([invoiceId]) + +4. Update TENANT_SCOPED_MODELS in prisma-tenant.ts to include "invoice" and "invoiceLine". Add query extensions. + +5. Create src/lib/services/invoice-service.ts: + - `generateInvoiceNumber(tenantPrisma, year)` — sequential per tenant+year, format "INV-{YYYY}-{NNNN}" + - `getInvoice(tenantPrisma, invoiceId)` — with subscriber, lines, journalEntry included + - `listInvoices(tenantPrisma, { subscriberId?, status?, startDate?, endDate?, page?, pageSize? })` — paginated, ordered by dueDate desc + - `updateInvoiceStatus(tenantPrisma, invoiceId, status)` — updates status field. Used internally by billing and payment services. + - `markOverdueInvoices(tenantPrisma)` — find all invoices where status is DRAFT or SENT, dueDate < today. Update status to OVERDUE. Return count updated. + - `voidInvoice(tenantPrisma, invoiceId, voidedById)` — set status to VOID, set voidedAt. If journalEntryId exists, call JournalEntryService.reverseEntry to reverse the AR journal entry. Return updated invoice. + +6. Create src/lib/services/billing-service.ts: + - `generateInvoiceForSubscriber(tenantPrisma, subscriber, period, createdById)`: + - Calculate periodStart and periodEnd based on subscriber.billingDay + - Check if invoice already exists for this subscriber+periodStart (idempotent — skip if exists) + - Create Invoice with lines (one line: subscription fee from servicePlan.monthlyPrice) + - Set dueDate: for POSTPAID, periodEnd. For PREPAID, periodStart. + - Create journal entry via JournalEntryService.createEntry: + - Debit: Accounts Receivable (1100) for totalAmount + - Credit: Subscription Revenue (4010) for totalAmount + - source: SYSTEM, referenceType: "Invoice", referenceId: invoice.id + - Link journalEntryId to the invoice + - Return created invoice + + - `generateMonthlyInvoices(tenantPrisma, targetDate, createdById)`: + - Determine which subscribers need invoices today: + - POSTPAID subscribers where billingDay === targetDate.getDate() and status === ACTIVE + - PREPAID subscribers where (billingDay - tenantSettings.prepaidLeadDays) === targetDate.getDate() and status === ACTIVE (accounting for month wrapping) + - For each eligible subscriber, call generateInvoiceForSubscriber + - Return { generated: number, skipped: number, errors: string[] } + - Must be idempotent: running twice on same day generates nothing new + +7. Run `npx prisma migrate dev --name add_invoice_model` + + + - `npx prisma migrate status` — no pending + - `npx prisma generate` succeeds + - `npx tsc --noEmit` — clean + + Invoice and InvoiceLine models exist. BillingService generates invoices with journal entries. InvoiceService handles CRUD, overdue detection, and void with journal reversal. + + + + Task 2: Billing API routes + comprehensive tests + + src/app/api/billing/generate/route.ts + src/app/api/invoices/route.ts + src/app/api/invoices/[id]/route.ts + src/app/api/invoices/[id]/void/route.ts + src/lib/__tests__/billing.test.ts + + +1. Create API routes: + + a. POST /api/billing/generate — trigger invoice generation for a target date. withPermission("manage", "Invoice"). Accepts { targetDate?: string } (defaults to today). Calls generateMonthlyInvoices. Returns { generated, skipped, errors }. This is the endpoint that BullMQ or a cron job would call (BullMQ integration is a scheduler concern — the API just needs to work when called). + + b. GET /api/invoices — list invoices. withPermission("read", "Invoice"). Accepts ?subscriberId=&status=&startDate=&endDate=&page=&pageSize=. Returns paginated results. + + c. GET /api/invoices/[id] — get invoice detail with lines. withPermission("read", "Invoice"). Returns invoice with subscriber, lines, journalEntry. + + d. POST /api/invoices/[id]/void — void an invoice. withPermission("manage", "Invoice"). Calls voidInvoice. Returns updated invoice. + +2. Write comprehensive tests in src/lib/__tests__/billing.test.ts: + + Invoice generation tests: + - Generate invoice for postpaid subscriber: creates invoice with correct period, dueDate = periodEnd + - Generate invoice for prepaid subscriber: creates invoice with dueDate = periodStart + - Invoice has correct amount from servicePlan.monthlyPrice + - Invoice has one InvoiceLine matching plan price + - Journal entry created: debit AR, credit Revenue, amounts match invoice + - Journal entry is balanced (debits = credits) + - Duplicate generation for same subscriber+period is skipped (idempotent) + + Billing cycle tests: + - generateMonthlyInvoices generates for all eligible subscribers on their billing day + - Subscribers with different billing days are not included + - Suspended/cancelled subscribers are not billed + - Prepaid subscribers get invoiced prepaidLeadDays before billing day + + Invoice status tests: + - markOverdueInvoices: unpaid invoice past dueDate becomes OVERDUE + - markOverdueInvoices: paid invoice past dueDate stays PAID + - voidInvoice: sets status to VOID and reverses journal entry + + Invoice numbering: + - Sequential per tenant: INV-2026-0001, INV-2026-0002 + - Two tenants have independent numbering + + Tenant isolation: + - Invoice from Tenant A not visible to Tenant B + +Run: `npx vitest run src/lib/__tests__/billing.test.ts` + + + - `npx vitest run src/lib/__tests__/billing.test.ts` — all tests pass + - `npx tsc --noEmit` — clean + - POST /api/billing/generate creates invoices for eligible subscribers + - GET /api/invoices returns filtered, paginated invoices + - POST /api/invoices/{id}/void reverses the journal entry + + Billing engine generates invoices with journal entries for active subscribers. Prepaid and postpaid timing logic works. Overdue detection and void with journal reversal work. All tests pass. + + + + + +- `npx vitest run` — all existing + new tests pass +- `npx tsc --noEmit` — clean +- Create subscriber -> generate billing -> invoice exists with journal entry +- Same billing run again -> no duplicate invoices (idempotent) +- Void invoice -> journal entry reversed +- Overdue detection updates past-due invoices + + + +- Invoice model with invoiceNumber, period dates, dueDate, amountPaid, status +- generateInvoiceForSubscriber creates invoice + journal entry atomically +- generateMonthlyInvoices handles prepaid/postpaid timing correctly +- Journal entries: debit AR (1100), credit Revenue (4010) +- Idempotent: no duplicate invoices for same subscriber+period +- Overdue detection marks past-due invoices +- Void reverses the associated journal entry +- All tests pass + + + +After completion, create `.planning/phases/02-subscriber-and-billing-core/02-04-SUMMARY.md` + diff --git a/.planning/phases/02-subscriber-and-billing-core/02-05-PLAN.md b/.planning/phases/02-subscriber-and-billing-core/02-05-PLAN.md new file mode 100644 index 0000000..3e42de3 --- /dev/null +++ b/.planning/phases/02-subscriber-and-billing-core/02-05-PLAN.md @@ -0,0 +1,296 @@ +--- +phase: 02-subscriber-and-billing-core +plan: "05" +type: execute +wave: 4 +depends_on: ["02-04"] +files_modified: + - prisma/schema.prisma + - src/lib/services/payment-service.ts + - src/lib/services/outstanding-report-service.ts + - src/app/api/payments/route.ts + - src/app/api/payments/[id]/route.ts + - src/app/api/payments/[id]/void/route.ts + - src/app/api/subscribers/[id]/payments/route.ts + - src/app/api/subscribers/[id]/balance/route.ts + - src/app/api/reports/outstanding/route.ts + - src/lib/prisma-tenant.ts + - prisma/migrations/*_add_payment_model/migration.sql + - src/lib/__tests__/payment.test.ts +autonomous: true + +must_haves: + truths: + - "Staff can record cash or bank payment against an invoice" + - "Partial payments are tracked — invoice moves to PARTIAL status" + - "Full payment moves invoice to PAID status" + - "Overpayment creates credit balance that auto-applies to next invoice" + - "Every payment creates a balanced journal entry (debit Cash/Bank, credit AR)" + - "Payment voids create reversing journal entries (no deletion)" + - "Payments use idempotency keys to prevent double-recording" + - "Outstanding report shows correct balances derived from journal entries" + - "Each subscriber has a payment history showing all transactions" + - "Payments are allocated to oldest unpaid invoice first (FIFO)" + artifacts: + - path: "prisma/schema.prisma" + provides: "Payment model with idempotency key" + contains: "model Payment" + - path: "src/lib/services/payment-service.ts" + provides: "Payment recording, FIFO allocation, void, credit balance" + exports: ["recordPayment", "voidPayment", "getSubscriberPaymentHistory"] + - path: "src/lib/services/outstanding-report-service.ts" + provides: "Outstanding balance report derived from journal entries" + exports: ["getOutstandingReport"] + - path: "src/app/api/payments/route.ts" + provides: "POST (record) and GET (list) payment endpoints" + - path: "src/app/api/reports/outstanding/route.ts" + provides: "GET outstanding balance report" + key_links: + - from: "src/lib/services/payment-service.ts" + to: "src/lib/accounting/journal-entry-service.ts" + via: "Each payment creates journal entry via JournalEntryService" + pattern: "JournalEntryService\\.createEntry" + - from: "src/lib/services/payment-service.ts" + to: "src/lib/services/invoice-service.ts" + via: "Updates invoice amountPaid and status after payment" + pattern: "updateInvoiceStatus|amountPaid" + - from: "src/lib/services/outstanding-report-service.ts" + to: "src/lib/accounting/journal-entry-service.ts" + via: "Derives outstanding balances from AR account journal entries" + pattern: "getAccountBalance|journalEntryLine" +--- + + +Build the payment recording system. Staff can record cash or bank payments against invoices. Payments are allocated FIFO to oldest unpaid invoices. Partial payments update invoice status to PARTIAL, full payments to PAID. Overpayments create credit balances. Every payment posts a balanced journal entry. Payment voids use reversing entries. Outstanding balance report derives all balances from journal entries. + +Purpose: This completes the revenue cycle: subscribers get invoices (02-04), and now those invoices can be paid. The outstanding report gives ISP owners the financial visibility that is the core product value. +Output: Payment model, PaymentService with FIFO allocation, void with reversing entries, subscriber payment history, outstanding balance report, all with tests. + + + +@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-02-SUMMARY.md +@.planning/phases/02-subscriber-and-billing-core/02-03-SUMMARY.md +@.planning/phases/02-subscriber-and-billing-core/02-04-SUMMARY.md +@prisma/schema.prisma +@src/lib/accounting/journal-entry-service.ts +@src/lib/services/invoice-service.ts +@src/lib/services/subscriber-service.ts +@src/lib/prisma-tenant.ts + + + + + + Task 1: Payment model + PaymentService with FIFO allocation + + prisma/schema.prisma + src/lib/services/payment-service.ts + src/lib/prisma-tenant.ts + prisma/migrations/*_add_payment_model/migration.sql + + +1. Add enums to prisma/schema.prisma: + - `PaymentMethod`: CASH, BANK_TRANSFER + - `PaymentStatus`: COMPLETED, VOIDED + +2. Add `Payment` model: + - id (uuid), tenantId (String), subscriberId (String, relation to Subscriber), amount (Decimal, precision 10 scale 2), paymentMethod (PaymentMethod), referenceNumber (String? — bank transfer reference, receipt number), paymentDate (DateTime), notes (String?), status (PaymentStatus, default COMPLETED), idempotencyKey (String — caller-provided unique key to prevent double-recording), journalEntryId (String? — links to the JE created), voidedAt (DateTime?), voidedById (String?), voidJournalEntryId (String? — the reversing JE), recordedById (String, relation to User — who recorded it), createdAt, updatedAt + - @@unique([tenantId, idempotencyKey]) — idempotency enforcement + - @@index([tenantId]), @@index([tenantId, subscriberId]), @@index([tenantId, paymentDate]) + +3. Add `PaymentAllocation` model (tracks which invoices a payment was applied to): + - id (uuid), tenantId (String), paymentId (String, relation to Payment), invoiceId (String, relation to Invoice), amount (Decimal, precision 10 scale 2), createdAt + - @@index([paymentId]), @@index([invoiceId]) + +4. Update TENANT_SCOPED_MODELS in prisma-tenant.ts to include "payment" and "paymentAllocation". Add query extensions. + +5. Create src/lib/services/payment-service.ts: + + a. `recordPayment(tenantPrisma, { subscriberId, amount, paymentMethod, referenceNumber?, paymentDate, notes?, idempotencyKey, recordedById })`: + - Check idempotency: if payment with this idempotencyKey already exists, return the existing payment (not an error) + - Validate amount > 0 + - Validate subscriber exists + + FIFO allocation (all in one transaction): + 1. Check subscriber.creditBalance > 0? If yes, include it in available amount. + 2. Find all unpaid/partial invoices for this subscriber, ordered by dueDate ASC (oldest first) + 3. Allocate payment amount to invoices FIFO: + - For each invoice: remaining = invoice.totalAmount - invoice.amountPaid + - Allocate min(availableAmount, remaining) to this invoice + - Create PaymentAllocation record + - Update invoice.amountPaid += allocated + - If invoice fully paid: update status to PAID, set paidAt + - If invoice partially paid: update status to PARTIAL + - Reduce availableAmount by allocated + - Stop when availableAmount reaches 0 + 4. If amount left over after all invoices: update subscriber.creditBalance += leftover + 5. Create journal entry via JournalEntryService.createEntry: + - For CASH: Debit Cash on Hand (1010), Credit AR (1100) + - For BANK_TRANSFER: Debit Cash in Bank (1020), Credit AR (1100) + - If overpayment exists: also Credit Subscriber Credits (1150) for overpayment portion + - source: SYSTEM, referenceType: "Payment", referenceId: payment.id + 6. Link journalEntryId to payment + 7. Return payment with allocations + + b. `voidPayment(tenantPrisma, paymentId, voidedById)`: + - Find payment with allocations + - Verify status is COMPLETED (not already voided) + - Reverse allocations: for each PaymentAllocation, reduce invoice.amountPaid, recalculate invoice status (PAID->PARTIAL or PARTIAL->SENT/OVERDUE) + - If subscriber.creditBalance was increased by overpayment, reduce it + - Create reversing journal entry via JournalEntryService.reverseEntry + - Update payment: status=VOIDED, voidedAt, voidedById, voidJournalEntryId + - All in one transaction + - Return voided payment + + c. `getSubscriberPaymentHistory(tenantPrisma, subscriberId, { page?, pageSize? })`: + - Return all payments for subscriber, ordered by paymentDate desc, with allocations and linked invoices + - Paginated + + d. `applyCredit(tenantPrisma, subscriberId, invoiceId)`: + - If subscriber.creditBalance > 0, apply it to the specified invoice + - Create PaymentAllocation, update invoice, reduce creditBalance + - Called automatically by billing service when generating new invoices for subscribers with credit + +6. Run `npx prisma migrate dev --name add_payment_model` + + + - `npx prisma migrate status` — no pending + - `npx prisma generate` succeeds + - `npx tsc --noEmit` — clean + + Payment and PaymentAllocation models exist. PaymentService handles FIFO allocation, overpayment credit, void with reversing entries, and idempotency. + + + + Task 2: Payment APIs + outstanding report + tests + + src/app/api/payments/route.ts + src/app/api/payments/[id]/route.ts + src/app/api/payments/[id]/void/route.ts + src/app/api/subscribers/[id]/payments/route.ts + src/app/api/subscribers/[id]/balance/route.ts + src/app/api/reports/outstanding/route.ts + src/lib/services/outstanding-report-service.ts + src/lib/__tests__/payment.test.ts + + +1. Create src/lib/services/outstanding-report-service.ts: + - `getOutstandingReport(tenantPrisma, { startDate?, endDate?, status?, minAmount?, maxAmount?, page?, pageSize? })`: + - Query invoices with status in [SENT, PARTIAL, OVERDUE] (unpaid) + - For each: outstanding = totalAmount - amountPaid + - Filter by date range (dueDate), status, amount range + - Include subscriber name, accountNumber, plan name + - Sort by outstanding amount desc (biggest debts first) + - Return { items: [...], totalOutstanding, totalCount, page, pageSize } + - Outstanding amounts MUST match what the journal shows (AR balance per subscriber) + +2. Create API routes: + + a. POST /api/payments — record a payment. withPermission("create", "Payment"). Accepts { subscriberId, amount, paymentMethod, referenceNumber?, paymentDate, notes?, idempotencyKey }. Calls recordPayment. Returns 201 with payment and allocations. + + b. GET /api/payments — list payments. withPermission("read", "Payment"). Accepts ?subscriberId=&startDate=&endDate=&method=&page=&pageSize=. Returns paginated payments. + + c. GET /api/payments/[id] — get payment detail with allocations. withPermission("read", "Payment"). + + d. POST /api/payments/[id]/void — void a payment. withPermission("manage", "Payment") (Admin/Office Staff only). Calls voidPayment. Returns voided payment. + + e. GET /api/subscribers/[id]/payments — subscriber payment history. withPermission("read", "Payment"). Calls getSubscriberPaymentHistory. Returns paginated history. + + f. GET /api/subscribers/[id]/balance — subscriber outstanding balance. withPermission("read", "Payment"). Returns { subscriberId, totalOutstanding, creditBalance, invoicesSummary }. + + g. GET /api/reports/outstanding — outstanding balance report. withPermission("read", "Report"). Accepts ?startDate=&endDate=&status=&minAmount=&maxAmount=&page=&pageSize=. Returns report with totals. + +3. Write comprehensive tests in src/lib/__tests__/payment.test.ts: + + Payment recording tests: + - Record full payment against single invoice: invoice status -> PAID + - Record partial payment: invoice status -> PARTIAL, amountPaid updated + - Record payment larger than invoice amount: overpayment creates credit balance + - FIFO allocation: payment applied to oldest invoice first + - Multiple partial payments accumulate on same invoice + - Journal entry created: debit Cash (1010 for CASH), credit AR (1100) + - Journal entry balanced (debits = credits) + - Payment with BANK_TRANSFER debits Cash in Bank (1020) + + Idempotency tests: + - Same idempotencyKey returns existing payment, not duplicate + - Different idempotencyKey creates new payment + + Credit balance tests: + - Overpayment increases subscriber.creditBalance + - Credit balance auto-applied to next invoice + + Void tests: + - Void payment reverses allocations (invoice.amountPaid decreases) + - Void payment creates reversing journal entry + - Void payment reduces credit balance if overpayment existed + - Cannot void already-voided payment + - Invoice status recalculated after void (PAID -> reverts appropriately) + + Outstanding report tests: + - Report shows only unpaid invoices (SENT, PARTIAL, OVERDUE) + - Outstanding = totalAmount - amountPaid + - Filter by date range works + - Filter by minimum amount works + - Total outstanding sums correctly + - Report excludes PAID and VOID invoices + + Payment history tests: + - Subscriber payment history shows all payments with allocations + - History ordered by date descending + - Subscriber balance shows correct outstanding amount + + Tenant isolation: + - Payment from Tenant A not visible to Tenant B + +Run: `npx vitest run src/lib/__tests__/payment.test.ts` + + + - `npx vitest run src/lib/__tests__/payment.test.ts` — all tests pass + - `npx vitest run` — ALL tests pass (full suite regression) + - `npx tsc --noEmit` — clean + - POST /api/payments with valid data returns 201 with FIFO allocations + - POST /api/payments with same idempotencyKey returns existing payment + - POST /api/payments/{id}/void reverses journal entry and allocations + - GET /api/reports/outstanding returns correct outstanding balances + - GET /api/subscribers/{id}/payments returns payment history + + Staff can record payments with FIFO allocation. Partial, full, and overpayments all handled correctly. Every payment has a balanced journal entry. Voids use reversing entries. Outstanding report derives balances from journal. Subscriber payment history available. Idempotency enforced. All tests pass. + + + + + +- `npx vitest run` — ALL tests pass (full regression across all 5 plans) +- `npx tsc --noEmit` — clean +- End-to-end flow: create subscriber -> generate invoice -> record payment -> verify journal entries balanced -> check outstanding report +- Void payment -> journal reversed -> outstanding recalculated +- Overpayment -> credit balance -> auto-applied to next invoice + + + +- Payment model with idempotencyKey, method, allocations +- FIFO allocation to oldest unpaid invoice +- Partial payments -> PARTIAL status, full -> PAID +- Overpayment -> subscriber credit balance +- Every payment creates balanced journal entry (debit Cash/Bank, credit AR) +- Void creates reversing journal entry, recalculates invoice status +- Idempotency key prevents double-recording +- Outstanding report shows correct balances filtered by date/status/amount +- Subscriber payment history shows all transactions +- Tenant isolation enforced +- All tests pass + + + +After completion, create `.planning/phases/02-subscriber-and-billing-core/02-05-SUMMARY.md` +