diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index 1ae933a56..16f5f2f21 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -11,6 +11,7 @@ class AccountsController < ApplicationController @lunchflow_items = family.lunchflow_items.ordered.includes(:syncs, :lunchflow_accounts) @enable_banking_items = family.enable_banking_items.ordered.includes(:syncs) @coinstats_items = family.coinstats_items.ordered.includes(:coinstats_accounts, :accounts, :syncs) + @mercury_items = family.mercury_items.ordered.includes(:syncs, :mercury_accounts) @coinbase_items = family.coinbase_items.ordered.includes(:coinbase_accounts, :accounts, :syncs) # Build sync stats maps for all providers @@ -242,6 +243,13 @@ class AccountsController < ApplicationController @coinstats_sync_stats_map[item.id] = latest_sync&.sync_stats || {} end + # Mercury sync stats + @mercury_sync_stats_map = {} + @mercury_items.each do |item| + latest_sync = item.syncs.ordered.first + @mercury_sync_stats_map[item.id] = latest_sync&.sync_stats || {} + end + # Coinbase sync stats @coinbase_sync_stats_map = {} @coinbase_unlinked_count_map = {} diff --git a/app/controllers/mercury_items_controller.rb b/app/controllers/mercury_items_controller.rb new file mode 100644 index 000000000..0c72e3cd6 --- /dev/null +++ b/app/controllers/mercury_items_controller.rb @@ -0,0 +1,779 @@ +class MercuryItemsController < ApplicationController + before_action :set_mercury_item, only: [ :show, :edit, :update, :destroy, :sync, :setup_accounts, :complete_account_setup ] + + def index + @mercury_items = Current.family.mercury_items.active.ordered + render layout: "settings" + end + + def show + end + + # Preload Mercury accounts in background (async, non-blocking) + def preload_accounts + begin + # Check if family has credentials + unless Current.family.has_mercury_credentials? + render json: { success: false, error: "no_credentials", has_accounts: false } + return + end + + cache_key = "mercury_accounts_#{Current.family.id}" + + # Check if already cached + cached_accounts = Rails.cache.read(cache_key) + + if cached_accounts.present? + render json: { success: true, has_accounts: cached_accounts.any?, cached: true } + return + end + + # Fetch from API + mercury_provider = Provider::MercuryAdapter.build_provider(family: Current.family) + + unless mercury_provider.present? + render json: { success: false, error: "no_api_token", has_accounts: false } + return + end + + accounts_data = mercury_provider.get_accounts + available_accounts = accounts_data[:accounts] || [] + + # Cache the accounts for 5 minutes + Rails.cache.write(cache_key, available_accounts, expires_in: 5.minutes) + + render json: { success: true, has_accounts: available_accounts.any?, cached: false } + rescue Provider::Mercury::MercuryError => e + Rails.logger.error("Mercury preload error: #{e.message}") + # API error (bad token, network issue, etc) - keep button visible, show error when clicked + render json: { success: false, error: "api_error", error_message: e.message, has_accounts: nil } + rescue StandardError => e + Rails.logger.error("Unexpected error preloading Mercury accounts: #{e.class}: #{e.message}") + # Unexpected error - keep button visible, show error when clicked + render json: { success: false, error: "unexpected_error", error_message: e.message, has_accounts: nil } + end + end + + # 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 + return + end + + cache_key = "mercury_accounts_#{Current.family.id}" + + # 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) + + unless mercury_provider.present? + redirect_to settings_providers_path, alert: t(".no_api_token", + default: "Mercury API token not found. Please configure it in Provider Settings.") + return + end + + accounts_data = mercury_provider.get_accounts + + @available_accounts = accounts_data[:accounts] || [] + + # Cache the accounts for 5 minutes + 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 + + @accountable_type = params[:accountable_type] || "Depository" + @return_to = safe_return_to_path + + if @available_accounts.empty? + redirect_to new_account_path, alert: t(".no_accounts_found") + return + end + + render layout: false + rescue Provider::Mercury::MercuryError => e + Rails.logger.error("Mercury API error in select_accounts: #{e.message}") + @error_message = e.message + @return_path = safe_return_to_path + render partial: "mercury_items/api_error", + locals: { error_message: @error_message, return_path: @return_path }, + layout: false + rescue StandardError => e + Rails.logger.error("Unexpected error in select_accounts: #{e.class}: #{e.message}") + @error_message = "An unexpected error occurred. Please try again later." + @return_path = safe_return_to_path + render partial: "mercury_items/api_error", + locals: { error_message: @error_message, return_path: @return_path }, + layout: false + end + end + + # Create accounts from selected Mercury accounts + def link_accounts + selected_account_ids = params[:account_ids] || [] + accountable_type = params[:accountable_type] || "Depository" + return_to = safe_return_to_path + + if selected_account_ids.empty? + redirect_to new_account_path, alert: t(".no_accounts_selected") + return + end + + # Create or find mercury_item for this family + mercury_item = Current.family.mercury_items.first_or_create!( + name: "Mercury Connection" + ) + + # Fetch account details from API + mercury_provider = Provider::MercuryAdapter.build_provider(family: Current.family) + unless mercury_provider.present? + redirect_to new_account_path, alert: t(".no_api_token") + return + end + + accounts_data = mercury_provider.get_accounts + + created_accounts = [] + already_linked_accounts = [] + invalid_accounts = [] + + selected_account_ids.each do |account_id| + # Find the account data from API response + account_data = accounts_data[:accounts].find { |acc| acc[:id].to_s == account_id.to_s } + next unless account_data + + # Get account name + account_name = account_data[:nickname].presence || account_data[:name].presence || account_data[:legalBusinessName].presence + + # Validate account name is not blank (required by Account model) + if account_name.blank? + invalid_accounts << account_id + Rails.logger.warn "MercuryItemsController - Skipping account #{account_id} with blank name" + next + end + + # Create or find mercury_account + mercury_account = mercury_item.mercury_accounts.find_or_initialize_by( + account_id: account_id.to_s + ) + mercury_account.upsert_mercury_snapshot!(account_data) + mercury_account.save! + + # Check if this mercury_account is already linked + if mercury_account.account_provider.present? + already_linked_accounts << account_name + next + end + + # Create the internal Account with proper balance initialization + account = Account.create_and_sync( + { + family: Current.family, + name: account_name, + balance: 0, # Initial balance will be set during sync + currency: "USD", # Mercury is US-only + accountable_type: accountable_type, + accountable_attributes: {} + }, + skip_initial_sync: true + ) + + # Link account to mercury_account via account_providers join table + AccountProvider.create!( + account: account, + provider: mercury_account + ) + + created_accounts << account + end + + # Trigger sync to fetch transactions if any accounts were created + mercury_item.sync_later if created_accounts.any? + + # Build appropriate flash message + if invalid_accounts.any? && created_accounts.empty? && already_linked_accounts.empty? + # All selected accounts were invalid (blank names) + redirect_to new_account_path, alert: t(".invalid_account_names", count: invalid_accounts.count) + elsif invalid_accounts.any? && (created_accounts.any? || already_linked_accounts.any?) + # Some accounts were created/already linked, but some had invalid names + redirect_to return_to || accounts_path, + alert: t(".partial_invalid", + created_count: created_accounts.count, + already_linked_count: already_linked_accounts.count, + invalid_count: invalid_accounts.count) + elsif created_accounts.any? && already_linked_accounts.any? + redirect_to return_to || accounts_path, + notice: t(".partial_success", + created_count: created_accounts.count, + already_linked_count: already_linked_accounts.count, + already_linked_names: already_linked_accounts.join(", ")) + elsif created_accounts.any? + redirect_to return_to || accounts_path, + notice: t(".success", count: created_accounts.count) + elsif already_linked_accounts.any? + redirect_to return_to || accounts_path, + alert: t(".all_already_linked", + count: already_linked_accounts.count, + names: already_linked_accounts.join(", ")) + else + redirect_to new_account_path, alert: t(".link_failed") + end + rescue Provider::Mercury::MercuryError => e + redirect_to new_account_path, alert: t(".api_error", message: e.message) + end + + # Fetch available Mercury accounts to link with an existing account + def select_existing_account + account_id = params[:account_id] + + unless account_id.present? + redirect_to accounts_path, alert: t(".no_account_specified") + return + end + + @account = Current.family.accounts.find(account_id) + + # Check if account is already linked + if @account.account_providers.exists? + redirect_to accounts_path, alert: t(".account_already_linked") + 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 + return + end + + begin + cache_key = "mercury_accounts_#{Current.family.id}" + + # 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) + + unless mercury_provider.present? + redirect_to settings_providers_path, alert: t(".no_api_token", + default: "Mercury API token not found. Please configure it in Provider Settings.") + return + end + + accounts_data = mercury_provider.get_accounts + + @available_accounts = accounts_data[:accounts] || [] + + # Cache the accounts for 5 minutes + Rails.cache.write(cache_key, @available_accounts, expires_in: 5.minutes) + end + + if @available_accounts.empty? + redirect_to accounts_path, alert: t(".no_accounts_found") + 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 + + if @available_accounts.empty? + redirect_to accounts_path, alert: t(".all_accounts_already_linked") + return + end + + @return_to = safe_return_to_path + + render layout: false + rescue Provider::Mercury::MercuryError => e + Rails.logger.error("Mercury API error in select_existing_account: #{e.message}") + @error_message = e.message + render partial: "mercury_items/api_error", + locals: { error_message: @error_message, return_path: accounts_path }, + layout: false + rescue StandardError => e + Rails.logger.error("Unexpected error in select_existing_account: #{e.class}: #{e.message}") + @error_message = "An unexpected error occurred. Please try again later." + render partial: "mercury_items/api_error", + locals: { error_message: @error_message, return_path: accounts_path }, + layout: false + end + end + + # Link a selected Mercury account to an existing account + def link_existing_account + account_id = params[:account_id] + mercury_account_id = params[:mercury_account_id] + return_to = safe_return_to_path + + unless account_id.present? && mercury_account_id.present? + redirect_to accounts_path, alert: t(".missing_parameters") + return + end + + @account = Current.family.accounts.find(account_id) + + # Check if account is already linked + if @account.account_providers.exists? + redirect_to accounts_path, alert: t(".account_already_linked") + return + end + + # Create or find mercury_item for this family + mercury_item = Current.family.mercury_items.first_or_create!( + name: "Mercury Connection" + ) + + # Fetch account details from API + mercury_provider = Provider::MercuryAdapter.build_provider(family: Current.family) + unless mercury_provider.present? + redirect_to accounts_path, alert: t(".no_api_token") + return + end + + accounts_data = mercury_provider.get_accounts + + # Find the selected Mercury account data + account_data = accounts_data[:accounts].find { |acc| acc[:id].to_s == mercury_account_id.to_s } + unless account_data + redirect_to accounts_path, alert: t(".mercury_account_not_found") + return + end + + # Get account name + account_name = account_data[:nickname].presence || account_data[:name].presence || account_data[:legalBusinessName].presence + + # Validate account name is not blank (required by Account model) + if account_name.blank? + redirect_to accounts_path, alert: t(".invalid_account_name") + return + end + + # Create or find mercury_account + mercury_account = mercury_item.mercury_accounts.find_or_initialize_by( + account_id: mercury_account_id.to_s + ) + mercury_account.upsert_mercury_snapshot!(account_data) + mercury_account.save! + + # Check if this mercury_account is already linked to another account + if mercury_account.account_provider.present? + redirect_to accounts_path, alert: t(".mercury_account_already_linked") + return + end + + # Link account to mercury_account via account_providers join table + AccountProvider.create!( + account: @account, + provider: mercury_account + ) + + # Trigger sync to fetch transactions + mercury_item.sync_later + + redirect_to return_to || accounts_path, + notice: t(".success", account_name: @account.name) + rescue Provider::Mercury::MercuryError => e + redirect_to accounts_path, alert: t(".api_error", message: e.message) + end + + def new + @mercury_item = Current.family.mercury_items.build + end + + def create + @mercury_item = Current.family.mercury_items.build(mercury_item_params) + @mercury_item.name ||= "Mercury Connection" + + if @mercury_item.save + # Trigger initial sync to fetch accounts + @mercury_item.sync_later + + if turbo_frame_request? + flash.now[:notice] = t(".success") + @mercury_items = Current.family.mercury_items.ordered + render turbo_stream: [ + turbo_stream.replace( + "mercury-providers-panel", + partial: "settings/providers/mercury_panel", + locals: { mercury_items: @mercury_items } + ), + *flash_notification_stream_items + ] + else + redirect_to accounts_path, notice: t(".success"), status: :see_other + end + else + @error_message = @mercury_item.errors.full_messages.join(", ") + + if turbo_frame_request? + render turbo_stream: turbo_stream.replace( + "mercury-providers-panel", + partial: "settings/providers/mercury_panel", + locals: { error_message: @error_message } + ), status: :unprocessable_entity + else + render :new, status: :unprocessable_entity + end + end + end + + def edit + end + + def update + if @mercury_item.update(mercury_item_params) + if turbo_frame_request? + flash.now[:notice] = t(".success") + @mercury_items = Current.family.mercury_items.ordered + render turbo_stream: [ + turbo_stream.replace( + "mercury-providers-panel", + partial: "settings/providers/mercury_panel", + locals: { mercury_items: @mercury_items } + ), + *flash_notification_stream_items + ] + else + redirect_to accounts_path, notice: t(".success"), status: :see_other + end + else + @error_message = @mercury_item.errors.full_messages.join(", ") + + if turbo_frame_request? + render turbo_stream: turbo_stream.replace( + "mercury-providers-panel", + partial: "settings/providers/mercury_panel", + locals: { error_message: @error_message } + ), status: :unprocessable_entity + else + render :edit, status: :unprocessable_entity + end + end + end + + def destroy + # Ensure we detach provider links before scheduling deletion + begin + @mercury_item.unlink_all!(dry_run: false) + rescue => e + Rails.logger.warn("Mercury unlink during destroy failed: #{e.class} - #{e.message}") + end + @mercury_item.destroy_later + redirect_to accounts_path, notice: t(".success") + end + + def sync + unless @mercury_item.syncing? + @mercury_item.sync_later + end + + respond_to do |format| + format.html { redirect_back_or_to accounts_path } + format.json { head :ok } + end + end + + # Show unlinked Mercury accounts for setup + def setup_accounts + # First, ensure we have the latest accounts from the API + @api_error = fetch_mercury_accounts_from_api + + # Get Mercury accounts that are not linked (no AccountProvider) + @mercury_accounts = @mercury_item.mercury_accounts + .left_joins(:account_provider) + .where(account_providers: { id: nil }) + + # Get supported account types from the adapter + supported_types = Provider::MercuryAdapter.supported_account_types + + # Map of account type keys to their internal values + account_type_keys = { + "depository" => "Depository", + "credit_card" => "CreditCard", + "investment" => "Investment", + "loan" => "Loan", + "other_asset" => "OtherAsset" + } + + # Build account type options using i18n, filtering to supported types + all_account_type_options = account_type_keys.filter_map do |key, type| + next unless supported_types.include?(type) + [ t(".account_types.#{key}"), type ] + end + + # Add "Skip" option at the beginning + @account_type_options = [ [ t(".account_types.skip"), "skip" ] ] + all_account_type_options + + # Helper to translate subtype options + translate_subtypes = ->(type_key, subtypes_hash) { + subtypes_hash.keys.map { |k| [ t(".subtypes.#{type_key}.#{k}"), k ] } + } + + # Subtype options for each account type (only include supported types) + all_subtype_options = { + "Depository" => { + label: t(".subtype_labels.depository"), + options: translate_subtypes.call("depository", Depository::SUBTYPES) + }, + "CreditCard" => { + label: t(".subtype_labels.credit_card"), + options: [], + message: t(".subtype_messages.credit_card") + }, + "Investment" => { + label: t(".subtype_labels.investment"), + options: translate_subtypes.call("investment", Investment::SUBTYPES) + }, + "Loan" => { + label: t(".subtype_labels.loan"), + options: translate_subtypes.call("loan", Loan::SUBTYPES) + }, + "OtherAsset" => { + label: t(".subtype_labels.other_asset").presence, + options: [], + message: t(".subtype_messages.other_asset") + } + } + + @subtype_options = all_subtype_options.slice(*supported_types) + end + + def complete_account_setup + account_types = params[:account_types] || {} + account_subtypes = params[:account_subtypes] || {} + + # Valid account types for this provider + valid_types = Provider::MercuryAdapter.supported_account_types + + created_accounts = [] + skipped_count = 0 + + begin + ActiveRecord::Base.transaction do + account_types.each do |mercury_account_id, selected_type| + # Skip accounts marked as "skip" + if selected_type == "skip" || selected_type.blank? + skipped_count += 1 + next + end + + # Validate account type is supported + unless valid_types.include?(selected_type) + Rails.logger.warn("Invalid account type '#{selected_type}' submitted for Mercury account #{mercury_account_id}") + next + end + + # Find account - scoped to this item to prevent cross-item manipulation + mercury_account = @mercury_item.mercury_accounts.find_by(id: mercury_account_id) + unless mercury_account + Rails.logger.warn("Mercury account #{mercury_account_id} not found for item #{@mercury_item.id}") + next + end + + # Skip if already linked (race condition protection) + if mercury_account.account_provider.present? + Rails.logger.info("Mercury account #{mercury_account_id} already linked, skipping") + next + end + + selected_subtype = account_subtypes[mercury_account_id] + + # Default subtype for CreditCard since it only has one option + selected_subtype = "credit_card" if selected_type == "CreditCard" && selected_subtype.blank? + + # Create account with user-selected type and subtype (raises on failure) + # Skip initial sync - provider sync will handle balance creation with correct currency + account = Account.create_and_sync( + { + family: Current.family, + name: mercury_account.name, + balance: mercury_account.current_balance || 0, + currency: "USD", # Mercury is US-only + accountable_type: selected_type, + accountable_attributes: selected_subtype.present? ? { subtype: selected_subtype } : {} + }, + skip_initial_sync: true + ) + + # Link account to mercury_account via account_providers join table (raises on failure) + AccountProvider.create!( + account: account, + provider: mercury_account + ) + + created_accounts << account + end + end + rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved => e + Rails.logger.error("Mercury account setup failed: #{e.class} - #{e.message}") + Rails.logger.error(e.backtrace.first(10).join("\n")) + flash[:alert] = t(".creation_failed", error: e.message) + redirect_to accounts_path, status: :see_other + return + rescue StandardError => e + Rails.logger.error("Mercury account setup failed unexpectedly: #{e.class} - #{e.message}") + Rails.logger.error(e.backtrace.first(10).join("\n")) + flash[:alert] = t(".creation_failed", error: "An unexpected error occurred") + redirect_to accounts_path, status: :see_other + return + end + + # Trigger a sync to process transactions + @mercury_item.sync_later if created_accounts.any? + + # Set appropriate flash message + if created_accounts.any? + flash[:notice] = t(".success", count: created_accounts.count) + elsif skipped_count > 0 + flash[:notice] = t(".all_skipped") + else + flash[:notice] = t(".no_accounts") + end + + if turbo_frame_request? + # Recompute data needed by Accounts#index partials + @manual_accounts = Account.uncached { + Current.family.accounts + .visible_manual + .order(:name) + .to_a + } + @mercury_items = Current.family.mercury_items.ordered + + manual_accounts_stream = if @manual_accounts.any? + turbo_stream.update( + "manual-accounts", + partial: "accounts/index/manual_accounts", + locals: { accounts: @manual_accounts } + ) + else + turbo_stream.replace("manual-accounts", view_context.tag.div(id: "manual-accounts")) + end + + render turbo_stream: [ + manual_accounts_stream, + turbo_stream.replace( + ActionView::RecordIdentifier.dom_id(@mercury_item), + partial: "mercury_items/mercury_item", + locals: { mercury_item: @mercury_item } + ) + ] + Array(flash_notification_stream_items) + else + redirect_to accounts_path, status: :see_other + end + end + + private + + # Fetch Mercury accounts from the API and store them locally + # Returns nil on success, or an error message string on failure + def fetch_mercury_accounts_from_api + # Skip if we already have accounts cached + return nil unless @mercury_item.mercury_accounts.empty? + + # Validate API token is configured + unless @mercury_item.credentials_configured? + return t("mercury_items.setup_accounts.no_api_token") + end + + # Use the specific mercury_item's provider (scoped to this family's item) + mercury_provider = @mercury_item.mercury_provider + unless mercury_provider.present? + return t("mercury_items.setup_accounts.no_api_token") + end + + begin + accounts_data = mercury_provider.get_accounts + available_accounts = accounts_data[:accounts] || [] + + if available_accounts.empty? + Rails.logger.info("Mercury API returned no accounts for item #{@mercury_item.id}") + return nil + end + + available_accounts.each do |account_data| + account_name = account_data[:nickname].presence || account_data[:name].presence || account_data[:legalBusinessName].presence + next if account_name.blank? + + mercury_account = @mercury_item.mercury_accounts.find_or_initialize_by( + account_id: account_data[:id].to_s + ) + mercury_account.upsert_mercury_snapshot!(account_data) + mercury_account.save! + end + + nil # Success + rescue Provider::Mercury::MercuryError => e + Rails.logger.error("Mercury API error: #{e.message}") + t("mercury_items.setup_accounts.api_error", message: e.message) + rescue StandardError => e + Rails.logger.error("Unexpected error fetching Mercury accounts: #{e.class}: #{e.message}") + t("mercury_items.setup_accounts.api_error", message: e.message) + end + end + + def set_mercury_item + @mercury_item = Current.family.mercury_items.find(params[:id]) + end + + def mercury_item_params + params.require(:mercury_item).permit(:name, :sync_start_date, :token, :base_url) + end + + # Sanitize return_to parameter to prevent XSS attacks + # Only allow internal paths, reject external URLs and javascript: URIs + def safe_return_to_path + return nil if params[:return_to].blank? + + return_to = params[:return_to].to_s + + # Parse the URL to check if it's external + begin + uri = URI.parse(return_to) + + # Reject absolute URLs with schemes (http:, https:, javascript:, etc.) + # Only allow relative paths + return nil if uri.scheme.present? + + # Ensure the path starts with / (is a relative path) + return nil unless return_to.start_with?("/") + + return_to + rescue URI::InvalidURIError + # If the URI is invalid, reject it + nil + end + end +end diff --git a/app/controllers/settings/providers_controller.rb b/app/controllers/settings/providers_controller.rb index 8c5797def..36a3a2972 100644 --- a/app/controllers/settings/providers_controller.rb +++ b/app/controllers/settings/providers_controller.rb @@ -126,6 +126,7 @@ class Settings::ProvidersController < ApplicationController config.provider_key.to_s.casecmp("simplefin").zero? || config.provider_key.to_s.casecmp("lunchflow").zero? || \ config.provider_key.to_s.casecmp("enable_banking").zero? || \ config.provider_key.to_s.casecmp("coinstats").zero? || \ + config.provider_key.to_s.casecmp("mercury").zero? config.provider_key.to_s.casecmp("coinbase").zero? end @@ -134,6 +135,7 @@ class Settings::ProvidersController < ApplicationController @lunchflow_items = Current.family.lunchflow_items.where.not(api_key: [ nil, "" ]).ordered.select(:id) @enable_banking_items = Current.family.enable_banking_items.ordered # Enable Banking panel needs session info for status display @coinstats_items = Current.family.coinstats_items.ordered # CoinStats panel needs account info for status display + @mercury_items = Current.family.mercury_items.ordered.select(:id) @coinbase_items = Current.family.coinbase_items.ordered # Coinbase panel needs name and sync info for status display end end diff --git a/app/models/data_enrichment.rb b/app/models/data_enrichment.rb index 0eb27ab7e..2639dd451 100644 --- a/app/models/data_enrichment.rb +++ b/app/models/data_enrichment.rb @@ -1,5 +1,5 @@ class DataEnrichment < ApplicationRecord belongs_to :enrichable, polymorphic: true - enum :source, { rule: "rule", plaid: "plaid", simplefin: "simplefin", lunchflow: "lunchflow", synth: "synth", ai: "ai", enable_banking: "enable_banking", coinstats: "coinstats" } + enum :source, { rule: "rule", plaid: "plaid", simplefin: "simplefin", lunchflow: "lunchflow", synth: "synth", ai: "ai", enable_banking: "enable_banking", coinstats: "coinstats", mercury: "mercury" } end diff --git a/app/models/family.rb b/app/models/family.rb index 92f3db2a9..245b8a5bc 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -1,4 +1,5 @@ class Family < ApplicationRecord + include MercuryConnectable include CoinbaseConnectable include PlaidConnectable, SimplefinConnectable, LunchflowConnectable, EnableBankingConnectable, Syncable, AutoTransferMatchable, Subscribeable, CoinstatsConnectable diff --git a/app/models/family/mercury_connectable.rb b/app/models/family/mercury_connectable.rb new file mode 100644 index 000000000..d0bf9fe27 --- /dev/null +++ b/app/models/family/mercury_connectable.rb @@ -0,0 +1,28 @@ +module Family::MercuryConnectable + extend ActiveSupport::Concern + + included do + has_many :mercury_items, dependent: :destroy + end + + def can_connect_mercury? + # Families can configure their own Mercury credentials + true + end + + def create_mercury_item!(token:, base_url: nil, item_name: nil) + mercury_item = mercury_items.create!( + name: item_name || "Mercury Connection", + token: token, + base_url: base_url + ) + + mercury_item.sync_later + + mercury_item + end + + def has_mercury_credentials? + mercury_items.where.not(token: nil).exists? + end +end diff --git a/app/models/mercury_account.rb b/app/models/mercury_account.rb new file mode 100644 index 000000000..43577ecc5 --- /dev/null +++ b/app/models/mercury_account.rb @@ -0,0 +1,60 @@ +class MercuryAccount < ApplicationRecord + include CurrencyNormalizable + + belongs_to :mercury_item + + # New association through account_providers + has_one :account_provider, as: :provider, dependent: :destroy + has_one :account, through: :account_provider, source: :account + has_one :linked_account, through: :account_provider, source: :account + + validates :name, :currency, presence: true + + # Helper to get account using account_providers system + def current_account + account + end + + def upsert_mercury_snapshot!(account_snapshot) + # Convert to symbol keys or handle both string and symbol keys + snapshot = account_snapshot.with_indifferent_access + + # Map Mercury field names to our field names + # Mercury API fields: id, name, currentBalance, availableBalance, status, type, kind, + # legalBusinessName, nickname, routingNumber, accountNumber, etc. + account_name = snapshot[:nickname].presence || snapshot[:name].presence || snapshot[:legalBusinessName].presence + + update!( + current_balance: snapshot[:currentBalance] || snapshot[:current_balance] || 0, + currency: "USD", # Mercury is US-only, always USD + name: account_name, + account_id: snapshot[:id]&.to_s, + account_status: snapshot[:status], + provider: "mercury", + institution_metadata: { + name: "Mercury", + domain: "mercury.com", + url: "https://mercury.com", + account_type: snapshot[:type], + account_kind: snapshot[:kind], + legal_business_name: snapshot[:legalBusinessName], + available_balance: snapshot[:availableBalance] + }.compact, + raw_payload: account_snapshot + ) + end + + def upsert_mercury_transactions_snapshot!(transactions_snapshot) + assign_attributes( + raw_transactions_payload: transactions_snapshot + ) + + save! + end + + private + + def log_invalid_currency(currency_value) + Rails.logger.warn("Invalid currency code '#{currency_value}' for Mercury account #{id}, defaulting to USD") + end +end diff --git a/app/models/mercury_account/processor.rb b/app/models/mercury_account/processor.rb new file mode 100644 index 000000000..54b99f692 --- /dev/null +++ b/app/models/mercury_account/processor.rb @@ -0,0 +1,78 @@ +class MercuryAccount::Processor + include CurrencyNormalizable + + attr_reader :mercury_account + + def initialize(mercury_account) + @mercury_account = mercury_account + end + + def process + unless mercury_account.current_account.present? + Rails.logger.info "MercuryAccount::Processor - No linked account for mercury_account #{mercury_account.id}, skipping processing" + return + end + + Rails.logger.info "MercuryAccount::Processor - Processing mercury_account #{mercury_account.id} (account #{mercury_account.account_id})" + + begin + process_account! + rescue StandardError => e + Rails.logger.error "MercuryAccount::Processor - Failed to process account #{mercury_account.id}: #{e.message}" + Rails.logger.error "Backtrace: #{e.backtrace.join("\n")}" + report_exception(e, "account") + raise + end + + process_transactions + end + + private + + def process_account! + if mercury_account.current_account.blank? + Rails.logger.error("Mercury account #{mercury_account.id} has no associated Account") + return + end + + # Update account balance from latest Mercury data + account = mercury_account.current_account + balance = mercury_account.current_balance || 0 + + # Mercury balance convention: + # - currentBalance is the actual balance of the account + # - For checking/savings (Depository): positive = money in account + # - For credit lines: positive = money owed, negative = credit available + # + # No sign conversion needed for Depository accounts + # Credit accounts are not typically offered by Mercury, but handle just in case + if account.accountable_type == "CreditCard" || account.accountable_type == "Loan" + balance = -balance + end + + # Mercury is US-only, always USD + currency = "USD" + + # Update account balance + account.update!( + balance: balance, + cash_balance: balance, + currency: currency + ) + end + + def process_transactions + MercuryAccount::Transactions::Processor.new(mercury_account).process + rescue => e + report_exception(e, "transactions") + end + + def report_exception(error, context) + Sentry.capture_exception(error) do |scope| + scope.set_tags( + mercury_account_id: mercury_account.id, + context: context + ) + end + end +end diff --git a/app/models/mercury_account/transactions/processor.rb b/app/models/mercury_account/transactions/processor.rb new file mode 100644 index 000000000..669ffd108 --- /dev/null +++ b/app/models/mercury_account/transactions/processor.rb @@ -0,0 +1,71 @@ +class MercuryAccount::Transactions::Processor + attr_reader :mercury_account + + def initialize(mercury_account) + @mercury_account = mercury_account + end + + def process + unless mercury_account.raw_transactions_payload.present? + Rails.logger.info "MercuryAccount::Transactions::Processor - No transactions in raw_transactions_payload for mercury_account #{mercury_account.id}" + return { success: true, total: 0, imported: 0, failed: 0, errors: [] } + end + + total_count = mercury_account.raw_transactions_payload.count + Rails.logger.info "MercuryAccount::Transactions::Processor - Processing #{total_count} transactions for mercury_account #{mercury_account.id}" + + imported_count = 0 + failed_count = 0 + errors = [] + + # Each entry is processed inside a transaction, but to avoid locking up the DB when + # there are hundreds or thousands of transactions, we process them individually. + mercury_account.raw_transactions_payload.each_with_index do |transaction_data, index| + begin + result = MercuryEntry::Processor.new( + transaction_data, + mercury_account: mercury_account + ).process + + if result.nil? + # Transaction was skipped (e.g., no linked account) + failed_count += 1 + errors << { index: index, transaction_id: transaction_data[:id], error: "No linked account" } + else + imported_count += 1 + end + rescue ArgumentError => e + # Validation error - log and continue + failed_count += 1 + transaction_id = transaction_data.try(:[], :id) || transaction_data.try(:[], "id") || "unknown" + error_message = "Validation error: #{e.message}" + Rails.logger.error "MercuryAccount::Transactions::Processor - #{error_message} (transaction #{transaction_id})" + errors << { index: index, transaction_id: transaction_id, error: error_message } + rescue => e + # Unexpected error - log with full context and continue + failed_count += 1 + transaction_id = transaction_data.try(:[], :id) || transaction_data.try(:[], "id") || "unknown" + error_message = "#{e.class}: #{e.message}" + Rails.logger.error "MercuryAccount::Transactions::Processor - Error processing transaction #{transaction_id}: #{error_message}" + Rails.logger.error e.backtrace.join("\n") + errors << { index: index, transaction_id: transaction_id, error: error_message } + end + end + + result = { + success: failed_count == 0, + total: total_count, + imported: imported_count, + failed: failed_count, + errors: errors + } + + if failed_count > 0 + Rails.logger.warn "MercuryAccount::Transactions::Processor - Completed with #{failed_count} failures out of #{total_count} transactions" + else + Rails.logger.info "MercuryAccount::Transactions::Processor - Successfully processed #{imported_count} transactions" + end + + result + end +end diff --git a/app/models/mercury_entry/processor.rb b/app/models/mercury_entry/processor.rb new file mode 100644 index 000000000..a18508111 --- /dev/null +++ b/app/models/mercury_entry/processor.rb @@ -0,0 +1,166 @@ +require "digest/md5" + +class MercuryEntry::Processor + include CurrencyNormalizable + + # mercury_transaction is the raw hash fetched from Mercury API and converted to JSONB + # Transaction structure: { id, amount, bankDescription, counterpartyId, counterpartyName, + # counterpartyNickname, createdAt, dashboardLink, details, + # estimatedDeliveryDate, failedAt, kind, note, postedAt, + # reasonForFailure, status } + def initialize(mercury_transaction, mercury_account:) + @mercury_transaction = mercury_transaction + @mercury_account = mercury_account + end + + def process + # Validate that we have a linked account before processing + unless account.present? + Rails.logger.warn "MercuryEntry::Processor - No linked account for mercury_account #{mercury_account.id}, skipping transaction #{external_id}" + return nil + end + + # Skip failed transactions + if data[:status] == "failed" + Rails.logger.debug "MercuryEntry::Processor - Skipping failed transaction #{external_id}" + return nil + end + + # Wrap import in error handling to catch validation and save errors + begin + import_adapter.import_transaction( + external_id: external_id, + amount: amount, + currency: currency, + date: date, + name: name, + source: "mercury", + merchant: merchant, + notes: notes + ) + rescue ArgumentError => e + # Re-raise validation errors (missing required fields, invalid data) + Rails.logger.error "MercuryEntry::Processor - Validation error for transaction #{external_id}: #{e.message}" + raise + rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved => e + # Handle database save errors + Rails.logger.error "MercuryEntry::Processor - Failed to save transaction #{external_id}: #{e.message}" + raise StandardError.new("Failed to import transaction: #{e.message}") + rescue => e + # Catch unexpected errors with full context + Rails.logger.error "MercuryEntry::Processor - Unexpected error processing transaction #{external_id}: #{e.class} - #{e.message}" + Rails.logger.error e.backtrace.join("\n") + raise StandardError.new("Unexpected error importing transaction: #{e.message}") + end + end + + private + attr_reader :mercury_transaction, :mercury_account + + def import_adapter + @import_adapter ||= Account::ProviderImportAdapter.new(account) + end + + def account + @account ||= mercury_account.current_account + end + + def data + @data ||= mercury_transaction.with_indifferent_access + end + + def external_id + id = data[:id].presence + raise ArgumentError, "Mercury transaction missing required field 'id'" unless id + "mercury_#{id}" + end + + def name + # Use counterparty name or bank description + data[:counterpartyNickname].presence || + data[:counterpartyName].presence || + data[:bankDescription].presence || + "Unknown transaction" + end + + def notes + # Combine note and details if present + note_parts = [] + note_parts << data[:note] if data[:note].present? + note_parts << data[:details] if data[:details].present? + note_parts.any? ? note_parts.join(" - ") : nil + end + + def merchant + counterparty_name = data[:counterpartyName].presence + return nil unless counterparty_name.present? + + # Create a stable merchant ID from the counterparty name + # Using digest to ensure uniqueness while keeping it deterministic + merchant_name = counterparty_name.to_s.strip + return nil if merchant_name.blank? + + merchant_id = Digest::MD5.hexdigest(merchant_name.downcase) + + @merchant ||= begin + import_adapter.find_or_create_merchant( + provider_merchant_id: "mercury_merchant_#{merchant_id}", + name: merchant_name, + source: "mercury" + ) + rescue ActiveRecord::RecordInvalid => e + Rails.logger.error "MercuryEntry::Processor - Failed to create merchant '#{merchant_name}': #{e.message}" + nil + end + end + + def amount + parsed_amount = case data[:amount] + when String + BigDecimal(data[:amount]) + when Numeric + BigDecimal(data[:amount].to_s) + else + BigDecimal("0") + end + + # Mercury uses standard convention where: + # - Negative amounts are money going out (expenses) + # - Positive amounts are money coming in (income) + # Our app uses opposite convention (expenses positive, income negative) + # So we negate the amount to convert from Mercury to our format + -parsed_amount + rescue ArgumentError => e + Rails.logger.error "Failed to parse Mercury transaction amount: #{data[:amount].inspect} - #{e.message}" + raise + end + + def currency + # Mercury is US-only, always USD + "USD" + end + + def date + # Mercury provides createdAt and postedAt - use postedAt if available, otherwise createdAt + date_value = data[:postedAt].presence || data[:createdAt].presence + + case date_value + when String + # Mercury uses ISO 8601 format: "2024-01-15T10:30:00Z" + DateTime.parse(date_value).to_date + when Integer, Float + # Unix timestamp + Time.at(date_value).to_date + when Time, DateTime + date_value.to_date + when Date + date_value + else + Rails.logger.error("Mercury transaction has invalid date value: #{date_value.inspect}") + raise ArgumentError, "Invalid date format: #{date_value.inspect}" + end + rescue ArgumentError, TypeError => e + Rails.logger.error("Failed to parse Mercury transaction date '#{date_value}': #{e.message}") + raise ArgumentError, "Unable to parse transaction date: #{date_value.inspect}" + end +end diff --git a/app/models/mercury_item.rb b/app/models/mercury_item.rb new file mode 100644 index 000000000..a6bcaeb99 --- /dev/null +++ b/app/models/mercury_item.rb @@ -0,0 +1,176 @@ +class MercuryItem < ApplicationRecord + include Syncable, Provided, Unlinking + + enum :status, { good: "good", requires_update: "requires_update" }, default: :good + + # Helper to detect if ActiveRecord Encryption is configured for this app + def self.encryption_ready? + creds_ready = Rails.application.credentials.active_record_encryption.present? + env_ready = ENV["ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY"].present? && + ENV["ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY"].present? && + ENV["ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT"].present? + creds_ready || env_ready + end + + # Encrypt sensitive credentials if ActiveRecord encryption is configured + if encryption_ready? + encrypts :token, deterministic: true + end + + validates :name, presence: true + validates :token, presence: true, on: :create + + belongs_to :family + has_one_attached :logo + + has_many :mercury_accounts, dependent: :destroy + has_many :accounts, through: :mercury_accounts + + scope :active, -> { where(scheduled_for_deletion: false) } + scope :ordered, -> { order(created_at: :desc) } + scope :needs_update, -> { where(status: :requires_update) } + + def destroy_later + update!(scheduled_for_deletion: true) + DestroyJob.perform_later(self) + end + + # TODO: Implement data import from provider API + # This method should fetch the latest data from the provider and import it. + # May need provider-specific validation (e.g., session validity checks). + # See LunchflowItem#import_latest_lunchflow_data or EnableBankingItem#import_latest_enable_banking_data for examples. + def import_latest_mercury_data + provider = mercury_provider + unless provider + Rails.logger.error "MercuryItem #{id} - Cannot import: provider is not configured" + raise StandardError.new("Mercury provider is not configured") + end + + # TODO: Add any provider-specific validation here (e.g., session checks) + MercuryItem::Importer.new(self, mercury_provider: provider).import + rescue => e + Rails.logger.error "MercuryItem #{id} - Failed to import data: #{e.message}" + raise + end + + # TODO: Implement account processing logic + # This method processes linked accounts after data import. + # Customize based on your provider's data structure and processing needs. + def process_accounts + return [] if mercury_accounts.empty? + + results = [] + mercury_accounts.joins(:account).merge(Account.visible).each do |mercury_account| + begin + result = MercuryAccount::Processor.new(mercury_account).process + results << { mercury_account_id: mercury_account.id, success: true, result: result } + rescue => e + Rails.logger.error "MercuryItem #{id} - Failed to process account #{mercury_account.id}: #{e.message}" + results << { mercury_account_id: mercury_account.id, success: false, error: e.message } + end + end + + results + end + + # TODO: Customize sync scheduling if needed + # This method schedules sync jobs for all linked accounts. + def schedule_account_syncs(parent_sync: nil, window_start_date: nil, window_end_date: nil) + return [] if accounts.empty? + + results = [] + accounts.visible.each do |account| + begin + account.sync_later( + parent_sync: parent_sync, + window_start_date: window_start_date, + window_end_date: window_end_date + ) + results << { account_id: account.id, success: true } + rescue => e + Rails.logger.error "MercuryItem #{id} - Failed to schedule sync for account #{account.id}: #{e.message}" + results << { account_id: account.id, success: false, error: e.message } + end + end + + results + end + + def upsert_mercury_snapshot!(accounts_snapshot) + assign_attributes( + raw_payload: accounts_snapshot + ) + + save! + end + + def has_completed_initial_setup? + # Setup is complete if we have any linked accounts + accounts.any? + end + + # TODO: Customize sync status summary if needed + # Some providers use latest_sync.sync_stats, others use count methods directly. + # See SimplefinItem#sync_status_summary or EnableBankingItem#sync_status_summary for examples. + def sync_status_summary + total_accounts = total_accounts_count + linked_count = linked_accounts_count + unlinked_count = unlinked_accounts_count + + if total_accounts == 0 + "No accounts found" + elsif unlinked_count == 0 + "#{linked_count} #{'account'.pluralize(linked_count)} synced" + else + "#{linked_count} synced, #{unlinked_count} need setup" + end + end + + def linked_accounts_count + mercury_accounts.joins(:account_provider).count + end + + def unlinked_accounts_count + mercury_accounts.left_joins(:account_provider).where(account_providers: { id: nil }).count + end + + def total_accounts_count + mercury_accounts.count + end + + def institution_display_name + institution_name.presence || institution_domain.presence || name + end + + # TODO: Customize based on how your provider stores institution data + # SimpleFin uses org_data, others use institution_metadata. + # Adjust the field name and key lookups as needed. + def connected_institutions + mercury_accounts.includes(:account) + .where.not(institution_metadata: nil) + .map { |acc| acc.institution_metadata } + .uniq { |inst| inst["name"] || inst["institution_name"] } + end + + # TODO: Customize institution summary if your provider has special fields + # EnableBanking uses aspsp_name as a fallback, for example. + def institution_summary + institutions = connected_institutions + case institutions.count + when 0 + "No institutions connected" + when 1 + institutions.first["name"] || institutions.first["institution_name"] || "1 institution" + else + "#{institutions.count} institutions" + end + end + + def credentials_configured? + token.present? + end + + def effective_base_url + base_url.presence || "https://api.mercury.com/api/v1" + end +end diff --git a/app/models/mercury_item/importer.rb b/app/models/mercury_item/importer.rb new file mode 100644 index 000000000..277ea5e2a --- /dev/null +++ b/app/models/mercury_item/importer.rb @@ -0,0 +1,302 @@ +class MercuryItem::Importer + attr_reader :mercury_item, :mercury_provider + + def initialize(mercury_item, mercury_provider:) + @mercury_item = mercury_item + @mercury_provider = mercury_provider + end + + def import + Rails.logger.info "MercuryItem::Importer - Starting import for item #{mercury_item.id}" + + # Step 1: Fetch all accounts from Mercury + accounts_data = fetch_accounts_data + unless accounts_data + Rails.logger.error "MercuryItem::Importer - Failed to fetch accounts data for item #{mercury_item.id}" + return { success: false, error: "Failed to fetch accounts data", accounts_imported: 0, transactions_imported: 0 } + end + + # Store raw payload + begin + mercury_item.upsert_mercury_snapshot!(accounts_data) + rescue => e + Rails.logger.error "MercuryItem::Importer - Failed to store accounts snapshot: #{e.message}" + # Continue with import even if snapshot storage fails + end + + # Step 2: Update linked accounts and create records for new accounts from API + accounts_updated = 0 + accounts_created = 0 + accounts_failed = 0 + + if accounts_data[:accounts].present? + # Get linked mercury account IDs (ones actually imported/used by the user) + linked_account_ids = mercury_item.mercury_accounts + .joins(:account_provider) + .pluck(:account_id) + .map(&:to_s) + + # Get all existing mercury account IDs (linked or not) + all_existing_ids = mercury_item.mercury_accounts.pluck(:account_id).map(&:to_s) + + accounts_data[:accounts].each do |account_data| + account_id = account_data[:id]&.to_s + next unless account_id.present? + + # Mercury uses 'name' or 'nickname' for account name + account_name = account_data[:nickname].presence || account_data[:name].presence || account_data[:legalBusinessName].presence + next if account_name.blank? + + if linked_account_ids.include?(account_id) + # Update existing linked accounts + begin + import_account(account_data) + accounts_updated += 1 + rescue => e + accounts_failed += 1 + Rails.logger.error "MercuryItem::Importer - Failed to update account #{account_id}: #{e.message}" + end + elsif !all_existing_ids.include?(account_id) + # Create new unlinked mercury_account records for accounts we haven't seen before + # This allows users to link them later via "Setup new accounts" + begin + mercury_account = mercury_item.mercury_accounts.build( + account_id: account_id, + name: account_name, + currency: "USD" # Mercury is US-only, always USD + ) + mercury_account.upsert_mercury_snapshot!(account_data) + accounts_created += 1 + Rails.logger.info "MercuryItem::Importer - Created new unlinked account record for #{account_id}" + rescue => e + accounts_failed += 1 + Rails.logger.error "MercuryItem::Importer - Failed to create account #{account_id}: #{e.message}" + end + end + end + end + + Rails.logger.info "MercuryItem::Importer - Updated #{accounts_updated} accounts, created #{accounts_created} new (#{accounts_failed} failed)" + + # Step 3: Fetch transactions only for linked accounts with active status + transactions_imported = 0 + transactions_failed = 0 + + mercury_item.mercury_accounts.joins(:account).merge(Account.visible).each do |mercury_account| + begin + result = fetch_and_store_transactions(mercury_account) + if result[:success] + transactions_imported += result[:transactions_count] + else + transactions_failed += 1 + end + rescue => e + transactions_failed += 1 + Rails.logger.error "MercuryItem::Importer - Failed to fetch/store transactions for account #{mercury_account.account_id}: #{e.message}" + # Continue with other accounts even if one fails + end + end + + Rails.logger.info "MercuryItem::Importer - Completed import for item #{mercury_item.id}: #{accounts_updated} accounts updated, #{accounts_created} new accounts discovered, #{transactions_imported} transactions" + + { + success: accounts_failed == 0 && transactions_failed == 0, + accounts_updated: accounts_updated, + accounts_created: accounts_created, + accounts_failed: accounts_failed, + transactions_imported: transactions_imported, + transactions_failed: transactions_failed + } + end + + private + + def fetch_accounts_data + begin + accounts_data = mercury_provider.get_accounts + rescue Provider::Mercury::MercuryError => e + # Handle authentication errors by marking item as requiring update + if e.error_type == :unauthorized || e.error_type == :access_forbidden + begin + mercury_item.update!(status: :requires_update) + rescue => update_error + Rails.logger.error "MercuryItem::Importer - Failed to update item status: #{update_error.message}" + end + end + Rails.logger.error "MercuryItem::Importer - Mercury API error: #{e.message}" + return nil + rescue JSON::ParserError => e + Rails.logger.error "MercuryItem::Importer - Failed to parse Mercury API response: #{e.message}" + return nil + rescue => e + Rails.logger.error "MercuryItem::Importer - Unexpected error fetching accounts: #{e.class} - #{e.message}" + Rails.logger.error e.backtrace.join("\n") + return nil + end + + # Validate response structure + unless accounts_data.is_a?(Hash) + Rails.logger.error "MercuryItem::Importer - Invalid accounts_data format: expected Hash, got #{accounts_data.class}" + return nil + end + + # Handle errors if present in response + if accounts_data[:error].present? + handle_error(accounts_data[:error]) + return nil + end + + accounts_data + end + + def import_account(account_data) + # Validate account data structure + unless account_data.is_a?(Hash) + Rails.logger.error "MercuryItem::Importer - Invalid account_data format: expected Hash, got #{account_data.class}" + raise ArgumentError, "Invalid account data format" + end + + account_id = account_data[:id] + + # Validate required account_id + if account_id.blank? + Rails.logger.warn "MercuryItem::Importer - Skipping account with missing ID" + raise ArgumentError, "Account ID is required" + end + + # Only find existing accounts, don't create new ones during sync + mercury_account = mercury_item.mercury_accounts.find_by( + account_id: account_id.to_s + ) + + # Skip if account wasn't previously selected + unless mercury_account + Rails.logger.debug "MercuryItem::Importer - Skipping unselected account #{account_id}" + return + end + + begin + mercury_account.upsert_mercury_snapshot!(account_data) + mercury_account.save! + rescue ActiveRecord::RecordInvalid => e + Rails.logger.error "MercuryItem::Importer - Failed to save mercury_account: #{e.message}" + raise StandardError.new("Failed to save account: #{e.message}") + end + end + + def fetch_and_store_transactions(mercury_account) + start_date = determine_sync_start_date(mercury_account) + Rails.logger.info "MercuryItem::Importer - Fetching transactions for account #{mercury_account.account_id} from #{start_date}" + + begin + # Fetch transactions + transactions_data = mercury_provider.get_account_transactions( + mercury_account.account_id, + start_date: start_date + ) + + # Validate response structure + unless transactions_data.is_a?(Hash) + Rails.logger.error "MercuryItem::Importer - Invalid transactions_data format for account #{mercury_account.account_id}" + return { success: false, transactions_count: 0, error: "Invalid response format" } + end + + transactions_count = transactions_data[:transactions]&.count || 0 + Rails.logger.info "MercuryItem::Importer - Fetched #{transactions_count} transactions for account #{mercury_account.account_id}" + + # Store transactions in the account + if transactions_data[:transactions].present? + begin + existing_transactions = mercury_account.raw_transactions_payload.to_a + + # Build set of existing transaction IDs for efficient lookup + existing_ids = existing_transactions.map do |tx| + tx.with_indifferent_access[:id] + end.to_set + + # Filter to ONLY truly new transactions (skip duplicates) + # Transactions are immutable on the bank side, so we don't need to update them + new_transactions = transactions_data[:transactions].select do |tx| + next false unless tx.is_a?(Hash) + + tx_id = tx.with_indifferent_access[:id] + tx_id.present? && !existing_ids.include?(tx_id) + end + + if new_transactions.any? + Rails.logger.info "MercuryItem::Importer - Storing #{new_transactions.count} new transactions (#{existing_transactions.count} existing, #{transactions_data[:transactions].count - new_transactions.count} duplicates skipped) for account #{mercury_account.account_id}" + mercury_account.upsert_mercury_transactions_snapshot!(existing_transactions + new_transactions) + else + Rails.logger.info "MercuryItem::Importer - No new transactions to store (all #{transactions_data[:transactions].count} were duplicates) for account #{mercury_account.account_id}" + end + rescue => e + Rails.logger.error "MercuryItem::Importer - Failed to store transactions for account #{mercury_account.account_id}: #{e.message}" + return { success: false, transactions_count: 0, error: "Failed to store transactions: #{e.message}" } + end + else + Rails.logger.info "MercuryItem::Importer - No transactions to store for account #{mercury_account.account_id}" + end + + { success: true, transactions_count: transactions_count } + rescue Provider::Mercury::MercuryError => e + Rails.logger.error "MercuryItem::Importer - Mercury API error for account #{mercury_account.id}: #{e.message}" + { success: false, transactions_count: 0, error: e.message } + rescue JSON::ParserError => e + Rails.logger.error "MercuryItem::Importer - Failed to parse transaction response for account #{mercury_account.id}: #{e.message}" + { success: false, transactions_count: 0, error: "Failed to parse response" } + rescue => e + Rails.logger.error "MercuryItem::Importer - Unexpected error fetching transactions for account #{mercury_account.id}: #{e.class} - #{e.message}" + Rails.logger.error e.backtrace.join("\n") + { success: false, transactions_count: 0, error: "Unexpected error: #{e.message}" } + end + end + + def determine_sync_start_date(mercury_account) + # Check if this account has any stored transactions + # If not, treat it as a first sync for this account even if the item has been synced before + has_stored_transactions = mercury_account.raw_transactions_payload.to_a.any? + + if has_stored_transactions + # Account has been synced before, use item-level logic with buffer + # For subsequent syncs, fetch from last sync date with a buffer + if mercury_item.last_synced_at + mercury_item.last_synced_at - 7.days + else + # Fallback if item hasn't been synced but account has transactions + 90.days.ago + end + else + # Account has no stored transactions - this is a first sync for this account + # Use account creation date or a generous historical window + account_baseline = mercury_account.created_at || Time.current + first_sync_window = [ account_baseline - 7.days, 90.days.ago ].max + + # Use the more recent of: (account created - 7 days) or (90 days ago) + # This caps old accounts at 90 days while respecting recent account creation dates + first_sync_window + end + end + + def handle_error(error_message) + # Mark item as requiring update for authentication-related errors + error_msg_lower = error_message.to_s.downcase + needs_update = error_msg_lower.include?("authentication") || + error_msg_lower.include?("unauthorized") || + error_msg_lower.include?("api key") || + error_msg_lower.include?("api token") + + if needs_update + begin + mercury_item.update!(status: :requires_update) + rescue => e + Rails.logger.error "MercuryItem::Importer - Failed to update item status: #{e.message}" + end + end + + Rails.logger.error "MercuryItem::Importer - API error: #{error_message}" + raise Provider::Mercury::MercuryError.new( + "Mercury API error: #{error_message}", + :api_error + ) + end +end diff --git a/app/models/mercury_item/provided.rb b/app/models/mercury_item/provided.rb new file mode 100644 index 000000000..19b415dfb --- /dev/null +++ b/app/models/mercury_item/provided.rb @@ -0,0 +1,13 @@ +module MercuryItem::Provided + extend ActiveSupport::Concern + + def mercury_provider + return nil unless credentials_configured? + + Provider::Mercury.new(token, base_url: effective_base_url) + end + + def syncer + MercuryItem::Syncer.new(self) + end +end diff --git a/app/models/mercury_item/sync_complete_event.rb b/app/models/mercury_item/sync_complete_event.rb new file mode 100644 index 000000000..056297b66 --- /dev/null +++ b/app/models/mercury_item/sync_complete_event.rb @@ -0,0 +1,25 @@ +class MercuryItem::SyncCompleteEvent + attr_reader :mercury_item + + def initialize(mercury_item) + @mercury_item = mercury_item + end + + def broadcast + # Update UI with latest account data + mercury_item.accounts.each do |account| + account.broadcast_sync_complete + end + + # Update the Mercury item view + mercury_item.broadcast_replace_to( + mercury_item.family, + target: "mercury_item_#{mercury_item.id}", + partial: "mercury_items/mercury_item", + locals: { mercury_item: mercury_item } + ) + + # Let family handle sync notifications + mercury_item.family.broadcast_sync_complete + end +end diff --git a/app/models/mercury_item/syncer.rb b/app/models/mercury_item/syncer.rb new file mode 100644 index 000000000..1a09e541c --- /dev/null +++ b/app/models/mercury_item/syncer.rb @@ -0,0 +1,64 @@ +class MercuryItem::Syncer + include SyncStats::Collector + + attr_reader :mercury_item + + def initialize(mercury_item) + @mercury_item = mercury_item + end + + def perform_sync(sync) + # Phase 1: Import data from Mercury API + sync.update!(status_text: "Importing accounts from Mercury...") if sync.respond_to?(:status_text) + mercury_item.import_latest_mercury_data + + # Phase 2: Collect setup statistics using shared concern + sync.update!(status_text: "Checking account configuration...") if sync.respond_to?(:status_text) + collect_setup_stats(sync, provider_accounts: mercury_item.mercury_accounts) + + # Check for unlinked accounts + linked_accounts = mercury_item.mercury_accounts.joins(:account_provider) + unlinked_accounts = mercury_item.mercury_accounts.left_joins(:account_provider).where(account_providers: { id: nil }) + + # Set pending_account_setup if there are unlinked accounts + if unlinked_accounts.any? + mercury_item.update!(pending_account_setup: true) + sync.update!(status_text: "#{unlinked_accounts.count} accounts need setup...") if sync.respond_to?(:status_text) + else + mercury_item.update!(pending_account_setup: false) + end + + # Phase 3: Process transactions for linked accounts only + if linked_accounts.any? + sync.update!(status_text: "Processing transactions...") if sync.respond_to?(:status_text) + mark_import_started(sync) + Rails.logger.info "MercuryItem::Syncer - Processing #{linked_accounts.count} linked accounts" + mercury_item.process_accounts + Rails.logger.info "MercuryItem::Syncer - Finished processing accounts" + + # Phase 4: Schedule balance calculations for linked accounts + sync.update!(status_text: "Calculating balances...") if sync.respond_to?(:status_text) + mercury_item.schedule_account_syncs( + parent_sync: sync, + window_start_date: sync.window_start_date, + window_end_date: sync.window_end_date + ) + + # Phase 5: Collect transaction statistics + account_ids = linked_accounts.includes(:account_provider).filter_map { |ma| ma.current_account&.id } + collect_transaction_stats(sync, account_ids: account_ids, source: "mercury") + else + Rails.logger.info "MercuryItem::Syncer - No linked accounts to process" + end + + # Mark sync health + collect_health_stats(sync, errors: nil) + rescue => e + collect_health_stats(sync, errors: [ { message: e.message, category: "sync_error" } ]) + raise + end + + def perform_post_sync + # no-op + end +end diff --git a/app/models/mercury_item/unlinking.rb b/app/models/mercury_item/unlinking.rb new file mode 100644 index 000000000..605bff3c7 --- /dev/null +++ b/app/models/mercury_item/unlinking.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +module MercuryItem::Unlinking + # Concern that encapsulates unlinking logic for a Mercury item. + extend ActiveSupport::Concern + + # Idempotently remove all connections between this Mercury item and local accounts. + # - Detaches any AccountProvider links for each MercuryAccount + # - Detaches Holdings that point at the AccountProvider links + # Returns a per-account result payload for observability + def unlink_all!(dry_run: false) + results = [] + + mercury_accounts.find_each do |provider_account| + links = AccountProvider.where(provider_type: "MercuryAccount", provider_id: provider_account.id).to_a + link_ids = links.map(&:id) + result = { + provider_account_id: provider_account.id, + name: provider_account.name, + provider_link_ids: link_ids + } + results << result + + next if dry_run + + begin + ActiveRecord::Base.transaction do + # Detach holdings for any provider links found + if link_ids.any? + Holding.where(account_provider_id: link_ids).update_all(account_provider_id: nil) + end + + # Destroy all provider links + links.each do |ap| + ap.destroy! + end + end + rescue StandardError => e + Rails.logger.warn( + "MercuryItem Unlinker: failed to fully unlink provider account ##{provider_account.id} (links=#{link_ids.inspect}): #{e.class} - #{e.message}" + ) + # Record error for observability; continue with other accounts + result[:error] = e.message + end + end + + results + end +end diff --git a/app/models/provider/mercury.rb b/app/models/provider/mercury.rb new file mode 100644 index 000000000..c78c14c1b --- /dev/null +++ b/app/models/provider/mercury.rb @@ -0,0 +1,157 @@ +class Provider::Mercury + include HTTParty + + headers "User-Agent" => "Sure Finance Mercury Client" + default_options.merge!(verify: true, ssl_verify_mode: OpenSSL::SSL::VERIFY_PEER, timeout: 120) + + attr_reader :token, :base_url + + def initialize(token, base_url: "https://api.mercury.com/api/v1") + @token = token + @base_url = base_url + end + + # Get all accounts + # Returns: { accounts: [...] } + # Account structure: { id, name, currentBalance, availableBalance, status, type, kind, legalBusinessName, nickname } + def get_accounts + response = self.class.get( + "#{@base_url}/accounts", + headers: auth_headers + ) + + handle_response(response) + rescue MercuryError + raise + rescue SocketError, Net::OpenTimeout, Net::ReadTimeout => e + Rails.logger.error "Mercury API: GET /accounts failed: #{e.class}: #{e.message}" + raise MercuryError.new("Exception during GET request: #{e.message}", :request_failed) + rescue => e + Rails.logger.error "Mercury API: Unexpected error during GET /accounts: #{e.class}: #{e.message}" + raise MercuryError.new("Exception during GET request: #{e.message}", :request_failed) + end + + # Get a single account by ID + # Returns: { id, name, currentBalance, availableBalance, status, type, kind, ... } + def get_account(account_id) + path = "/account/#{ERB::Util.url_encode(account_id.to_s)}" + + response = self.class.get( + "#{@base_url}#{path}", + headers: auth_headers + ) + + handle_response(response) + rescue MercuryError + raise + rescue SocketError, Net::OpenTimeout, Net::ReadTimeout => e + Rails.logger.error "Mercury API: GET #{path} failed: #{e.class}: #{e.message}" + raise MercuryError.new("Exception during GET request: #{e.message}", :request_failed) + rescue => e + Rails.logger.error "Mercury API: Unexpected error during GET #{path}: #{e.class}: #{e.message}" + raise MercuryError.new("Exception during GET request: #{e.message}", :request_failed) + end + + # Get transactions for a specific account + # Returns: { transactions: [...], total: N } + # Transaction structure: { id, amount, bankDescription, counterpartyId, counterpartyName, + # counterpartyNickname, createdAt, dashboardLink, details, + # estimatedDeliveryDate, failedAt, kind, note, postedAt, + # reasonForFailure, status } + def get_account_transactions(account_id, start_date: nil, end_date: nil, offset: nil, limit: nil) + query_params = {} + + if start_date + query_params[:start] = start_date.to_date.to_s + end + + if end_date + query_params[:end] = end_date.to_date.to_s + end + + if offset + query_params[:offset] = offset.to_i + end + + if limit + query_params[:limit] = limit.to_i + end + + path = "/account/#{ERB::Util.url_encode(account_id.to_s)}/transactions" + path += "?#{URI.encode_www_form(query_params)}" unless query_params.empty? + + response = self.class.get( + "#{@base_url}#{path}", + headers: auth_headers + ) + + handle_response(response) + rescue MercuryError + raise + rescue SocketError, Net::OpenTimeout, Net::ReadTimeout => e + Rails.logger.error "Mercury API: GET #{path} failed: #{e.class}: #{e.message}" + raise MercuryError.new("Exception during GET request: #{e.message}", :request_failed) + rescue => e + Rails.logger.error "Mercury API: Unexpected error during GET #{path}: #{e.class}: #{e.message}" + raise MercuryError.new("Exception during GET request: #{e.message}", :request_failed) + end + + private + + def auth_headers + { + "Authorization" => "Bearer #{token}", + "Content-Type" => "application/json", + "Accept" => "application/json" + } + end + + def handle_response(response) + case response.code + when 200 + JSON.parse(response.body, symbolize_names: true) + when 400 + Rails.logger.error "Mercury API: Bad request - #{response.body}" + raise MercuryError.new("Bad request to Mercury API: #{response.body}", :bad_request) + when 401 + # Parse the error response for more specific messages + error_message = parse_error_message(response.body) + raise MercuryError.new(error_message, :unauthorized) + when 403 + raise MercuryError.new("Access forbidden - check your API token permissions", :access_forbidden) + when 404 + raise MercuryError.new("Resource not found", :not_found) + when 429 + raise MercuryError.new("Rate limit exceeded. Please try again later.", :rate_limited) + else + Rails.logger.error "Mercury API: Unexpected response - Code: #{response.code}, Body: #{response.body}" + raise MercuryError.new("Failed to fetch data: #{response.code} #{response.message} - #{response.body}", :fetch_failed) + end + end + + def parse_error_message(body) + parsed = JSON.parse(body, symbolize_names: true) + errors = parsed[:errors] || {} + + case errors[:errorCode] + when "ipNotWhitelisted" + ip = errors[:ip] || "unknown" + "IP address not whitelisted (#{ip}). Add your IP to the API token's whitelist in Mercury dashboard." + when "noTokenInDBButMaybeMalformed" + "Invalid token format. Make sure to include the 'secret-token:' prefix." + else + errors[:message] || "Invalid API token" + end + rescue JSON::ParserError + "Invalid API token" + end + + class MercuryError < StandardError + attr_reader :error_type + + def initialize(message, error_type = :unknown) + super(message) + @error_type = error_type + end + end +end diff --git a/app/models/provider/mercury_adapter.rb b/app/models/provider/mercury_adapter.rb new file mode 100644 index 000000000..e1a70b839 --- /dev/null +++ b/app/models/provider/mercury_adapter.rb @@ -0,0 +1,105 @@ +class Provider::MercuryAdapter < Provider::Base + include Provider::Syncable + include Provider::InstitutionMetadata + + # Register this adapter with the factory + Provider::Factory.register("MercuryAccount", self) + + # Define which account types this provider supports + # Mercury is primarily a business banking provider with checking/savings accounts + def self.supported_account_types + %w[Depository] + end + + # Returns connection configurations for this provider + 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 + ) + } + } ] + end + + def provider_name + "mercury" + end + + # 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) + return nil unless family.present? + + # Get family-specific credentials + mercury_item = family.mercury_items.where.not(token: nil).first + return nil unless mercury_item&.credentials_configured? + + Provider::Mercury.new( + mercury_item.token, + base_url: mercury_item.effective_base_url + ) + end + + def sync_path + Rails.application.routes.url_helpers.sync_mercury_item_path(item) + end + + def item + provider_account.mercury_item + end + + def can_delete_holdings? + false + end + + def institution_domain + metadata = provider_account.institution_metadata + return nil unless metadata.present? + + domain = metadata["domain"] + url = metadata["url"] + + # Derive domain from URL if missing + if domain.blank? && url.present? + begin + domain = URI.parse(url).host&.gsub(/^www\./, "") + rescue URI::InvalidURIError + Rails.logger.warn("Invalid institution URL for Mercury account #{provider_account.id}: #{url}") + end + end + + domain + end + + def institution_name + metadata = provider_account.institution_metadata + return nil unless metadata.present? + + metadata["name"] || item&.institution_name + end + + def institution_url + metadata = provider_account.institution_metadata + return nil unless metadata.present? + + metadata["url"] || item&.institution_url + end + + def institution_color + item&.institution_color + end +end diff --git a/app/models/provider_merchant.rb b/app/models/provider_merchant.rb index 5bc7cea4f..7a9eca82f 100644 --- a/app/models/provider_merchant.rb +++ b/app/models/provider_merchant.rb @@ -1,5 +1,5 @@ class ProviderMerchant < Merchant - enum :source, { plaid: "plaid", simplefin: "simplefin", lunchflow: "lunchflow", synth: "synth", ai: "ai", enable_banking: "enable_banking", coinstats: "coinstats" } + enum :source, { plaid: "plaid", simplefin: "simplefin", lunchflow: "lunchflow", synth: "synth", ai: "ai", enable_banking: "enable_banking", coinstats: "coinstats", mercury: "mercury" } validates :name, uniqueness: { scope: [ :source ] } validates :source, presence: true diff --git a/app/views/accounts/index.html.erb b/app/views/accounts/index.html.erb index 65be084df..5ceb31549 100644 --- a/app/views/accounts/index.html.erb +++ b/app/views/accounts/index.html.erb @@ -21,7 +21,7 @@ -<% if @manual_accounts.empty? && @plaid_items.empty? && @simplefin_items.empty? && @lunchflow_items.empty? && @enable_banking_items.empty? && @coinstats_items.empty? && @coinbase_items.empty? %> +<% if @manual_accounts.empty? && @plaid_items.empty? && @simplefin_items.empty? && @lunchflow_items.empty? && @enable_banking_items.empty? && @coinstats_items.empty? && @mercury_items.empty? && @coinbase_items.empty? %> <%= render "empty" %> <% else %>
@@ -45,6 +45,10 @@ <%= render @coinstats_items.sort_by(&:created_at) %> <% end %> + <% if @mercury_items.any? %> + <%= render @mercury_items.sort_by(&:created_at) %> + <% end %> + <% if @coinbase_items.any? %> <%= render @coinbase_items.sort_by(&:created_at) %> <% end %> diff --git a/app/views/family_exports/index.html.erb b/app/views/family_exports/index.html.erb index 619aa9da8..fec621b8e 100644 --- a/app/views/family_exports/index.html.erb +++ b/app/views/family_exports/index.html.erb @@ -43,15 +43,15 @@ <% if export.processing? || export.pending? %> - <%= render 'shared/badge' do %> + <%= render "shared/badge" do %> <%= t("family_exports.table.row.status.in_progress") %> <% end %> <% elsif export.completed? %> - <%= render 'shared/badge', color: 'success' do %> + <%= render "shared/badge", color: "success" do %> <%= t("family_exports.table.row.status.complete") %> <% end %> <% elsif export.failed? %> - <%= render 'shared/badge', color: 'error' do %> + <%= render "shared/badge", color: "error" do %> <%= t("family_exports.table.row.status.failed") %> <% end %> <% end %> diff --git a/app/views/imports/index.html.erb b/app/views/imports/index.html.erb index b090a119a..4a3d9bcda 100644 --- a/app/views/imports/index.html.erb +++ b/app/views/imports/index.html.erb @@ -41,32 +41,32 @@ <% if import.account.present? %> <%= import.account.name + " " %> <% end %> - <%= import.type.titleize.gsub(/ Import\z/, '') %> + <%= import.type.titleize.gsub(/ Import\z/, "") %> <% end %> <% if import.pending? %> - <%= render 'shared/badge' do %> + <%= render "shared/badge" do %> <%= t("imports.table.row.status.in_progress") %> <% end %> <% elsif import.importing? %> - <%= render 'shared/badge', color: 'warning', pulse: true do %> + <%= render "shared/badge", color: "warning", pulse: true do %> <%= t("imports.table.row.status.uploading") %> <% end %> <% elsif import.failed? %> - <%= render 'shared/badge', color: 'error' do %> + <%= render "shared/badge", color: "error" do %> <%= t("imports.table.row.status.failed") %> <% end %> <% elsif import.reverting? %> - <%= render 'shared/badge', color: 'warning' do %> + <%= render "shared/badge", color: "warning" do %> <%= t("imports.table.row.status.reverting") %> <% end %> <% elsif import.revert_failed? %> - <%= render 'shared/badge', color: 'error' do %> + <%= render "shared/badge", color: "error" do %> <%= t("imports.table.row.status.revert_failed") %> <% end %> <% elsif import.complete? %> - <%= render 'shared/badge', color: 'success' do %> + <%= render "shared/badge", color: "success" do %> <%= t("imports.table.row.status.complete") %> <% end %> <% end %> diff --git a/app/views/mercury_items/_api_error.html.erb b/app/views/mercury_items/_api_error.html.erb new file mode 100644 index 000000000..bca14207b --- /dev/null +++ b/app/views/mercury_items/_api_error.html.erb @@ -0,0 +1,36 @@ +<%# locals: (error_message:, return_path:) %> +<%= turbo_frame_tag "modal" do %> + <%= render DS::Dialog.new do |dialog| %> + <% dialog.with_header(title: "Mercury Connection Error") %> + <% dialog.with_body do %> +
+
+ <%= icon("alert-circle", class: "text-destructive w-5 h-5 shrink-0 mt-0.5") %> +
+

