// Invoicing durable checkout (F07/F08/J01) - the client half shared by the authenticated portal and // the no-login pay link. Two jobs: classify ANY settled answer from POST /payment/checkout or // /payment/link/pay by its own typed `result`/`code`, never by HTTP status alone or a message // substring; and keep the one durable pending-checkout record for an invoice across the hosted-redirect // round trip, so "Check again" / a reopen replays the SAME operation id rather than minting a new one. export type CheckoutIntent = 'full' | 'partial'; // Known pre-effect refusal codes the UI has specific copy for; anything else is 'unknown' (generic // soft error), never silently swallowed into a wrong, more specific message. export type CheckoutRefusalCode = | 'no_payment_gateway' | 'payments_unavailable' | 'partial_pay_not_allowed' | 'amount_exceeds_balance' | 'invalid_amount' | 'payment_in_progress' | 'invoice_not_payable' | 'operation_reused' | 'invalid_redirect_url' | 'forbidden' | 'invoice_not_found' | 'invoice_id_required' | 'unknown'; const REFUSAL_CODES: ReadonlySet = new Set([ 'no_payment_gateway', 'payments_unavailable', 'partial_pay_not_allowed', 'amount_exceeds_balance', 'invalid_amount', 'payment_in_progress', 'invoice_not_payable', 'operation_reused', 'invalid_redirect_url', 'forbidden', 'invoice_not_found', 'invoice_id_required', ]); const refusalCode = (code: unknown): CheckoutRefusalCode => (typeof code === 'string' && REFUSAL_CODES.has(code) ? (code as CheckoutRefusalCode) : 'unknown'); // The bare `{error}` answers the start handlers give before any operation or provider call exists: the only untyped // answers that prove nothing was started. Any other untyped error (a generic failure) proves nothing either way. const BARE_PRE_EFFECT: ReadonlySet = new Set([ 'organization_required', 'invoice_id_required', 'no_payment_gateway', 'invoice_not_found', 'forbidden', ]); /** * Whose answer it is: `operation` was read from the operation stored under the id, so it disposes of that operation; * `attempt` is about this request alone and says nothing of an operation already stored under the id. */ export type CheckoutAnswerScope = 'operation' | 'attempt'; export type CheckoutOutcome = { scope?: CheckoutAnswerScope } & ( | { kind: 'ready'; operationId: string; url: string; sessionId: string; amountCents: number; currency: string; intent: CheckoutIntent; originalOperationId?: string } | { kind: 'pending'; operationId?: string; code?: string } | { kind: 'paid'; operationId?: string } | { kind: 'expired'; operationId?: string } | { kind: 'changed'; operationId?: string; remainingCents: number; currency: string; allowPartial: boolean; financialVersion?: string } // A refusal is always one known to have had no provider effect; an answer that cannot say so is `pending`. | { kind: 'refused'; operationId?: string; code: CheckoutRefusalCode; effect: 'none' }); const scopeOf = (value: unknown): { scope?: CheckoutAnswerScope } => (value === 'operation' || value === 'attempt' ? { scope: value } : {}); /** * Classifies a RESOLVED checkout/link-pay body. Every typed `result` maps to its own outcome, but a refusal counts only * when it says it had no effect: a typed refusal of unknown or unstated effect, an untyped error other than the start * handlers' own pre-effect ones, and a body with neither are all `pending` (outcome unknown), never a refusal. A first * send is no exception: where its answer failed is not known from its being first. */ export const classifyCheckoutAnswer = (body: unknown): CheckoutOutcome => { const b = (body ?? {}) as Record; const outcome = classifyResult(b); return outcome.kind === 'pending' && b.result !== 'pending' ? outcome : { ...outcome, ...scopeOf(b.scope) }; }; const classifyResult = (b: Record): CheckoutOutcome => { switch (b.result) { case 'ready': return { kind: 'ready', operationId: String(b.operationId ?? ''), url: String(b.url ?? ''), sessionId: String(b.sessionId ?? ''), amountCents: Number(b.amountCents ?? 0), currency: String(b.currency ?? 'usd'), intent: b.intent === 'partial' ? 'partial' : 'full', originalOperationId: typeof b.originalOperationId === 'string' ? b.originalOperationId : undefined, }; case 'pending': return { kind: 'pending', operationId: b.operationId as string | undefined, code: b.code as string | undefined }; case 'paid': return { kind: 'paid', operationId: b.operationId as string | undefined }; case 'expired': return { kind: 'expired', operationId: b.operationId as string | undefined }; case 'changed': return { kind: 'changed', operationId: b.operationId as string | undefined, remainingCents: Number(b.remainingCents ?? 0), currency: String(b.currency ?? 'usd'), allowPartial: Boolean(b.allowPartial), financialVersion: typeof b.financialVersion === 'string' ? b.financialVersion : undefined, }; case 'refused': { const code = b.code ?? b.error; if (b.effect !== 'none') { return { kind: 'pending', operationId: b.operationId as string | undefined, ...(typeof code === 'string' && { code }) }; } return { kind: 'refused', operationId: b.operationId as string | undefined, code: refusalCode(code), effect: 'none' }; } default: if (typeof b.error === 'string' && BARE_PRE_EFFECT.has(b.error)) { return { kind: 'refused', code: refusalCode(b.error), effect: 'none' }; } return { kind: 'pending', ...(typeof b.error === 'string' && { code: b.error }) }; } }; /** * Classifies a THROWN failure the same way a resolved answer would be. A network failure, an * unreadable body, or a 5xx with no typed body is always `pending` (outcome unknown) - it is never * read as a refusal, and must never clear a stored pending operation id. */ // Read by shape, not by importing the HTTP service, so this module loads where no browser does. const answeredBody = (err: unknown): unknown => { if (!(err instanceof Error) || typeof (err as { status?: unknown }).status !== 'number') return undefined; return (err as { body?: unknown }).body; }; export const classifyCheckoutFailure = (err: unknown): CheckoutOutcome => { const body = answeredBody(err); if (body !== undefined && body !== null) return classifyCheckoutAnswer(body); return { kind: 'pending' }; }; // --- Durable pending-attempt store (authenticated portal) -------------------------------------------- // Scoped by viewer + practice + invoice, so A->B->A (a different viewer, practice or invoice opening) // never reads back someone else's attempt. Kept in localStorage - NOT memory - because the hosted // redirect navigates away and back, destroying any in-memory module state. export interface CheckoutAttempt { operationId: string; invoiceId: string; intent: CheckoutIntent; amountCents?: number; currency: string; expectedRemainingCents: number; createdAt: number; // Set once the server has published a session for this attempt, so a later return-redirect (which // only carries the session id) can recover which operation it belongs to. sessionId?: string; // The request exactly as first sent: every retry of this operation sends it again unchanged. request?: CheckoutRequestBody; } /** * Thrown when the attempt cannot be durably persisted. The caller must show this BEFORE sending the * checkout request, rather than falling back to an in-memory record - a redirect round trip would lose * that memory anyway, so a silent fallback here would be a false durable-recovery claim. */ export class CheckoutStorageUnavailable extends Error {} const PREFIX = 'akashic.invoiceCheckoutAttempt.'; const slot = (viewer: string, practiceId: string, invoiceId: string) => `${PREFIX}${viewer}.${practiceId}.${invoiceId}`; const parseAttempt = (raw: string | null): CheckoutAttempt | null => { if (!raw) return null; try { const value = JSON.parse(raw) as Partial; return typeof value.operationId === 'string' && typeof value.invoiceId === 'string' ? (value as CheckoutAttempt) : null; } catch { return null; } }; /** * The stored attempt, or null when there is none. Throws CheckoutStorageUnavailable when storage cannot be read or * holds something unreadable: an attempt may be stored there, so it is never taken as absent. */ export const readCheckoutAttempt = (viewer: string, practiceId: string, invoiceId: string): CheckoutAttempt | null => { let raw: string | null; try { raw = window.localStorage.getItem(slot(viewer, practiceId, invoiceId)); } catch (storageError) { throw new CheckoutStorageUnavailable(storageError instanceof Error ? storageError.message : String(storageError)); } const attempt = parseAttempt(raw); if (raw && !attempt) throw new CheckoutStorageUnavailable('The stored checkout attempt could not be read.'); return attempt; }; export const writeCheckoutAttempt = ( viewer: string, practiceId: string, invoiceId: string, attempt: CheckoutAttempt | null, ): void => { const name = slot(viewer, practiceId, invoiceId); try { if (attempt) window.localStorage.setItem(name, JSON.stringify(attempt)); else window.localStorage.removeItem(name); } catch (storageError) { throw new CheckoutStorageUnavailable(storageError instanceof Error ? storageError.message : String(storageError)); } }; /** * Recovers a stored attempt by its published session id. The return-redirect poll only has the * session id in the URL (never the invoice id), so it scans this viewer+practice's own attempts rather * than being handed one directly. */ export const findCheckoutAttemptBySession = ( viewer: string, practiceId: string, sessionId: string, ): CheckoutAttempt | null => { try { const prefix = `${PREFIX}${viewer}.${practiceId}.`; for (let i = 0; i < window.localStorage.length; i += 1) { const key = window.localStorage.key(i); if (!key || !key.startsWith(prefix)) continue; const attempt = parseAttempt(window.localStorage.getItem(key)); if (attempt?.sessionId === sessionId) return attempt; } } catch { /* storage unavailable: the return poll proceeds without a recovered operation id. */ } return null; }; // --- Request body (authenticated portal) ------------------------------------------------------------- export interface CheckoutExpected { remainingCents: number; currency: string; allowPartial: boolean } export interface CheckoutRequestBody { invoiceId: string; operationId: string; intent: CheckoutIntent; amountCents?: number; expected: CheckoutExpected; successUrl: string; cancelUrl: string; } export const buildCheckoutRequestBody = (params: { invoiceId: string; operationId: string; intent: CheckoutIntent; amountCents?: number; expected: CheckoutExpected; successUrl: string; cancelUrl: string; }): CheckoutRequestBody => { const body: CheckoutRequestBody = { invoiceId: params.invoiceId, operationId: params.operationId, intent: params.intent, expected: params.expected, successUrl: params.successUrl, cancelUrl: params.cancelUrl, }; // Only a partial intent carries an explicit amount; a full intent always resolves to the server's // own current remaining balance, never a client-sent total. if (params.intent === 'partial' && params.amountCents !== undefined) body.amountCents = params.amountCents; return body; };