Phase 02: Subscriber and Billing Core - 5 plans in 4 waves - Wave 1: 02-01 (COA), 02-03 (Subscribers) parallel - Wave 2: 02-02 (Journal Entry Service) - Wave 3: 02-04 (Billing Engine) - Wave 4: 02-05 (Payment Tracker) - Ready for execution Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
12 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | must_haves | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 02-subscriber-and-billing-core | 04 | execute | 3 |
|
|
true |
|
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.
<execution_context> @C:\Users\KevinAsprec.claude/get-shit-done/workflows/execute-plan.md @C:\Users\KevinAsprec.claude/get-shit-done/templates/summary.md </execution_context>
@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/02-subscriber-and-billing-core/02-CONTEXT.md @.planning/phases/02-subscriber-and-billing-core/02-02-SUMMARY.md @.planning/phases/02-subscriber-and-billing-core/02-03-SUMMARY.md @prisma/schema.prisma @src/lib/accounting/journal-entry-service.ts @src/lib/accounting/chart-of-accounts.ts @src/lib/services/subscriber-service.ts @src/lib/prisma-tenant.ts Task 1: Invoice model + BillingService + InvoiceService prisma/schema.prisma src/lib/services/billing-service.ts src/lib/services/invoice-service.ts src/lib/prisma-tenant.ts prisma/migrations/*_add_invoice_model/migration.sql 1. Add enums to prisma/schema.prisma: - `InvoiceStatus`: DRAFT, SENT, PARTIAL, PAID, OVERDUE, VOID-
Add
Invoicemodel:- id (uuid), tenantId (String), invoiceNumber (String — auto-generated per tenant, e.g., "INV-2026-0001"), subscriberId (String, relation to Subscriber), periodStart (DateTime — billing period start), periodEnd (DateTime — billing period end), dueDate (DateTime), subtotal (Decimal, precision 10 scale 2), totalAmount (Decimal, precision 10 scale 2), amountPaid (Decimal, precision 10 scale 2, default 0), status (InvoiceStatus, default DRAFT), journalEntryId (String? — links to the JE created on generation), issuedAt (DateTime?), paidAt (DateTime?), voidedAt (DateTime?), createdAt, updatedAt
- @@unique([tenantId, invoiceNumber])
- @@unique([tenantId, subscriberId, periodStart]) — prevent duplicate invoices for same subscriber+period
- @@index([tenantId]), @@index([tenantId, status]), @@index([tenantId, subscriberId]), @@index([tenantId, dueDate])
-
Add
InvoiceLinemodel:- 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])
-
Update TENANT_SCOPED_MODELS in prisma-tenant.ts to include "invoice" and "invoiceLine". Add query extensions.
-
Create src/lib/services/invoice-service.ts:
generateInvoiceNumber(tenantPrisma, year)— sequential per tenant+year, format "INV-{YYYY}-{NNNN}"getInvoice(tenantPrisma, invoiceId)— with subscriber, lines, journalEntry includedlistInvoices(tenantPrisma, { subscriberId?, status?, startDate?, endDate?, page?, pageSize? })— paginated, ordered by dueDate descupdateInvoiceStatus(tenantPrisma, invoiceId, status)— updates status field. Used internally by billing and payment services.markOverdueInvoices(tenantPrisma)— find all invoices where status is DRAFT or SENT, dueDate < today. Update status to OVERDUE. Return count updated.voidInvoice(tenantPrisma, invoiceId, voidedById)— set status to VOID, set voidedAt. If journalEntryId exists, call JournalEntryService.reverseEntry to reverse the AR journal entry. Return updated invoice.
-
Create src/lib/services/billing-service.ts:
-
generateInvoiceForSubscriber(tenantPrisma, subscriber, period, createdById):- Calculate periodStart and periodEnd based on subscriber.billingDay
- Check if invoice already exists for this subscriber+periodStart (idempotent — skip if exists)
- Create Invoice with lines (one line: subscription fee from servicePlan.monthlyPrice)
- Set dueDate: for POSTPAID, periodEnd. For PREPAID, periodStart.
- Create journal entry via JournalEntryService.createEntry:
- Debit: Accounts Receivable (1100) for totalAmount
- Credit: Subscription Revenue (4010) for totalAmount
- source: SYSTEM, referenceType: "Invoice", referenceId: invoice.id
- Link journalEntryId to the invoice
- Return created invoice
-
generateMonthlyInvoices(tenantPrisma, targetDate, createdById):- Determine which subscribers need invoices today:
- POSTPAID subscribers where billingDay === targetDate.getDate() and status === ACTIVE
- PREPAID subscribers where (billingDay - tenantSettings.prepaidLeadDays) === targetDate.getDate() and status === ACTIVE (accounting for month wrapping)
- For each eligible subscriber, call generateInvoiceForSubscriber
- Return { generated: number, skipped: number, errors: string[] }
- Must be idempotent: running twice on same day generates nothing new
- Determine which subscribers need invoices today:
-
-
Run
npx prisma migrate dev --name add_invoice_modelnpx prisma migrate status— no pendingnpx prisma generatesucceedsnpx tsc --noEmit— clean Invoice and InvoiceLine models exist. BillingService generates invoices with journal entries. InvoiceService handles CRUD, overdue detection, and void with journal reversal.
a. POST /api/billing/generate — trigger invoice generation for a target date. withPermission("manage", "Invoice"). Accepts { targetDate?: string } (defaults to today). Calls generateMonthlyInvoices. Returns { generated, skipped, errors }. This is the endpoint that BullMQ or a cron job would call (BullMQ integration is a scheduler concern — the API just needs to work when called).
b. GET /api/invoices — list invoices. withPermission("read", "Invoice"). Accepts ?subscriberId=&status=&startDate=&endDate=&page=&pageSize=. Returns paginated results.
c. GET /api/invoices/[id] — get invoice detail with lines. withPermission("read", "Invoice"). Returns invoice with subscriber, lines, journalEntry.
d. POST /api/invoices/[id]/void — void an invoice. withPermission("manage", "Invoice"). Calls voidInvoice. Returns updated invoice.
-
Write comprehensive tests in src/lib/tests/billing.test.ts:
Invoice generation tests:
- Generate invoice for postpaid subscriber: creates invoice with correct period, dueDate = periodEnd
- Generate invoice for prepaid subscriber: creates invoice with dueDate = periodStart
- Invoice has correct amount from servicePlan.monthlyPrice
- Invoice has one InvoiceLine matching plan price
- Journal entry created: debit AR, credit Revenue, amounts match invoice
- Journal entry is balanced (debits = credits)
- Duplicate generation for same subscriber+period is skipped (idempotent)
Billing cycle tests:
- generateMonthlyInvoices generates for all eligible subscribers on their billing day
- Subscribers with different billing days are not included
- Suspended/cancelled subscribers are not billed
- Prepaid subscribers get invoiced prepaidLeadDays before billing day
Invoice status tests:
- markOverdueInvoices: unpaid invoice past dueDate becomes OVERDUE
- markOverdueInvoices: paid invoice past dueDate stays PAID
- voidInvoice: sets status to VOID and reverses journal entry
Invoice numbering:
- Sequential per tenant: INV-2026-0001, INV-2026-0002
- Two tenants have independent numbering
Tenant isolation:
- Invoice from Tenant A not visible to Tenant B
Run: npx vitest run src/lib/__tests__/billing.test.ts
- npx vitest run src/lib/__tests__/billing.test.ts — all tests pass
- npx tsc --noEmit — clean
- POST /api/billing/generate creates invoices for eligible subscribers
- GET /api/invoices returns filtered, paginated invoices
- POST /api/invoices/{id}/void reverses the journal entry
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.
<success_criteria>
- Invoice model with invoiceNumber, period dates, dueDate, amountPaid, status
- generateInvoiceForSubscriber creates invoice + journal entry atomically
- generateMonthlyInvoices handles prepaid/postpaid timing correctly
- Journal entries: debit AR (1100), credit Revenue (4010)
- Idempotent: no duplicate invoices for same subscriber+period
- Overdue detection marks past-due invoices
- Void reverses the associated journal entry
- All tests pass </success_criteria>