import { useCallback, useEffect, useRef, useState } from 'react'; import { usePracticeAgencyRemoval } from '../../../hooks/usePracticeAgencyRemoval'; import { SaveRetiredError } from '../../../utils/saveOperation'; import { Building2, Loader2, ShieldOff, X } from 'lucide-react'; import { Card, CardContent, CardHeader, CardTitle } from '../../../components/ui/card.tsx'; import { Button } from '../../../components/ui/button.tsx'; import { Alert, AlertDescription } from '../../../components/ui/alert.tsx'; import { AlertDialog, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from '../../../components/ui/alert-dialog.tsx'; import { EmptyState } from '../../../components/ui/empty-state.tsx'; import { StripeCheckoutModal } from '../../SubscriptionPage/StripeCheckoutModal.tsx'; import { useOrganizationContractorsService, type AssumeSubscriptionResult, type OrganizationContractorRow, } from '../../../services/OrganizationContractorsService.tsx'; import { relayServerFailure } from '../../../utils/fetchFailureMessage'; import { formatDayMonthYear } from '../../../utils/dateUtils'; import { useBillingContext } from '../../../services/BillingContext.tsx'; /** * WHO MANAGES THIS PRACTICE - and the two things a practice must be able to do about it. * * THE FAILURE THIS EXISTS TO PREVENT IS THE HIPAA FORMS ONE. An outside agency held access to practices * that had no way to see it, no way to end it, and no way to take their own billing back. All three of * these routes were built, permissioned and tested on the server, and not one of them had a caller in any * client - so the guarantee the agency model was designed around was, in practice, absent. * * ALL THREE ARE UNILATERAL, and that is the design rather than an omission. The contractor is not * consulted and does not have to agree, because each of these has to work on the day the agency stops * answering the phone. That is why the practice-side routes exist separately from the agency-side ones * and must never be merged with them. * * REVOKED ROWS STAY ON SCREEN. `agency.md` keeps a severed link rather than deleting it - "a severed * relationship is a fact worth holding" - and a practice asked in November about a contractor it fired in * March has nowhere else to look. So they are shown as history, dated, below the live ones. * * THE TAKEOVER IS OWNER-ONLY AND THE ROLE IS NOT THE GUARD. Owner is a designation on a PERSON, never a * role, precisely so that a contractor person holding an org Admin role cannot satisfy it. The server * refuses everyone else with `NOT_THE_OWNER`; this hides the control for the same reason, and the hiding * is courtesy - the refusal is the guard. */ /** The grant vocabulary, said in words a practice administrator can act on. */ const GRANT_LABELS: Record = { config: 'Plugin configuration and integration settings', branding: 'Branding and the “managed by” attribution', 'billing:read': 'Sees your subscription state', 'billing:sponsor': 'Pays for your subscription', 'status:read': 'Sees operational status', }; /** * The grant that RECORDS who agreed to pay - used to NAME the payer, never to decide whether one exists. * * THE DISTINCTION BETWEEN THE TWO FACTS IS THE WHOLE POINT. This screen used to derive "somebody is paying" * from a LIVE row carrying this grant, which is the ACCESS fact wearing the billing fact's clothes, and it * is wrong in both directions: * * revoke the agency the grant stops being live, the takeover control vanishes - while the * agency's card is still being charged. The product promises "it keeps running * until you take it back yourself" in the very dialog that removes the means. * never sponsored a link with no sponsorship still has no grant, so this happened to be right * by accident rather than by asking the right question. * * The server's own derivation says so by name: *"A practice that fires its contractor is STILL being * paid for by that contractor until the subscription actually moves... Keying on the link would flip the * mode the instant the contractor was fired and assert that the practice pays for itself while the * contractor's card is still charged. That is a false statement about money, made silently."* * * And it notes the asymmetry this screen must now respect: **access ends immediately on revocation; * payment does not.** Collapsing them in either direction is a defect. */ const SPONSOR_GRANT = 'billing:sponsor'; // The grant is permission to pay and outlives the payment, so it may not claim money is moving. const SPONSOR_GRANT_IDLE = 'May pay for your subscription'; /** * Somebody else's customer is paying for this practice RIGHT NOW. * * The server derives it from whose Stripe customer holds the live subscription and publishes it on the * entitlements read, so this asks the one authority rather than re-deriving a second opinion about money. */ const SPONSORED_MODE = 'contractor_sponsored'; /** The practice pays for its own subscription. */ const ORG_PAID_MODE = 'org_paid'; /** Nothing is being charged yet. It is a plan state, not the absence of billing. */ const FREE_BASIC_MODE = 'free_basic'; /** * WHO IS SUPPOSED TO PAY once there is something to charge - the answer an agency gave when it enrolled * this practice, and a different fact from the mode above. * * Settled by Spencer 2026-09-04: the contractor says whether it handles the subscription payments, and it * does not matter that the practice starts free. So a practice can be `free_basic` and designated to an * agency at the same time, and both statements are true. */ const CONTRACTOR_PAYER = 'contractor'; const ORGANIZATION_PAYER = 'organization'; const isLive = (row: OrganizationContractorRow) => !Object.prototype.hasOwnProperty.call(row, 'revokedAt'); const onDate = (value?: string) => { if (!value) return undefined; return Number.isNaN(new Date(value).getTime()) ? undefined : formatDayMonthYear(value); }; const agencyName = (row: OrganizationContractorRow) => row.name?.trim() || 'A contractor we cannot name'; interface ContractorAccessSectionProps { organizationId?: string; /** * The OWNER DESIGNATION, resolved once by the owner notice above and passed down rather than asked * for again. `undefined` means not yet known, and is deliberately not treated as `false`: showing an * owner-only control to nobody while the answer is in flight is better than flashing it away from the * one person entitled to it. */ viewerIsOwner?: boolean; /** Each list this section reads, so the account row behind the panel says what the panel says. */ onRowsRead?: (rows: OrganizationContractorRow[]) => void; } const ContractorAccessSection = ({ organizationId, viewerIsOwner, onRowsRead }: ContractorAccessSectionProps) => { const { listContractors, revokeContractor, assumeSubscription } = useOrganizationContractorsService(); const removal = usePracticeAgencyRemoval(organizationId, revokeContractor); // The billing fact, from the read that already carries it - no new request, and no second opinion // about money assembled on this page. const { billingMode, subscriptionPayer, refreshBilling } = useBillingContext(); const [rowView, setRowView] = useState<{ opening?: string; rows: OrganizationContractorRow[] }>(); const rows = rowView?.opening === removal.opening ? rowView?.rows ?? [] : []; const [loading, setLoading] = useState(false); const [attempted, setAttempted] = useState(false); const [error, setError] = useState(); const [confirmingOpening, setConfirmingOpening] = useState(); const [confirming, setConfirming] = useState(); const [busy, setBusy] = useState(false); const [actionError, setActionError] = useState(); const [notice, setNotice] = useState(); // Each carries the practice it was made for, so the render before the opening closes cannot show it elsewhere. const [checkout, setCheckout] = useState<{ secret: string; organizationId: string } | null>(null); const [zeroCostOffer, setZeroCostOffer] = useState<{ message?: string; organizationId: string }>(); // ONE OPENING PER PRACTICE AND OWNER STANDING. A takeover request, its answer, the question it asks and the // checkout it opens belong to the opening they were made in. A change of practice or of Owner standing closes // it and all of them with it; coming back opens a new one, so nothing from before is shown again. The server // keeps any operation already sent, and the next press asks it afresh. const opening = useRef(0); useEffect(() => { setZeroCostOffer(undefined); setCheckout(null); setBusy(false); // Closed on any change of practice or Owner standing, and when the section goes. return () => { opening.current += 1; }; }, [organizationId, viewerIsOwner]); // Present and for this practice: with no practice known yet, nothing compares equal to it. const zeroCostOpen = Boolean(zeroCostOffer && organizationId && zeroCostOffer.organizationId === organizationId) && viewerIsOwner === true; const checkoutSecret = checkout && organizationId && checkout.organizationId === organizationId && viewerIsOwner === true ? checkout.secret : null; const admissionFor = (mine: number) => () => { if (opening.current !== mine) throw new SaveRetiredError(); }; const readSequence = useRef(0); const load = useCallback(async () => { const admit = removal.capture(); try { admit(); } catch { return; } const sequence = ++readSequence.current; setLoading(true); setError(undefined); try { const result = await listContractors(admit, organizationId); admit(); if (sequence !== readSequence.current) return; if (!result || !Array.isArray(result.contractors)) throw new Error('The contractor list was unreadable.'); setRowView({ opening: removal.opening, rows: result.contractors }); onRowsRead?.(result.contractors); } catch (failure) { try { admit(); } catch { return; } if (sequence === readSequence.current) setError(relayServerFailure(failure, 'The connection failed, or you do not have access to this list.')); } finally { try { admit(); if (sequence === readSequence.current) { setAttempted(true); setLoading(false); } } catch { /* The current opening owns its read state. */ } } }, [listContractors, onRowsRead, organizationId, removal.opening]); useEffect(() => { setConfirming(undefined); setNotice(undefined); setActionError(undefined); setAttempted(false); if (organizationId) void load(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [organizationId, removal.opening]); const handleRevoke = async (row: OrganizationContractorRow) => { if (confirmingOpening !== removal.opening) return; const admit = removal.capture(); const result = await removal.start(row.contractorId, agencyName(row)); try { admit(); } catch { return; } if (result?.kind === 'fenced') { setConfirming(undefined); setNotice('The original access was ended. Current access is checked separately. Your subscription is unchanged.'); await load(); } }; // An answer for an opening this screen no longer shows is dropped rather than shown here. const settleAssume = (result: AssumeSubscriptionResult, mine: number, askedFor: string) => { if (opening.current !== mine) return; if ('clientSecret' in result) { setCheckout({ secret: result.clientSecret, organizationId: askedFor }); return; } if (result.state === 'confirm') { setZeroCostOffer({ message: result.message, organizationId: askedFor }); return; } setZeroCostOffer(undefined); setNotice(result.message); void refreshBilling(); }; const handleAssume = async () => { if (!organizationId) return; const mine = opening.current; setBusy(true); setActionError(undefined); setNotice(undefined); try { settleAssume(await assumeSubscription(organizationId, window.location.href, false, admissionFor(mine)), mine, organizationId); } catch (failure) { if (opening.current === mine) { setActionError(relayServerFailure(failure, 'We could not open the takeover checkout. Nothing was charged.')); } } finally { if (opening.current === mine) setBusy(false); } }; // The Owner's confirmation, and the only request that ends the agency's subscription. const handleConfirmZeroCost = async () => { if (!organizationId || !zeroCostOpen) return; const mine = opening.current; setBusy(true); setActionError(undefined); try { settleAssume(await assumeSubscription(organizationId, window.location.href, true, admissionFor(mine)), mine, organizationId); } catch (failure) { if (opening.current === mine) { setActionError(relayServerFailure( failure, 'We did not hear back about taking billing back. Nothing was charged. Open this again in a minute to see where it stands.', )); } } finally { if (opening.current === mine) setBusy(false); } }; const live = rows.filter(isLive); const ended = rows.filter((row) => !isLive(row)); /* * WHO IS PAYING - asked of the billing read, not inferred from who still has access. * * `null` while the answer is in flight, and that is deliberately NOT treated as "nobody pays": the * same reasoning the owner designation above documents. Saying nothing until it is known beats * flashing "You pay for this practice" at a sponsored practice for a frame. */ const sponsored = billingMode === SPONSORED_MODE; /* * The payer's NAME, and it is looked up across ENDED rows as well as live ones on purpose - a revoked * agency that is still being charged is exactly the case this screen used to get wrong, and it is the * one where naming them matters most. * * It never decides WHETHER to offer the takeover; `sponsored` above does that. If the sponsorship * predates any row we hold, the fact is still stated - unnamed rather than unsaid. */ const payer = rows.find((row) => (row.grants ?? []).includes(SPONSOR_GRANT)); return (

Contractors you have given access to. You can end a contractor’s access at any time without asking them, and doing so does not change your subscription.

{loading && (

)} {/* A FAILED LOAD AND AN EMPTY LIST ARE DIFFERENT FACTS, and on this screen the reassuring direction is the wrong one to be wrong in: "no agency has access" is exactly what somebody auditing this practice must not be told by mistake. NOT `ResourceListStatus` - its failure detail is written for a clinical list and says "you may not have access to this patient's record", which is not this screen's sentence and would be nonsense here. The invariant is the same; the words are not. */} {error && ( We couldn’t load which contractors have access to this practice. {' '} {error} This is not a statement that none have access. )} {!loading && !error && attempted && rows.length === 0 && ( )} {actionError && !confirming && !zeroCostOpen && ( {actionError} )} {notice && ( {notice} )} {live.map((row) => (

{agencyName(row)}

{row.status && row.status !== 'active' && ( {row.status} )}
    {(row.grants ?? []).map((grant) => (
  • {grant === SPONSOR_GRANT && !sponsored ? SPONSOR_GRANT_IDLE : GRANT_LABELS[grant] ?? grant}
  • ))}

{row.handlesSupport === false ? 'This contractor has chosen not to handle your new support requests.' : 'This contractor has chosen to handle your new support requests.'}

{onDate(row.linkedAt) && (

Has had access since {onDate(row.linkedAt)}

)}
))} {/* Who pays is one fact in this card, so it is a line under a rule, not a box inside it. */} {/* 💸 WHO PAYS, SAID IN BOTH STATES — and the OFFER only where it can do something. Spencer, 2026-09-02: "If a contractor isn't handling the subscription for the org then there should be no take back control option, that's just stupid." ⛔ So the refusal state is not drawn at all: not a disabled button, not a tooltip. A disabled control still claims the action belongs on this screen. ⭐ The FACT stays either way — who pays is worth stating even when there is nothing to do about it. What goes away is the offer, not the information. */} {sponsored && (

{payer ? `${agencyName(payer)} pays for this practice` : 'A contractor pays for this practice'}

{/* 🚨 OWNER-ONLY, and the hiding is courtesy rather than the guard — the server refuses everyone else with NOT_THE_OWNER. `undefined` means not yet known and is deliberately not `false`: see the prop's own note. */} {viewerIsOwner === true && ( <>

You can take the subscription back into your own name. Nothing lapses — your current subscription stays live until the new one is paid, and the contractor’s access is not affected.

)}
)} {/* The other half of the same fact. ⛔ Rendered ONLY for `org_paid`: a practice on the free plan is not paying either, and "You pay for this practice" would be a false statement about money — the exact thing the server's derivation warns against. An unknown mode says nothing at all rather than guessing. */} {billingMode === ORG_PAID_MODE && (

You pay for this practice

)} {/* 🔑 AND THE CASE THIS SCREEN USED TO HAVE NOTHING TO SAY ABOUT, which is most of them. Nobody is being charged yet, so neither sentence above can fire — and the question a practice on the free plan actually has is where the bill goes when it stops being free. The answer exists: an agency that enrolled them said at the time whether it handles their payments. ⛔ NOT A CONTROL AND NEVER A LOCK. It states a recorded answer; the takeover above is unaffected, and a practice may always pay for itself whatever was designated — *"Organizations may take over billing at any time, even if the contractor is unreachable."* ⛔ And an unanswered designation renders NOTHING. Most practices were never enrolled by an agency, so `null` is the ordinary case rather than an error, and treating it as "you pay for yourself" would assert something nobody said. */} {billingMode === FREE_BASIC_MODE && subscriptionPayer === CONTRACTOR_PAYER && (

{payer ? `${agencyName(payer)} handles your subscription payments` : 'Your contractor handles your subscription payments'}

Nothing is being charged while you are on the free plan. When you add a second clinician or an add-on, the bill goes to them rather than to you.

)} {billingMode === FREE_BASIC_MODE && subscriptionPayer === ORGANIZATION_PAYER && (

You pay for your own subscription

Nothing is being charged while you are on the free plan. When you add a second clinician or an add-on, the bill comes to you.

)} {removal.error && !confirming && ( {removal.error} )} {/* A saved request is not a live agency, so it is a row under a rule in this card, not a box. */} {removal.rows.length > 0 && (

Saved access requests

    {removal.rows.map(intent => { const result = removal.results[intent.operationId]; return
  • {intent.name} — {intent.operationId}

    Requested {intent.createdAt}

    {result?.kind === 'fenced' ? 'The original access was ended. Current access is checked separately.' : 'The original result is unconfirmed.'}

    {result?.originalActor &&

    Original actor: {result.originalActor.pool} / {result.originalActor.sub}; admitted {result.admittedAt}; generation {result.fencedGeneration}; revision {result.revision}.

    } {result?.kind === 'fenced' &&

    Recorded role/evidence work: {result.cleanup}. Evidence handoff: {result.handoff}. Evidence storage is unverified.

    }
  • ; })}
)} {ended.length > 0 && (

Access that has ended

    {ended.map((row) => (
  • {agencyName(row)} — access ended {onDate(row.revokedAt) ?? 'at an unrecorded time'}
  • ))}
)}
{ if (!open && !busy && !removal.busy) setConfirming(undefined); }} > {/* admin-v2 "End agency access": closing is the way out, so no Cancel button. */} End {confirming ? agencyName(confirming) : 'this contractor'}’s access? The contractor's platform managers lose access on their next request; their agreement is not needed. Your subscription continues. If the contractor pays, they remain the payer until you take billing back. Your forms, portal pages, and enquiry destinations remain. You can give a contractor access again at any time. This is recorded in your practice access log. {actionError && ( {actionError} )} {removal.error && {removal.error}} {/* A plain Button, not AlertDialogAction, so the dialog stays open on failure instead of closing over an error nobody read. */} { if (!open && !busy) { setZeroCostOffer(undefined); setActionError(undefined); } }} > Take billing back with nothing to pay? {zeroCostOffer?.message ?? 'Nothing is billed on this practice’s subscription now. Taking billing back ends the contractor subscription and puts this practice on Free Basic in its own name. Nothing is charged and no card is needed.'} The contractor’s access is not affected. You can add clinicians or add-ons later, billed to you. {actionError && ( {actionError} )} { setCheckout(null); void load(); }} onDismiss={() => setCheckout(null)} />
); }; export default ContractorAccessSection;