Skip to content

Commit c74cead

Browse files
authored
Merge pull request #48 from Foxy/feature/tokenization-embed
feat: add types and api for payment card embed
2 parents aaf2fee + 13fde73 commit c74cead

File tree

7 files changed

+564
-2
lines changed

7 files changed

+564
-2
lines changed

src/backend/Graph/default_payment_method.d.ts

+2
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ export interface DefaultPaymentMethod extends Graph {
1919
save_cc: boolean;
2020
/** The credit card or debit card type. This will be determined automatically once the payment card is saved. */
2121
cc_type: string | null;
22+
/** Token returned by our Tokenization Embed. Send this field with PATCH to update customer's payment method. */
23+
cc_token?: string;
2224
/** The payment card number. This property will not be displayed as part of this resource, but can be used to modify this payment method. */
2325
cc_number?: number;
2426
/** A masked version of this payment card showing only the last 4 digits. */

src/customer/Graph/default_payment_method.d.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ export interface DefaultPaymentMethod extends Graph {
1515
save_cc: boolean;
1616
/** The credit card or debit card type. This will be determined automatically once the payment card is saved. */
1717
cc_type: string | null;
18-
/** The payment card number. This property will not be displayed as part of this resource, but can be used to modify this payment method. */
19-
cc_number?: number;
18+
/** Token returned by our Tokenization Embed. Send this field with PATCH to update customer's payment method. */
19+
cc_token?: string;
2020
/** A masked version of this payment card showing only the last 4 digits. */
2121
cc_number_masked: string | null;
2222
/** The payment card expiration month in the MM format. */

src/customer/PaymentCardEmbed.ts

+179
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
import type { PaymentCardEmbedConfig } from './types';
2+
3+
/**
4+
* A convenience wrapper for the payment card embed iframe. You don't have to use
5+
* this class to embed the payment card iframe, but it provides a more convenient
6+
* way to interact with the iframe and listen to its events.
7+
*
8+
* @example
9+
* const embed = new PaymentCardEmbed({
10+
* url: 'https://embed.foxy.io/v1.html?template_set_id=123'
11+
* });
12+
*
13+
* await embed.mount(document.body);
14+
* console.log('Token:', await embed.tokenize());
15+
*/
16+
export class PaymentCardEmbed {
17+
/**
18+
* An event handler that is triggered when Enter is pressed in the card form.
19+
* This feature is not available for template sets configured with the `stripe_connect`
20+
* hosted payment gateway due to the limitations of Stripe.js.
21+
*/
22+
onsubmit: (() => void) | null = null;
23+
24+
private __tokenizationRequests: {
25+
resolve: (token: string) => void;
26+
reject: () => void;
27+
id: string;
28+
}[] = [];
29+
30+
private __iframeMessageHandler = (evt: MessageEvent) => {
31+
const data = JSON.parse(evt.data);
32+
33+
switch (data.type) {
34+
case 'tokenization_response': {
35+
const request = this.__tokenizationRequests.find(r => r.id === data.id);
36+
data.token ? request?.resolve(data.token) : request?.reject();
37+
this.__tokenizationRequests = this.__tokenizationRequests.filter(r => r.id !== data.id);
38+
break;
39+
}
40+
case 'submit': {
41+
this.onsubmit?.();
42+
break;
43+
}
44+
case 'resize': {
45+
if (this.__iframe) this.__iframe.style.height = data.height;
46+
break;
47+
}
48+
case 'ready': {
49+
this.configure(this.__config);
50+
this.__mountingTask?.resolve();
51+
break;
52+
}
53+
}
54+
};
55+
56+
private __iframeLoadHandler = (evt: Event) => {
57+
if (this.__channel) {
58+
const contentWindow = (evt.currentTarget as HTMLIFrameElement).contentWindow;
59+
if (!contentWindow) throw new Error('Content window is not available.');
60+
contentWindow.postMessage('connect', '*', [this.__channel.port2]);
61+
}
62+
};
63+
64+
private __mountingTask: { resolve: () => void; reject: () => void } | null = null;
65+
66+
private __channel: MessageChannel | null = null;
67+
68+
private __iframe: HTMLIFrameElement | null = null;
69+
70+
private __config: PaymentCardEmbedConfig;
71+
72+
private __url: string;
73+
74+
constructor({ url, ...config }: { url: string } & PaymentCardEmbedConfig) {
75+
this.__config = config;
76+
this.__url = url;
77+
}
78+
79+
/**
80+
* Updates the configuration of the payment card embed.
81+
* You can change style, translations, language and interactivity settings.
82+
* To change the URL of the payment card embed, you need to create a new instance.
83+
* You are not required to provide the full configuration object, only the properties you want to change.
84+
*
85+
* @param config - The new configuration.
86+
*/
87+
configure(config: PaymentCardEmbedConfig): void {
88+
this.__config = config;
89+
const message = { type: 'config', ...config };
90+
this.__channel?.port1.postMessage(JSON.stringify(message));
91+
}
92+
93+
/**
94+
* Requests the tokenization of the card data.
95+
*
96+
* @returns A promise that resolves with the tokenized card data.
97+
*/
98+
tokenize(): Promise<string> {
99+
return new Promise<string>((resolve, reject) => {
100+
if (this.__channel) {
101+
const id = this._createId();
102+
this.__tokenizationRequests.push({ id, reject, resolve });
103+
this.__channel.port1.postMessage(JSON.stringify({ id, type: 'tokenization_request' }));
104+
} else {
105+
reject();
106+
}
107+
});
108+
}
109+
110+
/**
111+
* Safely removes the embed iframe from the parent node,
112+
* closing the message channel and cleaning up event listeners.
113+
*/
114+
unmount(): void {
115+
this.__channel?.port1.removeEventListener('message', this.__iframeMessageHandler);
116+
this.__channel?.port1.close();
117+
this.__channel?.port2.close();
118+
this.__channel = null;
119+
120+
this.__iframe?.removeEventListener('load', this.__iframeLoadHandler);
121+
this.__iframe?.remove();
122+
this.__iframe = null;
123+
124+
this.__mountingTask?.reject();
125+
this.__mountingTask = null;
126+
}
127+
128+
/**
129+
* Mounts the payment card embed in the given root element. If the embed is already mounted,
130+
* it will be unmounted first.
131+
*
132+
* @param root - The root element to mount the embed in.
133+
* @returns A promise that resolves when the embed is mounted.
134+
*/
135+
mount(root: Element): Promise<void> {
136+
this.unmount();
137+
138+
this.__channel = this._createMessageChannel();
139+
this.__channel.port1.addEventListener('message', this.__iframeMessageHandler);
140+
this.__channel.port1.start();
141+
142+
this.__iframe = this._createIframe(root);
143+
this.__iframe.addEventListener('load', this.__iframeLoadHandler);
144+
this.__iframe.style.transition = 'height 0.15s ease';
145+
this.__iframe.style.margin = '-2px';
146+
this.__iframe.style.height = '100px';
147+
this.__iframe.style.width = 'calc(100% + 4px)';
148+
this.__iframe.src = this.__url;
149+
150+
root.append(this.__iframe);
151+
152+
return new Promise<void>((resolve, reject) => {
153+
this.__mountingTask = { reject, resolve };
154+
});
155+
}
156+
157+
/**
158+
* Clears the card data from the embed.
159+
* No-op if the embed is not mounted.
160+
*/
161+
clear(): void {
162+
this.__channel?.port1.postMessage(JSON.stringify({ type: 'clear' }));
163+
}
164+
165+
/* v8 ignore next */
166+
protected _createMessageChannel(): MessageChannel {
167+
return new MessageChannel();
168+
}
169+
170+
/* v8 ignore next */
171+
protected _createIframe(root: Element): HTMLIFrameElement {
172+
return root.ownerDocument.createElement('iframe');
173+
}
174+
175+
/* v8 ignore next */
176+
protected _createId(): string {
177+
return `${Date.now()}${Math.random().toFixed(6).slice(2)}`;
178+
}
179+
}

src/customer/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ export { getAllowedFrequencies } from './getAllowedFrequencies.js';
33
export { getNextTransactionDateConstraints } from './getNextTransactionDateConstraints.js';
44
export { getTimeFromFrequency } from '../backend/getTimeFromFrequency.js';
55
export { isNextTransactionDate } from './isNextTransactionDate.js';
6+
export { PaymentCardEmbed } from './PaymentCardEmbed.js';
67

78
import type * as Rels from './Rels';
89
export type { Graph } from './Graph';

src/customer/types.d.ts

+84
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,87 @@
1+
/** Tokenization embed configuration that can be updated any time after mount. */
2+
export type PaymentCardEmbedConfig = Partial<{
3+
/** Translations. Note that Stripe and Square provide their own translations that can't be customized. */
4+
translations: {
5+
stripe?: {
6+
label?: string;
7+
status?: {
8+
idle?: string;
9+
busy?: string;
10+
fail?: string;
11+
};
12+
};
13+
square?: {
14+
label?: string;
15+
status?: {
16+
idle?: string;
17+
busy?: string;
18+
fail?: string;
19+
};
20+
};
21+
default?: {
22+
'cc-number'?: {
23+
label?: string;
24+
placeholder?: string;
25+
v8n_required?: string;
26+
v8n_invalid?: string;
27+
v8n_unsupported?: string;
28+
};
29+
'cc-exp'?: {
30+
label?: string;
31+
placeholder?: string;
32+
v8n_required?: string;
33+
v8n_invalid?: string;
34+
v8n_expired?: string;
35+
};
36+
'cc-csc'?: {
37+
label?: string;
38+
placeholder?: string;
39+
v8n_required?: string;
40+
v8n_invalid?: string;
41+
};
42+
'status'?: {
43+
idle?: string;
44+
busy?: string;
45+
fail?: string;
46+
misconfigured?: string;
47+
};
48+
};
49+
};
50+
/** If true, all fields inside the embed will be disabled. */
51+
disabled: boolean;
52+
/** 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. */
53+
readonly: boolean;
54+
/** Appearance settings. */
55+
style: Partial<{
56+
'--lumo-space-m': string;
57+
'--lumo-space-s': string;
58+
'--lumo-contrast-5pct': string;
59+
'--lumo-contrast-10pct': string;
60+
'--lumo-contrast-50pct': string;
61+
'--lumo-size-m': string;
62+
'--lumo-size-xs': string;
63+
'--lumo-border-radius-m': string;
64+
'--lumo-border-radius-s': string;
65+
'--lumo-font-family': string;
66+
'--lumo-font-size-m': string;
67+
'--lumo-font-size-s': string;
68+
'--lumo-font-size-xs': string;
69+
'--lumo-primary-color': string;
70+
'--lumo-primary-text-color': string;
71+
'--lumo-primary-color-50pct': string;
72+
'--lumo-secondary-text-color': string;
73+
'--lumo-disabled-text-color': string;
74+
'--lumo-body-text-color': string;
75+
'--lumo-error-text-color': string;
76+
'--lumo-error-color-10pct': string;
77+
'--lumo-error-color-50pct': string;
78+
'--lumo-line-height-xs': string;
79+
'--lumo-base-color': string;
80+
}>;
81+
/** Locale to use with Stripe or Square. Has no effect on the default UI. */
82+
lang: string;
83+
}>;
84+
185
/** User credentials for authentication. */
286
export interface Credentials {
387
/** Email address associated with an account. */

0 commit comments

Comments
 (0)