@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.
@@ -68,6 +68,13 @@ export declare function updateMerchantName({ iframe, merchantName, }: {
68
68
  export declare function validateForm({ iframe }: {
69
69
  iframe: Iframe;
70
70
  }): Promise<boolean>;
71
+ /**
72
+ * Ask the bank iframe to mint a Plaid Link token (`POST /plaid_link_tokens`
73
+ * on embed). Resolves with `link_token`, or rejects on error / timeout.
74
+ */
75
+ export declare function requestPlaidLinkToken({ iframe, }: {
76
+ iframe: Iframe;
77
+ }): Promise<string>;
71
78
  /**
72
79
  * Clear all field values and API errors in the embedded credit-card or
73
80
  * bank-account iframe form.
package/dist/mount.d.ts CHANGED
@@ -40,6 +40,14 @@ export type AmosPaymentMethodFormMountController = PaymentMethodFormController &
40
40
  */
41
41
  iframe: HTMLIFrameElement;
42
42
  };
43
+ /**
44
+ * Controller returned by {@link mountAmosBankAccountPaymentMethodForm}.
45
+ * `update()` also accepts `amount` (major-currency decimal string) when
46
+ * the charge changes.
47
+ */
48
+ export type AmosBankAccountPaymentMethodFormMountController = Omit<AmosPaymentMethodFormMountController, "update"> & {
49
+ update: (patch: Partial<AmosBankAccountPaymentMethodFormOptions>) => void;
50
+ };
43
51
  /**
44
52
  * Mount the secure credit-card payment method form into a container
45
53
  * element. Returns a controller exposing the underlying iframe, an
@@ -70,20 +78,32 @@ export type AmosBankAccountPaymentMethodFormOptions = PaymentMethodFormListenerO
70
78
  * @default "country"
71
79
  */
72
80
  billingAddressRequirement?: BillingAddressRequirement;
81
+ /**
82
+ * Charge amount as a major-currency decimal string (e.g. `"50.00"`
83
+ * for $50.00), the same format as Google Pay / Apple Pay. Compared
84
+ * to the merchant ACH threshold fetched by the iframe. Omit for
85
+ * setup intents or when the charge is unknown — if a threshold is
86
+ * set, Plaid is required.
87
+ */
88
+ amount?: string;
73
89
  };
74
90
  /**
75
91
  * Mount the secure bank-account payment method form into a container
76
92
  * element. Returns a controller exposing the underlying iframe, an
77
93
  * `update()` method, and a `destroy()` method.
78
94
  *
79
- * A field-shaped skeleton is shown immediately and replaced by the
80
- * iframe once appearance is applied.
95
+ * When the iframe reports an ACH threshold and `amount` meets it (or
96
+ * `amount` is omitted), a Connect bank button is rendered in the parent
97
+ * document and Plaid Link is opened on click. The button uses the same
98
+ * `appearance.themeVariables` as the iframe (and inherits host-page
99
+ * tokens when those variables are unset). Otherwise a field-shaped
100
+ * skeleton is shown and replaced by the iframe once appearance is applied.
81
101
  *
82
102
  * Use the returned `controller.iframe` when calling
83
103
  * {@link validateForm}, {@link confirmPaymentIntent}, or
84
104
  * {@link confirmSetupIntent}.
85
105
  */
86
- export declare function mountAmosBankAccountPaymentMethodForm(container: Container, options: AmosBankAccountPaymentMethodFormOptions): AmosPaymentMethodFormMountController;
106
+ export declare function mountAmosBankAccountPaymentMethodForm(container: Container, options: AmosBankAccountPaymentMethodFormOptions): AmosBankAccountPaymentMethodFormMountController;
87
107
  /**
88
108
  * Options accepted by {@link mountAmosGooglePayButton}.
89
109
  */