Unable to connect to Mercury

+

<%= error_message %>

+
+
+ +
+

Common Issues:

+
    +
  • Invalid API Token: Check your API token in Provider Settings
  • +
  • Expired Credentials: Generate a new API token from Mercury
  • +
  • Insufficient Permissions: Ensure your token has read-only access
  • +
  • Network Issue: Check your internet connection
  • +
  • Service Down: Mercury API may be temporarily unavailable
  • +
+
+ +
+ <%= link_to settings_providers_path, + 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", + data: { turbo: false } do %> + Check Provider Settings + <% end %> +
+
+ <% end %> + <% end %> +<% end %> diff --git a/app/views/mercury_items/_mercury_item.html.erb b/app/views/mercury_items/_mercury_item.html.erb new file mode 100644 index 000000000..7988a6b28 --- /dev/null +++ b/app/views/mercury_items/_mercury_item.html.erb @@ -0,0 +1,130 @@ +<%# locals: (mercury_item:) %> + +<%= tag.div id: dom_id(mercury_item) do %> +
+ +
+ <%= icon "chevron-right", class: "group-open:transform group-open:rotate-90" %> + +
+ <% if mercury_item.logo.attached? %> + <%= image_tag mercury_item.logo, class: "rounded-full h-full w-full", loading: "lazy" %> + <% else %> +
+ <%= tag.p mercury_item.name.first.upcase, class: "text-blue-600 text-xs font-medium" %> +
+ <% end %> +
+ +
+
+ <%= tag.p mercury_item.name, class: "font-medium text-primary" %> + <% if mercury_item.scheduled_for_deletion? %> +

