// ── Top-level orchestrator ────────────────────────────────────── // // The class hosts integrate with. Composes the loader, element registry, // validation machine, tokenizer, and 3DS machine into a single subscribe-able // state machine. The React + web-component layers sit on top of this. // // Lifecycle: construct → subscribe → mount() → tokenize() → (optional) runThreeDs() → destroy() // // Key invariants: // - GPay flow is "prepare-and-wait": when Clover fires `paymentMethod`, the SDK // captures the token and enters `gpay_ready`. The host's Place Order click // triggers `tokenize()`, which returns the captured token immediately. // NEVER auto-submit (the form is equivalent to "card fields filled and valid"). // - `tokenize()` is idempotent: calling it again after success returns the same // result; calling it while in a tokenizing/submitting state will throw. import { getBrowserInfo } from './browser-info'; import { loadClover, loadClover3DS } from './clover-loader'; import { ElementRegistry } from './element-registry'; import { compileTheme } from './theme'; import { ThreeDsMachine } from './threeds-machine'; import { createRateLimiter, tokenize, type RateLimiter } from './tokenize'; import { ValidationMachine } from './validation-machine'; import type { CloverElement, CloverInstance, CloverPaymentRequestData, FieldKey, MountTargets, PaymentFieldsConfig, PaymentFieldsError, PaymentFieldsErrorCode, PaymentFieldsListener, PaymentFieldsSnapshot, ThreeDSUtil, ThreeDsInterim, ThreeDsResult, TokenizationResult, Unsubscribe, } from './types'; const GPAY_CANCEL_DEBOUNCE_MS = 1000; /** * Module-level SDK-singleton caches keyed by their natural ID. * * Why: Clover's SDK keeps internal singleton state for each * `new Clover(pakmsKey)` and especially each `new Clover3DS({merchantUuid})`. * Calling either constructor a second time on the same page produces * "Instance already created, no new instance allowed." and (for Clover * itself) intermittently makes the resulting instance's `.elements()` * return null — the WSO-27 TypeError variant. * * This bites us because WooCommerce's classic-checkout boot replaces the * payment-method `
  • ` block ~250ms after first render, destroying our * `` element and instantiating a fresh one. Each * fresh element instance otherwise calls `new Clover()` + `new Clover3DS()` * from scratch. * * We do NOT cache the ElementRegistry / its CloverElements. Clover's SDK * also dedupes event listeners per-(element, event) and throws on a * duplicate `addEventListener('change', ...)` — sharing elements across * mounts breaks `ValidationMachine.attach()`. Each mount creates its own * fresh elements via `clover.elements()`; mount #1's elements just get * orphaned (no change from the pre-fix world). */ const cloverByPakms = new Map(); const clover3DSByMerchant = new Map(); export class PaymentFieldsMachine { /** The config the machine was constructed with. Read-only after construction. */ readonly config: PaymentFieldsConfig; private readonly listeners = new Set(); private readonly rateLimiter: RateLimiter = createRateLimiter(); private snapshot: PaymentFieldsSnapshot = { state: 'idle', validation: { fields: {}, canSubmit: false }, }; private mounted = false; private destroyed = false; private clover: CloverInstance | null = null; private threeDsUtil: ThreeDSUtil | null = null; private registry: ElementRegistry | null = null; private validation: ValidationMachine | null = null; private capturedGpayToken: TokenizationResult | null = null; constructor(config: PaymentFieldsConfig) { this.config = config; } // ── public API ── /** Subscribe to snapshot updates. The listener is called immediately with the current snapshot. */ subscribe(listener: PaymentFieldsListener): Unsubscribe { this.listeners.add(listener); listener(this.snapshot); return () => { this.listeners.delete(listener); }; } getSnapshot(): PaymentFieldsSnapshot { return this.snapshot; } /** * Load the Clover SDK(s), create the elements, mount them to the host's DOM * targets, and wire validation + GPay event handlers. Idempotent — calling * again is a no-op. */ async mount(targets: MountTargets): Promise { console.log('[wcp-pf] mount() called', { targets, config: { pakmsKey: this.config.pakmsKey?.slice(0, 8) + '…', merchantId: this.config.merchantId, locale: this.config.locale, cartTotal: this.config.cartTotal, currency: this.config.currency, cloverSdkUrl: this.config.cloverSdkUrl, features: this.config.features } }); if (this.destroyed) { throw new Error('Machine has been destroyed.'); } if (this.mounted) { console.log('[wcp-pf] already mounted, skipping'); return; } try { const cachedClover = cloverByPakms.get(this.config.pakmsKey); if (cachedClover) { console.log('[wcp-pf] Clover already instantiated for this pakms — reusing'); this.clover = cachedClover; } else { console.log('[wcp-pf] loading Clover SDK from', this.config.cloverSdkUrl); const CloverCtor = await loadClover(this.config.cloverSdkUrl); console.log('[wcp-pf] Clover loaded, instantiating with pakms', this.config.pakmsKey?.slice(0, 8) + '…'); this.clover = new CloverCtor(this.config.pakmsKey); this.clover.options = { locale: this.config.locale, merchantId: this.config.merchantId, }; console.log('[wcp-pf] Clover instantiated', { hasElements: typeof this.clover.elements }); cloverByPakms.set(this.config.pakmsKey, this.clover); } } catch (error) { console.error('[wcp-pf] Clover load/instantiate FAILED', error); this.emitError('clover_load_failed', 'Failed to load Clover SDK.', error); throw error; } if (this.config.features?.threeDSecure && this.config.clover3DSSdkUrl) { const cached3DS = clover3DSByMerchant.get(this.config.merchantId); if (cached3DS) { this.threeDsUtil = cached3DS; } else { try { const Clover3DSCtor = await loadClover3DS(this.config.clover3DSSdkUrl); this.threeDsUtil = new Clover3DSCtor({ merchantUuid: this.config.merchantId }); clover3DSByMerchant.set(this.config.merchantId, this.threeDsUtil); } catch (error) { // Non-fatal — manual card flow can still work without 3DS, the API will surface the error. this.emitError('clover_3ds_load_failed', '3DS SDK failed to load.', error); } } } const wantsGpay = this.config.features?.googlePay === true; const cardholderVisible = this.shouldShowCardholderFields(); const keys: FieldKey[] = ['cardNumber', 'cardDate', 'cardCvv', 'cardPostalCode']; if (cardholderVisible) { keys.push('cardName', 'cardEmail'); } if (wantsGpay) { keys.push('paymentRequestButton'); } const styles = compileTheme(this.config.theme); const paymentRequestData = wantsGpay ? this.buildPaymentRequestData() : undefined; console.log('[wcp-pf] creating elements for keys', keys, 'with targets', targets); this.registry = new ElementRegistry({ clover: this.clover!, styles, paymentRequestData, }); this.registry.create(keys); console.log('[wcp-pf] elements created, checking targets exist in DOM:', keys.map((k) => { const sel = targets[k as keyof MountTargets]; return { key: k, selector: sel, found: sel ? !!document.querySelector(sel) : 'no-selector' }; })); this.registry.mount(targets); console.log('[wcp-pf] registry.mount() returned'); // Validation wiring const elements = new Map(); for (const [k, el] of this.registry.entries()) { elements.set(k, el); } this.validation = new ValidationMachine(keys.filter((k) => k !== 'paymentRequestButton')); this.validation.attach(elements); this.validation.subscribe((vs) => { this.update({ validation: vs }); }); // GPay event wiring const gpayEl = this.registry.get('paymentRequestButton'); if (gpayEl) { this.wireGpayEvents(gpayEl); } this.mounted = true; } /** * Return a tokenization result the host can POST to the API. * * Two paths: * - **GPay**: if we're in `gpay_ready`, return the already-captured token immediately (no second prompt). * - **Manual card**: call `clover.createToken()`, rate-limited. * * Throws on rate-limit, invalid card, missing token, or unmounted state. */ async tokenize(): Promise { if (!this.mounted) { const error: PaymentFieldsError = { code: 'not_mounted', message: 'mount() must complete before tokenize().' }; this.update({ state: 'error', error }); throw new Error(error.message); } // GPay fast path if (this.snapshot.state === 'gpay_ready' && this.capturedGpayToken) { const result = this.capturedGpayToken; this.update({ state: 'submitting', result }); this.config.onTokenized?.(result); return result; } if (!this.clover) { const error: PaymentFieldsError = { code: 'not_mounted', message: 'Clover not initialized.' }; this.update({ state: 'error', error }); throw new Error(error.message); } this.update({ state: 'tokenizing' }); let raw; try { raw = await tokenize(this.clover, { rateLimiter: this.rateLimiter }); } catch (error) { const message = (error as Error).message; const code: PaymentFieldsErrorCode = message.toLowerCase().includes('rate') ? 'rate_limited' : 'tokenization_failed'; const err: PaymentFieldsError = { code, message, cause: error }; this.update({ state: 'error', error: err }); this.config.onError?.(err); throw error; } if (raw.errors && hasErrors(raw.errors)) { // Route Clover's per-field errors back onto each firing field so the user // sees the error against the field that's actually wrong (e.g., a // "Required" message landing on cardName when CARD_NAME comes back empty). // The validation machine clears these the moment the user re-edits. // Clover-side empty-vs-valid ambiguity for cardName/cardEmail means // those fields may or may not appear in raw.errors when empty — we // surface whatever Clover reports. this.validation?.setSubmitErrors(raw.errors); const err: PaymentFieldsError = { code: 'tokenization_failed', message: firstError(raw.errors) ?? 'Card details invalid.', }; this.update({ state: 'error', error: err }); this.config.onError?.(err); throw new Error(err.message); } if (!raw.token || !raw.card) { const err: PaymentFieldsError = { code: 'tokenization_failed', message: 'Tokenization returned no token.', }; this.update({ state: 'error', error: err }); this.config.onError?.(err); throw new Error(err.message); } const result: TokenizationResult = { source: 'card', token: raw.token, card: { brand: raw.card.brand, last4: raw.card.last4, expMonth: raw.card.exp_month, expYear: raw.card.exp_year, first6: raw.card.first6, addressZip: raw.card.address_zip, }, browserInfo: this.config.features?.threeDSecure ? getBrowserInfo(this.threeDsUtil) : undefined, }; this.update({ state: 'submitting', result }); this.config.onTokenized?.(result); return result; } /** * Drive the 3DS flow given an interim payload from the host's API response. * Handles method/challenge dispatch, the `executePatch` callback, the * finalize POST, and escalation (method ↔ challenge) internally. * * @param options.extraBody Host-specific fields merged into the finalize POST body. * E.g., WC passes `{ order_id, order_key, nonce }` so the WP REST endpoint * can find and update the order. Keeps WC-specific concerns out of the SDK. */ async runThreeDs( interim: ThreeDsInterim, options: { readonly extraBody?: Record } = {}, ): Promise { if (!this.threeDsUtil) { const err: PaymentFieldsError = { code: 'threeds_prerequisites_missing', message: '3DS SDK not loaded.', }; this.update({ state: 'error', error: err }); return { kind: 'failure', message: err.message }; } const finalizeUrl = this.config.endpoints?.threeDsFinalize; if (!finalizeUrl) { const err: PaymentFieldsError = { code: 'threeds_finalize_failed', message: 'No 3DS finalize URL configured.', }; this.update({ state: 'error', error: err }); return { kind: 'failure', message: err.message }; } this.config.onThreeDsRequired?.(interim); const machine = new ThreeDsMachine({ util: this.threeDsUtil, finalizeUrl, headers: this.config.endpoints?.headers, extraBody: options.extraBody, }); let current = interim; while (true) { this.update({ state: current.state === 'method_required' ? 'threeds_method' : 'threeds_challenge', threeDs: current, }); const result = await machine.run(current); if (result.kind === 'escalation') { current = result.next; continue; } if (result.kind === 'success') { this.update({ state: 'done' }); return result; } const err: PaymentFieldsError = { code: 'threeds_finalize_failed', message: result.message }; this.update({ state: 'error', error: err }); this.config.onError?.(err); return result; } } /** * Restore the form to idle, discarding any captured GPay token. Used by the * "Use card details instead" link on the GPay-ready state. */ resetToIdle(): void { this.capturedGpayToken = null; this.update({ state: 'idle', result: undefined, threeDs: undefined, error: undefined, }); } destroy(): void { this.destroyed = true; this.registry?.destroy(); this.registry = null; this.validation = null; // The Clover instance and 3DS util are module-level singletons (see the // cache map at the top of this file). We drop our refs but the underlying // SDK objects stay alive for the next mount on this pakms key. this.threeDsUtil = null; this.clover = null; this.capturedGpayToken = null; this.listeners.clear(); } // ── internals ── private shouldShowCardholderFields(): boolean { const setting = this.config.features?.cardholderFields ?? 'auto'; if (setting === 'always') return true; if (setting === 'hidden') return false; return this.config.features?.threeDSecure === true; } private buildPaymentRequestData(): CloverPaymentRequestData { return { paymentReqData: { total: { label: 'Online purchase via Clover', currency: this.config.currency, amount: this.config.cartTotal, }, options: { button: { buttonType: 'short' }, }, }, }; } private wireGpayEvents(el: CloverElement): void { let methodFiredForCurrentFlow = false; el.addEventListener('paymentMethodStart', () => { methodFiredForCurrentFlow = false; this.capturedGpayToken = null; this.update({ state: 'gpay_opening' }); }); el.addEventListener('paymentMethod', (ev: Event) => { methodFiredForCurrentFlow = true; // Clover's GPay `paymentMethod` event payload is shaped completely // differently from manual-card tokenization — brand lives at // `customer.billingInfo.cardNetwork` and last4 at `cardDetails`. Expiry // and first6 are NOT provided by GPay. const evAny = ev as unknown as { token?: string; customer?: { email?: string; billingInfo?: { cardNetwork?: string; cardDetails?: string; billingAddress?: { postalCode?: string; }; }; }; }; const billing = evAny.customer?.billingInfo; const brand = billing?.cardNetwork; const last4 = billing?.cardDetails; if (!evAny.token || !brand || !last4) { const err: PaymentFieldsError = { code: 'tokenization_failed', message: 'Google Pay token missing required fields.', }; this.update({ state: 'error', error: err }); this.config.onError?.(err); return; } const result: TokenizationResult = { source: 'google_pay', token: evAny.token, card: { brand, last4, // GPay doesn't expose expMonth/expYear/first6 in the event payload. addressZip: billing?.billingAddress?.postalCode, }, browserInfo: this.config.features?.threeDSecure ? getBrowserInfo(this.threeDsUtil) : undefined, }; this.capturedGpayToken = result; // Critical: do NOT call onTokenized here. The host hasn't asked for the token. // We hold it internally; tokenize() returns it when called from Place Order. this.update({ state: 'gpay_ready', result }); }); el.addEventListener('paymentMethodEnd', () => { // Cancellation: if `paymentMethod` never fired within the debounce window, // the user closed the GPay sheet without confirming. window.setTimeout(() => { if (methodFiredForCurrentFlow) { return; } if (this.snapshot.state !== 'gpay_opening') { return; } this.update({ state: 'gpay_cancelled' }); window.setTimeout(() => { if (this.snapshot.state === 'gpay_cancelled') { this.update({ state: 'idle' }); } }, 100); }, GPAY_CANCEL_DEBOUNCE_MS); }); } private update(partial: Partial): void { this.snapshot = { ...this.snapshot, ...partial }; for (const listener of this.listeners) { listener(this.snapshot); } } private emitError(code: PaymentFieldsErrorCode, message: string, cause?: unknown): void { const error: PaymentFieldsError = { code, message, cause }; this.update({ state: 'error', error }); this.config.onError?.(error); } } function hasErrors(errors: Record): boolean { return Object.values(errors).some((v) => !!v); } function firstError(errors: Record): string | undefined { return Object.values(errors).find((v) => !!v); }