import { useCallback } from 'react'; import { environment } from '../environments/environment.ts'; import { HttpError, useHttpService } from './HttpService.tsx'; import { classifyCheckoutAnswer, classifyCheckoutFailure, type CheckoutOutcome } from '../utils/invoiceCheckoutAttempt'; // The emailed invoice pay link. Staff send or end it with their own session; the payer opens and pays it with // no session at all, so those two calls carry no Authorization header and the token in the body is the only key. const API_URL = environment.orgAuthorizationApi; const UNAUTHENTICATED = { bypassAuth: true, bypassOrganization: true }; export type PayLinkState = 'payable' | 'paid' | 'expired' | 'revoked' | 'changed' | 'void' | 'invalid' | 'unavailable'; export interface PayLinkView { state: PayLinkState; practiceName?: string; reference?: string; amountCents?: number; currency?: string; // payable only: the server's opaque invoice/remaining/currency/policy snapshot admitted with `pay`. financialVersion?: string; // payable only: the minimal checkout/settlement status of a stored operation, if one was asked for. checkout?: CheckoutOutcome; } export type PayLinkSendResult = | { ok: true; sentTo: Array<'patient' | 'payer'>; expiresAt: number } | { ok: false; reason: string }; /** Thrown when the request never reached Akashic, which says nothing about the link itself. */ export class PayLinkUnreachable extends Error {} export type PayLinkPayResult = { kind: 'view'; view: PayLinkView } | CheckoutOutcome; const STATES: PayLinkState[] = ['payable', 'paid', 'expired', 'revoked', 'changed', 'void', 'invalid', 'unavailable']; const checkoutOf = (body: unknown): CheckoutOutcome | undefined => { const checkout = (body as { checkout?: unknown } | null)?.checkout; return checkout ? classifyCheckoutAnswer(checkout) : undefined; }; const viewOf = (body: unknown): PayLinkView | undefined => { const b = body as { state?: unknown; financialVersion?: unknown } | null; if (!STATES.includes(b?.state as PayLinkState)) return undefined; return { ...(body as PayLinkView), financialVersion: typeof b?.financialVersion === 'string' ? b.financialVersion : undefined, checkout: checkoutOf(body), }; }; const reasonOf = (error: unknown): string => { const reason = (error as HttpError)?.body as { error?: unknown } | undefined; return typeof reason?.error === 'string' ? reason.error : 'unavailable'; }; export const useInvoicePayLinkService = () => { const { post } = useHttpService(); const sendPayLink = useCallback(async (invoiceId: string): Promise => { try { const sent = await post<{ sentTo: Array<'patient' | 'payer'>; expiresAt: number }>( `${API_URL}/payment/link`, { invoiceId, action: 'issue' }, ); return { ok: true, sentTo: sent.sentTo, expiresAt: sent.expiresAt }; } catch (error) { return { ok: false, reason: error instanceof HttpError ? reasonOf(error) : 'unreachable' }; } }, [post]); const endPayLinks = useCallback(async (invoiceId: string): Promise => { try { await post(`${API_URL}/payment/link`, { invoiceId, action: 'revoke' }); return true; } catch { return false; } }, [post]); // `open` never creates a checkout - it only ever reads the CURRENTLY authorized link's view, plus // (when asked) the minimal status of a stored operation this tab already started. `operationId` is // omitted entirely when there is none to ask about. const openPayLink = useCallback(async (token: string, operationId?: string): Promise => { try { const body: { token: string; operationId?: string } = { token }; if (operationId) body.operationId = operationId; return viewOf(await post(`${API_URL}/payment/link/open`, body, UNAUTHENTICATED)) ?? { state: 'unavailable' }; } catch (error) { if (!(error instanceof HttpError)) throw new PayLinkUnreachable(); return viewOf(error.body) ?? { state: 'unavailable' }; } }, [post]); // `pay` starts or replays the admitted operation - always full intent, and always the id/financial // snapshot the caller already has in hand (new id + the open view's financialVersion on a fresh // press; the SAME stored id on a retry) - never re-POSTed automatically by this service itself. A // link that is no longer payable at all (expired/revoked/void/changed) answers its plain `{state}` // view instead of a typed checkout result - `viewOf` recognizes that shape first. const startPayLinkCheckout = useCallback(async ( token: string, operationId: string, financialVersion: string, successUrl: string, cancelUrl: string, ): Promise => { try { const res = await post( `${API_URL}/payment/link/pay`, { token, operationId, financialVersion, successUrl, cancelUrl }, UNAUTHENTICATED, ); return viewOf(res) ? { kind: 'view', view: viewOf(res) as PayLinkView } : classifyCheckoutAnswer(res); } catch (error) { if (!(error instanceof HttpError)) return classifyCheckoutFailure(error); const view = viewOf(error.body); return view ? { kind: 'view', view } : classifyCheckoutAnswer(error.body); } }, [post]); return { sendPayLink, endPayLinks, openPayLink, startPayLinkCheckout }; };