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 %>
Unable to connect to Mercury
+<%= error_message %>
+Common Issues:
+<%= t(".deletion_in_progress") %>
+ <% end %> ++ <%= mercury_item.institution_summary %> +
+ <% end %> + <% if mercury_item.syncing? %> ++ <% 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 %> +<%= 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 + ) %> +<%= 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 + ) %> +API Token Not Configured
+Before you can link Mercury accounts, you need to configure your Mercury API token.
+Setup Steps:
++ <%= t(".description", product_name: product_name) %> +
+ + ++ <%= t(".description") %> +
+ + +<%= t(".fetch_failed") %>
+<%= @api_error %>
+<%= t(".no_accounts_to_setup") %>
+<%= t(".all_accounts_linked") %>
++ <%= t(".choose_account_type") %> +
+Setup instructions:
+secret-token: prefix) and paste it belowField descriptions:
+secret-token: prefix (required)
+ 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 %>
+Configured and ready to use. Visit the Accounts tab to manage and set up accounts.
+ <% else %> + +Not configured
+ <% end %> +