@amos.com/amos-js 0.9.17 → 0.9.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,421 @@
1
+ import { requestPlaidLinkToken } from "./messaging";
2
+ import type { PaymentMethodFormListenerOptions } from "./payment-method-form";
3
+ import {
4
+ linkedBankLabelFromMetadata,
5
+ majorAmountToMinorUnits,
6
+ openPlaidLink,
7
+ type PlaidCredentials,
8
+ plaidAccountIdFromMetadata,
9
+ requiresAchVerification,
10
+ } from "./plaid";
11
+ import { clearBankPlaidSession, setBankPlaidSession } from "./plaid-session";
12
+ import type { Appearance, Message, ThemeVariable } from "./types";
13
+
14
+ const STYLE_ID = "amos-js-plaid-bank-ui-styles";
15
+
16
+ const PLAID_BANK_UI_STYLES = `
17
+ .amos-js-plaid-panel {
18
+ box-sizing: border-box;
19
+ color: var(--foreground, oklch(0.145 0 0));
20
+ display: none;
21
+ flex-direction: column;
22
+ font-family: inherit;
23
+ gap: var(--control-gap, 0.5rem);
24
+ width: 100%;
25
+ }
26
+ .amos-js-plaid-panel[data-mode="connect"],
27
+ .amos-js-plaid-panel[data-mode="linked"] {
28
+ display: flex;
29
+ }
30
+ .amos-js-plaid-connect {
31
+ align-items: center;
32
+ background: var(--background, oklch(1 0 0));
33
+ border: var(--input-border-width, 1px) solid var(--border, oklch(0.922 0 0));
34
+ border-radius: calc(var(--radius, 0.625rem) * 0.8);
35
+ box-shadow: var(--input-shadow, 0 1px 2px 0 rgb(0 0 0 / 0.05));
36
+ box-sizing: border-box;
37
+ color: var(--foreground, oklch(0.145 0 0));
38
+ cursor: pointer;
39
+ display: inline-flex;
40
+ flex-shrink: 0;
41
+ font: inherit;
42
+ font-size: var(--input-font-size, 0.875rem);
43
+ font-weight: 500;
44
+ height: var(--input-height, 2.25rem);
45
+ justify-content: center;
46
+ line-height: 1.25;
47
+ outline: none;
48
+ padding: 0 var(--input-padding, 0.75rem);
49
+ transition: color 150ms, background-color 150ms, border-color 150ms, box-shadow 150ms;
50
+ width: 100%;
51
+ }
52
+ .amos-js-plaid-panel[data-mode="linked"] .amos-js-plaid-connect {
53
+ display: none;
54
+ }
55
+ .amos-js-plaid-connect:hover:not(:disabled) {
56
+ background: var(--accent, oklch(0.97 0 0));
57
+ color: var(--accent-foreground, oklch(0.205 0 0));
58
+ }
59
+ .amos-js-plaid-connect:focus-visible {
60
+ border-color: var(--ring, oklch(0.708 0 0));
61
+ box-shadow:
62
+ var(--input-shadow, 0 1px 2px 0 rgb(0 0 0 / 0.05)),
63
+ 0 0 0 var(--ring-width, 3px)
64
+ color-mix(in oklab, var(--ring, oklch(0.708 0 0)) 50%, transparent);
65
+ }
66
+ .amos-js-plaid-connect:disabled {
67
+ cursor: default;
68
+ opacity: 0.5;
69
+ pointer-events: none;
70
+ }
71
+ .amos-js-plaid-linked {
72
+ display: none;
73
+ flex-direction: column;
74
+ gap: 0.25rem;
75
+ }
76
+ .amos-js-plaid-panel[data-mode="linked"] .amos-js-plaid-linked {
77
+ display: flex;
78
+ }
79
+ .amos-js-plaid-linked-name {
80
+ font-size: var(--input-font-size, 0.875rem);
81
+ font-weight: 500;
82
+ }
83
+ .amos-js-plaid-linked-meta {
84
+ color: var(--muted-foreground, oklch(0.556 0 0));
85
+ font-size: 0.75rem;
86
+ }
87
+ .amos-js-plaid-disconnect {
88
+ align-self: flex-start;
89
+ background: none;
90
+ border: none;
91
+ border-radius: calc(var(--radius, 0.625rem) * 0.8);
92
+ color: var(--muted-foreground, oklch(0.556 0 0));
93
+ cursor: pointer;
94
+ font: inherit;
95
+ font-size: var(--input-font-size, 0.875rem);
96
+ margin-top: 0.25rem;
97
+ outline: none;
98
+ padding: 0;
99
+ }
100
+ .amos-js-plaid-disconnect:hover {
101
+ color: var(--accent-foreground, oklch(0.205 0 0));
102
+ }
103
+ .amos-js-plaid-disconnect:focus-visible {
104
+ box-shadow:
105
+ 0 0 0 var(--ring-width, 3px)
106
+ color-mix(in oklab, var(--ring, oklch(0.708 0 0)) 50%, transparent);
107
+ }
108
+ .amos-js-plaid-error {
109
+ color: var(--destructive, oklch(0.577 0.245 27.325));
110
+ display: none;
111
+ font-size: var(--error-font-size, 0.875rem);
112
+ margin: 0;
113
+ }
114
+ .amos-js-plaid-error:not(:empty) {
115
+ display: block;
116
+ }
117
+ @media (prefers-reduced-motion: reduce) {
118
+ .amos-js-plaid-connect {
119
+ transition: none;
120
+ }
121
+ }
122
+ `;
123
+
124
+ function ensurePlaidBankUiStyles(): void {
125
+ if (document.getElementById(STYLE_ID)) {
126
+ return;
127
+ }
128
+ const style = document.createElement("style");
129
+ style.id = STYLE_ID;
130
+ style.textContent = PLAID_BANK_UI_STYLES;
131
+ document.head.append(style);
132
+ }
133
+
134
+ function applyTheme(
135
+ panel: HTMLElement,
136
+ appearance: Appearance | undefined,
137
+ appliedKeys: Array<string>,
138
+ ): void {
139
+ for (const property of appliedKeys) {
140
+ panel.style.removeProperty(property);
141
+ }
142
+ appliedKeys.length = 0;
143
+ const themeVariables = appearance?.themeVariables;
144
+ if (!themeVariables) {
145
+ return;
146
+ }
147
+ for (const [property, value] of Object.entries(themeVariables) as Array<
148
+ [ThemeVariable, string | undefined]
149
+ >) {
150
+ if (typeof value === "string" && value.trim() !== "") {
151
+ panel.style.setProperty(property, value.trim());
152
+ appliedKeys.push(property);
153
+ }
154
+ }
155
+ }
156
+
157
+ export type PlaidBankUiOptions = {
158
+ amount?: string;
159
+ appearance?: PaymentMethodFormListenerOptions["appearance"];
160
+ onValidityChange?: PaymentMethodFormListenerOptions["onValidityChange"];
161
+ };
162
+
163
+ /**
164
+ * Parent-page Connect bank UI. Outline/ghost controls match `@amos/ui`
165
+ * (including `:focus-visible` `--ring`). Unset theme variables inherit
166
+ * from the host document, then fall back to {@link ThemeVariable}
167
+ * defaults; `appearance.themeVariables` overrides with the same replace
168
+ * model as the iframe.
169
+ */
170
+ export function attachPlaidBankUi({
171
+ host,
172
+ iframe,
173
+ options,
174
+ }: {
175
+ host: HTMLElement;
176
+ iframe: HTMLIFrameElement;
177
+ options: PlaidBankUiOptions;
178
+ }): {
179
+ update: (patch: Partial<PlaidBankUiOptions>) => void;
180
+ destroy: () => void;
181
+ } {
182
+ ensurePlaidBankUiStyles();
183
+
184
+ const current: PlaidBankUiOptions = { ...options };
185
+ const appliedThemeKeys: Array<string> = [];
186
+ let thresholdKnown = false;
187
+ let achThreshold: number | undefined;
188
+ let requireVerification = false;
189
+ let cachedLinkToken: string | undefined;
190
+ let opening = false;
191
+ let lastEmittedValid: boolean | undefined;
192
+ let destroyLink: (() => void) | undefined;
193
+ const abort = new AbortController();
194
+ let linked:
195
+ | { credentials: PlaidCredentials; bankName: string; last4: string }
196
+ | undefined;
197
+
198
+ const panel = document.createElement("div");
199
+ panel.className = "amos-js-plaid-panel";
200
+ panel.setAttribute("data-amos-plaid-panel", "true");
201
+ panel.dataset["mode"] = "hidden";
202
+ applyTheme(panel, current.appearance, appliedThemeKeys);
203
+
204
+ const connectButton = document.createElement("button");
205
+ connectButton.type = "button";
206
+ connectButton.className = "amos-js-plaid-connect";
207
+ connectButton.textContent = "Connect bank account";
208
+ connectButton.setAttribute("data-testid", "amos-plaid-connect");
209
+
210
+ const linkedEl = document.createElement("div");
211
+ linkedEl.className = "amos-js-plaid-linked";
212
+
213
+ const linkedName = document.createElement("span");
214
+ linkedName.className = "amos-js-plaid-linked-name";
215
+
216
+ const linkedMeta = document.createElement("span");
217
+ linkedMeta.className = "amos-js-plaid-linked-meta";
218
+
219
+ const disconnect = document.createElement("button");
220
+ disconnect.type = "button";
221
+ disconnect.className = "amos-js-plaid-disconnect";
222
+ disconnect.textContent = "Disconnect";
223
+ disconnect.setAttribute("aria-label", "Disconnect bank account");
224
+
225
+ const errorEl = document.createElement("p");
226
+ errorEl.className = "amos-js-plaid-error";
227
+ errorEl.setAttribute("role", "alert");
228
+
229
+ linkedEl.append(linkedName, linkedMeta, disconnect);
230
+ panel.append(connectButton, linkedEl, errorEl);
231
+ host.append(panel);
232
+ const formWrapper = iframe.parentElement;
233
+
234
+ function setError(message: string | undefined): void {
235
+ errorEl.textContent = message ?? "";
236
+ }
237
+
238
+ function requiresConnect(): boolean {
239
+ if (!thresholdKnown) {
240
+ return false;
241
+ }
242
+ if (requireVerification) {
243
+ return true;
244
+ }
245
+ return requiresAchVerification({
246
+ amount: majorAmountToMinorUnits(current.amount),
247
+ achThreshold,
248
+ });
249
+ }
250
+
251
+ function publishPlaidValidity(): void {
252
+ const isValid = Boolean(linked);
253
+ if (lastEmittedValid === isValid) {
254
+ return;
255
+ }
256
+ lastEmittedValid = isValid;
257
+ current.onValidityChange?.({ isValid });
258
+ }
259
+
260
+ function syncSession(): void {
261
+ const requiresVerification = requiresConnect();
262
+ setBankPlaidSession(iframe, {
263
+ requiresVerification,
264
+ plaid: requiresVerification ? linked?.credentials : undefined,
265
+ clearLinked: unlink,
266
+ });
267
+
268
+ if (!requiresVerification) {
269
+ panel.dataset["mode"] = "hidden";
270
+ if (formWrapper) {
271
+ formWrapper.style.display = "";
272
+ }
273
+ lastEmittedValid = undefined;
274
+ return;
275
+ }
276
+
277
+ panel.dataset["mode"] = linked ? "linked" : "connect";
278
+ if (formWrapper) {
279
+ formWrapper.style.display = "none";
280
+ }
281
+ if (linked) {
282
+ linkedName.textContent = linked.bankName;
283
+ linkedMeta.textContent = linked.last4
284
+ ? `****${linked.last4}`
285
+ : "Connected";
286
+ }
287
+ publishPlaidValidity();
288
+ }
289
+
290
+ function unlink(): void {
291
+ linked = undefined;
292
+ cachedLinkToken = undefined;
293
+ destroyLink?.();
294
+ destroyLink = undefined;
295
+ setError(undefined);
296
+ syncSession();
297
+ }
298
+
299
+ function handleIframeMessage(event: MessageEvent<Message>): void {
300
+ if (event.source !== iframe.contentWindow) {
301
+ return;
302
+ }
303
+ if (event.data.type !== "ACH_THRESHOLD") {
304
+ return;
305
+ }
306
+ thresholdKnown = true;
307
+ achThreshold = event.data.achThreshold ?? undefined;
308
+ requireVerification = event.data.requireVerification === true;
309
+ if (linked && !requiresConnect()) {
310
+ unlink();
311
+ return;
312
+ }
313
+ syncSession();
314
+ }
315
+
316
+ window.addEventListener("message", handleIframeMessage);
317
+ syncSession();
318
+
319
+ disconnect.addEventListener("click", () => {
320
+ unlink();
321
+ });
322
+
323
+ connectButton.addEventListener("click", () => {
324
+ void (async () => {
325
+ if (abort.signal.aborted || opening) {
326
+ return;
327
+ }
328
+ opening = true;
329
+ connectButton.disabled = true;
330
+ setError(undefined);
331
+ try {
332
+ if (!cachedLinkToken) {
333
+ cachedLinkToken = await requestPlaidLinkToken({ iframe });
334
+ }
335
+ if (abort.signal.aborted) {
336
+ return;
337
+ }
338
+ const token = cachedLinkToken;
339
+ destroyLink?.();
340
+ destroyLink = await openPlaidLink({
341
+ token,
342
+ signal: abort.signal,
343
+ onSuccess: (publicToken, metadata) => {
344
+ if (abort.signal.aborted) {
345
+ return;
346
+ }
347
+ const accountId = plaidAccountIdFromMetadata(metadata);
348
+ if (!accountId) {
349
+ setError("Select a bank account to continue.");
350
+ return;
351
+ }
352
+ const label = linkedBankLabelFromMetadata(metadata);
353
+ linked = {
354
+ credentials: {
355
+ public_token: publicToken,
356
+ account_id: accountId,
357
+ },
358
+ bankName: label.bankName,
359
+ last4: label.last4,
360
+ };
361
+ cachedLinkToken = undefined;
362
+ syncSession();
363
+ },
364
+ onExit: (error) => {
365
+ if (abort.signal.aborted) {
366
+ return;
367
+ }
368
+ if (error?.error_code === "INVALID_LINK_TOKEN") {
369
+ cachedLinkToken = undefined;
370
+ }
371
+ },
372
+ });
373
+ if (abort.signal.aborted) {
374
+ destroyLink();
375
+ destroyLink = undefined;
376
+ }
377
+ } catch (error) {
378
+ cachedLinkToken = undefined;
379
+ if (abort.signal.aborted) {
380
+ return;
381
+ }
382
+ setError(
383
+ error instanceof Error ? error.message : "Could not connect bank.",
384
+ );
385
+ } finally {
386
+ opening = false;
387
+ if (!abort.signal.aborted) {
388
+ connectButton.disabled = false;
389
+ }
390
+ }
391
+ })();
392
+ });
393
+
394
+ return {
395
+ update(patch) {
396
+ if ("amount" in patch) {
397
+ current.amount = patch.amount;
398
+ }
399
+ if ("onValidityChange" in patch) {
400
+ current.onValidityChange = patch.onValidityChange;
401
+ }
402
+ if ("appearance" in patch) {
403
+ current.appearance = patch.appearance;
404
+ applyTheme(panel, current.appearance, appliedThemeKeys);
405
+ }
406
+ if (linked && !requiresConnect()) {
407
+ unlink();
408
+ return;
409
+ }
410
+ syncSession();
411
+ },
412
+ destroy() {
413
+ abort.abort();
414
+ window.removeEventListener("message", handleIframeMessage);
415
+ destroyLink?.();
416
+ destroyLink = undefined;
417
+ clearBankPlaidSession(iframe);
418
+ panel.remove();
419
+ },
420
+ };
421
+ }
@@ -0,0 +1,41 @@
1
+ import type { PlaidCredentials } from "./plaid";
2
+
3
+ type BankPlaidSession = {
4
+ requiresVerification: boolean;
5
+ plaid?: PlaidCredentials;
6
+ /** Drop linked Plaid credentials and restore the Connect button. */
7
+ clearLinked?: () => void;
8
+ };
9
+
10
+ const sessions = new WeakMap<HTMLIFrameElement, BankPlaidSession>();
11
+
12
+ export function setBankPlaidSession(
13
+ iframe: HTMLIFrameElement,
14
+ session: BankPlaidSession,
15
+ ): void {
16
+ sessions.set(iframe, session);
17
+ }
18
+
19
+ export function getBankPlaidSession(
20
+ iframe: HTMLIFrameElement | null | undefined,
21
+ ): BankPlaidSession | undefined {
22
+ if (!iframe) {
23
+ return undefined;
24
+ }
25
+ return sessions.get(iframe);
26
+ }
27
+
28
+ export function clearBankPlaidSession(
29
+ iframe: HTMLIFrameElement | null | undefined,
30
+ ): void {
31
+ if (!iframe) {
32
+ return;
33
+ }
34
+ sessions.delete(iframe);
35
+ }
36
+
37
+ export function getBankPlaidCredentials(
38
+ iframe: HTMLIFrameElement | null | undefined,
39
+ ): PlaidCredentials | undefined {
40
+ return getBankPlaidSession(iframe)?.plaid;
41
+ }
package/src/plaid.ts ADDED
@@ -0,0 +1,215 @@
1
+ import type { components } from "@amos.com/node";
2
+
3
+ const PLAID_SCRIPT_SRC =
4
+ "https://cdn.plaid.com/link/v2/stable/link-initialize.js";
5
+
6
+ export type PlaidCredentials = components["schemas"]["PlaidCredentialsInput"];
7
+
8
+ export type PlaidLinkAccount = {
9
+ id?: string;
10
+ mask?: string;
11
+ name?: string;
12
+ subtype?: string | null;
13
+ type?: string;
14
+ };
15
+
16
+ export type PlaidLinkOnSuccessMetadata = {
17
+ institution?: { name?: string } | null;
18
+ accounts?: Array<PlaidLinkAccount>;
19
+ account?: PlaidLinkAccount;
20
+ account_id?: string;
21
+ };
22
+
23
+ type PlaidLinkHandler = {
24
+ open: () => void;
25
+ exit: (options?: { force?: boolean }) => void;
26
+ destroy: () => void;
27
+ };
28
+
29
+ type PlaidCreateConfig = {
30
+ token: string;
31
+ onSuccess: (
32
+ publicToken: string,
33
+ metadata: PlaidLinkOnSuccessMetadata,
34
+ ) => void;
35
+ onExit?: (
36
+ error: { error_code?: string; error_message?: string } | null,
37
+ metadata: unknown,
38
+ ) => void;
39
+ };
40
+
41
+ declare global {
42
+ interface Window {
43
+ Plaid?: {
44
+ create: (config: PlaidCreateConfig) => PlaidLinkHandler;
45
+ };
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Whether the bank form should show Plaid Link instead of routing/account
51
+ * fields.
52
+ *
53
+ * - No `achThreshold`: always manual ACH (backward compatible).
54
+ * - `amount` omitted: Plaid (setup / unknown future charge).
55
+ * - Otherwise: Plaid when `amount >= achThreshold`.
56
+ *
57
+ * `amount` and `achThreshold` are integer minor units (cents).
58
+ */
59
+ export function requiresAchVerification({
60
+ amount,
61
+ achThreshold,
62
+ }: {
63
+ amount?: number;
64
+ achThreshold?: number;
65
+ }): boolean {
66
+ if (achThreshold == null) {
67
+ return false;
68
+ }
69
+ if (amount == null) {
70
+ return true;
71
+ }
72
+ return amount >= achThreshold;
73
+ }
74
+
75
+ /**
76
+ * Convert a major-currency decimal string (e.g. `"50.00"`) to integer
77
+ * cents. Empty / invalid values are omitted.
78
+ */
79
+ export function majorAmountToMinorUnits(amount?: string): number | undefined {
80
+ if (amount == null) {
81
+ return undefined;
82
+ }
83
+ const trimmed = amount.trim();
84
+ if (trimmed === "") {
85
+ return undefined;
86
+ }
87
+ const major = Number(trimmed.replace(/[^\d.]/g, ""));
88
+ if (!Number.isFinite(major)) {
89
+ return undefined;
90
+ }
91
+ return Math.round(major * 100);
92
+ }
93
+
94
+ export function plaidAccountIdFromMetadata(
95
+ metadata: PlaidLinkOnSuccessMetadata,
96
+ ): string | undefined {
97
+ return (
98
+ metadata.account_id ?? metadata.account?.id ?? metadata.accounts?.[0]?.id
99
+ );
100
+ }
101
+
102
+ export function linkedBankLabelFromMetadata(
103
+ metadata: PlaidLinkOnSuccessMetadata,
104
+ ): { bankName: string; last4: string } {
105
+ const account = metadata.account ?? metadata.accounts?.[0];
106
+ const bankName = metadata.institution?.name ?? "Bank account";
107
+ const last4 = account?.mask ?? "";
108
+ return { bankName, last4 };
109
+ }
110
+
111
+ let plaidScriptPromise: Promise<void> | undefined;
112
+
113
+ function removePlaidScriptTags(): void {
114
+ for (const node of document.querySelectorAll(
115
+ `script[src="${PLAID_SCRIPT_SRC}"]`,
116
+ )) {
117
+ node.remove();
118
+ }
119
+ }
120
+
121
+ export function loadPlaidScript(): Promise<void> {
122
+ if (typeof window === "undefined") {
123
+ return Promise.reject(new Error("Plaid Link requires a browser"));
124
+ }
125
+ if (window.Plaid) {
126
+ return Promise.resolve();
127
+ }
128
+ if (plaidScriptPromise) {
129
+ return plaidScriptPromise;
130
+ }
131
+
132
+ // A previous failed attempt leaves a <script> whose load/error already
133
+ // fired. Waiting on that tag hangs; drop it and inject a new one.
134
+ removePlaidScriptTags();
135
+
136
+ plaidScriptPromise = new Promise((resolve, reject) => {
137
+ const script = document.createElement("script");
138
+ script.src = PLAID_SCRIPT_SRC;
139
+ script.async = true;
140
+
141
+ const fail = (message: string) => {
142
+ script.remove();
143
+ plaidScriptPromise = undefined;
144
+ reject(new Error(message));
145
+ };
146
+
147
+ script.addEventListener(
148
+ "load",
149
+ () => {
150
+ if (window.Plaid) {
151
+ resolve();
152
+ return;
153
+ }
154
+ fail("Plaid Link failed to initialize");
155
+ },
156
+ { once: true },
157
+ );
158
+ script.addEventListener("error", () => fail("Failed to load Plaid Link"), {
159
+ once: true,
160
+ });
161
+ document.head.append(script);
162
+ });
163
+
164
+ return plaidScriptPromise;
165
+ }
166
+
167
+ export type OpenPlaidLinkInput = {
168
+ token: string;
169
+ onSuccess: (
170
+ publicToken: string,
171
+ metadata: PlaidLinkOnSuccessMetadata,
172
+ ) => void;
173
+ onExit?: (error: { error_code?: string } | null) => void;
174
+ /**
175
+ * When aborted (e.g. the bank form was unmounted), skip opening Link
176
+ * and destroy the handler if it was already created.
177
+ */
178
+ signal?: AbortSignal;
179
+ };
180
+
181
+ /**
182
+ * Load Plaid Link (if needed) and open it with the given `link_token`.
183
+ * Returns a destroy function for the Link handler.
184
+ */
185
+ export async function openPlaidLink({
186
+ token,
187
+ onSuccess,
188
+ onExit,
189
+ signal,
190
+ }: OpenPlaidLinkInput): Promise<() => void> {
191
+ await loadPlaidScript();
192
+ if (signal?.aborted) {
193
+ return () => {};
194
+ }
195
+ if (!window.Plaid) {
196
+ throw new Error("Plaid Link failed to initialize");
197
+ }
198
+
199
+ const handler = window.Plaid.create({
200
+ token,
201
+ onSuccess,
202
+ onExit: (error) => {
203
+ onExit?.(error);
204
+ },
205
+ });
206
+ if (signal?.aborted) {
207
+ handler.destroy();
208
+ return () => {};
209
+ }
210
+ handler.open();
211
+
212
+ return () => {
213
+ handler.destroy();
214
+ };
215
+ }