| 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 |