Skip to main content

Billing Service — Application Logic

Status: populated Owner: TBD Last updated: 2026-04-17 Companion: Service Template

1. Use cases (commands)

Use caseActorCommandEmitsNotes
CaptureChargebilling clerk, system (event)CaptureChargeCommandbilling.charge.captured.v1Resolves price, computes tax, writes ledger entry
ReverseChargesupervisorReverseChargeCommandbilling.charge.reversed.v1New reversing ledger entry; original immutable
CreateDraftInvoicebilling clerkCreateDraftInvoiceCommandbilling.invoice.drafted.v1Bundles open charges into an invoice
IssueInvoicebilling clerkIssueInvoiceCommandbilling.invoice.issued.v1Freezes lines, computes totals, finalises tax
VoidInvoicesupervisorVoidInvoiceCommandbilling.invoice.voided.v1Emits reversing adjustments
PostPaymentcashier, remittance handlerPostPaymentCommand (requires Idempotency-Key)billing.payment.posted.v1, optionally billing.invoice.paid.v1Auto-allocates to oldest outstanding invoice unless allocations[] provided
RequestRefundcashierRequestRefundCommandbilling.refund.requested.v1May auto-approve if under threshold
ApproveRefundsupervisorApproveRefundCommandbilling.refund.approved.v1Requires billing:refund:approve scope
RejectRefundsupervisorRejectRefundCommandbilling.refund.rejected.v1Audit reason
PostRefundsystem (post-approve)PostRefundCommandbilling.refund.issued.v1Writes reversing ledger entry + optional gateway call
ApplyAdjustmentbiller, system (remittance)ApplyAdjustmentCommandbilling.adjustment.applied.v1Reason code required
StartStatementRunbilling adminStartStatementRunCommandbilling.statement.started.v1Async job, returns run_id
PublishPriceListtenant adminPublishPriceListCommandbilling.price_list.published.v1Validates no overlapping effective windows
SuspendAccountsupervisorSuspendAccountCommandbilling.account.suspended.v1Blocks new charge capture
ExportGeneralLedgerfinance adminExportGLCommandBatch export to CSV/JSON to object store

2. Use cases (queries)

QueryProjectionNotes
GetAccountByPatientAccountView (balance, aging)Pages by tenant
ListInvoicesPage of InvoiceSummaryFilter by account_id, status, date_from, date_to
GetInvoiceInvoiceDetail (+ lines + payments)
ListPaymentsPage of PaymentSummary
GetStatementRunStatementRunDetail
GetLedgerForAccountPaginated ledgerAudit + ops
GetAgingReportAging buckets per facilityMaterialised; refreshed nightly
GetRevenueByFacilityAggregated revenue KPIsDate range + facility scope

3. Ports (application interfaces)

PortPurpose
AccountRepositoryLoad/save Account, append ledger entries (transactional)
ChargeRepositoryPersist charges, reverse charges
InvoiceRepositoryPersist invoices and line items
PaymentRepositoryPersist payments + allocations; idempotency-key lookup
RefundRepositoryPersist refund lifecycle
AdjustmentRepositoryPersist adjustments
PriceListRepositoryLookup effective price per (facility_id, code, service_date, currency)
TaxPolicyCompute tax for a charge given facility + effective date
CodeValidatorValidate charge code + modifiers against terminology-service
EventPublisherPublish domain events via outbox → NATS
EventInboxDeduplicate incoming events (idempotent consumer)
IdempotencyStoreCache idempotency-key → response mapping with TTL
PaymentGatewayAdapterOptional — tokenise/charge card; returns payment_ref
FhirGatewayUpsert ChargeItem, Invoice, Account, PaymentReconciliation in FHIR store
StatementRendererRender statement PDFs (supports RTL Pashto/Dari/Arabic)
StatementDeliveryAdapterDeliver statements via print / SMS link / email
ERPExportAdapterEmit GL-ready batch files
LicenseClientRuntime 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_outbox persists every emitted event transactionally with the ledger write. A relay process drains to NATS JetStream at-least-once.
  • Inbox table billing_inbox stores the CloudEvents id of 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 classResponse
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 constraintBubble to caller as 500 + observed in Sentry; no retry inside use case
Outbox relay backlogMonitored by billing_outbox_lag metric; alert at > 30s

7. Transaction boundaries

TransactionScope
Charge captureInsert charges + ledger_entries + outbox row — one SQL transaction
Payment postInsert payments + payment_allocations + ledger_entries + outbox row
Invoice issueUpdate invoices.status + insert invoice_snapshots + outbox row
Refund postInsert refunds + ledger_entries + outbox row; optional external gateway call outside transaction with compensating write on failure
AdjustmentInsert 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 version column; conflicting writes return 409 AccountVersionConflict.
  • Invoice issue uses SELECT … FOR UPDATE on 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

CommandRequired scope
CaptureChargebilling:charge:write
ReverseChargebilling:charge:reverse
IssueInvoicebilling:invoice:issue
VoidInvoicebilling:invoice:void
PostPaymentbilling:payment:post
RequestRefundbilling:refund:request
ApproveRefundbilling:refund:approve
ApplyAdjustmentbilling:adjustment:post
StartStatementRunbilling:statement:run
PublishPriceListbilling:pricelist:manage