Phase 4 - Billing Ledger and Invoice Generation¶
Goal: Turn usage aggregates into money. Calculate what each tenant owes, generate line-item invoices, and maintain an immutable billing ledger.
Status: ✅ Complete
TLDR¶
Phase 4 is complete. Here's what you built:
- Invoice generation from usage aggregates + subscription pricing
- Overage calculator with micro-cents precision (all integer math, no floats)
- Invoice lifecycle: DRAFT → FINALIZED → PAID / VOID
- Sequential invoice numbers (INV-2026-0001) assigned at finalization only
- Double-entry billing ledger: CHARGE on finalize, PAYMENT on pay, CREDIT on void
- Billing period cron: auto-closes expired periods daily at 00:05 UTC
- Mid-cycle cancellation proration: base fee × (days used / total days)
- Seed data: 3 invoices, 10 line items, 3 ledger entries
What was built¶
Data model¶
Five new tables/objects:
- invoices - one per tenant per billing period. Status lifecycle (DRAFT → FINALIZED → PAID → VOID). Links to subscription. Stores totals, invoice number, due date.
- invoice_line_items - individual charges on an invoice. Base subscription fee, per-feature overage charges. Each line has: description, quantity, unit price (micro-cents), total amount (cents).
- billing_ledger_entries - double-entry accounting. Every monetary event (CHARGE, PAYMENT, CREDIT, REFUND, ADJUSTMENT) creates an immutable, append-only entry.
- invoice_sequences - counter table for sequential invoice numbers. One row per year, atomically incremented.
Invoice generation¶
Two modes, one endpoint. POST /api/v1/invoices/generate auto-detects:
- Active subscription → full-period invoice with base fee + overage line items
- Cancelled subscription → prorated invoice with base fee × (days used / total days) + full usage charges
Line items include:
- Base subscription fee (e.g., "Pro plan - monthly" × 1 = $99.00)
- Per-feature overage (e.g., "API Calls overage (55,000 used, 50,000 included)" × 5,000 = $5.00)
- HARD quota features are excluded (they block at limit, no overage to bill)
- BOOLEAN features are excluded (on/off, nothing to bill)
Overage calculator¶
Pure function, all integer math:
overageUnits = max(0, used - includedAmount)
totalMicroCents = overageUnits × overagePriceMicroCents
totalCents = Math.round(totalMicroCents / 100)
Where: $1.00 = 10,000 micro-cents, $0.01 = 100 micro-cents. Division by 100 converts micro-cents to cents.
Rounding happens once at the final cents conversion. This matches Stripe's approach - accumulate in the smallest unit, round once at the end.
Invoice lifecycle¶
| Transition | Side effects |
|---|---|
| DRAFT → FINALIZED | Assigns INV-YYYY-NNNN, sets due date (+30 days), creates ledger CHARGE |
| FINALIZED → PAID | Creates ledger PAYMENT |
| FINALIZED → VOID | Creates ledger CREDIT (reverses the charge) |
| DRAFT → VOID | No ledger entry (nothing was ever charged) |
Invoice numbers are assigned at FINALIZE, not creation. DRAFT invoices don't get numbers - prevents gaps in the sequence from voided drafts.
Billing ledger¶
Double-entry style, simplified for SaaS billing:
| Entry type | Debit | Credit | When |
|---|---|---|---|
| CHARGE | $99.00 | $0.00 | Invoice finalized |
| PAYMENT | $0.00 | $99.00 | Payment received |
| CREDIT | $0.00 | $99.00 | Invoice voided |
| REFUND | $0.00 | $50.00 | Money returned (Phase 5) |
| ADJUSTMENT | ±amount | ±amount | Manual correction (Phase 6) |
Balance = SUM(debit) - SUM(credit). Positive = tenant owes money.
Entries are immutable and append-only. To correct a mistake, add an ADJUSTMENT - never edit or delete existing entries.
Billing cron¶
BillingCronService runs daily at 00:05 UTC:
- Finds all subscriptions with expired billing periods (
currentPeriodEnd <= NOW()) - Finds all cancelled subscriptions needing prorated invoices
- For each: generate DRAFT → auto-finalize → advance period
- Logs results: "2 invoices generated, 0 failures"
- Each subscription processed independently - one failure doesn't block others
Proration¶
When a tenant cancels mid-cycle:
- Base fee is prorated:
$29.00 × (7 days used / 31 total days) = $6.55 - Usage charges are NOT prorated - billed for actual consumption
- Invoice notes show: "Prorated invoice - cancelled on 2026-05-20 (7/31 days used)"
API endpoints¶
Invoices (tenant-scoped)¶
| Method | Path | Guards | Description |
|---|---|---|---|
| GET | /invoices | JWT + Tenant | List invoices (paginated, filterable by status) |
| GET | /invoices/:id | JWT + Tenant | Get invoice with line items |
| GET | /invoices/:id/line-items | JWT + Tenant | Get line items only |
| POST | /invoices/generate | JWT + Tenant + OWNER/ADMIN | Generate invoice (auto-detects full/prorated) |
| POST | /invoices/:id/finalize | JWT + Tenant + OWNER/ADMIN | DRAFT → FINALIZED |
| POST | /invoices/:id/mark-paid | JWT + Tenant + OWNER/ADMIN | FINALIZED → PAID |
| POST | /invoices/:id/void | JWT + Tenant + OWNER/ADMIN | DRAFT/FINALIZED → VOID |
Billing (tenant-scoped)¶
| Method | Path | Guards | Description |
|---|---|---|---|
| GET | /billing/ledger | JWT + Tenant | List ledger entries (paginated, filterable by type) |
| GET | /billing/balance | JWT + Tenant | Get current balance |
Key decisions¶
| Decision | Why |
|---|---|
| Invoice number at FINALIZE only | No gaps from voided drafts. Legal compliance. |
| Micro-cents (÷100) not (÷10000) | $0.01 = 100 micro-cents. Avoids INT overflow on annual plans. |
| HARD quotas excluded from invoices | They block at limit - no overage exists to bill. |
| Usage NOT prorated on cancellation | Tenant is billed for actual consumption, not estimated. |
| One endpoint auto-detects full/prorated | Frontend doesn't need to maintain state. |
| Ledger entries append-only | Corrections via ADJUSTMENT, not edits. Audit-safe. |
| Daily cron at 00:05 UTC | 5-min buffer for late usage events. Simple, sufficient. |
| Independent subscription processing | One billing failure doesn't block others. |
Seed data¶
| Tenant | Plan | Invoice | Total | Line items |
|---|---|---|---|---|
| Acme | Pro ($99/mo) | INV-2026-0001 | $99.00 | Base fee + 3 usage (no overage) |
| Globex | Starter ($29/mo) | INV-2026-0002 | $29.00 | Base fee + 1 usage (no overage) |
| Stark | Enterprise ($4,788/yr) | INV-2026-0003 | $4,788.00 | Base fee + 3 usage (no overage) |
Gotchas encountered¶
- INT overflow on
unitPriceMicroCents- Enterprise annual base fee ($4,788) at ×10,000 = 4,788,000,000, exceeding Postgres INT max (2,147,483,647). Fixed by using ×100 conversion (cents → micro-cents) instead of ×10,000. - Missing DTOs - rushed the invoices module without response DTOs. Inconsistent with Phase 1-3 modules. Fixed: added InvoiceResponseDto, LedgerEntryResponseDto, BalanceResponseDto.
- Inline error returns - used
return { statusCode: 404 }instead ofthrow new NotFoundException(ERRORS.INVOICE.NOT_FOUND). Bypassed the global exception filter. Fixed all instances. - Overage calculator ÷10,000 bug - micro-cents to cents conversion used ÷10,000 instead of ÷100. Caused 5 GB × $0.02/GB = $0.00 instead of $0.10. Fixed and verified with all 4 test cases.
- Route ordering -
/invoices/:id/line-itemsmust be defined before/invoices/:idor NestJS interprets "line-items" as a UUID.
Limitations carried into next phases¶
- No payment provider integration -
mark-paidis manual. Phase 5 adds Stripe/webhook-based payment confirmation. - No tax calculation -
total = subtotalalways. Tax engine is a future enhancement. - No PDF generation - invoices are JSON only. PDF rendering is a future enhancement.
- No admin endpoints for dead letter retry - dead letter events exist but only queryable via SQL.
- No REFUND or ADJUSTMENT endpoints - enum values exist, no API to create them yet.