import { useRef, useSyncExternalStore } from 'react'; import type { Organization } from 'fhir/r4'; import { useAuthServiceContext } from '../../../services/AuthService.tsx'; import { useRefreshSessionOrganization } from '../../../hooks/useRefreshSessionOrganization'; import { mayHaveBeenWritten } from '../../../utils/fetchFailureMessage'; import { isSaveRetired, outcomeUnknown, useSaveScope, type SaveTransport } from '../../../utils/saveOperation'; import { olderThan } from '../../../utils/versionOrder'; import { hasLostAnswer, noteLostAnswer, onLostAnswerChange, onViewerChange, settleLostAnswers, startRead, writeThroughViewer } from '../../../utils/writeThroughHold'; // Saves to one practice's billing route go one after another, so a later save is built on what the earlier one saved: // the partial-payments toggle resends net days, and must send the terms just saved rather than the ones it opened with. const turns = new Map>(); const inTurn = (key: string, work: () => Promise): Promise => { const mine = (turns.get(key) ?? Promise.resolve()).catch(() => undefined).then(work); const settled = mine.catch(() => undefined); turns.set(key, settled); void settled.then(() => { if (turns.get(key) === settled) turns.delete(key); }); return mine; }; // The newest organization a billing save returned, per practice, for a save queued behind it before the page re-renders. const published = new Map(); onViewerChange(() => published.clear()); const newer = (a: Organization | null | undefined, b: Organization | undefined) => (!a ? b : !b || olderThan(b, a) ? a : b); export type BillingSaveOutcome = | { outcome: 'saved' } | { outcome: 'retired' } | { outcome: 'refused'; error: unknown } | { outcome: 'unconfirmed' } | { outcome: 'held' }; // Every billing route a section saves to: one read of the practice settles a lost answer on any of them. const routes = new Set(); // Which section's save lost its answer on a route, so its siblings say the hold is another's. const lostBy = new Map(); class RouteUnsettled extends Error {} // An answer from the server that refused the write: nothing of it landed. const refusal = (error: unknown) => typeof (error as { status?: unknown } | null)?.status === 'number' && !mayHaveBeenWritten(error); /** * One billing default's saves, owned by the practice on screen and the person signed in. A switch of either, or the * section closing, retires them: nothing unsent goes out, and no answer is shown or published afterwards. */ export const useBillingDefaultSave = (route: string, field: string, onRetired: () => void) => { const { currentOrganization, setWpOrganization } = useAuthServiceContext(); const refresh = useRefreshSessionOrganization(); const practiceId = currentOrganization?.id; const scope = useSaveScope(practiceId, onRetired); const live = useRef(currentOrganization); live.current = currentOrganization; const current = () => newer(live.current, practiceId ? published.get(practiceId) : undefined); routes.add(route); const turn = (practiceId ?? '') + ' ' + route; // A sent save whose answer was lost holds its route for the viewer who sent it (person, acted-as link and // practice) until a read of theirs settles it; another viewer neither sees nor clears it. const holdOf = (viewer: string | null, each = route) => (viewer ?? '') + ' ' + (practiceId ?? '') + ' ' + each; const held = useSyncExternalStore(onLostAnswerChange, () => hasLostAnswer(holdOf(writeThroughViewer()))); const checksHere = held && lostBy.get(holdOf(writeThroughViewer())) === field; const unsettled = held ? (checksHere ? SAVE_UNCONFIRMED : SIBLING_UNCONFIRMED) : undefined; const save = async ( write: (transport: SaveTransport, organization: Organization | null | undefined) => Promise<{ organization: Organization }>, ): Promise => { const operation = scope.start(); const owner = holdOf(writeThroughViewer()); try { const { organization } = await inTurn(turn, async () => { // What this save would resend with its own field may already have been changed by the lost one. if (hasLostAnswer(owner)) throw new RouteUnsettled(); let sent = false; let answered = false; const transport = { ...operation.transport, onSend: () => { sent = true; } }; try { return await operation.step(() => write(transport, current()).then( (answer) => { answered = true; return answer; }, (error) => { answered = refusal(error); throw error; }, ), true); } catch (error) { if (sent && !answered) { lostBy.set(owner, field); noteLostAnswer(owner); } throw error; } }); if (!operation.belongs()) return { outcome: 'retired' }; const held = current(); // An older snapshot never undoes a newer one the session already holds. if (organization?.id === practiceId && (!held || !olderThan(organization, held))) { published.set(organization.id as string, organization); setWpOrganization(organization); } return { outcome: 'saved' }; } catch (error) { if (isSaveRetired(error) || !operation.belongs()) return { outcome: 'retired' }; if (error instanceof RouteUnsettled) return { outcome: 'held' }; // Unknown only once it was sent: one stopped before that went nowhere. return outcomeUnknown(error) && hasLostAnswer(owner) ? { outcome: 'unconfirmed' } : { outcome: 'refused', error }; } finally { operation.retire(); } }; /** The practice as saved now, read after an answer was lost; null when it could not be read. */ const readBack = async (): Promise => { const operation = scope.start(); const viewer = writeThroughViewer(); const started = startRead(); try { const fresh = await operation.step(() => refresh(operation.transport)); if (!operation.belongs()) return 'retired'; if (!fresh?.id) return null; // A copy newer than this read was saved after it, so it, not the read, is what is saved now. const held = current(); const now = held && olderThan(fresh, held) ? held : fresh; if (now === fresh) published.set(fresh.id, fresh); settleLostAnswers([...routes].map((each) => holdOf(viewer, each)), started); return now; } catch (error) { if (isSaveRetired(error) || !operation.belongs()) return 'retired'; return null; } finally { operation.retire(); } }; return { practiceId, organization: currentOrganization, save, readBack, unsettled, checksHere }; }; export const SAVE_UNCONFIRMED = 'The answer to a save here was lost, so it may have saved. Check what was saved before trying again.'; export const SIBLING_UNCONFIRMED = 'Another billing setting here lost the answer to its save. Check what it saved before changing this.'; export const READ_BACK_FAILED = 'What was saved could not be read. Try again.';