--- phase: 02-subscriber-and-billing-core plan: "04" subsystem: billing tags: [prisma, invoice, billing-engine, journal-entry, double-entry, credit, vitest] # Dependency graph requires: - phase: 02-02 provides: JournalEntryService (sole gateway to ledger), account IDs 1100/4010/1150 - phase: 02-03 provides: Subscriber model with creditBalance, billingDay, BillingType, servicePlan relation provides: - 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 affects: - 02-05 (PaymentService needs Invoice.amountPaid, InvoiceStatus lifecycle, applyCredit) - 03+ (collector workflow references invoices) # Tech tracking tech-stack: 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)" key-files: created: - 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 modified: - 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) key-decisions: - "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" patterns-established: - "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" # Metrics duration: 12min completed: 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*