From 6c84fc760e7f1a8b56e57f094a37617bab152c0c Mon Sep 17 00:00:00 2001 From: ghost <49853598+JSONbored@users.noreply.github.com> Date: Sun, 3 May 2026 02:56:31 -0600 Subject: [PATCH] fix(mercury): support named multiple API connections (#1627) * fix(mercury): support named multiple connections * fix(mercury): address multi-connection review feedback * fix(mercury): localize connection labels * fix(mercury): strip API tokens before provider calls * test(mercury): localize provider config assertions * fix(mercury): address multi-connection review * refactor(mercury): simplify connection selection failure --- app/controllers/mercury_items_controller.rb | 168 +++++++---- .../settings/providers_controller.rb | 2 +- app/models/family/mercury_connectable.rb | 2 +- app/models/mercury_item.rb | 2 +- app/models/provider/mercury_adapter.rb | 65 +++-- .../mercury_items/select_accounts.html.erb | 1 + .../select_existing_account.html.erb | 1 + .../providers/_mercury_panel.html.erb | 159 ++++++++--- config/locales/views/mercury_items/en.yml | 34 +++ .../mercury_items_controller_test.rb | 268 ++++++++++++++++++ test/models/mercury_account_test.rb | 28 ++ test/models/mercury_item_test.rb | 41 +++ test/models/provider/mercury_adapter_test.rb | 105 ++++++- 13 files changed, 747 insertions(+), 129 deletions(-) create mode 100644 test/controllers/mercury_items_controller_test.rb diff --git a/app/controllers/mercury_items_controller.rb b/app/controllers/mercury_items_controller.rb index 14e34ca0f..f971caec5 100644 --- a/app/controllers/mercury_items_controller.rb +++ b/app/controllers/mercury_items_controller.rb @@ -13,13 +13,19 @@ class MercuryItemsController < ApplicationController # Preload Mercury accounts in background (async, non-blocking) def preload_accounts begin - # Check if family has credentials - unless Current.family.has_mercury_credentials? + account_flow = mercury_item_account_flow_context + mercury_item = account_flow[:mercury_item] + unless mercury_item + render json: mercury_item_selection_error_payload(account_flow[:credentialed_items]) + return + end + + unless mercury_item.credentials_configured? render json: { success: false, error: "no_credentials", has_accounts: false } return end - cache_key = "mercury_accounts_#{Current.family.id}" + cache_key = mercury_accounts_cache_key(mercury_item) # Check if already cached cached_accounts = Rails.cache.read(cache_key) @@ -29,8 +35,7 @@ class MercuryItemsController < ApplicationController return end - # Fetch from API - mercury_provider = Provider::MercuryAdapter.build_provider(family: Current.family) + mercury_provider = mercury_item.mercury_provider unless mercury_provider.present? render json: { success: false, error: "no_api_token", has_accounts: false } @@ -58,28 +63,21 @@ class MercuryItemsController < ApplicationController # Fetch available accounts from Mercury API and show selection UI def select_accounts begin - # Check if family has Mercury credentials configured - unless Current.family.has_mercury_credentials? - if turbo_frame_request? - # Render setup modal for turbo frame requests - render partial: "mercury_items/setup_required", layout: false - else - # Redirect for regular requests - redirect_to settings_providers_path, - alert: t(".no_credentials_configured", - default: "Please configure your Mercury API token first in Provider Settings.") - end + account_flow = mercury_item_account_flow_context + @mercury_item = account_flow[:mercury_item] + unless @mercury_item + render_mercury_item_selection_failure(credentialed_items: account_flow[:credentialed_items]) return end - cache_key = "mercury_accounts_#{Current.family.id}" + cache_key = mercury_accounts_cache_key(@mercury_item) # Try to get cached accounts first @available_accounts = Rails.cache.read(cache_key) # If not cached, fetch from API if @available_accounts.nil? - mercury_provider = Provider::MercuryAdapter.build_provider(family: Current.family) + mercury_provider = @mercury_item.mercury_provider unless mercury_provider.present? redirect_to settings_providers_path, alert: t(".no_api_token", @@ -95,12 +93,8 @@ class MercuryItemsController < ApplicationController Rails.cache.write(cache_key, @available_accounts, expires_in: 5.minutes) end - # Filter out already linked accounts - mercury_item = Current.family.mercury_items.first - if mercury_item - linked_account_ids = mercury_item.mercury_accounts.joins(:account_provider).pluck(:account_id) - @available_accounts = @available_accounts.reject { |acc| linked_account_ids.include?(acc[:id].to_s) } - end + linked_account_ids = @mercury_item.mercury_accounts.joins(:account_provider).pluck(:account_id) + @available_accounts = @available_accounts.reject { |acc| linked_account_ids.include?(acc[:id].to_s) } @accountable_type = params[:accountable_type] || "Depository" @return_to = safe_return_to_path @@ -139,13 +133,16 @@ class MercuryItemsController < ApplicationController return end - # Create or find mercury_item for this family - mercury_item = Current.family.mercury_items.first_or_create!( - name: "Mercury Connection" - ) + account_flow = mercury_item_account_flow_context + mercury_item = account_flow[:mercury_item] + + unless mercury_item + redirect_to settings_providers_path, alert: t(".select_connection", default: "Choose a Mercury connection before linking accounts.") + return + end # Fetch account details from API - mercury_provider = Provider::MercuryAdapter.build_provider(family: Current.family) + mercury_provider = mercury_item.mercury_provider unless mercury_provider.present? redirect_to new_account_path, alert: t(".no_api_token") return @@ -259,29 +256,22 @@ class MercuryItemsController < ApplicationController return end - # Check if family has Mercury credentials configured - unless Current.family.has_mercury_credentials? - if turbo_frame_request? - # Render setup modal for turbo frame requests - render partial: "mercury_items/setup_required", layout: false - else - # Redirect for regular requests - redirect_to settings_providers_path, - alert: t(".no_credentials_configured", - default: "Please configure your Mercury API token first in Provider Settings.") - end + account_flow = mercury_item_account_flow_context + @mercury_item = account_flow[:mercury_item] + unless @mercury_item + render_mercury_item_selection_failure(credentialed_items: account_flow[:credentialed_items]) return end begin - cache_key = "mercury_accounts_#{Current.family.id}" + cache_key = mercury_accounts_cache_key(@mercury_item) # Try to get cached accounts first @available_accounts = Rails.cache.read(cache_key) # If not cached, fetch from API if @available_accounts.nil? - mercury_provider = Provider::MercuryAdapter.build_provider(family: Current.family) + mercury_provider = @mercury_item.mercury_provider unless mercury_provider.present? redirect_to settings_providers_path, alert: t(".no_api_token", @@ -302,12 +292,8 @@ class MercuryItemsController < ApplicationController return end - # Filter out already linked accounts - mercury_item = Current.family.mercury_items.first - if mercury_item - linked_account_ids = mercury_item.mercury_accounts.joins(:account_provider).pluck(:account_id) - @available_accounts = @available_accounts.reject { |acc| linked_account_ids.include?(acc[:id].to_s) } - end + linked_account_ids = @mercury_item.mercury_accounts.joins(:account_provider).pluck(:account_id) + @available_accounts = @available_accounts.reject { |acc| linked_account_ids.include?(acc[:id].to_s) } if @available_accounts.empty? redirect_to accounts_path, alert: t(".all_accounts_already_linked") @@ -343,6 +329,9 @@ class MercuryItemsController < ApplicationController return end + account_flow = mercury_item_account_flow_context + mercury_item = account_flow[:mercury_item] + @account = Current.family.accounts.find(account_id) # Check if account is already linked @@ -351,13 +340,13 @@ class MercuryItemsController < ApplicationController return end - # Create or find mercury_item for this family - mercury_item = Current.family.mercury_items.first_or_create!( - name: "Mercury Connection" - ) + unless mercury_item + redirect_to settings_providers_path, alert: t(".select_connection", default: "Choose a Mercury connection before linking accounts.") + return + end # Fetch account details from API - mercury_provider = Provider::MercuryAdapter.build_provider(family: Current.family) + mercury_provider = mercury_item.mercury_provider unless mercury_provider.present? redirect_to accounts_path, alert: t(".no_api_token") return @@ -423,7 +412,7 @@ class MercuryItemsController < ApplicationController if turbo_frame_request? flash.now[:notice] = t(".success") - @mercury_items = Current.family.mercury_items.ordered + @mercury_items = Current.family.mercury_items.active.ordered.includes(:syncs, :mercury_accounts) render turbo_stream: [ turbo_stream.replace( "mercury-providers-panel", @@ -454,10 +443,15 @@ class MercuryItemsController < ApplicationController end def update - if @mercury_item.update(mercury_item_params) + permitted_params = mercury_item_params + expire_accounts_cache = mercury_accounts_cache_sensitive_update?(permitted_params) + + if @mercury_item.update(permitted_params) + Rails.cache.delete(mercury_accounts_cache_key(@mercury_item)) if expire_accounts_cache + if turbo_frame_request? flash.now[:notice] = t(".success") - @mercury_items = Current.family.mercury_items.ordered + @mercury_items = Current.family.mercury_items.active.ordered.includes(:syncs, :mercury_accounts) render turbo_stream: [ turbo_stream.replace( "mercury-providers-panel", @@ -750,7 +744,67 @@ class MercuryItemsController < ApplicationController end def mercury_item_params - params.require(:mercury_item).permit(:name, :sync_start_date, :token, :base_url) + permitted = params.require(:mercury_item).permit(:name, :sync_start_date, :token, :base_url) + permitted.delete(:token) if @mercury_item&.persisted? && permitted[:token].blank? + permitted + end + + def mercury_items_with_credentials + Current.family.mercury_items.active.ordered.select(&:credentials_configured?) + end + + def mercury_item_account_flow_context + credentialed_items = mercury_items_with_credentials + mercury_item = nil + + if params[:mercury_item_id].present? + mercury_item = credentialed_items.find { |item| item.id.to_s == params[:mercury_item_id].to_s } + elsif credentialed_items.one? + mercury_item = credentialed_items.first + end + + { + mercury_item: mercury_item, + credentialed_items: credentialed_items + } + end + + def mercury_accounts_cache_key(mercury_item) + "mercury_accounts_#{Current.family.id}_#{mercury_item.id}" + end + + def mercury_accounts_cache_sensitive_update?(permitted_params) + permitted_params.key?(:token) || permitted_params.key?(:base_url) + end + + def mercury_item_selection_error_payload(credentialed_items) + if mercury_item_selection_required?(credentialed_items) + { + success: false, + error: "select_connection", + error_message: t(".select_connection", default: "Choose a Mercury connection before loading accounts."), + has_accounts: nil + } + else + { success: false, error: "no_credentials", has_accounts: false } + end + end + + def render_mercury_item_selection_failure(credentialed_items:) + if mercury_item_selection_required?(credentialed_items) + redirect_to settings_providers_path, + alert: t(".select_connection", default: "Choose a Mercury connection in Provider Settings.") + elsif turbo_frame_request? + render partial: "mercury_items/setup_required", layout: false + else + redirect_to settings_providers_path, + alert: t(".no_credentials_configured", + default: "Please configure your Mercury API token first in Provider Settings.") + end + end + + def mercury_item_selection_required?(credentialed_items) + credentialed_items.count > 1 && params[:mercury_item_id].blank? end # Sanitize return_to parameter to prevent XSS attacks diff --git a/app/controllers/settings/providers_controller.rb b/app/controllers/settings/providers_controller.rb index f95e94a2b..7c6428376 100644 --- a/app/controllers/settings/providers_controller.rb +++ b/app/controllers/settings/providers_controller.rb @@ -141,7 +141,7 @@ class Settings::ProvidersController < ApplicationController # Providers page only needs to know whether any Sophtron connections exist with valid credentials @sophtron_items = Current.family.sophtron_items.where.not(user_id: [ nil, "" ], access_key: [ nil, "" ]).ordered.select(:id) @coinstats_items = Current.family.coinstats_items.ordered # CoinStats panel needs account info for status display - @mercury_items = Current.family.mercury_items.ordered.select(:id) + @mercury_items = Current.family.mercury_items.active.ordered.includes(:syncs, :mercury_accounts) @coinbase_items = Current.family.coinbase_items.ordered # Coinbase panel needs name and sync info for status display @snaptrade_items = Current.family.snaptrade_items.includes(:snaptrade_accounts).ordered @indexa_capital_items = Current.family.indexa_capital_items.ordered.select(:id) diff --git a/app/models/family/mercury_connectable.rb b/app/models/family/mercury_connectable.rb index d0bf9fe27..31f3998e8 100644 --- a/app/models/family/mercury_connectable.rb +++ b/app/models/family/mercury_connectable.rb @@ -23,6 +23,6 @@ module Family::MercuryConnectable end def has_mercury_credentials? - mercury_items.where.not(token: nil).exists? + mercury_items.active.any?(&:credentials_configured?) end end diff --git a/app/models/mercury_item.rb b/app/models/mercury_item.rb index eb062b2b9..5869156fb 100644 --- a/app/models/mercury_item.rb +++ b/app/models/mercury_item.rb @@ -168,7 +168,7 @@ class MercuryItem < ApplicationRecord end def credentials_configured? - token.present? + token.to_s.strip.present? end def effective_base_url diff --git a/app/models/provider/mercury_adapter.rb b/app/models/provider/mercury_adapter.rb index e1a70b839..96f6666ba 100644 --- a/app/models/provider/mercury_adapter.rb +++ b/app/models/provider/mercury_adapter.rb @@ -15,23 +15,11 @@ class Provider::MercuryAdapter < Provider::Base def self.connection_configs(family:) return [] unless family.can_connect_mercury? - [ { - key: "mercury", - name: "Mercury", - description: "Connect to your bank via Mercury", - can_connect: true, - new_account_path: ->(accountable_type, return_to) { - Rails.application.routes.url_helpers.select_accounts_mercury_items_path( - accountable_type: accountable_type, - return_to: return_to - ) - }, - existing_account_path: ->(account_id) { - Rails.application.routes.url_helpers.select_existing_account_mercury_items_path( - account_id: account_id - ) - } - } ] + mercury_items = family.mercury_items.active.ordered.select(&:credentials_configured?) + + return [ connection_config_for(nil) ] if mercury_items.empty? + + mercury_items.map { |mercury_item| connection_config_for(mercury_item) } end def provider_name @@ -41,19 +29,54 @@ class Provider::MercuryAdapter < Provider::Base # Build a Mercury provider instance with family-specific credentials # @param family [Family] The family to get credentials for (required) # @return [Provider::Mercury, nil] Returns nil if credentials are not configured - def self.build_provider(family: nil) + def self.build_provider(family: nil, mercury_item_id: nil) return nil unless family.present? - # Get family-specific credentials - mercury_item = family.mercury_items.where.not(token: nil).first + mercury_item = resolve_mercury_item(family, mercury_item_id) return nil unless mercury_item&.credentials_configured? Provider::Mercury.new( - mercury_item.token, + mercury_item.token.to_s.strip, base_url: mercury_item.effective_base_url ) end + def self.connection_config_for(mercury_item) + path_params = ->(extra = {}) do + mercury_item.present? ? extra.merge(mercury_item_id: mercury_item.id) : extra + end + + { + key: mercury_item.present? ? "mercury_#{mercury_item.id}" : "mercury", + name: mercury_item.present? ? I18n.t("mercury_items.provider_connection.name", name: mercury_item.name) : I18n.t("mercury_items.provider_connection.default_name"), + description: mercury_item.present? ? I18n.t("mercury_items.provider_connection.description", name: mercury_item.name) : I18n.t("mercury_items.provider_connection.default_description"), + can_connect: true, + new_account_path: ->(accountable_type, return_to) { + Rails.application.routes.url_helpers.select_accounts_mercury_items_path( + path_params.call(accountable_type: accountable_type, return_to: return_to) + ) + }, + existing_account_path: ->(account_id) { + Rails.application.routes.url_helpers.select_existing_account_mercury_items_path( + path_params.call(account_id: account_id) + ) + } + } + end + private_class_method :connection_config_for + + def self.resolve_mercury_item(family, mercury_item_id) + if mercury_item_id.present? + item = family.mercury_items.active.find_by(id: mercury_item_id) + return item if item&.credentials_configured? + + return nil + end + + family.mercury_items.active.ordered.find(&:credentials_configured?) + end + private_class_method :resolve_mercury_item + def sync_path Rails.application.routes.url_helpers.sync_mercury_item_path(item) end diff --git a/app/views/mercury_items/select_accounts.html.erb b/app/views/mercury_items/select_accounts.html.erb index 7b9c15857..5e80783c6 100644 --- a/app/views/mercury_items/select_accounts.html.erb +++ b/app/views/mercury_items/select_accounts.html.erb @@ -10,6 +10,7 @@
<%= hidden_field_tag :authenticity_token, form_authenticity_token %> + <%= hidden_field_tag :mercury_item_id, @mercury_item.id %> <%= hidden_field_tag :accountable_type, @accountable_type %> <%= hidden_field_tag :return_to, @return_to %> diff --git a/app/views/mercury_items/select_existing_account.html.erb b/app/views/mercury_items/select_existing_account.html.erb index d1ab2c4be..e66266e61 100644 --- a/app/views/mercury_items/select_existing_account.html.erb +++ b/app/views/mercury_items/select_existing_account.html.erb @@ -10,6 +10,7 @@ <%= hidden_field_tag :authenticity_token, form_authenticity_token %> + <%= hidden_field_tag :mercury_item_id, @mercury_item.id %> <%= hidden_field_tag :account_id, @account.id %> <%= hidden_field_tag :return_to, @return_to %> diff --git a/app/views/settings/providers/_mercury_panel.html.erb b/app/views/settings/providers/_mercury_panel.html.erb index 660c279e9..43173fa2b 100644 --- a/app/views/settings/providers/_mercury_panel.html.erb +++ b/app/views/settings/providers/_mercury_panel.html.erb @@ -1,24 +1,19 @@ -
+
+ <% active_items = local_assigns[:mercury_items] || @mercury_items || Current.family.mercury_items.active.ordered %> + <% credentialed_items = active_items.select(&:credentials_configured?) %> +
-

Setup instructions:

+

<%= t("mercury_items.provider_panel.setup_title") %>

    -
  1. Visit Mercury and log in to your account
  2. -
  3. Go to Settings > Developer > API Tokens
  4. -
  5. Create a new API token with "Read Only" access
  6. -
  7. Important: Add your server's IP address to the token's whitelist
  8. -
  9. Copy the full token (including the secret-token: prefix) and paste it below
  10. -
  11. After a successful connection, go to the Accounts tab to set up new accounts
  12. +
  13. <%= t("mercury_items.provider_panel.instructions.sign_in_html", link: link_to("Mercury", "https://mercury.com", target: "_blank", rel: "noopener noreferrer", class: "link")) %>
  14. +
  15. <%= t("mercury_items.provider_panel.instructions.open_tokens") %>
  16. +
  17. <%= t("mercury_items.provider_panel.instructions.create_token") %>
  18. +
  19. <%= t("mercury_items.provider_panel.instructions.whitelist_ip_html") %>
  20. +
  21. <%= t("mercury_items.provider_panel.instructions.copy_token_html") %>
-

Field descriptions:

-
    -
  • API Token: Your full Mercury API token including the secret-token: prefix (required)
  • -
  • Base URL: Mercury API URL (optional, defaults to https://api.mercury.com/api/v1)
  • -
-

- Note: For sandbox testing, use https://api-sandbox.mercury.com/api/v1 as the Base URL. - Mercury requires IP whitelisting - make sure to add your IP in the Mercury dashboard. + <%= t("mercury_items.provider_panel.sandbox_note_html") %>

@@ -29,45 +24,119 @@
<% end %> - <% - # Get or initialize a mercury_item for this family - # - If family has an item WITH credentials, use it (for updates) - # - If family has an item WITHOUT credentials, use it (to add credentials) - # - If family has no items at all, create a new one - mercury_item = Current.family.mercury_items.first_or_initialize(name: "Mercury Connection") - is_new_record = mercury_item.new_record? - %> + <% if active_items.any? %> +
+ <% active_items.each do |item| %> +
+ +
+
+

<%= item.name.to_s.first.to_s.upcase %>

+
+
+

<%= item.name %>

+

<%= item.sync_status_summary %>

+
+
+
- <%= styled_form_with model: mercury_item, - url: is_new_record ? mercury_items_path : mercury_item_path(mercury_item), - scope: :mercury_item, - method: is_new_record ? :post : :patch, - data: { turbo: true }, - class: "space-y-3" do |form| %> - <%= form.text_field :token, - label: "Token", - placeholder: is_new_record ? "Paste token here" : "Enter new token to update", - type: :password %> +
+
+ <%= button_to sync_mercury_item_path(item), + method: :post, + class: "inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium text-secondary hover:text-primary border border-secondary rounded-lg hover:border-primary", + disabled: item.syncing? do %> + <%= icon "refresh-cw", size: "sm" %> + <%= t("mercury_items.provider_panel.sync") %> + <% end %> + <%= button_to mercury_item_path(item), + method: :delete, + class: "inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium text-destructive hover:bg-destructive/10 rounded-lg", + data: { turbo_confirm: t("mercury_items.provider_panel.disconnect_confirm", name: item.name) } do %> + <%= icon "trash-2", size: "sm" %> + <% end %> +
- <%= form.text_field :base_url, - label: "Base Url (Optional)", - placeholder: "https://api.mercury.com/api/v1 (default)", - value: mercury_item.base_url %> + <%= styled_form_with model: item, + url: mercury_item_path(item), + scope: :mercury_item, + method: :patch, + data: { turbo: true }, + class: "space-y-3" do |form| %> + <%= form.text_field :name, + label: t("mercury_items.provider_panel.connection_name_label"), + placeholder: t("mercury_items.provider_panel.connection_name_placeholder") %> -
- <%= form.submit is_new_record ? "Save Configuration" : "Update Configuration", - class: "inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium text-white bg-gray-900 hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-gray-900 focus:ring-offset-2 transition-colors" %> + <%= form.text_field :token, + label: t("mercury_items.provider_panel.token_label"), + placeholder: t("mercury_items.provider_panel.keep_token_placeholder"), + type: :password, + value: nil %> + + <%= form.text_field :base_url, + label: t("mercury_items.provider_panel.base_url_label"), + placeholder: t("mercury_items.provider_panel.base_url_placeholder"), + value: item.base_url %> + +
+ <%= render DS::Link.new( + text: t("mercury_items.provider_panel.setup_accounts"), + icon: "settings", + variant: "secondary", + href: setup_accounts_mercury_item_path(item), + frame: :modal + ) %> + <%= form.submit t("mercury_items.provider_panel.update_connection"), + class: "inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium text-inverse bg-inverse hover:bg-inverse-hover focus:outline-none focus:ring-2 focus:ring-primary transition-colors" %> +
+ <% end %> +
+
+ <% end %>
<% end %> - <% items = local_assigns[:mercury_items] || @mercury_items || Current.family.mercury_items.where.not(token: [nil, ""]) %> +
class="group bg-container p-4 shadow-border-xs rounded-xl"> + + <%= icon "plus" %> + <%= t("mercury_items.provider_panel.add_connection") %> + + + <% mercury_item = Current.family.mercury_items.build(name: t("mercury_items.provider_panel.default_connection_name")) %> + <%= styled_form_with model: mercury_item, + url: mercury_items_path, + scope: :mercury_item, + method: :post, + data: { turbo: true }, + class: "space-y-3 mt-4" do |form| %> + <%= form.text_field :name, + label: t("mercury_items.provider_panel.connection_name_label"), + placeholder: t("mercury_items.provider_panel.connection_name_placeholder") %> + + <%= form.text_field :token, + label: t("mercury_items.provider_panel.token_label"), + placeholder: t("mercury_items.provider_panel.token_placeholder"), + type: :password, + value: nil %> + + <%= form.text_field :base_url, + label: t("mercury_items.provider_panel.base_url_label"), + placeholder: t("mercury_items.provider_panel.base_url_placeholder") %> + +
+ <%= form.submit t("mercury_items.provider_panel.add_connection"), + class: "inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium text-inverse bg-inverse hover:bg-inverse-hover focus:outline-none focus:ring-2 focus:ring-primary transition-colors" %> +
+ <% end %> +
+
- <% if items&.any? %> + <% if credentialed_items.any? %>
-

Configured and ready to use. Visit the Accounts tab to manage and set up accounts.

+

<%= t("mercury_items.provider_panel.configured_html", accounts_link: link_to(t("mercury_items.provider_panel.accounts_link"), accounts_path, class: "link")) %>

<% else %>
-

Not configured

+

<%= t("mercury_items.provider_panel.not_configured") %>

<% end %>
diff --git a/config/locales/views/mercury_items/en.yml b/config/locales/views/mercury_items/en.yml index dc45ca889..f3916fae5 100644 --- a/config/locales/views/mercury_items/en.yml +++ b/config/locales/views/mercury_items/en.yml @@ -23,6 +23,7 @@ en: no_api_token: Mercury API token not found. Please configure it in Provider Settings. partial_invalid: "Successfully linked %{created_count} account(s), %{already_linked_count} were already linked, %{invalid_count} account(s) had invalid names" partial_success: "Successfully linked %{created_count} account(s). %{already_linked_count} account(s) were already linked: %{already_linked_names}" + select_connection: Choose a Mercury connection before linking accounts. success: one: "Successfully linked %{count} account" other: "Successfully linked %{count} accounts" @@ -42,6 +43,36 @@ en: syncing: Syncing... total: Total unlinked: Unlinked + provider_panel: + accounts_link: Accounts + add_connection: Add Mercury connection + base_url_label: Base URL (optional) + base_url_placeholder: https://api.mercury.com/api/v1 (default) + configured_html: "Configured and ready to use. Visit the %{accounts_link} tab to manage and set up accounts." + connection_name_label: Connection name + connection_name_placeholder: Business checking + default_connection_name: Mercury Connection + disconnect_confirm: "Disconnect %{name}?" + instructions: + copy_token_html: "Copy the full token (including the secret-token: prefix) and add it as a named connection below" + create_token: Create a new API token with "Read Only" access + open_tokens: Go to Settings > Developer > API Tokens + sign_in_html: "Visit %{link} and log in to the account you want to connect" + whitelist_ip_html: "Important: Add your server's IP address to the token's whitelist" + keep_token_placeholder: Leave blank to keep the current token + not_configured: Not configured + sandbox_note_html: "Use a separate named connection for each Mercury login/API token you want to sync. For sandbox testing, use https://api-sandbox.mercury.com/api/v1 as the Base URL. Mercury requires IP whitelisting - make sure to add your IP in the Mercury dashboard." + setup_accounts: Set up accounts + setup_title: "Setup instructions:" + sync: Sync + token_label: Token + token_placeholder: Paste token here + update_connection: Update connection + provider_connection: + default_description: Connect to your bank via Mercury + default_name: Mercury + description: "Connect using %{name}" + name: "Mercury - %{name}" select_accounts: accounts_selected: accounts selected api_error: "API error: %{message}" @@ -53,6 +84,7 @@ en: no_api_token: Mercury API token is not configured. Please configure it in Settings. no_credentials_configured: Please configure your Mercury API token first in Provider Settings. no_name_placeholder: "(No name)" + select_connection: Choose a Mercury connection in Provider Settings. title: Select Mercury Accounts select_existing_account: account_already_linked: This account is already linked to a provider @@ -67,6 +99,7 @@ en: no_api_token: Mercury API token is not configured. Please configure it in Settings. no_credentials_configured: Please configure your Mercury API token first in Provider Settings. no_name_placeholder: "(No name)" + select_connection: Choose a Mercury connection in Provider Settings. title: "Link %{account_name} with Mercury" link_existing_account: account_already_linked: This account is already linked to a provider @@ -76,6 +109,7 @@ en: mercury_account_not_found: Mercury account not found missing_parameters: Missing required parameters no_api_token: Mercury API token not found. Please configure it in Provider Settings. + select_connection: Choose a Mercury connection before linking accounts. success: "Successfully linked %{account_name} with Mercury" setup_accounts: account_type_label: "Account Type:" diff --git a/test/controllers/mercury_items_controller_test.rb b/test/controllers/mercury_items_controller_test.rb new file mode 100644 index 000000000..fbd7d3cc9 --- /dev/null +++ b/test/controllers/mercury_items_controller_test.rb @@ -0,0 +1,268 @@ +# frozen_string_literal: true + +require "test_helper" + +class MercuryItemsControllerTest < ActionDispatch::IntegrationTest + setup do + sign_in users(:family_admin) + Rails.cache.clear + SyncJob.stubs(:perform_later) + + @family = families(:dylan_family) + @existing_item = mercury_items(:one) + @second_item = MercuryItem.create!( + family: @family, + name: "Business Mercury", + token: "second_mercury_token", + base_url: "https://api.mercury.com/api/v1" + ) + end + + teardown do + Rails.cache.clear + end + + test "create adds a new mercury connection without overwriting existing credentials" do + existing_token = @existing_item.token + + assert_difference "MercuryItem.count", 1 do + post mercury_items_url, params: { + mercury_item: { + name: "Joint Mercury", + token: "joint_mercury_token", + base_url: "https://api.mercury.com/api/v1" + } + } + end + + assert_redirected_to accounts_path + assert_equal existing_token, @existing_item.reload.token + assert_equal "joint_mercury_token", @family.mercury_items.find_by!(name: "Joint Mercury").token + end + + test "update changes only the selected mercury connection" do + existing_token = @existing_item.token + + patch mercury_item_url(@second_item), params: { + mercury_item: { + name: "Renamed Business Mercury", + token: "updated_second_token", + base_url: "https://api-sandbox.mercury.com/api/v1" + } + } + + assert_redirected_to accounts_path + assert_equal existing_token, @existing_item.reload.token + assert_equal "Renamed Business Mercury", @second_item.reload.name + assert_equal "updated_second_token", @second_item.token + assert_equal "https://api-sandbox.mercury.com/api/v1", @second_item.base_url + end + + test "blank token update preserves the selected mercury token" do + original_token = @second_item.token + + patch mercury_item_url(@second_item), params: { + mercury_item: { + name: "Renamed Business Mercury", + token: "", + base_url: "https://api.mercury.com/api/v1" + } + } + + assert_redirected_to accounts_path + assert_equal "Renamed Business Mercury", @second_item.reload.name + assert_equal original_token, @second_item.token + end + + test "update expires selected mercury account cache when credentials change" do + Rails.cache.expects(:delete).with(mercury_cache_key(@existing_item)).never + Rails.cache.expects(:delete).with(mercury_cache_key(@second_item)).once + + patch mercury_item_url(@second_item), params: { + mercury_item: { + name: "Renamed Business Mercury", + token: "updated_second_token", + base_url: "https://api-sandbox.mercury.com/api/v1" + } + } + + assert_redirected_to accounts_path + end + + test "update does not expire selected mercury account cache for name-only changes" do + Rails.cache.expects(:delete).never + + patch mercury_item_url(@second_item), params: { + mercury_item: { + name: "Renamed Business Mercury" + } + } + + assert_redirected_to accounts_path + assert_equal "Renamed Business Mercury", @second_item.reload.name + end + + test "preload accounts uses selected mercury item cache key" do + Rails.cache.expects(:read).with(mercury_cache_key(@second_item)).returns(nil) + Rails.cache.expects(:write).with(mercury_cache_key(@second_item), mercury_accounts_payload, expires_in: 5.minutes) + + provider = mock("mercury_provider") + provider.expects(:get_accounts).returns(accounts: mercury_accounts_payload) + Provider::Mercury.expects(:new) + .with(@second_item.token, base_url: @second_item.effective_base_url) + .returns(provider) + + get preload_accounts_mercury_items_url, params: { mercury_item_id: @second_item.id }, as: :json + + assert_response :success + response = JSON.parse(@response.body) + assert_equal true, response["success"] + assert_equal true, response["has_accounts"] + end + + test "select accounts requires an explicit connection when multiple mercury items exist" do + get select_accounts_mercury_items_url, params: { accountable_type: "Depository" } + + assert_redirected_to settings_providers_path + assert_equal "Choose a Mercury connection in Provider Settings.", flash[:alert] + end + + test "select accounts renders the selected mercury item id" do + Rails.cache.expects(:read).with(mercury_cache_key(@second_item)).returns(nil) + Rails.cache.expects(:write).with(mercury_cache_key(@second_item), mercury_accounts_payload, expires_in: 5.minutes) + + provider = mock("mercury_provider") + provider.expects(:get_accounts).returns(accounts: mercury_accounts_payload) + Provider::Mercury.expects(:new) + .with(@second_item.token, base_url: @second_item.effective_base_url) + .returns(provider) + + get select_accounts_mercury_items_url, params: { + mercury_item_id: @second_item.id, + accountable_type: "Depository" + } + + assert_response :success + assert_includes @response.body, %(name="mercury_item_id") + assert_includes @response.body, %(value="#{@second_item.id}") + end + + test "select existing account renders the selected mercury item id" do + account = @family.accounts.create!( + name: "Manual Checking", + balance: 0, + currency: "USD", + accountable: Depository.new + ) + + Rails.cache.expects(:read).with(mercury_cache_key(@second_item)).returns(nil) + Rails.cache.expects(:write).with(mercury_cache_key(@second_item), mercury_accounts_payload, expires_in: 5.minutes) + + provider = mock("mercury_provider") + provider.expects(:get_accounts).returns(accounts: mercury_accounts_payload) + Provider::Mercury.expects(:new) + .with(@second_item.token, base_url: @second_item.effective_base_url) + .returns(provider) + + get select_existing_account_mercury_items_url, params: { + mercury_item_id: @second_item.id, + account_id: account.id + } + + assert_response :success + assert_includes @response.body, %(name="mercury_item_id") + assert_includes @response.body, %(value="#{@second_item.id}") + end + + test "link accounts uses selected mercury item and allows duplicate upstream ids across items" do + @existing_item.mercury_accounts.create!( + account_id: "shared_mercury_account", + name: "Shared Checking", + currency: "USD", + current_balance: 1000 + ) + + provider = mock("mercury_provider") + provider.expects(:get_accounts).returns(accounts: mercury_accounts_payload) + Provider::Mercury.expects(:new) + .with(@second_item.token, base_url: @second_item.effective_base_url) + .returns(provider) + + assert_difference -> { @second_item.mercury_accounts.where(account_id: "shared_mercury_account").count }, 1 do + assert_difference "AccountProvider.count", 1 do + post link_accounts_mercury_items_url, params: { + mercury_item_id: @second_item.id, + account_ids: [ "shared_mercury_account" ], + accountable_type: "Depository" + } + end + end + + assert_redirected_to accounts_path + assert_equal 1, @existing_item.mercury_accounts.where(account_id: "shared_mercury_account").count + end + + test "link accounts does not silently use the first connection when multiple items exist" do + assert_no_difference "MercuryAccount.count" do + assert_no_difference "Account.count" do + post link_accounts_mercury_items_url, params: { + account_ids: [ "shared_mercury_account" ], + accountable_type: "Depository" + } + end + end + + assert_redirected_to settings_providers_path + assert_equal "Choose a Mercury connection before linking accounts.", flash[:alert] + end + + test "link existing account does not silently use the first connection when multiple items exist" do + account = @family.accounts.create!( + name: "Manual Checking", + balance: 0, + currency: "USD", + accountable: Depository.new + ) + + assert_no_difference "MercuryAccount.count" do + assert_no_difference "AccountProvider.count" do + post link_existing_account_mercury_items_url, params: { + account_id: account.id, + mercury_account_id: "shared_mercury_account" + } + end + end + + assert_redirected_to settings_providers_path + assert_equal "Choose a Mercury connection before linking accounts.", flash[:alert] + end + + test "sync only queues a sync for the selected mercury item" do + assert_difference -> { Sync.where(syncable: @second_item).count }, 1 do + assert_no_difference -> { Sync.where(syncable: @existing_item).count } do + post sync_mercury_item_url(@second_item) + end + end + + assert_response :redirect + end + + private + + def mercury_accounts_payload + [ + { + id: "shared_mercury_account", + nickname: "Shared Checking", + name: "Shared Checking", + status: "active", + type: "checking", + currentBalance: 1000 + } + ] + end + + def mercury_cache_key(mercury_item) + "mercury_accounts_#{@family.id}_#{mercury_item.id}" + end +end diff --git a/test/models/mercury_account_test.rb b/test/models/mercury_account_test.rb index 801728bdf..92497581e 100644 --- a/test/models/mercury_account_test.rb +++ b/test/models/mercury_account_test.rb @@ -44,6 +44,34 @@ class MercuryAccountTest < ActiveSupport::TestCase end end + test "same account_id can be linked under different mercury_items in the same family" do + item_a_2 = MercuryItem.create!( + family: @family_a, + name: "Family A Second Mercury", + token: "token_a_2", + base_url: "https://api-sandbox.mercury.com/api/v1", + status: "good" + ) + + MercuryAccount.create!( + mercury_item: @item_a, + account_id: "shared_merc_acc_1", + name: "Checking", + currency: "USD", + current_balance: 5000 + ) + + assert_difference "MercuryAccount.count", 1 do + MercuryAccount.create!( + mercury_item: item_a_2, + account_id: "shared_merc_acc_1", + name: "Checking", + currency: "USD", + current_balance: 5000 + ) + end + end + test "same account_id cannot appear twice under the same mercury_item" do MercuryAccount.create!( mercury_item: @item_a, diff --git a/test/models/mercury_item_test.rb b/test/models/mercury_item_test.rb index 365697ec4..91d67c26d 100644 --- a/test/models/mercury_item_test.rb +++ b/test/models/mercury_item_test.rb @@ -22,6 +22,11 @@ class MercuryItemTest < ActiveSupport::TestCase assert_not @mercury_item.credentials_configured? end + test "credentials_configured returns false when token is whitespace" do + @mercury_item.token = " " + assert_not @mercury_item.credentials_configured? + end + test "effective_base_url returns custom url when set" do assert_equal "https://api-sandbox.mercury.com/api/v1", @mercury_item.effective_base_url end @@ -42,6 +47,42 @@ class MercuryItemTest < ActiveSupport::TestCase assert_nil @mercury_item.mercury_provider end + test "family credential check ignores blank and scheduled for deletion items" do + family = families(:empty) + blank_item = MercuryItem.create!( + family: family, + name: "Blank Mercury", + token: "temporary_token", + base_url: "https://api-sandbox.mercury.com/api/v1" + ) + blank_item.update_column(:token, "") + + whitespace_item = MercuryItem.create!( + family: family, + name: "Whitespace Mercury", + token: "temporary_token", + base_url: "https://api-sandbox.mercury.com/api/v1" + ) + whitespace_item.update_column(:token, " ") + + deleted_item = MercuryItem.create!( + family: family, + name: "Deleted Mercury", + token: "deleted_token", + base_url: "https://api-sandbox.mercury.com/api/v1", + scheduled_for_deletion: true + ) + + refute family.has_mercury_credentials? + + whitespace_item.update_column(:token, "configured_token") + assert family.has_mercury_credentials? + + whitespace_item.update_column(:token, " ") + deleted_item.update!(scheduled_for_deletion: false) + assert family.has_mercury_credentials? + end + test "syncer returns MercuryItem::Syncer instance" do syncer = @mercury_item.send(:syncer) assert_instance_of MercuryItem::Syncer, syncer diff --git a/test/models/provider/mercury_adapter_test.rb b/test/models/provider/mercury_adapter_test.rb index f89ff14ce..d60b2f177 100644 --- a/test/models/provider/mercury_adapter_test.rb +++ b/test/models/provider/mercury_adapter_test.rb @@ -1,3 +1,5 @@ +require "uri" + require "test_helper" class Provider::MercuryAdapterTest < ActiveSupport::TestCase @@ -9,17 +11,59 @@ class Provider::MercuryAdapterTest < ActiveSupport::TestCase assert_not_includes Provider::MercuryAdapter.supported_account_types, "Investment" end - test "returns connection configs for any family" do + test "returns fallback connection config when no credentials exist yet" do # Mercury is a per-family provider - any family can connect - family = families(:dylan_family) + family = families(:empty) configs = Provider::MercuryAdapter.connection_configs(family: family) assert_equal 1, configs.length assert_equal "mercury", configs.first[:key] - assert_equal "Mercury", configs.first[:name] + assert_equal I18n.t("mercury_items.provider_connection.default_name"), configs.first[:name] assert configs.first[:can_connect] end + test "returns one connection config per credentialed mercury item" do + family = families(:dylan_family) + first_item = mercury_items(:one) + second_item = MercuryItem.create!( + family: family, + name: "Business Mercury", + token: "second_mercury_token", + base_url: "https://api.mercury.com/api/v1" + ) + + configs = Provider::MercuryAdapter.connection_configs(family: family) + + assert_equal 2, configs.length + assert_equal [ "mercury_#{second_item.id}", "mercury_#{first_item.id}" ], configs.map { |config| config[:key] } + assert_equal [ + I18n.t("mercury_items.provider_connection.name", name: second_item.name), + I18n.t("mercury_items.provider_connection.name", name: first_item.name) + ], configs.map { |config| config[:name] } + + new_account_uri = URI.parse(configs.first[:new_account_path].call("Depository", "/accounts")) + assert_equal "/mercury_items/select_accounts", new_account_uri.path + assert_includes new_account_uri.query, "mercury_item_id=#{second_item.id}" + + existing_account_uri = URI.parse(configs.first[:existing_account_path].call(accounts(:depository).id)) + assert_equal "/mercury_items/select_existing_account", existing_account_uri.path + assert_includes existing_account_uri.query, "mercury_item_id=#{second_item.id}" + end + + test "connection configs ignore items with whitespace-only tokens" do + family = families(:dylan_family) + MercuryItem.create!( + family: family, + name: "Blank Mercury", + token: "temporary_token", + base_url: "https://api.mercury.com/api/v1" + ).update_column(:token, " ") + + configs = Provider::MercuryAdapter.connection_configs(family: family) + + assert_equal [ "mercury_#{mercury_items(:one).id}" ], configs.map { |config| config[:key] } + end + test "build_provider returns nil when family is nil" do assert_nil Provider::MercuryAdapter.build_provider(family: nil) end @@ -35,4 +79,59 @@ class Provider::MercuryAdapterTest < ActiveSupport::TestCase assert_instance_of Provider::Mercury, provider end + + test "build_provider uses explicit mercury item credentials" do + family = families(:dylan_family) + second_item = MercuryItem.create!( + family: family, + name: "Business Mercury", + token: "second_mercury_token", + base_url: "https://api.mercury.com/api/v1" + ) + + provider = Provider::MercuryAdapter.build_provider(family: family, mercury_item_id: second_item.id) + + assert_instance_of Provider::Mercury, provider + assert_equal "second_mercury_token", provider.token + assert_equal "https://api.mercury.com/api/v1", provider.base_url + end + + test "build_provider strips surrounding token whitespace" do + family = families(:dylan_family) + second_item = MercuryItem.create!( + family: family, + name: "Business Mercury", + token: " second_mercury_token \n", + base_url: "https://api.mercury.com/api/v1" + ) + + provider = Provider::MercuryAdapter.build_provider(family: family, mercury_item_id: second_item.id) + + assert_equal "second_mercury_token", provider.token + end + + test "build_provider refuses mercury items outside the family" do + family = families(:dylan_family) + other_item = MercuryItem.create!( + family: families(:empty), + name: "Other Mercury", + token: "other_mercury_token", + base_url: "https://api.mercury.com/api/v1" + ) + + assert_nil Provider::MercuryAdapter.build_provider(family: family, mercury_item_id: other_item.id) + end + + test "build_provider refuses explicit mercury item without usable credentials" do + family = families(:dylan_family) + blank_item = MercuryItem.create!( + family: family, + name: "Blank Mercury", + token: "temporary_token", + base_url: "https://api.mercury.com/api/v1" + ) + blank_item.update_column(:token, " ") + + assert_nil Provider::MercuryAdapter.build_provider(family: family, mercury_item_id: blank_item.id) + end end