<%= t(".deletion_in_progress") %>

+ <% end %> +
+ <% if mercury_item.accounts.any? %> +

+ <%= mercury_item.institution_summary %> +

+ <% end %> + <% if mercury_item.syncing? %> +
+ <%= icon "loader", size: "sm", class: "animate-spin" %> + <%= tag.span t(".syncing") %> +
+ <% elsif mercury_item.sync_error.present? %> +
+ <%= render DS::Tooltip.new(text: mercury_item.sync_error, icon: "alert-circle", size: "sm", color: "destructive") %> + <%= tag.span t(".error"), class: "text-destructive" %> +
+ <% else %> +

+ <% if mercury_item.last_synced_at %> + <% if mercury_item.sync_status_summary %> + <%= t(".status_with_summary", timestamp: time_ago_in_words(mercury_item.last_synced_at), summary: mercury_item.sync_status_summary) %> + <% else %> + <%= t(".status", timestamp: time_ago_in_words(mercury_item.last_synced_at)) %> + <% end %> + <% else %> + <%= t(".status_never") %> + <% end %> +

+ <% end %> +
+
+ +
+ <% if Rails.env.development? %> + <%= icon( + "refresh-cw", + as_button: true, + href: sync_mercury_item_path(mercury_item) + ) %> + <% end %> + + <%= render DS::Menu.new do |menu| %> + <% menu.with_item( + variant: "button", + text: t(".delete"), + icon: "trash-2", + href: mercury_item_path(mercury_item), + method: :delete, + confirm: CustomConfirm.for_resource_deletion(mercury_item.name, high_severity: true) + ) %> + <% end %> +
+
+ + <% unless mercury_item.scheduled_for_deletion? %> +
+ <% if mercury_item.accounts.any? %> + <%= render "accounts/index/account_groups", accounts: mercury_item.accounts %> + <% end %> + + <%# Sync summary (collapsible) - using shared ProviderSyncSummary component %> + <% stats = if defined?(@mercury_sync_stats_map) && @mercury_sync_stats_map + @mercury_sync_stats_map[mercury_item.id] || {} + else + mercury_item.syncs.ordered.first&.sync_stats || {} + end %> + <%= render ProviderSyncSummary.new( + stats: stats, + provider_item: mercury_item, + institutions_count: mercury_item.connected_institutions.size + ) %> + + <%# Use model methods for consistent counts %> + <% unlinked_count = mercury_item.unlinked_accounts_count %> + <% linked_count = mercury_item.linked_accounts_count %> + <% total_count = mercury_item.total_accounts_count %> + + <% if unlinked_count > 0 %> +
+

