diff --git a/src/backend/Graph/default_payment_method.d.ts b/src/backend/Graph/default_payment_method.d.ts index 2f4946b..a2fd319 100644 --- a/src/backend/Graph/default_payment_method.d.ts +++ b/src/backend/Graph/default_payment_method.d.ts @@ -19,6 +19,8 @@ export interface DefaultPaymentMethod extends Graph { save_cc: boolean; /** The credit card or debit card type. This will be determined automatically once the payment card is saved. */ cc_type: string | null; + /** Token returned by our Tokenization Embed. Send this field with PATCH to update customer's payment method. */ + cc_token?: string; /** The payment card number. This property will not be displayed as part of this resource, but can be used to modify this payment method. */ cc_number?: number; /** A masked version of this payment card showing only the last 4 digits. */ diff --git a/src/customer/Graph/default_payment_method.d.ts b/src/customer/Graph/default_payment_method.d.ts index 591d433..a2833bf 100644 --- a/src/customer/Graph/default_payment_method.d.ts +++ b/src/customer/Graph/default_payment_method.d.ts @@ -15,8 +15,8 @@ export interface DefaultPaymentMethod extends Graph { save_cc: boolean; /** The credit card or debit card type. This will be determined automatically once the payment card is saved. */ cc_type: string | null; - /** The payment card number. This property will not be displayed as part of this resource, but can be used to modify this payment method. */ - cc_number?: number; + /** Token returned by our Tokenization Embed. Send this field with PATCH to update customer's payment method. */ + cc_token?: string; /** A masked version of this payment card showing only the last 4 digits. */ cc_number_masked: string | null; /** The payment card expiration month in the MM format. */ diff --git a/src/customer/PaymentCardEmbed.ts b/src/customer/PaymentCardEmbed.ts new file mode 100644 index 0000000..ff1fb66 --- /dev/null +++ b/src/customer/PaymentCardEmbed.ts @@ -0,0 +1,179 @@ +import type { PaymentCardEmbedConfig } from './types'; + +/** + * A convenience wrapper for the payment card embed iframe. You don't have to use + * this class to embed the payment card iframe, but it provides a more convenient + * way to interact with the iframe and listen to its events. + * + * @example + * const embed = new PaymentCardEmbed({ + * url: 'https://embed.foxy.io/v1.html?template_set_id=123' + * }); + * + * await embed.mount(document.body); + * console.log('Token:', await embed.tokenize()); + */ +export class PaymentCardEmbed { + /** + * An event handler that is triggered when Enter is pressed in the card form. + * This feature is not available for template sets configured with the `stripe_connect` + * hosted payment gateway due to the limitations of Stripe.js. + */ + onsubmit: (() => void) | null = null; + + private __tokenizationRequests: { + resolve: (token: string) => void; + reject: () => void; + id: string; + }[] = []; + + private __iframeMessageHandler = (evt: MessageEvent) => { + const data = JSON.parse(evt.data); + + switch (data.type) { + case 'tokenization_response': { + const request = this.__tokenizationRequests.find(r => r.id === data.id); + data.token ? request?.resolve(data.token) : request?.reject(); + this.__tokenizationRequests = this.__tokenizationRequests.filter(r => r.id !== data.id); + break; + } + case 'submit': { + this.onsubmit?.(); + break; + } + case 'resize': { + if (this.__iframe) this.__iframe.style.height = data.height; + break; + } + case 'ready': { + this.configure(this.__config); + this.__mountingTask?.resolve(); + break; + } + } + }; + + private __iframeLoadHandler = (evt: Event) => { + if (this.__channel) { + const contentWindow = (evt.currentTarget as HTMLIFrameElement).contentWindow; + if (!contentWindow) throw new Error('Content window is not available.'); + contentWindow.postMessage('connect', '*', [this.__channel.port2]); + } + }; + + private __mountingTask: { resolve: () => void; reject: () => void } | null = null; + + private __channel: MessageChannel | null = null; + + private __iframe: HTMLIFrameElement | null = null; + + private __config: PaymentCardEmbedConfig; + + private __url: string; + + constructor({ url, ...config }: { url: string } & PaymentCardEmbedConfig) { + this.__config = config; + this.__url = url; + } + + /** + * Updates the configuration of the payment card embed. + * You can change style, translations, language and interactivity settings. + * To change the URL of the payment card embed, you need to create a new instance. + * You are not required to provide the full configuration object, only the properties you want to change. + * + * @param config - The new configuration. + */ + configure(config: PaymentCardEmbedConfig): void { + this.__config = config; + const message = { type: 'config', ...config }; + this.__channel?.port1.postMessage(JSON.stringify(message)); + } + + /** + * Requests the tokenization of the card data. + * + * @returns A promise that resolves with the tokenized card data. + */ + tokenize(): Promise { + return new Promise((resolve, reject) => { + if (this.__channel) { + const id = this._createId(); + this.__tokenizationRequests.push({ id, reject, resolve }); + this.__channel.port1.postMessage(JSON.stringify({ id, type: 'tokenization_request' })); + } else { + reject(); + } + }); + } + + /** + * Safely removes the embed iframe from the parent node, + * closing the message channel and cleaning up event listeners. + */ + unmount(): void { + this.__channel?.port1.removeEventListener('message', this.__iframeMessageHandler); + this.__channel?.port1.close(); + this.__channel?.port2.close(); + this.__channel = null; + + this.__iframe?.removeEventListener('load', this.__iframeLoadHandler); + this.__iframe?.remove(); + this.__iframe = null; + + this.__mountingTask?.reject(); + this.__mountingTask = null; + } + + /** + * Mounts the payment card embed in the given root element. If the embed is already mounted, + * it will be unmounted first. + * + * @param root - The root element to mount the embed in. + * @returns A promise that resolves when the embed is mounted. + */ + mount(root: Element): Promise { + this.unmount(); + + this.__channel = this._createMessageChannel(); + this.__channel.port1.addEventListener('message', this.__iframeMessageHandler); + this.__channel.port1.start(); + + this.__iframe = this._createIframe(root); + this.__iframe.addEventListener('load', this.__iframeLoadHandler); + this.__iframe.style.transition = 'height 0.15s ease'; + this.__iframe.style.margin = '-2px'; + this.__iframe.style.height = '100px'; + this.__iframe.style.width = 'calc(100% + 4px)'; + this.__iframe.src = this.__url; + + root.append(this.__iframe); + + return new Promise((resolve, reject) => { + this.__mountingTask = { reject, resolve }; + }); + } + + /** + * Clears the card data from the embed. + * No-op if the embed is not mounted. + */ + clear(): void { + this.__channel?.port1.postMessage(JSON.stringify({ type: 'clear' })); + } + + /* v8 ignore next */ + protected _createMessageChannel(): MessageChannel { + return new MessageChannel(); + } + + /* v8 ignore next */ + protected _createIframe(root: Element): HTMLIFrameElement { + return root.ownerDocument.createElement('iframe'); + } + + /* v8 ignore next */ + protected _createId(): string { + return `${Date.now()}${Math.random().toFixed(6).slice(2)}`; + } +} diff --git a/src/customer/index.ts b/src/customer/index.ts index cf725c2..6096a48 100644 --- a/src/customer/index.ts +++ b/src/customer/index.ts @@ -3,6 +3,7 @@ export { getAllowedFrequencies } from './getAllowedFrequencies.js'; export { getNextTransactionDateConstraints } from './getNextTransactionDateConstraints.js'; export { getTimeFromFrequency } from '../backend/getTimeFromFrequency.js'; export { isNextTransactionDate } from './isNextTransactionDate.js'; +export { PaymentCardEmbed } from './PaymentCardEmbed.js'; import type * as Rels from './Rels'; export type { Graph } from './Graph'; diff --git a/src/customer/types.d.ts b/src/customer/types.d.ts index 309db9a..eeba2b0 100644 --- a/src/customer/types.d.ts +++ b/src/customer/types.d.ts @@ -1,3 +1,87 @@ +/** Tokenization embed configuration that can be updated any time after mount. */ +export type PaymentCardEmbedConfig = Partial<{ + /** Translations. Note that Stripe and Square provide their own translations that can't be customized. */ + translations: { + stripe?: { + label?: string; + status?: { + idle?: string; + busy?: string; + fail?: string; + }; + }; + square?: { + label?: string; + status?: { + idle?: string; + busy?: string; + fail?: string; + }; + }; + default?: { + 'cc-number'?: { + label?: string; + placeholder?: string; + v8n_required?: string; + v8n_invalid?: string; + v8n_unsupported?: string; + }; + 'cc-exp'?: { + label?: string; + placeholder?: string; + v8n_required?: string; + v8n_invalid?: string; + v8n_expired?: string; + }; + 'cc-csc'?: { + label?: string; + placeholder?: string; + v8n_required?: string; + v8n_invalid?: string; + }; + 'status'?: { + idle?: string; + busy?: string; + fail?: string; + misconfigured?: string; + }; + }; + }; + /** If true, all fields inside the embed will be disabled. */ + disabled: boolean; + /** If true, all fields inside the embed will be set to be read-only. For Stripe and Square the fields will be disabled and styled as readonly. */ + readonly: boolean; + /** Appearance settings. */ + style: Partial<{ + '--lumo-space-m': string; + '--lumo-space-s': string; + '--lumo-contrast-5pct': string; + '--lumo-contrast-10pct': string; + '--lumo-contrast-50pct': string; + '--lumo-size-m': string; + '--lumo-size-xs': string; + '--lumo-border-radius-m': string; + '--lumo-border-radius-s': string; + '--lumo-font-family': string; + '--lumo-font-size-m': string; + '--lumo-font-size-s': string; + '--lumo-font-size-xs': string; + '--lumo-primary-color': string; + '--lumo-primary-text-color': string; + '--lumo-primary-color-50pct': string; + '--lumo-secondary-text-color': string; + '--lumo-disabled-text-color': string; + '--lumo-body-text-color': string; + '--lumo-error-text-color': string; + '--lumo-error-color-10pct': string; + '--lumo-error-color-50pct': string; + '--lumo-line-height-xs': string; + '--lumo-base-color': string; + }>; + /** Locale to use with Stripe or Square. Has no effect on the default UI. */ + lang: string; +}>; + /** User credentials for authentication. */ export interface Credentials { /** Email address associated with an account. */ diff --git a/tests/customer/PaymentCardEmbed.test.ts b/tests/customer/PaymentCardEmbed.test.ts new file mode 100644 index 0000000..23e041a --- /dev/null +++ b/tests/customer/PaymentCardEmbed.test.ts @@ -0,0 +1,292 @@ +import { PaymentCardEmbed } from '../../src/customer/PaymentCardEmbed'; + +const testMessageChannel = { + port1: { + addEventListener: jest.fn(), + close: jest.fn(), + postMessage: jest.fn(), + removeEventListener: jest.fn(), + start: jest.fn(), + }, + port2: { + close: jest.fn(), + }, +}; + +const testIframe = { + addEventListener: jest.fn(), + contentWindow: { postMessage: jest.fn() }, + remove: jest.fn(), + removeEventListener: jest.fn(), + src: '', + style: {} as Record, +}; + +class TestPaymentCardEmbed extends PaymentCardEmbed { + protected _createMessageChannel(): MessageChannel { + return (testMessageChannel as unknown) as MessageChannel; + } + + protected _createIframe(): HTMLIFrameElement { + return (testIframe as unknown) as HTMLIFrameElement; + } + + protected _createId(): string { + return 'test'; + } +} + +class TestElement { + append = jest.fn(); +} + +describe('Customer', () => { + describe('PaymentCardEmbed', () => { + beforeEach(() => jest.clearAllMocks()); + + it('creates an instance of PaymentCardEmbed', () => { + const embed = new TestPaymentCardEmbed({ url: 'https://embed.foxy.test/v1.html?demo=default' }); + expect(embed).toBeInstanceOf(PaymentCardEmbed); + }); + + it('mounts the embed on .mount()', async () => { + const embed = new TestPaymentCardEmbed({ + disabled: true, + lang: 'es', + url: 'https://embed.foxy.test/v1.html?demo=default', + }); + + const mockRoot = new TestElement(); + const unmountMethod = jest.spyOn(embed, 'unmount'); + const configureMethod = jest.spyOn(embed, 'configure'); + const mountPromise = embed.mount((mockRoot as unknown) as Element); + + // First, it must unmount the embed + expect(unmountMethod).toHaveBeenCalledTimes(1); + + // Then, it must create an iframe and append it to the root element + expect(testIframe.addEventListener).toHaveBeenCalledTimes(1); + expect(testIframe.addEventListener).toHaveBeenCalledWith('load', expect.any(Function)); + expect(testIframe.style.transition).toBe('height 0.15s ease'); + expect(testIframe.style.margin).toBe('-2px'); + expect(testIframe.style.height).toBe('100px'); + expect(testIframe.style.width).toBe('calc(100% + 4px)'); + expect(testIframe.src).toBe('https://embed.foxy.test/v1.html?demo=default'); + expect(mockRoot.append).toHaveBeenCalledTimes(1); + + // It must also create a message channel and start listening for messages + expect(testMessageChannel.port1.addEventListener).toHaveBeenCalledTimes(1); + expect(testMessageChannel.port1.addEventListener).toHaveBeenCalledWith('message', expect.any(Function)); + expect(testMessageChannel.port1.start).toHaveBeenCalledTimes(1); + + const loadListener = testIframe.addEventListener.mock.calls.find(([event]) => event === 'load')[1]; + await new Promise(resolve => setTimeout(resolve, 0)); + + // When iframe is loaded, it must send a message to the iframe to establish connection + loadListener({ currentTarget: testIframe }); + expect(testIframe.contentWindow.postMessage).toHaveBeenCalledTimes(1); + expect(testIframe.contentWindow.postMessage).toHaveBeenCalledWith('connect', '*', [testMessageChannel.port2]); + + // Finally, when iframe responds with "ready" event, it must resolve the mounting promise + const messageListener = testMessageChannel.port1.addEventListener.mock.calls.find(([e]) => e === 'message')[1]; + messageListener({ data: JSON.stringify({ type: 'ready' }) }); + await expect(mountPromise).resolves.toBeUndefined(); + + // And must configure the embed with the config passed to constructor + expect(configureMethod).toHaveBeenCalledTimes(1); + expect(configureMethod).toHaveBeenCalledWith({ disabled: true, lang: 'es' }); + }); + + it('unmounts the embed on .unmount()', async () => { + const embed = new TestPaymentCardEmbed({ url: 'https://embed.foxy.test/v1.html?demo=default' }); + const mountingPromise = embed.mount((new TestElement() as unknown) as Element); + + jest.clearAllMocks(); + embed.unmount(); + + // It must close the message channel and remove event listeners + expect(testMessageChannel.port2.close).toHaveBeenCalledTimes(1); + expect(testMessageChannel.port1.close).toHaveBeenCalledTimes(1); + expect(testMessageChannel.port1.removeEventListener).toHaveBeenCalledTimes(1); + expect(testMessageChannel.port1.removeEventListener).toHaveBeenCalledWith('message', expect.any(Function)); + + // It must remove the iframe and its event listeners + expect(testIframe.removeEventListener).toHaveBeenCalledTimes(1); + expect(testIframe.removeEventListener).toHaveBeenCalledWith('load', expect.any(Function)); + expect(testIframe.remove).toHaveBeenCalledTimes(1); + + // If there's a mounting promise, it must reject it + await expect(mountingPromise).rejects.toBeUndefined(); + }); + + it('does not fail if .unmount() is called before .mount()', () => { + const embed = new TestPaymentCardEmbed({ url: 'https://embed.foxy.test/v1.html?demo=default' }); + expect(() => embed.unmount()).not.toThrow(); + }); + + it('sends "clear" event to iframe on .clear()', async () => { + const embed = new TestPaymentCardEmbed({ url: 'https://embed.foxy.test/v1.html?demo=default' }); + const mountingPromise = embed.mount((new TestElement() as unknown) as Element); + + // Mount the mock embed + const loadListener = testIframe.addEventListener.mock.calls.find(([event]) => event === 'load')[1]; + const messageListener = testMessageChannel.port1.addEventListener.mock.calls.find(([e]) => e === 'message')[1]; + await new Promise(resolve => setTimeout(resolve, 0)); + loadListener({ currentTarget: testIframe }); + messageListener({ data: JSON.stringify({ type: 'ready' }) }); + await mountingPromise; + jest.clearAllMocks(); + + // Clear the fields + embed.clear(); + + // It must send a message to the iframe via MessageChannel with "clear" event + expect(testMessageChannel.port1.postMessage).toHaveBeenCalledTimes(1); + expect(testMessageChannel.port1.postMessage).toHaveBeenCalledWith(JSON.stringify({ type: 'clear' })); + }); + + it('does not fail if .clear() is called before .mount()', () => { + const embed = new TestPaymentCardEmbed({ url: 'https://embed.foxy.test/v1.html?demo=default' }); + expect(() => embed.clear()).not.toThrow(); + }); + + it('sends "config" event to iframe on .configure()', async () => { + const embed = new TestPaymentCardEmbed({ url: 'https://embed.foxy.test/v1.html?demo=default' }); + const mountingPromise = embed.mount((new TestElement() as unknown) as Element); + + // Mount the mock embed + const loadListener = testIframe.addEventListener.mock.calls.find(([event]) => event === 'load')[1]; + const messageListener = testMessageChannel.port1.addEventListener.mock.calls.find(([e]) => e === 'message')[1]; + await new Promise(resolve => setTimeout(resolve, 0)); + loadListener({ currentTarget: testIframe }); + messageListener({ data: JSON.stringify({ type: 'ready' }) }); + await mountingPromise; + jest.clearAllMocks(); + + // Clear the fields + embed.configure({ + translations: { default: { 'cc-number': { label: 'Test' } } }, + // eslint-disable-next-line sort-keys + disabled: true, + lang: 'es', + }); + + // It must send a message to the iframe via MessageChannel with "config" event + expect(testMessageChannel.port1.postMessage).toHaveBeenCalledTimes(1); + expect(testMessageChannel.port1.postMessage).toHaveBeenCalledWith( + JSON.stringify({ + type: 'config', + // eslint-disable-next-line sort-keys + translations: { default: { 'cc-number': { label: 'Test' } } }, + // eslint-disable-next-line sort-keys + disabled: true, + lang: 'es', + }) + ); + }); + + it('does not fail if .configure() is called before .mount()', () => { + const embed = new TestPaymentCardEmbed({ url: 'https://embed.foxy.test/v1.html?demo=default' }); + expect(() => embed.configure({ disabled: true })).not.toThrow(); + }); + + it('requests tokenization on .tokenize() (positive path)', async () => { + const embed = new TestPaymentCardEmbed({ url: 'https://embed.foxy.test/v1.html?demo=default' }); + const mountingPromise = embed.mount((new TestElement() as unknown) as Element); + + // Mount the mock embed + const loadListener = testIframe.addEventListener.mock.calls.find(([event]) => event === 'load')[1]; + const messageListener = testMessageChannel.port1.addEventListener.mock.calls.find(([e]) => e === 'message')[1]; + await new Promise(resolve => setTimeout(resolve, 0)); + loadListener({ currentTarget: testIframe }); + messageListener({ data: JSON.stringify({ type: 'ready' }) }); + await mountingPromise; + jest.clearAllMocks(); + + // Request tokenization + const tokenizePromise = embed.tokenize(); + + // It must send a message to the iframe via MessageChannel with "tokenize_request" event + expect(testMessageChannel.port1.postMessage).toHaveBeenCalledTimes(1); + expect(testMessageChannel.port1.postMessage).toHaveBeenCalledWith( + JSON.stringify({ id: 'test', type: 'tokenization_request' }) + ); + + // On tokenization response with a token, it must resolve the promise + // eslint-disable-next-line sort-keys + messageListener({ data: JSON.stringify({ id: 'test', type: 'tokenization_response', token: 'test-token' }) }); + await expect(tokenizePromise).resolves.toBe('test-token'); + }); + + it('requests tokenization on .tokenize() (negative path)', async () => { + const embed = new TestPaymentCardEmbed({ url: 'https://embed.foxy.test/v1.html?demo=default' }); + const mountingPromise = embed.mount((new TestElement() as unknown) as Element); + + // Mount the mock embed + const loadListener = testIframe.addEventListener.mock.calls.find(([event]) => event === 'load')[1]; + const messageListener = testMessageChannel.port1.addEventListener.mock.calls.find(([e]) => e === 'message')[1]; + await new Promise(resolve => setTimeout(resolve, 0)); + loadListener({ currentTarget: testIframe }); + messageListener({ data: JSON.stringify({ type: 'ready' }) }); + await mountingPromise; + jest.clearAllMocks(); + + // Request tokenization + const tokenizePromise = embed.tokenize(); + + // It must send a message to the iframe via MessageChannel with "tokenize_request" event + expect(testMessageChannel.port1.postMessage).toHaveBeenCalledTimes(1); + expect(testMessageChannel.port1.postMessage).toHaveBeenCalledWith( + JSON.stringify({ id: 'test', type: 'tokenization_request' }) + ); + + // On tokenization response without a token, it must reject the promise + messageListener({ data: JSON.stringify({ id: 'test', type: 'tokenization_response' }) }); + await expect(tokenizePromise).rejects.toBeUndefined(); + }); + + it('rejects tokenization promise if iframe is not mounted', async () => { + const embed = new TestPaymentCardEmbed({ url: 'https://embed.foxy.test/v1.html?demo=default' }); + await expect(embed.tokenize()).rejects.toBeUndefined(); + }); + + it('sets iframe height on "resize" event', async () => { + const embed = new TestPaymentCardEmbed({ url: 'https://embed.foxy.test/v1.html?demo=default' }); + const mountingPromise = embed.mount((new TestElement() as unknown) as Element); + + // Mount the mock embed + const loadListener = testIframe.addEventListener.mock.calls.find(([event]) => event === 'load')[1]; + const messageListener = testMessageChannel.port1.addEventListener.mock.calls.find(([e]) => e === 'message')[1]; + await new Promise(resolve => setTimeout(resolve, 0)); + loadListener({ currentTarget: testIframe }); + messageListener({ data: JSON.stringify({ type: 'ready' }) }); + await mountingPromise; + jest.clearAllMocks(); + + // Mock "resize" event from iframe + // eslint-disable-next-line sort-keys + messageListener({ data: JSON.stringify({ type: 'resize', height: '200px' }) }); + + // It must set the height of the iframe + expect(testIframe.style.height).toBe('200px'); + }); + + it('calls .onsubmit on "submit" event', async () => { + const embed = new TestPaymentCardEmbed({ url: 'https://embed.foxy.test/v1.html?demo=default' }); + const mountingPromise = embed.mount((new TestElement() as unknown) as Element); + const loadListener = testIframe.addEventListener.mock.calls.find(([event]) => event === 'load')[1]; + const messageListener = testMessageChannel.port1.addEventListener.mock.calls.find(([e]) => e === 'message')[1]; + const onSubmit = jest.fn(); + + await new Promise(resolve => setTimeout(resolve, 0)); + loadListener({ currentTarget: testIframe }); + messageListener({ data: JSON.stringify({ type: 'ready' }) }); + await mountingPromise; + jest.clearAllMocks(); + embed.onsubmit = onSubmit; + messageListener({ data: JSON.stringify({ type: 'submit' }) }); + + expect(onSubmit).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/tests/customer/index.test.ts b/tests/customer/index.test.ts index 7376001..11f5c97 100644 --- a/tests/customer/index.test.ts +++ b/tests/customer/index.test.ts @@ -26,4 +26,8 @@ describe('Customer', () => { it('exports isNextTransactionDate', () => { expect(Customer).toHaveProperty('isNextTransactionDate', isNextTransactionDate); }); + + it('exports PaymentCardEmbed', () => { + expect(Customer).toHaveProperty('PaymentCardEmbed'); + }); });