Files
NetForge/.planning/phases/02-subscriber-and-billing-core/02-04-SUMMARY.md
kevin-asprec df4a467a38 docs(02-04): complete billing engine plan
Tasks completed: 2/2
- Task 1: Invoice model + BillingService + InvoiceService + CreditService
- Task 2: Billing API routes + comprehensive tests (38 passing)

SUMMARY: .planning/phases/02-subscriber-and-billing-core/02-04-SUMMARY.md
2026-03-04 23:41:50 +08:00

8.7 KiB

phase, plan, subsystem, tags, requires, provides, affects, tech-stack, key-files, key-decisions, patterns-established, duration, completed
phase plan subsystem tags requires provides affects tech-stack key-files key-decisions patterns-established duration completed
02-subscriber-and-billing-core 04 billing
prisma
invoice
billing-engine
journal-entry
double-entry
credit
vitest
phase provides
02-02 JournalEntryService (sole gateway to ledger), account IDs 1100/4010/1150
phase provides
02-03 Subscriber model with creditBalance, billingDay, BillingType, servicePlan relation
Invoice and InvoiceLine Prisma models with migration
BillingService
generateInvoiceForSubscriber (idempotent, with JE + auto-credit), generateMonthlyInvoices (postpaid + prepaid timing)
InvoiceService
getInvoice, listInvoices, markOverdueInvoices, voidInvoice (with JE reversal)
CreditService
applyCredit (DR 1150 Subscriber Credits, CR 1100 AR)
API routes
POST /billing/generate, GET /invoices, GET /invoices/[id], POST /invoices/[id]/void
38 integration tests covering all billing scenarios
02-05 (PaymentService needs Invoice.amountPaid, InvoiceStatus lifecycle, applyCredit)
03+ (collector workflow references invoices)
added patterns
Invoice generation creates JE atomically (DR AR 1100, CR Revenue 4010) via JournalEntryService
Credit application creates JE atomically (DR Sub Credits 1150, CR AR 1100) via JournalEntryService
Idempotency via @@unique([tenantId, subscriberId, periodStart]) — duplicate = null return
shouldBillToday handles PREPAID month wrapping: lastDayOfCurrentMonth + (billingDay - leadDays)
Dynamic route params pattern: export function GET(req, { params }) wrapping withPermission()(handler)(req)
created modified
prisma/migrations/20260304152900_add_invoice_model/migration.sql
src/lib/services/invoice-service.ts
src/lib/services/credit-service.ts
src/lib/services/billing-service.ts
src/app/api/billing/generate/route.ts
src/app/api/invoices/route.ts
src/app/api/invoices/[id]/route.ts
src/app/api/invoices/[id]/void/route.ts
src/lib/__tests__/billing.test.ts
prisma/schema.prisma (InvoiceStatus enum, Invoice, InvoiceLine models, Subscriber.invoices relation)
src/lib/prisma-tenant.ts (invoice, invoiceLine added to TENANT_SCOPED_MODELS + withTenantContext extensions)
Invoice.amountPaid is a transactional convenience field, NOT a standalone stored balance — always updated atomically with journal entries (mirrors creditBalance pattern from 02-03)
shouldBillToday PREPAID month-wrapping: when leadDay <= 0, actualLeadDay = lastDayOfCurrentMonth + leadDay (negative) — not previous month's last day
generateInvoiceForSubscriber returns null (not error) for duplicates — idempotent by design
CreditService creates its own JE per application (DR Sub Credits 1150, CR AR 1100) — separate from invoice generation JE
voidInvoice throws for PAID invoices (issue credit memo/refund instead) but allows DRAFT/SENT/PARTIAL/OVERDUE
Dynamic route handlers use export function GET/POST pattern (not withPermission HOF directly) to capture Next.js params Promise
Billing service never writes JournalEntry directly — always via JournalEntryService.createEntry()
Credit auto-application called after invoice creation if creditBalance > 0
markOverdueInvoices is a bulk updateMany — designed for daily cron job
Invoice cleanup order in tests: invoiceLines -> invoices -> journalEntryLines -> null reversesEntryId -> journalEntries -> subscribers -> servicePlans -> tenantSettings -> accountingPeriods -> accounts -> users -> tenant
12min 2026-03-04

Phase 2 Plan 4: Billing Engine Summary

Invoice generation billing engine with double-entry JEs (DR AR 1100 / CR Revenue 4010), credit auto-application (DR Sub Credits 1150 / CR AR 1100), prepaid/postpaid timing, overdue detection, and void with JE reversal — 38 integration tests

Performance

  • Duration: 12 min
  • Started: 2026-03-04T15:27:25Z
  • Completed: 2026-03-04T15:39:35Z
  • Tasks: 2
  • Files modified: 11 (2 modified, 9 created)

