import type { ChargeItemDefinition, CodeableConcept, Extension } from 'fhir/r4'; import { v4 as uuidv4 } from 'uuid'; import { fromFhirMoney, toFhirMoney } from '../utils/money'; // FHIR requires ChargeItemDefinition.url (1..1) and HealthLake VALIDATES it on write - a CID without a url // is rejected ("url: minimum required = 1, but only found 0"). We never resolve by it, so on create we mint a // urn:uuid, and on update we preserve whatever the resource already had (read back into the draft). const catalogItemUrl = (url?: string): string => url ?? `urn:uuid:${uuidv4()}`; // A "Fee" is a flat, named charge (e.g. "Late fee", "No-show fee") - modeled as a // ChargeItemDefinition, distinguished from a priced HealthcareService by this category coding on // `code`. This is a CLIENT-ONLY convention: the server stores `code` as opaque pass-through and does // not validate the category. Future CID catalog types (inventory/labor, epic Phases B/C) get other // codes under this same system. export const FEE_CATEGORY_SYSTEM = 'https://akashichealthrecords.com/fhir/CodeSystem/catalog-item-category'; export const FEE_CATEGORY_CODE = 'fee'; // A searchable copy of the catalog category (fee/inventory) as a FHIR identifier. `code` is NOT a // ChargeItemDefinition search parameter in R4, but `identifier` is - so per-category server-side lists // (Feature B) filter on this. It's kept ALONGSIDE code.coding (which readFee/readInventory/isFeeCid/ // isInventoryCid still key on); this is purely the searchable projection of the same fact. Because the // builders re-stamp it on every save, editing an existing item also backfills it. export const CATALOG_CATEGORY_IDENTIFIER_SYSTEM = 'https://akashichealthrecords.com/fhir/identifier/catalog-item-category'; // A HealthLake `identifier` token (`system|value`) for filtering a CID list to one catalog category. export const catalogCategorySearchToken = (category: 'fee' | 'inventory'): string => `${CATALOG_CATEGORY_IDENTIFIER_SYSTEM}|${category}`; export type FeeDraft = { id?: string; url?: string; title: string; priceCents: number }; const FEE_CODE: CodeableConcept = { coding: [{ system: FEE_CATEGORY_SYSTEM, code: FEE_CATEGORY_CODE }], }; // Build a create/update payload. `id` is only included when present (create omits it; update carries it // through). `url` is REQUIRED by FHIR/HealthLake - minted on create, preserved on update via the draft // (see catalogItemUrl). Cents -> decimal only via toFhirMoney (money.ts is the sole conversion point). export const buildFeeChargeItemDefinition = (draft: FeeDraft): ChargeItemDefinition => ({ resourceType: 'ChargeItemDefinition', status: 'active', url: catalogItemUrl(draft.url), identifier: [{ system: CATALOG_CATEGORY_IDENTIFIER_SYSTEM, value: FEE_CATEGORY_CODE }], title: draft.title, code: FEE_CODE, propertyGroup: [ { priceComponent: [{ type: 'base', amount: toFhirMoney(draft.priceCents) }] }, ], ...(draft.id ? { id: draft.id } : {}), } as ChargeItemDefinition); // True iff `code.coding` carries the fee category - how a Fee CID is told apart from any other // ChargeItemDefinition category the catalog may add later. export const isFeeCid = (cid: ChargeItemDefinition): boolean => (cid.code?.coding ?? []).some( (c) => c.system === FEE_CATEGORY_SYSTEM && c.code === FEE_CATEGORY_CODE, ); // Read a CID back to a FeeDraft. `id` is only included when present, so a draft built without one // roundtrips without gaining an `id: undefined` key. `fromFhirMoney` already returns 0 for // absent/invalid input, so a CID with no base priceComponent reads as priceCents: 0. export const readFee = (cid: ChargeItemDefinition): FeeDraft => { const amount = cid.propertyGroup ?.flatMap((group) => group.priceComponent ?? []) .find((pc) => pc.type === 'base')?.amount; return { ...(cid.id ? { id: cid.id } : {}), ...(cid.url ? { url: cid.url } : {}), title: cid.title ?? '', priceCents: fromFhirMoney(amount), }; }; // "Inventory" is a priced good (e.g. "Bandages (box of 50)", "Crutches") - same CID shape as a Fee // (title + base priceComponent under FEE_CATEGORY_SYSTEM), distinguished by INVENTORY_CATEGORY_CODE // instead of FEE_CATEGORY_CODE, plus a taxable flag carried as a valueBoolean extension on the CID. export const INVENTORY_CATEGORY_CODE = 'inventory'; export const INVENTORY_TAXABLE_EXTENSION_URL = 'https://akashichealthrecords.com/fhir/StructureDefinition/inventory-taxable'; // Optional stock-keeping unit for an inventory item, carried as a SECOND searchable FHIR identifier // (alongside the category identifier). It's the match key for the CSV/JSON import upsert (Feature C): // findChargeItemDefinitionBySku searches identifier=|. Uniqueness is by convention (soft) - // the import's SKU lookup is the practical guard, not a hard FHIR constraint. export const CATALOG_SKU_IDENTIFIER_SYSTEM = 'https://akashichealthrecords.com/fhir/identifier/catalog-sku'; export const skuSearchToken = (sku: string): string => `${CATALOG_SKU_IDENTIFIER_SYSTEM}|${sku}`; export type InventoryDraft = { id?: string; url?: string; title: string; priceCents: number; taxable: boolean; sku?: string }; const INVENTORY_CODE: CodeableConcept = { coding: [{ system: FEE_CATEGORY_SYSTEM, code: INVENTORY_CATEGORY_CODE }], }; // Build a create/update payload, mirroring buildFeeChargeItemDefinition (see its comment for the // `id`/cents/cast rationale). The taxable flag is always written explicitly - including // `valueBoolean: false` - so a non-taxable item roundtrips correctly rather than being read back as // the default (see readInventory). export const buildInventoryChargeItemDefinition = (draft: InventoryDraft): ChargeItemDefinition => ({ resourceType: 'ChargeItemDefinition', status: 'active', url: catalogItemUrl(draft.url), identifier: [ { system: CATALOG_CATEGORY_IDENTIFIER_SYSTEM, value: INVENTORY_CATEGORY_CODE }, ...(draft.sku ? [{ system: CATALOG_SKU_IDENTIFIER_SYSTEM, value: draft.sku }] : []), ], title: draft.title, code: INVENTORY_CODE, propertyGroup: [ { priceComponent: [{ type: 'base', amount: toFhirMoney(draft.priceCents) }] }, ], extension: [{ url: INVENTORY_TAXABLE_EXTENSION_URL, valueBoolean: draft.taxable } as Extension], ...(draft.id ? { id: draft.id } : {}), } as ChargeItemDefinition); // True iff `code.coding` carries the inventory category - mutually exclusive with isFeeCid since // they differ only by category code under the same FEE_CATEGORY_SYSTEM. export const isInventoryCid = (cid: ChargeItemDefinition): boolean => (cid.code?.coding ?? []).some( (c) => c.system === FEE_CATEGORY_SYSTEM && c.code === INVENTORY_CATEGORY_CODE, ); // Read a CID back to an InventoryDraft. `id` and `title` follow readFee's conventions. `taxable` // defaults to true when the extension is absent - inventory is taxable by default. export const readInventory = (cid: ChargeItemDefinition): InventoryDraft => { const amount = cid.propertyGroup ?.flatMap((group) => group.priceComponent ?? []) .find((pc) => pc.type === 'base')?.amount; const taxableExt = cid.extension?.find((ext) => ext.url === INVENTORY_TAXABLE_EXTENSION_URL); const sku = cid.identifier?.find((id) => id.system === CATALOG_SKU_IDENTIFIER_SYSTEM)?.value; return { ...(cid.id ? { id: cid.id } : {}), ...(cid.url ? { url: cid.url } : {}), ...(sku ? { sku } : {}), title: cid.title ?? '', priceCents: fromFhirMoney(amount), taxable: taxableExt?.valueBoolean ?? true, }; };