Skip to main content

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

FieldValue
Issue typeStory
SummarySystem auto-captures charges on encounter/order/dispense events
Epic linkBILL-EPIC-01
StatusTo Do
PriorityMust
Story points8
Labelsservice:billing, type:backend, slice:S2
Componentscharge-capture, nats-consumer
FR referencesFR-BILL-001
Legacy FR refsFR-BILL-001
Dependenciescross-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.v1 event with valid encounterId, patientId, providerId, facilityId, and serviceDate, when the billing inbox consumer processes it, then a Charge with status=posted and a corresponding LedgerEntry exist in the database within 10 s.
  • Given an event with a patientId belonging to a different tenant, when processed, then the charge is rejected with CROSS_TENANT_REFERENCE and 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_inbox table for dedup (UNIQUE (source, event_id)).
  • Outbox pattern: insert billing_outbox row 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

FieldValue
Issue typeStory
SummaryCharge codes and modifiers validated against terminology, stored on charge
Epic linkBILL-EPIC-01
StatusTo Do
PriorityMust
Story points5
Labelsservice:billing, type:backend, slice:S2
Componentscoding, terminology-client
FR referencesFR-BILL-002
Legacy FR refsFR-BILL-002
Dependenciescross-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 /charges request with code.system=CPT and code.code=99213, when terminology-service confirms the code is valid, then the charge is persisted with the code and modifier round-tripping on GET /charges/:id.
  • Given a code 99999X not in the terminology value set, when the charge is submitted, then the API returns 400 CHARGE_CODE_UNKNOWN and 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 with correlationId.

Technical notes:

  • CodeValidator port calls GET /internal/terminology/validate?system=CPT&code=99213.
  • Modifiers stored as JSONB array on the charges table.
  • 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

FieldValue
Issue typeStory
SummaryGET /accounts returns balance and 0-30/31-60/61-90/91-120/121+ aging
Epic linkBILL-EPIC-02
StatusTo Do
PriorityMust
Story points5
Labelsservice:billing, type:api, slice:S2
Componentsaccounts, ledger, aging
FR referencesFR-BILL-003
Legacy FR refsFR-BILL-003
DependenciesBILL-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/aging is called, then the response shows "0-30": {minor_units: 150000} and "31-60": {minor_units: 50000} and the five buckets sum to balance.
  • 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 calls GET /accounts/:id, then 403 CROSS_TENANT_REFERENCE is returned.

Technical notes:

  • account_balances materialised view; also recomputable on demand.
  • RLS enforced on accounts table.

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

FieldValue
Issue typeStory
SummaryInvoice draft → issue (freeze lines) → void with reversing entries
Epic linkBILL-EPIC-02
StatusTo Do
PriorityMust
Story points8
Labelsservice:billing, type:api, slice:S2
Componentsinvoices, ledger
FR referencesFR-BILL-004
Legacy FR refsFR-BILL-004
DependenciesBILL-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/issue is called, then response status is issued, all line items have immutable prices, and billing.invoice.issued.v1 is published via outbox.
  • Given an issued invoice, when PATCH on any line item is attempted, then 409 INVOICE_ALREADY_ISSUED is returned and the ledger is unchanged.
  • Given a supervisor with billing:invoice:void scope calls POST /invoices/:id/void, when processed, then reversing ledger entries appear and invoice status transitions to voided, emitting billing.invoice.voided.v1.

Technical notes:

  • SELECT … FOR UPDATE on invoice row during issue to serialise the freeze step.
  • Tax snapshot applied at issue; TaxLine rows inserted once.
  • FHIR: Invoice resource upserted via FhirGateway port.

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

FieldValue
Issue typeStory
SummaryPOST /payments with Idempotency-Key, multi-method, invoice allocation
Epic linkBILL-EPIC-03
StatusTo Do
PriorityMust
Story points8
Labelsservice:billing, type:api, slice:S2
Componentspayments, ledger, idempotency
FR referencesFR-BILL-005, FR-BILL-011
Legacy FR refsFR-BILL-005
DependenciesBILL-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 /payments with Idempotency-Key: uuid1, method CASH, and allocation to invoice inv_01, when processed, then a ledger entry is appended, account balance decreases, and billing.payment.posted.v1 is emitted.
  • Given the same request with Idempotency-Key: uuid1 is retried, when processed, then the original paymentId is returned with 201 and no duplicate ledger entry is created.
  • Given the same Idempotency-Key: uuid1 with a different amount, when submitted, then 409 IDEMPOTENCY_CONFLICT is returned.
  • Given MOBILE_MONEY method for an AFN-currency account, when posted, then payment.method=MOBILE_MONEY is stored and the ledger entry reflects AFN minor units.

