Skip to content

Commit

Permalink
Merge pull request #48 from Foxy/feature/tokenization-embed
Browse files Browse the repository at this point in the history
feat: add types and api for payment card embed
  • Loading branch information
brettflorio authored Jun 14, 2024
2 parents aaf2fee + 13fde73 commit c74cead
Show file tree
Hide file tree
Showing 7 changed files with 564 additions and 2 deletions.
2 changes: 2 additions & 0 deletions src/backend/Graph/default_payment_method.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
4 changes: 2 additions & 2 deletions src/customer/Graph/default_payment_method.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
179 changes: 179 additions & 0 deletions src/customer/PaymentCardEmbed.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
return new Promise<string>((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<void> {
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<void>((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)}`;
}
}
1 change: 1 addition & 0 deletions src/customer/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
84 changes: 84 additions & 0 deletions src/customer/types.d.ts
Original file line number Diff line number Diff line change
@@ -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. */
Expand Down
Loading

0 comments on commit c74cead

Please sign in to comment.