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
This commit is contained in:
ghost
2026-05-03 02:56:31 -06:00
committed by GitHub
parent e677d382c2
commit 6c84fc760e
13 changed files with 747 additions and 129 deletions

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -168,7 +168,7 @@ class MercuryItem < ApplicationRecord
end
def credentials_configured?
token.present?
token.to_s.strip.present?
end
def effective_base_url

View File

@@ -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

View File

@@ -10,6 +10,7 @@
<form action="<%= link_accounts_mercury_items_path %>" method="post" class="space-y-4" data-turbo-frame="_top">
<%= 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 %>

View File

@@ -10,6 +10,7 @@
<form action="<%= link_existing_account_mercury_items_path %>" method="post" class="space-y-4" data-turbo-frame="_top">
<%= 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 %>

View File

@@ -1,24 +1,19 @@
<div class="space-y-4">
<div id="mercury-providers-panel" class="space-y-4">
<% active_items = local_assigns[:mercury_items] || @mercury_items || Current.family.mercury_items.active.ordered %>
<% credentialed_items = active_items.select(&:credentials_configured?) %>
<div class="prose prose-sm text-secondary">
<p class="text-primary font-medium">Setup instructions:</p>
<p class="text-primary font-medium"><%= t("mercury_items.provider_panel.setup_title") %></p>
<ol>
<li>Visit <a href="https://mercury.com" target="_blank" rel="noopener noreferrer" class="link">Mercury</a> and log in to your account</li>
<li>Go to Settings > Developer > API Tokens</li>
<li>Create a new API token with "Read Only" access</li>
<li><strong>Important:</strong> Add your server's IP address to the token's whitelist</li>
<li>Copy the <strong>full token</strong> (including the <code>secret-token:</code> prefix) and paste it below</li>
<li>After a successful connection, go to the Accounts tab to set up new accounts</li>
<li><%= t("mercury_items.provider_panel.instructions.sign_in_html", link: link_to("Mercury", "https://mercury.com", target: "_blank", rel: "noopener noreferrer", class: "link")) %></li>
<li><%= t("mercury_items.provider_panel.instructions.open_tokens") %></li>
<li><%= t("mercury_items.provider_panel.instructions.create_token") %></li>
<li><%= t("mercury_items.provider_panel.instructions.whitelist_ip_html") %></li>
<li><%= t("mercury_items.provider_panel.instructions.copy_token_html") %></li>
</ol>
<p class="text-primary font-medium">Field descriptions:</p>
<ul>
<li><strong>API Token:</strong> Your full Mercury API token including the <code>secret-token:</code> prefix (required)</li>
<li><strong>Base URL:</strong> Mercury API URL (optional, defaults to https://api.mercury.com/api/v1)</li>
</ul>
<p class="text-sm text-subdued mt-2">
<strong>Note:</strong> For sandbox testing, use <code>https://api-sandbox.mercury.com/api/v1</code> 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") %>
</p>
</div>
@@ -29,45 +24,119 @@
</div>
<% 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? %>
<div class="space-y-3">
<% active_items.each do |item| %>
<details class="group bg-container p-4 shadow-border-xs rounded-xl">
<summary class="flex items-center justify-between gap-2">
<div class="flex items-center gap-3">
<div class="flex items-center justify-center h-8 w-8 bg-blue-600/10 rounded-full">
<p class="text-blue-600 text-xs font-medium"><%= item.name.to_s.first.to_s.upcase %></p>
</div>
<div>
<p class="font-medium text-primary"><%= item.name %></p>
<p class="text-xs text-secondary"><%= item.sync_status_summary %></p>
</div>
</div>
</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 %>
<div class="mt-4 space-y-4">
<div class="flex items-center gap-2">
<%= 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 %>
</div>
<%= 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") %>
<div class="flex justify-end">
<%= 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 %>
<div class="flex flex-wrap justify-end gap-2">
<%= 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" %>
</div>
<% end %>
</div>
</details>
<% end %>
</div>
<% end %>
<% items = local_assigns[:mercury_items] || @mercury_items || Current.family.mercury_items.where.not(token: [nil, ""]) %>
<details <%= "open" unless active_items.any? %> class="group bg-container p-4 shadow-border-xs rounded-xl">
<summary class="flex items-center gap-2 text-sm font-medium text-primary">
<%= icon "plus" %>
<%= t("mercury_items.provider_panel.add_connection") %>
</summary>
<% 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") %>
<div class="flex justify-end">
<%= 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" %>
</div>
<% end %>
</details>
<div class="flex items-center gap-2">
<% if items&.any? %>
<% if credentialed_items.any? %>
<div class="w-2 h-2 bg-success rounded-full"></div>
<p class="text-sm text-secondary">Configured and ready to use. Visit the <a href="<%= accounts_path %>" class="link">Accounts</a> tab to manage and set up accounts.</p>
<p class="text-sm text-secondary"><%= t("mercury_items.provider_panel.configured_html", accounts_link: link_to(t("mercury_items.provider_panel.accounts_link"), accounts_path, class: "link")) %></p>
<% else %>
<div class="w-2 h-2 bg-gray-400 rounded-full"></div>
<p class="text-sm text-secondary">Not configured</p>
<p class="text-sm text-secondary"><%= t("mercury_items.provider_panel.not_configured") %></p>
<% end %>
</div>
</div>

View File

@@ -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 <strong>full token</strong> (including the <code>secret-token:</code> 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: "<strong>Important:</strong> 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 <code>https://api-sandbox.mercury.com/api/v1</code> 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:"

View File

@@ -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

View File

@@ -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,

View File

@@ -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

View File

@@ -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