Skip to main content

Billing Service — API Contracts

Status: populated Owner: TBD Last updated: 2026-04-17 Companion: Service Template · API conventions · FHIR-first · Error codes

1. Conventions

  • Base path (edge): /api/v1/billing/… via Kong (strip_path=false).
  • Auth: Bearer JWT issued by identity-service; tid (active tenant) claim required on every request. Cross-tenant access returns 403.
  • Scope claims: billing:*:* claims enforced at controller guard.
  • Headers: X-Request-Id propagated; Idempotency-Key required on POST /payments, POST /refunds, POST /adjustments.
  • Money encoding: { "currency": "AFN", "minor_units": 25000 } — never floats.
  • Pagination: ?page=1&pageSize=50 (max 100); response envelope { data: [...], total, page, pageSize }.
  • FHIR parity: ChargeItem, Account, Invoice, PaymentNotice, PaymentReconciliation served via interop-service at /fhir/R4/…; this service is upstream.

2. Resource summary

ResourcePaths
ChargesPOST /charges, GET /charges, GET /charges/:id, POST /charges/:id/reverse
AccountsGET /accounts, GET /accounts/:id, GET /accounts/:id/ledger, GET /accounts/:id/aging, POST /accounts/:id/suspend
InvoicesPOST /invoices, GET /invoices, GET /invoices/:id, POST /invoices/:id/issue, POST /invoices/:id/void
PaymentsPOST /payments, GET /payments, GET /payments/:id, POST /payments/:id/reverse
RefundsPOST /refunds, GET /refunds/:id, POST /refunds/:id/approve, POST /refunds/:id/reject
AdjustmentsPOST /adjustments, GET /adjustments
StatementsPOST /statements/runs, GET /statements/runs, GET /statements/runs/:id, GET /statements/:statementId/pdf
Price listsPOST /price-lists, GET /price-lists, POST /price-lists/:id/publish, POST /price-lists/:id/retire
ExportsPOST /exports/gl, GET /exports/:id
OperationalGET /healthz, GET /readyz, GET /metrics

3. Charges

POST /charges

Captures one or more charges for a patient encounter. FR-BILL-001, FR-BILL-002.

Headers: Authorization, X-Request-Id. No Idempotency-Key required (domain is already keyed to encounter+order).

Request:

{
"patientId": "pat_01J0...",
"encounterId": "enc_01J0...",
"facilityId": "fac_01J0...",
"serviceDate": "2026-04-10",
"items": [
{
"code": { "system": "CPT", "code": "99213" },
"modifiers": [{ "system": "CPT-MOD", "code": "25" }],
"units": 1,
"overrideUnitPrice": null,
"taxInclusive": false
}
]
}

Response 201:

{
"chargeBatchId": "chb_01J0...",
"accountId": "acc_01J0...",
"charges": [
{ "id": "chr_01J0...", "ledgerEntryId": "led_01J0...", "amount": { "currency": "AFN", "minor_units": 250000 } }
],
"capturedAt": "2026-04-10T08:12:00Z"
}

Errors: VALIDATION_FAILED (400), MODULE_NOT_ACTIVE (403), CHARGE_CODE_UNKNOWN (400), PRICE_NOT_FOUND (404), CROSS_TENANT_REFERENCE (403).

GET /charges?patientId=&encounterId=&status=&from=&to=

Paginated list. Response wraps ChargeSummaryDto[].

GET /charges/:id

Response 200: ChargeDetailDto including ledger entry reference.

POST /charges/:id/reverse

Body: { "reason": "CODING_CORRECTION" }. Emits billing.charge.reversed.v1. Errors: LEDGER_IMMUTABLE (for double reverse).

4. Accounts

GET /accounts?patientId=

Returns one or more accounts (one per currency). Includes current balance and aging buckets.

Response 200:

{
"data": [{
"id": "acc_01J0...",
"patientId": "pat_01J0...",
"tenantId": "ten_01J0...",
"currency": "AFN",
"status": "active",
"balance": { "currency": "AFN", "minor_units": 250000 },
"aging": {
"0-30": { "currency": "AFN", "minor_units": 250000 },
"31-60": { "currency": "AFN", "minor_units": 0 },
"61-90": { "currency": "AFN", "minor_units": 0 },
"91-120": { "currency": "AFN", "minor_units": 0 },
"121+": { "currency": "AFN", "minor_units": 0 }
}
}]
}

