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 adminExportGLCommand—Batch 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