import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, useLayoutEffect } from 'react'; import { useHttpService } from './HttpService.tsx'; import { useAuthServiceContext } from './AuthService.tsx'; import { environment } from '../environments/environment.ts'; import { isPermanentDenial } from '../utils/fetchFailureMessage.ts'; import { dunningFromStaffNotice } from './staffSubscriptionNotice.ts'; import { announceSeatOwner, bindSeatOwner, registerSeatScope, retireSeatOwner, setSeatStanding, type SeatStanding } from '../utils/seatStanding.ts'; // - no appointment fields anywhere below. Appointments stopped being a monetized dimension // (Spencer, 2026-08-04) and the server no longer derives, enforces or returns a limit for them. // // THE FIELDS HAD TO GO RATHER THAN JUST STOP BEING RENDERED. `SubscriptionPage` read // `entitlements?.appointmentLimitPerMonth ?? 100`, so leaving the field in place while the server // stopped sending it would have made the page DISPLAY A 100-APPOINTMENT WALL THAT NO LONGER EXISTS - // a fallback quietly presenting a retired limit as current. Deleting the field makes that // unrepresentable instead of relying on every reader remembering. // - `staffSeatLimit` is gone for the same reason `appointmentLimitPerMonth` was. // Non-clinician staff are FREE and UNCAPPED (the server gate went in 6b-1) and billing is per CLINICIAN. // Leaving the field would have been WORSE than the appointment case: `UsersPage` read // `entitlements?.staffSeatLimit ?? 2` and used it to DISABLE the Add-staff button, so a stale fallback // was a functional lockout, not a wrong label. // - no `questionnaireLimit` either. Questionnaires are core product: no charge, no cap, not a // subscription concept. The SERVER still sends the field, permanently unlimited, purely so any client // still applying a `?? 1` fallback does not cap itself at one. Nothing here reads it, so it is off the // client contract - do not re-add it and derive a ceiling from it. export interface BillingEntitlements { storageGBLimit: number; smsRemindersEnabled: boolean; smsPromotionsEnabled: boolean; eVisitEnabled: boolean; /** Absent on a patient read the server did not answer for: not known, never false. */ directPatientPaymentsEnabled?: boolean; /** * - the per-clinician AI Notes add-on. The server has always sent this; the client simply never * read it, which is how the encounter came to ask a patient for permission to record a visit at a * practice that had not bought recording. * `entitlements` is `null` until the first fetch resolves, so a consumer must test for `=== true`. * **Absent is NOT false here** - it is "not known yet", and a gate that treats the two alike either * flashes a control it is about to hide or hides one it should show. */ aiNotesEnabled: boolean; /** * - DECLARED EVEN THOUGH NOTHING READS THEM YET, and that is the point. * * The server has always derived and sent all seven purchasable flags. The client declared five, and * the two it omitted were **invisible to the type system and to anyone grepping for them** - which is * exactly how the AI Notes recording-consent prompt survived to be found in a live encounter rather * than in a search. A field the client does not declare cannot be gated on and cannot be found. * * Declaring is not gating. Whether each surface needs a client gate is answered per surface on card * 536; do not add five gates as a batch on the strength of these existing. */ campaignsEnabled: boolean; /** * A CAP LIFTER, not a feature toggle - telehealth works for every org at 20 hosted visits a month, * and this only removes the ceiling. There is no control to hide when it is false, which is why this * one legitimately has no client gate. */ telehealthUnlimitedEnabled: boolean; } export interface BaseLimits { storageGB: number; } /** * What to say about a failing card, and to whom. * * Spencer's ruling, 2026-08-14. DERIVED SERVER-SIDE and sent whole, deliberately: assembling this * on the client out of status + attempt count + dates would be a second opinion on when to alarm a * practice, and the two would disagree the first time either side grew a case. */ export interface SubscriptionDunning { stage: 'none' | 'admin-only' | 'all-staff' | 'stopped'; notifyAdmin: boolean; notifyAllStaff: boolean; /** Undefined when unknown - never a guessed number on a warning a practice will act on. */ daysRemaining?: number; } // - `extraQuestionnairePrice` is gone: the server stopped sending it when questionnaires // stopped being sold. Leaving the field declared would have let a surface read `undefined` as a price // and render "$undefined/mo" with no type error to warn anyone. export interface AddOnPricing { extraStorageBlockPrice: number; directPatientPaymentsPrice: number; // /311 - every purchasable capability's unit price, keyed by add-on key, in CENTS. // A capability whose Stripe Price is not configured is ABSENT from this map, never zero. The // page's rule is that a row with no price does not render at all - a $0 row would read as free. // Cents, not dollars, unlike the two legacy fields above: money in a float is how a total ends // up a cent out, and `utils/money.ts` exists because this page's predecessor did exactly that. unitPriceCents?: Partial>; // The one-time A2P carrier registration. Stated BEFORE the SMS toggle, never at checkout. smsRegistrationOneTimeCents?: number; } /** * What the practice is actually paying, read from the live Stripe subscription. * * done-when 1. None of this existed in the client contract before - the subscription page * showed a status badge and two raw Stripe identifiers, and never stated a price. */ export interface SubscriptionSummary { monthlyTotalCents: number; /** `YYYY-MM-DD`, or null when there is nothing to charge. Never a guessed date. */ nextChargeAt: string | null; /** The same moment as an instant, from a server that sends it. */ nextChargeInstant?: string | null; /** True when the plan is set to end rather than renew; there is then no next charge. */ cancelAtPeriodEnd?: boolean; /** `YYYY-MM-DD`, the last day already paid for: the renewal date, or the end of a cancelled plan. */ paidThrough?: string | null; paidThroughInstant?: string | null; /** * - `label` is what the page renders, and `brand`/`last4` are card-only detail. * * A real successful charge showed an EMPTY "Payment method" heading, because the method's type was * Stripe **Link** rather than `card`: there is no brand and no last four to assemble a sentence from, * so the old `{brand} ending {last4}` produced nothing at all. The server now names the method and * the client renders that name. Do not go back to composing the sentence here - the set of payment * method types is Stripe's, and it grows. */ paymentMethod: { brand: string; last4: string; label: string } | null; } export interface BillingUsage { activeSeatCount: number; activeQuestionnaireCount: number; storageBytesUsed: number; // The server still COUNTS appointments for usage analytics ( kept the counter, decoupled // from billing) - but nothing in the client renders it any more, so it is not carried here. currentMonth: { monthKey: string; }; /** * The hosted-video allowance, DERIVED BY THE SERVER and never re-derived here. * * The server is the only party that knows the free ceiling, this month's count and whether the * add-on lifts it, and it refuses the visit itself past the allowance. A client computing its own * would be a second opinion about money that can disagree with the gate actually refusing. * * Optional because a server too old to send it has said nothing, and nothing said must not render * as zero used. */ telehealth?: { limit: number; used: number; remaining: number; exhausted: boolean }; } export interface BillingLimitError { limitType: string; currentUsage: number; limit: number; upgradeTarget: string; diagnostics: string; } interface PatientEntitlementsResponse { eVisitEnabled: boolean; smsRemindersEnabled: boolean; directPatientPaymentsEnabled?: boolean; billingStatus: string; } /** * WHY THE ENTITLEMENTS ARE NOT KNOWN, when they are not. * * `null` on `entitlements` already means *not known yet*, and the type's own note says absent is * not false. But a page rendering after the fetch has finished cannot tell *not known yet* from * *we asked and were refused*, and the two need different sentences: one is a boundary the reader * can do something about, the other is a fault they can retry. * * `denied` the server refused the read - 401 or 403 * `failed` anything else: a network fault, a 5xx, a parse error * `noPractice` a patient read that was not made: no practice is chosen, so there is none to ask * `null` no failure - either the read succeeded, or it has not run yet */ export type EntitlementsUnavailable = 'denied' | 'failed' | 'noPractice' | null; /** Who a billing read belongs to. The generation moves on every change of viewer, role, practice, acted-for * person or auth session, and is never reused, so a practice left and returned to is a new owner. */ export interface BillingReadOwner { viewerId: string | null; practiceId: string | null; actingAsId: string | null; generation: number; } /** * How a billing read ended. Only `answered` is fresh: it was admitted for `owner` and published, and carries * exactly what it published (unknown stays null, never 0). `retired` published nothing: its owner changed, a later read * had already answered, or the caller aborted it. */ export type BillingReadOutcome = | { kind: 'answered'; owner: BillingReadOwner; addOns: Partial> | null; monthlyTotalCents: number | null } | { kind: 'refused'; owner: BillingReadOwner; status: number } | { kind: 'failed'; owner: BillingReadOwner } | { kind: 'noPractice'; owner: BillingReadOwner } | { kind: 'retired'; owner: BillingReadOwner }; /** Thrown inside a read whose owner ended or whose caller aborted; it is a retirement, not a failure. */ class BillingReadRetired extends Error {} /** One owner's lifetime: its generation, the auth session it was opened in, and a signal aborted when it ends. */ type OwnerLifetime = { generation: number; session: number | undefined; ended: AbortController }; const isRetired = (error: unknown): boolean => error instanceof BillingReadRetired || (error as { name?: unknown } | null)?.name === 'AbortError'; interface BillingContextValue { entitlements: BillingEntitlements | null; /** * SET WHENEVER `entitlements` IS NULL BECAUSE THE READ DID NOT SUCCEED. * * MEASURED: `GET /R4/Stripe/entitlements` answers 403 for a Platform Manager, the catch below * discarded the error, and `entitlements` stayed null - so Patient Payment Settings told them * *"Direct patient payments are not enabled in your organization's subscription"*. That is a * statement about the practice's plan, made by a client that never learned anything about the * plan. A refusal rendering as a fact about the record, which is the rule this exists to keep. */ entitlementsUnavailable: EntitlementsUnavailable; /** * NULL means "we could not read it", and the page must say nothing rather than $0. A zeroed * summary shown to a paying practice is a false statement about their own bill. */ subscription: SubscriptionSummary | null; /** * - the OTHER active Stripe subscriptions this practice holds. * * A second active subscription means they are being charged twice and nothing anywhere told * them; the one real instance was found because a human opened the Stripe dashboard. * * THREE STATES, AND THE LAST TWO ARE NOT THE SAME. A non-empty array is a duplicate; `[]` * means we checked and there is none; **`null` means we could not check**, and the page must * then say NOTHING rather than an all-clear it did not earn - the same rule `subscription` * above follows for the monthly total. */ otherActiveSubscriptions: string[] | null; /** * Clinicians enabled in Staff. Three settled prices are PER CLINICIAN, so the bill cannot be stated * without it. Undefined when the count could not be read - never 0, which would render a $0 seat * line to a practice with four clinicians. */ clinicianCount: number | undefined; dunning: SubscriptionDunning | null; /** * - can the out-of-band credential-change notice actually be TEXTED right now? * * A DEPLOYMENT fact, not an org one: the notice is sent with `billTo: null`, so the practice's SMS * add-on is irrelevant to it and the only thing that stops it is an unprovisioned transactional * stream. No per-org and no per-patient question to conflate. * * THREE STATES, and the third is the whole point. `undefined` is NOT KNOWN - a server that has * not shipped the field, or a read that failed. It must never coalesce to `false`: warning a * receptionist that a patient cannot be reached, on the strength of a fact nobody measured, is the * false-assertion class this codebase keeps paying for. */ smsTransactionalConfigured: boolean | undefined; smsRegistrationApproved: boolean | undefined; /** What the org holds today, per add-on key. The rows show current state from this. */ addOns: Partial> | null; baseLimits: BaseLimits | null; addOnPricing: AddOnPricing | null; usage: BillingUsage | null; billingStatus: string | null; billingMode: string | null; /** * Who is SUPPOSED to pay when there is something to charge - the answer an agency gave when it * enrolled this practice. * * A DIFFERENT FACT FROM `billingMode` ABOVE. That one describes money that is actually moving; this * one is an answer somebody gave before any was. A practice on the free plan whose agency will pay is * `free_basic` and `contractor` at the same time, and both are true. * * `null` when nobody has answered - which is every practice that was not enrolled by an agency - and * also when a server too old to send it answered the request. Neither is "the practice pays for * itself"; the screen says nothing rather than making a claim about money. */ subscriptionPayer: string | null; billingLimitError: BillingLimitError | null; setBillingLimitError: (error: BillingLimitError | null) => void; /** `deadline` is absolute (epoch ms): past it the read is neither sent nor published, whether or not a timer ran. */ refreshBilling: (options?: { seatChanged?: boolean; signal?: AbortSignal; deadline?: number }) => Promise; /** The owner the published values belong to; compare an outcome's owner with it before acting on it. */ billingOwner: BillingReadOwner; /** True while the clinician count is still behind a seat change this session just made. */ clinicianCountSettling: boolean; isLoading: boolean; /** Whether the server has been asked at all. See the state's own note. */ billingChecked: boolean; } type BillingLimitHandler = (error: BillingLimitError) => void; let _billingLimitHandler: BillingLimitHandler | null = null; export function registerBillingLimitHandler(handler: BillingLimitHandler): void { _billingLimitHandler = handler; } // Purchases already paid for, whose follow-up write is in flight: a refusal of that write is reported by the // screen making it, never as a fresh offer to buy the same thing again. const _paidRetries = new Map(); export function holdBillingLimitPrompt(upgradeTarget: string): () => void { _paidRetries.set(upgradeTarget, (_paidRetries.get(upgradeTarget) ?? 0) + 1); let released = false; return () => { if (released) return; released = true; const left = (_paidRetries.get(upgradeTarget) ?? 1) - 1; if (left > 0) _paidRetries.set(upgradeTarget, left); else _paidRetries.delete(upgradeTarget); }; } export function emitBillingLimitError(error: BillingLimitError): void { if (_paidRetries.has(error.upgradeTarget)) return; _billingLimitHandler?.(error); } type BillingPaymentCompleteListener = (upgradeTarget: string) => void; const _paymentCompleteListeners = new Set(); /** A screen whose save was refused for want of a subscription finishes that save once the checkout is paid. */ export function onBillingPaymentComplete(listener: BillingPaymentCompleteListener): () => void { _paymentCompleteListeners.add(listener); return () => { _paymentCompleteListeners.delete(listener); }; } export function emitBillingPaymentComplete(upgradeTarget: string): void { _paymentCompleteListeners.forEach((listener) => listener(upgradeTarget)); } const BillingContext = createContext(undefined); export const BillingProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { const { get } = useHttpService(); const { isLoggedIn, userRole, USER_ROLES, currentOrganization, actingAs, currentUser, actingAsSessionRef } = useAuthServiceContext(); const practiceId = currentOrganization?.id; const [entitlements, setEntitlements] = useState(null); const [entitlementsUnavailable, setEntitlementsUnavailable] = useState(null); const [baseLimits, setBaseLimits] = useState(null); const [addOnPricing, setAddOnPricing] = useState(null); const [subscription, setSubscription] = useState(null); const [otherActiveSubscriptions, setOtherActiveSubscriptions] = useState(null); const [clinicianCount, setClinicianCount] = useState(undefined); const [addOns, setAddOns] = useState> | null>(null); const [dunning, setDunning] = useState(null); // Whether the warning on show came from the staff notice rather than the full billing answer. const noticeHeldRef = useRef(false); const [usage, setUsage] = useState(null); const [billingStatus, setBillingStatus] = useState(null); const [billingMode, setBillingMode] = useState(null); const [subscriptionPayer, setSubscriptionPayer] = useState(null); const [billingLimitError, setBillingLimitError] = useState(null); // - THREE states, and the third is the point. See the context type. const [smsTransactionalConfigured, setSmsTransactionalConfigured] = useState(undefined); const [smsRegistrationApproved, setSmsRegistrationApproved] = useState(undefined); const [isLoading, setIsLoading] = useState(true); /** * HAS ANYBODY ASKED THE SERVER YET - which is a different question from whether we are loading. * * `subscription` is `null` both when the read FAILED and before anybody has read at all, and the * subscription page renders "We couldn't load your billing details just now" on `null`. Measured * 2026-09-14 on a cold load of a paying practice: that sentence was on screen while * `GET /R4/Stripe/entitlements` answered 200 with the whole subscription in it, and the real total * replaced it seconds later with no reload. * * The cause is the effect below, not the fetch: it only fetches once a ROLE has resolved, and its * other arm has already settled `isLoading` to false by then - deliberately, because a context that * can never settle once took the whole staff app dark. So there is a window where nothing is * loading, nothing has been asked, and the page says the ask failed. * * TRUE ONLY WHEN AN ANSWER EXISTS: a read that has finished either way, or a signed-out session, * where there is genuinely nothing to ask. Signed in with no role yet leaves it false, so the page * waits rather than reporting a failure nobody suffered. */ const [billingChecked, setBillingChecked] = useState(false); /** * THE COUNT IS BEHIND A SEAT CHANGE FOR A MINUTE OR TWO, AND THE PAGE MUST NOT PRICE FROM IT. * * `clinicianCount` is derived server-side from a search index that lags the PractitionerRole a seat * change has just written. Measured on staging: a practice that seated a fourth clinician read three * for several minutes and reached four on its own, with nothing corrected. * * So a refresh that follows a seat change and comes back with the SAME count has not caught up yet. * That is what this records, and it is why the flag is set here rather than by each caller: the two * seat paths already both call the refresh, and asking them to remember a second call is the shape * that left one of them out the first time. */ const [clinicianCountSettling, setClinicianCountSettling] = useState(false); const clinicianCountRef = useRef(undefined); clinicianCountRef.current = clinicianCount; // WHO A READ BELONGS TO: the signed-in viewer, role, practice, acted-for person and auth session (which moves on // sign-out and on every act-as set or clear, the same person included). Any change opens a new owner // generation during render, so even the render before effects never shows one owner another's answer. const viewerId = currentUser?.fhir?.id ?? null; const actingAsId = actingAs?.relatedPersonId ?? null; // A mount after cleanup (re-entry) is a new lifetime too, so nothing of the earlier one revives. const [reentry, setReentry] = useState(0); const ownerScope = [isLoggedIn, viewerId, userRole, practiceId, actingAsId, actingAsSessionRef?.current, reentry].join('|'); const lifetimeRef = useRef(); const ownerScopeRef = useRef(ownerScope); if (!lifetimeRef.current || ownerScopeRef.current !== ownerScope) { ownerScopeRef.current = ownerScope; // Ending the previous lifetime settles every read still held under it. lifetimeRef.current?.ended.abort(); lifetimeRef.current = { generation: (lifetimeRef.current?.generation ?? 0) + 1, session: actingAsSessionRef?.current, ended: new AbortController(), }; } const lifetime = lifetimeRef.current; const ownerGeneration = lifetime.generation; // The lifetime object is the seat standing's owner: distinct across owners, sessions and remounts, and named here // in render so the children rendered next never read the previous owner's standing. bindSeatOwner(lifetime); const billingOwner = useMemo( () => ({ viewerId, practiceId: practiceId ?? null, actingAsId, generation: ownerGeneration }), [viewerId, practiceId, actingAsId, ownerGeneration], ); useEffect(() => { // Re-entry after a cleanup opens a fresh lifetime rather than reusing the one cleanup ended. if (lifetimeRef.current?.ended.signal.aborted) setReentry((n) => n + 1); return () => { lifetimeRef.current?.ended.abort(); }; }, []); // The seat standing belongs to this owner: a new owner withholds the old answer, and a free-seat request is // admitted only while the owner it was prepared for is still the current one. const billingOwnerRef = useRef(billingOwner); billingOwnerRef.current = billingOwner; useLayoutEffect(() => { announceSeatOwner(); return () => retireSeatOwner(lifetime); }, [lifetime]); useEffect(() => registerSeatScope(() => { const own = lifetimeRef.current!; const live = () => !own.ended.signal.aborted && lifetimeRef.current === own && actingAsSessionRef?.current === own.session; return { owner: own, organizationId: billingOwnerRef.current.practiceId ?? undefined, signal: own.ended.signal, live, admit: () => { if (!live()) throw new BillingReadRetired(); }, }; }), [actingAsSessionRef]); // Reads are numbered as they start; one publishes only if no later read has published already. const readSequenceRef = useRef(0); const publishedReadRef = useRef(0); // The owner generation the stored values were published for. Values for any other owner are not shown. const [publishedFor, setPublishedFor] = useState(0); const publishedForRef = useRef(0); const clearPublished = useCallback(() => { setEntitlements(null); setEntitlementsUnavailable(null); setBaseLimits(null); setAddOnPricing(null); setSubscription(null); setOtherActiveSubscriptions(null); setClinicianCount(undefined); setClinicianCountSettling(false); setAddOns(null); setDunning(null); noticeHeldRef.current = false; setUsage(null); setBillingStatus(null); setBillingMode(null); setSubscriptionPayer(null); setSmsTransactionalConfigured(undefined); setSmsRegistrationApproved(undefined); setBillingChecked(false); setSeatStanding(null); }, []); const refreshBilling = useCallback(async (options?: { seatChanged?: boolean; signal?: AbortSignal; deadline?: number }): Promise => { // The owner this callback was made for. A callback kept from an ended owner starts nothing and changes nothing. const owner = billingOwner; const own = lifetime; const retired: BillingReadOutcome = { kind: 'retired', owner }; // The session is the one the owner was opened in, not whatever the ref holds when this runs. const live = () => !own.ended.signal.aborted && lifetimeRef.current === own && actingAsSessionRef?.current === own.session && !options?.signal?.aborted && !(options?.deadline !== undefined && Date.now() >= options.deadline); if (!live()) return retired; const read = ++readSequenceRef.current; const mayPublish = () => live() && publishedReadRef.current < read; // A failure is cleared by the read that replaces it, at publication, so a retired read clears nothing. const publish = () => { if (publishedForRef.current !== owner.generation) clearPublished(); publishedReadRef.current = read; publishedForRef.current = owner.generation; setPublishedFor(owner.generation); setEntitlementsUnavailable(null); // Every answer replaces the standing: a patient, no-practice or failed read leaves none behind. setSeatStanding(null, own); }; // Checked by the transport before the token and proof waits and again just before sending, so a read is // never sent for an owner that has ended; the owner's practice is named rather than read from shared state. // Ends with the owner or the caller, whichever is first, and cancels the request itself. const cancel = new AbortController(); const stop = () => cancel.abort(); own.ended.signal.addEventListener('abort', stop, { once: true }); options?.signal?.addEventListener('abort', stop, { once: true }); if (!live()) stop(); const transport = { organizationId: owner.practiceId ?? undefined, signal: cancel.signal, admit: () => { if (!live()) throw new BillingReadRetired(); }, }; // The owner ending or the caller aborting settles the caller at once, whatever the request still waits on. const held = (pending: Promise): Promise => new Promise((resolve, reject) => { const onStop = () => reject(new BillingReadRetired()); if (cancel.signal.aborted) { onStop(); return; } cancel.signal.addEventListener('abort', onStop, { once: true }); pending.then(resolve, reject).finally(() => cancel.signal.removeEventListener('abort', onStop)); }); // A staff member refused the bill is still owed the all-staff and stopped warnings. Read after the refusal // is published, so a slow answer never holds the app; it lands only while this read is still the current answer. const readStaffNotice = (read: number) => { const stopNotice = new AbortController(); const end = () => stopNotice.abort(); own.ended.signal.addEventListener('abort', end, { once: true }); const current = () => live() && publishedReadRef.current === read; get<{ notice?: unknown }>(`${environment.apiUrl}/Stripe/subscription-notice`, { organizationId: owner.practiceId ?? undefined, signal: stopNotice.signal, admit: () => { if (!current()) throw new BillingReadRetired(); }, }).then((answer) => { const notice = dunningFromStaffNotice(answer?.notice); if (!notice || !current()) return; setDunning(notice); noticeHeldRef.current = true; }, () => undefined).finally(() => own.ended.signal.removeEventListener('abort', end)); }; const countBefore = options?.seatChanged ? clinicianCountRef.current : undefined; const isPatientRole = userRole === USER_ROLES.PATIENT || userRole === USER_ROLES.RELATED_PERSON; try { if (isPatientRole && !practiceId) { // No practice is chosen (the patients host before one is picked), so no practice is asked // about its add-ons. They are unknown, never off; the read runs once a practice is chosen. if (!mayPublish()) return retired; publish(); setEntitlements(null); setEntitlementsUnavailable('noPractice'); setBillingStatus(null); setSubscription(null); setClinicianCount(undefined); setAddOns(null); setDunning(null); setOtherActiveSubscriptions(null); return { kind: 'noPractice', owner }; } else if (isPatientRole) { const response = await held(get( `${environment.apiUrl}/Stripe/patient-entitlements`, transport, )); if (!mayPublish()) return retired; publish(); setEntitlements({ eVisitEnabled: response.eVisitEnabled, smsRemindersEnabled: response.smsRemindersEnabled, storageGBLimit: 0, smsPromotionsEnabled: false, // Checkout's own add-on gate, so the portal hides Pay only where checkout would refuse. directPatientPaymentsEnabled: typeof response.directPatientPaymentsEnabled === 'boolean' ? response.directPatientPaymentsEnabled : undefined, // A PATIENT never holds these - they are practice add-ons, and the surfaces they gate // exist only in a staff session. The patient entitlements response carries neither. aiNotesEnabled: false, campaignsEnabled: false, telehealthUnlimitedEnabled: false, }); setUsage({ activeSeatCount: 0, activeQuestionnaireCount: 0, storageBytesUsed: 0, currentMonth: { monthKey: '', }, }); setBillingStatus(response.billingStatus); // A patient's entitlements response carries no bill - there is no practice subscription // to state. Cleared explicitly so a role switch cannot leave the previous org's totals on // screen. setSubscription(null); setClinicianCount(undefined); setAddOns(null); // A patient never sees a practice's billing trouble - it is not their subscription, and // naming it to them would disclose the practice's payment state to a stranger. setDunning(null); // A patient has no practice bill, so there is nothing to have a duplicate OF - and `null` // rather than `[]` for the same reason `setSubscription(null)` above is null: the question // does not apply, which is not the same claim as "we checked and there are none". setOtherActiveSubscriptions(null); return { kind: 'answered', owner, addOns: null, monthlyTotalCents: null }; } else { const response = await held(get<{ baseLimits: BaseLimits; entitlements: BillingEntitlements; usage: BillingUsage; billing: { status: string; billingMode: string; subscriptionPayer?: string }; addOnPricing: AddOnPricing; subscription: SubscriptionSummary | null; otherActiveSubscriptions?: string[] | null; clinicianCount?: number; seat?: SeatStanding; addOns?: Partial>; dunning?: SubscriptionDunning; notifications?: { smsTransactionalConfigured?: boolean; smsCarrierRegistration?: { approved?: boolean } }; }>(`${environment.apiUrl}/Stripe/entitlements`, transport)); if (!mayPublish()) return retired; publish(); setBaseLimits(response.baseLimits); setEntitlements(response.entitlements); setUsage(response.usage); setBillingStatus(response.billing.status); setBillingMode(response.billing.billingMode); // `?? null`, and null is a real answer here rather than a fallback: an absent designation // means nobody has said who pays, which is not the same claim as the practice paying for // itself. Defaulting it would put a sentence about money on screen that nobody asserted. setSubscriptionPayer(response.billing.subscriptionPayer ?? null); setAddOnPricing(response.addOnPricing); // `?? null` and `?? undefined` rather than a zero default. Every defaulting expression // this surface has shipped was chosen for the happy path, where it never fires. setSubscription(response.subscription ?? null); // `?? null` on purpose, and it is the SAFE direction here: a server that has not // shipped this field yet has said nothing about duplicates, and "said nothing" must // not render as "you have none". setOtherActiveSubscriptions(response.otherActiveSubscriptions ?? null); setClinicianCount(response.clinicianCount); // Absent from an older server: no standing, so nothing is withheld on the client. setSeatStanding(response.seat ?? null, own); // Unchanged after a seat change means the index has not caught up. The page shows that // it is settling rather than stating a number it may have to revise. setClinicianCountSettling( countBefore !== undefined && response.clinicianCount === countBefore, ); setAddOns(response.addOns ?? null); setDunning(response.dunning ?? null); noticeHeldRef.current = false; // - `undefined` is NOT KNOWN and must never coalesce to a boolean. `false` // means "this deployment cannot text the patient", which is a claim; a server that has // not shipped this field yet has said nothing, and the dialog must then say nothing // rather than warn a receptionist about a channel it never measured. setSmsTransactionalConfigured(response.notifications?.smsTransactionalConfigured); setSmsRegistrationApproved(response.notifications?.smsCarrierRegistration?.approved); return { kind: 'answered', owner, addOns: response.addOns ?? null, monthlyTotalCents: response.subscription?.monthlyTotalCents ?? null, }; } } catch (error) { if (isRetired(error) || !mayPublish()) return retired; publish(); console.error('Failed to fetch billing entitlements:', error); // A BARE CATCH WAS THE WHOLE DEFECT. The error was thrown away, so a refusal and a // dropped connection left the identical state - and every consumer then read the // absence as an answer. `isPermanentDenial` is the same 401/403 test the write-failure // copy already uses, so the two paths cannot disagree about what a refusal is. const status = (error as { status?: unknown } | null | undefined)?.status; if (typeof status === 'number' && isPermanentDenial(status)) { setEntitlementsUnavailable('denied'); // A refusal says nothing current about the warning; only this read's own notice may show one again. setDunning(null); noticeHeldRef.current = false; if (!isPatientRole) readStaffNotice(read); return { kind: 'refused', owner, status }; } setEntitlementsUnavailable('failed'); // A notice read under an earlier refusal is not this answer; a full answer's warning stays as before. if (noticeHeldRef.current) { setDunning(null); noticeHeldRef.current = false; } return { kind: 'failed', owner }; } finally { own.ended.signal.removeEventListener('abort', stop); options?.signal?.removeEventListener('abort', stop); // Only the read that published settles these; a retired one is not an answer. if (publishedReadRef.current === read && live()) { setIsLoading(false); // Both ways out of the try land here, and both are answers. setBillingChecked(true); } } }, [get, userRole, USER_ROLES, practiceId, billingOwner, lifetime, actingAsSessionRef, clearPublished]); useEffect(() => { registerBillingLimitHandler(setBillingLimitError); }, []); /** * THE THIRD STATE HAD NO BRANCH, AND ITS SYMPTOM WAS A BLANK PRODUCT. * * Signed in with no resolved role matched neither arm, so nothing fetched and `isLoading` kept its * initial `true` forever. `BillingGate` returns its spinner while this is true, so the entire staff * app rendered nothing at all - measured 2026-08-30 with every request 200 and no billing request * ever made. Whatever goes wrong upstream, a context that can never settle turns it into a screen * with no diagnosis on it. * * SETTLING IS THE SAFE DIRECTION, and it is the direction `billingGateDecision` already documents: * an unknown billing state falls through to `app`, because a bad answer must never take a paying * practice dark. It also introduces no flash - `isLoading` is only ever true on the first render, * since the signed-out arm has always cleared it and nothing sets it back. */ useEffect(() => { if (isLoggedIn && userRole) { void refreshBilling(); return; } setIsLoading(false); // SIGNED OUT IS AN ANSWER; signed in with no role yet is not. Only the first arm sets this, // so a page that waits for the read waits for the read rather than for the role. if (!isLoggedIn) { // Nothing the previous session read stays published for the signed-out owner. clearPublished(); publishedForRef.current = ownerGeneration; setPublishedFor(ownerGeneration); setBillingChecked(true); } }, [isLoggedIn, userRole, refreshBilling, clearPublished, ownerGeneration]); // Only what was published for the current owner is delivered; for any other owner every value is unknown. // isLoading stays the first-load gate, so a practice switch does not blank the app behind BillingGate. const current = publishedFor === ownerGeneration; return ( {children} ); }; export const useBillingContext = (): BillingContextValue => { const context = useContext(BillingContext); if (context === undefined) { throw new Error('useBillingContext must be used within a BillingProvider'); } return context; }; /** * The same context, but `undefined` instead of a throw when no provider is above.. * * THE THROWING HOOK IS RIGHT FOR A BILLING SURFACE and wrong for a consumer that only wants a * refinement. A subscription page without its billing data is broken and should say so loudly; the * credential-recovery dialog merely wants to add a caveat, and throwing there would take down the ONLY * place a receptionist can restore a patient's sign-in - to improve a warning. That trade is the same one * `useOptionalVideoServiceContext` and the capability service's neutral fallback already make. * * A caller of this must treat `undefined` as NOT KNOWN, never as a value. */ export const useOptionalBillingContext = (): BillingContextValue | undefined => useContext(BillingContext);