- 02-05: Update outstanding report key_link and action to document Invoice.amountPaid as transactional convenience field (not standalone stored balance), consistent with creditBalance pattern in 02-03 - 02-04: Add applyCredit wiring to generateInvoiceForSubscriber, add credit-service.ts extraction to avoid circular imports, add credit auto-application tests - 02-01/02-03: Add parallel Prisma migration serialization notes for Wave 1 concurrent execution Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
17 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 |
|
|
true |
|
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-
Add
Paymentmodel:- 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])
-
Add
PaymentAllocationmodel (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])
-
Update TENANT_SCOPED_MODELS in prisma-tenant.ts to include "payment" and "paymentAllocation". Add query extensions.
-
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):
- Check subscriber.creditBalance > 0? If yes, include it in available amount.
- Find all unpaid/partial invoices for this subscriber, ordered by dueDate ASC (oldest first)
- 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
- If amount left over after all invoices: update subscriber.creditBalance += leftover
- 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
- Link journalEntryId to payment
- 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
-
Run
npx prisma migrate dev --name add_payment_modelnpx prisma migrate status— no pendingnpx prisma generatesucceedsnpx tsc --noEmit— clean Payment and PaymentAllocation models exist. PaymentService handles FIFO allocation, overpayment credit, void with reversing entries, and idempotency.
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.
-
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.
-
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.
<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
- Outstanding report uses amountPaid convenience field, reconcilable with journal
- Subscriber payment history shows all transactions
- Tenant isolation enforced
- All tests pass </success_criteria>