Files
NetForge/.planning/phases/02-subscriber-and-billing-core/02-05-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

15 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 05 execute 4
02-04
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
true
truths artifacts key_links
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)
path provides contains
prisma/schema.prisma Payment model with idempotency key model Payment
path provides exports
src/lib/services/payment-service.ts Payment recording, FIFO allocation, void, credit balance
recordPayment
voidPayment
getSubscriberPaymentHistory
path provides exports
src/lib/services/outstanding-report-service.ts Outstanding balance report derived from journal entries
getOutstandingReport
path provides
src/app/api/payments/route.ts POST (record) and GET (list) payment endpoints
path provides
src/app/api/reports/outstanding/route.ts GET outstanding balance report
from to via pattern
src/lib/services/payment-service.ts src/lib/accounting/journal-entry-service.ts Each payment creates journal entry via JournalEntryService JournalEntryService.createEntry
from to via pattern
src/lib/services/payment-service.ts src/lib/services/invoice-service.ts Updates invoice amountPaid and status after payment updateInvoiceStatus|amountPaid
from to via pattern
src/lib/services/outstanding-report-service.ts src/lib/accounting/journal-entry-service.ts Derives outstanding balances from AR account journal entries 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.

<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-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
  1. 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])
  2. 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])
  3. Update TENANT_SCOPED_MODELS in prisma-tenant.ts to include "payment" and "paymentAllocation". Add query extensions.

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

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

<success_criteria>

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