// ── 3DS state machine ─────────────────────────────────────────── // // Drives Clover's `_3DSUtil` through method (fingerprinting) and challenge // flows, listens for the `executePatch` event Clover dispatches on `document` // when its iframe completes, POSTs the outcome to the host's finalize URL, // and recurses on escalation (method → challenge or vice versa). import { parseInterim, postFinalize } from './threeds-finalize-client'; import type { HeadersProvider, ThreeDSUtil, ThreeDsInterim, ThreeDsResult } from './types'; const DEFAULT_METHOD_TIMEOUT_MS = 30 * 1000; const DEFAULT_CHALLENGE_TIMEOUT_MS = 5 * 60 * 1000; interface ExecutePatchDetail { _3DSStatus?: string; } export interface ThreeDsMachineOptions { readonly util: ThreeDSUtil; readonly finalizeUrl: string; readonly headers?: HeadersProvider; /** Extra fields the host needs in the finalize body (order_id, order_key, nonce). */ readonly extraBody?: Record; readonly methodTimeoutMs?: number; readonly challengeTimeoutMs?: number; } export class ThreeDsMachine { private readonly options: ThreeDsMachineOptions; private cancelled = false; constructor(options: ThreeDsMachineOptions) { this.options = options; } cancel(): void { this.cancelled = true; } /** * Run a single 3DS interim payload to a terminal result. For escalation * (method completes → Clover wants challenge, or vice versa), the result * is returned with `kind: 'escalation'`; the caller (PaymentFieldsMachine) * re-enters the loop with the next interim. */ async run(interim: ThreeDsInterim): Promise { if (this.cancelled) { return { kind: 'failure', message: 'Cancelled.' }; } if (interim.state === 'method_required') { try { const status = await this.runMethod(interim); if (this.cancelled) { return { kind: 'failure', message: 'Cancelled.' }; } return this.finalize(interim, status); } catch (error) { return { kind: 'failure', message: (error as Error).message }; } } if (interim.state === 'challenge_required') { const cleanup = this.styleChallengeContainer(); try { const status = await this.runChallenge(interim); if (this.cancelled) { return { kind: 'failure', message: 'Cancelled.' }; } return await this.finalize(interim, status); } catch (error) { return { kind: 'failure', message: (error as Error).message }; } finally { cleanup(); } } return { kind: 'failure', message: `Unsupported 3DS state: ${(interim as { state: string }).state}`, }; } // ── flow handlers ── private runMethod(interim: ThreeDsInterim): Promise { const f = interim.fields; if (!f.threeDsServerTransactionId || !f.methodUrl || !f.methodNotificationUrl) { return Promise.reject(new Error('3DS method prerequisites missing.')); } return this.awaitExecutePatch( this.options.methodTimeoutMs ?? DEFAULT_METHOD_TIMEOUT_MS, 'method flow timed out', () => this.options.util.perform3DSFingerPrinting({ threeDSServerTransID: f.threeDsServerTransactionId!, threeDSMethodUrl: f.methodUrl!, methodNotificationUrl: f.methodNotificationUrl!, }), ); } private runChallenge(interim: ThreeDsInterim): Promise { const f = interim.fields; if (!f.acsUrl || !f.acsTransactionId || !f.threeDsServerTransactionId || !f.protocolVersion) { return Promise.reject(new Error('3DS challenge prerequisites missing.')); } return this.awaitExecutePatch( this.options.challengeTimeoutMs ?? DEFAULT_CHALLENGE_TIMEOUT_MS, 'challenge flow timed out', () => this.options.util.perform3DSChallenge({ messageVersion: f.protocolVersion!, acsTransID: f.acsTransactionId!, acsUrl: f.acsUrl!, threeDSServerTransID: f.threeDsServerTransactionId!, }), ); } private awaitExecutePatch(timeoutMs: number, timeoutMessage: string, kickoff: () => void): Promise { return new Promise((resolve, reject) => { let settled = false; const finalize = (status: string): void => { if (settled) return; settled = true; window.clearTimeout(timeoutId); document.removeEventListener('executePatch', handler); resolve(status); }; const handler = (ev: Event): void => { const detail = (ev as CustomEvent).detail; finalize(detail?._3DSStatus ?? ''); }; const timeoutId = window.setTimeout(() => { if (settled) return; settled = true; document.removeEventListener('executePatch', handler); reject(new Error(timeoutMessage)); }, timeoutMs); document.addEventListener('executePatch', handler); try { kickoff(); } catch (error) { settled = true; window.clearTimeout(timeoutId); document.removeEventListener('executePatch', handler); reject(error as Error); } }); } // ── finalize ── private async finalize(interim: ThreeDsInterim, flowStatus: string): Promise { const body: Record = { charge_id: interim.chargeId, flow_status: flowStatus, ...(this.options.extraBody ?? {}), }; let envelope; try { envelope = await postFinalize({ url: this.options.finalizeUrl, headers: this.options.headers, body, }); } catch (error) { return { kind: 'failure', message: (error as Error).message }; } const data = envelope.data; if (!data) { return { kind: 'failure', message: 'Verification response unreadable.' }; } if (data.weeconnectpay_threeds_interim) { const next = parseInterim(data.weeconnectpay_threeds_interim); if (next) { return { kind: 'escalation', next }; } } if (envelope.ok && data.success === true) { return { kind: 'success', redirect: data.redirect, data }; } return { kind: 'failure', message: data.message ?? 'Payment verification failed.', }; } // ── challenge container styling ── // // Clover injects `#threedsContainer` into the DOM when `perform3DSChallenge` // runs. We style it (overlay + centered card) via a MutationObserver so we // don't depend on Clover's render timing. private styleChallengeContainer(): () => void { const container = document.getElementById('threedsContainer'); if (!container) { return () => undefined; } container.style.cssText = 'position:fixed;inset:0;z-index:99999;display:flex;flex-direction:column;' + 'align-items:center;justify-content:center;background:rgba(0,0,0,0.6);padding:1rem;'; const styleIframe = (): boolean => { const iframe = container.querySelector('iframe'); if (!iframe) { return false; } iframe.style.background = 'white'; iframe.style.borderRadius = '0.75rem'; iframe.style.boxShadow = '0 25px 50px -12px rgba(0,0,0,0.4)'; iframe.style.maxWidth = '100%'; iframe.style.display = 'block'; return true; }; let observer: MutationObserver | null = null; if (!styleIframe()) { observer = new MutationObserver(() => { if (styleIframe()) { observer?.disconnect(); observer = null; } }); observer.observe(container, { childList: true }); } return () => { observer?.disconnect(); container.style.cssText = ''; }; } }