Billing Service — Application Logic
Status: populated Owner: Platform Engineering + Finance Last updated: 2026-04-18
1. Use Cases
| Use case | Trigger | Notes |
|---|---|---|
IngestBillingEventUseCase | NATS billing.events consumer | Idempotent insert; resolves pricing + cost; updates usage summary |
ResolvePricingUseCase | Internal (called by IngestBillingEvent) | Redis cache + PG fallback |
GenerateMonthlyInvoicesUseCase | Monthly cron (1st of month, 00:05 UTC) | Aggregates summaries, renders PDF, stores S3, publishes event |
GetUsageQueryUseCase | GET /v1/billing/usage | Returns aggregated usage for a given account + date range |
ListInvoicesUseCase | GET /v1/billing/invoices | Paginated invoice list for account |
DownloadInvoiceUseCase | GET /v1/billing/invoices/{id}/download | Returns presigned S3 URL (TTL 15 min) |
CreatePricingTableUseCase | POST /v1/admin/pricing | Admin creates a pricing rule; invalidates Redis cache |
UpdatePricingTableUseCase | PATCH /v1/admin/pricing/{id} | Sets effectiveTo; creates new row; cache invalidate |
DeletePricingTableUseCase | DELETE /v1/admin/pricing/{id} | Soft-delete (set effectiveTo = today) |
CreateOperatorCostUseCase | POST /v1/admin/operator-costs | Admin creates cost entry |
UpdateOperatorCostUseCase | PATCH /v1/admin/operator-costs/{id} | Versioned update |
VoidInvoiceUseCase | POST /v1/admin/invoices/{id}/void | Finance-admin only; appends audit record |
2. Ports
| Port | Adapter |
|---|---|
BillingEventRepository | Prisma (billing.billing_events) |
PricingTableRepository | Prisma (billing.pricing_tables) |
OperatorCostRepository | Prisma (billing.operator_costs) |
InvoiceRepository | Prisma (billing.invoices) |
UsageSummaryRepository | Prisma (billing.usage_summaries) |
PricingCache | Redis (billing:pricing:{tier}:{opId}:{dir}, TTL 60s) |
ObjectStore | S3-compatible SDK (presigned PUT + GET) |
TemplateRenderer | Handlebars + PDF generator (e.g. puppeteer/headless Chrome or WeasyPrint) |
EventPublisher | NATS JetStream |
EventConsumer | NATS JetStream durable consumer billing-consumer on stream BILLING_EVENTS |
3. IngestBillingEventUseCase — detailed flow
1. Receive billing.message.charged.v1 from NATS
2. Extract messageId — check billing_events for existing row (ON CONFLICT DO NOTHING)
3. If duplicate → ACK, return (idempotent)
4. PricingResolver.resolve(accountTier, operatorId, direction, currency, chargedAt)
a. Check Redis key billing:pricing:{tier}:{opId}:{dir}
b. Cache miss → SELECT pricing_tables WHERE accountTier=? AND operatorId=? AND direction=?
AND effectiveFrom <= chargedAt AND (effectiveTo IS NULL OR effectiveTo > chargedAt)
c. Cache SET EX 60
5. CostResolver.resolve(operatorId, direction, chargedAt) — same pattern, no Redis cache (lower volume)
6. Compute customerPrice: if PER_SEGMENT → unitPrice * segmentCount; FLAT_PER_MESSAGE → unitPrice
7. Compute operatorCost: costPerSegment * segmentCount
8. Compute margin: customerPrice - operatorCost
9. INSERT billing_events (ON CONFLICT (message_id) DO NOTHING)
10. UPSERT usage_summaries (account, operator, date_trunc('hour', chargedAt)) — increment counts + amounts
11. ACK NATS message
Error handling:
- Pricing not found → NAK with delay; alert
BillingPricingNotFound; do not lose event. - PG error → NAK; NATS redelivery; eventual manual remediation.
- Redis error → log warning; fall through to PG; do not fail event processing.
4. GenerateMonthlyInvoicesUseCase — detailed flow
1. Cron fires: compute periodStart = first day of last month, periodEnd = last day of last month
2. SELECT DISTINCT (tenantId, accountId) FROM usage_summaries WHERE bucket_hour BETWEEN periodStart AND periodEnd
3. For each account:
a. SELECT SUM(*) FROM usage_summaries WHERE accountId=? AND bucket_hour BETWEEN ...
b. INSERT invoices (status=DRAFT)
c. Render PDF: load Handlebars template 'invoice_monthly', inject data
d. PUT to S3: key = invoices/{accountId}/{year}/{month}/{invoiceId}.pdf
e. UPDATE invoices SET status=FINALIZED, s3_key=..., generated_at=now()
f. Publish billing.invoice.generated.v1 to NATS
4. Log summary: N invoices generated, M failed
Failure handling:
- PDF render failure → invoice stays
DRAFT; retry next scheduled run or manual trigger. - S3 failure → same as above.
- Individual account failure does NOT block other accounts (per-account try/catch).
5. Pricing Cache Strategy
Key: billing:pricing:{accountTier}:{operatorId}:{direction}
Value: JSON { pricingTableId, pricingModel, unitPrice, currency }
TTL: 60 seconds
Write: on cache miss (read-through)
Invalidate: on CreatePricingTableUseCase / UpdatePricingTableUseCase / DeletePricingTableUseCase
→ DEL billing:pricing:{tier}:{opId}:{dir}