<%= t(".setup_needed") %>

+

<%= t(".setup_description", linked: linked_count, total: total_count) %>

+ <%= render DS::Link.new( + text: t(".setup_action"), + icon: "settings", + variant: "primary", + href: setup_accounts_mercury_item_path(mercury_item), + frame: :modal + ) %> +
+ <% elsif mercury_item.accounts.empty? && total_count == 0 %> +
+

<%= t(".no_accounts_title") %>

+

<%= t(".no_accounts_description") %>

+ <%= render DS::Link.new( + text: t(".setup_action"), + icon: "settings", + variant: "primary", + href: setup_accounts_mercury_item_path(mercury_item), + frame: :modal + ) %> +
+ <% end %> +
+ <% end %> +
+<% end %> diff --git a/app/views/mercury_items/_setup_required.html.erb b/app/views/mercury_items/_setup_required.html.erb new file mode 100644 index 000000000..b8f576022 --- /dev/null +++ b/app/views/mercury_items/_setup_required.html.erb @@ -0,0 +1,34 @@ +<%= turbo_frame_tag "modal" do %> + <%= render DS::Dialog.new do |dialog| %> + <% dialog.with_header(title: "Mercury Setup Required") %> + <% dialog.with_body do %> +
+
+ <%= icon("alert-circle", class: "text-warning w-5 h-5 shrink-0 mt-0.5") %> +
+

