// - what an SMS body actually costs the practice, surfaced in the composer. // // WHY THIS EXISTS AT ALL. A campaign has ONE `body`, shared by both channels, and the composer // allows 20,000 characters - which is a perfectly ordinary email and **125 SMS segments**. At // $0.029/segment that is $3.63 PER RECIPIENT, so a 5,000-patient campaign would cost about $18,000 // and the practice would have no way to know before clicking Send. // // The billing unit is the SEGMENT, not the message, and the boundary is invisible in a text box: 160 // characters costs one, 161 costs two. Nothing in the UI hints at that, so this module is what turns // an invisible cliff into a number on the screen. /** GSM-7 fits 160 characters per segment; cost is per segment. Mirrors the server's constant. */ export const SMS_SEGMENT_CHARS = 160; /** * $0.029 per segment (settled 2026-08-09). * * A DISPLAY ESTIMATE, NEVER A BILL. The authoritative count is what the server meters at send * time from what actually went on the wire - a body edited after this renders, a CTA link expanded * into the placeholder, or a non-GSM-7 character can all move it. Label it as an estimate wherever * it is shown. */ export const SMS_PRICE_PER_SEGMENT_USD = 0.029; /** * Appended to every campaign text by the SERVER, so the practice never types it. * * MUST MATCH `SMS_OPT_OUT_FOOTER` IN THE API (utils/campaign/campaignSms.ts) EXACTLY. Duplicated * rather than imported because these are two repositories with no shared package. If they drift, this * estimate silently under-reports what every recipient is charged. * * THE PROTECTION IS THAT **BOTH SIDES PIN THE SAME LITERAL** - this file's test cannot see the API * at all, so on its own it proves nothing about the server. That was verified rather than assumed: * changing the API's constant left this repository's suite entirely green. The API has its own test * pinning the identical string, so a change on either side is red *somewhere*. * If either fails, re-sync BOTH. Loosening either assertion removes the only thing connecting them. */ export const SMS_OPT_OUT_FOOTER = ' Reply STOP to opt out.'; /** The server substitutes this with the recipient's portal login URL. Mirrors campaignCta.ts. */ export const CTA_PLACEHOLDER = '[[AKASHIC_CTA]]'; /** * A stand-in length for the login URL the placeholder becomes. * * NOMINAL, and it is the one genuinely uncertain input to the estimate. The real URL carries a * per-recipient token, so its length is not knowable while the practice is typing. 40 is a * representative length; being a little wrong here is why the figure on screen says "estimated". * Counting it at ALL matters far more than the exact value: the placeholder is 15 characters and * the URL is roughly 40, so ignoring the difference would under-report by ~25 characters - enough to * hide a segment boundary on exactly the campaigns that use a login link. */ export const NOMINAL_CTA_URL_CHARS = 40; /** * What will actually go on the wire, as closely as the client can predict it: the practice's text, * with the CTA placeholder expanded, plus the server's opt-out footer. * * THE ESTIMATE MUST PRICE THE WIRE TEXT, NOT THE TEXTAREA. Both additions are invisible in the box * the practice is typing into, and both are charged. Pricing the raw textarea would under-report every * campaign - and would do it in the direction that costs the practice money. */ export const smsWireTextFor = (smsBody: string): string => smsBody.split(CTA_PLACEHOLDER).join('x'.repeat(NOMINAL_CTA_URL_CHARS)) + SMS_OPT_OUT_FOOTER; const GSM_BASIC = new Set(Array.from("@£$¥èéùìòÇ\nØø\rÅåΔ_ΦΓΛΩΠΨΣΘΞÆæßÉ !\"#¤%&'()*+,-./0123456789:;<=>?¡ABCDEFGHIJKLMNOPQRSTUVWXYZÄÖÑܧ¿abcdefghijklmnopqrstuvwxyzäöñüà")); const GSM_EXTENSION = new Set(Array.from('^{}\\[~]|€\f')); export interface SmsEncodingPlan { encoding: 'gsm7' | 'unicode'; units: number; segments: number; remaining: number; valid: boolean; reason?: string; } /** Estimates the rendered text; the server's final URL and accepted wire text determine usage. */ export const smsEncodingPlan = (body: string): SmsEncodingPlan => { let gsm = true; let units = 0; let invalid = false; for (let at = 0; at < body.length; at += 1) { const unit = body.charCodeAt(at); if (unit >= 0xd800 && unit <= 0xdbff) { const next = body.charCodeAt(at + 1); if (!(next >= 0xdc00 && next <= 0xdfff)) invalid = true; else at += 1; gsm = false; } else if (unit >= 0xdc00 && unit <= 0xdfff) { invalid = true; gsm = false; } else if (unit < 32 && ![10, 13, 12].includes(unit)) invalid = true; } for (const character of body) { if (GSM_EXTENSION.has(character)) units += 2; else if (GSM_BASIC.has(character)) units += 1; else gsm = false; } if (!gsm) units = body.length; const single = gsm ? 160 : 70; const joined = gsm ? 153 : 67; const segments = Math.max(1, units <= single ? 1 : Math.ceil(units / joined)); const valid = !invalid && body.length <= (gsm ? 1530 : 630) && (!gsm || units <= 1530); return { encoding: gsm ? 'gsm7' : 'unicode', units, segments, remaining: units <= single ? single - units : segments * joined - units, valid, ...(!valid ? { reason: invalid ? 'Remove unsupported control characters or incomplete characters.' : `The final rendered text exceeds the ${gsm ? '1,530' : '630'} character limit.` } : {}), }; }; export const smsSegmentsFor = (body: string): number => smsEncodingPlan(body).segments; export const smsCostPerRecipientUsd = (body: string): number => smsSegmentsFor(body) * SMS_PRICE_PER_SEGMENT_USD; export const charactersUntilNextSegment = (body: string): number => smsEncodingPlan(body).remaining;