Technical notes:

  • IdempotencyStore port backed by payments_idempotency table with (key_hash, tenant_id) unique constraint.
  • PCI out-of-scope: card tokenisation delegated to PaymentGatewayAdapter port.
  • 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

FieldValue
Issue typeStory
SummaryPOST /exports/gl triggers async export; signed download URL returned on completion
Epic linkBILL-EPIC-06
StatusTo Do
PriorityShould
Story points5
Labelsservice:billing, type:backend, slice:S4
Componentsgl-export, erp-adapter
FR referencesFR-BILL-006
Legacy FR refsFR-BILL-006
DependenciesBILL-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/gl with from=2026-04-01, to=2026-04-30, format=CSV, when processed, then response is 202 with exportId and status=queued.
  • Given the job completes, when GET /exports/:id is polled, then status=completed and a downloadUrl (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 completed with an empty (header-only) CSV and no error.

Technical notes:

  • ERPExportAdapter port; 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)

FieldValue
Issue typeStory
SummaryCharge supports payer-type classification; patient responsibility splits per configured rules
Epic linkBILL-EPIC-04
StatusTo Do
PriorityMust
Story points5
Labelsservice:billing, type:backend, slice:S3
Componentscharge-capture, payer-config
FR referencesFR-BILL-007
Legacy FR refsFR-BILL-007
DependenciesBILL-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=CORPORATE and 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_FAILED with field error on payerType.

Technical notes:

  • Payer config stored in payer_contracts table; effective-dated per (tenant_id, payer_id).
  • Responsibility split computed in TaxPolicy port 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

FieldValue
Issue typeStory
SummaryPrice lists scoped to facility and effective date; no-overlap enforcement
Epic linkBILL-EPIC-04
StatusTo Do
PriorityMust
Story points5
Labelsservice:billing, type:api, slice:S3
Componentsprice-lists
FR referencesFR-BILL-008
Legacy FR refsFR-BILL-008
Dependenciescross-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-lists creates a price list and POST /price-lists/:id/publish publishes it, when service_date falls within the effective window, then CaptureCharge resolves the correct unit price.
  • Given a second price list with an overlapping effective window for the same (facility_id, code, currency), when POST /price-lists/:id/publish is called, then 409 PRICE_LIST_OVERLAP is returned and neither list is mutated.
  • Given no active price entry exists for the combination, when charge is captured, then 404 PRICE_NOT_FOUND is returned with structured detail.

Technical notes:

  • PriceListRepository port; precedence: facility → tenant → national fallback.
  • PriceEntry effective windows stored with effective_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)

FieldValue
Issue typeStory
SummaryTenant default currency; accounts pinned to one currency; cross-currency blocked
Epic linkBILL-EPIC-04
StatusTo Do
PriorityMust
Story points3
Labelsservice:billing, type:backend, slice:S3
Componentsaccounts, currency
FR referencesFR-BILL-009
Legacy FR refsFR-BILL-009
DependenciesBILL-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, then account.currency=AFN and invoice totals render in Af with 2 decimal places.
  • Given an account opened with currency=AFN, when a payment with currency=AED is submitted, then 400 MONEY_CURRENCY_MISMATCH is 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

FieldValue
Issue typeStory
SummaryTax rules effective-dated per country/facility; invoice shows itemised tax lines
Epic linkBILL-EPIC-04
StatusTo Do
PriorityMust
Story points5
Labelsservice:billing, type:backend, slice:S3
Componentstax, invoices
FR referencesFR-BILL-010
Legacy FR refsFR-BILL-010
DependenciesBILL-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 TaxLine shows rate=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_MISSING is returned and the invoice remains in draft.
  • 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:

  • TaxPolicy port backed by tax_rules table (tenant_id, facility_id, effective_from, effective_to, rate, jurisdiction).
  • Tax rates snapshotted into TaxLine rows 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

