import { useCallback, useEffect, useRef, useState } from 'react'; import type { FoundingState } from '../../lib/billingCheckout.ts'; import type { BillingReadOutcome, BillingReadOwner } from '../../services/BillingContext.tsx'; import type { ReceiptsNote, SettlementCheck } from './CheckoutOutcome'; import type { SubscriptionRowKey } from './subscriptionRows'; /** How long the automatic reads after a payment may run, however long any one read is held. */ export const AUTOMATIC_ALLOWANCE_MS = 120_000; /** How long one Check again may wait for billing, and then for receipts, before it says the read did not answer. */ export const CHECK_ALLOWANCE_MS = 15_000; export type ReceiptsRead = 'loaded' | 'unreadable' | 'refused' | 'retired'; /** Reads receipts for `owner`; publishes only while `stillWanted` holds, settles by `deadline` (epoch ms) and ends at once with `signal`. */ export type ReadReceipts = (owner: BillingReadOwner, deadline: number, stillWanted: () => boolean, signal?: AbortSignal) => Promise; /** * Whether a billing owner is still the one shown: this page is mounted, the generation is current, and the auth * session it was opened in has not moved. The session can move before the next render, so it is read from the ref. */ export function useOwnerSession(generation: number, sessionRef: { current: number } | undefined) { const opened = useRef<{ generation: number; session: number | undefined }>(); if (opened.current?.generation !== generation) opened.current = { generation, session: sessionRef?.current }; const mounted = useRef(true); useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []); return useCallback((owner: number) => mounted.current && opened.current?.generation === owner && sessionRef?.current === opened.current.session, [sessionRef]); } // A practice still on the free plan may have a purchase the server is finishing from before this page opened. // One read-only check per owner says where it stands; it never opens a payment form or starts a purchase, and a // check that fails or runs out of time says nothing rather than guessing. export function useFoundingCheck(args: { wanted: boolean; owner: number; ownerIsLive: (generation: number) => boolean; check: (signal: AbortSignal) => Promise; }): FoundingState | null { const { wanted, owner } = args; const latest = useRef(args); latest.current = args; const [standing, setStanding] = useState<{ owner: number; state: FoundingState } | null>(null); useEffect(() => { if (!wanted) return undefined; const controller = new AbortController(); const timer = window.setTimeout(() => controller.abort(), CHECK_ALLOWANCE_MS); latest.current.check(controller.signal).then((state) => { if (controller.signal.aborted || !latest.current.ownerIsLive(owner)) return; setStanding(state === 'none' ? null : { owner, state }); }, () => undefined).finally(() => window.clearTimeout(timer)); return () => { window.clearTimeout(timer); controller.abort(); }; }, [wanted, owner]); return wanted && standing?.owner === owner ? standing.state : null; } type Purchase = { id: number; owner: number; added: string[]; previousTotalCents: number; requested: Partial>; secret: string; }; export type Opening = { seq: number; owner: number; practiceId: string | null }; type Phase = 'idle' | 'paying' | 'confirming' | 'slow'; type Settled = 'confirmed' | 'pending' | 'unreadable' | 'refused' | 'retired'; const receiptsNote = (read: ReceiptsRead): ReceiptsNote => (read === 'unreadable' || read === 'refused' ? read : 'none'); // One purchase lifetime: opening, payment, automatic confirmation, and Check again once that gives up. It belongs to // the billing owner it was opened under. A callback acts only for the purchase it was handed out with, only while // that purchase is current for the shown owner, and every await is followed by the same test plus its deadline. export function usePurchaseRecovery(args: { billingOwner: BillingReadOwner; ownerIsLive: (generation: number) => boolean; refreshBilling: (options?: { signal?: AbortSignal }) => Promise; readReceipts: ReadReceipts; onConfirmed: (confirmed: { added: string[]; changeCents: number; totalCents: number }) => void; onRetired: () => void; }) { const owner = args.billingOwner.generation; const latest = useRef(args); latest.current = args; const purchaseRef = useRef(null); const purchaseSeq = useRef(0); const openingSeq = useRef(0); const checkSeq = useRef(0); const receiptsSeq = useRef(0); const liveRead = useRef(null); const receiptsRead = useRef(null); const [purchaseId, setPurchaseId] = useState(null); const [phase, setPhaseState] = useState('idle'); const [check, setCheckState] = useState('none'); const [receiptsCheck, setReceiptsCheck] = useState('none'); const phaseRef = useRef('idle'); const checkRef = useRef('none'); const setPhase = (next: Phase) => { phaseRef.current = next; setPhaseState(next); }; const setCheck = (next: SettlementCheck) => { checkRef.current = next; setCheckState(next); }; const ownerIsShown = (purchase: Purchase) => latest.current.ownerIsLive(purchase.owner); const isCurrent = (purchase: Purchase) => purchaseRef.current === purchase && ownerIsShown(purchase); // Ends the purchase and every read and check started for it. An opening in flight is not its to end. const endPurchase = useCallback(() => { liveRead.current?.abort(); liveRead.current = null; receiptsRead.current?.abort(); receiptsRead.current = null; checkSeq.current += 1; receiptsSeq.current += 1; purchaseRef.current = null; phaseRef.current = 'idle'; checkRef.current = 'none'; setPurchaseId(null); setPhaseState('idle'); setCheckState('none'); setReceiptsCheck('none'); }, []); const shownOwner = useRef(owner); useEffect(() => { if (shownOwner.current === owner) return; shownOwner.current = owner; openingSeq.current += 1; endPurchase(); latest.current.onRetired(); }, [owner, endPurchase]); useEffect(() => () => { openingSeq.current += 1; endPurchase(); }, [endPurchase]); // A receipts read for this purchase; replacing or ending the purchase cancels it at once. const readPurchaseReceipts = (receiptsOwner: BillingReadOwner, wanted: () => boolean) => { receiptsRead.current?.abort(); const controller = new AbortController(); receiptsRead.current = controller; return latest.current.readReceipts(receiptsOwner, Date.now() + CHECK_ALLOWANCE_MS, wanted, controller.signal) .finally(() => { if (receiptsRead.current === controller) receiptsRead.current = null; }); }; // After a confirmation the receipt is read too, by the same deadline rule, and its own result is reported. const readReceiptsAfterConfirming = async (receiptsOwner: BillingReadOwner) => { const id = ++receiptsSeq.current; const wanted = () => receiptsSeq.current === id && latest.current.ownerIsLive(receiptsOwner.generation); setReceiptsCheck('reading'); const read = await readPurchaseReceipts(receiptsOwner, wanted); if (wanted()) setReceiptsCheck(receiptsNote(read)); }; // One billing read for this purchase, settled by `deadline` whichever of the timer or the answer runs first. // Only an answer admitted for the purchase's own owner can confirm it, and only from what that answer carried. const settle = async (purchase: Purchase, deadline: number): Promise => { if (!isCurrent(purchase)) return 'retired'; // An allowance already spent starts no read; there is nothing it could still answer in time. if (Date.now() >= deadline) return 'unreadable'; liveRead.current?.abort(); const controller = new AbortController(); liveRead.current = controller; const timer = window.setTimeout(() => controller.abort(), Math.max(0, deadline - Date.now())); const outcome = await latest.current.refreshBilling({ signal: controller.signal }) .catch((): BillingReadOutcome => ({ kind: 'failed', owner: latest.current.billingOwner })); window.clearTimeout(timer); if (liveRead.current !== controller || !isCurrent(purchase)) return 'retired'; liveRead.current = null; if (controller.signal.aborted || Date.now() >= deadline) return 'unreadable'; if (outcome.owner.generation !== purchase.owner) return 'retired'; if (outcome.kind === 'refused') return 'refused'; // Failed, no practice, or a read another answered first: this one published nothing, so it says nothing. if (outcome.kind !== 'answered') return 'unreadable'; if (!confirms(purchase, outcome)) return 'pending'; endPurchase(); latest.current.onConfirmed({ added: purchase.added, changeCents: (outcome.monthlyTotalCents as number) - purchase.previousTotalCents, totalCents: outcome.monthlyTotalCents as number, }); void readReceiptsAfterConfirming(outcome.owner); return 'confirmed'; }; // The automatic reads stop at one absolute ceiling, kept by its own timer and by elapsed time at every answer. useEffect(() => { const purchase = purchaseRef.current; if (phase !== 'confirming' || !purchase) return; const ceilingAt = Date.now() + AUTOMATIC_ALLOWANCE_MS; let stopped = false; let timer = 0; const giveUp = () => { if (stopped || !isCurrent(purchase) || phaseRef.current !== 'confirming') return; stopped = true; window.clearTimeout(timer); liveRead.current?.abort(); liveRead.current = null; setPhase('slow'); }; const ceiling = window.setTimeout(giveUp, AUTOMATIC_ALLOWANCE_MS); const tick = async () => { if (stopped) return; const result = await settle(purchase, ceilingAt); if (stopped || result === 'confirmed' || result === 'retired') return; if (Date.now() >= ceilingAt) { giveUp(); return; } timer = window.setTimeout(() => { void tick(); }, 2_500); }; timer = window.setTimeout(() => { void tick(); }, 1_500); return () => { stopped = true; window.clearTimeout(timer); window.clearTimeout(ceiling); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [phase, purchaseId]); // CHECK AGAIN: one bounded read of billing and then receipts for the purchase shown when it was rendered. It never // pays, opens checkout or activates. A callback kept from an earlier purchase starts nothing. const boundPurchase = purchaseId; const checkAgain = async () => { const purchase = purchaseRef.current; if (!purchase || purchase.id !== boundPurchase || !isCurrent(purchase)) return; if (phaseRef.current !== 'slow' || checkRef.current === 'checking' || checkRef.current === 'refused') return; const checkId = ++checkSeq.current; const deadline = Date.now() + CHECK_ALLOWANCE_MS; const live = () => checkSeq.current === checkId && isCurrent(purchase) && phaseRef.current === 'slow'; setCheck('checking'); setReceiptsCheck('none'); const result = await settle(purchase, deadline); if (result === 'confirmed' || result === 'retired' || !live()) return; if (result !== 'pending') { setCheck(result); return; } const receiptsOwner = latest.current.billingOwner; const receipts = await readPurchaseReceipts(receiptsOwner, live); if (!live()) return; setReceiptsCheck(receiptsNote(receipts)); setCheck('pending'); }; /** * Taken before the checkout request; it names the owner and practice the request may be sent for. It belongs to * the render that handed it out, so one kept from an earlier owner, session or mount opens nothing. */ const renderedOwner = args.billingOwner; const beginOpening = (): Opening | null => { if (!latest.current.ownerIsLive(renderedOwner.generation)) return null; return { seq: ++openingSeq.current, owner: renderedOwner.generation, practiceId: renderedOwner.practiceId }; }; const openingIsCurrent = (opening: Opening) => opening.seq === openingSeq.current && latest.current.ownerIsLive(opening.owner); /** A new checkout ends the previous purchase, its reads and its outcome, before its payment form opens. */ const admitOpening = (opening: Opening, details: Omit) => { if (!openingIsCurrent(opening)) return false; endPurchase(); const id = ++purchaseSeq.current; purchaseRef.current = { id, owner: opening.owner, ...details }; setPurchaseId(id); setPhase('paying'); return true; }; /** Stripe accepted the payment for `secret`; only the unpaid purchase that secret opened starts confirming. */ const paymentComplete = (secret: string) => { const purchase = purchaseRef.current; if (!purchase || purchase.secret !== secret || !isCurrent(purchase) || phaseRef.current !== 'paying') return false; setCheck('none'); setPhase('confirming'); return true; }; /** Closing an unpaid checkout ends that purchase; a paid one keeps its recovery on the page. */ const dismiss = (secret: string) => { const purchase = purchaseRef.current; if (purchase && purchase.secret === secret && phaseRef.current === 'paying') endPurchase(); }; return { phase, check, receiptsCheck, checkAgain, beginOpening, openingIsCurrent, admitOpening, paymentComplete, dismiss }; } // Every requested quantity, and the total, must be in the answer itself; an unknown is never read as zero. function confirms(purchase: Purchase, answer: Extract) { const { addOns, monthlyTotalCents } = answer; if (!addOns || typeof monthlyTotalCents !== 'number') return false; return Object.entries(purchase.requested).every(([key, quantity]) => typeof addOns[key] === 'number' && addOns[key] === quantity); }