// The subscription page, as arithmetic. settled design (§4), decisions 1-12. // // THE ORGANIZING IDEA: a subscription page is A BILL YOU CAN READ AND ACT ON. The page this replaces // was a status badge, one storage meter and two raw Stripe identifiers, and it never stated a price. // // THE PAGE IS THE UPGRADE VIEW. There is no second screen - every row shows what you have AND is the // control for changing it. That is not only fewer clicks: it makes the defect that raised this card // structurally hard to repeat. If every purchasable thing is a row on a page an admin always sees, a // MISSING one is obvious. "Campaigns - Off, $29/mo" with no way to switch it on gets reported; a row // that was never rendered does not. // // Derivation lives here rather than in the component because the numbers are what an admin checks us // on. A component test can prove a row rendered; only this can prove the row is right. /** The settled order (§4 decision 3). Not alphabetical, not by price - this is the ruled sequence. */ export const ROW_ORDER = [ 'clinicianSeats', 'telehealthUnlimited', 'smsReminders', 'campaigns', 'directPatientPayments', 'aiNotes', 'extraStorageBlocks', ] as const; export type SubscriptionRowKey = (typeof ROW_ORDER)[number]; export interface SubscriptionRowMeter { used: number; limit: number; label: string; /** True once usage has reached the limit - the row states it in the attention colour. */ atLimit: boolean; unit?: string; } export interface SubscriptionRow { key: SubscriptionRowKey; name: string; description: string; /** Usage limits, shown as their own smaller paragraph beneath the description. */ fairUse?: string; /** `report` links out and cannot be edited here; `toggle` is on/off; `stepper` is a quantity. */ kind: 'report' | 'toggle' | 'stepper'; unitPriceCents: number; /** `org` bills once whatever the practice size; `clinician` multiplies by the clinician count. */ unit: 'org' | 'clinician' | 'block'; /** * What this row's quantity is multiplied by to reach its line total. * * CARRIED ON THE ROW rather than re-derived at pricing time. A per-clinician capability is * `$10 × the clinician count`, and the alternative - having the delta function work that out - means * two places deciding what a row costs. They agree until one grows a case, which is the counting * defect this codebase has now fixed on four surfaces. */ unitMultiplier: number; /** What is held TODAY. For the seat row this is the BILLABLE count, not the clinician count. */ quantity: number; enabled: boolean; /** A one-time charge incurred when this is first switched on. Declared BEFORE the toggle. */ oneTimeCents?: number; /** Stated on BOTH sides of a dependency - one side alone is invisible to whoever reads the other. */ dependencyNote?: string; /** Usage against what is bought. Never present for an uncapped dimension: a meter invents a ceiling. */ meter?: SubscriptionRowMeter; /** Said plainly when a line costs nothing, so a $0 row does not read as a billing error. */ includedNote?: string; /** * Said when the practice is PAYING for this and it cannot do its job yet. * * Different from every other note on this row: the others explain what a capability is, what it * includes or what it depends on, and none of them is about the practice's own state. This one is, * and it is the difference between a price list and an honest one - a row that bills monthly while * the capability behind it is inert has to say so on the row, not leave the practice to discover it * the first time nothing arrives. */ notReadyNote?: string; /** * Why this row CANNOT BE SWITCHED ON AT ALL right now, in the practice's own terms. Absent means it can. * * SEPARATE FROM `notReadyNote`, AND THE DIFFERENCE IS WHOSE MONEY IS ALREADY MOVING. That one is said * to a practice ALREADY PAYING for something inert. This one stops the charge beginning, and it exists * because a deferral makes the CAPABILITY unavailable and does nothing to the CHARGE for it: the * subscription page was happy to start $29 a month for texting that the platform has switched off. * * A CLIENT GATE IS NOT A GATE, and this one is not pretending to be. The checkout is the boundary * and cannot see this yet - the origination identity is a stack parameter and it is not on that * function. This removes the only door a practice can actually reach and says why; the server refusal * is the follow-up, named on the card rather than left implied. */ unavailableReason?: string; /** Where a `report` row sends an admin to make the change for real. */ manageAt?: string; } export interface SubscriptionRowsInput { pricing: { unitPriceCents: Partial>; smsRegistrationOneTimeCents?: number; }; /** * Whether the platform can actually send a text yet, straight off the entitlements response. * * `undefined` says nothing rather than guessing: an older server that does not send the field must * not make every practice read a warning about a state nobody measured. */ smsTransactionalConfigured?: boolean; addOns: Partial>; baseLimits: { storageGB: number }; usage: { storageBytesUsed: number; /** * Hosted video visits this month. * * UNDEFINED WHEN NOTHING COUNTS THEM. Something does now: the server keeps a monthly counter * per organization, the video-session route reads it and refuses the visit past the allowance, * and the entitlements response carries the derived allowance beside the flag. A `?? 0` would render * "0 / 20 used" and assert that a practice has hosted no visits, which is a measurement we * never took. This surface has shipped that mistake four times (100 appointments, 2 staff * seats, 1 questionnaire, a `?? 0` questionnaire count); an absent measurement renders as * nothing, never as zero. */ telehealthVisitsThisMonth?: number; }; /** Clinicians enabled in Staff. The seat line follows this; it is never edited here. */ clinicianCount: number; } /** Free video visits per month, PER ORG - not per seat (settled 2026-08-14). */ export const FREE_TELEHEALTH_VISITS_PER_MONTH = 20; const GB = 1024 ** 3; export function buildSubscriptionRows(input: SubscriptionRowsInput): SubscriptionRow[] { const { pricing, addOns, baseLimits, usage, clinicianCount, smsTransactionalConfigured } = input; const priceOf = (key: string) => pricing.unitPriceCents?.[key]; const held = (key: string) => Number(addOns?.[key] ?? 0) || 0; // The server refuses to create a per-clinician line at a count of zero, every time, so a practice // with no enabled clinician is told here rather than after a review it cannot complete. const needsAClinician = (key: string) => (clinicianCount === 0 && held(key) === 0 ? 'Priced per clinician, and no clinician is enabled yet. Enable a clinician in Staff first.' : undefined); const telehealthUnlimited = held('telehealthUnlimited') > 0; const rows: Array = [ (() => { // THE SEAT ROW RENDERS EVEN WITH NO PRICE, and it is the ONLY row that does. Every other // row here is a thing you BUY, and a purchase with no price is a checkout that completes and // buys nothing - so `return undefined` is right for them. // // THIS ROW IS NOT A PURCHASE. It REPORTS the line the practice is actually billed for, // and that line exists whether or not we managed to read its price. Dropping it cost the // page its core revenue row: six rows where the design says seven, with the missing one the // only one that costs money - and the page's own "off rows still render, priced" safeguard // could not catch it, because a row that never renders has nothing to notice. const unitPriceCents = priceOf('clinicianSeats'); // The first clinician is free, so the BILLABLE count is one less than the enabled count. const billable = Math.max(0, clinicianCount - 1); return { key: 'clinicianSeats', name: 'Clinician seats', description: 'Charged for each clinician after the first. Reception, billing and admin staff are free.', // REPORTS, NEVER CONTROLS (§4 decision 4). Seats follow the clinicians enabled in // Staff; editing them in two places is how the count drifts from the reality it bills. kind: 'report', // Zero rather than a guessed $50. A hardcoded fallback is a price we would state as // fact and could be wrong about, and this row is what an admin checks their bill // against. The count is the thing that must survive; the amount says nothing rather // than something untrue. unitPriceCents: unitPriceCents ?? 0, unit: 'clinician', // The seat row's QUANTITY is already the billable clinician count, so it never multiplies // again - doing so would bill every clinician for every other clinician. unitMultiplier: 1, quantity: billable, enabled: billable > 0, includedNote: unitPriceCents === undefined // Says we could not read the price rather than implying the seats are free. A // silent $0 on the one billed line is the reading an admin would act on. ? `${clinicianCount} enabled, first one free. We could not load the seat price — check Staff.` : billable === 0 // A solo practice runs the whole platform free. "0 × $50" reads as a billing error. ? 'Your first clinician is free.' : `${clinicianCount} enabled, first one free.`, // The directory marks each provider without a seat and offers the grant on the row. manageAt: '/dashboard/staff', } satisfies SubscriptionRow; })(), (() => { const unitPriceCents = priceOf('telehealthUnlimited'); if (unitPriceCents === undefined) return undefined; return { key: 'telehealthUnlimited', name: 'Telehealth unlimited', description: `Lifts the limit of ${FREE_TELEHEALTH_VISITS_PER_MONTH} free hosted video visits a month for your whole practice.`, kind: 'toggle', unitPriceCents, unit: 'clinician', unitMultiplier: Math.max(1, clinicianCount), quantity: held('telehealthUnlimited'), unavailableReason: needsAClinician('telehealthUnlimited'), enabled: telehealthUnlimited, // The meter is doing the conversion work and is a first-class element, not a // footnote - it is the moment the free-with-a-cap strategy depends on. And it // disappears once the cap is lifted: a meter against no ceiling invents one. meter: telehealthUnlimited || usage.telehealthVisitsThisMonth === undefined ? undefined : { label: 'hosted visits this month', used: usage.telehealthVisitsThisMonth, limit: FREE_TELEHEALTH_VISITS_PER_MONTH, atLimit: usage.telehealthVisitsThisMonth >= FREE_TELEHEALTH_VISITS_PER_MONTH, }, } satisfies SubscriptionRow; })(), (() => { const unitPriceCents = priceOf('smsReminders'); if (unitPriceCents === undefined) return undefined; return { key: 'smsReminders', name: 'SMS messaging', description: 'Appointment reminders by text.', kind: 'toggle', unitPriceCents, unit: 'org', unitMultiplier: 1, quantity: held('smsReminders'), enabled: held('smsReminders') > 0, // DECLARED BEFORE THE TOGGLE (§4 decision 9). Finding it at checkout reads as a bait. oneTimeCents: pricing.smsRegistrationOneTimeCents, dependencyNote: 'Also lets marketing campaigns send by text.', // The server already knows whether a text can leave the platform at all, and said so // on this very response - and nothing read it here, so a practice paying every month // for this line had no way to learn the line does nothing. Only when the practice is // actually holding the add-on: telling somebody who has not bought it that the thing // they have not bought is not working is noise. notReadyNote: held('smsReminders') > 0 && smsTransactionalConfigured === false ? 'Text messages cannot be sent yet. Texting is switched off across Akashic while we ' + 'finish setting it up, so appointment reminders and campaigns go out by email.' : undefined, // AND A PRACTICE THAT HAS NOT BOUGHT IT CANNOT START. `smsTransactionalConfigured` is // OURS - it is true when the platform holds a transactional origination identity - so a // practice beginning a $29 monthly charge here is paying us for something we have // decided nobody gets yet. `undefined` does NOT block: an older server that never // sends the field has said nothing, and treating silence as "off" would take the row // away from every practice the day this ships against an older api. unavailableReason: held('smsReminders') === 0 && smsTransactionalConfigured === false ? 'Not available yet. Texting is switched off across Akashic while we finish setting ' + 'it up, and we will not charge for it until it can send.' : undefined, } satisfies SubscriptionRow; })(), (() => { const unitPriceCents = priceOf('campaigns'); if (unitPriceCents === undefined) return undefined; return { key: 'campaigns', name: 'Marketing campaigns', description: 'Audiences, templates, scheduling and reporting. Email sending included.', kind: 'toggle', unitPriceCents, unit: 'org', unitMultiplier: 1, quantity: held('campaigns'), enabled: held('campaigns') > 0, // The same dependency from the other side (§4 decision 10) - quietly, never as an // error, because nothing is wrong. dependencyNote: 'Sending campaigns by text also needs SMS messaging.', } satisfies SubscriptionRow; })(), (() => { const unitPriceCents = priceOf('directPatientPayments'); if (unitPriceCents === undefined) return undefined; return { key: 'directPatientPayments', name: 'Direct patient payments', description: 'Let patients pay you online through your own Stripe account.', kind: 'toggle', unitPriceCents, unit: 'clinician', unitMultiplier: Math.max(1, clinicianCount), quantity: held('directPatientPayments'), unavailableReason: needsAClinician('directPatientPayments'), enabled: held('directPatientPayments') > 0, } satisfies SubscriptionRow; })(), (() => { const unitPriceCents = priceOf('aiNotes'); if (unitPriceCents === undefined) return undefined; return { key: 'aiNotes', // §4 decision 12. The design records an open question on this NAME - it undersells // what the feature does, and the in-product consent copy already says "recorded to help // draft the note", so the sales label and the clinical copy describe two products. // Spencer's call; built to the settled string rather than silently overridden. name: 'AI notes transcription', // Matches the site: what it adds over typed notes, and its fair-use limits stated plainly. description: 'With consent, records the visit and prepares a draft note for the clinician to review before filing. ' + 'Typed notes and filing stay included without it.', fairUse: 'Fair use: up to 60 minutes per recording session and 12,800 recorded minutes per clinician each month.', kind: 'toggle', unitPriceCents, // Per clinician, like telehealth and direct pay. AI Notes is $129 per // clinician a month, superseding the per-organization price; the server charges the same count. unit: 'clinician', unitMultiplier: Math.max(1, clinicianCount), quantity: held('aiNotes'), unavailableReason: needsAClinician('aiNotes'), enabled: held('aiNotes') > 0, } satisfies SubscriptionRow; })(), (() => { const unitPriceCents = priceOf('extraStorageBlocks'); if (unitPriceCents === undefined) return undefined; const blocks = held('extraStorageBlocks'); const limitGB = baseLimits.storageGB + blocks * baseLimits.storageGB; const usedGB = usage.storageBytesUsed / GB; return { key: 'extraStorageBlocks', name: 'File storage', description: `${baseLimits.storageGB} GB included. Each block adds ${baseLimits.storageGB} GB.`, kind: 'stepper', unitPriceCents, unit: 'block', unitMultiplier: 1, quantity: blocks, enabled: blocks > 0, meter: { label: 'storage used', used: Math.round(usedGB * 10) / 10, limit: limitGB, atLimit: usedGB >= limitGB, unit: 'GB', }, } satisfies SubscriptionRow; })(), ]; const byKey = new Map(rows.filter((row): row is SubscriptionRow => row !== undefined).map((row) => [row.key, row])); // Order comes from the ruled sequence, never from the order the rows happen to be built in - a // list whose order is an accident of construction is one refactor away from being re-sorted. return ROW_ORDER.map((key) => byKey.get(key)).filter((row): row is SubscriptionRow => row !== undefined); } export interface DraftDelta { /** The change to the MONTHLY bill, in cents. Negative when a capability is turned off. */ deltaCents: number; /** The monthly bill after the change. */ newTotalCents: number; /** One-time charges the change incurs, stated separately - never folded into a monthly figure. */ oneTimeCents: number; changedKeys: SubscriptionRowKey[]; } /** * What the draft would cost, against what is held today. * * THE DELTA IS THE MESSAGE (§4 decision 8). A bare new total makes an admin do the subtraction to * find out what they just agreed to. * * Nothing here charges anything. Adjusting a row is a DRAFT; the commit is a separate, deliberate * act. A stepper that bills on click is the opposite of this design. */ /** * What one row's line costs at a given quantity. * * A PER-CLINICIAN TOGGLE'S QUANTITY IS NOT A COUNT OF THINGS TO BILL - it is on or off, and the line * is priced at the clinician count, which the row carries as `unitMultiplier`. * * The two sides arrive in different units, which is the whole reason this exists. The HELD quantity * comes back from the server already derived from headcount: a two-clinician practice reads * `telehealthUnlimited: 2` even though the client only ever sends 1 to switch the row on (measured on * a practice with no clinicians at all - it still sent 1). The DRAFT quantity is whatever the switch * put there, which is 1. Multiplying either by the count again billed every clinician for every other * one: a practice paying $80.00 was quoted $129.00 for a $29.00 add-on, delta right and total wrong, * because the same doubled base cancelled out of the subtraction and only the total showed it. * * Pricing both sides off the multiplier is what makes them the same money. Do not "simplify" this * back to `qty * unitMultiplier` - that is the defect. */ const lineCents = (row: SubscriptionRow, qty: number): number => row.unit === 'clinician' && row.kind === 'toggle' ? (qty > 0 ? row.unitPriceCents * row.unitMultiplier : 0) : row.unitPriceCents * qty * row.unitMultiplier; export function computeDraftDelta( rows: SubscriptionRow[], draft: Partial>, ): DraftDelta { let heldCents = 0; let draftCents = 0; let oneTimeCents = 0; const changedKeys: SubscriptionRowKey[] = []; for (const row of rows) { // A `report` row is not part of the draft - its quantity follows Staff - but it IS part of the // bill, so it contributes equally to both sides and nets to zero in the delta. const heldQty = row.quantity; const draftQty = row.kind === 'report' ? heldQty : draft[row.key] ?? heldQty; // The multiplier comes off the ROW. Re-deriving it here would be a second opinion on what a // line costs, and the two would agree right up until one of them grew a case. heldCents += lineCents(row, heldQty); draftCents += lineCents(row, draftQty); if (draftQty !== heldQty) { changedKeys.push(row.key); // A one-time charge is incurred only on the way ON, and only when it is not already // held - re-billing a carrier registration to a practice that already registered is a // charge they cannot explain. if (row.oneTimeCents && heldQty === 0 && draftQty > 0) oneTimeCents += row.oneTimeCents; } } return { deltaCents: draftCents - heldCents, newTotalCents: draftCents, oneTimeCents, changedKeys, }; }