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