Errors: ACCOUNT_NOT_FOUND (404).

GET /accounts/:id/ledger?from=&to=&page=

Paginated ledger entries (append-only).

GET /accounts/:id/aging

Returns computed aging on demand.

POST /accounts/:id/suspend

Body: { "reason": "..." }. Emits billing.account.suspended.v1.

5. Invoices

POST /invoices

Builds a draft invoice from unbilled charges.

{ "accountId": "acc_01J0...", "encounterId": "enc_01J0...", "chargeIds": ["chr_01J0...", "chr_01J0..."] }

Response 201: { "invoiceId": "inv_01J0...", "status": "draft" }.

POST /invoices/:id/issue

Freezes lines, computes totals, applies tax policy snapshot. Emits billing.invoice.issued.v1. Response 200: InvoiceDetailDto. Errors: INVOICE_ALREADY_ISSUED (409), INVOICE_EMPTY (400).

POST /invoices/:id/void

Requires billing:invoice:void. Body: { "reason": "..." }. Produces reversing ledger entries.

GET /invoices/:id

Full detail (header + lines + payments applied + tax breakdown + PDF URL if issued).

GET /invoices?status=&from=&to=&accountId=

Paginated search. Wraps InvoiceSummaryDto[].

6. Payments

POST /payments

Headers: Idempotency-Key: <uuid> required. FR-BILL-005.

{
"accountId": "acc_01J0...",
"amount": { "currency": "AFN", "minor_units": 250000 },
"method": "CASH",
"reference": "RCPT-2026-00042",
"allocations": [
{ "invoiceId": "inv_01J0...", "amount": { "currency": "AFN", "minor_units": 250000 } }
]
}

method: CASH | CARD | BANK_TRANSFER | MOBILE_MONEY | PAYER_REMITTANCE | CHECK. Response 201: { "paymentId": "pay_01J0...", "postedAt": "2026-04-10T08:15:00Z" }. Errors: IDEMPOTENCY_CONFLICT (409), MONEY_CURRENCY_MISMATCH (400), ACCOUNT_NOT_FOUND (404), OVERPAYMENT_NOT_ALLOWED (400).

GET /payments?accountId=&from=&to=

Paginated list.

POST /payments/:id/reverse

Body { "reason": "BANK_CHARGEBACK" }. Produces reversing ledger entry; original payment remains posted + reversed=true.

7. Refunds

POST /refunds

Headers: Idempotency-Key required.

{
"originalPaymentId": "pay_01J0...",
"amount": { "currency": "AFN", "minor_units": 100000 },
"reason": "SERVICE_NOT_RENDERED"
}

Response 201 (auto-approved under threshold): { "refundId": "rfd_01J0...", "status": "posted" }. Response 201 (above threshold): { "refundId": "rfd_01J0...", "status": "pending_approval" }. Errors: REFUND_EXCEEDS_PAYMENT (400), REFUND_REQUIRES_APPROVAL (403 when caller lacks scope).

POST /refunds/:id/approve

Scope billing:refund:approve. Moves to approved → auto-posts.

POST /refunds/:id/reject

Body { "reason": "..." }. Terminal.

8. Adjustments

POST /adjustments

Headers: Idempotency-Key required.

{
"accountId": "acc_01J0...",
"amount": { "currency": "AFN", "minor_units": -50000 },
"reason": "CONTRACTUAL",
"relatedInvoiceId": "inv_01J0...",
"claimId": "clm_01J0..."
}

Response 201: { "adjustmentId": "adj_01J0..." }.

9. Statements

POST /statements/runs

{ "facilityId": "fac_01J0...", "asOfDate": "2026-04-30", "language": "ps", "delivery": ["PRINT","SMS"] }

Response 202: { "runId": "srun_01J0...", "status": "queued" }.

GET /statements/runs/:id

Response 200:

{ "runId": "srun_01J0...", "status": "completed", "generated": 482, "failed": 1, "completedAt": "2026-05-01T03:14:00Z" }

GET /statements/:statementId/pdf

Returns application/pdf. Tenant-scoped object storage fetch.

10. Price lists

POST /price-lists

Creates a draft price list.

POST /price-lists/:id/publish