FieldValue
Issue typeStory
SummaryUnlicensed tenants receive 403 MODULE_NOT_ACTIVE on all billing routes
Epic linkBILL-EPIC-07
StatusTo Do
PriorityMust
Story points2
Labelsservice:billing, type:backend, slice:S2
Componentslicensing, guards
FR referencesFR-BILL-013
Legacy FR refsENH-BILL-004
Dependenciescross-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 billing entitlement, when any GET or POST on /api/v1/billing/* is called, then HTTP status is 403 with code=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 LicenseClient is unreachable, when a billing route is accessed, then fail-closed: 503 SERVICE_UNAVAILABLE is returned; no billing data is served.

Technical notes:

  • LicenseClient port 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

FieldValue
Issue typeStory
SummaryPOST /charges/:id/reverse creates reversing ledger entry; original immutable
Epic linkBILL-EPIC-01
StatusTo Do
PriorityMust
Story points3
Labelsservice:billing, type:api, slice:S2
Componentscharge-capture, ledger
FR referencesFR-BILL-001
Legacy FR refsFR-BILL-001
DependenciesBILL-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, when POST /charges/chr_01/reverse with { reason: "CODING_CORRECTION" } is called, then a new reversing ledger entry is appended, billing.charge.reversed.v1 is emitted, and the original charge's reversed=true flag is set.
  • Given a charge already reversed, when reverse is attempted again, then 409 LEDGER_IMMUTABLE is returned.
  • Given the actor lacks billing:charge:reverse scope, when attempted, then 403 ACCESS_DENIED is returned.

Technical notes:

  • Original charges row is immutable; new reversing charges row with type=REVERSAL references original_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

FieldValue
Issue typeStory
SummaryPOST /charges allows manual charge entry by billing clerk with encounter context
Epic linkBILL-EPIC-01
StatusTo Do
PriorityShould
Story points3
Labelsservice:billing, type:api, slice:S2
Componentscharge-capture
FR referencesFR-BILL-001, FR-BILL-002
Legacy FR refsFR-BILL-001, FR-BILL-002
DependenciesBILL-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 /charges body with patientId, encounterId, facilityId, serviceDate, and one item with a valid code, when submitted, then a posted charge and ledger entry exist, and billing.charge.captured.v1 is emitted.
  • Given overrideUnitPrice is supplied, when processed, then the override is used instead of the price list, and the charge record notes price_override=true.
  • Given missing facilityId, when submitted, then 400 VALIDATION_FAILED with field error facilityId required.

Technical notes:

  • Reuses CaptureCharge use 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

FieldValue
Issue typeStory
SummaryPOST /statements/runs triggers async batch; statements delivered via print/SMS/email
Epic linkBILL-EPIC-02
StatusTo Do
PriorityMust
Story points8
Labelsservice:billing, type:backend, slice:S3
Componentsstatements, renderer, delivery
FR referencesFR-BILL-004, FR-BILL-014
Legacy FR refsFR-BILL-004
DependenciesBILL-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/runs with facilityId, asOfDate, language=ps, delivery=["PRINT","SMS"], when processed, then response is 202 with runId and status=queued.
  • Given the run completes, when GET /statements/runs/:id is polled, then status=completed and generated count matches account rows processed.
  • Given a statement in Pashto (ps), when PDF is rendered, then text is RTL-correct per StatementRenderer port and GET /statements/:id/pdf returns application/pdf with valid content.

Technical notes:

  • StatementRenderer port supports RTL (Pashto, Dari, Arabic) per NFR-BILL-003.
  • StatementDeliveryAdapter routes per delivery method; billing.statement.generated.v1 triggers 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

FieldValue
Issue typeStory
SummaryPOST /accounts/:id/suspend blocks new charge capture; emits account.suspended
Epic linkBILL-EPIC-02
StatusTo Do
PriorityShould
Story points2
Labelsservice:billing, type:api, slice:S3
Componentsaccounts
FR referencesFR-BILL-003
Legacy FR refsFR-BILL-003
DependenciesBILL-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/suspend with { reason: "DISPUTE" } is called, then account status becomes suspended and billing.account.suspended.v1 is 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:suspend scope, when attempted, then 403 ACCESS_DENIED.

Technical notes:

  • SuspendAccount use case; scope billing: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

FieldValue
Issue typeStory
SummaryPOST /refunds → auto-approve below threshold; above threshold requires supervisor approval
Epic linkBILL-EPIC-03
StatusTo Do
PriorityMust
Story points8
Labelsservice:billing, type:api, slice:S3
Componentsrefunds, ledger
FR referencesFR-BILL-005, FR-BILL-012
Legacy FR refsFR-BILL-005
DependenciesBILL-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 /refunds with valid originalPaymentId is called with Idempotency-Key, then response shows status=posted and a reversing ledger entry exists.
  • Given a refund amount > tenant threshold, when submitted by a cashier (no billing:refund:approve scope), then response shows status=pending_approval and billing.refund.requested.v1 is emitted.
  • Given a supervisor calls POST /refunds/:id/approve, when processed, then status=approved → posted and billing.refund.approved.v1 + billing.refund.issued.v1 are emitted.
  • Given a refund amount > original payment minus prior refunds, when submitted, then 400 REFUND_EXCEEDS_PAYMENT.

Technical notes:

  • RefundRepository stores lifecycle; threshold read from tenant config.
  • PostRefundCommand calls PaymentGatewayAdapter for 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

FieldValue
Issue typeStory
SummaryPOST /adjustments posts write-off/contractual/courtesy adjustments with tenant reason codes
Epic linkBILL-EPIC-03
StatusTo Do
PriorityMust
Story points3
Labelsservice:billing, type:api, slice:S2
Componentsadjustments, ledger
FR referencesFR-BILL-005
Legacy FR refsFR-BILL-005
DependenciesBILL-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 /adjustments with reason=CONTRACTUAL, a valid accountId, and a negative amount, when processed, then a ledger entry with type=ADJUSTMENT and adjustment_reason=CONTRACTUAL is appended and billing.adjustment.applied.v1 is emitted.
  • Given a reason code not in the tenant's configured list, when submitted, then 400 VALIDATION_FAILED with reason: unknown.
  • Given Idempotency-Key is not supplied, when submitted, then 400 VALIDATION_FAILED with message about required header.

Technical notes:

  • AdjustmentRepository; reason codes from tenant_adjustment_reasons config 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)

FieldValue
Issue typeStory
SummaryPOST /payments/:id/reverse handles bank chargeback; original payment remains with reversed flag
Epic linkBILL-EPIC-03
StatusTo Do
PriorityShould
Story points3
Labelsservice:billing, type:api, slice:S3
Componentspayments, ledger
FR referencesFR-BILL-005
Legacy FR refsFR-BILL-005
DependenciesBILL-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/reverse with { reason: "BANK_CHARGEBACK" } is called, then a reversing ledger entry is appended, account balance increases by the payment amount, and billing.payment.reversed.v1 is emitted.
  • Given the original payment is already reversed, when reverse is attempted again, then 409 LEDGER_IMMUTABLE.
  • Given the actor lacks billing:payment:reverse scope, when attempted, then 403 ACCESS_DENIED.

Technical notes:

  • Original payments row has reversed=true flag 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

FieldValue
Issue typeStory
Summaryclaims.remittance.posted.v1 consumer auto-posts payer payment and contractual adjustment
Epic linkBILL-EPIC-05
StatusTo Do
PriorityMust
Story points5
Labelsservice:billing, type:backend, slice:S3
Componentsremittance, inbox, adjustments
FR referencesFR-BILL-005, FR-BILL-007
Legacy FR refsFR-BILL-005, FR-BILL-007
DependenciesBILL-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.v1 with claimId, payerAmount, and contractualAdjustment arrives, when matched to an outstanding invoice via claim_id, then PostPayment(PAYER_REMITTANCE) and ApplyAdjustment(CONTRACTUAL) are both committed in one transaction and events emitted.
  • Given claimId does not match any invoice, when processed, then a warning log entry is created with remittance_id and the unmatched amount is surfaced in the ops dashboard metric billing_unmatched_remittance_total.
  • Given the same remittance event is received twice, when processed, then inbox dedup prevents double-posting.

Technical notes:

  • billing_inbox dedup on (source=claims, event_id).
  • Outbox emits billing.payment.posted.v1 and billing.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

FieldValue
Issue typeStory
SummaryUnmatched remittance items logged as ops metric and queued for manual review
Epic linkBILL-EPIC-05
StatusTo Do
PriorityShould
Story points2
Labelsservice:billing, type:backend, slice:S3
Componentsremittance, observability
FR referencesFR-BILL-007
Legacy FR refsFR-BILL-007
DependenciesBILL-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_total counter increments with tenant_id and claim_id labels.
  • 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

FieldValue
Issue typeStory
SummaryPOST /price-lists/:id/publish validates windows; POST /price-lists/:id/retire moves to retired
Epic linkBILL-EPIC-06
StatusTo Do
PriorityShould
Story points2
Labelsservice:billing, type:api, slice:S3
Componentsprice-lists
FR referencesFR-BILL-008
Legacy FR refsFR-BILL-008
DependenciesBILL-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/retire is called, then status=retired and no future charge uses this list.
  • Given a retired price list, when POST /price-lists/:id/publish is attempted, then 409 PRICE_LIST_RETIRED.

Technical notes:

  • PriceList state: draft → published → retired.
  • PublishPriceListCommand emits billing.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

FieldValue
Issue typeStory
Summarybilling_outbox_lag metric; alert if p95 > 10 s; relay health tracked
Epic linkBILL-EPIC-07
StatusTo Do
PriorityMust
Story points3
Labelsservice:billing, type:backend, slice:S2
Componentsoutbox, observability
FR referencesFR-BILL-011
Legacy FR refsENH-BILL-002
DependenciesBILL-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_ms is ≤ 10 000.
  • Given the relay stalls for > 30 s, when the alert evaluates, then a P2 page fires and billing_outbox_stalled gauge is 1.

Technical notes:

  • Metric: billing_outbox_lag_ms histogram; label status=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

FieldValue
Issue typeStory
SummarySame Idempotency-Key with different payload returns 409 IDEMPOTENCY_CONFLICT
Epic linkBILL-EPIC-07
StatusTo Do
PriorityMust
Story points2
Labelsservice:billing, type:backend, slice:S2
Componentsidempotency
FR referencesFR-BILL-011
Legacy FR refsENH-BILL-001
DependenciesBILL-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: key1 previously used for payment A, when a second request with key1 and different amount is submitted, then 409 IDEMPOTENCY_CONFLICT is returned with originalPaymentId in the error detail.
  • Given the key is reused with an identical body (legitimate retry), when processed, then 201 with the original paymentId and 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

FieldValue
Issue typeStory
SummaryOn charge capture and account mutation, upsert ChargeItem / Account FHIR resources
Epic linkBILL-EPIC-08
StatusTo Do
PriorityShould
Story points5
Labelsservice:billing, type:backend, slice:S3
Componentsfhir-gateway
FR referencesFR-BILL-001, FR-BILL-003
Legacy FR refsFR-BILL-001, FR-BILL-003
DependenciesBILL-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 FhirGateway port is called, then a ChargeItem with matching code, subject, quantity, and priceOverride exists 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:

  • FhirGateway port 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

FieldValue
Issue typeStory
SummaryOn invoice issue and payment post, upsert Invoice / PaymentReconciliation FHIR resources
Epic linkBILL-EPIC-08
StatusTo Do
PriorityShould
Story points3
Labelsservice:billing, type:backend, slice:S3
Componentsfhir-gateway
FR referencesFR-BILL-004, FR-BILL-005
Legacy FR refsFR-BILL-004, FR-BILL-005
DependenciesBILL-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, when FhirGateway.upsertInvoice() is called, then the FHIR Invoice resource has status=active, matching totalNet, totalGross, and lineItem array.
  • Given a payment is posted, when FhirGateway.upsertPaymentReconciliation() is called, then the FHIR resource references the correct Invoice and PaymentNotice.

Technical notes:

  • Async adapter; failures surfaced via billing_fhir_upsert_error_total metric.
  • 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.