@@ -0,0 +1,21 @@
1
+ import { PaymentMethodFormListenerOptions } from './payment-method-form';
2
+ export type PlaidBankUiOptions = {
3
+ amount?: string;
4
+ appearance?: PaymentMethodFormListenerOptions["appearance"];
5
+ onValidityChange?: PaymentMethodFormListenerOptions["onValidityChange"];
6
+ };
7
+ /**
8
+ * Parent-page Connect bank UI. Outline/ghost controls match `@amos/ui`
9
+ * (including `:focus-visible` `--ring`). Unset theme variables inherit
10
+ * from the host document, then fall back to {@link ThemeVariable}
11
+ * defaults; `appearance.themeVariables` overrides with the same replace
12
+ * model as the iframe.
13
+ */
14
+ export declare function attachPlaidBankUi({ host, iframe, options, }: {
15
+ host: HTMLElement;
16
+ iframe: HTMLIFrameElement;
17
+ options: PlaidBankUiOptions;
18
+ }): {
19
+ update: (patch: Partial<PlaidBankUiOptions>) => void;
20
+ destroy: () => void;
21
+ };
@@ -0,0 +1,12 @@
1
+ import { PlaidCredentials } from './plaid';
2
+ type BankPlaidSession = {
3
+ requiresVerification: boolean;
4
+ plaid?: PlaidCredentials;
5
+ /** Drop linked Plaid credentials and restore the Connect button. */
6
+ clearLinked?: () => void;
7
+ };
8
+ export declare function setBankPlaidSession(iframe: HTMLIFrameElement, session: BankPlaidSession): void;
9
+ export declare function getBankPlaidSession(iframe: HTMLIFrameElement | null | undefined): BankPlaidSession | undefined;
10
+ export declare function clearBankPlaidSession(iframe: HTMLIFrameElement | null | undefined): void;
11
+ export declare function getBankPlaidCredentials(iframe: HTMLIFrameElement | null | undefined): PlaidCredentials | undefined;
12
+ export {};
@@ -0,0 +1,82 @@
1
+ import { components } from '@amos.com/node';
2
+ export type PlaidCredentials = components["schemas"]["PlaidCredentialsInput"];
3
+ export type PlaidLinkAccount = {
4
+ id?: string;
5
+ mask?: string;
6
+ name?: string;
7
+ subtype?: string | null;
8
+ type?: string;
9
+ };
10
+ export type PlaidLinkOnSuccessMetadata = {
11
+ institution?: {
12
+ name?: string;
13
+ } | null;
14
+ accounts?: Array<PlaidLinkAccount>;
15
+ account?: PlaidLinkAccount;
16
+ account_id?: string;
17
+ };
18
+ type PlaidLinkHandler = {
19
+ open: () => void;
20
+ exit: (options?: {
21
+ force?: boolean;
22
+ }) => void;
23
+ destroy: () => void;
24
+ };
25
+ type PlaidCreateConfig = {
26
+ token: string;
27
+ onSuccess: (publicToken: string, metadata: PlaidLinkOnSuccessMetadata) => void;
28
+ onExit?: (error: {
29
+ error_code?: string;
30
+ error_message?: string;
31
+ } | null, metadata: unknown) => void;
32
+ };
33
+ declare global {
34
+ interface Window {
35
+ Plaid?: {
36
+ create: (config: PlaidCreateConfig) => PlaidLinkHandler;
37
+ };
38
+ }
39
+ }
40
+ /**
41
+ * Whether the bank form should show Plaid Link instead of routing/account
42
+ * fields.
43
+ *
44
+ * - No `achThreshold`: always manual ACH (backward compatible).
45
+ * - `amount` omitted: Plaid (setup / unknown future charge).
46
+ * - Otherwise: Plaid when `amount >= achThreshold`.
47
+ *
48
+ * `amount` and `achThreshold` are integer minor units (cents).
49
+ */
50
+ export declare function requiresAchVerification({ amount, achThreshold, }: {
51
+ amount?: number;
52
+ achThreshold?: number;
53
+ }): boolean;
54
+ /**
55
+ * Convert a major-currency decimal string (e.g. `"50.00"`) to integer
56
+ * cents. Empty / invalid values are omitted.
57
+ */
58
+ export declare function majorAmountToMinorUnits(amount?: string): number | undefined;
59
+ export declare function plaidAccountIdFromMetadata(metadata: PlaidLinkOnSuccessMetadata): string | undefined;
60
+ export declare function linkedBankLabelFromMetadata(metadata: PlaidLinkOnSuccessMetadata): {
61
+ bankName: string;
62
+ last4: string;
63
+ };
64
+ export declare function loadPlaidScript(): Promise<void>;
65
+ export type OpenPlaidLinkInput = {
66
+ token: string;
67
+ onSuccess: (publicToken: string, metadata: PlaidLinkOnSuccessMetadata) => void;
68
+ onExit?: (error: {
69
+ error_code?: string;
70
+ } | null) => void;
71
+ /**
72
+ * When aborted (e.g. the bank form was unmounted), skip opening Link
73
+ * and destroy the handler if it was already created.
74
+ */
75
+ signal?: AbortSignal;
76
+ };
77
+ /**
78
+ * Load Plaid Link (if needed) and open it with the given `link_token`.
79
+ * Returns a destroy function for the Link handler.
80
+ */
81
+ export declare function openPlaidLink({ token, onSuccess, onExit, signal, }: OpenPlaidLinkInput): Promise<() => void>;
82
+ export {};
package/dist/types.d.ts CHANGED
@@ -185,10 +185,16 @@ export type Message = {
185
185
  } | ({
186
186
  /** Parent → embed: confirm a payment intent with an embed token. */
187
187
  type: "CONFIRM_PAYMENT_INTENT";
188
- } & Pick<components["schemas"]["PaymentIntent"], "id"> & Pick<components["schemas"]["EmbedToken"], "token">) | ({
188
+ } & Pick<components["schemas"]["PaymentIntent"], "id"> & Pick<components["schemas"]["EmbedToken"], "token"> & {
189
+ /** Present when ACH verification completed via Plaid Link in the parent. */
190
+ plaid?: components["schemas"]["PlaidCredentialsInput"];
191
+ }) | ({
189
192
  /** Parent → embed: confirm a setup intent with an embed token. */
190
193
  type: "CONFIRM_SETUP_INTENT";
191
- } & Pick<components["schemas"]["SetupIntent"], "id"> & Pick<components["schemas"]["EmbedToken"], "token">) | {
194
+ } & Pick<components["schemas"]["SetupIntent"], "id"> & Pick<components["schemas"]["EmbedToken"], "token"> & {
195
+ /** Present when ACH verification completed via Plaid Link in the parent. */
196
+ plaid?: components["schemas"]["PlaidCredentialsInput"];
197
+ }) | {
192
198
  /**
193
199
  * Embed → parent: the interactive confirmation flow finished.
194
200
  * Parent → embed: express-checkout initiation failed on the host.
@@ -220,6 +226,26 @@ export type Message = {
220
226
  */
221
227
  type: "FORM_VALIDITY_CHANGE";
222
228
  isValid: boolean;
229
+ } | {
230
+ /**
231
+ * Embed → parent: merchant ACH verification threshold for this
232
+ * render token. `achThreshold` is cents, or `null` when the
233
+ * merchant has no threshold (manual ACH). `requireVerification`
234
+ * is true when the fetch failed in production (fail closed).
235
+ */
236
+ type: "ACH_THRESHOLD";
237
+ achThreshold?: number | null;
238
+ requireVerification?: boolean;
239
+ } | {
240
+ /** Parent → embed: mint a Plaid Link token for Connect. */
241
+ type: "CREATE_PLAID_LINK_TOKEN";
242
+ requestId: string;
243
+ } | {
244
+ /** Embed → parent: minted Plaid Link token, or `error`. */
245
+ type: "PLAID_LINK_TOKEN";
246
+ requestId: string;
247
+ link_token?: string;
248
+ error?: string;
223
249
  };
