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)
├── 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/
└── 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)
├── 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)
└── 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.