bison-web-components 2.0.0 → 3.0.0

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.
@@ -288,10 +288,8 @@ class BisonJibPayAPI {
288
288
  * const api = new BisonJibPayAPI(baseURL, embeddableKey);
289
289
  * const tokenData = await api.generateMoovToken('operator@example.com');
290
290
  * console.log(tokenData.access_token);
291
- */
291
+ */
292
292
  async generateMoovToken(operatorEmail, moovAccountId = null, operatorId = null, clientId = null) {
293
- console.log("CALLED GENERATE MOOV TOKEN");
294
-
295
293
  // Use provided moovAccountId or fetch it if not provided
296
294
  let accountId = moovAccountId;
297
295
  if (!accountId) {
@@ -315,7 +313,6 @@ class BisonJibPayAPI {
315
313
  };
316
314
  }
317
315
  }
318
- console.log("MOOV ACCOUNT ID", accountId);
319
316
  let accountScopes = [
320
317
  "/accounts/{ACCOUNT_ID}/bank-accounts.read",
321
318
  "/accounts/{ACCOUNT_ID}/bank-accounts.write",
@@ -381,7 +378,7 @@ class BisonJibPayAPI {
381
378
  return this.request("/api/embeddable/plaid/link-token", {
382
379
  method: "POST",
383
380
  body: JSON.stringify({
384
- clientName: wioEmail,
381
+ clientName: "BisonJibPay",
385
382
  countryCodes: ["US"],
386
383
  user: {
387
384
  clientUserId: "wio-email",
@@ -393,6 +390,187 @@ class BisonJibPayAPI {
393
390
  });
394
391
  }
395
392
 
393
+ /**
394
+ * Create Plaid Link token using the new Plaid flow.
395
+ *
396
+ * @param {Object} payload
397
+ * @returns {Promise<any>}
398
+ */
399
+ async createPlaidLinkToken(payload) {
400
+ if (!payload?.user?.clientUserId) {
401
+ throw {
402
+ status: 400,
403
+ data: {
404
+ success: false,
405
+ message: "user.clientUserId is required",
406
+ errors: ["payload.user.clientUserId parameter is missing"],
407
+ },
408
+ };
409
+ }
410
+
411
+ return this.request("/api/plaid/create-token", {
412
+ method: "POST",
413
+ body: JSON.stringify(payload),
414
+ });
415
+ }
416
+
417
+ /**
418
+ * Generate Plaid embeddable Link token
419
+ *
420
+ * Calls POST /api/plaid/embeddable/create-token with entityId as query param.
421
+ *
422
+ * @param {string} entityId - Entity UUID (required)
423
+ * @param {Object} payload - Plaid create-token request payload
424
+ * @returns {Promise<any>}
425
+ */
426
+ async generatePlaidLinkToken(entityId, payload = {}) {
427
+ if (!entityId || typeof entityId !== "string" || !entityId.trim()) {
428
+ throw {
429
+ status: 400,
430
+ data: {
431
+ success: false,
432
+ message: "entityId is required",
433
+ errors: ["entityId parameter is missing"],
434
+ },
435
+ };
436
+ }
437
+
438
+ const isUuid =
439
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
440
+ entityId
441
+ );
442
+ if (!isUuid) {
443
+ throw {
444
+ status: 400,
445
+ data: {
446
+ success: false,
447
+ message: "entityId must be a valid UUID",
448
+ errors: ["entityId parameter must be a UUID"],
449
+ },
450
+ };
451
+ }
452
+
453
+ const params = new URLSearchParams();
454
+ params.append("entityId", entityId);
455
+
456
+ return this.request(`/api/plaid/embeddable/create-token?${params.toString()}`, {
457
+ method: "POST",
458
+ body: JSON.stringify(payload),
459
+ });
460
+ }
461
+
462
+ /**
463
+ * Register a Plaid-linked bank account using public token and selected account.
464
+ *
465
+ * @param {Object} payload
466
+ * @returns {Promise<any>}
467
+ */
468
+ async registerPlaidBankAccount(payload) {
469
+ if (!payload?.publicToken) {
470
+ throw {
471
+ status: 400,
472
+ data: {
473
+ success: false,
474
+ message: "publicToken is required",
475
+ errors: ["payload.publicToken parameter is missing"],
476
+ },
477
+ };
478
+ }
479
+
480
+ if (!payload?.accountId) {
481
+ throw {
482
+ status: 400,
483
+ data: {
484
+ success: false,
485
+ message: "accountId is required",
486
+ errors: ["payload.accountId parameter is missing"],
487
+ },
488
+ };
489
+ }
490
+
491
+ if (payload?.entityType !== 0 && payload?.entityType !== 1) {
492
+ throw {
493
+ status: 400,
494
+ data: {
495
+ success: false,
496
+ message: "entityType must be 0 (WIO) or 1 (Operator)",
497
+ errors: ["payload.entityType must be 0 or 1"],
498
+ },
499
+ };
500
+ }
501
+
502
+ if (!payload?.entityId) {
503
+ throw {
504
+ status: 400,
505
+ data: {
506
+ success: false,
507
+ message: "entityId is required",
508
+ errors: ["payload.entityId parameter is missing"],
509
+ },
510
+ };
511
+ }
512
+
513
+ return this.request("/api/plaid/embeddable/register-bank-account", {
514
+ method: "POST",
515
+ body: JSON.stringify(payload),
516
+ });
517
+ }
518
+
519
+ /**
520
+ * Retry Plaid bank account registration (embeddable)
521
+ *
522
+ * Calls POST /api/plaid/embeddable/retry-registration.
523
+ *
524
+ * @param {Object} payload - Retry registration payload
525
+ * @param {number} payload.entityType - 0 for WIO, 1 for Operator
526
+ * @param {string} payload.entityId - Entity ID
527
+ * @param {string[]} payload.providers - Provider names to retry against
528
+ * @returns {Promise<any>}
529
+ */
530
+ async retryEmbeddablePlaidRegistration(payload = {}) {
531
+ if (payload?.entityType !== 0 && payload?.entityType !== 1) {
532
+ throw {
533
+ status: 400,
534
+ data: {
535
+ success: false,
536
+ message: "entityType must be 0 (WIO) or 1 (Operator)",
537
+ errors: ["payload.entityType must be 0 or 1"],
538
+ },
539
+ };
540
+ }
541
+
542
+ if (!payload?.entityId || typeof payload.entityId !== "string" || !payload.entityId.trim()) {
543
+ throw {
544
+ status: 400,
545
+ data: {
546
+ success: false,
547
+ message: "entityId is required",
548
+ errors: ["payload.entityId parameter is missing"],
549
+ },
550
+ };
551
+ }
552
+
553
+ if (
554
+ !Array.isArray(payload?.providers) ||
555
+ payload.providers.length === 0 ||
556
+ payload.providers.some((provider) => typeof provider !== "string" || !provider.trim())
557
+ ) {
558
+ throw {
559
+ status: 400,
560
+ data: {
561
+ success: false,
562
+ message: "providers is required",
563
+ errors: ["payload.providers must be a non-empty string array"],
564
+ },
565
+ };
566
+ }
567
+
568
+ return this.request("/api/plaid/embeddable/retry-registration", {
569
+ method: "POST",
570
+ body: JSON.stringify(payload),
571
+ });
572
+ }
573
+
396
574
  /**
397
575
  * Create Plaid processor token
398
576
  *
@@ -761,6 +939,14 @@ class BisonOperatorPayments extends HTMLElement {
761
939
  this._linkModalDirty = false;
762
940
  this._linkModalBeforeUnload = null;
763
941
  this._linkModalPopState = null;
942
+ this._isPlaidLinkInProgress = false;
943
+ this._isPlaidLinkFinalizing = false;
944
+ this._isPlaidRetryInProgress = false;
945
+ this._plaidLinkErrorState = null;
946
+ this._plaidRetryPayload = null;
947
+ this._plaidLinkToken = null;
948
+ this._plaidLinkHandler = null;
949
+ this._plaidScriptPromise = null;
764
950
 
765
951
  // Embeddable key gate
766
952
  this._embeddableKey = null;
@@ -1183,12 +1369,8 @@ class BisonOperatorPayments extends HTMLElement {
1183
1369
 
1184
1370
  /** Internal dev-friendly logger, prefixed for easy identification. */
1185
1371
  _log(msg, data) {
1186
- const prefix = "[BOP]";
1187
- if (data !== undefined) {
1188
- console.log(prefix, msg, data);
1189
- } else {
1190
- console.log(prefix, msg);
1191
- }
1372
+ void msg;
1373
+ void data;
1192
1374
  }
1193
1375
 
1194
1376
  _resetState() {
@@ -1203,6 +1385,13 @@ class BisonOperatorPayments extends HTMLElement {
1203
1385
  this._unlinkResult = null;
1204
1386
  this._isFetchingAccounts = false;
1205
1387
  this._pendingLinkedAccount = null;
1388
+ this._isPlaidLinkInProgress = false;
1389
+ this._isPlaidLinkFinalizing = false;
1390
+ this._isPlaidRetryInProgress = false;
1391
+ this._plaidLinkErrorState = null;
1392
+ this._plaidRetryPayload = null;
1393
+ this._plaidLinkToken = null;
1394
+ this._plaidLinkHandler = null;
1206
1395
  this._resetLinkModal();
1207
1396
  }
1208
1397
 
@@ -1269,7 +1458,19 @@ class BisonOperatorPayments extends HTMLElement {
1269
1458
  }
1270
1459
  }
1271
1460
 
1272
- _handleClose() {
1461
+ _handleClose(showBlockedAlert = true) {
1462
+ if (this._isPlaidLinkInProgress) {
1463
+ if (!showBlockedAlert) return;
1464
+ let message = "Please finish or exit the Plaid flow before closing this window.";
1465
+ if (this._isPlaidRetryInProgress) {
1466
+ message = "Please wait — we're retrying your bank link.";
1467
+ } else if (this._isPlaidLinkFinalizing) {
1468
+ message = "Please wait — we're finishing your bank link.";
1469
+ }
1470
+ window.alert(message);
1471
+ return;
1472
+ }
1473
+
1273
1474
  if (this._linkModalOpen && this._linkModalSubmitting) {
1274
1475
  // Submitting — cannot close (keep native alert for this blocking case)
1275
1476
  window.alert(
@@ -1332,6 +1533,7 @@ class BisonOperatorPayments extends HTMLElement {
1332
1533
  }
1333
1534
 
1334
1535
  _handleAccountToggle(id) {
1536
+ if (this._isPlaidLinkInProgress) return;
1335
1537
  if (this._selectedAccounts.has(id)) this._selectedAccounts.delete(id);
1336
1538
  else this._selectedAccounts.add(id);
1337
1539
  // Update selection state in-place without re-rendering
@@ -1504,8 +1706,528 @@ class BisonOperatorPayments extends HTMLElement {
1504
1706
  this._navigateStep("select-accounts", -1);
1505
1707
  }
1506
1708
 
1507
- _handleLinkAccountClick() {
1508
- this._openLinkModal();
1709
+ async _handleLinkAccountClick() {
1710
+ if (this._isPlaidLinkInProgress) return;
1711
+
1712
+ const api = await this._getApi();
1713
+ if (
1714
+ !api ||
1715
+ typeof api.generatePlaidLinkToken !== "function" ||
1716
+ typeof api.registerPlaidBankAccount !== "function"
1717
+ ) {
1718
+ const err = { message: "Plaid API handlers are not available on the API instance." };
1719
+ this._log("Error linking account via Plaid:", err);
1720
+ if (typeof this.onLinkError === "function") this.onLinkError(err);
1721
+ window.alert(err.message);
1722
+ return;
1723
+ }
1724
+
1725
+ this._isPlaidLinkInProgress = true;
1726
+ this._isPlaidLinkFinalizing = false;
1727
+ this._isPlaidRetryInProgress = false;
1728
+ this._clearPlaidLinkErrorState();
1729
+ this._plaidRetryPayload = null;
1730
+ this._syncSelectAccountsInteractionState();
1731
+
1732
+ let entityContext = null;
1733
+ try {
1734
+ entityContext = this._resolvePlaidEntityContext();
1735
+
1736
+ const createTokenPayload = {
1737
+ clientName: "BisonJibPay",
1738
+ language: "en",
1739
+ products: ["auth"],
1740
+ countryCodes: ["US"],
1741
+ user: {
1742
+ clientUserId: entityContext.clientUserId,
1743
+ },
1744
+ };
1745
+
1746
+ const tokenResponse = await api.generatePlaidLinkToken(
1747
+ entityContext.entityId,
1748
+ createTokenPayload,
1749
+ );
1750
+ const tokenData = tokenResponse?.data || tokenResponse || {};
1751
+ this._plaidLinkToken = tokenData.linkToken || tokenData.link_token || null;
1752
+
1753
+ if (!this._plaidLinkToken) {
1754
+ throw new Error("Failed to create Plaid Link token.");
1755
+ }
1756
+
1757
+ const linkResult = await this._openPlaidLinkAndRegister(
1758
+ api,
1759
+ this._plaidLinkToken,
1760
+ entityContext,
1761
+ );
1762
+
1763
+ // User exited Plaid without completing a link.
1764
+ if (!linkResult) return;
1765
+
1766
+ const registerResponse = linkResult.registerResponse || {};
1767
+ if (this._isPlaidRegistrationFailure(registerResponse)) {
1768
+ this._setPlaidLinkErrorState(
1769
+ registerResponse,
1770
+ "We couldn’t finish linking your account. Please retry.",
1771
+ entityContext,
1772
+ );
1773
+ if (typeof this.onLinkError === "function") this.onLinkError(registerResponse);
1774
+ return;
1775
+ }
1776
+
1777
+ if (this._operatorId) {
1778
+ await this._fetchBankAccounts(api);
1779
+ } else if (this._step === "select-accounts") {
1780
+ this._renderAccountCards();
1781
+ }
1782
+
1783
+ if (typeof this.onLinkSuccess === "function") {
1784
+ this.onLinkSuccess(registerResponse?.data || registerResponse);
1785
+ }
1786
+ } catch (err) {
1787
+ const errData = err?.data || err;
1788
+ this._log("Error linking account via Plaid:", errData);
1789
+ if (typeof this.onLinkError === "function") this.onLinkError(errData);
1790
+ const message =
1791
+ errData?.message || err?.message || "Unable to link bank account.";
1792
+
1793
+ // If we already have retry context, show in-modal retry screen.
1794
+ if (this._plaidRetryPayload && this._step === "select-accounts") {
1795
+ this._setPlaidLinkErrorState(errData, message, entityContext);
1796
+ } else {
1797
+ window.alert(message);
1798
+ }
1799
+ } finally {
1800
+ this._isPlaidLinkFinalizing = false;
1801
+ this._isPlaidRetryInProgress = false;
1802
+ this._isPlaidLinkInProgress = false;
1803
+ if (this._step === "select-accounts") {
1804
+ this._renderHeader();
1805
+ this._renderContent();
1806
+ }
1807
+ }
1808
+ }
1809
+
1810
+ async _handlePlaidRetryRegistration() {
1811
+ if (this._isPlaidRetryInProgress) return;
1812
+ if (!this._plaidRetryPayload) return;
1813
+
1814
+ const api = await this._getApi();
1815
+ if (!api || typeof api.retryEmbeddablePlaidRegistration !== "function") {
1816
+ const err = { message: "Plaid retry API handler is not available on the API instance." };
1817
+ this._log("Error retrying Plaid registration:", err);
1818
+ if (typeof this.onLinkError === "function") this.onLinkError(err);
1819
+ this._setPlaidLinkErrorState(err, err.message);
1820
+ this._renderHeader();
1821
+ this._renderContent();
1822
+ return;
1823
+ }
1824
+
1825
+ this._isPlaidLinkInProgress = true;
1826
+ this._isPlaidRetryInProgress = true;
1827
+ this._isPlaidLinkFinalizing = false;
1828
+ this._renderHeader();
1829
+ this._renderContent();
1830
+
1831
+ try {
1832
+ const retryResponse = await api.retryEmbeddablePlaidRegistration(
1833
+ this._plaidRetryPayload,
1834
+ );
1835
+
1836
+ if (this._isPlaidRegistrationFailure(retryResponse)) {
1837
+ this._setPlaidLinkErrorState(
1838
+ retryResponse,
1839
+ "Retry failed. Please try again.",
1840
+ );
1841
+ if (typeof this.onLinkError === "function") this.onLinkError(retryResponse);
1842
+ return;
1843
+ }
1844
+
1845
+ this._clearPlaidLinkErrorState();
1846
+ this._isPlaidLinkFinalizing = true;
1847
+ this._renderHeader();
1848
+ this._renderContent();
1849
+
1850
+ if (this._operatorId) {
1851
+ await this._fetchBankAccounts(api);
1852
+ } else if (this._step === "select-accounts") {
1853
+ this._renderAccountCards();
1854
+ }
1855
+
1856
+ if (typeof this.onLinkSuccess === "function") {
1857
+ this.onLinkSuccess(retryResponse?.data || retryResponse);
1858
+ }
1859
+ } catch (err) {
1860
+ const errData = err?.data || err;
1861
+ this._log("Error retrying Plaid registration:", errData);
1862
+ if (typeof this.onLinkError === "function") this.onLinkError(errData);
1863
+ const message =
1864
+ errData?.message || err?.message || "Unable to retry linking bank account.";
1865
+ this._setPlaidLinkErrorState(errData, message);
1866
+ } finally {
1867
+ this._isPlaidLinkFinalizing = false;
1868
+ this._isPlaidRetryInProgress = false;
1869
+ this._isPlaidLinkInProgress = false;
1870
+ if (this._step === "select-accounts") {
1871
+ this._renderHeader();
1872
+ this._renderContent();
1873
+ }
1874
+ }
1875
+ }
1876
+
1877
+ _dismissPlaidLinkError() {
1878
+ this._clearPlaidLinkErrorState();
1879
+ this._renderHeader();
1880
+ this._renderContent();
1881
+ }
1882
+
1883
+ _getLinkAccountButtonLabel() {
1884
+ return this._isPlaidLinkInProgress
1885
+ ? `<span class="bop-spinner">${BOP_ICONS.loader}</span> Linking...`
1886
+ : `${BOP_ICONS.plus} Link Account`;
1887
+ }
1888
+
1889
+ _syncSelectAccountsInteractionState() {
1890
+ if (this._step !== "select-accounts") return;
1891
+
1892
+ const closeBtn = this._headerEl?.querySelector(".bop-close-btn");
1893
+ if (closeBtn) closeBtn.disabled = this._isPlaidLinkInProgress;
1894
+
1895
+ const linkAccountBtn = this._contentEl?.querySelector(".bop-link-account-btn");
1896
+ if (linkAccountBtn) {
1897
+ linkAccountBtn.disabled = this._isPlaidLinkInProgress;
1898
+ linkAccountBtn.innerHTML = this._getLinkAccountButtonLabel();
1899
+ }
1900
+
1901
+ this._contentEl
1902
+ ?.querySelectorAll(".bop-account-card")
1903
+ .forEach((card) => {
1904
+ card.disabled = this._isPlaidLinkInProgress;
1905
+ });
1906
+
1907
+ const unlinkBtn = this._contentEl?.querySelector(".bop-btn-unlink");
1908
+ if (unlinkBtn) unlinkBtn.disabled = this._isPlaidLinkInProgress;
1909
+ }
1910
+
1911
+ _resolvePlaidEntityContext() {
1912
+ const lookupData = this._operatorData?.data || this._operatorData || {};
1913
+ const operatorId = lookupData.operatorId || this._operatorId || null;
1914
+
1915
+ if (operatorId) {
1916
+ return {
1917
+ entityType: 1,
1918
+ entityId: String(operatorId),
1919
+ clientUserId: String(operatorId),
1920
+ };
1921
+ }
1922
+
1923
+ const wioEntityId = lookupData.wioId || lookupData.entityId || null;
1924
+ if (!wioEntityId) {
1925
+ throw new Error(
1926
+ "Missing entityId for WIO flow. Lookup data must include operatorId, wioId, or entityId.",
1927
+ );
1928
+ }
1929
+
1930
+ return {
1931
+ entityType: 0,
1932
+ entityId: String(wioEntityId),
1933
+ clientUserId: String(wioEntityId),
1934
+ };
1935
+ }
1936
+
1937
+ _getPlaidAccountType(metadataAccount) {
1938
+ const subtype = (metadataAccount?.subtype || "").toLowerCase();
1939
+ if (subtype.includes("savings")) return "Savings";
1940
+ return "Checking";
1941
+ }
1942
+
1943
+ _buildPlaidRegisterPayload(publicToken, plaidAccountId, entityContext, metadata) {
1944
+ const lookupData = this._operatorData?.data || this._operatorData || {};
1945
+ const metadataAccount = metadata?.accounts?.[0] || null;
1946
+ const payload = {
1947
+ publicToken,
1948
+ accountId: plaidAccountId,
1949
+ entityType: entityContext.entityType,
1950
+ entityId: entityContext.entityId,
1951
+ accountType: this._getPlaidAccountType(metadataAccount),
1952
+ description: "Linked via Plaid Link",
1953
+ };
1954
+
1955
+ const accountHolderName = (lookupData.companyName || "").trim();
1956
+ if (accountHolderName) payload.accountHolderName = accountHolderName;
1957
+
1958
+ return payload;
1959
+ }
1960
+
1961
+ _extractPlaidRetryDetails(source) {
1962
+ if (!source || typeof source !== "object") return {};
1963
+ if (
1964
+ source.data &&
1965
+ typeof source.data === "object" &&
1966
+ ("success" in source || "message" in source || "errors" in source || "status" in source)
1967
+ ) {
1968
+ return source.data;
1969
+ }
1970
+ return source;
1971
+ }
1972
+
1973
+ _collectPlaidRetryProviders(details) {
1974
+ const providers = [];
1975
+
1976
+ if (Array.isArray(details?.providers)) {
1977
+ for (const provider of details.providers) {
1978
+ if (typeof provider === "string" && provider.trim()) {
1979
+ providers.push(provider.trim());
1980
+ }
1981
+ }
1982
+ }
1983
+
1984
+ if (Array.isArray(details?.registrations)) {
1985
+ for (const registration of details.registrations) {
1986
+ const provider = registration?.provider;
1987
+ if (typeof provider === "string" && provider.trim()) {
1988
+ providers.push(provider.trim());
1989
+ }
1990
+ }
1991
+ }
1992
+
1993
+ const unique = Array.from(new Set(providers));
1994
+ return unique.length > 0 ? unique : ["Moov", "Column"];
1995
+ }
1996
+
1997
+ _updatePlaidRetryPayload(entityContext, details = {}) {
1998
+ if (!entityContext?.entityId) return null;
1999
+
2000
+ const existing = this._plaidRetryPayload || {};
2001
+ const next = {
2002
+ entityType: entityContext.entityType,
2003
+ entityId: entityContext.entityId,
2004
+ providers: this._collectPlaidRetryProviders({
2005
+ ...details,
2006
+ providers: details.providers || existing.providers,
2007
+ registrations: details.registrations || [],
2008
+ }),
2009
+ };
2010
+
2011
+ const accountId = details.accountId || existing.accountId || null;
2012
+ if (accountId) next.accountId = accountId;
2013
+
2014
+ const plaidItemId = details.plaidItemId || existing.plaidItemId || null;
2015
+ if (plaidItemId) next.plaidItemId = plaidItemId;
2016
+
2017
+ this._plaidRetryPayload = next;
2018
+ return next;
2019
+ }
2020
+
2021
+ _isPlaidRegistrationFailure(response) {
2022
+ if (!response || typeof response !== "object") return true;
2023
+ if (response.success === false) return true;
2024
+
2025
+ const data = this._extractPlaidRetryDetails(response);
2026
+ if (data?.allSucceeded === false) return true;
2027
+
2028
+ if (
2029
+ Array.isArray(data?.registrations) &&
2030
+ data.registrations.some((registration) => registration?.success === false)
2031
+ ) {
2032
+ return true;
2033
+ }
2034
+
2035
+ return false;
2036
+ }
2037
+
2038
+ _setPlaidLinkErrorState(source, fallbackMessage, entityContext = null) {
2039
+ const details = this._extractPlaidRetryDetails(source);
2040
+ const retryContext =
2041
+ entityContext ||
2042
+ (this._plaidRetryPayload
2043
+ ? {
2044
+ entityType: this._plaidRetryPayload.entityType,
2045
+ entityId: this._plaidRetryPayload.entityId,
2046
+ }
2047
+ : null);
2048
+ if (retryContext) this._updatePlaidRetryPayload(retryContext, details);
2049
+
2050
+ const message =
2051
+ source?.message ||
2052
+ details?.message ||
2053
+ fallbackMessage ||
2054
+ "We couldn’t finish linking your bank account.";
2055
+
2056
+ this._isPlaidLinkFinalizing = false;
2057
+ this._plaidLinkErrorState = {
2058
+ message,
2059
+ };
2060
+ }
2061
+
2062
+ _clearPlaidLinkErrorState() {
2063
+ this._plaidLinkErrorState = null;
2064
+ }
2065
+
2066
+ async _ensurePlaidLinkLoaded() {
2067
+ if (typeof window !== "undefined" && window.Plaid && typeof window.Plaid.create === "function") {
2068
+ return;
2069
+ }
2070
+
2071
+ if (!this._plaidScriptPromise) {
2072
+ this._plaidScriptPromise = new Promise((resolve, reject) => {
2073
+ const scriptSrc = "https://cdn.plaid.com/link/v2/stable/link-initialize.js";
2074
+ const existingScript = document.querySelector(`script[src="${scriptSrc}"]`);
2075
+ let timeoutId = null;
2076
+
2077
+ const cleanup = () => {
2078
+ if (timeoutId) {
2079
+ clearTimeout(timeoutId);
2080
+ timeoutId = null;
2081
+ }
2082
+ };
2083
+
2084
+ const handleLoad = () => {
2085
+ cleanup();
2086
+ resolve();
2087
+ };
2088
+ const handleError = () => {
2089
+ cleanup();
2090
+ reject(new Error("Failed to load Plaid Link script."));
2091
+ };
2092
+ timeoutId = setTimeout(() => {
2093
+ reject(new Error("Timed out while loading Plaid Link script."));
2094
+ }, 10000);
2095
+
2096
+ if (existingScript) {
2097
+ existingScript.addEventListener("load", handleLoad, { once: true });
2098
+ existingScript.addEventListener("error", handleError, { once: true });
2099
+ if (window.Plaid && typeof window.Plaid.create === "function") {
2100
+ cleanup();
2101
+ resolve();
2102
+ return;
2103
+ }
2104
+ if (existingScript.readyState === "complete" || existingScript.readyState === "loaded") {
2105
+ cleanup();
2106
+ reject(new Error("Plaid Link script is present but Plaid is unavailable."));
2107
+ }
2108
+ return;
2109
+ }
2110
+
2111
+ const script = document.createElement("script");
2112
+ script.src = scriptSrc;
2113
+ script.async = true;
2114
+ script.onload = handleLoad;
2115
+ script.onerror = handleError;
2116
+ document.head.appendChild(script);
2117
+ });
2118
+ }
2119
+
2120
+ try {
2121
+ await this._plaidScriptPromise;
2122
+ } catch (error) {
2123
+ this._plaidScriptPromise = null;
2124
+ throw error;
2125
+ }
2126
+
2127
+ if (!window.Plaid || typeof window.Plaid.create !== "function") {
2128
+ throw new Error("Plaid Link is not available after script load.");
2129
+ }
2130
+ }
2131
+
2132
+ _setPlaidOverlayPriority(isPlaidOpen) {
2133
+ const overlay = this.shadowRoot?.querySelector(".bop-overlay");
2134
+ if (!overlay) return;
2135
+ overlay.classList.toggle("bop-overlay--behind-plaid", Boolean(isPlaidOpen));
2136
+ }
2137
+
2138
+ _ensurePlaidGlobalLayerFix() {
2139
+ if (typeof document === "undefined") return;
2140
+ const styleId = "bop-plaid-link-layer-fix";
2141
+ if (document.getElementById(styleId)) return;
2142
+
2143
+ const style = document.createElement("style");
2144
+ style.id = styleId;
2145
+ style.textContent = `
2146
+ iframe[id^="plaid-link-iframe"],
2147
+ iframe[name^="plaid-link-iframe"],
2148
+ .plaid-link-iframe,
2149
+ .plaid-link-container,
2150
+ .plaid-link-iframe-wrapper {
2151
+ z-index: 2147483647 !important;
2152
+ }
2153
+ `;
2154
+ document.head.appendChild(style);
2155
+ }
2156
+
2157
+ async _openPlaidLinkAndRegister(api, linkToken, entityContext) {
2158
+ await this._ensurePlaidLinkLoaded();
2159
+ this._setPlaidOverlayPriority(true);
2160
+ this._ensurePlaidGlobalLayerFix();
2161
+
2162
+ return new Promise((resolve, reject) => {
2163
+ let settled = false;
2164
+ let successStarted = false;
2165
+ const resolveOnce = (value) => {
2166
+ if (settled) return;
2167
+ settled = true;
2168
+ this._setPlaidOverlayPriority(false);
2169
+ resolve(value);
2170
+ };
2171
+ const rejectOnce = (error) => {
2172
+ if (settled) return;
2173
+ settled = true;
2174
+ this._setPlaidOverlayPriority(false);
2175
+ reject(error);
2176
+ };
2177
+
2178
+ try {
2179
+ this._plaidLinkHandler = window.Plaid.create({
2180
+ token: linkToken,
2181
+ onSuccess: async (publicToken, metadata) => {
2182
+ successStarted = true;
2183
+ this._isPlaidLinkFinalizing = true;
2184
+ this._setPlaidOverlayPriority(false);
2185
+ if (this._step === "select-accounts") {
2186
+ this._renderHeader();
2187
+ this._renderContent();
2188
+ }
2189
+ try {
2190
+ const plaidAccountId = metadata?.accounts?.[0]?.id;
2191
+ if (!plaidAccountId) {
2192
+ throw new Error("Plaid did not return an accountId in onSuccess metadata.");
2193
+ }
2194
+
2195
+ this._updatePlaidRetryPayload(entityContext, {
2196
+ accountId: plaidAccountId,
2197
+ });
2198
+
2199
+ const registerPayload = this._buildPlaidRegisterPayload(
2200
+ publicToken,
2201
+ plaidAccountId,
2202
+ entityContext,
2203
+ metadata,
2204
+ );
2205
+
2206
+ const registerResponse = await api.registerPlaidBankAccount(registerPayload);
2207
+ this._updatePlaidRetryPayload(
2208
+ entityContext,
2209
+ this._extractPlaidRetryDetails(registerResponse),
2210
+ );
2211
+ resolveOnce({ registerResponse, metadata });
2212
+ } catch (error) {
2213
+ rejectOnce(error);
2214
+ }
2215
+ },
2216
+ onExit: (error) => {
2217
+ if (successStarted) return;
2218
+ if (error) {
2219
+ rejectOnce(error);
2220
+ return;
2221
+ }
2222
+ resolveOnce(null);
2223
+ },
2224
+ });
2225
+
2226
+ this._plaidLinkHandler.open();
2227
+ } catch (error) {
2228
+ rejectOnce(error);
2229
+ }
2230
+ });
1509
2231
  }
1510
2232
 
1511
2233
  _getHeaderTitle() {
@@ -1513,6 +2235,8 @@ class BisonOperatorPayments extends HTMLElement {
1513
2235
  case "loading":
1514
2236
  return this._selectedBank?.name || "Connecting";
1515
2237
  case "select-accounts":
2238
+ if (this._isPlaidLinkFinalizing) return "Linking Account";
2239
+ if (this._plaidLinkErrorState) return "Link Failed";
1516
2240
  return "Manage Accounts";
1517
2241
  case "confirm-unlink":
1518
2242
  return "Unlink Account";
@@ -1973,12 +2697,13 @@ class BisonOperatorPayments extends HTMLElement {
1973
2697
  @keyframes bopSlideFromLeft{from{opacity:0;transform:translateX(-10px)}to{opacity:1;transform:translateX(0)}}
1974
2698
  @keyframes bopFadeIn{from{opacity:0}to{opacity:1}}
1975
2699
  @keyframes bopFadeOut{from{opacity:1}to{opacity:0}}
1976
- @keyframes bopExpandIn{from{opacity:0;max-height:0;padding-top:0;padding-bottom:0}to{opacity:1;max-height:60px;padding-top:.75rem;padding-bottom:.75rem}}
2700
+ @keyframes bopExpandIn{from{opacity:0;max-height:0;padding-top:0;padding-bottom:0}to{opacity:1;max-height:60px;padding-top:.75rem;padding-bottom:.75rem}}
1977
2701
 
1978
- .bop-overlay{position:fixed;inset:0;z-index:40;display:flex;align-items:center;justify-content:center;padding:1rem;border:none;background:transparent;max-width:none;max-height:none;width:100%;height:100%;margin:0}
1979
- .bop-overlay::backdrop{background:transparent}
1980
- .bop-overlay[data-state="open"]{pointer-events:auto}
1981
- .bop-overlay[data-state="closed"]{pointer-events:none}
2702
+ .bop-overlay{position:fixed;inset:0;z-index:40;display:flex;align-items:center;justify-content:center;padding:1rem;border:none;background:transparent;max-width:none;max-height:none;width:100%;height:100%;margin:0}
2703
+ .bop-overlay.bop-overlay--behind-plaid{z-index:-1!important;opacity:0!important;pointer-events:none!important}
2704
+ .bop-overlay::backdrop{background:transparent}
2705
+ .bop-overlay[data-state="open"]{pointer-events:auto}
2706
+ .bop-overlay[data-state="closed"]{pointer-events:none}
1982
2707
  .bop-backdrop{position:absolute;inset:0;background:rgba(0,0,0,.4);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);animation:bopBackdropIn .3s var(--bop-ease) forwards}
1983
2708
  .bop-overlay[data-state="closing"] .bop-backdrop{animation:bopBackdropOut .3s var(--bop-ease) forwards}
1984
2709
  .bop-modal{position:relative;width:100%;max-width:448px;height:520px;background:#fff;border:1px solid var(--bop-border);box-shadow:var(--bop-shadow-2xl);border-radius:var(--bop-radius-xl);overflow:hidden;display:flex;flex-direction:column;max-height:90vh;animation:bopModalIn .3s var(--bop-ease-spring) forwards;transition:max-width .4s var(--bop-ease-spring),height .4s var(--bop-ease-spring)}
@@ -1994,6 +2719,8 @@ class BisonOperatorPayments extends HTMLElement {
1994
2719
  .bop-header-logo{width:22px;height:22px;object-fit:contain;flex-shrink:0}
1995
2720
  .bop-close-btn{padding:.375rem;margin-right:-.375rem;color:var(--bop-secondary);background:transparent;border:none;border-radius:var(--bop-radius-md);display:flex;align-items:center;justify-content:center;cursor:pointer;transition:color var(--bop-dur-norm) var(--bop-ease),background var(--bop-dur-norm) var(--bop-ease)}
1996
2721
  .bop-close-btn:hover{color:var(--bop-headline);background:var(--bop-sidebar)}
2722
+ .bop-close-btn:disabled{opacity:.45;cursor:not-allowed}
2723
+ .bop-close-btn:disabled:hover{color:var(--bop-secondary);background:transparent}
1997
2724
 
1998
2725
  .bop-content{position:relative;flex:1;overflow:hidden;min-height:0;background:rgba(248,250,252,.3);transition:opacity .2s var(--bop-ease)}
1999
2726
  .bop-content.bop-content-fading{opacity:0}
@@ -2064,6 +2791,8 @@ class BisonOperatorPayments extends HTMLElement {
2064
2791
  .bop-accounts-fetch-text{font-size:var(--bop-xs);font-weight:500;color:var(--bop-primary)}
2065
2792
  .bop-account-card{width:100%;display:flex;align-items:center;padding:1rem;border-radius:var(--bop-radius-xl);border:2px solid transparent;background:#fff;box-shadow:var(--bop-shadow-sm);cursor:pointer;transition:all var(--bop-dur-norm) var(--bop-ease);font-family:var(--bop-font);text-align:left;animation:bopItemFade .3s var(--bop-ease) forwards;opacity:0}
2066
2793
  .bop-account-card:hover{border-color:rgba(76,123,99,.2);box-shadow:var(--bop-shadow-md)}
2794
+ .bop-account-card:disabled{opacity:.7;cursor:not-allowed;pointer-events:none}
2795
+ .bop-account-card:disabled:hover{border-color:transparent;box-shadow:var(--bop-shadow-sm)}
2067
2796
  .bop-account-card[data-selected="true"]{border-color:var(--bop-primary);background:rgba(76,123,99,.05)}
2068
2797
  .bop-card-inner{display:flex;align-items:center;gap:1rem;flex:1}
2069
2798
  .bop-check-circle{width:1.5rem;height:1.5rem;border-radius:var(--bop-radius-full);border:2px solid var(--bop-border);display:flex;align-items:center;justify-content:center;flex-shrink:0;transition:all var(--bop-dur-norm) var(--bop-ease)}
@@ -2150,15 +2879,17 @@ class BisonOperatorPayments extends HTMLElement {
2150
2879
  .bop-btn-done:active{transform:scale(.98)}
2151
2880
  .bop-btn-label{animation:bopFadeIn .2s var(--bop-ease) forwards}
2152
2881
  .bop-btn-loading{display:flex;align-items:center;justify-content:center;gap:.5rem;position:absolute;inset:0;animation:bopFadeIn .2s var(--bop-ease) forwards}
2153
- .bop-spinner{animation:bopSpin 1s linear infinite}
2882
+ .bop-spinner{display:inline-flex;animation:bopSpin 1s linear infinite}
2154
2883
  .bop-hidden{display:none!important}
2155
2884
  .bop-loading-view{padding:2rem;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;background:#fff;gap:1.5rem}
2156
2885
  .bop-loading-logo{width:4.5rem;height:4.5rem;border-radius:1.25rem;display:flex;align-items:center;justify-content:center;color:#fff;box-shadow:var(--bop-shadow-md);outline:1px solid rgba(0,0,0,.05);overflow:hidden;animation:bopFadeInUp .5s var(--bop-ease-spring) forwards,bopBreath 2s ease-in-out .5s infinite}
2157
2886
  .bop-loading-body{display:flex;flex-direction:column;align-items:center;gap:.375rem;opacity:0;animation:bopFadeInUp .4s var(--bop-ease) .15s forwards}
2158
2887
  .bop-loading-title{font-size:var(--bop-base);font-weight:600;color:var(--bop-headline)}
2159
2888
  .bop-loading-text{font-size:var(--bop-sm);color:var(--bop-secondary);line-height:1.5}
2160
- .bop-loading-bar-wrap{width:11rem;height:3px;background:var(--bop-sidebar);border-radius:var(--bop-radius-full);overflow:hidden;opacity:0;animation:bopFadeIn .3s var(--bop-ease) .3s forwards}
2161
- .bop-loading-bar{height:100%;width:0;background:var(--bop-primary);border-radius:var(--bop-radius-full);animation:bopBarFill 1s cubic-bezier(.4,0,.2,1) .15s forwards}
2889
+ .bop-loading-indicator{display:inline-flex;align-items:center;gap:.5rem;opacity:0;animation:bopFadeIn .3s var(--bop-ease) .3s forwards}
2890
+ .bop-loading-spinner{display:inline-flex;color:var(--bop-primary);animation:bopSpin 1s linear infinite}
2891
+ .bop-loading-spinner svg{width:1rem;height:1rem}
2892
+ .bop-loading-indicator-text{font-size:var(--bop-xs);color:var(--bop-secondary)}
2162
2893
  .bop-loading-secure{display:flex;align-items:center;gap:.375rem;font-size:var(--bop-xs);color:var(--bop-secondary);opacity:0;animation:bopFadeIn .3s var(--bop-ease) .45s forwards}
2163
2894
  .bop-loading-secure svg{color:#10b981}
2164
2895
  @keyframes bopBreath{0%,100%{transform:scale(1)}50%{transform:scale(1.04)}}
@@ -2273,12 +3004,12 @@ class BisonOperatorPayments extends HTMLElement {
2273
3004
  // Intercept native ESC key behavior to use smooth animation
2274
3005
  overlay.addEventListener("cancel", (e) => {
2275
3006
  e.preventDefault();
2276
- this._handleClose();
3007
+ this._handleClose(false);
2277
3008
  });
2278
3009
 
2279
3010
  const backdrop = document.createElement("div");
2280
3011
  backdrop.className = "bop-backdrop";
2281
- backdrop.addEventListener("click", () => this._handleClose());
3012
+ backdrop.addEventListener("click", () => this._handleClose(false));
2282
3013
  overlay.appendChild(backdrop);
2283
3014
 
2284
3015
  const modal = document.createElement("div");
@@ -2296,11 +3027,9 @@ class BisonOperatorPayments extends HTMLElement {
2296
3027
  this._renderContent();
2297
3028
 
2298
3029
  this.shadowRoot.appendChild(overlay);
2299
- if (typeof overlay.showModal === "function") {
2300
- overlay.showModal();
2301
- } else {
2302
- overlay.setAttribute("open", "");
2303
- }
3030
+ // Use non-modal dialog mode so external overlays (e.g., Plaid Link iframe)
3031
+ // can stack above this component via normal z-index rules.
3032
+ overlay.setAttribute("open", "");
2304
3033
  }
2305
3034
 
2306
3035
  _renderHeader() {
@@ -2329,7 +3058,8 @@ class BisonOperatorPayments extends HTMLElement {
2329
3058
  const close = document.createElement("button");
2330
3059
  close.className = "bop-close-btn";
2331
3060
  close.innerHTML = BOP_ICONS.x;
2332
- close.addEventListener("click", () => this._handleClose());
3061
+ close.disabled = this._isPlaidLinkInProgress;
3062
+ close.addEventListener("click", () => this._handleClose(false));
2333
3063
  this._headerEl.appendChild(close);
2334
3064
  }
2335
3065
 
@@ -2488,7 +3218,10 @@ class BisonOperatorPayments extends HTMLElement {
2488
3218
  <p class="bop-loading-title">${bankName}</p>
2489
3219
  <p class="bop-loading-text">Securely retrieving your accounts</p>
2490
3220
  </div>
2491
- <div class="bop-loading-bar-wrap"><div class="bop-loading-bar"></div></div>
3221
+ <div class="bop-loading-indicator">
3222
+ <span class="bop-loading-spinner">${BOP_ICONS.loader}</span>
3223
+ <span class="bop-loading-indicator-text">Establishing secure connection...</span>
3224
+ </div>
2492
3225
  <div class="bop-loading-secure">${BOP_ICONS.shield} 256-bit encrypted connection</div>
2493
3226
  `;
2494
3227
  this._contentEl.appendChild(step);
@@ -2524,6 +3257,16 @@ class BisonOperatorPayments extends HTMLElement {
2524
3257
  }
2525
3258
 
2526
3259
  _renderSelectAccounts() {
3260
+ if (this._isPlaidLinkFinalizing) {
3261
+ this._renderPlaidFinalizingView();
3262
+ return;
3263
+ }
3264
+
3265
+ if (this._plaidLinkErrorState) {
3266
+ this._renderPlaidLinkErrorView();
3267
+ return;
3268
+ }
3269
+
2527
3270
  const step = document.createElement("div");
2528
3271
  step.className = "bop-step bop-accounts";
2529
3272
  step.setAttribute(
@@ -2553,10 +3296,9 @@ class BisonOperatorPayments extends HTMLElement {
2553
3296
 
2554
3297
  const linkAccountBtn = document.createElement("button");
2555
3298
  linkAccountBtn.className = "bop-link-account-btn";
2556
- linkAccountBtn.innerHTML = `${BOP_ICONS.plus} Link Account`;
2557
- linkAccountBtn.addEventListener("click", () =>
2558
- this._handleLinkAccountClick(),
2559
- );
3299
+ linkAccountBtn.disabled = this._isPlaidLinkInProgress;
3300
+ linkAccountBtn.innerHTML = this._getLinkAccountButtonLabel();
3301
+ linkAccountBtn.addEventListener("click", () => this._handleLinkAccountClick());
2560
3302
  hd.appendChild(linkAccountBtn);
2561
3303
  inner.appendChild(hd);
2562
3304
 
@@ -2576,9 +3318,89 @@ class BisonOperatorPayments extends HTMLElement {
2576
3318
  this._contentEl.appendChild(step);
2577
3319
  }
2578
3320
 
3321
+ _renderPlaidFinalizingView() {
3322
+ this._accountListEl = null;
3323
+ this._accountsFooterEl = null;
3324
+
3325
+ const step = document.createElement("div");
3326
+ step.className = "bop-step bop-loading-view";
3327
+ step.setAttribute(
3328
+ "data-direction",
3329
+ this._direction > 0 ? "forward" : "backward",
3330
+ );
3331
+
3332
+ const bankBg = this._selectedBank?.bg || "#2563eb";
3333
+ const bankName = this._selectedBank?.name || "your bank";
3334
+ const bankLogo = this._selectedBank?.logo
3335
+ ? `<img class="bop-logo-img" src="${this._selectedBank.logo}" alt="${bankName}">`
3336
+ : BOP_ICONS.buildingLg;
3337
+
3338
+ step.innerHTML = `
3339
+ <div class="bop-loading-logo" style="background:${bankBg}">${bankLogo}</div>
3340
+ <div class="bop-loading-body">
3341
+ <p class="bop-loading-title">Finalizing account link</p>
3342
+ <p class="bop-loading-text">Please wait while we securely connect your bank account.</p>
3343
+ </div>
3344
+ <div class="bop-loading-indicator">
3345
+ <span class="bop-loading-spinner">${BOP_ICONS.loader}</span>
3346
+ <span class="bop-loading-indicator-text">Finalizing with payment providers...</span>
3347
+ </div>
3348
+ <div class="bop-loading-secure">${BOP_ICONS.shield} Verifying and syncing account details</div>
3349
+ `;
3350
+
3351
+ this._contentEl.appendChild(step);
3352
+ }
3353
+
3354
+ _renderPlaidLinkErrorView() {
3355
+ this._accountListEl = null;
3356
+ this._accountsFooterEl = null;
3357
+
3358
+ const step = document.createElement("div");
3359
+ step.className = "bop-step bop-confirm-unlink";
3360
+ step.setAttribute(
3361
+ "data-direction",
3362
+ this._direction > 0 ? "forward" : "backward",
3363
+ );
3364
+
3365
+ const alertIcon =
3366
+ '<svg viewBox="0 0 24 24" width="28" height="28" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 8v4"/><path d="M12 16h.01"/></svg>';
3367
+
3368
+ const message =
3369
+ this._plaidLinkErrorState?.message ||
3370
+ "We couldn’t finish linking your bank account. Please retry.";
3371
+
3372
+ step.innerHTML = `
3373
+ <div class="bop-unlink-icon">${alertIcon}</div>
3374
+ <h3 class="bop-unlink-title">Unable to Link Account</h3>
3375
+ <p class="bop-unlink-desc">${message}</p>
3376
+ <div class="bop-unlink-actions"></div>
3377
+ `;
3378
+
3379
+ const actions = step.querySelector(".bop-unlink-actions");
3380
+
3381
+ const retryBtn = document.createElement("button");
3382
+ retryBtn.className = "bop-btn";
3383
+ retryBtn.disabled = this._isPlaidRetryInProgress;
3384
+ retryBtn.innerHTML = this._isPlaidRetryInProgress
3385
+ ? `<span class="bop-btn-loading"><span class="bop-spinner">${BOP_ICONS.loader}</span><span>Retrying...</span></span>`
3386
+ : '<span class="bop-btn-label">Retry Link</span>';
3387
+ retryBtn.addEventListener("click", () => this._handlePlaidRetryRegistration());
3388
+ actions.appendChild(retryBtn);
3389
+
3390
+ const backBtn = document.createElement("button");
3391
+ backBtn.className = "bop-btn bop-btn-ghost";
3392
+ backBtn.disabled = this._isPlaidRetryInProgress;
3393
+ backBtn.innerHTML = '<span class="bop-btn-label">Back to Accounts</span>';
3394
+ backBtn.addEventListener("click", () => this._dismissPlaidLinkError());
3395
+ actions.appendChild(backBtn);
3396
+
3397
+ this._contentEl.appendChild(step);
3398
+ }
3399
+
2579
3400
  _renderAccountCards(skipAnimationIds = null) {
2580
3401
  if (!this._accountListEl) return;
2581
3402
  this._accountListEl.innerHTML = "";
3403
+ const isInteractionLocked = this._isPlaidLinkInProgress;
2582
3404
 
2583
3405
  // Show loader while refetching accounts after a successful link
2584
3406
  if (this._isFetchingAccounts) {
@@ -2610,6 +3432,7 @@ class BisonOperatorPayments extends HTMLElement {
2610
3432
  card.className = "bop-account-card";
2611
3433
  card.dataset.accountId = acct.id;
2612
3434
  card.setAttribute("data-selected", String(isSelected));
3435
+ card.disabled = isInteractionLocked;
2613
3436
 
2614
3437
  // Skip fade-in for accounts already visible before this render pass
2615
3438
  if (skipAnimationIds && skipAnimationIds.has(acct.id)) {
@@ -2642,6 +3465,7 @@ class BisonOperatorPayments extends HTMLElement {
2642
3465
  _renderAccountsButton() {
2643
3466
  if (!this._accountsFooterEl) return;
2644
3467
  const c = this._selectedAccounts.size;
3468
+ const isInteractionLocked = this._isPlaidLinkInProgress;
2645
3469
  const isOpen = this._accountsFooterEl.classList.contains(
2646
3470
  "bop-accounts-footer--open",
2647
3471
  );
@@ -2667,6 +3491,7 @@ class BisonOperatorPayments extends HTMLElement {
2667
3491
  if (existing) {
2668
3492
  // Already open — just update the label, no rebuild
2669
3493
  existing.innerHTML = label;
3494
+ existing.disabled = isInteractionLocked;
2670
3495
  return;
2671
3496
  }
2672
3497
 
@@ -2675,7 +3500,9 @@ class BisonOperatorPayments extends HTMLElement {
2675
3500
  btn.className = "bop-btn bop-btn-danger bop-btn-unlink";
2676
3501
  btn.style.animation = "bopFadeIn .15s var(--bop-ease) forwards";
2677
3502
  btn.innerHTML = label;
3503
+ btn.disabled = isInteractionLocked;
2678
3504
  btn.addEventListener("click", () => {
3505
+ if (this._isPlaidLinkInProgress) return;
2679
3506
  const accounts = this._accounts.filter((a) =>
2680
3507
  this._selectedAccounts.has(a.id),
2681
3508
  );
@@ -2718,7 +3545,10 @@ class BisonOperatorPayments extends HTMLElement {
2718
3545
  <p class="bop-loading-title">Unlinking Account${plural ? "s" : ""}</p>
2719
3546
  <p class="bop-loading-text">Removing ${count} account${plural ? "s" : ""} from your linked accounts</p>
2720
3547
  </div>
2721
- <div class="bop-loading-bar-wrap"><div class="bop-loading-bar"></div></div>
3548
+ <div class="bop-loading-indicator">
3549
+ <span class="bop-loading-spinner">${BOP_ICONS.loader}</span>
3550
+ <span class="bop-loading-indicator-text">Removing selected account${plural ? "s" : ""}...</span>
3551
+ </div>
2722
3552
  `;
2723
3553
  this._contentEl.appendChild(step);
2724
3554
  return;