fix(02): revise plans based on checker feedback

- 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>
This commit is contained in:
kevin-asprec
2026-03-04 22:35:59 +08:00
parent 9489b5c7bc
commit bd8e83bf68
4 changed files with 48 additions and 15 deletions

View File

@@ -114,6 +114,8 @@ Output: Account and AccountingPeriod Prisma models, ISP COA seed data, auto-prov
6. Update TENANT_SCOPED_MODELS in src/lib/prisma-tenant.ts to include "account" and "accountingPeriod". Add the same query extension blocks (findMany, findFirst, create, update, delete, etc.) following the existing `user` pattern exactly.
7. Run `npx prisma migrate dev --name add_accounting_models` to generate and apply the migration.
NOTE — Parallel migration conflict: This plan (02-01) and plan 02-03 are both Wave 1 and both run `prisma migrate dev`. When executing these plans in parallel, Prisma migrations MUST be serialized: one plan must complete its migration before the other begins its migration step. The executor should run Task 1 of whichever plan starts first through the migration step, then allow the other plan to proceed with its migration. Non-migration tasks (code files, tests) can still run in parallel.
</action>
<verify>
- `npx prisma migrate status` shows no pending migrations

View File

@@ -133,6 +133,8 @@ Output: Subscriber and ServicePlan Prisma models, service layer, API routes, sea
- `getSubscriber(tenantPrisma, subscriberId)` — get single subscriber with servicePlan included.
8. Run `npx prisma migrate dev --name add_subscriber_models`
NOTE — Parallel migration conflict: This plan (02-03) and plan 02-01 are both Wave 1 and both run `prisma migrate dev`. When executing these plans in parallel, Prisma migrations MUST be serialized: one plan must complete its migration before the other begins its migration step. The executor should run Task 1 of whichever plan starts first through the migration step, then allow the other plan to proceed with its migration. Non-migration tasks (code files, tests) can still run in parallel.
</action>
<verify>
- `npx prisma migrate status` — no pending

View File

