EU Billing Country-Packs — Pluggable Jurisdiction Billing
Audiences: clinical-buyer, developer, investor
A second, RELATIONAL billing path that runs parallel to the FHIR billing facades: a full EU billing system-of-record (Coverage → ChargeItem → Invoice, plus the insurance Claim → ClaimResponse → PaymentReconciliation chain) stored in
ddx_api_acc(Prisma-accounting), where all jurisdiction-specific behaviour — tariffs, VAT, payer schemes, e-invoice format — is keyed to a country PURELY by data throughCountryPackService. Adding a new country (FR/CH/AT/MENA) is a DATA operation, never a code change.
Business Purpose
The FHIR billing path (see Billing) is optimised for interoperability — Invoice/Account/ChargeItem as FHIR R4 resources. But reimbursement in the EU is jurisdiction-specific: Germany uses GOÄ/EBM fee schedules with §238 HGB gap-less numbering; France uses CCAM/NGAP; each has its own VAT rules, payer-ID schemes, and e-invoice formats (ZUGFeRD, Factur-X). Branching billing logic on a literal country code per feature is a maintenance dead-end.
The country-pack model solves this with a single jurisdiction seam:
- Relational system-of-record —
EuBillingServiceowns Coverage / ChargeItem / Invoice CRUD + lifecycle inddx_api_acc, with money kept inPrisma.Decimal(never JS float) and every query scoped byorganizationId. - Pluggable country seam —
CountryPackServiceis the ONLY place billing behaviour is keyed to a jurisdiction. Every billing service resolves tariff prices, VAT rules, and payer schemes THROUGH it by passing a country code — never by branching on a literal country. - Platform-shared catalogs — GOÄ/EBM/CCAM/NGAP/TARMED are federal fee schedules that do not differ per clinic, so
CatalogService(CR-049) persists them as platform rows (organizationId = NULL), visible to every tenant, with per-org overrides layerable later. - ICD-10 diagnosis catalog —
Icd10Serviceprovides the diagnosis-code catalog (ICD-10-GM) used on charge items and claims.
Audiences
- Investor: adding a country is a data operation (INSERT a
CountryPack+ tariff/VAT rows) — no schema migration, no code change. This makes EU-wide expansion a configuration exercise, not an engineering project, and is the moat vs. HMS vendors who hard-code one country's rules. - Clinical buyer: reception/finance staff bill in their jurisdiction's fee schedule (GOÄ/EBM for DE) with correct VAT and payer handling, without the software "knowing" it is German in code — the pack supplies it.
- Developer/partner:
EuBillingController(@Controller('billing/eu')) +EuBillingService(relational, ACC-backed). Never branch on a literal country — resolve tariff/VAT/payer throughCountryPackService. Catalog reads viaCatalogService/Icd10Service. DTOs inbilling/dto/eu-billing.types. - Internal (ops/support): catalog rows are platform-shared (
organizationId = NULL) per theproducts_platform_country_code_uqpartial unique index (migration20260522150000_catalog_platform_shared). The add-a-country runbook isddx-api/docs/country-packs.md.
Architecture
billing/eu/ — relational EU billing system-of-record (ACC / Prisma-accounting)
| File | Role |
|---|---|
eu-billing.controller.ts | @Controller('billing/eu') — POST/GET/PATCH coverages · POST/GET/PATCH charge-items + POST charge-items/:id/transition (PLANNED→BILLABLE→BILLED) · POST/GET/PATCH invoices (DRAFT → ISSUED → BALANCED) |
eu-billing.service.ts | EuBillingService — CRUD + lifecycle; on invoice ISSUE resolves the active pack by countryCode, computes each line via the pack's TariffItem (fee × factor) + per-line VAT, enforces totalGross = totalNet + Σ vatAmount (AC-1/AC-3/AC-5) |
tariff.service.ts | TariffService — CRUD over pack-keyed TariffItem catalog; net unit price = baseFee × factor for a (countryPack, codeSystem, code) tuple |
billing/country-packs/
| File | Role |
|---|---|
country-pack.service.ts | CountryPackService — the pluggable jurisdiction SEAM. Resolves tariff prices, VAT rules, payer schemes by countryCode. Adding a country = INSERT CountryPack row + TariffItem rows + VatRate rows. No schema/code change. |
billing/catalog/ — platform-shared fee-schedule + diagnosis catalogs (CR-049)
| File | Role |
|---|---|
catalog.service.ts | CatalogService — GOÄ/EBM/CCAM/NGAP/TARMED reader/writer; platform rows (organizationId = NULL) visible to all tenants |
icd10.service.ts | Icd10Service — ICD-10-GM diagnosis code catalog |
catalog.controller.ts | GET/POST billing/catalog(/search/:id), GET/POST billing/icd10(/search/:id) |
insurance/claims-eu/ — insurer reimbursement chain (relational, ACC)
| File | Role |
|---|---|
claims-eu.service.ts | Claim → ClaimResponse → PaymentReconciliation (see insurance.md) |
CatalogModule, EuBillingController, EuBillingService, CountryPackService, and TariffService are all wired into BillingModule (billing.module.ts). The FHIR facades and this relational path share the module but are independent write paths.
Tech Stack & Choices
| Concern | Choice | Rationale |
|---|---|---|
| Storage | ddx_api_acc (Prisma-accounting) | Reimbursement system-of-record; relational, not FHIR |
| Money | Prisma.Decimal everywhere (never JS float) | Exact currency math; VAT/tariff arithmetic must not drift |
| Jurisdiction | CountryPackService seam | Single place country is keyed; adding a country is data, not code |
| Tariff pricing | baseFee × factor per (countryPack, codeSystem, code) TariffItem | Fee schedules as data rows; no per-country code branch |
| Catalogs | Platform-shared rows (organizationId = NULL), CR-049 | Federal fee schedules are identical across clinics; per-org overrides layerable |
| Tenant isolation | Every query scoped by organizationId | Multi-tenant safety on all EU billing reads/writes |
| HTTP mapping | Services NEVER throw HttpException | Controllers / the global filter own HTTP status mapping |
Data Flow
Issue an EU invoice (tariff + VAT resolution)
POST /billing/eu/invoices/:id/... (ISSUE)
→ EuBillingService
1. resolve active CountryPack by invoice.countryCode ← CountryPackService
2. for each line: net = TariffService(baseFee × factor) for (pack, codeSystem, code)
3. per-line VAT resolution via the pack's VatRate rows
4. enforce totalGross = totalNet + Σ vatAmount (server-side invariant, AC-1/AC-3/AC-5)
5. persist Invoice + InvoiceLineItem (prisma:acc), status DRAFT → ISSUED
Add a new country (data-only)
1. INSERT CountryPack (countryCode, currency, activeTariffCodeSystems, eInvoiceFormat, payerIdScheme) 2. INSERT TariffItem rows for that pack's code systems 3. INSERT VatRate rows tied to that pack → no schema migration, no code change. Runbook: ddx-api/docs/country-packs.md
Implicated Code
ddx-api/src/financial/billing/eu/eu-billing.service.ts:196—EuBillingService; relational Coverage/ChargeItem/Invoice CRUD + lifecycle inprisma:acc; tariff/VAT resolved viaCountryPackService; enforcestotalGross = totalNet + Σ vatAmount.ddx-api/src/financial/billing/eu/eu-billing.controller.ts:106—EuBillingController@Controller('billing/eu'); coverages, charge-items (+ transition), invoices endpoints.ddx-api/src/financial/billing/eu/tariff.service.ts:26—TariffService; CRUD over pack-keyedTariffItemrows; net unit price =baseFee × factor.ddx-api/src/financial/billing/country-packs/country-pack.service.ts:123—CountryPackService; the pluggable jurisdiction seam; resolves tariff/VAT/payer scheme by country code.ddx-api/src/financial/billing/catalog/catalog.service.ts:141—CatalogService(CR-049); GOÄ/EBM/CCAM/NGAP/TARMED platform-shared catalog (organizationId = NULL).ddx-api/src/financial/billing/catalog/icd10.service.ts:47—Icd10Service; ICD-10-GM diagnosis catalog.ddx-api/src/financial/billing/catalog/catalog.controller.ts:85—CatalogController;GET/POST billing/catalog(/search/:id),GET/POST billing/icd10(/search/:id).ddx-api/src/financial/billing/dto/eu-billing.types.ts— shared EU billing DTO types (also consumed byclaims-eu).
Operational Notes
- Never branch on a literal country — all jurisdiction behaviour flows through
CountryPackService. Aif (country === 'DE')anywhere in the billing services is a regression against the seam design. - Catalog rows are platform-shared —
organizationId = NULLcatalog rows are visible to every tenant (CR-049, migration20260522150000_catalog_platform_shared). Do not scope catalog reads to a single org unless resolving a per-org override row. - Money is
Prisma.Decimal— never convert tariff/VAT amounts through JSnumber; rounding drift breaks thetotalGross = totalNet + Σ vatAmountinvariant. - Two billing write paths coexist — the FHIR facades (Billing) are the interoperability projection; this relational path is the reimbursement system-of-record. They share
BillingModulebut write to different stores (HAPI FHIR vsddx_api_acc). The composition wiring of the FHIR catalog onto the relationalEuBillingServicewrite path is noted as future work inbilling.module.ts. - AR clearing — settled EU billing /
claims-euPaymentReconciliationrows clearArOpenItems via the accountingReconciliationService, which posts the matching double-entryJournalEntry(see Accounting).
Related Topics
- Billing — Invoices, Charge Items and Pricing — the FHIR facade billing path this relational path runs parallel to.
- Accounting — Journal Entries, Budgets and Financial Reports —
ReconciliationServiceclears AR open items from settled EU billing / claims-eu payments and posts the double-entry. - Insurance and Coverage — Claims Management — the
claims-eusubmodule (Claim → ClaimResponse → PaymentReconciliation) shares this relational EU billing model. - Prisma 7 Schemas —
ddx_api_acc/PrismaAccountingServiceis the store for all EU billing + catalog rows.