Skip to main content

Billing Service — Application Logic

Status: populated Owner: Platform Engineering + Finance Last updated: 2026-04-18

1. Use Cases

Use caseTriggerNotes
IngestBillingEventUseCaseNATS billing.events consumerIdempotent insert; resolves pricing + cost; updates usage summary
ResolvePricingUseCaseInternal (called by IngestBillingEvent)Redis cache + PG fallback
GenerateMonthlyInvoicesUseCaseMonthly cron (1st of month, 00:05 UTC)Aggregates summaries, renders PDF, stores S3, publishes event
GetUsageQueryUseCaseGET /v1/billing/usageReturns aggregated usage for a given account + date range
ListInvoicesUseCaseGET /v1/billing/invoicesPaginated invoice list for account
DownloadInvoiceUseCaseGET /v1/billing/invoices/{id}/downloadReturns presigned S3 URL (TTL 15 min)
CreatePricingTableUseCasePOST /v1/admin/pricingAdmin creates a pricing rule; invalidates Redis cache
UpdatePricingTableUseCasePATCH /v1/admin/pricing/{id}Sets effectiveTo; creates new row; cache invalidate
DeletePricingTableUseCaseDELETE /v1/admin/pricing/{id}Soft-delete (set effectiveTo = today)
CreateOperatorCostUseCasePOST /v1/admin/operator-costsAdmin creates cost entry
UpdateOperatorCostUseCasePATCH /v1/admin/operator-costs/{id}Versioned update
VoidInvoiceUseCasePOST /v1/admin/invoices/{id}/voidFinance-admin only; appends audit record

2. Ports

PortAdapter
BillingEventRepositoryPrisma (billing.billing_events)
PricingTableRepositoryPrisma (billing.pricing_tables)
OperatorCostRepositoryPrisma (billing.operator_costs)
InvoiceRepositoryPrisma (billing.invoices)
UsageSummaryRepositoryPrisma (billing.usage_summaries)
PricingCacheRedis (billing:pricing:{tier}:{opId}:{dir}, TTL 60s)
ObjectStoreS3-compatible SDK (presigned PUT + GET)
TemplateRendererHandlebars + PDF generator (e.g. puppeteer/headless Chrome or WeasyPrint)
EventPublisherNATS JetStream
EventConsumerNATS 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}