224
250
  /**
225
251
  * Identity helper that brands an object as a typed `Message`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amos.com/amos-js",
3
- "version": "0.9.17",
3
+ "version": "0.9.18",
4
4
  "main": "dist/index.js",
5
5
  "repository": {
6
6
  "type": "git",
package/src/index.ts CHANGED
@@ -52,6 +52,7 @@ export {
52
52
  export type {
53
53
  AmosApplePayButtonMountController,
54
54
  AmosApplePayButtonOptions,
55
+ AmosBankAccountPaymentMethodFormMountController,
55
56
  AmosBankAccountPaymentMethodFormOptions,
56
57
  AmosCreditCardPaymentMethodFormOptions,
57
58
  AmosGooglePayButtonMountController,
@@ -77,6 +78,18 @@ export {
77
78
  getCreditCardFormInitialHeight,
78
79
  getCreditCardFormSrc,
79
80
  } from "./payment-method-form";
81
+ export type {
82
+ OpenPlaidLinkInput,
83
+ PlaidCredentials,
84
+ PlaidLinkOnSuccessMetadata,
85
+ } from "./plaid";
86
+ export {
87
+ linkedBankLabelFromMetadata,
88
+ loadPlaidScript,
89
+ openPlaidLink,
90
+ plaidAccountIdFromMetadata,
91
+ requiresAchVerification,
92
+ } from "./plaid";
80
93
  export type {
81
94
  Appearance,
82
95
  AppearanceLabels,
package/src/messaging.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { components } from "@amos.com/node";
2
2
  import { decodeJwt } from "./jwt";
3
+ import { getBankPlaidSession } from "./plaid-session";
3
4
  import {
4
5
  type Appearance,
5
6
  type ApplePayButtonElementProps,
@@ -163,6 +164,11 @@ export function updateMerchantName({
163
164
  * `false` if the iframe does not respond within 5 seconds.
164
165
  */
