diff --git a/app/javascript/controllers/plaid_controller.js b/app/javascript/controllers/plaid_controller.js index a12660f0a..e24031f77 100644 --- a/app/javascript/controllers/plaid_controller.js +++ b/app/javascript/controllers/plaid_controller.js @@ -10,11 +10,75 @@ export default class extends Controller { }; connect() { - this.open(); + this._connectionToken = (this._connectionToken ?? 0) + 1; + const connectionToken = this._connectionToken; + this.open(connectionToken).catch((error) => { + console.error("Failed to initialize Plaid Link", error); + }); } - open() { - const handler = Plaid.create({ + disconnect() { + this._handler?.destroy(); + this._handler = null; + this._connectionToken = (this._connectionToken ?? 0) + 1; + } + + waitForPlaid() { + if (typeof Plaid !== "undefined") { + return Promise.resolve(); + } + + return new Promise((resolve, reject) => { + let plaidScript = document.querySelector( + 'script[src*="link-initialize.js"]' + ); + + // Reject if the CDN request stalls without firing load or error + const timeoutId = window.setTimeout(() => { + if (plaidScript) plaidScript.dataset.plaidState = "error"; + reject(new Error("Timed out loading Plaid script")); + }, 10_000); + + // Remove previously failed script so we can retry with a fresh element + if (plaidScript?.dataset.plaidState === "error") { + plaidScript.remove(); + plaidScript = null; + } + + if (!plaidScript) { + plaidScript = document.createElement("script"); + plaidScript.src = "https://cdn.plaid.com/link/v2/stable/link-initialize.js"; + plaidScript.async = true; + plaidScript.dataset.plaidState = "loading"; + document.head.appendChild(plaidScript); + } + + plaidScript.addEventListener("load", () => { + window.clearTimeout(timeoutId); + plaidScript.dataset.plaidState = "loaded"; + resolve(); + }, { once: true }); + plaidScript.addEventListener("error", () => { + window.clearTimeout(timeoutId); + plaidScript.dataset.plaidState = "error"; + reject(new Error("Failed to load Plaid script")); + }, { once: true }); + + // Re-check after attaching listeners in case the script loaded between + // the initial typeof check and listener attachment (avoids a permanently + // pending promise on retry flows). + if (typeof Plaid !== "undefined") { + window.clearTimeout(timeoutId); + resolve(); + } + }); + } + + async open(connectionToken = this._connectionToken) { + await this.waitForPlaid(); + if (connectionToken !== this._connectionToken) return; + + this._handler = Plaid.create({ token: this.linkTokenValue, onSuccess: this.handleSuccess, onLoad: this.handleLoad, @@ -22,7 +86,7 @@ export default class extends Controller { onEvent: this.handleEvent, }); - handler.open(); + this._handler.open(); } handleSuccess = (public_token, metadata) => { diff --git a/app/models/plaid_item/syncer.rb b/app/models/plaid_item/syncer.rb index 74d66a58b..1f90f68c6 100644 --- a/app/models/plaid_item/syncer.rb +++ b/app/models/plaid_item/syncer.rb @@ -12,8 +12,16 @@ class PlaidItem::Syncer sync.update!(status_text: "Importing accounts from Plaid...") if sync.respond_to?(:status_text) plaid_item.import_latest_plaid_data - # Phase 2: Collect setup statistics + # Phase 2: Process the raw Plaid data and create/update internal domain objects + # This must happen before the linked/unlinked check because process_accounts + # is what creates Account and AccountProvider records for new PlaidAccounts. + sync.update!(status_text: "Processing accounts...") if sync.respond_to?(:status_text) + mark_import_started(sync) + plaid_item.process_accounts + + # Phase 3: Collect setup statistics (now that accounts have been processed) sync.update!(status_text: "Checking account configuration...") if sync.respond_to?(:status_text) + plaid_item.plaid_accounts.reload collect_setup_stats(sync, provider_accounts: plaid_item.plaid_accounts) # Check for unlinked accounts and update pending_account_setup flag @@ -25,14 +33,9 @@ class PlaidItem::Syncer plaid_item.update!(pending_account_setup: false) if plaid_item.respond_to?(:pending_account_setup=) end - # Phase 3: Process the raw Plaid data and updates internal domain objects + # Phase 4: Schedule balance calculations for linked accounts linked_accounts = plaid_item.plaid_accounts.select { |pa| pa.current_account.present? } if linked_accounts.any? - sync.update!(status_text: "Processing transactions...") if sync.respond_to?(:status_text) - mark_import_started(sync) - plaid_item.process_accounts - - # Phase 4: Schedule balance calculations sync.update!(status_text: "Calculating balances...") if sync.respond_to?(:status_text) plaid_item.schedule_account_syncs( parent_sync: sync, diff --git a/app/views/layouts/shared/_head.html.erb b/app/views/layouts/shared/_head.html.erb index b96ce2c61..d908a5d0a 100644 --- a/app/views/layouts/shared/_head.html.erb +++ b/app/views/layouts/shared/_head.html.erb @@ -8,7 +8,6 @@ <%= combobox_style_tag %> - <%= yield :plaid_link %> <%= javascript_importmap_tags %> <%= render "layouts/dark_mode_check" %> <%= turbo_refreshes_with method: :morph, scroll: :preserve %> diff --git a/app/views/plaid_items/_auto_link_opener.html.erb b/app/views/plaid_items/_auto_link_opener.html.erb index b25884954..7e9c76950 100644 --- a/app/views/plaid_items/_auto_link_opener.html.erb +++ b/app/views/plaid_items/_auto_link_opener.html.erb @@ -1,9 +1,5 @@ <%# locals: (link_token:, region:, item_id:, is_update: false) %> -<% content_for :plaid_link, flush: true do %> - <%= javascript_include_tag "https://cdn.plaid.com/link/v2/stable/link-initialize.js" %> -<% end %> - <%= tag.div data: { controller: "plaid", plaid_link_token_value: link_token,