// ── 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);
}