--- phase: 02-subscriber-and-billing-core plan: "05" subsystem: payments tags: [prisma, postgresql, payment, fifo, journal-entry, double-entry, outstanding-report, idempotency] # Dependency graph requires: - phase: 02-04 provides: Invoice model with amountPaid convenience field, BillingService, CreditService - phase: 02-02 provides: JournalEntryService as sole accounting gateway - phase: 02-01 provides: ISP COA — accounts 1010/1020/1100/1150/4010 provides: - Payment model with idempotency key, FIFO allocation, void fields - PaymentAllocation model linking payments to invoices - PaymentService: recordPayment() FIFO allocation, voidPayment() with reversing JE, getSubscriberPaymentHistory() - OutstandingReportService: getOutstandingReport() with filters/pagination - REST API: POST/GET /api/payments, GET /api/payments/[id], POST /api/payments/[id]/void - REST API: GET /api/subscribers/[id]/payments, GET /api/subscribers/[id]/balance - REST API: GET /api/reports/outstanding - 29 integration tests for full payment lifecycle affects: - 03-collections-and-routing (collectors receive/record payments) - 05-online-payment (payment gateway integration scaffolded here) - reporting (outstanding balances, payment history) # Tech tracking tech-stack: added: [] patterns: - FIFO allocation: oldest unpaid invoice (by dueDate ASC) allocated first - Overpayment creates subscriber.creditBalance (atomically with JE, same pattern as amountPaid) - Idempotency via @@unique([tenantId, idempotencyKey]) — safe to retry - Void via reversing JE (immutability — no deletes, corrections only) - PaymentAllocation model tracks allocation breakdown per payment per invoice key-files: created: - prisma/migrations/20260304154606_add_payment_model/migration.sql - 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/__tests__/payment.test.ts modified: - prisma/schema.prisma - src/lib/prisma-tenant.ts key-decisions: - "PaymentAllocation is a separate model (not embedded) — enables per-invoice allocation queries and supports void recalculation" - "FIFO by dueDate ASC — earliest due date gets first allocation (matches ISP norms)" - "Overpayment goes to subscriber.creditBalance (same atomically-updated pattern as amountPaid)" - "Void recalculates invoice.amountPaid by subtracting allocation.amount — safe for multiple-payment scenarios" - "Outstanding report computes in-process (not raw SQL) — Prisma computed fields not supported; totalAmount-amountPaid computed in JS" - "Tests use unique invoiceCounter for periodStart — avoids @@unique([tenantId, subscriberId, periodStart]) constraint in test helpers" patterns-established: - "Payment cleanup order in tests: paymentAllocations -> payments -> invoiceLines -> invoices -> journalEntryLines -> null reversesEntryId -> journalEntries -> subscribers -> servicePlans -> tenantSettings -> accountingPeriods -> accounts -> users -> tenant" - "withPermission() HOF pattern continued for all payment/report routes" # Metrics duration: 8min completed: 2026-03-04 --- # Phase 2 Plan 05: Payment System Summary **FIFO cash/bank payment recording with idempotency, partial/full/overpayment tracking, reversing-JE voids, and outstanding balance report — completing the ISP revenue cycle** ## Performance - **Duration:** 8 min - **Started:** 2026-03-04T15:44:52Z - **Completed:** 2026-03-04T15:52:55Z - **Tasks:** 2/2 - **Files modified:** 12 ## Accomplishments - Payment model with FIFO allocation, idempotency key, void support, and journal entry links - PaymentService: recordPayment() with FIFO, voidPayment() with reversing JE, getSubscriberPaymentHistory() - OutstandingReportService showing who owes what — the core product value - 7 REST API routes for payment recording, history, balance, void, and outstanding report - 29 integration tests covering all payment scenarios (full/partial/overpayment, FIFO, idempotency, void, tenant isolation) - Full regression: 265/265 tests pass ## Task Commits Each task was committed atomically: 1. **Task 1: Payment model + PaymentService with FIFO allocation** - `6b91e67` (feat) 2. **Task 2: Payment APIs + outstanding report + tests** - `bbfc9d6` (feat) **Plan metadata:** (created below) ## Files Created/Modified - `prisma/schema.prisma` - Added PaymentMethod/PaymentStatus enums, Payment and PaymentAllocation models, relations - `src/lib/prisma-tenant.ts` - Added "payment" and "paymentAllocation" to TENANT_SCOPED_MODELS and withTenantContext() - `prisma/migrations/20260304154606_add_payment_model/migration.sql` - DB migration for Payment and PaymentAllocation tables - `src/lib/services/payment-service.ts` - recordPayment() FIFO, voidPayment() reversing JE, getSubscriberPaymentHistory() - `src/lib/services/outstanding-report-service.ts` - getOutstandingReport() with amount/date/status filters - `src/app/api/payments/route.ts` - POST record payment, GET list payments - `src/app/api/payments/[id]/route.ts` - GET single payment detail - `src/app/api/payments/[id]/void/route.ts` - POST void payment - `src/app/api/subscribers/[id]/payments/route.ts` - GET subscriber payment history - `src/app/api/subscribers/[id]/balance/route.ts` - GET subscriber outstanding balance - `src/app/api/reports/outstanding/route.ts` - GET outstanding balance report - `src/lib/__tests__/payment.test.ts` - 29 integration tests ## Decisions Made - **PaymentAllocation as separate model:** Enables per-invoice allocation queries and void recalculation. Each allocation is a row: paymentId + invoiceId + amount. - **FIFO by dueDate ASC:** Oldest due date allocated first — matches standard ISP billing practice. - **Overpayment to subscriber.creditBalance:** Same pattern as invoice.amountPaid — always updated atomically in same transaction as journal entry. CreditService from 02-04 is callable for future credit-to-invoice application. - **Void recalculation via subtraction:** voidPayment() subtracts allocation.amount from invoice.amountPaid — safe even when multiple payments partially cover the same invoice. - **Outstanding report computed in JS:** Prisma does not support computed fields in WHERE/ORDER BY. totalAmount-amountPaid computed in process after fetching invoices. Acceptable for ISP scale (hundreds, not millions of records). - **Test invoiceCounter for periodStart uniqueness:** Invoice schema has @@unique([tenantId, subscriberId, periodStart]). Test helper uses a monotonic counter to generate unique periodStart per invoice, avoiding constraint violations when creating multiple invoices per subscriber in tests. ## Deviations from Plan ### Auto-fixed Issues **1. [Rule 1 - Bug] Fixed unique periodStart constraint in test helper** - **Found during:** Task 2 (first test run — 6 tests failed with PrismaClientKnownRequestError) - **Issue:** Test `createInvoice()` helper used a fixed `periodStart: new Date(Date.UTC(2026, 1, 1))` for all invoices. Multiple invoices for the same subscriber violated `@@unique([tenantId, subscriberId, periodStart])`. - **Fix:** Added `invoiceCounter` variable; each `createInvoice()` call uses `new Date(Date.UTC(2020, 0, invoiceCounter))` as unique periodStart. - **Files modified:** `src/lib/__tests__/payment.test.ts` - **Verification:** All 29 tests pass after fix - **Committed in:** `bbfc9d6` (Task 2 commit) --- **Total deviations:** 1 auto-fixed (Rule 1 - Bug in test helper) **Impact on plan:** Minimal. Test helper bug; no production code affected. Fix is a standard test-isolation technique. ## Issues Encountered None beyond the test helper bug documented above. ## User Setup Required None - no external service configuration required. ## Next Phase Readiness - Phase 2 (Subscriber and Billing Core) is **complete**: COA, JournalEntryService, Subscriber, BillingEngine, PaymentService all done. - 265 total tests passing across all Phase 1 and Phase 2 plans. - Phase 3 (Collections and Routing) can begin: collector routes, payment collection, zone-based assignment. - Key context for Phase 3: collectors will use PaymentService.recordPayment() to record collected payments; subscriber.zone field is already on the schema. --- *Phase: 02-subscriber-and-billing-core* *Completed: 2026-03-04*