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-Idpropagated;Idempotency-Keyrequired 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,PaymentReconciliationserved viainterop-serviceat/fhir/R4/…; this service is upstream.
2. Resource summary
| Resource | Paths |
|---|---|
| Charges | POST /charges, GET /charges, GET /charges/:id, POST /charges/:id/reverse |
| Accounts | GET /accounts, GET /accounts/:id, GET /accounts/:id/ledger, GET /accounts/:id/aging, POST /accounts/:id/suspend |
| Invoices | POST /invoices, GET /invoices, GET /invoices/:id, POST /invoices/:id/issue, POST /invoices/:id/void |
| Payments | POST /payments, GET /payments, GET /payments/:id, POST /payments/:id/reverse |
| Refunds | POST /refunds, GET /refunds/:id, POST /refunds/:id/approve, POST /refunds/:id/reject |
| Adjustments | POST /adjustments, GET /adjustments |
| Statements | POST /statements/runs, GET /statements/runs, GET /statements/runs/:id, GET /statements/:statementId/pdf |
| Price lists | POST /price-lists, GET /price-lists, POST /price-lists/:id/publish, POST /price-lists/:id/retire |
| Exports | POST /exports/gl, GET /exports/:id |
| Operational | GET /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
| Code | HTTP | Condition |
|---|---|---|
VALIDATION_FAILED | 400 | DTO / field-level validation |
MONEY_CURRENCY_MISMATCH | 400 | Mixed currencies |
CHARGE_CODE_UNKNOWN | 400 | Code not in terminology |
PRICE_NOT_FOUND | 404 | No effective price row |
ACCOUNT_NOT_FOUND | 404 | Missing account |
INVOICE_NOT_FOUND | 404 | Missing invoice |
INVOICE_ALREADY_ISSUED | 409 | Attempted edit after issue |
INVOICE_EMPTY | 400 | Issue with no lines |
LEDGER_IMMUTABLE | 409 | Edit / double reverse attempt |
IDEMPOTENCY_CONFLICT | 409 | Same key, different body |
REFUND_EXCEEDS_PAYMENT | 400 | Refund > original payment |
REFUND_REQUIRES_APPROVAL | 403 | Caller lacks approve scope |
ACCESS_DENIED | 403 | Missing scope / cross-tenant |
CROSS_TENANT_REFERENCE | 403 | Referenced aggregate belongs to other tenant |
MODULE_NOT_ACTIVE | 403 | Tenant unlicensed for billing |
OVERPAYMENT_NOT_ALLOWED | 400 | Payment > outstanding balance w/o flag |
RATE_LIMITED | 429 | Rate limit hit |
INTERNAL | 500 | Unexpected |
13. FR / FHIR mapping
| Internal endpoint | FHIR equivalent | FR | Legacy ref |
|---|---|---|---|
POST /charges | POST /fhir/R4/ChargeItem | FR-BILL-001, FR-BILL-002 | FR-BILL-001, FR-BILL-002 |
GET /accounts | GET /fhir/R4/Account?patient=Patient/{id} | FR-BILL-003 | FR-BILL-003 |
POST /invoices / POST /invoices/:id/issue | POST/PUT /fhir/R4/Invoice | FR-BILL-004 | FR-BILL-004 |
POST /payments | POST /fhir/R4/PaymentNotice + PaymentReconciliation | FR-BILL-005 | FR-BILL-005 |
POST /adjustments | POST /fhir/R4/PaymentReconciliation (adjust line) | FR-BILL-005 | FR-BILL-005 |
POST /exports/gl | — (internal export) | FR-BILL-006 | FR-BILL-006 |
| Price list + payer config | FHIR ChargeItemDefinition (future) | FR-BILL-007, FR-BILL-008 | FR-BILL-007, FR-BILL-008 |
Currency in Money | Money datatype | FR-BILL-009 | FR-BILL-009 |
| Tax lines on Invoice | Invoice.totalPriceComponent | FR-BILL-010 | FR-BILL-010 |
14. FR register (restatement)
| FR | Requirement | Legacy ref |
|---|---|---|
| FR-BILL-001 | Capture charges linked to patient, encounter, provider, facility, service date | FR-BILL-001 |
| FR-BILL-002 | Coding + modifiers per configured standards | FR-BILL-002 |
| FR-BILL-003 | Patient account balances + aging buckets | FR-BILL-003 |
| FR-BILL-004 | Generate invoices + statements | FR-BILL-004 |
| FR-BILL-005 | Payments / refunds / adjustments with reasons | FR-BILL-005 |
| FR-BILL-006 | GL export for ERP integration | FR-BILL-006 |
| FR-BILL-007 | Mixed payer models (self-pay, corporate, private, public) | FR-BILL-007 |
| FR-BILL-008 | Facility-scoped price lists with effective dating | FR-BILL-008 |
| FR-BILL-009 | Multi-currency (AFN, AED, USD) per tenant default | FR-BILL-009 |
| FR-BILL-010 | VAT / tax rules per country and facility | FR-BILL-010 |
| FR-BILL-011 | Idempotent payment posting | (derived from NFR-BILL-001) |
| FR-BILL-012 | Refund approval workflow by threshold | (derived from BR-BILL-002) |
| FR-BILL-013 | Module licensing gate | MODULE_SHARED_STANDARDS §1 |
| FR-BILL-014 | RTL statement rendering (Pashto/Dari/Arabic) | NFR-BILL-003 |
15. NFRs
| NFR | Target |
|---|---|
| NFR-BILL-001 | POST /payments p95 ≤ 2 s |
| NFR-BILL-002 | Statement run: 10 000 accounts / 15 min |
| NFR-BILL-003 | RTL + locale-correct formatting |
| NFR-BILL-004 | Availability 99.9 % monthly |
| NFR-BILL-005 | Outbox lag p95 ≤ 10 s |