Billing Service — User Stories
Service: billing-service Story prefix: BILL-US Last updated: 2026-04-17 Companion: EPICS · Service Template · API_CONTRACTS
Stories
BILL-US-001 — Capture charges from encounter events
| Field | Value |
|---|---|
| Issue type | Story |
| Summary | System auto-captures charges on encounter/order/dispense events |
| Epic link | BILL-EPIC-01 |
| Status | To Do |
| Priority | Must |
| Story points | 8 |
| Labels | service:billing, type:backend, slice:S2 |
| Components | charge-capture, nats-consumer |
| FR references | FR-BILL-001 |
| Legacy FR refs | FR-BILL-001 |
| Dependencies | cross-service: REG-US-010, ORDERS-US-010 |
User story:
As the billing system, when a registration.encounter.discharged.v1 or orders.service_request.completed.v1 event is received, I want to automatically create and post charges linked to patient, encounter, provider, facility, and service date so that revenue capture happens without manual intervention.
Acceptance criteria (Gherkin):
- Given a
registration.encounter.discharged.v1event with validencounterId,patientId,providerId,facilityId, andserviceDate, when the billing inbox consumer processes it, then aChargewithstatus=postedand a correspondingLedgerEntryexist in the database within 10 s. - Given an event with a
patientIdbelonging to a different tenant, when processed, then the charge is rejected withCROSS_TENANT_REFERENCEand a structured error is logged; no ledger entry is created. - Given the same event is received twice (duplicate CloudEvents
id), when the inbox dedup check runs, then only one charge is captured and the second is silently dropped.
Technical notes:
- NATS subject pattern:
registration.encounter.discharged.v1,scheduling.appointment.completed.v1,orders.service_request.completed.v1,medication.administration.recorded.v1,immunizations.administration.recorded.v1,virtual_care.billing.session_chargeable.v1. - Use
billing_inboxtable for dedup (UNIQUE (source, event_id)). - Outbox pattern: insert
billing_outboxrow transactionally with the ledger entry write.
Definition of Done:
- Unit + integration tests added; coverage ≥ thresholds in
DEFINITION_OF_DONE.md. - OpenAPI contract updated; Pact consumer tests green.
- Event schema registered; schema conformance test green.
- Telemetry spans/metrics added.
- Documentation updated in relevant 17 docs.
BILL-US-002 — Charge coding and modifier storage
| Field | Value |
|---|---|
| Issue type | Story |
| Summary | Charge codes and modifiers validated against terminology, stored on charge |
| Epic link | BILL-EPIC-01 |
| Status | To Do |
| Priority | Must |
| Story points | 5 |
| Labels | service:billing, type:backend, slice:S2 |
| Components | coding, terminology-client |
| FR references | FR-BILL-002 |
| Legacy FR refs | FR-BILL-002 |
| Dependencies | cross-service: TERM-US-001 |
User story:
As a coder, when a charge is captured via POST /charges, I want each item's CPT/HCPCS/ICHI/local code and up to four modifiers validated against terminology-service and stored so that claims and analytics always reference valid, reconciled codes.
Acceptance criteria (Gherkin):
- Given a
POST /chargesrequest withcode.system=CPTandcode.code=99213, when terminology-service confirms the code is valid, then the charge is persisted with the code and modifier round-tripping onGET /charges/:id. - Given a code
99999Xnot in the terminology value set, when the charge is submitted, then the API returns400 CHARGE_CODE_UNKNOWNand no ledger entry is created. - Given terminology-service is temporarily unavailable, when a charge is submitted, then the API returns
400(fail-closed for code validation) and the error is logged withcorrelationId.
Technical notes:
CodeValidatorport callsGET /internal/terminology/validate?system=CPT&code=99213.- Modifiers stored as
JSONBarray on thechargestable. - Timeout: 2 s; fail-closed.
Definition of Done:
- Unit + integration tests added; coverage ≥ thresholds in
DEFINITION_OF_DONE.md. - OpenAPI contract updated; Pact consumer tests green.
- Event schema registered; schema conformance test green.
- Telemetry spans/metrics added.
- Documentation updated in relevant 17 docs.
BILL-US-003 — Patient account balance and aging buckets
| Field | Value |
|---|---|
| Issue type | Story |
| Summary | GET /accounts returns balance and 0-30/31-60/61-90/91-120/121+ aging |
| Epic link | BILL-EPIC-02 |
| Status | To Do |
| Priority | Must |
| Story points | 5 |
| Labels | service:billing, type:api, slice:S2 |
| Components | accounts, ledger, aging |
| FR references | FR-BILL-003 |
| Legacy FR refs | FR-BILL-003 |
| Dependencies | BILL-US-001 |
User story:
As a patient accountant, when reviewing AR for a patient, I want GET /accounts?patientId= to return current balance and aging buckets so that collection priorities are immediately visible without running a separate report.
Acceptance criteria (Gherkin):
- Given an account with charges totalling AFN 1 500 (0-30 days) and AFN 500 (31-60 days), when
GET /accounts/:id/agingis called, then the response shows"0-30": {minor_units: 150000}and"31-60": {minor_units: 50000}and the five buckets sum tobalance. - Given a payment of AFN 1 000 is posted, when aging is retrieved, then the total outstanding reflects the reduction within 1 s of posting.
- Given a cross-tenant
accountId, when a tenant callsGET /accounts/:id, then403 CROSS_TENANT_REFERENCEis returned.
Technical notes:
account_balancesmaterialised view; also recomputable on demand.- RLS enforced on
accountstable.
Definition of Done:
- Unit + integration tests added; coverage ≥ thresholds in
DEFINITION_OF_DONE.md. - OpenAPI contract updated; Pact consumer tests green.
- Event schema registered; schema conformance test green.
- Telemetry spans/metrics added.
- Documentation updated in relevant 17 docs.
BILL-US-004 — Invoice lifecycle: draft, issue, void
| Field | Value |
|---|---|
| Issue type | Story |
| Summary | Invoice draft → issue (freeze lines) → void with reversing entries |
| Epic link | BILL-EPIC-02 |
| Status | To Do |
| Priority | Must |
| Story points | 8 |
| Labels | service:billing, type:api, slice:S2 |
| Components | invoices, ledger |
| FR references | FR-BILL-004 |
| Legacy FR refs | FR-BILL-004 |
| Dependencies | BILL-US-001, BILL-US-010 |
User story: As a billing clerk, when billing a visit, I want to create a draft invoice from open charges, issue it (freezing line items and applying tax), and optionally void it with a reversing entry so that the patient receives an accurate, immutable billing record.
Acceptance criteria (Gherkin):
- Given a draft invoice with two charge lines, when
POST /invoices/:id/issueis called, then response status isissued, all line items have immutable prices, andbilling.invoice.issued.v1is published via outbox. - Given an issued invoice, when
PATCHon any line item is attempted, then409 INVOICE_ALREADY_ISSUEDis returned and the ledger is unchanged. - Given a supervisor with
billing:invoice:voidscope callsPOST /invoices/:id/void, when processed, then reversing ledger entries appear and invoice status transitions tovoided, emittingbilling.invoice.voided.v1.
Technical notes:
SELECT … FOR UPDATEon invoice row during issue to serialise the freeze step.- Tax snapshot applied at issue;
TaxLinerows inserted once. FHIR: Invoiceresource upserted viaFhirGatewayport.
Definition of Done:
- Unit + integration tests added; coverage ≥ thresholds in
DEFINITION_OF_DONE.md. - OpenAPI contract updated; Pact consumer tests green.
- Event schema registered; schema conformance test green.
- Telemetry spans/metrics added.
- Documentation updated in relevant 17 docs.
BILL-US-005 — Post cash/card/mobile-money payment
| Field | Value |
|---|---|
| Issue type | Story |
| Summary | POST /payments with Idempotency-Key, multi-method, invoice allocation |
| Epic link | BILL-EPIC-03 |
| Status | To Do |
| Priority | Must |
| Story points | 8 |
| Labels | service:billing, type:api, slice:S2 |
| Components | payments, ledger, idempotency |
| FR references | FR-BILL-005, FR-BILL-011 |
| Legacy FR refs | FR-BILL-005 |
| Dependencies | BILL-US-004 |
User story:
As a cashier, when collecting payment at the point of care, I want to post a payment via POST /payments with an Idempotency-Key using any supported method (cash, card, mobile money) and allocate it to outstanding invoices so that the patient's account balance is immediately updated and duplicate payments are prevented on network retry.
Acceptance criteria (Gherkin):
- Given a valid
POST /paymentswithIdempotency-Key: uuid1, methodCASH, and allocation to invoiceinv_01, when processed, then a ledger entry is appended, account balance decreases, andbilling.payment.posted.v1is emitted. - Given the same request with
Idempotency-Key: uuid1is retried, when processed, then the originalpaymentIdis returned with201and no duplicate ledger entry is created. - Given the same
Idempotency-Key: uuid1with a differentamount, when submitted, then409 IDEMPOTENCY_CONFLICTis returned. - Given
MOBILE_MONEYmethod for an AFN-currency account, when posted, thenpayment.method=MOBILE_MONEYis stored and the ledger entry reflects AFN minor units.
Technical notes:
IdempotencyStoreport backed bypayments_idempotencytable with(key_hash, tenant_id)unique constraint.- PCI out-of-scope: card tokenisation delegated to
PaymentGatewayAdapterport. - Scope required:
billing:payment:post.
Definition of Done:
- Unit + integration tests added; coverage ≥ thresholds in
DEFINITION_OF_DONE.md. - OpenAPI contract updated; Pact consumer tests green.
- Event schema registered; schema conformance test green.
- Telemetry spans/metrics added.
- Documentation updated in relevant 17 docs.
BILL-US-006 — GL batch export for ERP integration
| Field | Value |
|---|---|
| Issue type | Story |
| Summary | POST /exports/gl triggers async export; signed download URL returned on completion |
| Epic link | BILL-EPIC-06 |
| Status | To Do |
| Priority | Should |
| Story points | 5 |
| Labels | service:billing, type:backend, slice:S4 |
| Components | gl-export, erp-adapter |
| FR references | FR-BILL-006 |
| Legacy FR refs | FR-BILL-006 |
| Dependencies | BILL-US-003, BILL-US-005 |
User story:
As a finance lead, when closing the monthly books, I want to trigger a GL export via POST /exports/gl and download a reconciled, chart-of-accounts-mapped file so that ERP journals can be imported without manual data transformation.
Acceptance criteria (Gherkin):
- Given
POST /exports/glwithfrom=2026-04-01,to=2026-04-30,format=CSV, when processed, then response is202withexportIdandstatus=queued. - Given the job completes, when
GET /exports/:idis polled, thenstatus=completedand adownloadUrl(signed, 1-h TTL) pointing to the tenant-scoped object store is returned. - Given an overlapping date range that returns zero ledger entries, when exported, then response is
completedwith an empty (header-only) CSV and no error.
Technical notes:
ERPExportAdapterport; async via job queue.- Object storage: tenant-scoped bucket; 90-day retention.
- Scope required:
billing:export:gl.
Definition of Done:
- Unit + integration tests added; coverage ≥ thresholds in
DEFINITION_OF_DONE.md. - OpenAPI contract updated; Pact consumer tests green.
- Event schema registered; schema conformance test green.
- Telemetry spans/metrics added.
- Documentation updated in relevant 17 docs.
BILL-US-007 — Mixed payer models (self-pay, corporate, insurer)
| Field | Value |
|---|---|
| Issue type | Story |
| Summary | Charge supports payer-type classification; patient responsibility splits per configured rules |
| Epic link | BILL-EPIC-04 |
| Status | To Do |
| Priority | Must |
| Story points | 5 |
| Labels | service:billing, type:backend, slice:S3 |
| Components | charge-capture, payer-config |
| FR references | FR-BILL-007 |
| Legacy FR refs | FR-BILL-007 |
| Dependencies | BILL-US-001, BILL-US-008 |
User story: As a revenue analyst, when configuring a multi-payer deployment, I want self-pay, corporate, private-insurer, and public-scheme payer types to coexist on the same platform so that each encounter's patient responsibility is split correctly according to the applicable payer contract.
Acceptance criteria (Gherkin):
- Given a charge with
payerType=CORPORATEand a corporate contract that covers 80%, when the invoice is issued, then patient responsibility line = 20% and corporate line = 80%. - Given
payerType=SELF_PAY, when charge is captured, then full amount is attributed to patient responsibility with no split. - Given payer type not in the configured list, when charge is submitted, then
400 VALIDATION_FAILEDwith field error onpayerType.
Technical notes:
- Payer config stored in
payer_contractstable; effective-dated per(tenant_id, payer_id). - Responsibility split computed in
TaxPolicyport extended for payer logic.
Definition of Done:
- Unit + integration tests added; coverage ≥ thresholds in
DEFINITION_OF_DONE.md. - OpenAPI contract updated; Pact consumer tests green.
- Event schema registered; schema conformance test green.
- Telemetry spans/metrics added.
- Documentation updated in relevant 17 docs.
BILL-US-008 — Facility price lists with effective dating
| Field | Value |
|---|---|
| Issue type | Story |
| Summary | Price lists scoped to facility and effective date; no-overlap enforcement |
| Epic link | BILL-EPIC-04 |
| Status | To Do |
| Priority | Must |
| Story points | 5 |
| Labels | service:billing, type:api, slice:S3 |
| Components | price-lists |
| FR references | FR-BILL-008 |
| Legacy FR refs | FR-BILL-008 |
| Dependencies | cross-service: TENANT-US-005 |
User story: As an admin, when managing billing rate catalogs, I want facility-specific price lists with effective date windows so that rates change on a known date without affecting historical charge pricing.
Acceptance criteria (Gherkin):
- Given
POST /price-listscreates a price list andPOST /price-lists/:id/publishpublishes it, whenservice_datefalls within the effective window, thenCaptureChargeresolves the correct unit price. - Given a second price list with an overlapping effective window for the same
(facility_id, code, currency), whenPOST /price-lists/:id/publishis called, then409 PRICE_LIST_OVERLAPis returned and neither list is mutated. - Given no active price entry exists for the combination, when charge is captured, then
404 PRICE_NOT_FOUNDis returned with structured detail.
Technical notes:
PriceListRepositoryport; precedence: facility → tenant → national fallback.PriceEntryeffective windows stored witheffective_from / effective_to TIMESTAMPTZ.
Definition of Done:
- Unit + integration tests added; coverage ≥ thresholds in
DEFINITION_OF_DONE.md. - OpenAPI contract updated; Pact consumer tests green.
- Event schema registered; schema conformance test green.
- Telemetry spans/metrics added.
- Documentation updated in relevant 17 docs.
BILL-US-009 — Multi-currency account defaults (AFN, AED, USD)
| Field | Value |
|---|---|
| Issue type | Story |
| Summary | Tenant default currency; accounts pinned to one currency; cross-currency blocked |
| Epic link | BILL-EPIC-04 |
| Status | To Do |
| Priority | Must |
| Story points | 3 |
| Labels | service:billing, type:backend, slice:S3 |
| Components | accounts, currency |
| FR references | FR-BILL-009 |
| Legacy FR refs | FR-BILL-009 |
| Dependencies | BILL-US-003, cross-service: TENANT-US-003 |
User story: As a regional deployer, when configuring a new tenant, I want a default currency (AFN for Afghanistan, AED for UAE) so that invoices and payment receipts display the correct currency symbol and decimal precision without per-clinic configuration.
Acceptance criteria (Gherkin):
- Given tenant default currency
AFN, when an account is opened, thenaccount.currency=AFNand invoice totals render inAfwith 2 decimal places. - Given an account opened with
currency=AFN, when a payment withcurrency=AEDis submitted, then400 MONEY_CURRENCY_MISMATCHis returned. - Given a tenant with
currency=USD, when invoice PDF is rendered, then currency symbol and decimal precision comply with ISO 4217 USD rules.
Technical notes:
Money = { currency: ISO-4217, minor_units: bigint }— no floats (F-BILL-01).- Tenant currency set in
tenant_service; fetched at account open time.
Definition of Done:
- Unit + integration tests added; coverage ≥ thresholds in
DEFINITION_OF_DONE.md. - OpenAPI contract updated; Pact consumer tests green.
- Event schema registered; schema conformance test green.
- Telemetry spans/metrics added.
- Documentation updated in relevant 17 docs.
BILL-US-010 — VAT/tax rules per facility
| Field | Value |
|---|---|
| Issue type | Story |
| Summary | Tax rules effective-dated per country/facility; invoice shows itemised tax lines |
| Epic link | BILL-EPIC-04 |
| Status | To Do |
| Priority | Must |
| Story points | 5 |
| Labels | service:billing, type:backend, slice:S3 |
| Components | tax, invoices |
| FR references | FR-BILL-010 |
| Legacy FR refs | FR-BILL-010 |
| Dependencies | BILL-US-004, BILL-US-008 |
User story: As a compliance analyst, when auditing tax on invoices, I want VAT/tax rules configurable per country and facility so that each invoice shows an itemised tax breakdown that satisfies the applicable tax authority.
Acceptance criteria (Gherkin):
- Given a facility configured with VAT 10%, when a charge of AFN 1 000 is issued on an invoice, then
TaxLineshowsrate=0.10,amount={minor_units:10000},jurisdiction=AF. - Given no tax rule exists for the facility on the service date, when invoice is issued, then
500 TAX_RULE_MISSINGis returned and the invoice remains indraft. - Given tax rule effective window expires mid-period, when a charge on the new date is captured, then the new rule's rate is applied; old charges retain their snapshotted rate.
Technical notes:
TaxPolicyport backed bytax_rulestable (tenant_id, facility_id, effective_from, effective_to, rate, jurisdiction).- Tax rates snapshotted into
TaxLinerows at invoice issue; not re-calculated on retrieval.
Definition of Done:
- Unit + integration tests added; coverage ≥ thresholds in
DEFINITION_OF_DONE.md. - OpenAPI contract updated; Pact consumer tests green.
- Event schema registered; schema conformance test green.
- Telemetry spans/metrics added.
- Documentation updated in relevant 17 docs.
BILL-US-011 — Module licensing gate
| Field | Value |
|---|---|
| Issue type | Story |
| Summary | Unlicensed tenants receive 403 MODULE_NOT_ACTIVE on all billing routes |
| Epic link | BILL-EPIC-07 |
| Status | To Do |
| Priority | Must |
| Story points | 2 |
| Labels | service:billing, type:backend, slice:S2 |
| Components | licensing, guards |
| FR references | FR-BILL-013 |
| Legacy FR refs | ENH-BILL-004 |
| Dependencies | cross-service: TENANT-US-001 |
User story:
As a platform operator, when a tenant is not licensed for the billing module, I want all billing API calls to return 403 MODULE_NOT_ACTIVE so that billing logic, data, and pricing are fully isolated from unlicensed tenants.
Acceptance criteria (Gherkin):
- Given a tenant without the
billingentitlement, when anyGETorPOSTon/api/v1/billing/*is called, then HTTP status is403withcode=MODULE_NOT_ACTIVE. - Given the same tenant is granted the billing entitlement, when the API is called again, then the request proceeds normally (no cache stale — grace period ≤ 60 s).
- Given the
LicenseClientis unreachable, when a billing route is accessed, then fail-closed:503 SERVICE_UNAVAILABLEis returned; no billing data is served.
Technical notes:
LicenseClientport at Kong plugin level + NestJS guard fallback.- License check cached with 30-s TTL per tenant.
Definition of Done:
- Unit + integration tests added; coverage ≥ thresholds in
DEFINITION_OF_DONE.md. - OpenAPI contract updated; Pact consumer tests green.
- Event schema registered; schema conformance test green.
- Telemetry spans/metrics added.
- Documentation updated in relevant 17 docs.
BILL-US-012 — Reverse a posted charge
| Field | Value |
|---|---|
| Issue type | Story |
| Summary | POST /charges/:id/reverse creates reversing ledger entry; original immutable |
| Epic link | BILL-EPIC-01 |
| Status | To Do |
| Priority | Must |
| Story points | 3 |
| Labels | service:billing, type:api, slice:S2 |
| Components | charge-capture, ledger |
| FR references | FR-BILL-001 |
| Legacy FR refs | FR-BILL-001 |
| Dependencies | BILL-US-001 |
User story:
As a billing supervisor, when a charge is coded incorrectly, I want to reverse a posted charge via POST /charges/:id/reverse with a reason so that the ledger balance is corrected without mutating the original entry.
Acceptance criteria (Gherkin):
- Given a posted charge
chr_01, whenPOST /charges/chr_01/reversewith{ reason: "CODING_CORRECTION" }is called, then a new reversing ledger entry is appended,billing.charge.reversed.v1is emitted, and the original charge'sreversed=trueflag is set. - Given a charge already reversed, when reverse is attempted again, then
409 LEDGER_IMMUTABLEis returned. - Given the actor lacks
billing:charge:reversescope, when attempted, then403 ACCESS_DENIEDis returned.
Technical notes:
- Original
chargesrow is immutable; new reversingchargesrow withtype=REVERSALreferencesoriginal_charge_id. - Both rows written in one transaction.
Definition of Done:
- Unit + integration tests added; coverage ≥ thresholds in
DEFINITION_OF_DONE.md. - OpenAPI contract updated; Pact consumer tests green.
- Event schema registered; schema conformance test green.
- Telemetry spans/metrics added.
- Documentation updated in relevant 17 docs.
BILL-US-013 — Charge capture via manual entry
| Field | Value |
|---|---|
| Issue type | Story |
| Summary | POST /charges allows manual charge entry by billing clerk with encounter context |
| Epic link | BILL-EPIC-01 |
| Status | To Do |
| Priority | Should |
| Story points | 3 |
| Labels | service:billing, type:api, slice:S2 |
| Components | charge-capture |
| FR references | FR-BILL-001, FR-BILL-002 |
| Legacy FR refs | FR-BILL-001, FR-BILL-002 |
| Dependencies | BILL-US-002 |
User story:
As a billing clerk, when an event-driven charge was not automatically captured, I want to manually submit a charge via POST /charges with the encounter and item details so that no billable service is missed.
Acceptance criteria (Gherkin):
- Given a valid
POST /chargesbody withpatientId,encounterId,facilityId,serviceDate, and one item with a valid code, when submitted, then a posted charge and ledger entry exist, andbilling.charge.captured.v1is emitted. - Given
overrideUnitPriceis supplied, when processed, then the override is used instead of the price list, and the charge record notesprice_override=true. - Given missing
facilityId, when submitted, then400 VALIDATION_FAILEDwith field errorfacilityId required.
Technical notes:
- Reuses
CaptureChargeuse case (same code path as event-driven). - Scope:
billing:charge:write.
Definition of Done:
- Unit + integration tests added; coverage ≥ thresholds in
DEFINITION_OF_DONE.md. - OpenAPI contract updated; Pact consumer tests green.
- Event schema registered; schema conformance test green.
- Telemetry spans/metrics added.
- Documentation updated in relevant 17 docs.
BILL-US-014 — Statement run scheduling and PDF delivery
| Field | Value |
|---|---|
| Issue type | Story |
| Summary | POST /statements/runs triggers async batch; statements delivered via print/SMS/email |
| Epic link | BILL-EPIC-02 |
| Status | To Do |
| Priority | Must |
| Story points | 8 |
| Labels | service:billing, type:backend, slice:S3 |
| Components | statements, renderer, delivery |
| FR references | FR-BILL-004, FR-BILL-014 |
| Legacy FR refs | FR-BILL-004 |
| Dependencies | BILL-US-004, cross-service: COMMS-US-010 |
User story: As a billing admin, when the monthly billing cycle is due, I want to trigger a statement run for a facility that renders PDFs in the patient's preferred language and delivers them via print, SMS-link, or email so that patients receive clear, localised balance notifications.
Acceptance criteria (Gherkin):
- Given
POST /statements/runswithfacilityId,asOfDate,language=ps,delivery=["PRINT","SMS"], when processed, then response is202withrunIdandstatus=queued. - Given the run completes, when
GET /statements/runs/:idis polled, thenstatus=completedandgeneratedcount matches account rows processed. - Given a statement in Pashto (
ps), when PDF is rendered, then text is RTL-correct perStatementRendererport andGET /statements/:id/pdfreturnsapplication/pdfwith valid content.
Technical notes:
StatementRendererport supports RTL (Pashto, Dari, Arabic) per NFR-BILL-003.StatementDeliveryAdapterroutes per delivery method;billing.statement.generated.v1triggers communication-service consumer.- Capacity target: 10 000 accounts / 15 min (NFR-BILL-002).
Definition of Done:
- Unit + integration tests added; coverage ≥ thresholds in
DEFINITION_OF_DONE.md. - OpenAPI contract updated; Pact consumer tests green.
- Event schema registered; schema conformance test green.
- Telemetry spans/metrics added.
- Documentation updated in relevant 17 docs.
BILL-US-015 — Suspend patient account
| Field | Value |
|---|---|
| Issue type | Story |
| Summary | POST /accounts/:id/suspend blocks new charge capture; emits account.suspended |
| Epic link | BILL-EPIC-02 |
| Status | To Do |
| Priority | Should |
| Story points | 2 |
| Labels | service:billing, type:api, slice:S3 |
| Components | accounts |
| FR references | FR-BILL-003 |
| Legacy FR refs | FR-BILL-003 |
| Dependencies | BILL-US-003 |
User story: As a billing supervisor, when a patient's account has a dispute or hardship agreement, I want to suspend the account so that no new charges are automatically posted to it until the issue is resolved.
Acceptance criteria (Gherkin):
- Given an active account, when
POST /accounts/:id/suspendwith{ reason: "DISPUTE" }is called, then account status becomessuspendedandbilling.account.suspended.v1is emitted. - Given a suspended account, when a charge capture event arrives for that patient, then the charge is not posted (rejected with a structured log entry).
- Given the actor lacks
billing:account:suspendscope, when attempted, then403 ACCESS_DENIED.
Technical notes:
SuspendAccountuse case; scopebilling:account:suspend.- Charge capture pipeline checks account status before posting.
Definition of Done:
- Unit + integration tests added; coverage ≥ thresholds in
DEFINITION_OF_DONE.md. - OpenAPI contract updated; Pact consumer tests green.
- Event schema registered; schema conformance test green.
- Telemetry spans/metrics added.
- Documentation updated in relevant 17 docs.
BILL-US-016 — Refund request and approval workflow
| Field | Value |
|---|---|
| Issue type | Story |
| Summary | POST /refunds → auto-approve below threshold; above threshold requires supervisor approval |
| Epic link | BILL-EPIC-03 |
| Status | To Do |
| Priority | Must |
| Story points | 8 |
| Labels | service:billing, type:api, slice:S3 |
| Components | refunds, ledger |
| FR references | FR-BILL-005, FR-BILL-012 |
| Legacy FR refs | FR-BILL-005 |
| Dependencies | BILL-US-005 |
User story: As a cashier, when a patient is owed a refund, I want to submit a refund request with the original payment reference so that small refunds are processed immediately and large refunds are escalated for supervisor approval, maintaining financial controls.
Acceptance criteria (Gherkin):
- Given a refund amount ≤ tenant threshold, when
POST /refundswith validoriginalPaymentIdis called withIdempotency-Key, then response showsstatus=postedand a reversing ledger entry exists. - Given a refund amount > tenant threshold, when submitted by a cashier (no
billing:refund:approvescope), then response showsstatus=pending_approvalandbilling.refund.requested.v1is emitted. - Given a supervisor calls
POST /refunds/:id/approve, when processed, thenstatus=approved → postedandbilling.refund.approved.v1+billing.refund.issued.v1are emitted. - Given a refund amount > original payment minus prior refunds, when submitted, then
400 REFUND_EXCEEDS_PAYMENT.
Technical notes:
RefundRepositorystores lifecycle; threshold read from tenant config.PostRefundCommandcallsPaymentGatewayAdapterfor card-originated payments (outside transaction, compensating write on failure).
Definition of Done:
- Unit + integration tests added; coverage ≥ thresholds in
DEFINITION_OF_DONE.md. - OpenAPI contract updated; Pact consumer tests green.
- Event schema registered; schema conformance test green.
- Telemetry spans/metrics added.
- Documentation updated in relevant 17 docs.
BILL-US-017 — Apply billing adjustment with reason code
| Field | Value |
|---|---|
| Issue type | Story |
| Summary | POST /adjustments posts write-off/contractual/courtesy adjustments with tenant reason codes |
| Epic link | BILL-EPIC-03 |
| Status | To Do |
| Priority | Must |
| Story points | 3 |
| Labels | service:billing, type:api, slice:S2 |
| Components | adjustments, ledger |
| FR references | FR-BILL-005 |
| Legacy FR refs | FR-BILL-005 |
| Dependencies | BILL-US-003 |
User story: As a biller, when a contractual or courtesy discount must be applied, I want to post an adjustment with a configured reason code so that the account balance is corrected and the type of adjustment is auditable.
Acceptance criteria (Gherkin):
- Given
POST /adjustmentswithreason=CONTRACTUAL, a validaccountId, and a negativeamount, when processed, then a ledger entry withtype=ADJUSTMENTandadjustment_reason=CONTRACTUALis appended andbilling.adjustment.applied.v1is emitted. - Given a reason code not in the tenant's configured list, when submitted, then
400 VALIDATION_FAILEDwithreason: unknown. - Given
Idempotency-Keyis not supplied, when submitted, then400 VALIDATION_FAILEDwith message about required header.
Technical notes:
AdjustmentRepository; reason codes fromtenant_adjustment_reasonsconfig table.- Contractual adjustments from remittance auto-supply
claim_id.
Definition of Done:
- Unit + integration tests added; coverage ≥ thresholds in
DEFINITION_OF_DONE.md. - OpenAPI contract updated; Pact consumer tests green.
- Event schema registered; schema conformance test green.
- Telemetry spans/metrics added.
- Documentation updated in relevant 17 docs.
BILL-US-018 — Reverse a posted payment (chargeback/bank reversal)
| Field | Value |
|---|---|
| Issue type | Story |
| Summary | POST /payments/:id/reverse handles bank chargeback; original payment remains with reversed flag |
| Epic link | BILL-EPIC-03 |
| Status | To Do |
| Priority | Should |
| Story points | 3 |
| Labels | service:billing, type:api, slice:S3 |
| Components | payments, ledger |
| FR references | FR-BILL-005 |
| Legacy FR refs | FR-BILL-005 |
| Dependencies | BILL-US-005 |
User story: As a billing supervisor, when a bank notifies us of a chargeback, I want to reverse the posted payment so that the patient's account balance is restored and the event is permanently auditable.
Acceptance criteria (Gherkin):
- Given a posted payment, when
POST /payments/:id/reversewith{ reason: "BANK_CHARGEBACK" }is called, then a reversing ledger entry is appended, account balance increases by the payment amount, andbilling.payment.reversed.v1is emitted. - Given the original payment is already reversed, when reverse is attempted again, then
409 LEDGER_IMMUTABLE. - Given the actor lacks
billing:payment:reversescope, when attempted, then403 ACCESS_DENIED.
Technical notes:
- Original
paymentsrow hasreversed=trueflag set; new reversing row is inserted. - Scope:
billing:payment:reverse.
Definition of Done:
- Unit + integration tests added; coverage ≥ thresholds in
DEFINITION_OF_DONE.md. - OpenAPI contract updated; Pact consumer tests green.
- Event schema registered; schema conformance test green.
- Telemetry spans/metrics added.
- Documentation updated in relevant 17 docs.
BILL-US-019 — Auto-post payer remittance payments
| Field | Value |
|---|---|
| Issue type | Story |
| Summary | claims.remittance.posted.v1 consumer auto-posts payer payment and contractual adjustment |
| Epic link | BILL-EPIC-05 |
| Status | To Do |
| Priority | Must |
| Story points | 5 |
| Labels | service:billing, type:backend, slice:S3 |
| Components | remittance, inbox, adjustments |
| FR references | FR-BILL-005, FR-BILL-007 |
| Legacy FR refs | FR-BILL-005, FR-BILL-007 |
| Dependencies | BILL-US-005, BILL-US-017, cross-service: CLAIMS-US-020 |
User story: As the billing system, when claims-service publishes a remittance event, I want to automatically post the payer payment and contractual adjustment so that AR is updated in near-real time without manual keying.
Acceptance criteria (Gherkin):
- Given
claims.remittance.posted.v1withclaimId,payerAmount, andcontractualAdjustmentarrives, when matched to an outstanding invoice viaclaim_id, thenPostPayment(PAYER_REMITTANCE)andApplyAdjustment(CONTRACTUAL)are both committed in one transaction and events emitted. - Given
claimIddoes not match any invoice, when processed, then a warning log entry is created withremittance_idand the unmatched amount is surfaced in the ops dashboard metricbilling_unmatched_remittance_total. - Given the same remittance event is received twice, when processed, then inbox dedup prevents double-posting.
Technical notes:
billing_inboxdedup on(source=claims, event_id).- Outbox emits
billing.payment.posted.v1andbilling.adjustment.applied.v1.
Definition of Done:
- Unit + integration tests added; coverage ≥ thresholds in
DEFINITION_OF_DONE.md. - OpenAPI contract updated; Pact consumer tests green.
- Event schema registered; schema conformance test green.
- Telemetry spans/metrics added.
- Documentation updated in relevant 17 docs.
BILL-US-020 — Unmatched remittance surfaced in operations dashboard
| Field | Value |
|---|---|
| Issue type | Story |
| Summary | Unmatched remittance items logged as ops metric and queued for manual review |
| Epic link | BILL-EPIC-05 |
| Status | To Do |
| Priority | Should |
| Story points | 2 |
| Labels | service:billing, type:backend, slice:S3 |
| Components | remittance, observability |
| FR references | FR-BILL-007 |
| Legacy FR refs | FR-BILL-007 |
| Dependencies | BILL-US-019 |
User story: As a billing manager, when a payer remittance cannot be matched to an invoice, I want the discrepancy flagged in the operations dashboard so that AR staff can investigate and resolve manually.
Acceptance criteria (Gherkin):
- Given an unmatched remittance item, when processed, then
billing_unmatched_remittance_totalcounter increments withtenant_idandclaim_idlabels. - Given an alert threshold of 5 unmatched items in 1 h is exceeded, when the alert fires, then a P3 ticket is created via the alerting runbook.
Technical notes:
- Metric emitted via OpenTelemetry; Grafana panel on BILLING — Revenue ops dashboard.
Definition of Done:
- Unit + integration tests added; coverage ≥ thresholds in
DEFINITION_OF_DONE.md. - OpenAPI contract updated; Pact consumer tests green.
- Event schema registered; schema conformance test green.
- Telemetry spans/metrics added.
- Documentation updated in relevant 17 docs.
BILL-US-021 — Publish and retire price list
| Field | Value |
|---|---|
| Issue type | Story |
| Summary | POST /price-lists/:id/publish validates windows; POST /price-lists/:id/retire moves to retired |
| Epic link | BILL-EPIC-06 |
| Status | To Do |
| Priority | Should |
| Story points | 2 |
| Labels | service:billing, type:api, slice:S3 |
| Components | price-lists |
| FR references | FR-BILL-008 |
| Legacy FR refs | FR-BILL-008 |
| Dependencies | BILL-US-008 |
User story: As a billing admin, when a price list's effective period ends, I want to retire it so that future charges do not use superseded rates and historical charges retain their original pricing.
Acceptance criteria (Gherkin):
- Given a published price list, when
POST /price-lists/:id/retireis called, thenstatus=retiredand no future charge uses this list. - Given a retired price list, when
POST /price-lists/:id/publishis attempted, then409 PRICE_LIST_RETIRED.
Technical notes:
PriceListstate:draft → published → retired.PublishPriceListCommandemitsbilling.price_list.published.v1.
Definition of Done:
- Unit + integration tests added; coverage ≥ thresholds in
DEFINITION_OF_DONE.md. - OpenAPI contract updated; Pact consumer tests green.
- Event schema registered; schema conformance test green.
- Telemetry spans/metrics added.
- Documentation updated in relevant 17 docs.
BILL-US-022 — Outbox lag SLO and alert
| Field | Value |
|---|---|
| Issue type | Story |
| Summary | billing_outbox_lag metric; alert if p95 > 10 s; relay health tracked |
| Epic link | BILL-EPIC-07 |
| Status | To Do |
| Priority | Must |
| Story points | 3 |
| Labels | service:billing, type:backend, slice:S2 |
| Components | outbox, observability |
| FR references | FR-BILL-011 |
| Legacy FR refs | ENH-BILL-002 |
| Dependencies | BILL-US-001 |
User story: As an SRE, when monitoring billing pipeline health, I want the outbox relay lag tracked and alerted so that delayed events are caught before they cause revenue-impacting stale AR balances.
Acceptance criteria (Gherkin):
- Given the outbox relay is healthy, when metrics are scraped, then
billing_outbox_lag_p95_msis ≤ 10 000. - Given the relay stalls for > 30 s, when the alert evaluates, then a P2 page fires and
billing_outbox_stalledgauge is1.
Technical notes:
- Metric:
billing_outbox_lag_mshistogram; labelstatus=pending. - Alert in Grafana alerting with runbook
runbooks/billing/outbox-stalled.md.
Definition of Done:
- Unit + integration tests added; coverage ≥ thresholds in
DEFINITION_OF_DONE.md. - OpenAPI contract updated; Pact consumer tests green.
- Event schema registered; schema conformance test green.
- Telemetry spans/metrics added.
- Documentation updated in relevant 17 docs.
BILL-US-023 — Idempotency conflict returns deterministic 409
| Field | Value |
|---|---|
| Issue type | Story |
| Summary | Same Idempotency-Key with different payload returns 409 IDEMPOTENCY_CONFLICT |
| Epic link | BILL-EPIC-07 |
| Status | To Do |
| Priority | Must |
| Story points | 2 |
| Labels | service:billing, type:backend, slice:S2 |
| Components | idempotency |
| FR references | FR-BILL-011 |
| Legacy FR refs | ENH-BILL-001 |
| Dependencies | BILL-US-005 |
User story:
As a payment integration developer, when I accidentally reuse an idempotency key with different payment details, I want a deterministic 409 IDEMPOTENCY_CONFLICT response so that my integration can detect and alert on unexpected key reuse without silently accepting the wrong transaction.
Acceptance criteria (Gherkin):
- Given
Idempotency-Key: key1previously used for payment A, when a second request withkey1and differentamountis submitted, then409 IDEMPOTENCY_CONFLICTis returned withoriginalPaymentIdin the error detail. - Given the key is reused with an identical body (legitimate retry), when processed, then
201with the originalpaymentIdand no new ledger entry.
Technical notes:
- SHA-256 of request body stored alongside idempotency key in
payments_idempotency. - TTL: 24 h.
Definition of Done:
- Unit + integration tests added; coverage ≥ thresholds in
DEFINITION_OF_DONE.md. - OpenAPI contract updated; Pact consumer tests green.
- Event schema registered; schema conformance test green.
- Telemetry spans/metrics added.
- Documentation updated in relevant 17 docs.
BILL-US-024 — FHIR ChargeItem and Account write-through
| Field | Value |
|---|---|
| Issue type | Story |
| Summary | On charge capture and account mutation, upsert ChargeItem / Account FHIR resources |
| Epic link | BILL-EPIC-08 |
| Status | To Do |
| Priority | Should |
| Story points | 5 |
| Labels | service:billing, type:backend, slice:S3 |
| Components | fhir-gateway |
| FR references | FR-BILL-001, FR-BILL-003 |
| Legacy FR refs | FR-BILL-001, FR-BILL-003 |
| Dependencies | BILL-US-001, BILL-US-003, cross-service: INTEROP-US-005 |
User story:
As an interoperability engineer, when charges and accounts are mutated in billing, I want corresponding FHIR ChargeItem and Account resources updated in the FHIR store via interop-service so that external payer and HIE integrations always see current data.
Acceptance criteria (Gherkin):
- Given a charge is posted, when the
FhirGatewayport is called, then aChargeItemwith matchingcode,subject,quantity, andpriceOverrideexists in the FHIR store. - Given the FHIR gateway call fails transiently, when the outbox relay retries, then the write eventually succeeds and no data loss occurs.
Technical notes:
FhirGatewayport is an async adapter; failures do not block the primary charge capture transaction.- FHIR resource IDs:
ChargeItem/<charge_id>,Account/<account_id>.
Definition of Done:
- Unit + integration tests added; coverage ≥ thresholds in
DEFINITION_OF_DONE.md. - OpenAPI contract updated; Pact consumer tests green.
- Event schema registered; schema conformance test green.
- Telemetry spans/metrics added.
- Documentation updated in relevant 17 docs.
BILL-US-025 — FHIR Invoice and PaymentReconciliation write-through
| Field | Value |
|---|---|
| Issue type | Story |
| Summary | On invoice issue and payment post, upsert Invoice / PaymentReconciliation FHIR resources |
| Epic link | BILL-EPIC-08 |
| Status | To Do |
| Priority | Should |
| Story points | 3 |
| Labels | service:billing, type:backend, slice:S3 |
| Components | fhir-gateway |
| FR references | FR-BILL-004, FR-BILL-005 |
| Legacy FR refs | FR-BILL-004, FR-BILL-005 |
| Dependencies | BILL-US-004, BILL-US-005, cross-service: INTEROP-US-005 |
User story:
As an interoperability engineer, when invoices are issued and payments are posted, I want FHIR Invoice and PaymentReconciliation resources to be upserted in the FHIR store so that payer systems and national HIEs can retrieve accurate billing summaries.
Acceptance criteria (Gherkin):
- Given an invoice moves to
issued, whenFhirGateway.upsertInvoice()is called, then the FHIRInvoiceresource hasstatus=active, matchingtotalNet,totalGross, andlineItemarray. - Given a payment is posted, when
FhirGateway.upsertPaymentReconciliation()is called, then the FHIR resource references the correctInvoiceandPaymentNotice.
Technical notes:
- Async adapter; failures surfaced via
billing_fhir_upsert_error_totalmetric. - FHIR resource IDs:
Invoice/<invoice_id>,PaymentReconciliation/<payment_id>.
Definition of Done:
- Unit + integration tests added; coverage ≥ thresholds in
DEFINITION_OF_DONE.md. - OpenAPI contract updated; Pact consumer tests green.
- Event schema registered; schema conformance test green.
- Telemetry spans/metrics added.
- Documentation updated in relevant 17 docs.