API Token Not Configured

+

Before you can link Mercury accounts, you need to configure your Mercury API token.

+
+
+ +
+

Setup Steps:

+
    +
  1. Go to Settings > Providers
  2. +
  3. Find the Mercury section
  4. +
  5. Enter your Mercury API token
  6. +
  7. Return here to link your accounts
  8. +
+
+ +
+ <%= link_to settings_providers_path, + 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", + data: { turbo: false } do %> + Go to Provider Settings + <% end %> +
+
+ <% end %> + <% end %> +<% end %> diff --git a/app/views/mercury_items/_subtype_select.html.erb b/app/views/mercury_items/_subtype_select.html.erb new file mode 100644 index 000000000..a3b29c8cf --- /dev/null +++ b/app/views/mercury_items/_subtype_select.html.erb @@ -0,0 +1,23 @@ + diff --git a/app/views/mercury_items/select_accounts.html.erb b/app/views/mercury_items/select_accounts.html.erb new file mode 100644 index 000000000..7b9c15857 --- /dev/null +++ b/app/views/mercury_items/select_accounts.html.erb @@ -0,0 +1,57 @@ +<%= turbo_frame_tag "modal" do %> + <%= render DS::Dialog.new do |dialog| %> + <% dialog.with_header(title: t(".title")) %> + + <% dialog.with_body do %> +
+