165
166
  export function validateForm({ iframe }: { iframe: Iframe }): Promise<boolean> {
167
+ const session = getBankPlaidSession(iframe);
168
+ if (session?.requiresVerification) {
169
+ return Promise.resolve(Boolean(session.plaid));
170
+ }
171
+
166
172
  const requestId = crypto.randomUUID();
167
173
 
168
174
  return new Promise((resolve) => {
@@ -193,11 +199,71 @@ export function validateForm({ iframe }: { iframe: Iframe }): Promise<boolean> {
193
199
  });
194
200
  }
195
201
 
202
+ const PLAID_LINK_TOKEN_TIMEOUT_MS = 10_000;
203
+
204
+ /**
205
+ * Ask the bank iframe to mint a Plaid Link token (`POST /plaid_link_tokens`
206
+ * on embed). Resolves with `link_token`, or rejects on error / timeout.
207
+ */
208
+ export function requestPlaidLinkToken({
209
+ iframe,
210
+ }: {
211
+ iframe: Iframe;
212
+ }): Promise<string> {
213
+ const requestId = crypto.randomUUID();
214
+
215
+ return new Promise((resolve, reject) => {
216
+ if (!iframe?.contentWindow) {
217
+ reject(new Error("Bank form is not ready."));
218
+ return;
219
+ }
220
+
221
+ const contentWindow = iframe.contentWindow;
222
+ contentWindow.postMessage(
223
+ createMessage({ type: "CREATE_PLAID_LINK_TOKEN", requestId }),
224
+ getIframeTargetOrigin(iframe),
225
+ );
226
+
227
+ const timeoutId = setTimeout(() => {
228
+ window.removeEventListener("message", handleMessage);
229
+ reject(new Error("Timed out waiting for Plaid Link token."));
230
+ }, PLAID_LINK_TOKEN_TIMEOUT_MS);
231
+
232
+ function handleMessage(event: MessageEvent<Message>) {
233
+ if (event.source !== contentWindow) {
234
+ return;
235
+ }
236
+ if (
237
+ event.data.type !== "PLAID_LINK_TOKEN" ||
238
+ event.data.requestId !== requestId
239
+ ) {
240
+ return;
241
+ }
242
+ window.removeEventListener("message", handleMessage);
243
+ clearTimeout(timeoutId);
244
+ if (event.data.link_token) {
245
+ resolve(event.data.link_token);
246
+ return;
247
+ }
248
+ reject(
249
+ new Error(event.data.error ?? "Could not create Plaid Link token."),
250
+ );
251
+ }
252
+
253
+ window.addEventListener("message", handleMessage);
254
+ });
255
+ }
256
+
196
257
  /**
197
258
  * Clear all field values and API errors in the embedded credit-card or
198
259
  * bank-account iframe form.
199
260
  */
200
261
  export function resetForm({ iframe }: { iframe: Iframe }): void {
262
+ getBankPlaidSession(iframe)?.clearLinked?.();
263
+ resetIframeFields(iframe);
264
+ }
265
+
266
+ function resetIframeFields(iframe: Iframe): void {
201
267
  if (!iframe?.contentWindow) {
202
268
  return;
203
269
  }
@@ -208,6 +274,35 @@ export function resetForm({ iframe }: { iframe: Iframe }): void {
208
274
  );
209
275
  }
210
276
 
277
+ function postConfirmIntent({
278
+ iframe,
279
+ type,
280
+ token,
281
+ id,
282
+ }: {
283
+ iframe: HTMLIFrameElement;
284
+ type: "CONFIRM_PAYMENT_INTENT" | "CONFIRM_SETUP_INTENT";
285
+ token: string | undefined;
286
+ id: string | undefined;
287
+ }): void {
288
+ const session = getBankPlaidSession(iframe);
289
+ const plaid = session?.plaid;
290
+ // Plaid mode: do not confirm leftover routing/account numbers.
291
+ if (session?.requiresVerification && !plaid) {
292
+ resetIframeFields(iframe);
293
+ }
294
+
295
+ iframe.contentWindow?.postMessage(
296
+ createMessage({
297
+ type,
298
+ token,
299
+ id,
300
+ ...(plaid ? { plaid } : {}),
301
+ }),
302
+ getIframeTargetOrigin(iframe),
303
+ );
304
+ }
305
+
211
306
  /**
212
307
  * Confirm a payment intent in the embedded iframe flow.
213
308
  *
@@ -227,14 +322,12 @@ export function confirmPaymentIntent({
227
322
 
228
323
  const { payment_intent_id: id }: components["schemas"]["EmbedTokenJwt"] =
229
324
  decodeJwt(token).payload;
230
- iframe.contentWindow.postMessage(
231
- createMessage({
232
- type: "CONFIRM_PAYMENT_INTENT",
233
- token,
234
- id: id ?? undefined,
235
- }),
236
- getIframeTargetOrigin(iframe),
237
- );
325
+ postConfirmIntent({
326
+ iframe,
327
+ type: "CONFIRM_PAYMENT_INTENT",
328
+ token,
329
+ id: id ?? undefined,
330
+ });
238
331
  }
239
332
 
240
333
  /**
@@ -256,14 +349,12 @@ export function confirmSetupIntent({
256
349
 
257
350
  const { setup_intent_id: id }: components["schemas"]["EmbedTokenJwt"] =
258
351
  decodeJwt(token).payload;
259
- iframe.contentWindow.postMessage(
260
- createMessage({
261
- type: "CONFIRM_SETUP_INTENT",
262
- token,
263
- id: id ?? undefined,
264
- }),
265
- getIframeTargetOrigin(iframe),
266
- );
352
+ postConfirmIntent({
353
+ iframe,
354
+ type: "CONFIRM_SETUP_INTENT",
355
+ token,
356
+ id: id ?? undefined,
357
+ });
267
358
  }
268
359
 
269
360
  /**
package/src/mount.ts CHANGED
@@ -31,6 +31,7 @@ import {
31
31
  type PaymentMethodFormController,
32
32
  type PaymentMethodFormListenerOptions,
33
33
  } from "./payment-method-form";
34
+ import { attachPlaidBankUi } from "./plaid-bank-ui";
34
35
 
35
36
  type Container = HTMLElement | string;
36
37
 
@@ -425,6 +426,18 @@ export type AmosPaymentMethodFormMountController =
425
426
  iframe: HTMLIFrameElement;
426
427
  };
427
428
 
429
+ /**
430
+ * Controller returned by {@link mountAmosBankAccountPaymentMethodForm}.
431
+ * `update()` also accepts `amount` (major-currency decimal string) when
432
+ * the charge changes.
433
+ */
434
+ export type AmosBankAccountPaymentMethodFormMountController = Omit<
435
+ AmosPaymentMethodFormMountController,
436
+ "update"
437
+ > & {
438
+ update: (patch: Partial<AmosBankAccountPaymentMethodFormOptions>) => void;
439
+ };
440
+
428
441
  /**
429
442
  * Mount the secure credit-card payment method form into a container
430
443
  * element. Returns a controller exposing the underlying iframe, an
@@ -494,6 +507,14 @@ export type AmosBankAccountPaymentMethodFormOptions =
494
507
  * @default "country"
495
508
  */
496
509
  billingAddressRequirement?: BillingAddressRequirement;
510
+ /**
511
+ * Charge amount as a major-currency decimal string (e.g. `"50.00"`
512
+ * for $50.00), the same format as Google Pay / Apple Pay. Compared
513
+ * to the merchant ACH threshold fetched by the iframe. Omit for
514
+ * setup intents or when the charge is unknown — if a threshold is
515
+ * set, Plaid is required.
516
+ */
517
+ amount?: string;
497
518
  };
