import type { ReadOptions } from '../hooks/anchorView/readOptions'; import { overlayHeldBundle, refersToPatient, writtenSince } from '../utils/writeThroughHold'; import { useState, useCallback, createContext, useContext, ReactNode, useEffect, useRef } from 'react'; import type { Bundle, Coverage, Patient } from 'fhir/r4'; import { environment } from '../environments/environment.ts'; import { useHttpService } from './HttpService.tsx'; import { useFhirServiceContext } from './FhirService.tsx'; import { usePatientServiceContext } from './PatientService.tsx'; import { applyArchive, isArchived, type ArchiveReason } from '../utils/archivedResource'; import { fetchAllPagesAsBundle } from '../utils/fetchAllPagesAsBundle'; import { searchPageSize } from '../utils/pageSizeOverride'; import { useCacheOwner } from '../utils/cacheOwner'; const API_URL = environment.fhirBaseApi; export interface CoverageFormData { status: string; type: { code: string; display: string; system: string } | null; subscriberId: string; relationship: { code: string; display: string; system: string } | null; periodStart: string; periodEnd: string; /** * The insurance company, AS THE PATIENT WRITES IT. * * Spencer, 2026-08-15: *"I don't want patients selecting a list of insurers from a list an org * creates. Remember that the patient is org-agnostic and can use multiple orgs and there's tons of * insurance companies out there. Makes no sense for a patient to select from one list of insurance * companies from one org. The patient should just have regular fields in their profile where they * can add any insurance company along with their ID number. No different than any other new patient * intake form that would ask for insurance information or a receptionist looking at their insurance * ID card in person."* * * IT USED TO BE `{ id, name }` - a REFERENCE to an Organization the practice had created. That is * the org-agnostic model broken at its own foundation: a patient belongs to no organization, so * asking them to pick their insurer from ONE practice's curated list means their coverage is * unenterable the moment their insurer is not on it, and means the same human's insurer is a * different resource at every practice they attend. * * A plain string is what an intake form asks for and what a receptionist reads off the card. */ payorName: string; groupNumber: string; planName: string; order: string; network: string; } export const COVERAGE_TYPE_OPTIONS = [ { code: 'UNINSURED', display: 'Uninsured', system: 'https://akashichealthrecords.com/fhir/coverage-type' }, { code: 'SELF', display: 'Self Pay', system: 'http://terminology.hl7.org/CodeSystem/v3-ActCode' }, { code: 'EHCPOL', display: 'Medical', system: 'http://terminology.hl7.org/CodeSystem/v3-ActCode' }, { code: 'DENTAL', display: 'Dental', system: 'http://terminology.hl7.org/CodeSystem/v3-ActCode' }, { code: 'VISPOL', display: 'Vision', system: 'http://terminology.hl7.org/CodeSystem/v3-ActCode' }, ]; import { isUninsuredCoverage, isSelfPayCoverage } from '../utils/coverageKind'; export { isUninsuredCoverage, isSelfPayCoverage, isNoPayerCoverage } from '../utils/coverageKind'; export const RELATIONSHIP_OPTIONS = [ { code: 'self', display: 'Self', system: 'http://terminology.hl7.org/CodeSystem/subscriber-relationship' }, { code: 'spouse', display: 'Spouse', system: 'http://terminology.hl7.org/CodeSystem/subscriber-relationship' }, { code: 'child', display: 'Child', system: 'http://terminology.hl7.org/CodeSystem/subscriber-relationship' }, { code: 'other', display: 'Other', system: 'http://terminology.hl7.org/CodeSystem/subscriber-relationship' }, ]; export const useCoverageService = () => { const { get, post, put, delete: del } = useHttpService(); const { patientState } = usePatientServiceContext(); const { createReferenceObject, formatDate, unpackBundle, } = useFhirServiceContext(); const [coverageList, setCoverageList] = useState([]); const [coveragesBundle, setCoveragesBundle] = useState | null>(null); const [shouldReload, setShouldReload] = useState(false); const [lastUpserted, setLastUpserted] = useState(null); const [unsaved, setUnsaved] = useState(false); const coveragesBundleRef = useRef | null>(null); const lastFetchTimeRef = useRef(0); useEffect(() => { coveragesBundleRef.current = coveragesBundle; }, [coveragesBundle]); // WHOSE RECORDS ARE THESE? The reset below fires only when there is NO patient, which moving // between two charts never is - so the previous patient's bundle survived the switch and was // served as a cache hit, with no request and nothing on screen to say the list was not theirs. const cacheOwner = useCacheOwner('Coverage', () => { setLastUpserted(null); setCoveragesBundle(null); setCoverageList([]); lastFetchTimeRef.current = 0; }); useEffect(() => { if (!patientState) { setCoveragesBundle(null); setCoverageList([]); lastFetchTimeRef.current = 0; cacheOwner.release(); } }, [patientState, cacheOwner.release]); const fetchCoverages = useCallback(async (patient: Patient, options?: ReadOptions): Promise> => { return overlayHeldBundle('Coverage', await fetchAllPagesAsBundle((pageParam) => { const params = new URLSearchParams(); params.append('_count', searchPageSize('50')); params.append('beneficiary', patient.id as string); if (pageParam) params.set('page', pageParam); return get>(`${API_URL}/Coverage?${params.toString()}`, options); }), (resource) => refersToPatient(resource, patient.id)) as Bundle; }, [get]); const fetchCoveragesWithPolling = useCallback(async (patient: Patient, forceReload = false, expectedId?: string): Promise> => { const now = Date.now(); const cacheFresh = (now - lastFetchTimeRef.current) < 5 * 60 * 1000; // cacheOwner.isFor is the load-bearing clause: a fresh bundle belonging to the PREVIOUS // patient satisfies every other condition here and would be returned as a hit. if (!forceReload && !shouldReload && !writtenSince('Coverage', lastFetchTimeRef.current) && coveragesBundleRef.current && cacheFresh && cacheOwner.isCurrentFor(patient.id)) { return coveragesBundleRef.current; } const operation = cacheOwner.begin(); const targetId = expectedId ?? lastUpserted ?? null; let retries = 10; const delay = 3000; let found = false; let bundle: Bundle = { resourceType: 'Bundle', type: 'searchset' }; let entries: Coverage[] = []; do { bundle = await fetchCoverages(patient); entries = unpackBundle(bundle).filter((r) => !isArchived(r)); found = targetId ? entries.some(e => e.id === targetId) : true; if (!found) { await new Promise(resolve => setTimeout(resolve, delay)); } retries--; } while (!found && retries > 0 && cacheOwner.publishes(operation)); if (!cacheOwner.publishes(operation)) return cacheOwner.answerFor(operation, bundle); setCoveragesBundle(bundle); setCoverageList(entries.filter((r) => !isArchived(r))); setLastUpserted(null); setShouldReload(false); lastFetchTimeRef.current = now; // Stamped WITH the data, never before it - on a throw the owner must still name the list // that is actually in state. cacheOwner.claim(patient.id, operation); return bundle; }, [fetchCoverages, unpackBundle, lastUpserted, shouldReload]); const fetchCoverage = useCallback(async (id: string): Promise => { const cached = cacheOwner.isCurrent(cacheOwner.claimed) ? coverageList.find(c => c.id === id) : undefined; if (cached) return cached; return get(`${API_URL}/Coverage/${id}`); }, [get, coverageList]); const fetchCoverageHistory = useCallback(async (id: string, options?: ReadOptions): Promise> => { return get>(`${API_URL}/Coverage/${id}/_history`, options); }, [get]); const createCoverage = useCallback(async (coverage: Coverage): Promise => { const params = new URLSearchParams(); const beneficiaryId = coverage.beneficiary?.reference?.split('/').pop(); if (beneficiaryId) params.append('beneficiary', beneficiaryId); return post(`${API_URL}/Coverage?${params.toString()}`, coverage); }, [post]); const updateCoverage = useCallback(async (coverage: Coverage): Promise => { return put(`${API_URL}/Coverage/${coverage.id}`, coverage); }, [put]); const deleteCoverage = useCallback(async (coverage: Coverage): Promise => { await del(`${API_URL}/Coverage/${coverage.id}`); }, [del]); const archiveCoverage = useCallback(async (coverage: Coverage, reason: ArchiveReason, note?: string): Promise => { await put(`${API_URL}/Coverage/${coverage.id}`, applyArchive(coverage, reason, note)); }, [put]); const transformToFhirCoverage = useCallback((formData: CoverageFormData, patient: Patient): Coverage => { const { status, type, subscriberId, relationship, periodStart, periodEnd, payorName, groupNumber, planName, order, network } = formData; const isNoPayer = type?.code === 'UNINSURED' || type?.code === 'SELF'; const classEntries: Coverage['class'] = []; if (!isNoPayer && groupNumber) { classEntries.push({ type: { coding: [{ system: 'http://terminology.hl7.org/CodeSystem/coverage-class', code: 'group', display: 'Group' }] }, value: groupNumber, }); } if (!isNoPayer && planName) { classEntries.push({ type: { coding: [{ system: 'http://terminology.hl7.org/CodeSystem/coverage-class', code: 'plan', display: 'Plan' }] }, value: planName, }); } const coverage: Coverage = { resourceType: 'Coverage', status: isNoPayer ? 'active' : ((status as Coverage['status']) || 'active'), beneficiary: createReferenceObject('Patient', patient.id as string), subscriber: createReferenceObject('Patient', patient.id as string), // A REFERENCE CARRYING ONLY A `display` - which is valid FHIR and means exactly what is // true here: we know the insurer's NAME and we have not resolved it to a resource. // No `reference: Organization/...`. Writing one would create an insurer as an Organization, // and insurers are not care organizations - they then surface in every provider picker in // the product. The old dropdown was what created them. // `payor` is 1..* in R4, and a display-only reference satisfies that honestly. payor: isNoPayer ? [{ reference: `Patient/${patient.id}`, display: 'Self' }] : payorName.trim() ? [{ display: payorName.trim() }] : [], ...(type && { type: { coding: [{ system: type.system, code: type.code, display: type.display }], text: type.display } }), ...(!isNoPayer && subscriberId && { subscriberId }), ...(!isNoPayer && relationship && { relationship: { coding: [{ system: relationship.system, code: relationship.code, display: relationship.display }], text: relationship.display } }), ...(!isNoPayer && (periodStart || periodEnd) && { period: { ...(periodStart && { start: periodStart }), ...(periodEnd && { end: periodEnd }), } }), ...(!isNoPayer && order && { order: parseInt(order, 10) }), ...(!isNoPayer && network && { network }), ...(classEntries.length > 0 && { class: classEntries }), }; return coverage; }, [createReferenceObject]); const transformFromFhirCoverage = useCallback((coverage: Coverage): CoverageFormData => { const groupClass = coverage.class?.find(c => c.type?.coding?.[0]?.code === 'group'); const planClass = coverage.class?.find(c => c.type?.coding?.[0]?.code === 'plan'); const payorRef = coverage.payor?.[0]; // READS BOTH SHAPES ON PURPOSE. Coverage written before this change carries // `reference: Organization/...` with the insurer's name in `display`, and the name is the part // that matters - so an existing record still shows the right insurer without a migration. // We never write a reference again; we just do not lose what is already there. const payorName = payorRef?.display?.trim() ?? ''; return { status: coverage.status || 'active', type: coverage.type?.coding?.[0] ? { code: coverage.type.coding[0].code || '', display: coverage.type.coding[0].display || coverage.type.text || '', system: coverage.type.coding[0].system || '', } : null, subscriberId: coverage.subscriberId || '', relationship: coverage.relationship?.coding?.[0] ? { code: coverage.relationship.coding[0].code || '', display: coverage.relationship.coding[0].display || '', system: coverage.relationship.coding[0].system || '', } : null, periodStart: coverage.period?.start ? formatDate(coverage.period.start) : '', periodEnd: coverage.period?.end ? formatDate(coverage.period.end) : '', // "Self" is the sentinel the no-payer branch writes, not an insurer anybody typed - // echoing it back into the field would put the word "Self" in the insurance-company box. payorName: payorName === 'Self' ? '' : payorName, groupNumber: groupClass?.value || '', planName: planClass?.value || '', order: (coverage.order ?? 1).toString(), network: coverage.network || '', }; }, [formatDate]); const getCoverageDisplay = useCallback((coverage: Coverage): string => { if (isUninsuredCoverage(coverage)) return 'Uninsured'; if (isSelfPayCoverage(coverage)) return 'Self Pay'; const payorName = coverage.payor?.[0]?.display || 'Unknown Payer'; const planClass = coverage.class?.find(c => c.type?.coding?.[0]?.code === 'plan'); return `${payorName}${planClass?.value ? ` — ${planClass.value}` : ''}`; }, []); const isCoverageExpired = useCallback((coverage: Coverage): boolean => { if (!coverage.period?.end) return false; return new Date(coverage.period.end) < new Date(); }, []); return { // WHO THE CACHED LIST IS FOR, so a consumer can ask whether it belongs to the patient on // screen rather than trusting an app-wide context with no patient in its name. cachedForPatientId: cacheOwner.ownerId, coverageList, setCoverageList, coveragesBundle, setCoveragesBundle, shouldReload, setShouldReload, lastUpserted, setLastUpserted, unsaved, setUnsaved, fetchCoverages, fetchCoveragesWithPolling, fetchCoverage, fetchCoverageHistory, createCoverage, updateCoverage, deleteCoverage, archiveCoverage, transformToFhirCoverage, transformFromFhirCoverage, getCoverageDisplay, isCoverageExpired, }; }; const CoverageServiceContext = createContext | undefined>(undefined); export const CoverageServiceProvider = ({ children }: { children: ReactNode }) => { const coverageService = useCoverageService(); return ( {children} ); }; export const useCoverageServiceContext = () => { const context = useContext(CoverageServiceContext); if (context === undefined) { throw new Error('useCoverageServiceContext must be used within a CoverageServiceProvider'); } return context; };