10-cross-cuttingwave: W6filled7 citations

EU Billing Country-Packs — Pluggable Jurisdiction Billing

Audiences: clinical-buyer, developer, investor

EU Billing Country-Packs — Pluggable Jurisdiction Billing

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 through CountryPackService. 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:

  1. Relational system-of-recordEuBillingService owns Coverage / ChargeItem / Invoice CRUD + lifecycle in ddx_api_acc, with money kept in Prisma.Decimal (never JS float) and every query scoped by organizationId.
  2. Pluggable country seamCountryPackService is 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.
  3. 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.
  4. ICD-10 diagnosis catalogIcd10Service provides 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 through CountryPackService. Catalog reads via CatalogService / Icd10Service. DTOs in billing/dto/eu-billing.types.
  • Internal (ops/support): catalog rows are platform-shared (organizationId = NULL) per the products_platform_country_code_uq partial unique index (migration 20260522150000_catalog_platform_shared). The add-a-country runbook is ddx-api/docs/country-packs.md.

Architecture

billing/eu/ — relational EU billing system-of-record (ACC / Prisma-accounting)

FileRole
eu-billing.controller.ts@Controller('billing/eu')POST/GET/PATCH coverages · POST/GET/PATCH charge-items + POST charge-items/:id/transition (PLANNEDBILLABLEBILLED) · POST/GET/PATCH invoices (DRAFTISSUEDBALANCED)
eu-billing.service.tsEuBillingService — 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.tsTariffService — CRUD over pack-keyed TariffItem catalog; net unit price = baseFee × factor for a (countryPack, codeSystem, code) tuple

billing/country-packs/

FileRole
country-pack.service.tsCountryPackService — 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)

FileRole
catalog.service.tsCatalogService — GOÄ/EBM/CCAM/NGAP/TARMED reader/writer; platform rows (organizationId = NULL) visible to all tenants
icd10.service.tsIcd10Service — ICD-10-GM diagnosis code catalog
catalog.controller.tsGET/POST billing/catalog(/search/:id), GET/POST billing/icd10(/search/:id)

insurance/claims-eu/ — insurer reimbursement chain (relational, ACC)

FileRole
claims-eu.service.tsClaimClaimResponsePaymentReconciliation (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

ConcernChoiceRationale
Storageddx_api_acc (Prisma-accounting)Reimbursement system-of-record; relational, not FHIR
MoneyPrisma.Decimal everywhere (never JS float)Exact currency math; VAT/tariff arithmetic must not drift
JurisdictionCountryPackService seamSingle place country is keyed; adding a country is data, not code
Tariff pricingbaseFee × factor per (countryPack, codeSystem, code) TariffItemFee schedules as data rows; no per-country code branch
CatalogsPlatform-shared rows (organizationId = NULL), CR-049Federal fee schedules are identical across clinics; per-org overrides layerable
Tenant isolationEvery query scoped by organizationIdMulti-tenant safety on all EU billing reads/writes
HTTP mappingServices NEVER throw HttpExceptionControllers / 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:196EuBillingService; relational Coverage/ChargeItem/Invoice CRUD + lifecycle in prisma:acc; tariff/VAT resolved via CountryPackService; enforces totalGross = totalNet + Σ vatAmount.
  • ddx-api/src/financial/billing/eu/eu-billing.controller.ts:106EuBillingController @Controller('billing/eu'); coverages, charge-items (+ transition), invoices endpoints.
  • ddx-api/src/financial/billing/eu/tariff.service.ts:26TariffService; CRUD over pack-keyed TariffItem rows; net unit price = baseFee × factor.
  • ddx-api/src/financial/billing/country-packs/country-pack.service.ts:123CountryPackService; the pluggable jurisdiction seam; resolves tariff/VAT/payer scheme by country code.
  • ddx-api/src/financial/billing/catalog/catalog.service.ts:141CatalogService (CR-049); GOÄ/EBM/CCAM/NGAP/TARMED platform-shared catalog (organizationId = NULL).
  • ddx-api/src/financial/billing/catalog/icd10.service.ts:47Icd10Service; ICD-10-GM diagnosis catalog.
  • ddx-api/src/financial/billing/catalog/catalog.controller.ts:85CatalogController; 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 by claims-eu).

Operational Notes

  • Never branch on a literal country — all jurisdiction behaviour flows through CountryPackService. A if (country === 'DE') anywhere in the billing services is a regression against the seam design.
  • Catalog rows are platform-sharedorganizationId = NULL catalog rows are visible to every tenant (CR-049, migration 20260522150000_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 JS number; rounding drift breaks the totalGross = totalNet + Σ vatAmount invariant.
  • Two billing write paths coexist — the FHIR facades (Billing) are the interoperability projection; this relational path is the reimbursement system-of-record. They share BillingModule but write to different stores (HAPI FHIR vs ddx_api_acc). The composition wiring of the FHIR catalog onto the relational EuBillingService write path is noted as future work in billing.module.ts.
  • AR clearing — settled EU billing / claims-eu PaymentReconciliation rows clear ArOpenItems via the accounting ReconciliationService, which posts the matching double-entry JournalEntry (see Accounting).
    EU Billing Country-Packs — Pluggable Jurisdiction Billing — Dudoxx Docs | Dudoxx