@@ -24,6 +24,7 @@ must_haves:
- "Invoice has status lifecycle: DRAFT -> SENT -> PARTIAL -> PAID -> OVERDUE -> VOID"
- "Duplicate invoices for same subscriber+period are prevented"
- "Overdue detection marks unpaid invoices past due date"
- "Subscriber credit balance is auto-applied to newly generated invoices"
artifacts:
- path: "prisma/schema.prisma"
provides: "Invoice, InvoiceLine models"
@@ -49,13 +50,17 @@ must_haves:
to: "src/lib/accounting/chart-of-accounts.ts"
via: "Uses AR and Revenue account codes for journal entry"
pattern: "1100|4010"
- from: "src/lib/services/billing-service.ts"
to: "src/lib/services/payment-service.ts"
via: "Calls applyCredit after invoice creation for subscribers with credit balance"
pattern: "applyCredit|creditBalance"
---
<objective>
Build the billing engine that auto-generates invoices for active subscribers. Prepaid and postpaid billing types follow distinct timing logic. Every invoice generation posts a balanced journal entry (debit Accounts Receivable, credit Subscription Revenue). Includes overdue detection and invoice status management.
Build the billing engine that auto-generates invoices for active subscribers. Prepaid and postpaid billing types follow distinct timing logic. Every invoice generation posts a balanced journal entry (debit Accounts Receivable, credit Subscription Revenue). After creating an invoice, if the subscriber has a credit balance, it is automatically applied. Includes overdue detection and invoice status management.
Purpose: The billing engine is the revenue cycle — it turns service plans into invoices. Without invoices, there's nothing to pay against. The payment tracker (02-05) depends on invoices existing.
Output: Invoice model, BillingService for invoice generation, InvoiceService for CRUD/status, overdue detection, API routes, tests.
Output: Invoice model, BillingService for invoice generation with credit application, InvoiceService for CRUD/status, overdue detection, API routes, tests.
</objective>
<execution_context>
@@ -98,6 +103,8 @@ Output: Invoice model, BillingService for invoice generation, InvoiceService for
- @@unique([tenantId, subscriberId, periodStart]) — prevent duplicate invoices for same subscriber+period
- @@index([tenantId]), @@index([tenantId, status]), @@index([tenantId, subscriberId]), @@index([tenantId, dueDate])
IMPORTANT on amountPaid: Like Subscriber.creditBalance (see 02-03), Invoice.amountPaid is a transactional convenience field, NOT a standalone stored accounting balance. It is always updated atomically within the same database transaction as the corresponding journal entry. 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.
3. Add `InvoiceLine` model:
- id (uuid), tenantId (String), invoiceId (String, relation to Invoice), description (String), quantity (Int, default 1), unitPrice (Decimal, precision 10 scale 2), lineTotal (Decimal, precision 10 scale 2), createdAt
- @@index([invoiceId])
@@ -123,7 +130,11 @@ Output: Invoice model, BillingService for invoice generation, InvoiceService for
- Credit: Subscription Revenue (4010) for totalAmount
- source: SYSTEM, referenceType: "Invoice", referenceId: invoice.id
- Link journalEntryId to the invoice
- Return created invoice
- **After invoice creation, check if subscriber.creditBalance > 0. If yes, auto-apply credit to the new invoice:**
- Import and call `applyCredit(tenantPrisma, subscriberId, invoice.id)` from payment-service.ts
- To avoid circular imports: extract `applyCredit` into a dedicated file `src/lib/services/credit-service.ts` that both billing-service and payment-service can import. Payment-service's `applyCredit` function should be moved to (or re-exported from) credit-service.ts.
- This ensures subscribers with existing credit from overpayments have it automatically applied to new invoices
- Return created invoice (with updated amountPaid if credit was applied)
- `generateMonthlyInvoices(tenantPrisma, targetDate, createdById)`:
- Determine which subscribers need invoices today:
@@ -140,7 +151,7 @@ Output: Invoice model, BillingService for invoice generation, InvoiceService for
- `npx prisma generate` succeeds
- `npx tsc --noEmit` — clean
</verify>
<done>Invoice and InvoiceLine models exist. BillingService generates invoices with journal entries. InvoiceService handles CRUD, overdue detection, and void with journal reversal.</done>
<done>Invoice and InvoiceLine models exist. BillingService generates invoices with journal entries and auto-applies subscriber credit balances. InvoiceService handles CRUD, overdue detection, and void with journal reversal.</done>
</task>
<task type="auto">
@@ -174,6 +185,13 @@ Output: Invoice model, BillingService for invoice generation, InvoiceService for
- Journal entry is balanced (debits = credits)
- Duplicate generation for same subscriber+period is skipped (idempotent)
Credit auto-application tests:
- Generate invoice for subscriber with creditBalance > 0: credit is applied to new invoice
- If credit covers full invoice amount: invoice status becomes PAID
- If credit partially covers: invoice status becomes PARTIAL, amountPaid reflects credit applied
- If no credit: invoice remains DRAFT with amountPaid = 0
- Credit application creates its own journal entry (debit Subscriber Credits 1150, credit AR 1100)
Billing cycle tests:
- generateMonthlyInvoices generates for all eligible subscribers on their billing day
- Subscribers with different billing days are not included
@@ -201,7 +219,7 @@ Run: `npx vitest run src/lib/__tests__/billing.test.ts`
- GET /api/invoices returns filtered, paginated invoices
- POST /api/invoices/{id}/void reverses the journal entry
</verify>
<done>Billing engine generates invoices with journal entries for active subscribers. Prepaid and postpaid timing logic works. Overdue detection and void with journal reversal work. All tests pass.</done>
<done>Billing engine generates invoices with journal entries for active subscribers. Credit balances auto-applied to new invoices. Prepaid and postpaid timing logic works. Overdue detection and void with journal reversal work. All tests pass.</done>
</task>
</tasks>
@@ -211,13 +229,16 @@ Run: `npx vitest run src/lib/__tests__/billing.test.ts`
- `npx tsc --noEmit` — clean
- Create subscriber -> generate billing -> invoice exists with journal entry
- Same billing run again -> no duplicate invoices (idempotent)
- Subscriber with credit balance -> generate invoice -> credit auto-applied
- Void invoice -> journal entry reversed
- Overdue detection updates past-due invoices
</verification>
<success_criteria>
- Invoice model with invoiceNumber, period dates, dueDate, amountPaid, status
- amountPaid documented as transactional convenience field (not standalone stored balance)
- generateInvoiceForSubscriber creates invoice + journal entry atomically
- generateInvoiceForSubscriber auto-applies subscriber credit balance to new invoice
- generateMonthlyInvoices handles prepaid/postpaid timing correctly
- Journal entries: debit AR (1100), credit Revenue (4010)
- Idempotent: no duplicate invoices for same subscriber+period

View File

@@ -39,7 +39,7 @@ must_haves:
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"
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"
@@ -55,13 +55,13 @@ must_haves:
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"
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"
---
<objective>
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.
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.
@@ -157,8 +157,12 @@ Output: Payment model, PaymentService with FIFO allocation, void with reversing
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
- 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`
</action>
@@ -191,7 +195,8 @@ Output: Payment model, PaymentService with FIFO allocation, void with reversing
- 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)
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:
@@ -227,7 +232,7 @@ Output: Payment model, PaymentService with FIFO allocation, void with reversing
Credit balance tests:
- Overpayment increases subscriber.creditBalance
- Credit balance auto-applied to next invoice
- Credit balance auto-applied to next invoice (via applyCredit called from billing service)
Void tests:
- Void payment reverses allocations (invoice.amountPaid decreases)
@@ -243,6 +248,7 @@ Output: Payment model, PaymentService with FIFO allocation, void with reversing
- 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
@@ -264,7 +270,7 @@ Run: `npx vitest run src/lib/__tests__/payment.test.ts`
- GET /api/reports/outstanding returns correct outstanding balances
- GET /api/subscribers/{id}/payments returns payment history
</verify>
<done>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.</done>
<done>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.</done>
</task>
</tasks>
@@ -275,6 +281,7 @@ Run: `npx vitest run src/lib/__tests__/payment.test.ts`
- 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
</verification>
<success_criteria>
@@ -286,6 +293,7 @@ Run: `npx vitest run src/lib/__tests__/payment.test.ts`
- 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