// Pure, React-free helpers for the patient-portal payment redirect flow (Invoicing Phase 3, Slice B - // direct patient payments, BYO processor). Kept dependency-free so it unit-tests without dragging the // services barrel, and so the checkout/return logic is verifiable in isolation. import { formatMoney, parseDollarsToCents } from './money'; // Checkout result classification and the request-body shape now live in invoiceCheckoutAttempt.ts, // which also owns the durable pending-operation store; this file keeps only the React-free helpers // that do not depend on the typed endpoint result (URL building, session-id parsing, partial-amount UX). // Build the same-origin portal URLs handed to the checkout endpoint as successUrl/cancelUrl. // // We KEEP the current route (window.location.hash) because the app is a HashRouter SPA: dropping the // hash would return the patient to the default route instead of the invoices page where the return poll // is mounted. We DROP any existing query string (callers pass window.location.{origin,pathname,hash}) so // a stale session_id is never re-sent. The server appends `session_id={CHECKOUT_SESSION_ID}` itself and // validates only the HOST against the org's configured portal host, so path + fragment are free to vary. export const buildCheckoutUrls = ( origin: string, pathname: string, hash: string, ): { successUrl: string; cancelUrl: string } => { const base = `${origin}${pathname}${hash}`; return { successUrl: base, cancelUrl: base }; }; // Extract Stripe's checkout session id from the returned URL. Because the app is a HashRouter SPA, the // server-appended `session_id=` may land on the REAL query string (`?session_id=`) OR inside the fragment // (`#/invoices?session_id=`) depending on whether the successUrl carried a hash - so we scan the whole // href for a `session_id` query param rather than reading window.location.search alone. export const parseReturnSessionId = (href: string): string | null => { const match = /[?&]session_id=([^&#]+)/.exec(href ?? ''); return match ? decodeURIComponent(match[1]) : null; }; // Remove the session_id (and our optional pay=cancelled marker) from a URL so a manual refresh doesn't // re-trigger the poll, tidying any dangling separators left behind. Operates on the string form so it // works identically whether the params sit on the query string or inside a HashRouter fragment. export const stripPaymentReturnParams = (href: string): string => { let out = href ?? ''; out = out.replace(/([?&])session_id=[^&#]*/g, '$1'); out = out.replace(/([?&])pay=[^&#]*/g, '$1'); out = out.replace(/([?&])&+/g, '$1'); // collapse "?&""?" and "&&""&" out = out.replace(/[?&]$/g, ''); // drop a now-empty trailing "?" or "&" return out; }; // Client-side validation for the patient portal's "Pay a different amount" input (Task 9). This is UX // only - a disabled button / inline hint before submit - never the real boundary; the server re-validates // amountCents against the remaining balance and the invoice's allowPartialPay independently. Parses the // same way the invoice line editor does (parseDollarsToCents: dollars text -> integer cents), then // range-checks against the remaining balance (also integer cents). export type PartialPayValidation = { ok: true; cents: number } | { ok: false; error: string }; export const parsePartialPayAmount = (input: string, remainingCents: number): PartialPayValidation => { const cents = parseDollarsToCents(input); if (cents === undefined) { return { ok: false, error: 'Enter an amount.' }; } if (cents <= 0) { return { ok: false, error: 'Enter an amount greater than $0.' }; } if (cents > remainingCents) { return { ok: false, error: `Cannot exceed ${formatMoney(remainingCents)} remaining.` }; } return { ok: true, cents }; };