Accomplishments

  • Invoice and InvoiceLine Prisma models with migration and tenant-scoping in withTenantContext()
  • BillingService generates invoices atomically with double-entry JEs and auto-applies subscriber credit
  • InvoiceService handles CRUD, overdue bulk detection, and void with JE reversal
  • CreditService for reusable credit application (callable from BillingService and future PaymentService)
  • 4 API routes: generate cycle, list, detail, void
  • 38 comprehensive integration tests: all billing scenarios, credit flows, tenant isolation

Task Commits

  1. Task 1: Invoice model + BillingService + InvoiceService - 7cb7a90 (feat)
  2. Task 2: Billing API routes + comprehensive tests - 9025876 (feat)

Plan metadata: (docs commit follows)

Files Created/Modified

  • prisma/schema.prisma - InvoiceStatus enum, Invoice + InvoiceLine models, Subscriber.invoices relation
  • src/lib/prisma-tenant.ts - invoice, invoiceLine added to TENANT_SCOPED_MODELS and withTenantContext() extensions
  • prisma/migrations/20260304152900_add_invoice_model/migration.sql - DB migration
  • src/lib/services/invoice-service.ts - generateInvoiceNumber, getInvoice, listInvoices, updateInvoiceStatus, markOverdueInvoices, voidInvoice
  • src/lib/services/credit-service.ts - applyCredit (subscriber credit -> invoice payment with JE)
  • src/lib/services/billing-service.ts - computeBillingPeriod, shouldBillToday, generateInvoiceForSubscriber, generateMonthlyInvoices
  • src/app/api/billing/generate/route.ts - POST endpoint to trigger billing cycle
  • src/app/api/invoices/route.ts - GET paginated list with filters
  • src/app/api/invoices/[id]/route.ts - GET invoice detail with lines
  • src/app/api/invoices/[id]/void/route.ts - POST void with JE reversal
  • src/lib/__tests__/billing.test.ts - 38 integration tests

Decisions Made

  • Invoice.amountPaid transactional convenience field: mirrors creditBalance pattern from 02-03 — always updated atomically with JEs, never updated standalone
  • shouldBillToday PREPAID month-wrapping bug fix: initial implementation used previous month's last day incorrectly. Correct: lastDayOfCurrentMonth + (billingDay - leadDays) where leadDays is the negative offset
  • generateInvoiceForSubscriber returns null for duplicates: idempotent by design — generateMonthlyInvoices tracks these in the skipped array
  • Separate CreditService: applyCredit extracted to credit-service.ts for reuse by upcoming PaymentService (02-05) when overpayments create credits
  • voidInvoice rejects PAID invoices: fully paid invoices require credit memo/refund path, not void — prevents balance sheet errors
  • Dynamic route handler pattern: export function GET(req, { params }) wrapper around withPermission() — required to capture Next.js 15 params Promise for [id] routes

Deviations from Plan

Auto-fixed Issues

1. [Rule 1 - Bug] Fixed PREPAID month-wrapping logic in shouldBillToday()

  • Found during: Task 2 (billing.test.ts test failure on month-wrapping case)
  • Issue: Initial implementation computed prevMonthLastDay = new Date(Date.UTC(targetYear, targetMonth, 0)).getUTCDate() which gives the last day of the month BEFORE the targetDate's month, not the last day of the targetDate's month
  • Fix: Changed to lastDayOfCurrentMonth = new Date(Date.UTC(targetYear, targetMonth + 1, 0)).getUTCDate() — this gives the last day of targetDate's current month, which is the month before the billing month when spilling occurs
  • Files modified: src/lib/services/billing-service.ts
  • Verification: Test "PREPAID: month wrapping — billingDay=5, leadDays=7 bills on last day of prev month" passes; 38/38 tests pass
  • Committed in: 9025876 (Task 2 commit)

Total deviations: 1 auto-fixed (Rule 1 - Bug) Impact on plan: Critical correctness fix — wrong month-wrapping would cause PREPAID subscribers near month boundaries to miss billing. No scope creep.

Issues Encountered

None — all other tasks executed as specified.

User Setup Required

None - no external service configuration required.

Next Phase Readiness

  • Invoice model exists — PaymentService (02-05) can now record payments against invoices
  • applyCredit in credit-service.ts is ready for reuse by PaymentService for overpayment handling
  • markOverdueInvoices() ready to be called from a daily cron job (Phase 3 or later)
  • CASL "Invoice" subject is used in API routes — CASL abilities need to include Invoice permissions (see 01-04 RBAC setup, currently these routes will work for ADMIN role)

Phase: 02-subscriber-and-billing-core Completed: 2026-03-04