import { useEffect, useState } from 'react'; import { environment } from '../environments/environment'; export type AutopayMarkup = { type: 'none' } | { type: 'percent'; basisPoints: number } | { type: 'flat'; cents: number }; export type AutopayState = 'ready' | 'pending' | 'active' | 'expired' | 'stale' | 'needsReview' | 'refused'; export interface AutopaySetupResult { operationId: string; organizationId: string; contractorId: string; offerIdentity: string; packageVersion: number; state: AutopayState; canContinue: boolean; evidencePending: boolean; reason?: string; url?: string; expiresAt?: string; admitted?: boolean | 'unknown'; code?: string; message?: string; } export interface AgencyAutopayArrangement { contractorId: string; contractorName?: string; status: 'offered' | 'authorizing' | 'active'; offerIdentity: string; packageVersion: number; markup: AutopayMarkup; lines: { description: string; monthlyCents: number }[]; offeredAt?: string; activatedAt?: string; card?: { brand: string; last4: string }; setup?: AutopaySetupResult; } export interface AutopayPage { organizationId: string; arrangements: AgencyAutopayArrangement[]; complete: boolean; nextCursor?: string; } type Json = Record; const record = (value: unknown): value is Json => typeof value === 'object' && value !== null && !Array.isArray(value); const text = (value: unknown): value is string => typeof value === 'string' && value.trim().length > 0; const version = (value: unknown): value is number => Number.isInteger(value) && (value as number) >= 0; export const isAutopayOperationId = (value: unknown): value is string => typeof value === 'string' && /^[a-f\d]{8}(?:-[a-f\d]{4}){3}-[a-f\d]{12}$/i.test(value); const unreadable = (): never => { throw new Error('Contractor auto-pay could not be checked. Its setup is still unconfirmed.'); }; export const decodeAutopayResult = (value: unknown, expected: { organizationId?: string; contractorId?: string; operationId?: string; offerIdentity?: string; packageVersion?: number; }, allowReady = false): AutopaySetupResult => { if (!record(value) || !isAutopayOperationId(value.operationId) || !text(value.organizationId) || !text(value.contractorId) || !text(value.offerIdentity) || !version(value.packageVersion) || typeof value.canContinue !== 'boolean' || typeof value.evidencePending !== 'boolean' || !['ready', 'pending', 'active', 'expired', 'stale', 'needsReview', 'refused'].includes(String(value.state))) return unreadable(); for (const key of ['organizationId', 'contractorId', 'operationId', 'offerIdentity', 'packageVersion'] as const) { if (expected[key] !== undefined && value[key] !== expected[key]) return unreadable(); } if (value.reason !== undefined && !text(value.reason)) return unreadable(); if (value.expiresAt !== undefined && (!text(value.expiresAt) || !Number.isFinite(Date.parse(value.expiresAt)))) return unreadable(); if (value.state === 'ready') { if (!allowReady || !text(value.url) || !text(value.expiresAt) || Date.parse(value.expiresAt) <= Date.now()) return unreadable(); let url: URL; try { url = new URL(value.url); } catch { return unreadable(); } if (url.protocol !== 'https:' || url.hostname !== 'checkout.stripe.com' || url.username || url.password || url.port) return unreadable(); } else if (value.url !== undefined) return unreadable(); return value as unknown as AutopaySetupResult; }; export const decodeAutopayPage = (value: unknown, organizationId?: string): AutopayPage => { if (!record(value) || !text(value.organizationId) || (organizationId && value.organizationId !== organizationId) || !Array.isArray(value.arrangements) || typeof value.complete !== 'boolean' || (value.nextCursor !== undefined && !text(value.nextCursor)) || (value.complete && value.nextCursor !== undefined)) return unreadable(); const seen = new Set(); for (const row of value.arrangements) { if (!record(row) || !text(row.contractorId) || seen.has(row.contractorId) || !text(row.offerIdentity) || !version(row.packageVersion) || !['offered', 'authorizing', 'active'].includes(String(row.status)) || !record(row.markup) || !Array.isArray(row.lines)) return unreadable(); seen.add(row.contractorId); const markup = row.markup; if (markup.type !== 'none' && !(markup.type === 'percent' && version(markup.basisPoints)) && !(markup.type === 'flat' && version(markup.cents))) return unreadable(); if (row.lines.some((line) => !record(line) || !text(line.description) || !version(line.monthlyCents))) return unreadable(); if (row.contractorName !== undefined && !text(row.contractorName)) return unreadable(); if (row.card !== undefined && (!record(row.card) || typeof row.card.brand !== 'string' || typeof row.card.last4 !== 'string' || !/^\d{4}$/.test(row.card.last4))) return unreadable(); if (row.setup !== undefined) decodeAutopayResult(row.setup, { organizationId: value.organizationId, contractorId: row.contractorId, offerIdentity: row.offerIdentity, packageVersion: row.packageVersion, }); } return value as unknown as AutopayPage; }; export interface AutopayHold { operationId: string; opening: string; attempt: string; sent: boolean; } const fallback = new Map(); const fallbackOnly = new Set(); const listeners = new Set<() => void>(); const key = (holder: string) => `akashic.autopaySetup.v1:${environment.orgAuthorizationApi}:${holder}`; const changed = () => listeners.forEach((listener) => listener()); export const autopayHolder = (viewerId: string, organizationId: string, contractorId: string) => JSON.stringify([viewerId, organizationId, contractorId]); export const readAutopayHold = (holder: string): AutopayHold | undefined => { try { const raw = sessionStorage.getItem(key(holder)); if (!raw) return fallbackOnly.has(holder) ? fallback.get(holder) : undefined; const held: unknown = JSON.parse(raw); if (record(held) && isAutopayOperationId(held.operationId) && text(held.opening) && text(held.attempt) && typeof held.sent === 'boolean') return held as unknown as AutopayHold; return fallback.get(holder); } catch { return fallback.get(holder); } }; export const autopayStorageAvailable = (): boolean => { try { const probe = key('probe'); sessionStorage.setItem(probe, '1'); sessionStorage.removeItem(probe); return true; } catch { return false; } }; export const writeAutopayHold = (holder: string, hold: AutopayHold | undefined): void => { if (hold) fallback.set(holder, hold); else fallback.delete(holder); try { if (hold) sessionStorage.setItem(key(holder), JSON.stringify(hold)); else sessionStorage.removeItem(key(holder)); fallbackOnly.delete(holder); } catch { fallbackOnly.add(holder); } changed(); }; export const replaceAutopayHold = (holder: string, expected: AutopayHold, next: AutopayHold | undefined): boolean => { const current = readAutopayHold(holder); if (!current || current.operationId !== expected.operationId || current.attempt !== expected.attempt || current.opening !== expected.opening) return false; writeAutopayHold(holder, next); return true; }; export const useAutopayHolds = () => { const [, update] = useState(0); useEffect(() => { const listener = () => update((value) => value + 1); listeners.add(listener); return () => { listeners.delete(listener); }; }, []); }; export const newAutopayOperationId = (): string => { if (typeof crypto.randomUUID === 'function') return crypto.randomUUID(); const bytes = crypto.getRandomValues(new Uint8Array(16)); bytes[6] = (bytes[6]! & 15) | 64; bytes[8] = (bytes[8]! & 63) | 128; const hex = [...bytes].map((value) => value.toString(16).padStart(2, '0')).join(''); return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; };