From df4a467a383baa178ee9f1ae794b96124fd36943 Mon Sep 17 00:00:00 2001 From: kevin-asprec Date: Wed, 4 Mar 2026 23:41:50 +0800 Subject: [PATCH] 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 --- .planning/STATE.md | 30 ++-- .../02-04-SUMMARY.md | 154 ++++++++++++++++++ 2 files changed, 172 insertions(+), 12 deletions(-) create mode 100644 .planning/phases/02-subscriber-and-billing-core/02-04-SUMMARY.md diff --git a/.planning/STATE.md b/.planning/STATE.md index d64297d..632c7c2 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -10,29 +10,29 @@ See: .planning/PROJECT.md (updated 2026-03-04) ## Current Position Phase: 2 of 5 (Subscriber and Billing Core) — In progress -Plan: 4 of 5 in phase 2 complete (9/20 total) -Status: In progress. 02-01 (COA), 02-02 (JournalEntryService), 02-03 (Subscriber) complete. Now ready for 02-04 (BillingEngine). -Last activity: 2026-03-04 — Completed 02-02-PLAN.md (JournalEntryService, 36 new tests, 198 total) +Plan: 5 of 5 in phase 2 remaining (10/20 total complete) +Status: In progress. 02-01 (COA), 02-02 (JournalEntryService), 02-03 (Subscriber), 02-04 (BillingEngine) complete. Ready for 02-05 (PaymentService). +Last activity: 2026-03-04 — Completed 02-04-PLAN.md (BillingEngine, 38 tests) -Progress: [████████░░] 45% (9/20 plans across all phases) +Progress: [█████████░] 50% (10/20 plans across all phases) ## Performance Metrics **Velocity:** -- Total plans completed: 7 -- Average duration: 8.7 min -- Total execution time: 64 min +- Total plans completed: 8 +- Average duration: 9.5 min +- Total execution time: 76 min **By Phase:** | Phase | Plans | Total | Avg/Plan | |-------|-------|-------|----------| | 01-foundation | 5/5 complete | 41 min | 8.2 min | -| 02-subscriber-and-billing-core | 4/5 complete | 37 min | 9.25 min | +| 02-subscriber-and-billing-core | 4/5 complete | 49 min | 12.25 min | **Recent Trend:** -- Last 7 plans: 01-02 (8 min), 01-03 (9 min), 01-04 (7 min), 01-05 (6 min), 02-01 (7 min), 02-03 (7 min), 02-02 (16 min) -- Trend: slightly elevated (02-02 was complex — schema + service + API + 36 tests) +- Last 8 plans: 01-02 (8 min), 01-03 (9 min), 01-04 (7 min), 01-05 (6 min), 02-01 (7 min), 02-03 (7 min), 02-02 (16 min), 02-04 (12 min) +- Trend: stable (02-04 was complex — schema + 3 services + 4 API routes + 38 tests) *Updated after each plan completion* @@ -85,6 +85,11 @@ Recent decisions affecting current work: - [02-03]: billingDay capped at 28 — subscribers signing up on days 29-31 get billingDay=28 to avoid month-length invoice generation issues - [02-03]: CANCELLED -> ACTIVE transition is reversible by design — ISPs frequently reinstate cancelled accounts per CONTEXT.md - [02-03]: as any cast in service create() calls — Prisma static type requires tenantId but withTenantContext() extension injects at runtime; cast is intentional +- [02-04]: Invoice.amountPaid is transactional convenience field, NOT standalone stored balance — always updated atomically with JEs (same pattern as creditBalance from 02-03) +- [02-04]: shouldBillToday PREPAID month-wrapping: actualLeadDay = lastDayOfCurrentMonth + (billingDay - leadDays) — uses current month's last day, not previous month's +- [02-04]: generateInvoiceForSubscriber returns null (not error) for duplicates — idempotent by design; generateMonthlyInvoices tracks in skipped array +- [02-04]: CreditService is standalone module — applyCredit() callable from BillingService (auto-apply) and PaymentService (02-05 overpayment) +- [02-04]: Dynamic route handlers pattern: export function GET/POST(req, { params }) wrapping withPermission()(handler)(req) — required for Next.js 15 Promise params in [id] routes ### Pending Todos @@ -97,9 +102,10 @@ None. - [01-04 note]: CASL condition types for string subjects use any cast — upgrade to class-based subjects when Phase 2 Prisma models (Subscriber, Invoice, etc.) are defined - [01-05 note]: /admin/tenants/[id] detail page is a stub link ("View") — detail view not implemented yet (out of Phase 1 scope) - [02-02 note]: JournalEntry afterAll cleanup needs explicit order (lines → null reversesEntryId → entries → tenant) — document for any future test that involves journal entries +- [02-04 note]: Invoice cleanup order in tests: invoiceLines → invoices → journalEntryLines → null reversesEntryId → journalEntries → subscribers → servicePlans → tenantSettings → accountingPeriods → accounts → users → tenant ## Session Continuity -Last session: 2026-03-04T15:23:19Z -Stopped at: Completed 02-02-PLAN.md (JournalEntryService + 36 new tests, 198 total) +Last session: 2026-03-04T15:39:35Z +Stopped at: Completed 02-04-PLAN.md (BillingEngine + 38 tests, Invoice/InvoiceLine models) Resume file: None diff --git a/.planning/phases/02-subscriber-and-billing-core/02-04-SUMMARY.md b/.planning/phases/02-subscriber-and-billing-core/02-04-SUMMARY.md new file mode 100644 index 0000000..a374f9d --- /dev/null +++ b/.planning/phases/02-subscriber-and-billing-core/02-04-SUMMARY.md @@ -0,0 +1,154 @@ +--- +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*