Validates no overlapping effective windows; publishes.

POST /price-lists/:id/retire

Moves to retired.

11. Exports

POST /exports/gl

Body: { "from": "2026-04-01", "to": "2026-04-30", "format": "CSV" }. Returns exportId (async).

GET /exports/:id

Returns status + signed URL for download when completed.

12. Error code table

CodeHTTPCondition
VALIDATION_FAILED400DTO / field-level validation
MONEY_CURRENCY_MISMATCH400Mixed currencies
CHARGE_CODE_UNKNOWN400Code not in terminology
PRICE_NOT_FOUND404No effective price row
ACCOUNT_NOT_FOUND404Missing account
INVOICE_NOT_FOUND404Missing invoice
INVOICE_ALREADY_ISSUED409Attempted edit after issue
INVOICE_EMPTY400Issue with no lines
LEDGER_IMMUTABLE409Edit / double reverse attempt
IDEMPOTENCY_CONFLICT409Same key, different body
REFUND_EXCEEDS_PAYMENT400Refund > original payment
REFUND_REQUIRES_APPROVAL403Caller lacks approve scope
ACCESS_DENIED403Missing scope / cross-tenant
CROSS_TENANT_REFERENCE403Referenced aggregate belongs to other tenant
MODULE_NOT_ACTIVE403Tenant unlicensed for billing
OVERPAYMENT_NOT_ALLOWED400Payment > outstanding balance w/o flag
RATE_LIMITED429Rate limit hit
INTERNAL500Unexpected

13. FR / FHIR mapping

Internal endpointFHIR equivalentFRLegacy ref
POST /chargesPOST /fhir/R4/ChargeItemFR-BILL-001, FR-BILL-002FR-BILL-001, FR-BILL-002
GET /accountsGET /fhir/R4/Account?patient=Patient/{id}FR-BILL-003FR-BILL-003
POST /invoices / POST /invoices/:id/issuePOST/PUT /fhir/R4/InvoiceFR-BILL-004FR-BILL-004
POST /paymentsPOST /fhir/R4/PaymentNotice + PaymentReconciliationFR-BILL-005FR-BILL-005
POST /adjustmentsPOST /fhir/R4/PaymentReconciliation (adjust line)FR-BILL-005FR-BILL-005
POST /exports/gl— (internal export)FR-BILL-006FR-BILL-006
Price list + payer configFHIR ChargeItemDefinition (future)FR-BILL-007, FR-BILL-008FR-BILL-007, FR-BILL-008
Currency in MoneyMoney datatypeFR-BILL-009FR-BILL-009
Tax lines on InvoiceInvoice.totalPriceComponentFR-BILL-010FR-BILL-010

14. FR register (restatement)

FRRequirementLegacy ref
FR-BILL-001Capture charges linked to patient, encounter, provider, facility, service dateFR-BILL-001
FR-BILL-002Coding + modifiers per configured standardsFR-BILL-002
FR-BILL-003Patient account balances + aging bucketsFR-BILL-003
FR-BILL-004Generate invoices + statementsFR-BILL-004
FR-BILL-005Payments / refunds / adjustments with reasonsFR-BILL-005
FR-BILL-006GL export for ERP integrationFR-BILL-006
FR-BILL-007Mixed payer models (self-pay, corporate, private, public)FR-BILL-007
FR-BILL-008Facility-scoped price lists with effective datingFR-BILL-008
FR-BILL-009Multi-currency (AFN, AED, USD) per tenant defaultFR-BILL-009
FR-BILL-010VAT / tax rules per country and facilityFR-BILL-010
FR-BILL-011Idempotent payment posting(derived from NFR-BILL-001)
FR-BILL-012Refund approval workflow by threshold(derived from BR-BILL-002)
FR-BILL-013Module licensing gateMODULE_SHARED_STANDARDS §1
FR-BILL-014RTL statement rendering (Pashto/Dari/Arabic)NFR-BILL-003

15. NFRs

NFRTarget
NFR-BILL-001POST /payments p95 ≤ 2 s
NFR-BILL-002Statement run: 10 000 accounts / 15 min
NFR-BILL-003RTL + locale-correct formatting
NFR-BILL-004Availability 99.9 % monthly
NFR-BILL-005Outbox lag p95 ≤ 10 s