+ <%= t(".description", product_name: product_name) %> +

+ +
+ <%= hidden_field_tag :authenticity_token, form_authenticity_token %> + <%= hidden_field_tag :accountable_type, @accountable_type %> + <%= hidden_field_tag :return_to, @return_to %> + +
+ <% @available_accounts.each do |account| %> + <% account_name = account[:nickname].presence || account[:name].presence || account[:legalBusinessName].presence %> + <% has_blank_name = account_name.blank? %> + + <% end %> +
+ +
+ <%= link_to t(".cancel"), @return_to || new_account_path, + class: "inline-flex items-center gap-1 px-3 py-2 text-sm font-medium rounded-lg text-primary button-bg-secondary hover:button-bg-secondary-hover", + data: { turbo_frame: "_top", action: "DS--dialog#close" } %> + <%= submit_tag t(".link_accounts"), + class: "inline-flex items-center gap-1 px-3 py-2 text-sm font-medium rounded-lg text-inverse bg-inverse hover:bg-inverse-hover disabled:button-bg-disabled" %> +
+
+
+ <% end %> + <% end %> +<% end %> diff --git a/app/views/mercury_items/select_existing_account.html.erb b/app/views/mercury_items/select_existing_account.html.erb new file mode 100644 index 000000000..d1ab2c4be --- /dev/null +++ b/app/views/mercury_items/select_existing_account.html.erb @@ -0,0 +1,57 @@ +<%= turbo_frame_tag "modal" do %> + <%= render DS::Dialog.new do |dialog| %> + <% dialog.with_header(title: t(".title", account_name: @account.name)) %> + + <% dialog.with_body do %> +
+

+ <%= t(".description") %> +

+ +
+ <%= hidden_field_tag :authenticity_token, form_authenticity_token %> + <%= hidden_field_tag :account_id, @account.id %> + <%= hidden_field_tag :return_to, @return_to %> + +
+ <% @available_accounts.each do |account| %> + <% account_name = account[:nickname].presence || account[:name].presence || account[:legalBusinessName].presence %> + <% has_blank_name = account_name.blank? %> + + <% end %> +
+ +
+ <%= link_to t(".cancel"), @return_to || accounts_path, + class: "inline-flex items-center gap-1 px-3 py-2 text-sm font-medium rounded-lg text-primary button-bg-secondary hover:button-bg-secondary-hover", + data: { turbo_frame: "_top", action: "DS--dialog#close" } %> + <%= submit_tag t(".link_account"), + class: "inline-flex items-center gap-1 px-3 py-2 text-sm font-medium rounded-lg text-inverse bg-inverse hover:bg-inverse-hover disabled:button-bg-disabled" %> +
+
+
+ <% end %> + <% end %> +<% end %> diff --git a/app/views/mercury_items/setup_accounts.html.erb b/app/views/mercury_items/setup_accounts.html.erb new file mode 100644 index 000000000..04f38c814 --- /dev/null +++ b/app/views/mercury_items/setup_accounts.html.erb @@ -0,0 +1,105 @@ +<% content_for :title, "Set Up Mercury Accounts" %> + +<%= render DS::Dialog.new do |dialog| %> + <% dialog.with_header(title: t(".title")) do %> +
+ <%= icon "building-2", class: "text-primary" %> + <%= t(".subtitle") %> +
+ <% end %> + + <% dialog.with_body do %> + <%= form_with url: complete_account_setup_mercury_item_path(@mercury_item), + method: :post, + local: true, + data: { + controller: "loading-button", + action: "submit->loading-button#showLoading", + loading_button_loading_text_value: t(".creating_accounts"), + turbo_frame: "_top" + }, + class: "space-y-6" do |form| %> + +
+ <% if @api_error.present? %> +
+ <%= icon "alert-circle", size: "lg", class: "text-destructive" %> +

<%= t(".fetch_failed") %>

+

<%= @api_error %>

+
+ <% elsif @mercury_accounts.empty? %> +
+ <%= icon "check-circle", size: "lg", class: "text-success" %> +

<%= t(".no_accounts_to_setup") %>

+

<%= t(".all_accounts_linked") %>

+
+ <% else %> +
+
+ <%= icon "info", size: "sm", class: "text-primary mt-0.5 flex-shrink-0" %> +
+

+ <%= t(".choose_account_type") %> +

+
    + <% @account_type_options.reject { |_, type| type == "skip" }.each do |label, type| %> +
  • <%= label %>
  • + <% end %> +
+
+
+
+ + <% @mercury_accounts.each do |mercury_account| %> +
+
+
+

+ <%= mercury_account.name %> +

+
+
+ +
+
+ <%= label_tag "account_types[#{mercury_account.id}]", t(".account_type_label"), + class: "block text-sm font-medium text-primary mb-2" %> + <%= select_tag "account_types[#{mercury_account.id}]", + options_for_select(@account_type_options, "skip"), + { class: "appearance-none bg-container border border-primary rounded-md px-3 py-2 text-sm leading-6 text-primary focus:border-primary focus:ring-1 focus:ring-primary focus:outline-none w-full", + data: { + action: "change->account-type-selector#updateSubtype" + } } %> +
+ + +
+ <% @subtype_options.each do |account_type, subtype_config| %> + <%= render "mercury_items/subtype_select", account_type: account_type, subtype_config: subtype_config, mercury_account: mercury_account %> + <% end %> +
+
+
+ <% end %> + <% end %> +
+ +
+ <%= render DS::Button.new( + text: t(".create_accounts"), + variant: "primary", + icon: "plus", + type: "submit", + class: "flex-1", + disabled: @api_error.present? || @mercury_accounts.empty?, + data: { loading_button_target: "button" } + ) %> + <%= render DS::Link.new( + text: t(".cancel"), + variant: "secondary", + href: accounts_path + ) %> +
+ <% end %> + <% end %> +<% end %> diff --git a/app/views/pages/dashboard/_outflows_donut.html.erb b/app/views/pages/dashboard/_outflows_donut.html.erb index 882a344f6..0787c418c 100644 --- a/app/views/pages/dashboard/_outflows_donut.html.erb +++ b/app/views/pages/dashboard/_outflows_donut.html.erb @@ -74,7 +74,7 @@ category_content = capture do %>
-
+
+

