---
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 using Invoice.amountPaid convenience field"
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: "prisma/schema.prisma"
via: "Reads Invoice.amountPaid (transactional convenience field, always updated atomically with journal entry) for efficiency. Journal reconcilability guaranteed because amountPaid is only ever written inside the same transaction as the corresponding JournalEntry."
pattern: "totalAmount.*amountPaid|invoice\\.findMany"
---
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 uses Invoice.amountPaid for efficiency — a transactional convenience field always updated atomically with journal entries, guaranteeing journal reconcilability.
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.amountPaid, reduce creditBalance
- If credit fully covers invoice: set status to PAID, set paidAt
- If credit partially covers: set status to PARTIAL
- Create journal entry: Debit Subscriber Credits (1150), Credit AR (1100) for applied amount
- All in one transaction
- Called by billing service (02-04) 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 }
IMPORTANT on amountPaid: Invoice.amountPaid is a transactional convenience field, NOT a standalone stored balance. It is always updated atomically within the same database transaction as the corresponding journal entry (in PaymentService.recordPayment and PaymentService.voidPayment). This means outstanding = totalAmount - amountPaid is always consistent with what the AR journal entries show. The "no stored balance fields" principle (ACCT-09) refers to account/ledger balances — Invoice.amountPaid is an operational field on a transactional record (like an order's fulfillment count), not a derived accounting balance. Journal reconcilability is guaranteed by the atomic transaction constraint.
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 (via applyCredit called from billing service)
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
- Outstanding amounts consistent with AR journal entry lines (reconciliation test: sum AR debits - AR credits per subscriber matches report totalOutstanding)
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 uses Invoice.amountPaid (transactional convenience field) for efficiency, with journal reconcilability guaranteed by atomic transactions. 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
- Reconciliation check: AR journal entry sum per subscriber matches outstanding report totals
- 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
- Outstanding report uses amountPaid convenience field, reconcilable with journal
- Subscriber payment history shows all transactions
- Tenant isolation enforced
- All tests pass