498
519
 
499
520
  /**
@@ -501,8 +522,12 @@ export type AmosBankAccountPaymentMethodFormOptions =
501
522
  * element. Returns a controller exposing the underlying iframe, an
502
523
  * `update()` method, and a `destroy()` method.
503
524
  *
504
- * A field-shaped skeleton is shown immediately and replaced by the
505
- * iframe once appearance is applied.
525
+ * When the iframe reports an ACH threshold and `amount` meets it (or
526
+ * `amount` is omitted), a Connect bank button is rendered in the parent
527
+ * document and Plaid Link is opened on click. The button uses the same
528
+ * `appearance.themeVariables` as the iframe (and inherits host-page
529
+ * tokens when those variables are unset). Otherwise a field-shaped
530
+ * skeleton is shown and replaced by the iframe once appearance is applied.
506
531
  *
507
532
  * Use the returned `controller.iframe` when calling
508
533
  * {@link validateForm}, {@link confirmPaymentIntent}, or
@@ -511,11 +536,12 @@ export type AmosBankAccountPaymentMethodFormOptions =
511
536
  export function mountAmosBankAccountPaymentMethodForm(
512
537
  container: Container,
513
538
  options: AmosBankAccountPaymentMethodFormOptions,
514
- ): AmosPaymentMethodFormMountController {
539
+ ): AmosBankAccountPaymentMethodFormMountController {
515
540
  const host = resolveContainer(container);
516
541
  const {
517
542
  renderToken,
518
543
  billingAddressRequirement = "country",
544
+ amount,
519
545
  ...listenerOptions
520
546
  } = options;
521
547
 
@@ -526,7 +552,7 @@ export function mountAmosBankAccountPaymentMethodForm(
526
552
  height: getBankAccountFormInitialHeight(billingAddressRequirement),
527
553
  });
528
554
 
529
- return mountPaymentMethodFormWithSkeleton({
555
+ const iframeMount = mountPaymentMethodFormWithSkeleton({
530
556
  host,
531
557
  iframe,
532
558
  listenerOptions,
@@ -536,6 +562,28 @@ export function mountAmosBankAccountPaymentMethodForm(
536
562
  billingAddressRequirement,
537
563
  },
538
564
  });
565
+
566
+ const plaidUi = attachPlaidBankUi({
567
+ host,
568
+ iframe,
569
+ options: {
570
+ amount,
571
+ appearance: listenerOptions.appearance,
572
+ onValidityChange: listenerOptions.onValidityChange,
573
+ },
574
+ });
575
+
576
+ return {
577
+ iframe,
578
+ update(patch) {
579
+ iframeMount.update(patch as Partial<PaymentMethodFormListenerOptions>);
580
+ plaidUi.update(patch);
581
+ },
582
+ destroy() {
583
+ plaidUi.destroy();
584
+ iframeMount.destroy();
585
+ },
586
+ };
539
587
  }
540
588
 
541
589
  /**
@@ -3,6 +3,7 @@ import {
3
3
  sendParentReadyMessage,
4
4
  updateAppearance as sendUpdateAppearance,
5
5
  } from "./messaging";
6
+ import { getBankPlaidSession } from "./plaid-session";
6
7
  import type {
7
8
  Appearance,
8
9
  ConfirmationResult,
@@ -203,6 +204,9 @@ export function attachPaymentMethodFormListeners(
203
204
  break;
204
205
 
205
206
  case "FORM_VALIDITY_CHANGE":
207
+ if (getBankPlaidSession(iframe)?.requiresVerification) {
208
+ break;
209
+ }
206
210
  current.onValidityChange?.({ isValid: event.data.isValid });
207
211
  break;
208
212