import { environment } from '../environments/environment.ts'; export interface AddOnCheckoutResult { clientSecret?: string; redirected: boolean; updated: boolean; /** * True when the limit named a capability this build cannot buy, so NOTHING was sent. * *. The caller must be able to tell this apart from a checkout the user simply * cancelled - the old shape returned `{ redirected: false, updated: false }` for both, which is how * "the purchase quietly bought nothing" looked identical to "they changed their mind". */ unknownTarget: boolean; /** A founding the server already holds for this practice; nothing was opened or charged by this request. */ founding?: FoundingState; } export type FoundingState = 'pending' | 'provider-complete' | 'ended'; type FoundingAnswer = { state?: unknown }; export const isFoundingState = (state: unknown): state is FoundingState => state === 'pending' || state === 'provider-complete' || state === 'ended'; const foundingResult = (state: FoundingState): AddOnCheckoutResult => ({ redirected: false, updated: false, unknownTarget: false, founding: state }); const FOUNDING_SENTENCES: Record = { 'pending': 'A purchase for this practice is still being completed. Nothing more was charged. Check your subscription again in a moment.', 'provider-complete': "This practice's purchase has been paid and its subscription is being set up. Nothing more was charged.", 'ended': 'That purchase ended before it was paid. Nothing was charged, and you can start again.', }; export const foundingSentence = (state: FoundingState): string => FOUNDING_SENTENCES[state]; /** Where this practice's founding stands, without starting or resuming one. */ export async function checkFounding( post: (url: string, data: unknown) => Promise, ): Promise { const answer = await post(`${environment.apiUrl}/Stripe/checkout`, { action: 'check', embedded: true }); if (answer.state === 'none' || isFoundingState(answer.state)) return answer.state; throw new Error('Billing did not say where this purchase stands.'); } export const UNKNOWN_UPGRADE_TARGET: AddOnCheckoutResult = Object.freeze({ redirected: false, updated: false, unknownTarget: true, }); // - no `appointmentBlocks` target. The Appointment Capacity SKU is retired. // - no `extraSeats` target: staff seats stopped being purchasable when non-clinician // staff became free and uncapped, and billing moved to per CLINICIAN. // - no `extraQuestionnaires`: questionnaires are FREE on a paid plan, so offering the upgrade // would charge for something we give away. // // /311 // THIS MAP HELD EXACTLY ONE ENTRY, and an unmatched target left the posted add-ons ALL ZERO - and // posted anyway. So a limit error for campaigns, SMS, telehealth or the scribe opened a checkout that // completed and bought nothing. Worse than a missing button, because the practice believes it paid: // it surfaces as a refund conversation rather than a bug report. // // The fix is not only "add the missing entries". A target that resolves to nothing now STOPS BEFORE // THE NETWORK, because the failure it otherwise produces is silent success - the one shape nothing // downstream can tell apart from a real purchase. const PURCHASABLE_TARGETS = [ 'extraStorageBlocks', 'directPatientPayments', // ONE product, and `smsReminders` is the key that carries it: the server's `sms` Stripe product // sets both the reminder and the promotion flags. Do not add a second SMS target - // two ways to buy one thing is how they drift. 'smsReminders', 'campaigns', 'telehealthUnlimited', 'aiNotes', ] as const; /** * THE CLINICIAN SEAT, WHICH IS SOLD HERE AND IS NOT AN ADD-ON. * * Spencer, 2026-09-14: "the 2nd clinician seat starts a checkout and founds a subscription because * the 2nd clinician seat is not free like the first." The server refuses the seat write with a 402 * naming this target, `HttpService` raises the billing modal, and this is what the modal starts. * * DELIBERATELY NOT A MEMBER OF THE LIST ABOVE. Those keys are `AddOns` keys: they are posted as * quantities, they come back through the webhook's product-metadata mapping, they derive * entitlements and they draw the subscription page's rows. A seat is none of those things, and * adding it to the array would make it one on five surfaces at once. * * AND NO QUANTITY IS SENT. How many seats are chargeable is a fact about the practice that the * server reads for itself - a client that could send it could buy four seats for the price of one. */ const CLINICIAN_SEAT_TARGET = 'clinicianSeats'; export async function startAddOnCheckout( post: (url: string, data: unknown) => Promise, upgradeTarget: string, /** * The prices the server actually has, from the entitlements response. * * - THE SECOND WAY TO REACH A CHECKOUT THAT BUYS NOTHING. The subscription page already * refuses to draw a row whose capability has no Stripe Price, but a LIMIT ERROR opens this flow * from anywhere in the app and never consults that page. A capability that is purchasable in * principle and unpriced in fact would post and sell nothing - the same silent success the target * check above exists to stop, arriving through the door nobody was watching. * * Optional, and an omitted map skips the check rather than refusing everything: a caller that * has not loaded pricing must not be turned into a wall. The check applies when we know. */ unitPriceCents?: Partial>, ): Promise { const successUrlForSeat = `${window.location.origin}${window.location.pathname}#/dashboard/subscription`; if (upgradeTarget === CLINICIAN_SEAT_TARGET) { // The same price check as below, for the same reason: a checkout that completes and buys // nothing is worse than a missing button, because the practice believes they paid. if (unitPriceCents !== undefined && unitPriceCents[CLINICIAN_SEAT_TARGET] === undefined) { console.error('startAddOnCheckout: the clinician seat has no configured price — not starting a checkout.'); return UNKNOWN_UPGRADE_TARGET; } // Every add-on at zero, and the seat as its own field. The body says "found a subscription // for a seat" and nothing else. const seatResponse = await post<{ clientSecret?: string; updated?: boolean } & FoundingAnswer>( `${environment.apiUrl}/Stripe/checkout`, { addOns: Object.fromEntries(PURCHASABLE_TARGETS.map((target) => [target, 0])), clinicianSeat: true, embedded: true, successUrl: successUrlForSeat, cancelUrl: window.location.href, }, ); if (seatResponse.clientSecret) { return { clientSecret: seatResponse.clientSecret, redirected: false, updated: false, unknownTarget: false }; } if (seatResponse.updated) { return { redirected: false, updated: true, unknownTarget: false }; } if (isFoundingState(seatResponse.state)) return foundingResult(seatResponse.state); throw new Error('We could not open the payment form. Please try again.'); } const resolved = PURCHASABLE_TARGETS.find((target) => target === upgradeTarget); if (!resolved) { // Deliberately BEFORE the request. Posting an all-zero body is what made this a purchase flow // that completes and sells nothing. console.error(`startAddOnCheckout: no purchasable capability for upgrade target "${upgradeTarget}" — not starting a checkout.`); return UNKNOWN_UPGRADE_TARGET; } // Also before the network, and for the identical reason. This state has NO REACHABLE INSTANCE // on staging any more - every capability now has a Price - which is exactly why it is pinned here // rather than left to a browser walk: a protection nobody can reach is a protection nobody can // verify, and it stops being real the moment a Price is retired. if (unitPriceCents !== undefined && unitPriceCents[resolved] === undefined) { console.error(`startAddOnCheckout: "${resolved}" has no configured price — not starting a checkout.`); return UNKNOWN_UPGRADE_TARGET; } // Every key is sent explicitly at 0 except the one being bought, so the server sees a complete, // unambiguous request rather than inferring absence. const addOns: Record = Object.fromEntries( PURCHASABLE_TARGETS.map((target) => [target, target === resolved ? 1 : 0]), ); const successUrl = `${window.location.origin}${window.location.pathname}#/dashboard/subscription`; const cancelUrl = window.location.href; const response = await post<{ checkoutUrl?: string; clientSecret?: string; updated?: boolean } & FoundingAnswer>( `${environment.apiUrl}/Stripe/checkout`, { addOns, successUrl, cancelUrl, embedded: true }, ); if (response.clientSecret) { return { clientSecret: response.clientSecret, redirected: false, updated: false, unknownTarget: false }; } if (response.checkoutUrl) { throw new Error('We could not open the payment form here. Nothing has been charged.'); } if (response.updated) { return { redirected: false, updated: true, unknownTarget: false }; } if (isFoundingState(response.state)) return foundingResult(response.state); throw new Error('We could not complete that subscription change. Please try again.'); }