Setup instructions:

+
    +
  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. +
+ +

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. +

+
+ + <% error_msg = local_assigns[:error_message] || @error_message %> + <% if error_msg.present? %> +
+

<%= error_msg %>

+
+ <% 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? + %> + + <%= 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 %> + + <%= form.text_field :base_url, + label: "Base Url (Optional)", + placeholder: "https://api.mercury.com/api/v1 (default)", + value: mercury_item.base_url %> + +
+ <%= 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" %> +
+ <% end %> + + <% items = local_assigns[:mercury_items] || @mercury_items || Current.family.mercury_items.where.not(token: [nil, ""]) %> +
+ <% if items&.any? %> +
+

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

+ <% else %> +
+

Not configured

+ <% end %> +
+
diff --git a/app/views/settings/providers/show.html.erb b/app/views/settings/providers/show.html.erb index 0ab1312c3..03175b748 100644 --- a/app/views/settings/providers/show.html.erb +++ b/app/views/settings/providers/show.html.erb @@ -42,6 +42,12 @@ <% end %> + <%= settings_section title: "Mercury", collapsible: true, open: false do %> + + <%= render "settings/providers/mercury_panel" %> + + <% end %> + <%= settings_section title: "Coinbase (beta)", collapsible: true, open: false do %> <%= render "settings/providers/coinbase_panel" %> diff --git a/app/views/shared/_badge.html.erb b/app/views/shared/_badge.html.erb index f23a7cd26..56e099cb3 100644 --- a/app/views/shared/_badge.html.erb +++ b/app/views/shared/_badge.html.erb @@ -3,14 +3,14 @@ <% def badge_classes(c, p) classes = case c - when 'success' - 'bg-green-500/5 text-green-500' - when 'error' - 'bg-red-500/5 text-red-500' - when 'warning' - 'bg-orange-500/5 text-orange-500' + when "success" + "bg-green-500/5 text-green-500" + when "error" + "bg-red-500/5 text-red-500" + when "warning" + "bg-orange-500/5 text-orange-500" else - 'bg-gray-500/5 text-secondary' + "bg-gray-500/5 text-secondary" end p ? "#{classes} animate-pulse" : classes @@ -19,4 +19,4 @@ <%= yield %> - \ No newline at end of file + diff --git a/app/views/trades/_trade.html.erb b/app/views/trades/_trade.html.erb index d768430e6..40ec53d05 100644 --- a/app/views/trades/_trade.html.erb +++ b/app/views/trades/_trade.html.erb @@ -8,9 +8,9 @@
<%= check_box_tag dom_id(entry, "selection"), class: "checkbox checkbox--light hidden lg:block", - data: { - id: entry.id, - "bulk-select-target": "row", + data: { + id: entry.id, + "bulk-select-target": "row", action: "bulk-select#toggleRowSelection", checkbox_toggle_target: "selectionEntry" } %> diff --git a/config/locales/views/mercury_items/en.yml b/config/locales/views/mercury_items/en.yml new file mode 100644 index 000000000..dc45ca889 --- /dev/null +++ b/config/locales/views/mercury_items/en.yml @@ -0,0 +1,147 @@ +--- +en: + mercury_items: + create: + success: Mercury connection created successfully + destroy: + success: Mercury connection removed + index: + title: Mercury Connections + loading: + loading_message: Loading Mercury accounts... + loading_title: Loading + link_accounts: + all_already_linked: + one: "The selected account (%{names}) is already linked" + other: "All %{count} selected accounts are already linked: %{names}" + api_error: "API error: %{message}" + invalid_account_names: + one: "Cannot link account with blank name" + other: "Cannot link %{count} accounts with blank names" + link_failed: Failed to link accounts + no_accounts_selected: Please select at least one account + 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}" + success: + one: "Successfully linked %{count} account" + other: "Successfully linked %{count} accounts" + mercury_item: + accounts_need_setup: Accounts need setup + delete: Delete connection + deletion_in_progress: deletion in progress... + error: Error + no_accounts_description: This connection has no linked accounts yet. + no_accounts_title: No accounts + setup_action: Set Up New Accounts + setup_description: "%{linked} of %{total} accounts linked. Choose account types for your newly imported Mercury accounts." + setup_needed: New accounts ready to set up + status: "Synced %{timestamp} ago" + status_never: Never synced + status_with_summary: "Last synced %{timestamp} ago - %{summary}" + syncing: Syncing... + total: Total + unlinked: Unlinked + select_accounts: + accounts_selected: accounts selected + api_error: "API error: %{message}" + cancel: Cancel + configure_name_in_mercury: Cannot import - please configure account name in Mercury + description: Select the accounts you want to link to your %{product_name} account. + link_accounts: Link selected accounts + no_accounts_found: No accounts found. Please check your API token configuration. + 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)" + title: Select Mercury Accounts + select_existing_account: + account_already_linked: This account is already linked to a provider + all_accounts_already_linked: All Mercury accounts are already linked + api_error: "API error: %{message}" + cancel: Cancel + configure_name_in_mercury: Cannot import - please configure account name in Mercury + description: Select a Mercury account to link with this account. Transactions will be synced and deduplicated automatically. + link_account: Link account + no_account_specified: No account specified + no_accounts_found: No Mercury accounts found. Please check your API token configuration. + 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)" + title: "Link %{account_name} with Mercury" + link_existing_account: + account_already_linked: This account is already linked to a provider + api_error: "API error: %{message}" + invalid_account_name: Cannot link account with blank name + mercury_account_already_linked: This Mercury account is already linked to another account + 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. + success: "Successfully linked %{account_name} with Mercury" + setup_accounts: + account_type_label: "Account Type:" + all_accounts_linked: "All your Mercury accounts have already been set up." + api_error: "API error: %{message}" + fetch_failed: "Failed to Fetch Accounts" + no_accounts_to_setup: "No Accounts to Set Up" + no_api_token: "Mercury API token is not configured. Please check your connection settings." + account_types: + skip: Skip this account + depository: Checking or Savings Account + credit_card: Credit Card + investment: Investment Account + loan: Loan or Mortgage + other_asset: Other Asset + subtype_labels: + depository: "Account Subtype:" + credit_card: "" + investment: "Investment Type:" + loan: "Loan Type:" + other_asset: "" + subtype_messages: + credit_card: "Credit cards will be automatically set up as credit card accounts." + other_asset: "No additional options needed for Other Assets." + subtypes: + depository: + checking: Checking + savings: Savings + hsa: Health Savings Account + cd: Certificate of Deposit + money_market: Money Market + investment: + brokerage: Brokerage + pension: Pension + retirement: Retirement + "401k": "401(k)" + roth_401k: "Roth 401(k)" + "403b": "403(b)" + tsp: Thrift Savings Plan + "529_plan": "529 Plan" + hsa: Health Savings Account + mutual_fund: Mutual Fund + ira: Traditional IRA + roth_ira: Roth IRA + angel: Angel + loan: + mortgage: Mortgage + student: Student Loan + auto: Auto Loan + other: Other Loan + balance: Balance + cancel: Cancel + choose_account_type: "Choose the correct account type for each Mercury account:" + create_accounts: Create Accounts + creating_accounts: Creating Accounts... + historical_data_range: "Historical Data Range:" + subtitle: Choose the correct account types for your imported accounts + sync_start_date_help: Select how far back you want to sync transaction history. Maximum 3 years of history available. + sync_start_date_label: "Start syncing transactions from:" + title: Set Up Your Mercury Accounts + complete_account_setup: + all_skipped: "All accounts were skipped. No accounts were created." + creation_failed: "Failed to create accounts: %{error}" + no_accounts: "No accounts to set up." + success: "Successfully created %{count} account(s)." + sync: + success: Sync started + update: + success: Mercury connection updated diff --git a/config/routes.rb b/config/routes.rb index b26aa24de..73d25a030 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -2,6 +2,22 @@ require "sidekiq/web" require "sidekiq/cron/web" Rails.application.routes.draw do + resources :mercury_items, only: %i[index new create show edit update destroy] do + collection do + get :preload_accounts + get :select_accounts + post :link_accounts + get :select_existing_account + post :link_existing_account + end + + member do + post :sync + get :setup_accounts + post :complete_account_setup + end + end + resources :coinbase_items, only: [ :index, :new, :create, :show, :edit, :update, :destroy ] do collection do get :preload_accounts @@ -17,6 +33,7 @@ Rails.application.routes.draw do post :complete_account_setup end end + # CoinStats routes resources :coinstats_items, only: [ :index, :new, :create, :update, :destroy ] do collection do diff --git a/db/migrate/20260121101345_create_mercury_items_and_accounts.rb b/db/migrate/20260121101345_create_mercury_items_and_accounts.rb new file mode 100644 index 000000000..a0f384fab --- /dev/null +++ b/db/migrate/20260121101345_create_mercury_items_and_accounts.rb @@ -0,0 +1,61 @@ +class CreateMercuryItemsAndAccounts < ActiveRecord::Migration[7.2] + def change + # Create provider items table (stores per-family connection credentials) + create_table :mercury_items, id: :uuid do |t| + t.references :family, null: false, foreign_key: true, type: :uuid + t.string :name + + # Institution metadata + t.string :institution_id + t.string :institution_name + t.string :institution_domain + t.string :institution_url + t.string :institution_color + + # Status and lifecycle + t.string :status, default: "good" + t.boolean :scheduled_for_deletion, default: false + t.boolean :pending_account_setup, default: false + + # Sync settings + t.datetime :sync_start_date + + # Raw data storage + t.jsonb :raw_payload + t.jsonb :raw_institution_payload + + # Provider-specific credential fields + t.text :token + t.string :base_url + + t.timestamps + end + + add_index :mercury_items, :status + + # Create provider accounts table (stores individual account data from provider) + create_table :mercury_accounts, id: :uuid do |t| + t.references :mercury_item, null: false, foreign_key: true, type: :uuid + + # Account identification + t.string :name + t.string :account_id, null: false + + # Account details + t.string :currency + t.decimal :current_balance, precision: 19, scale: 4 + t.string :account_status + t.string :account_type + t.string :provider + + # Metadata and raw data + t.jsonb :institution_metadata + t.jsonb :raw_payload + t.jsonb :raw_transactions_payload + + t.timestamps + end + + add_index :mercury_accounts, :account_id, unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 9942a09f5..14a9ea149 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2026_01_19_005756) do +ActiveRecord::Schema[7.2].define(version: 2026_01_21_101345) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -778,6 +778,46 @@ ActiveRecord::Schema[7.2].define(version: 2026_01_19_005756) do t.index ["type"], name: "index_merchants_on_type" end + create_table "mercury_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "mercury_item_id", null: false + t.string "name" + t.string "account_id", null: false + t.string "currency" + t.decimal "current_balance", precision: 19, scale: 4 + t.string "account_status" + t.string "account_type" + t.string "provider" + t.jsonb "institution_metadata" + t.jsonb "raw_payload" + t.jsonb "raw_transactions_payload" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["account_id"], name: "index_mercury_accounts_on_account_id", unique: true + t.index ["mercury_item_id"], name: "index_mercury_accounts_on_mercury_item_id" + end + + create_table "mercury_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "family_id", null: false + t.string "name" + t.string "institution_id" + t.string "institution_name" + t.string "institution_domain" + t.string "institution_url" + t.string "institution_color" + t.string "status", default: "good" + t.boolean "scheduled_for_deletion", default: false + t.boolean "pending_account_setup", default: false + t.datetime "sync_start_date" + t.jsonb "raw_payload" + t.jsonb "raw_institution_payload" + t.text "token" + t.string "base_url" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["family_id"], name: "index_mercury_items_on_family_id" + t.index ["status"], name: "index_mercury_items_on_status" + end + create_table "messages", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.uuid "chat_id", null: false t.string "type", null: false @@ -1365,6 +1405,8 @@ ActiveRecord::Schema[7.2].define(version: 2026_01_19_005756) do add_foreign_key "lunchflow_accounts", "lunchflow_items" add_foreign_key "lunchflow_items", "families" add_foreign_key "merchants", "families" + add_foreign_key "mercury_accounts", "mercury_items" + add_foreign_key "mercury_items", "families" add_foreign_key "messages", "chats" add_foreign_key "mobile_devices", "users" add_foreign_key "oauth_access_grants", "oauth_applications", column: "application_id" diff --git a/lib/generators/provider/family/family_generator.rb b/lib/generators/provider/family/family_generator.rb index f652794a6..fb38528d4 100644 --- a/lib/generators/provider/family/family_generator.rb +++ b/lib/generators/provider/family/family_generator.rb @@ -318,11 +318,11 @@ class Provider::FamilyGenerator < Rails::Generators::NamedBase # Add section before the last closing div (at end of file) section_content = <<~ERB - <%%= settings_section title: "#{class_name}", collapsible: true, open: false do %> + <%= settings_section title: "#{class_name}", collapsible: true, open: false do %> - <%%= render "settings/providers/#{file_name}_panel" %> + <%= render "settings/providers/#{file_name}_panel" %> - <%% end %> + <% end %> ERB # Insert before the final
at the end of file @@ -331,6 +331,99 @@ class Provider::FamilyGenerator < Rails::Generators::NamedBase end end + def update_accounts_controller + controller_path = "app/controllers/accounts_controller.rb" + return unless File.exist?(controller_path) + + content = File.read(controller_path) + items_var = "@#{file_name}_items" + + # Check if already added + if content.include?(items_var) + say "Accounts controller already has #{items_var}", :skip + return + end + + # Add to index action - find the last @*_items line and insert after it + lines = content.lines + last_items_index = nil + lines.each_with_index do |line, index| + if line =~ /@\w+_items = family\.\w+_items\.ordered/ + last_items_index = index + end + end + + if last_items_index + indentation = lines[last_items_index][/^\s*/] + new_line = "#{indentation}#{items_var} = family.#{file_name}_items.ordered.includes(:syncs, :#{file_name}_accounts)\n" + lines.insert(last_items_index + 1, new_line) + File.write(controller_path, lines.join) + say "Added #{items_var} to accounts controller index", :green + else + say "Could not find @*_items assignments in accounts controller", :yellow + end + + # Add sync stats map + add_accounts_controller_sync_stats_map(controller_path) + end + + def update_accounts_index_view + view_path = "app/views/accounts/index.html.erb" + return unless File.exist?(view_path) + + content = File.read(view_path) + items_var = "@#{file_name}_items" + + if content.include?(items_var) + say "Accounts index view already has #{class_name} section", :skip + return + end + + # Add to empty check - find the existing pattern and append our check + content = content.gsub( + /@coinstats_items\.empty\? %>/, + "@coinstats_items.empty? && #{items_var}.empty? %>" + ) + + # Add provider section before manual_accounts + section = <<~ERB + + <% if #{items_var}.any? %> + <%= render #{items_var}.sort_by(&:created_at) %> + <% end %> + + ERB + + content = content.gsub( + /<% if @manual_accounts\.any\? %>/, + "#{section.strip}\n\n <% if @manual_accounts.any? %>" + ) + + File.write(view_path, content) + say "Added #{class_name} section to accounts index view", :green + end + + def create_locale_file + locale_dir = "config/locales/views/#{file_name}_items" + locale_path = "#{locale_dir}/en.yml" + + if File.exist?(locale_path) + say "Locale file already exists: #{locale_path}", :skip + return + end + + FileUtils.mkdir_p(locale_dir) + template "locale.en.yml.tt", locale_path + say "Created locale file: #{locale_path}", :green + end + + def update_source_enums + # Add the new provider to the source enum in ProviderMerchant and DataEnrichment + # These enums track which provider created a merchant or enrichment record + update_source_enum("app/models/provider_merchant.rb") + update_source_enum("app/models/data_enrichment.rb") + end + def show_summary say "\n" + "=" * 80, :green say "Successfully generated per-family provider: #{class_name}", :green @@ -395,6 +488,77 @@ class Provider::FamilyGenerator < Rails::Generators::NamedBase private + def update_source_enum(model_path) + return unless File.exist?(model_path) + + content = File.read(model_path) + model_name = File.basename(model_path, ".rb").camelize + + # Check if provider is already in the enum + if content.include?("#{file_name}: \"#{file_name}\"") + say "#{model_name} source enum already includes #{file_name}", :skip + return + end + + # Find the enum :source line and add the new provider + # Pattern: enum :source, { key: "value", ... } + if content =~ /(enum :source, \{[^}]+)(})/ + # Insert the new provider before the closing brace + updated_content = content.sub( + /(enum :source, \{[^}]+)(})/, + "\\1, #{file_name}: \"#{file_name}\"\\2" + ) + File.write(model_path, updated_content) + say "Added #{file_name} to #{model_name} source enum", :green + else + say "Could not find source enum in #{model_name}", :yellow + end + end + + def add_accounts_controller_sync_stats_map(controller_path) + content = File.read(controller_path) + stats_var = "@#{file_name}_sync_stats_map" + + if content.include?(stats_var) + say "Accounts controller already has #{stats_var}", :skip + return + end + + # Find the build_sync_stats_maps method and add our stats map before the closing 'end' + sync_stats_block = <<~RUBY + + # #{class_name} sync stats + #{stats_var} = {} + @#{file_name}_items.each do |item| + latest_sync = item.syncs.ordered.first + #{stats_var}[item.id] = latest_sync&.sync_stats || {} + end + RUBY + + lines = content.lines + method_start = nil + method_end = nil + indent_level = 0 + + lines.each_with_index do |line, index| + if line.include?("def build_sync_stats_maps") + method_start = index + indent_level = line[/^\s*/].length + elsif method_start && line =~ /^#{' ' * indent_level}end\s*$/ + method_end = index + break + end + end + + if method_end + lines.insert(method_end, sync_stats_block) + File.write(controller_path, lines.join) + say "Added #{stats_var} to build_sync_stats_maps", :green + else + say "Could not find build_sync_stats_maps method end", :yellow + end + end + def table_name "#{file_name}_items" end diff --git a/lib/generators/provider/family/templates/locale.en.yml.tt b/lib/generators/provider/family/templates/locale.en.yml.tt new file mode 100644 index 000000000..bcdbeae6c --- /dev/null +++ b/lib/generators/provider/family/templates/locale.en.yml.tt @@ -0,0 +1,147 @@ +--- +en: + <%= file_name %>_items: + create: + success: <%= class_name %> connection created successfully + destroy: + success: <%= class_name %> connection removed + index: + title: <%= class_name %> Connections + loading: + loading_message: Loading <%= class_name %> accounts... + loading_title: Loading + link_accounts: + all_already_linked: + one: "The selected account (%%{names}) is already linked" + other: "All %%{count} selected accounts are already linked: %%{names}" + api_error: "API error: %%{message}" + invalid_account_names: + one: "Cannot link account with blank name" + other: "Cannot link %%{count} accounts with blank names" + link_failed: Failed to link accounts + no_accounts_selected: Please select at least one account + no_api_key: <%= class_name %> API key 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}" + success: + one: "Successfully linked %%{count} account" + other: "Successfully linked %%{count} accounts" + <%= file_name %>_item: + accounts_need_setup: Accounts need setup + delete: Delete connection + deletion_in_progress: deletion in progress... + error: Error + no_accounts_description: This connection has no linked accounts yet. + no_accounts_title: No accounts + setup_action: Set Up New Accounts + setup_description: "%%{linked} of %%{total} accounts linked. Choose account types for your newly imported <%= class_name %> accounts." + setup_needed: New accounts ready to set up + status: "Synced %%{timestamp} ago" + status_never: Never synced + status_with_summary: "Last synced %%{timestamp} ago - %%{summary}" + syncing: Syncing... + total: Total + unlinked: Unlinked + select_accounts: + accounts_selected: accounts selected + api_error: "API error: %%{message}" + cancel: Cancel + configure_name_in_provider: Cannot import - please configure account name in <%= class_name %> + description: Select the accounts you want to link to your %%{product_name} account. + link_accounts: Link selected accounts + no_accounts_found: No accounts found. Please check your API key configuration. + no_api_key: <%= class_name %> API key is not configured. Please configure it in Settings. + no_credentials_configured: Please configure your <%= class_name %> credentials first in Provider Settings. + no_name_placeholder: "(No name)" + title: Select <%= class_name %> Accounts + select_existing_account: + account_already_linked: This account is already linked to a provider + all_accounts_already_linked: All <%= class_name %> accounts are already linked + api_error: "API error: %%{message}" + cancel: Cancel + configure_name_in_provider: Cannot import - please configure account name in <%= class_name %> + description: Select a <%= class_name %> account to link with this account. Transactions will be synced and deduplicated automatically. + link_account: Link account + no_account_specified: No account specified + no_accounts_found: No <%= class_name %> accounts found. Please check your API key configuration. + no_api_key: <%= class_name %> API key is not configured. Please configure it in Settings. + no_credentials_configured: Please configure your <%= class_name %> credentials first in Provider Settings. + no_name_placeholder: "(No name)" + title: "Link %%{account_name} with <%= class_name %>" + link_existing_account: + account_already_linked: This account is already linked to a provider + api_error: "API error: %%{message}" + invalid_account_name: Cannot link account with blank name + provider_account_already_linked: This <%= class_name %> account is already linked to another account + provider_account_not_found: <%= class_name %> account not found + missing_parameters: Missing required parameters + no_api_key: <%= class_name %> API key not found. Please configure it in Provider Settings. + success: "Successfully linked %%{account_name} with <%= class_name %>" + setup_accounts: + account_type_label: "Account Type:" + all_accounts_linked: "All your <%= class_name %> accounts have already been set up." + api_error: "API error: %%{message}" + fetch_failed: "Failed to Fetch Accounts" + no_accounts_to_setup: "No Accounts to Set Up" + no_api_key: "<%= class_name %> API key is not configured. Please check your connection settings." + account_types: + skip: Skip this account + depository: Checking or Savings Account + credit_card: Credit Card + investment: Investment Account + loan: Loan or Mortgage + other_asset: Other Asset + subtype_labels: + depository: "Account Subtype:" + credit_card: "" + investment: "Investment Type:" + loan: "Loan Type:" + other_asset: "" + subtype_messages: + credit_card: "Credit cards will be automatically set up as credit card accounts." + other_asset: "No additional options needed for Other Assets." + subtypes: + depository: + checking: Checking + savings: Savings + hsa: Health Savings Account + cd: Certificate of Deposit + money_market: Money Market + investment: + brokerage: Brokerage + pension: Pension + retirement: Retirement + "401k": "401(k)" + roth_401k: "Roth 401(k)" + "403b": "403(b)" + tsp: Thrift Savings Plan + "529_plan": "529 Plan" + hsa: Health Savings Account + mutual_fund: Mutual Fund + ira: Traditional IRA + roth_ira: Roth IRA + angel: Angel + loan: + mortgage: Mortgage + student: Student Loan + auto: Auto Loan + other: Other Loan + balance: Balance + cancel: Cancel + choose_account_type: "Choose the correct account type for each <%= class_name %> account:" + create_accounts: Create Accounts + creating_accounts: Creating Accounts... + historical_data_range: "Historical Data Range:" + subtitle: Choose the correct account types for your imported accounts + sync_start_date_help: Select how far back you want to sync transaction history. + sync_start_date_label: "Start syncing transactions from:" + title: Set Up Your <%= class_name %> Accounts + complete_account_setup: + all_skipped: "All accounts were skipped. No accounts were created." + creation_failed: "Failed to create accounts: %%{error}" + no_accounts: "No accounts to set up." + success: "Successfully created %%{count} account(s)." + sync: + success: Sync started + update: + success: <%= class_name %> connection updated diff --git a/test/fixtures/mercury_accounts.yml b/test/fixtures/mercury_accounts.yml new file mode 100644 index 000000000..62a801937 --- /dev/null +++ b/test/fixtures/mercury_accounts.yml @@ -0,0 +1,6 @@ +checking_account: + mercury_item: one + account_id: "merc_acc_checking_1" + name: "Mercury Checking" + currency: USD + current_balance: 10000.00 diff --git a/test/fixtures/mercury_items.yml b/test/fixtures/mercury_items.yml new file mode 100644 index 000000000..f5c31bb63 --- /dev/null +++ b/test/fixtures/mercury_items.yml @@ -0,0 +1,6 @@ +one: + family: dylan_family + name: "Test Mercury Connection" + token: "test_mercury_token_123" + base_url: "https://api-sandbox.mercury.com/api/v1" + status: good diff --git a/test/models/mercury_item_test.rb b/test/models/mercury_item_test.rb new file mode 100644 index 000000000..365697ec4 --- /dev/null +++ b/test/models/mercury_item_test.rb @@ -0,0 +1,49 @@ +require "test_helper" + +class MercuryItemTest < ActiveSupport::TestCase + def setup + @mercury_item = mercury_items(:one) + end + + test "fixture is valid" do + assert @mercury_item.valid? + end + + test "belongs to family" do + assert_equal families(:dylan_family), @mercury_item.family + end + + test "credentials_configured returns true when token present" do + assert @mercury_item.credentials_configured? + end + + test "credentials_configured returns false when token blank" do + @mercury_item.token = nil + 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 + + test "effective_base_url returns default when base_url blank" do + @mercury_item.base_url = nil + assert_equal "https://api.mercury.com/api/v1", @mercury_item.effective_base_url + end + + test "mercury_provider returns Provider::Mercury instance" do + provider = @mercury_item.mercury_provider + assert_instance_of Provider::Mercury, provider + assert_equal @mercury_item.token, provider.token + end + + test "mercury_provider returns nil when credentials not configured" do + @mercury_item.token = nil + assert_nil @mercury_item.mercury_provider + end + + test "syncer returns MercuryItem::Syncer instance" do + syncer = @mercury_item.send(:syncer) + assert_instance_of MercuryItem::Syncer, syncer + end +end diff --git a/test/models/provider/mercury_adapter_test.rb b/test/models/provider/mercury_adapter_test.rb new file mode 100644 index 000000000..f89ff14ce --- /dev/null +++ b/test/models/provider/mercury_adapter_test.rb @@ -0,0 +1,38 @@ +require "test_helper" + +class Provider::MercuryAdapterTest < ActiveSupport::TestCase + test "supports Depository accounts" do + assert_includes Provider::MercuryAdapter.supported_account_types, "Depository" + end + + test "does not support Investment accounts" do + assert_not_includes Provider::MercuryAdapter.supported_account_types, "Investment" + end + + test "returns connection configs for any family" do + # Mercury is a per-family provider - any family can connect + family = families(:dylan_family) + 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 configs.first[:can_connect] + end + + test "build_provider returns nil when family is nil" do + assert_nil Provider::MercuryAdapter.build_provider(family: nil) + end + + test "build_provider returns nil when family has no mercury items" do + family = families(:empty) + assert_nil Provider::MercuryAdapter.build_provider(family: family) + end + + test "build_provider returns Mercury provider when credentials configured" do + family = families(:dylan_family) + provider = Provider::MercuryAdapter.build_provider(family: family) + + assert_instance_of Provider::Mercury, provider + end +end diff --git a/test/models/provider/mercury_test.rb b/test/models/provider/mercury_test.rb new file mode 100644 index 000000000..98306a5a9 --- /dev/null +++ b/test/models/provider/mercury_test.rb @@ -0,0 +1,29 @@ +require "test_helper" + +class Provider::MercuryTest < ActiveSupport::TestCase + def setup + @provider = Provider::Mercury.new("test_token", base_url: "https://api-sandbox.mercury.com/api/v1") + end + + test "initializes with token and default base_url" do + provider = Provider::Mercury.new("my_token") + assert_equal "my_token", provider.token + assert_equal "https://api.mercury.com/api/v1", provider.base_url + end + + test "initializes with custom base_url" do + assert_equal "test_token", @provider.token + assert_equal "https://api-sandbox.mercury.com/api/v1", @provider.base_url + end + + test "MercuryError includes error_type" do + error = Provider::Mercury::MercuryError.new("Test error", :unauthorized) + assert_equal "Test error", error.message + assert_equal :unauthorized, error.error_type + end + + test "MercuryError defaults error_type to unknown" do + error = Provider::Mercury::MercuryError.new("Test error") + assert_equal :unknown, error.error_type + end +end