Billing Service — Application Logic
Status: populated Owner: TBD Last updated: 2026-04-17 Companion: Service Template
1. Use cases (commands)
| Use case | Actor | Command | Emits | Notes |
|---|---|---|---|---|
CaptureCharge | billing clerk, system (event) | CaptureChargeCommand | billing.charge.captured.v1 | Resolves price, computes tax, writes ledger entry |
ReverseCharge | supervisor | ReverseChargeCommand | billing.charge.reversed.v1 | New reversing ledger entry; original immutable |
CreateDraftInvoice | billing clerk | CreateDraftInvoiceCommand | billing.invoice.drafted.v1 | Bundles open charges into an invoice |
IssueInvoice | billing clerk | IssueInvoiceCommand | billing.invoice.issued.v1 | Freezes lines, computes totals, finalises tax |
VoidInvoice | supervisor | VoidInvoiceCommand | billing.invoice.voided.v1 | Emits reversing adjustments |
PostPayment | cashier, remittance handler | PostPaymentCommand (requires Idempotency-Key) | billing.payment.posted.v1, optionally billing.invoice.paid.v1 | Auto-allocates to oldest outstanding invoice unless allocations[] provided |
RequestRefund | cashier | RequestRefundCommand | billing.refund.requested.v1 | May auto-approve if under threshold |
ApproveRefund | supervisor | ApproveRefundCommand | billing.refund.approved.v1 | Requires billing:refund:approve scope |
RejectRefund | supervisor | RejectRefundCommand | billing.refund.rejected.v1 | Audit reason |
PostRefund | system (post-approve) | PostRefundCommand | billing.refund.issued.v1 | Writes reversing ledger entry + optional gateway call |
ApplyAdjustment | biller, system (remittance) | ApplyAdjustmentCommand | billing.adjustment.applied.v1 | Reason code required |
StartStatementRun | billing admin | StartStatementRunCommand | billing.statement.started.v1 | Async job, returns run_id |
PublishPriceList | tenant admin | PublishPriceListCommand | billing.price_list.published.v1 | Validates no overlapping effective windows |
SuspendAccount | supervisor | SuspendAccountCommand | billing.account.suspended.v1 | Blocks new charge capture |
ExportGeneralLedger | finance admin | ExportGLCommand | — | Batch export to CSV/JSON to object store |
2. Use cases (queries)
| Query | Projection | Notes |
|---|---|---|
GetAccountByPatient | AccountView (balance, aging) | Pages by tenant |
ListInvoices | Page of InvoiceSummary | Filter by account_id, status, date_from, date_to |
GetInvoice | InvoiceDetail (+ lines + payments) | — |
ListPayments | Page of PaymentSummary | — |
GetStatementRun | StatementRunDetail | — |
GetLedgerForAccount | Paginated ledger | Audit + ops |
GetAgingReport | Aging buckets per facility | Materialised; refreshed nightly |
GetRevenueByFacility | Aggregated revenue KPIs | Date range + facility scope |
3. Ports (application interfaces)
| Port | Purpose |
|---|---|
AccountRepository | Load/save Account, append ledger entries (transactional) |
ChargeRepository | Persist charges, reverse charges |
InvoiceRepository | Persist invoices and line items |
PaymentRepository | Persist payments + allocations; idempotency-key lookup |
RefundRepository | Persist refund lifecycle |
AdjustmentRepository | Persist adjustments |
PriceListRepository | Lookup effective price per (facility_id, code, service_date, currency) |
TaxPolicy | Compute tax for a charge given facility + effective date |
CodeValidator | Validate charge code + modifiers against terminology-service |
EventPublisher | Publish domain events via outbox → NATS |
EventInbox | Deduplicate incoming events (idempotent consumer) |
IdempotencyStore | Cache idempotency-key → response mapping with TTL |
PaymentGatewayAdapter | Optional — tokenise/charge card; returns payment_ref |
FhirGateway | Upsert ChargeItem, Invoice, Account, PaymentReconciliation in FHIR store |
StatementRenderer | Render statement PDFs (supports RTL Pashto/Dari/Arabic) |
StatementDeliveryAdapter | Deliver statements via print / SMS link / email |
ERPExportAdapter | Emit GL-ready batch files |
LicenseClient | Runtime module licensing check |
4. Orchestration flows
4.1 Charge-from-encounter saga
4.2 Invoice issue + payment
4.3 Remittance posting (consumer)
4.4 Statement run (batch)
5. Saga / outbox pattern
- Outbox table
billing_outboxpersists every emitted event transactionally with the ledger write. A relay process drains to NATS JetStream at-least-once. - Inbox table
billing_inboxstores the CloudEventsidof consumed events for dedup; uniqueness constraint on(source, id)turns the consumer idempotent. - Sagas: Charge-from-encounter, Remittance-posting, Statement-run are all modelled as sagas with the outbox as the commit barrier.
6. Error handling strategy
| Failure class | Response |
|---|---|
| Validation (DTO, code, currency) | 400 with structured error detail |
| Authorization (scope, tenant, licensing) | 403 — ACCESS_DENIED, MODULE_NOT_ACTIVE |
| Not found (account, invoice) | 404 |
| Conflict (ledger immutable, idempotency, state) | 409 |
| Upstream unavailable (terminology, fhir-gateway) | Fail closed for charge code validation (400); degrade gracefully for fhir-gateway (retry + async) |
| Database constraint | Bubble to caller as 500 + observed in Sentry; no retry inside use case |
| Outbox relay backlog | Monitored by billing_outbox_lag metric; alert at > 30s |
7. Transaction boundaries
| Transaction | Scope |
|---|---|
| Charge capture | Insert charges + ledger_entries + outbox row — one SQL transaction |
| Payment post | Insert payments + payment_allocations + ledger_entries + outbox row |
| Invoice issue | Update invoices.status + insert invoice_snapshots + outbox row |
| Refund post | Insert refunds + ledger_entries + outbox row; optional external gateway call outside transaction with compensating write on failure |
| Adjustment | Insert adjustments + ledger_entries + outbox row |
All writes go through the repository layer which wraps drizzle transactions; domain events are published from the domain aggregates and flushed via outbox inside the same transaction.
8. Concurrency
- Accounts use optimistic concurrency with a
versioncolumn; conflicting writes return 409AccountVersionConflict. - Invoice issue uses
SELECT … FOR UPDATEon the invoice row to serialise the freeze step. - Statement runs are partitioned by tenant/facility; no two runs for the same
(tenant, facility, as_of_date)can be queued simultaneously.
9. Authorisation
| Command | Required scope |
|---|---|
CaptureCharge | billing:charge:write |
ReverseCharge | billing:charge:reverse |
IssueInvoice | billing:invoice:issue |
VoidInvoice | billing:invoice:void |
PostPayment | billing:payment:post |
RequestRefund | billing:refund:request |
ApproveRefund | billing:refund:approve |
ApplyAdjustment | billing:adjustment:post |
StartStatementRun | billing:statement:run |
PublishPriceList | billing:pricelist:manage |