diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index 9ae5767e5..f7a4510be 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -20,6 +20,7 @@ class AccountsController < ApplicationController @coinbase_items = visible_provider_items(family.coinbase_items.ordered.includes(:coinbase_accounts, :accounts, :syncs)) @snaptrade_items = visible_provider_items(family.snaptrade_items.ordered.includes(:syncs, :snaptrade_accounts)) @indexa_capital_items = visible_provider_items(family.indexa_capital_items.ordered.includes(:syncs, :indexa_capital_accounts)) + @sophtron_items = visible_provider_items(family.sophtron_items.ordered.includes(:syncs, :sophtron_accounts)) # Build sync stats maps for all providers build_sync_stats_maps @@ -299,6 +300,13 @@ class AccountsController < ApplicationController @coinstats_sync_stats_map[item.id] = latest_sync&.sync_stats || {} end + # Sophtron sync stats + @sophtron_sync_stats_map = {} + @sophtron_items.each do |item| + latest_sync = item.syncs.ordered.first + @sophtron_sync_stats_map[item.id] = latest_sync&.sync_stats || {} + end + # Mercury sync stats @mercury_sync_stats_map = {} @mercury_items.each do |item| diff --git a/app/controllers/settings/bank_sync_controller.rb b/app/controllers/settings/bank_sync_controller.rb index f954e5369..91e55a3ef 100644 --- a/app/controllers/settings/bank_sync_controller.rb +++ b/app/controllers/settings/bank_sync_controller.rb @@ -30,6 +30,13 @@ class Settings::BankSyncController < ApplicationController path: "https://enablebanking.com", target: "_blank", rel: "noopener noreferrer" + }, + { + name: "Sophtron (alpha)", + description: "US & Canada bank, credit card, investment, loan, insurance, utility, and other connections.", + path: "https://www.sophtron.com/", + target: "_blank", + rel: "noopener noreferrer" } ] end diff --git a/app/controllers/settings/providers_controller.rb b/app/controllers/settings/providers_controller.rb index b8c07784b..ed32b8893 100644 --- a/app/controllers/settings/providers_controller.rb +++ b/app/controllers/settings/providers_controller.rb @@ -6,7 +6,7 @@ class Settings::ProvidersController < ApplicationController def show @breadcrumbs = [ [ "Home", root_path ], - [ "Sync Providers", nil ] + [ "Bank Sync Providers", nil ] ] prepare_show_context @@ -125,7 +125,8 @@ class Settings::ProvidersController < ApplicationController Provider::Factory.ensure_adapters_loaded @provider_configurations = Provider::ConfigurationRegistry.all.reject do |config| 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("enable_banking").zero? || \ + config.provider_key.to_s.casecmp("sophtron").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? || \ @@ -137,6 +138,8 @@ class Settings::ProvidersController < ApplicationController @simplefin_items = Current.family.simplefin_items.where.not(access_url: [ nil, "" ]).ordered.select(:id) @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 + # Providers page only needs to know whether any Sophtron connections exist with valid credentials + @sophtron_items = Current.family.sophtron_items.where.not(user_id: [ nil, "" ], access_key: [ nil, "" ]).ordered.select(:id) @coinstats_items = Current.family.coinstats_items.ordered # CoinStats panel needs account info for status display @mercury_items = Current.family.mercury_items.ordered.select(:id) @coinbase_items = Current.family.coinbase_items.ordered # Coinbase panel needs name and sync info for status display diff --git a/app/controllers/sophtron_items_controller.rb b/app/controllers/sophtron_items_controller.rb new file mode 100644 index 000000000..73fc9c730 --- /dev/null +++ b/app/controllers/sophtron_items_controller.rb @@ -0,0 +1,757 @@ +class SophtronItemsController < ApplicationController + before_action :set_sophtron_item, only: [ :show, :edit, :update, :destroy, :sync, :setup_accounts, :complete_account_setup ] + + def index + @sophtron_items = Current.family.sophtron_items.active.ordered + render layout: "settings" + end + + def show + end + + # Preload Sophtron accounts in background (async, non-blocking) + def preload_accounts + begin + # Check if family has credentials + unless Current.family.has_sophtron_credentials? + render json: { success: false, error: "no_credentials_configured", has_accounts: false } + return + end + + cache_key = "sophtron_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 + sophtron_provider = Provider::SophtronAdapter.build_provider(family: Current.family) + + unless sophtron_provider.present? + render json: { success: false, error: "no_access_key", has_accounts: false } + return + end + + response = sophtron_provider.get_accounts + available_accounts = response.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::Error => e + Rails.logger.error("Sophtron preload error: #{e.message}") + # API error (bad key, network issue, etc) - keep button visible, show error when clicked + render json: { success: false, error: "api_error", error_message: t(".api_error"), has_accounts: nil } + rescue StandardError => e + Rails.logger.error("Unexpected error preloading Sophtron accounts: #{e.class}: #{e.message}") + # Unexpected error - keep button visible, show error when clicked + render json: { success: false, error: "unexpected_error", error_message: t(".unexpected_error"), has_accounts: nil } + end + end + + # Fetch available accounts from Sophtron API and show selection UI + def select_accounts + begin + # Check if family has Sophtron credentials configured + unless Current.family.has_sophtron_credentials? + if turbo_frame_request? + # Render setup modal for turbo frame requests + render partial: "sophtron_items/setup_required", layout: false + else + # Redirect for regular requests + redirect_to settings_providers_path, + alert: t(".no_credentials_configured") + end + return + end + + cache_key = "sophtron_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? + sophtron_provider = Provider::SophtronAdapter.build_provider(family: Current.family) + + unless sophtron_provider.present? + redirect_to settings_providers_path, alert: t(".no_access_key") + return + end + + response = sophtron_provider.get_accounts + @available_accounts = response.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 + sophtron_item = Current.family.sophtron_items.first + if sophtron_item + linked_account_ids = sophtron_item.sophtron_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::Error => e + Rails.logger.error("Sophtron API error in select_accounts: #{e.message}") + @error_message = t(".api_error") + @return_path = safe_return_to_path + render partial: "sophtron_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 = t(".unexpected_error") + @return_path = safe_return_to_path + render partial: "sophtron_items/api_error", + locals: { error_message: @error_message, return_path: @return_path }, + layout: false + end + end + + # Create accounts from selected Sophtron 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 sophtron_item for this family + sophtron_item = Current.family.sophtron_items.first_or_create!( + name: t("sophtron_items.defaults.name") + ) + + # Fetch account details from API + sophtron_provider = Provider::SophtronAdapter.build_provider(family: Current.family) + unless sophtron_provider.present? + redirect_to new_account_path, alert: t(".no_access_key") + return + end + + response = sophtron_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 = response.data[:accounts].find { |acc| acc[:id].to_s == account_id.to_s } + next unless account_data + + # Validate account name is not blank (required by Account model) + if account_data[:account_name].blank? + invalid_accounts << account_id + Rails.logger.warn "SophtronItemsController - Skipping account #{account_id} with blank name" + next + end + + # Create or find sophtron_account + sophtron_account = sophtron_item.sophtron_accounts.find_or_initialize_by( + account_id: account_id.to_s + ) + sophtron_account.upsert_sophtron_snapshot!(account_data) + sophtron_account.save! + # Check if this sophtron_account is already linked + if sophtron_account.account_provider.present? + already_linked_accounts << account_data[:account_name] + next + end + + # Create the internal Account with proper balance initialization + account = Account.create_and_sync( + { + family: Current.family, + name: account_data[:account_name], + balance: 0, # Initial balance will be set during sync + currency: account_data[:currency] || "USD", + accountable_type: accountable_type, + accountable_attributes: {} + }, + skip_initial_sync: true + ) + # Link account to sophtron_account via account_providers join table + AccountProvider.create!( + account: account, + provider: sophtron_account + ) + + created_accounts << account + end + + # Trigger sync to fetch transactions if any accounts were created + sophtron_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::Error => e + redirect_to new_account_path, alert: t(".api_error") + Rails.logger.error("Sophtron API error in link_accounts: #{e.message}") + end + + # Fetch available Sophtron 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 Sophtron credentials configured + unless Current.family.has_sophtron_credentials? + if turbo_frame_request? + # Render setup modal for turbo frame requests + render partial: "sophtron_items/setup_required", layout: false + else + # Redirect for regular requests + redirect_to settings_providers_path, + alert: t(".no_credentials_configured", + default: "Please configure your Sophtron API key first in Provider Settings.") + end + return + end + + begin + cache_key = "sophtron_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? + sophtron_provider = Provider::SophtronAdapter.build_provider(family: Current.family) + + unless sophtron_provider.present? + redirect_to settings_providers_path, alert: t(".no_access_key") + return + end + + response = sophtron_provider.get_accounts + @available_accounts = response.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 + sophtron_item = Current.family.sophtron_items.first + if sophtron_item + linked_account_ids = sophtron_item.sophtron_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::Error => e + Rails.logger.error("Sophtron API error in select_existing_account: #{e.message}") + @error_message = t(".api_error", message: e.message) + render partial: "sophtron_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 = t(".unexpected_error") + render partial: "sophtron_items/api_error", + locals: { error_message: @error_message, return_path: accounts_path }, + layout: false + end + end + + # Link a selected Sophtron account to an existing account + def link_existing_account + account_id = params[:account_id] + sophtron_account_id = params[:sophtron_account_id] + return_to = safe_return_to_path + + unless account_id.present? && sophtron_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 sophtron_item for this family + sophtron_item = Current.family.sophtron_items.first_or_create!( + name: "Sophtron Connection" + ) + + # Fetch account details from API + sophtron_provider = Provider::SophtronAdapter.build_provider(family: Current.family) + unless sophtron_provider.present? + redirect_to accounts_path, alert: t(".no_access_key") + return + end + + response = sophtron_provider.get_accounts + + # Find the selected Sophtron account data + account_data = response.data[:accounts].find { |acc| acc[:id].to_s == sophtron_account_id.to_s } + unless account_data + redirect_to accounts_path, alert: t(".sophtron_account_not_found") + return + end + + # Validate account name is not blank (required by Account model) + if account_data[:account_name].blank? + redirect_to accounts_path, alert: t(".invalid_account_name") + return + end + + # Create or find sophtron_account + sophtron_account = sophtron_item.sophtron_accounts.find_or_initialize_by( + account_id: sophtron_account_id.to_s + ) + sophtron_account.upsert_sophtron_snapshot!(account_data) + sophtron_account.save! + + # Check if this sophtron_account is already linked to another account + if sophtron_account.account_provider.present? + redirect_to accounts_path, alert: t(".sophtron_account_already_linked") + return + end + + # Link account to sophtron_account via account_providers join table + AccountProvider.create!( + account: @account, + provider: sophtron_account + ) + + # Trigger sync to fetch transactions + sophtron_item.sync_later + redirect_to return_to || accounts_path, + notice: t(".success", account_name: @account.name) + rescue Provider::Error => e + Rails.logger.error("Sophtron API error in link_existing_account: #{e.message}") + redirect_to accounts_path, alert: t(".api_error") + end + + def new + @sophtron_item = Current.family.sophtron_items.build + end + + def create + @sophtron_item = Current.family.sophtron_items.build(sophtron_params) + @sophtron_item.name ||= t("sophtron_items.defaults.name") + if @sophtron_item.save + # Trigger initial sync to fetch accounts + @sophtron_item.sync_later + if turbo_frame_request? + flash.now[:notice] = t(".success") + @sophtron_items = Current.family.sophtron_items.ordered + render turbo_stream: [ + turbo_stream.replace( + "sophtron-providers-panel", + partial: "settings/providers/sophtron_panel", + locals: { sophtron_items: @sophtron_items } + ), + *flash_notification_stream_items + ] + else + redirect_to accounts_path, notice: t(".success"), status: :see_other + end + else + @error_message = @sophtron_item.errors.full_messages.join(", ") + if turbo_frame_request? + render turbo_stream: turbo_stream.replace( + "sophtron-providers-panel", + partial: "settings/providers/sophtron_panel", + locals: { error_message: @error_message } + ), status: :unprocessable_entity + else + render :new, status: :unprocessable_entity + end + end + end + def edit + end + + def update + if @sophtron_item.update(sophtron_params) + if turbo_frame_request? + flash.now[:notice] = t(".success") + @sophtron_items = Current.family.sophtron_items.ordered + render turbo_stream: [ + turbo_stream.replace( + "sophtron-providers-panel", + partial: "settings/providers/sophtron_panel", + locals: { sophtron_items: @sophtron_items } + ), + *flash_notification_stream_items + ] + else + redirect_to accounts_path, notice: t(".success"), status: :see_other + end + else + @error_message = @sophtron_item.errors.full_messages.join(", ") + if turbo_frame_request? + render turbo_stream: turbo_stream.replace( + "sophtron-providers-panel", + partial: "settings/providers/sophtron_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 + @sophtron_item.unlink_all!(dry_run: false) + rescue => e + Rails.logger.warn("Sophtron unlink during destroy failed: #{e.class} - #{e.message}") + end + @sophtron_item.destroy_later + redirect_to accounts_path, notice: t(".success") + end + + def sync + unless @sophtron_item.syncing? + @sophtron_item.sync_later + end + + respond_to do |format| + format.html { redirect_back_or_to accounts_path } + format.json { head :ok } + end + end + + # Show unlinked Sophtron accounts for setup (similar to SimpleFIN setup_accounts) + def setup_accounts + # First, ensure we have the latest accounts from the API + @api_error = fetch_sophtron_accounts_from_api + + # Get Sophtron accounts that are not linked (no AccountProvider) + @sophtron_accounts = @sophtron_item.sophtron_accounts + .left_joins(:account_provider) + .where(account_providers: { id: nil }) + + # Get supported account types from the adapter + supported_types = Provider::SophtronAdapter.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 + + # Subtype options for each account type + @subtype_options = { + "Depository" => { + label: "Account Subtype:", + options: Depository::SUBTYPES.map { |k, v| [ v[:long], k ] } + }, + "CreditCard" => { + label: "", + options: [], + message: "Credit cards will be automatically set up as credit card accounts." + }, + "Investment" => { + label: "Investment Type:", + options: Investment::SUBTYPES.map { |k, v| [ v[:long], k ] } + }, + "Loan" => { + label: "Loan Type:", + options: Loan::SUBTYPES.map { |k, v| [ v[:long], k ] } + }, + "Crypto" => { + label: nil, + options: [], + message: "Crypto accounts track cryptocurrency holdings." + }, + "OtherAsset" => { + label: nil, + options: [], + message: "No additional options needed for Other Assets." + } + } + end + + def complete_account_setup + account_types = params[:account_types] || {} + account_subtypes = params[:account_subtypes] || {} + + # Valid account types for this provider + valid_types = Provider::SophtronAdapter.supported_account_types + + created_accounts = [] + skipped_count = 0 + + begin + ActiveRecord::Base.transaction do + account_types.each do |sophtron_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 Sophtron account #{sophtron_account_id}") + next + end + + # Find account - scoped to this item to prevent cross-item manipulation + sophtron_account = @sophtron_item.sophtron_accounts.find_by(id: sophtron_account_id) + unless sophtron_account + Rails.logger.warn("Sophtron account #{sophtron_account_id} not found for item #{@sophtron_item.id}") + next + end + + # Skip if already linked (race condition protection) + if sophtron_account.account_provider.present? + Rails.logger.info("Sophtron account #{sophtron_account_id} already linked, skipping") + next + end + + selected_subtype = account_subtypes[sophtron_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) + account = Account.create_and_sync( + { + family: Current.family, + name: sophtron_account.name, + balance: sophtron_account.balance || 0, + currency: sophtron_account.currency || "USD", + accountable_type: selected_type, + accountable_attributes: selected_subtype.present? ? { subtype: selected_subtype } : {} + }, + skip_initial_sync: true + ) + + # Link account to sophtron_account via account_providers join table (raises on failure) + AccountProvider.create!( + account: account, + provider: sophtron_account + ) + + created_accounts << account + end + end + rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved => e + Rails.logger.error("Sophtron account setup failed: #{e.class} - #{e.message}") + Rails.logger.error(e.backtrace.first(10).join("\n")) + flash[:alert] = t(".creation_failed") + redirect_to accounts_path, status: :see_other + return + rescue StandardError => e + Rails.logger.error("Sophtron account setup failed unexpectedly: #{e.class} - #{e.message}") + Rails.logger.error(e.backtrace.first(10).join("\n")) + flash[:alert] = t(".unexpected_error") + redirect_to accounts_path, status: :see_other + return + end + + # Trigger a sync to process transactions + @sophtron_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 + } + @sophtron_items = Current.family.sophtron_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(@sophtron_item), + partial: "sophtron_items/sophtron_item", + locals: { sophtron_item: @sophtron_item } + ) + ] + Array(flash_notification_stream_items) + else + redirect_to accounts_path, status: :see_other + end + end + + private + + # Fetch Sophtron accounts from the API and store them locally + # Returns nil on success, or an error message string on failure + def fetch_sophtron_accounts_from_api + # Skip if we already have accounts cached + return nil unless @sophtron_item.sophtron_accounts.empty? + + # Validate Access key is configured + unless @sophtron_item.credentials_configured? + return t("sophtron_items.setup_accounts.no_access_key") + end + + # Use the specific sophtron_item's provider (scoped to this family's item) + sophtron_provider = @sophtron_item.sophtron_provider + unless sophtron_provider.present? + return t("sophtron_items.setup_accounts.no_access_key") + end + + begin + response = sophtron_provider.get_accounts + available_accounts = response.data[:accounts] || [] + + if available_accounts.empty? + return nil + end + + available_accounts.each_with_index do |account_data, index| + next if account_data[:account_name].blank? + + sophtron_account = @sophtron_item.sophtron_accounts.find_or_initialize_by( + account_id: account_data[:account_id].to_s + ) + sophtron_account.upsert_sophtron_snapshot!(account_data) + sophtron_account.save! + end + + nil # Success + rescue Provider::Error => e + Rails.logger.error("Sophtron API error: #{e.message}") + t("sophtron_items.setup_accounts.api_error") + rescue StandardError => e + Rails.logger.error("Unexpected error fetching Sophtron accounts: #{e.class}: #{e.message}") + Rails.logger.error(e.backtrace.first(10).join("\n")) + t("sophtron_items.setup_accounts.api_error") + end + end + + def set_sophtron_item + @sophtron_item = Current.family.sophtron_items.find(params[:id]) + end + + def sophtron_params + params.require(:sophtron_item).permit(:name, :user_id, :access_key, :base_url, :sync_start_date) + 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? || uri.host.present? + return nil if return_to.start_with?("//") + # 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/jobs/identify_recurring_transactions_job.rb b/app/jobs/identify_recurring_transactions_job.rb index 92e1e9151..9bb2c4156 100644 --- a/app/jobs/identify_recurring_transactions_job.rb +++ b/app/jobs/identify_recurring_transactions_job.rb @@ -51,6 +51,7 @@ class IdentifyRecurringTransactionsJob < ApplicationJob return true if family.simplefin_items.joins(:syncs).merge(Sync.incomplete).exists? if family.respond_to?(:simplefin_items) return true if family.lunchflow_items.joins(:syncs).merge(Sync.incomplete).exists? if family.respond_to?(:lunchflow_items) return true if family.enable_banking_items.joins(:syncs).merge(Sync.incomplete).exists? if family.respond_to?(:enable_banking_items) + return true if family.sophtron_items.joins(:syncs).merge(Sync.incomplete).exists? if family.respond_to?(:sophtron_items) # Check accounts' syncs return true if family.accounts.joins(:syncs).merge(Sync.incomplete).exists? diff --git a/app/models/data_enrichment.rb b/app/models/data_enrichment.rb index 5c720cc1f..db09f81b5 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", mercury: "mercury", indexa_capital: "indexa_capital" } + enum :source, { rule: "rule", plaid: "plaid", simplefin: "simplefin", lunchflow: "lunchflow", synth: "synth", ai: "ai", enable_banking: "enable_banking", coinstats: "coinstats", mercury: "mercury", indexa_capital: "indexa_capital", sophtron: "sophtron" } end diff --git a/app/models/family.rb b/app/models/family.rb index 6156f6861..3a3547b58 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -1,7 +1,7 @@ class Family < ApplicationRecord include Syncable, AutoTransferMatchable, Subscribeable, VectorSearchable include PlaidConnectable, SimplefinConnectable, LunchflowConnectable, EnableBankingConnectable - include CoinbaseConnectable, BinanceConnectable, CoinstatsConnectable, SnaptradeConnectable, MercuryConnectable + include CoinbaseConnectable, BinanceConnectable, CoinstatsConnectable, SnaptradeConnectable, MercuryConnectable, SophtronConnectable include IndexaCapitalConnectable DATE_FORMATS = [ diff --git a/app/models/family/sophtron_connectable.rb b/app/models/family/sophtron_connectable.rb new file mode 100644 index 000000000..f2f241dad --- /dev/null +++ b/app/models/family/sophtron_connectable.rb @@ -0,0 +1,29 @@ +module Family::SophtronConnectable + extend ActiveSupport::Concern + + included do + has_many :sophtron_items, dependent: :destroy + end + + def can_connect_sophtron? + # Families can now configure their own Sophtron credentials + true + end + + def create_sophtron_item!(user_id:, access_key:, base_url: nil, item_name: nil) + sophtron_item = sophtron_items.create!( + name: item_name || "Sophtron Connection", + user_id: user_id, + access_key: access_key, + base_url: base_url + ) + + sophtron_item.sync_later + + sophtron_item + end + + def has_sophtron_credentials? + sophtron_items.where.not(user_id: [ nil, "" ], access_key: [ nil, "" ]).exists? + end +end diff --git a/app/models/family/syncer.rb b/app/models/family/syncer.rb index 858ed9bc4..3eace0b06 100644 --- a/app/models/family/syncer.rb +++ b/app/models/family/syncer.rb @@ -18,6 +18,7 @@ class Family::Syncer coinstats_items mercury_items snaptrade_items + sophtron_items ].freeze def initialize(family) diff --git a/app/models/provider/sophtron.rb b/app/models/provider/sophtron.rb new file mode 100644 index 000000000..69526524c --- /dev/null +++ b/app/models/provider/sophtron.rb @@ -0,0 +1,373 @@ +# Sophtron API client for account aggregation. +# +# This provider implements the Sophtron API v2 for fetching bank account data, +# transactions, and balances. It uses HMAC-SHA256 authentication for secure +# API requests. +# +# The Sophtron API organizes data hierarchically: +# - Customers (identified by customer_id) +# - Accounts (identified by account_id within a customer) +# - Transactions (identified by transaction_id within an account) +# +# @example Initialize a Sophtron provider +# provider = Provider::Sophtron.new( +# "user123", +# "base64_encoded_access_key", +# base_url: "https://api.sophtron.com/api/v2" +# ) +# +# @see https://www.sophtron.com Documentation for Sophtron API +class Provider::Sophtron < Provider + include HTTParty + + headers "User-Agent" => "Sure Finance So Client" + default_options.merge!(verify: true, ssl_verify_mode: OpenSSL::SSL::VERIFY_PEER, timeout: 120) + + attr_reader :user_id, :access_key, :base_url + + # Initializes a new Sophtron API client. + # + # @param user_id [String] Sophtron User ID for authentication + # @param access_key [String] Base64-encoded Sophtron Access Key + # @param base_url [String] Base URL for the Sophtron API (defaults to production) + def initialize(user_id, access_key, base_url: "https://api.sophtron.com/api/v2") + @user_id = user_id + @access_key = access_key + @base_url = base_url + super() + end + + # Fetches all accounts across all customers for this Sophtron user. + # + # This method: + # 1. Fetches the list of customer IDs + # 2. For each customer, fetches their accounts + # 3. Normalizes and deduplicates the account data + # 4. Returns a combined list of all accounts + # + # @return [Hash] Account data with keys: + # - :accounts [Array] Array of account objects + # - :total [Integer] Total number of accounts + # @raise [Provider::Error] if the API request fails + # @example + # result = provider.get_accounts + # # => { accounts: [{id: "123", account_name: "Checking", ...}], total: 1 } + def get_accounts + with_provider_response do + # fetching accounts for sophtron + # Obtain customer IDs using a dedicated helper + customer_ids = get_customer_ids + + all_accounts = [] + customer_ids.each do |cust_id| + begin + accounts_resp = get_customer_accounts(cust_id) + + # `handle_response` returns parsed JSON (hash/array) so normalize + raw_accounts = if accounts_resp.is_a?(Hash) && accounts_resp[:accounts].is_a?(Array) + accounts_resp[:accounts] + elsif accounts_resp.is_a?(Array) + accounts_resp + else + [] + end + + normalized = raw_accounts.map { |a| a.transform_keys { |k| k.to_s.underscore }.with_indifferent_access } + + # Ensure each account has a customer_id set + normalized.each do |acc| + # check common variants that may already exist + existing = acc[:customer_id] + acc[:customer_id] = cust_id.to_s if existing.blank? + end + + all_accounts.concat(normalized) + rescue Provider::Error => e + Rails.logger.warn("Failed to fetch accounts for customer #{cust_id}: #{e.message}") + rescue => e + Rails.logger.warn("Unexpected error fetching accounts for customer #{cust_id}: #{e.class} #{e.message}") + end + end + + # Deduplicate by id where present + unique_accounts = all_accounts.uniq { |a| a[:id].to_s } + + { accounts: unique_accounts, total: unique_accounts.length } + end + end + + # Fetches transactions for a specific account. + # + # Retrieves transaction history for a given account within a date range. + # If no end date is provided, defaults to tomorrow to include today's transactions. + # + # @param customer_id [String] Sophtron customer ID + # @param account_id [String] Sophtron account ID + # @param start_date [Date, nil] Start date for transaction history (optional) + # @param end_date [Date, nil] End date for transaction history (defaults to tomorrow) + # @return [Hash] Transaction data with keys: + # - :transactions [Array] Array of transaction objects + # - :total [Integer] Total number of transactions + # @raise [Provider::Error] if the API request fails + # @example + # result = provider.get_account_transactions("cust123", "acct456", start_date: 30.days.ago) + # # => { transactions: [{id: "tx1", amount: -50.00, ...}], total: 25 } + def get_account_transactions(customer_id, account_id, start_date: nil, end_date: nil) + with_provider_response do + query_params = {} + + if start_date + query_params[:startDate] = start_date.to_date + end + if end_date + query_params[:endDate] = end_date.to_date + else + query_params[:endDate] = Date.tomorrow + end + + path = "/customers/#{ERB::Util.url_encode(customer_id.to_s)}/accounts/#{ERB::Util.url_encode(account_id.to_s)}/transactions" + path += "?#{URI.encode_www_form(query_params)}" unless query_params.empty? + url = "#{@base_url}#{path}" + + response = self.class.get( + url, + headers: auth_headers(url: url, http_method: "GET") + ) + + parsed = handle_response(response) + # Normalize transactions response into { transactions: [...], total: N } + if parsed.is_a?(Array) + txs = parsed.map { |tx| tx.transform_keys { |k| k.to_s.underscore }.with_indifferent_access } + mapped = txs.map { |tx| map_transaction(tx, account_id) } + { transactions: mapped, total: mapped.length } + elsif parsed.is_a?(Hash) + if parsed[:transactions].is_a?(Array) + txs = parsed[:transactions].map { |tx| tx.transform_keys { |k| k.to_s.underscore }.with_indifferent_access } + mapped = txs.map { |tx| map_transaction(tx, account_id) } + parsed[:transactions] = mapped + parsed[:total] = parsed[:total] || mapped.length + parsed + else + # Single transaction object -> wrap and map + single = parsed.transform_keys { |k| k.to_s.underscore }.with_indifferent_access + mapped = map_transaction(single, account_id) + { transactions: [ mapped ], total: 1 } + end + else + { transactions: [], total: 0 } + end + end + end + + # Fetches the current balance for a specific account. + # + # @param customer_id [String] Sophtron customer ID + # @param account_id [String] Sophtron account ID + # @return [Hash] Balance data with keys: + # - :balance [Hash] Balance information + # - :amount [Numeric] Current balance amount + # - :currency [String] Currency code (defaults to "USD") + # @raise [Provider::Error] if the API request fails + # @example + # result = provider.get_account_balance("cust123", "acct456") + # # => { balance: { amount: 1000.00, currency: "USD" } } + def get_account_balance(customer_id, account_id) + with_provider_response do + path = "/customers/#{ERB::Util.url_encode(customer_id.to_s)}/accounts/#{ERB::Util.url_encode(account_id.to_s)}" + url = "#{@base_url}#{path}" + + response = self.class.get( + url, + headers: auth_headers(url: url, http_method: "GET") + ) + + parsed = handle_response(response) + + # Normalize balance information into { balance: { amount: N, currency: "XXX" } } + # Sophtron returns balance as flat fields: Balance and BalanceCurrency (capitalized) + # After JSON symbolization these become: :Balance and :BalanceCurrency + balance_amount = parsed[:Balance] || parsed[:balance] + balance_currency = parsed[:BalanceCurrency] || parsed[:balance_currency] + + if parsed.is_a?(Hash) && balance_amount.present? + result = { + balance: { + amount: balance_amount, + currency: balance_currency.presence || "USD" + } + } + else + result = { balance: { amount: 0, currency: "USD" } } + end + result + end + end + + private + + def sophtron_auth_code(url:, http_method:) + require "base64" + require "openssl" + # sophtron auth code generation + # Parse path portion of the URL and use the last "/..." segment (matching upstream examples) + uri = URI.parse(url) + # Sign the last path segment (lowercased) and include the query string if present + path = (uri.path || "").downcase + idx = path.rindex("/") + last_seg = idx ? path[idx..-1] : path + query_str = uri.query ? "?#{uri.query.to_s.downcase}" : "" + auth_path = "#{last_seg}#{query_str}" + # Build the plain text to sign: "METHOD\n/auth_path" + plain_key = "#{http_method.to_s.upcase}\n#{auth_path}" + # Decode the base64 access key and compute HMAC-SHA256 + begin + key_bytes = Base64.decode64(@access_key.to_s) + rescue => decode_err + Rails.logger.error("[sophtron_auth_code] Failed to decode access_key: #{decode_err.class}: #{decode_err.message}") + raise + end + signature = OpenSSL::HMAC.digest(OpenSSL::Digest.new("sha256"), key_bytes, plain_key) + sig_b64_str = Base64.strict_encode64(signature) + auth_code = "FIApiAUTH:#{@user_id}:#{sig_b64_str}:#{auth_path}" + auth_code + end + + def auth_headers(url:, http_method:) + { + "Authorization" => sophtron_auth_code(url: url, http_method: http_method), + "Content-Type" => "application/json", + "Accept" => "application/json" + } + end + + # Fetch list of customer IDs by calling GET /customers and extracting identifier fields + def get_customer_ids + url = "#{@base_url}/customers" + response = self.class.get( + url, + headers: auth_headers(url: url, http_method: "GET") + ) + parsed = handle_response(response) + ids = [] + if parsed.is_a?(Array) + ids = parsed.map do |r| + next unless r.is_a?(Hash) + # Find a key that likely contains the customer id (handles :CustomerID, :customerID, :customer_id, :ID, :id) + key = r.keys.find { |k| k.to_s.downcase.include?("customer") && k.to_s.downcase.include?("id") } || + r.keys.find { |k| k.to_s.downcase == "id" } + r[key] + end.compact + elsif parsed.is_a?(Hash) + if parsed[:customers].is_a?(Array) + ids = parsed[:customers].map do |r| + next unless r.is_a?(Hash) + key = r.keys.find { |k| k.to_s.downcase.include?("customer") && k.to_s.downcase.include?("id") } || + r.keys.find { |k| k.to_s.downcase == "id" } + r[key] + end.compact + else + key = parsed.keys.find { |k| k.to_s.downcase.include?("customer") && k.to_s.downcase.include?("id") } || + parsed.keys.find { |k| k.to_s.downcase == "id" } + ids = [ parsed[key] ].compact + end + end + + # Normalize to strings and unique (avoid destructive methods that may return nil) + ids = ids.map(&:to_s).compact.uniq + ids + end + + # Fetch accounts for a specific customer via GET /customers/:customer_id/accounts + def get_customer_accounts(customer_id) + path = "/customers/#{ERB::Util.url_encode(customer_id.to_s)}/accounts" + url = "#{@base_url}#{path}" + response = self.class.get( + url, + headers: auth_headers(url: url, http_method: "GET") + ) + handle_response(response) + end + + # Map a normalized Sophtron transaction hash into our standard transaction shape + # Returns: { id, accountId, type, status, amount, currency, date, merchant, description } + def map_transaction(tx, account_id) + tx = tx.with_indifferent_access + { + id: tx[:transaction_id], + accountId: account_id, + type: tx[:type] || "unknown", + status: tx[:status] || "completed", + amount: tx[:amount] || 0.0, + currency: tx[:currency] || "USD", + date: tx[:transaction_date] || nil, + merchant: tx[:merchant] || extract_merchant(tx[:description]) ||"", + description: tx[:description] || "" + }.with_indifferent_access + end + + def extract_merchant(line) + return nil if line.nil? + line = line.strip + return nil if line.empty? + + # 1. Handle special bank fees and automated transactions + if line =~ /INSUFFICIENT FUNDS FEE/i + return "Bank Fee: Insufficient Funds" + elsif line =~ /OVERDRAFT PROTECTION/i + return "Bank Transfer: Overdraft Protection" + elsif line =~ /AUTO PAY WF HOME MTG/i + return "Wells Fargo Home Mortgage" + elsif line =~ /PAYDAY LOAN/i + return "Payday Loan" + end + + # 2. Refined CHECKCARD Pattern + # Logic: + # - Start after 'CHECKCARD XXXX ' + # - Capture everything (.+?) + # - STOP when we see: + # a) Two or more spaces (\s{2,}) + # b) A masked number (x{3,}) + # c) A pattern of [One Word] + [Space] + [State Code] (\s+\S+\s+[A-Z]{2}\b) + # The (\s+\S+) part matches the city, so we stop BEFORE it. + if line =~ /CHECKCARD \d{4}\s+(.+?)(?=\s{2,}|x{3,}|\s+\S+\s+[A-Z]{2}\b)/i + return $1.strip + end + + # 3. Handle standard purchase rows (e.g., EXXONMOBIL POS 12/08) + # Stops before date (MM/DD) or hash (#) + if line =~ /^(.+?)(?=\s+\d{2}\/\d{2}|\s+#)/ + name = $1.strip + return name.gsub(/\s+POS$/i, "").strip + end + + # 4. Fallback for other formats + line[0..25].strip + end + + def handle_response(response) + case response.code + when 200 + begin + JSON.parse(response.body, symbolize_names: true) + rescue JSON::ParserError => e + Rails.logger.error "Sophtron API: Invalid JSON response - #{e.message}" + raise Provider::Error.new("Invalid JSON response from Sophtron API", :invalid_response) + end + when 400 + Rails.logger.error "Sophtron API: Bad request - #{response.body}" + raise Provider::Error.new("Bad request to Sophtron API: #{response.body}", :bad_request) + when 401 + raise Provider::Error.new("Invalid User ID or Access key", :unauthorized) + when 403 + raise Provider::Error.new("Access forbidden - check your User ID and Access key permissions", :access_forbidden) + when 404 + raise Provider::Error.new("Resource not found", :not_found) + when 429 + raise Provider::Error.new("Rate limit exceeded. Please try again later.", :rate_limited) + else + Rails.logger.error "Sophtron API: Unexpected response - Code: #{response.code}, Body: #{response.body}" + raise Provider::Error.new("Failed to fetch data: #{response.code} #{response.message} - #{response.body}", :fetch_failed) + end + end +end diff --git a/app/models/provider/sophtron_adapter.rb b/app/models/provider/sophtron_adapter.rb new file mode 100644 index 000000000..155c6bb2c --- /dev/null +++ b/app/models/provider/sophtron_adapter.rb @@ -0,0 +1,107 @@ +class Provider::SophtronAdapter < Provider::Base + include Provider::Syncable + include Provider::InstitutionMetadata + + # Register this adapter with the factory + Provider::Factory.register("SophtronAccount", self) + + # Define which account types this provider supports + def self.supported_account_types + %w[Depository CreditCard Loan Investment] + end + + # Returns connection configurations for this provider + def self.connection_configs(family:) + return [] unless family.can_connect_sophtron? + + [ { + key: "sophtron", + name: "Sophtron", + description: "Connect to your bank via Sophtron's secure API aggregation service.", + can_connect: true, + new_account_path: ->(accountable_type, return_to) { + Rails.application.routes.url_helpers.select_accounts_sophtron_items_path( + accountable_type: accountable_type, + return_to: return_to + ) + }, + existing_account_path: ->(account_id) { + Rails.application.routes.url_helpers.select_existing_account_sophtron_items_path( + account_id: account_id + ) + } + } ] + end + + def provider_name + "sophtron" + end + + # Build a Sophtron provider instance with family-specific credentials + # Sophtron is now fully per-family - no global credentials supported + # @param family [Family] The family to get credentials for (required) + # @return [Provider::Sophtron, nil] Returns nil if User ID and Access key is not configured + def self.build_provider(family: nil) + return nil unless family.present? + + # Get family-specific credentials + sophtron_item = family.sophtron_items.where.not(user_id: nil, access_key: nil).first + return nil unless sophtron_item&.credentials_configured? + + Provider::Sophtron.new( + sophtron_item.user_id, + sophtron_item.access_key, + base_url: sophtron_item.effective_base_url + ) + end + + def sync_path + Rails.application.routes.url_helpers.sync_sophtron_item_path(item) + end + + def item + provider_account.sophtron_item + end + + def can_delete_holdings? + false + end + + def institution_domain + # Sophtron may provide institution metadata in account data + 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 Sophtron 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 110a2535c..089d937eb 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", mercury: "mercury", indexa_capital: "indexa_capital" } + enum :source, { plaid: "plaid", simplefin: "simplefin", lunchflow: "lunchflow", synth: "synth", ai: "ai", enable_banking: "enable_banking", coinstats: "coinstats", mercury: "mercury", indexa_capital: "indexa_capital", sophtron: "sophtron" } validates :name, uniqueness: { scope: [ :source ] } validates :source, presence: true diff --git a/app/models/sophtron_account.rb b/app/models/sophtron_account.rb new file mode 100644 index 000000000..f61af0eab --- /dev/null +++ b/app/models/sophtron_account.rb @@ -0,0 +1,130 @@ +# Represents a single bank account from Sophtron. +# +# A SophtronAccount stores account-level data fetched from the Sophtron API, +# including balances, account type, and raw transaction data. It can be linked +# to a Maybe Account through the account_provider association. +# +# @attr [String] name Account name from Sophtron +# @attr [String] account_id Sophtron's unique identifier for this account +# @attr [String] customer_id Sophtron customer ID this account belongs to +# @attr [String] member_id Sophtron member ID +# @attr [String] currency Three-letter currency code (e.g., 'USD') +# @attr [Decimal] balance Current account balance +# @attr [Decimal] available_balance Available balance (for credit accounts) +# @attr [String] account_type Type of account (e.g., 'checking', 'savings') +# @attr [String] account_sub_type Detailed account subtype +# @attr [JSONB] raw_payload Raw account data from Sophtron API +# @attr [JSONB] raw_transactions_payload Raw transaction data from Sophtron API +# @attr [DateTime] last_updated When Sophtron last updated this account +class SophtronAccount < ApplicationRecord + include CurrencyNormalizable + + belongs_to :sophtron_item + + # Association to link this Sophtron account to a Maybe Account + 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 + validate :has_balance + # Returns the linked Maybe Account for this Sophtron account. + # + # @return [Account, nil] The linked Maybe Account, or nil if not linked + def current_account + account + end + + # Updates this SophtronAccount with fresh data from the Sophtron API. + # + # Maps Sophtron field names to our database schema and saves the changes. + # Stores the complete raw payload for reference. + # + # @param account_snapshot [Hash] Raw account data from Sophtron API + # @return [Boolean] true if save was successful + # @raise [ActiveRecord::RecordInvalid] if validation fails + def upsert_sophtron_snapshot!(account_snapshot) + # Convert to symbol keys or handle both string and symbol keys + snapshot = account_snapshot.with_indifferent_access + + # Map Sophtron field names to our field names + assign_attributes( + name: snapshot[:account_name], + account_id: snapshot[:account_id], + currency: parse_currency(snapshot[:balance_currency]) || "USD", + balance: parse_balance(snapshot[:balance]), + available_balance: parse_balance(snapshot[:"available-balance"]), + account_type: snapshot["account_type"] || "unknown", + account_sub_type: snapshot["sub_type"] || "unknown", + last_updated: parse_balance_date(snapshot[:"last_updated"]), + raw_payload: account_snapshot, + customer_id: snapshot["customer_id"], + member_id: snapshot["member_id"] + ) + + save! + end + + # Stores raw transaction data from the Sophtron API. + # + # This method saves the raw transaction payload which will later be + # processed by SophtronAccount::Transactions::Processor to create + # actual Transaction records. + # + # @param transactions_snapshot [Array] Array of raw transaction data + # @return [Boolean] true if save was successful + # @raise [ActiveRecord::RecordInvalid] if validation fails + def upsert_sophtron_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 Sophtron account #{id}, defaulting to USD") + end + + + def parse_balance(balance_value) + return nil if balance_value.nil? + + case balance_value + when String + BigDecimal(balance_value) + when Numeric + BigDecimal(balance_value.to_s) + else + nil + end + rescue ArgumentError + nil + end + + def parse_balance_date(balance_date_value) + return nil if balance_date_value.nil? + + case balance_date_value + when String + Time.parse(balance_date_value) + when Numeric + t = balance_date_value + t = (t / 1000.0) if t > 1_000_000_000_000 # likely ms epoch + Time.at(t) + when Time, DateTime + balance_date_value + else + nil + end + rescue ArgumentError, TypeError + Rails.logger.warn("Invalid balance date for Sophtron account: #{balance_date_value}") + nil + end + def has_balance + return if balance.present? || available_balance.present? + errors.add(:base, "Sophtron account must have either current or available balance") + end +end diff --git a/app/models/sophtron_account/processor.rb b/app/models/sophtron_account/processor.rb new file mode 100644 index 000000000..fcd88fedb --- /dev/null +++ b/app/models/sophtron_account/processor.rb @@ -0,0 +1,119 @@ +# Processes a SophtronAccount to update Maybe Account and Transaction records. +# +# This processor is responsible for: +# 1. Updating the linked Maybe Account's balance from Sophtron data +# 2. Processing stored transactions to create Maybe Transaction records +# +# The processor handles currency normalization and sign conventions for +# different account types (e.g., credit cards use inverted signs). +class SophtronAccount::Processor + include CurrencyNormalizable + + attr_reader :sophtron_account + + # Initializes a new processor for a Sophtron account. + # + # @param sophtron_account [SophtronAccount] The account to process + def initialize(sophtron_account) + @sophtron_account = sophtron_account + end + + # Processes the account to update balances and transactions. + # + # This method: + # - Validates that the account is linked to a Maybe Account + # - Updates the Maybe Account's balance from Sophtron data + # - Processes all stored transactions to create Transaction records + # + # @return [Hash, nil] Transaction processing result hash or nil if no linked account + # @raise [StandardError] if processing fails (errors are logged and reported to Sentry) + def process + unless sophtron_account.current_account.present? + Rails.logger.info "SophtronAccount::Processor - No linked account for sophtron_account #{sophtron_account.id}, skipping processing" + return + end + + Rails.logger.info "SophtronAccount::Processor - Processing sophtron_account #{sophtron_account.id} (account #{sophtron_account.account_id})" + begin + process_account! + rescue StandardError => e + Rails.logger.error "SophtronAccount::Processor - Failed to process account #{sophtron_account.id}: #{e.message}" + Rails.logger.error "Backtrace: #{e.backtrace.join("\n")}" + report_exception(e, "account") + raise + end + + process_transactions + end + + private + + # Updates the linked Maybe Account's balance from Sophtron data. + # + # Handles sign conventions for different account types: + # - CreditCard and Loan accounts use inverted signs (negated) + # - Other account types use Sophtron's native sign convention + # + # @return [void] + # @raise [ActiveRecord::RecordInvalid] if the account update fails + def process_account! + if sophtron_account.current_account.blank? + Rails.logger.error("Sophtron account #{sophtron_account.id} has no associated Account") + return + end + + # Update account balance from latest Sophtron data + account = sophtron_account.current_account + balance = sophtron_account.balance || sophtron_account.available_balance || 0 + + # Sophtron balance convention matches our app convention: + # - Positive balance = debt (you owe money) + # - Negative balance = credit balance (bank owes you, e.g., overpayment) + # No sign conversion needed - pass through as-is (same as Plaid) + # + # Exception: CreditCard and Loan accounts return inverted signs + # Provider returns negative for positive balance, so we negate it + if account.accountable_type == "CreditCard" || account.accountable_type == "Loan" + balance = -balance + end + + # Normalize currency with fallback chain: parsed sophtron currency -> existing account currency -> USD + currency = parse_currency(sophtron_account.currency) || account.currency || "USD" + # Update account balance + account.update!( + balance: balance, + cash_balance: balance, + currency: currency + ) + end + + # Processes all stored transactions for this account. + # + # Delegates to SophtronAccount::Transactions::Processor to convert + # raw transaction data into Maybe Transaction records. + # + # @return [void] + # @raise [StandardError] if transaction processing fails + def process_transactions + SophtronAccount::Transactions::Processor.new(sophtron_account).process + rescue StandardError => e + Rails.logger.error "SophtronAccount::Processor - Failed to process transactions for sophtron_account #{sophtron_account.id}: #{e.message}" + Rails.logger.error "Backtrace: #{e.backtrace.join("\n")}" + report_exception(e, "transactions") + raise + end + + # Reports an exception to Sentry with Sophtron account context. + # + # @param error [Exception] The error to report + # @param context [String] Additional context (e.g., 'account', 'transactions') + # @return [void] + def report_exception(error, context) + Sentry.capture_exception(error) do |scope| + scope.set_tags( + sophtron_account_id: sophtron_account.id, + context: context + ) + end + end +end diff --git a/app/models/sophtron_account/transactions/processor.rb b/app/models/sophtron_account/transactions/processor.rb new file mode 100644 index 000000000..20ca58eb7 --- /dev/null +++ b/app/models/sophtron_account/transactions/processor.rb @@ -0,0 +1,98 @@ +# Processes raw transaction data to create Maybe Transaction records. +# +# This processor takes the raw transaction payload stored in a SophtronAccount +# and converts each transaction into a Maybe Transaction record using +# SophtronEntry::Processor. It processes transactions individually to avoid +# database lock issues when handling large transaction volumes. +# +# The processor is resilient to errors - if one transaction fails, it logs +# the error and continues processing the remaining transactions. +class SophtronAccount::Transactions::Processor + attr_reader :sophtron_account + + # Initializes a new transaction processor. + # + # @param sophtron_account [SophtronAccount] The account whose transactions to process + def initialize(sophtron_account) + @sophtron_account = sophtron_account + end + + # Processes all transactions in the raw_transactions_payload. + # + # Each transaction is processed individually to avoid database lock contention. + # Errors are caught and logged, allowing the process to continue with remaining + # transactions. + # + # @return [Hash] Processing results with the following keys: + # - :success [Boolean] true if all transactions processed successfully + # - :total [Integer] Total number of transactions found + # - :imported [Integer] Number of transactions successfully imported + # - :failed [Integer] Number of transactions that failed + # - :errors [Array] Details of any errors encountered + # @example + # result = processor.process + # # => { success: true, total: 100, imported: 98, failed: 2, errors: [...] } + def process + unless sophtron_account.raw_transactions_payload.present? + Rails.logger.info "SophtronAccount::Transactions::Processor - No transactions in raw_transactions_payload for sophtron_account #{sophtron_account.id}" + return { success: true, total: 0, imported: 0, failed: 0, errors: [] } + end + + total_count = sophtron_account.raw_transactions_payload.count + Rails.logger.info "SophtronAccount::Transactions::Processor - Processing #{total_count} transactions for sophtron_account #{sophtron_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. + sophtron_account.raw_transactions_payload.each_with_index do |transaction_data, index| + begin + result = SophtronEntry::Processor.new( + transaction_data, + sophtron_account: sophtron_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 "SophtronAccount::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 "SophtronAccount::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 "SophtronAccount::Transactions::Processor - Completed with #{failed_count} failures out of #{total_count} transactions" + else + Rails.logger.info "SophtronAccount::Transactions::Processor - Successfully processed #{imported_count} transactions" + end + + result + end +end diff --git a/app/models/sophtron_entry/processor.rb b/app/models/sophtron_entry/processor.rb new file mode 100644 index 000000000..2ca30c124 --- /dev/null +++ b/app/models/sophtron_entry/processor.rb @@ -0,0 +1,229 @@ +require "digest/md5" + +# Processes a single Sophtron transaction and creates/updates a Maybe Transaction. +# +# This processor takes raw transaction data from the Sophtron API and converts it +# into a Maybe Transaction record using the Account::ProviderImportAdapter. +# It handles currency normalization, merchant matching, and data validation. +# +# Expected transaction structure from Sophtron: +# { +# id: String, +# accountId: String, +# amount: Numeric, +# currency: String, +# date: String/Date, +# merchant: String, +# description: String +# } +class SophtronEntry::Processor + include CurrencyNormalizable + + # Initializes a new processor for a Sophtron transaction. + # + # @param sophtron_transaction [Hash] Raw transaction data from Sophtron API + # @param sophtron_account [SophtronAccount] The account this transaction belongs to + def initialize(sophtron_transaction, sophtron_account:) + @sophtron_transaction = sophtron_transaction + @sophtron_account = sophtron_account + end + + # Processes the transaction and creates/updates a Maybe Transaction record. + # + # This method validates the transaction data, creates or finds a merchant, + # and uses the ProviderImportAdapter to import the transaction into Maybe. + # It respects user overrides through the enrichment pattern. + # + # @return [Entry, nil] The created/updated Entry, or nil if account not linked + # @raise [ArgumentError] if required transaction fields are missing + # @raise [StandardError] if the transaction cannot be saved + def process + # Validate that we have a linked account before processing + unless account.present? + Rails.logger.warn "SophtronEntry::Processor - No linked account for sophtron_account #{sophtron_account.id}, skipping 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: "sophtron", + merchant: merchant, + notes: notes + ) + rescue ArgumentError => e + # Re-raise validation errors (missing required fields, invalid data) + Rails.logger.error "SophtronEntry::Processor - Validation error for transaction #{external_id}: #{e.message}" + raise + rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved => e + # Handle database save errors + Rails.logger.error "SophtronEntry::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 "SophtronEntry::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 :sophtron_transaction, :sophtron_account + + # Returns the import adapter for this transaction's account. + # + # @return [Account::ProviderImportAdapter] Adapter for importing transactions + def import_adapter + @import_adapter ||= Account::ProviderImportAdapter.new(account) + end + + # Returns the linked Maybe Account for this transaction. + # + # @return [Account, nil] The linked account + def account + @account ||= sophtron_account.current_account + end + + # Returns the transaction data with indifferent access. + # + # @return [ActiveSupport::HashWithIndifferentAccess] Normalized transaction data + def data + @data ||= sophtron_transaction.with_indifferent_access + end + + # Generates a unique external ID for this transaction. + # + # Prefixes the Sophtron transaction ID with 'sophtron_' to avoid conflicts + # with other providers. + # + # @return [String] The external ID (e.g., 'sophtron_12345') + # @raise [ArgumentError] if the transaction ID is missing + def external_id + id = data[:id].presence + raise ArgumentError, "Sophtron transaction missing required field 'id'" unless id + "sophtron_#{id}" + end + + # Extracts the transaction name from the data. + # + # Falls back to "Unknown transaction" if merchant is not present. + # + # @return [String] The transaction name + def name + data[:merchant].presence || t("sophtron_items.sophtron_entry.processor.unknown_transaction") + end + + # Extracts optional notes/description from the transaction. + # + # @return [String, nil] Transaction description + def notes + data[:description].presence + end + + # Finds or creates a merchant for this transaction. + # + # Creates a deterministic merchant ID using MD5 hash of the merchant name. + # This ensures the same merchant name always maps to the same merchant record. + # + # @return [Merchant, nil] The merchant object, or nil if merchant data is missing + def merchant + return nil unless data[:merchant].present? + + # Create a stable merchant ID from the merchant name + # Using digest to ensure uniqueness while keeping it deterministic + merchant_name = data[:merchant].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: "sophtron_merchant_#{merchant_id}", + name: merchant_name, + source: "sophtron" + ) + rescue ActiveRecord::RecordInvalid => e + Rails.logger.error "SophtronEntry::Processor - Failed to create merchant '#{merchant_name}': #{e.message}" + nil + end + end + + # Parses and converts the transaction amount. + # + # Sophtron uses standard banking convention (negative = expense, positive = income) + # while Maybe uses inverted signs (positive = expense, negative = income). + # This method negates the amount to convert between conventions. + # + # @return [BigDecimal] The converted amount + # @raise [ArgumentError] if the amount cannot be parsed + def amount + parsed_amount = case data[:amount] + when String + BigDecimal(data[:amount]) + when Numeric + BigDecimal(data[:amount].to_s) + else + BigDecimal("0") + end + + # Sophtron likely uses standard convention where negative is expense, positive is income + # Maybe expects opposite convention (expenses positive, income negative) + # So we negate the amount to convert from Sophtron to Maybe format + -parsed_amount + rescue ArgumentError => e + Rails.logger.error "Failed to parse Sophtron transaction amount: #{data[:amount].inspect} - #{e.message}" + raise + end + + # Extracts and normalizes the currency code. + # + # Falls back to the account currency, then USD if not specified. + # + # @return [String] Three-letter currency code (e.g., 'USD') + def currency + parse_currency(data[:currency]) || account&.currency || "USD" + end + + # Logs invalid currency codes. + # + # @param currency_value [String] The invalid currency code + # @return [void] + def log_invalid_currency(currency_value) + Rails.logger.warn("Invalid currency code '#{currency_value}' in Sophtron transaction #{external_id}, falling back to account currency") + end + + # Parses the transaction date from various formats. + # + # Handles: + # - String dates (ISO format) + # - Unix timestamps (Integer/Float) + # - Time/DateTime objects + # - Date objects + # + # @return [Date] The parsed transaction date + # @raise [ArgumentError] if the date cannot be parsed + def date + case data[:date] + when String + Date.parse(data[:date]) + when Integer, Float + # Unix timestamp + Time.at(data[:date]).to_date + when Time, DateTime + data[:date].to_date + when Date + data[:date] + else + Rails.logger.error("Sophtron transaction has invalid date value: #{data[:date].inspect}") + raise ArgumentError, "Invalid date format: #{data[:date].inspect}" + end + rescue ArgumentError, TypeError => e + Rails.logger.error("Failed to parse Sophtron transaction date '#{data[:date]}': #{e.message}") + raise ArgumentError, "Unable to parse transaction date: #{data[:date].inspect}" + end +end diff --git a/app/models/sophtron_item.rb b/app/models/sophtron_item.rb new file mode 100644 index 000000000..52a5f3cb7 --- /dev/null +++ b/app/models/sophtron_item.rb @@ -0,0 +1,198 @@ +# Represents a Sophtron integration item for a family. +# +# A SophtronItem stores Sophtron API credentials and manages the connection +# to a family's Sophtron account. It can have multiple associated SophtronAccounts, +# which represent individual bank accounts linked through Sophtron. +# +# @attr [String] name The display name for this Sophtron connection +# @attr [String] user_id Sophtron User ID (encrypted if encryption is configured) +# @attr [String] access_key Sophtron Access Key (encrypted if encryption is configured) +# @attr [String] base_url Base URL for Sophtron API (optional, defaults to production) +# @attr [String] status Current status: 'good' or 'requires_update' +# @attr [Boolean] scheduled_for_deletion Whether the item is scheduled for deletion +# @attr [DateTime] last_synced_at When the last successful sync occurred +class SophtronItem < 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. + # + # Checks both Rails credentials and environment variables for encryption keys. + # + # @return [Boolean] true if encryption is properly configured + 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 (credentials OR env vars) + if encryption_ready? + encrypts :user_id, deterministic: true + encrypts :access_key, deterministic: true + end + + validates :name, presence: true + validates :user_id, presence: true, on: :create + validates :access_key, presence: true, on: :create + + belongs_to :family + has_one_attached :logo + + has_many :sophtron_accounts, dependent: :destroy + has_many :accounts, through: :sophtron_accounts + + scope :active, -> { where(scheduled_for_deletion: false) } + scope :syncable, -> { active } + 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 + + # Imports the latest account and transaction data from Sophtron. + # + # This method fetches all accounts and transactions from the Sophtron API + # and updates the local database accordingly. It will: + # - Fetch all accounts associated with the Sophtron connection + # - Create new SophtronAccount records for newly discovered accounts + # - Update existing linked accounts with latest data + # - Fetch and store transactions for all linked accounts + # + # @return [Hash] Import results with counts of accounts and transactions imported + # @raise [StandardError] if the Sophtron provider is not configured + # @raise [Provider::Error] if the Sophtron API returns an error + def import_latest_sophtron_data + provider = sophtron_provider + unless provider + Rails.logger.error "SophtronItem #{id} - Cannot import: Sophtron provider is not configured (missing API key)" + raise StandardError.new("Sophtron provider is not configured") + end + + SophtronItem::Importer.new(self, sophtron_provider: provider).import + rescue => e + Rails.logger.error "SophtronItem #{id} - Failed to import data: #{e.message}" + raise + end + + def process_accounts + return [] if sophtron_accounts.empty? + + results = [] + # Only process accounts that are linked and have active status + sophtron_accounts.joins(:account).merge(Account.visible).each do |sophtron_account| + begin + result = SophtronAccount::Processor.new(sophtron_account).process + results << { sophtron_account_id: sophtron_account.id, success: true, result: result } + rescue => e + Rails.logger.error "SophtronItem #{id} - Failed to process account #{sophtron_account.id}: #{e.message}" + results << { sophtron_account_id: sophtron_account.id, success: false, error: e.message } + # Continue processing other accounts even if one fails + end + end + + results + end + + def schedule_account_syncs(parent_sync: nil, window_start_date: nil, window_end_date: nil) + return [] if accounts.empty? + + results = [] + # Only schedule syncs for active accounts + 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 "SophtronItem #{id} - Failed to schedule sync for account #{account.id}: #{e.message}" + results << { account_id: account.id, success: false, error: e.message } + # Continue scheduling other accounts even if one fails + end + end + + results + end + + def upsert_sophtron_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 + + def sync_status_summary + # Use centralized count helper methods for consistency + 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 + sophtron_accounts.joins(:account_provider).count + end + + def unlinked_accounts_count + sophtron_accounts.left_joins(:account_provider).where(account_providers: { id: nil }).count + end + + def total_accounts_count + sophtron_accounts.count + end + + def institution_display_name + # Try to get institution name from stored metadata + institution_name.presence || institution_domain.presence || name + end + + def connected_institutions + # Get unique institutions from all accounts + sophtron_accounts.includes(:account) + .where.not(institution_metadata: nil) + .map { |acc| acc.institution_metadata } + .uniq { |inst| inst["name"] || inst["institution_name"] } + end + + 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? + user_id.present? && + access_key.present? + end + + def effective_base_url + base_url.presence || "https://api.sophtron.com/api/v2" + end +end diff --git a/app/models/sophtron_item/importer.rb b/app/models/sophtron_item/importer.rb new file mode 100644 index 000000000..dabf0f316 --- /dev/null +++ b/app/models/sophtron_item/importer.rb @@ -0,0 +1,446 @@ +require "set" + +# Imports account and transaction data from Sophtron API. +# +# This class orchestrates the complete import process for a SophtronItem: +# 1. Fetches all accounts from Sophtron +# 2. Updates existing linked accounts with latest data +# 3. Creates SophtronAccount records for newly discovered accounts +# 4. Fetches and stores transactions for all linked accounts +# 5. Updates account balances +# +# The importer maintains a separation between "discovered" accounts (any account +# returned by the Sophtron API) and "linked" accounts (accounts the user has +# explicitly connected to Maybe Accounts). This allows users to selectively +# import accounts of their choosing. +class SophtronItem::Importer + attr_reader :sophtron_item, :sophtron_provider + + # Initializes a new importer. + # + # @param sophtron_item [SophtronItem] The Sophtron item to import data for + # @param sophtron_provider [Provider::Sophtron] Configured Sophtron API client + def initialize(sophtron_item, sophtron_provider:) + @sophtron_item = sophtron_item + @sophtron_provider = sophtron_provider + end + + # Performs the complete import process for this Sophtron item. + # + # This method: + # - Fetches all accounts from Sophtron API + # - Stores raw account data snapshot + # - Updates existing linked accounts + # - Creates records for newly discovered accounts + # - Fetches transactions for all linked accounts + # - Updates account balances + # + # @return [Hash] Import results with the following keys: + # - :success [Boolean] Overall success status + # - :accounts_updated [Integer] Number of existing accounts updated + # - :accounts_created [Integer] Number of new account records created + # - :accounts_failed [Integer] Number of accounts that failed to import + # - :transactions_imported [Integer] Total number of transactions imported + # - :transactions_failed [Integer] Number of accounts with transaction import failures + # @example + # result = importer.import + # # => { success: true, accounts_updated: 2, accounts_created: 1, + # # accounts_failed: 0, transactions_imported: 150, transactions_failed: 0 } + def import + Rails.logger.info "SophtronItem::Importer - Starting import for item #{sophtron_item.id}" + # Step 1: Fetch all accounts from Sophtron + accounts_data = fetch_accounts_data + unless accounts_data + Rails.logger.error "SophtronItem::Importer - Failed to fetch accounts data for item #{sophtron_item.id}" + return { success: false, error: "Failed to fetch accounts data", accounts_imported: 0, transactions_imported: 0 } + end + + # Store raw payload + begin + sophtron_item.upsert_sophtron_snapshot!(accounts_data) + rescue => e + Rails.logger.error "SophtronItem::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 sophtron account IDs (ones actually imported/used by the user) + linked_account_ids = sophtron_item.sophtron_accounts + .joins(:account_provider) + .pluck(:account_id) + .map(&:to_s) + # Get all existing sophtron account IDs (linked or not) + all_existing_ids = sophtron_item.sophtron_accounts.pluck(:account_id).map(&:to_s) + accounts_data[:accounts].each do |account_data| + account_id = (account_data[:account_id] || account_data[:id])&.to_s + next unless account_id.present? + account_name = account_data[:account_name] || account_data[:name] + 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 "SophtronItem::Importer - Failed to update account #{account_id}: #{e.message}" + end + elsif !all_existing_ids.include?(account_id) + # Create new unlinked sophtron_account records for accounts we haven't seen before + # This allows users to link them later via "Setup new accounts" + begin + sophtron_account = sophtron_item.sophtron_accounts.build( + account_id: account_id, + name: account_name, + currency: account_data[:currency] || "USD" + ) + sophtron_account.upsert_sophtron_snapshot!(account_data) + accounts_created += 1 + Rails.logger.info "SophtronItem::Importer - Created new unlinked account record for #{account_id}" + rescue => e + accounts_failed += 1 + Rails.logger.error "SophtronItem::Importer - Failed to create account #{account_id}: #{e.message}" + end + end + end + end + + Rails.logger.info "SophtronItem::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 + + linked_accounts = sophtron_item.sophtron_accounts.joins(:account).merge(Account.visible) + linked_accounts.each do |sophtron_account| + begin + result = fetch_and_store_transactions(sophtron_account) + if result[:success] + transactions_imported += result[:transactions_count] + else + transactions_failed += 1 + end + rescue => e + transactions_failed += 1 + Rails.logger.error "SophtronItem::Importer - Failed to fetch/store transactions for account #{sophtron_account.account_id}: #{e.message}" + # Continue with other accounts even if one fails + end + end + + Rails.logger.info "SophtronItem::Importer - Completed import for item #{sophtron_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 = sophtron_provider.get_accounts + # Extract data from Provider::Response object if needed + if accounts_data.respond_to?(:data) + accounts_data = accounts_data.data + end + rescue Provider::Error => e + # Handle authentication errors by marking item as requiring update + if e.error_type == :unauthorized || e.error_type == :access_forbidden + begin + sophtron_item.update!(status: :requires_update) + rescue => update_error + Rails.logger.error "SophtronItem::Importer - Failed to update item status: #{update_error.message}" + end + end + Rails.logger.error "SophtronItem::Importer - Sophtron API error: #{e.message}" + return nil + rescue JSON::ParserError => e + Rails.logger.error "SophtronItem::Importer - Failed to parse Sophtron API response: #{e.message}" + return nil + rescue => e + Rails.logger.error "SophtronItem::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 "SophtronItem::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 + + # Imports and updates a single account from Sophtron data. + # + # This method only updates existing SophtronAccount records that were + # previously created. It does not create new accounts during sync. + # + # @param account_data [Hash] Raw account data from Sophtron API + # @return [void] + # @raise [ArgumentError] if account_data is invalid or account_id is missing + # @raise [StandardError] if the account cannot be saved + def import_account(account_data) + # Validate account data structure + unless account_data.is_a?(Hash) + Rails.logger.error "SophtronItem::Importer - Invalid account_data format: expected Hash, got #{account_data.class}" + raise ArgumentError, "Invalid account data format" + end + + account_id = (account_data[:account_id] || account_data[:id])&.to_s + + # Validate required account_id + if account_id.blank? + Rails.logger.warn "SophtronItem::Importer - Skipping account with missing ID" + raise ArgumentError, "Account ID is required" + end + + # Only find existing accounts, don't create new ones during sync + sophtron_account = sophtron_item.sophtron_accounts.find_by( + account_id: account_id + ) + + # Skip if account wasn't previously selected + unless sophtron_account + return + end + + begin + sophtron_account.upsert_sophtron_snapshot!(account_data) + sophtron_account.save! + rescue ActiveRecord::RecordInvalid => e + Rails.logger.error "SophtronItem::Importer - Failed to save sophtron_account: #{e.message}" + raise StandardError.new("Failed to save account: #{e.message}") + end + end + + # Fetches and stores transactions for a Sophtron account. + # + # This method: + # 1. Determines the appropriate sync start date + # 2. Fetches transactions from the Sophtron API + # 3. Deduplicates against existing transactions + # 4. Stores new transactions in raw_transactions_payload + # 5. Updates the account balance + # + # @param sophtron_account [SophtronAccount] The account to fetch transactions for + # @return [Hash] Result with keys: + # - :success [Boolean] Whether the fetch was successful + # - :transactions_count [Integer] Number of transactions fetched + # - :error [String, nil] Error message if failed + def fetch_and_store_transactions(sophtron_account) + start_date = determine_sync_start_date(sophtron_account) + Rails.logger.info "SophtronItem::Importer - Fetching transactions for account #{sophtron_account.account_id} from #{start_date}" + + begin + # Fetch transactions + transactions_data = sophtron_provider.get_account_transactions( + sophtron_account.customer_id, + sophtron_account.account_id, + start_date: start_date + ) + + # Extract data from Provider::Response object if needed + if transactions_data.respond_to?(:data) + transactions_data = transactions_data.data + end + + # Validate response structure + unless transactions_data.is_a?(Hash) + Rails.logger.error "SophtronItem::Importer - Invalid transactions_data format for account #{sophtron_account.account_id}" + return { success: false, transactions_count: 0, error: "Invalid response format" } + end + + transactions_count = transactions_data[:transactions]&.count || 0 + Rails.logger.info "SophtronItem::Importer - Fetched #{transactions_count} transactions for account #{sophtron_account.account_id}" + + # Store transactions in the account + if transactions_data[:transactions].present? + begin + existing_transactions = sophtron_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 "SophtronItem::Importer - Storing #{new_transactions.count} new transactions (#{existing_transactions.count} existing, #{transactions_data[:transactions].count - new_transactions.count} duplicates skipped) for account #{sophtron_account.account_id}" + sophtron_account.upsert_sophtron_transactions_snapshot!(existing_transactions + new_transactions) + else + Rails.logger.info "SophtronItem::Importer - No new transactions to store (all #{transactions_data[:transactions].count} were duplicates) for account #{sophtron_account.account_id}" + end + rescue => e + Rails.logger.error "SophtronItem::Importer - Failed to store transactions for account #{sophtron_account.account_id}: #{e.message}" + return { success: false, transactions_count: 0, error: "Failed to store transactions: #{e.message}" } + end + else + Rails.logger.info "SophtronItem::Importer - No transactions to store for account #{sophtron_account.account_id}" + end + + # Fetch and update balance + begin + fetch_and_update_balance(sophtron_account) + rescue => e + # Log but don't fail transaction import if balance fetch fails + Rails.logger.warn "SophtronItem::Importer - Failed to update balance for account #{sophtron_account.account_id}: #{e.message}" + end + + { success: true, transactions_count: transactions_count } + rescue Provider::Error => e + Rails.logger.error "SophtronItem::Importer - Sophtron API error for account #{sophtron_account.id}: #{e.message}" + { success: false, transactions_count: 0, error: e.message } + rescue JSON::ParserError => e + Rails.logger.error "SophtronItem::Importer - Failed to parse transaction response for account #{sophtron_account.id}: #{e.message}" + { success: false, transactions_count: 0, error: "Failed to parse response" } + rescue => e + Rails.logger.error "SophtronItem::Importer - Unexpected error fetching transactions for account #{sophtron_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 fetch_and_update_balance(sophtron_account) + begin + balance_data = sophtron_provider.get_account_balance(sophtron_account.customer_id, sophtron_account.account_id) + # Extract data from Provider::Response object if needed + if balance_data.respond_to?(:data) + balance_data = balance_data.data + end + + # Validate response structure + unless balance_data.is_a?(Hash) + Rails.logger.error "SophtronItem::Importer - Invalid balance_data format for account #{sophtron_account.account_id}" + return + end + + if balance_data[:balance].present? + balance_info = balance_data[:balance] + + # Validate balance info structure + unless balance_info.is_a?(Hash) + Rails.logger.error "SophtronItem::Importer - Invalid balance info format for account #{sophtron_account.account_id}" + return + end + + # Only update if we have a valid amount + if balance_info[:amount].present? + sophtron_account.update!( + balance: balance_info[:amount], + currency: balance_info[:currency].presence || sophtron_account.currency + ) + else + Rails.logger.warn "SophtronItem::Importer - No amount in balance data for account #{sophtron_account.account_id}" + end + else + Rails.logger.warn "SophtronItem::Importer - No balance data returned for account #{sophtron_account.account_id}" + end + rescue Provider::Error => e + Rails.logger.error "SophtronItem::Importer - Sophtron API error fetching balance for account #{sophtron_account.id}: #{e.message}" + # Don't fail if balance fetch fails + rescue ActiveRecord::RecordInvalid => e + Rails.logger.error "SophtronItem::Importer - Failed to save balance for account #{sophtron_account.id}: #{e.message}" + # Don't fail if balance save fails + rescue => e + Rails.logger.error "SophtronItem::Importer - Unexpected error updating balance for account #{sophtron_account.id}: #{e.class} - #{e.message}" + # Don't fail if balance update fails + end + end + + # Determines the appropriate start date for fetching transactions. + # + # Logic: + # - For accounts with stored transactions: uses last sync date minus 60-day buffer + # - For new accounts: uses account creation date minus 60 days, capped at 120 days ago + # + # This ensures we capture any late-arriving transactions while limiting + # the historical window for new accounts. + # + # @param sophtron_account [SophtronAccount] The account to determine start date for + # @return [Date] The start date for transaction sync + def determine_sync_start_date(sophtron_account) + configured_start = sophtron_item.sync_start_date&.to_time + max_history_start = 3.years.ago + floor_start = [ configured_start, max_history_start ].compact.max + # 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 = sophtron_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 sophtron_item.last_synced_at + [ sophtron_item.last_synced_at - 60.days, floor_start ].compact.max + else + # Fallback if item hasn't been synced but account has transactions + floor_start || 120.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 = sophtron_account.created_at || Time.current + first_sync_window = [ account_baseline - 60.days, floor_start || 120.days.ago ].max + + # Use the more recent of: (account created - 60 days) or (120 days ago) + # This caps old accounts at 120 days while respecting recent account creation dates + first_sync_window + end + end + + # Handles API errors and marks the item for re-authentication if needed. + # + # Authentication-related errors cause the item status to be set to + # :requires_update, prompting the user to re-enter credentials. + # + # @param error_message [String] The error message from the API + # @return [void] + # @raise [Provider::Error] Always raises an error with the message + 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?("user id") || + error_msg_lower.include?("access key") + + if needs_update + begin + sophtron_item.update!(status: :requires_update) + rescue => e + Rails.logger.error "SophtronItem::Importer - Failed to update item status: #{e.message}" + end + end + + Rails.logger.error "SophtronItem::Importer - API error: #{error_message}" + raise Provider::Error.new( + "Sophtron API error: #{error_message}", + :api_error + ) + end +end diff --git a/app/models/sophtron_item/provided.rb b/app/models/sophtron_item/provided.rb new file mode 100644 index 000000000..f18b0281f --- /dev/null +++ b/app/models/sophtron_item/provided.rb @@ -0,0 +1,9 @@ +module SophtronItem::Provided + extend ActiveSupport::Concern + + def sophtron_provider + return nil unless credentials_configured? + + Provider::Sophtron.new(user_id, access_key, base_url: effective_base_url) + end +end diff --git a/app/models/sophtron_item/sync_complete_event.rb b/app/models/sophtron_item/sync_complete_event.rb new file mode 100644 index 000000000..9bb83089c --- /dev/null +++ b/app/models/sophtron_item/sync_complete_event.rb @@ -0,0 +1,25 @@ +class SophtronItem::SyncCompleteEvent + attr_reader :sophtron_item + + def initialize(sophtron_item) + @sophtron_item = sophtron_item + end + + def broadcast + # Update UI with latest account data + sophtron_item.accounts.each do |account| + account.broadcast_sync_complete + end + + # Update the Sophtron item view + sophtron_item.broadcast_replace_to( + sophtron_item.family, + target: "sophtron_item_#{sophtron_item.id}", + partial: "sophtron_items/sophtron_item", + locals: { sophtron_item: sophtron_item } + ) + + # Let family handle sync notifications + sophtron_item.family.broadcast_sync_complete + end +end diff --git a/app/models/sophtron_item/syncer.rb b/app/models/sophtron_item/syncer.rb new file mode 100644 index 000000000..70465ac83 --- /dev/null +++ b/app/models/sophtron_item/syncer.rb @@ -0,0 +1,96 @@ +# Orchestrates the complete sync process for a SophtronItem. +# +# The syncer coordinates multiple phases: +# 1. Import accounts and transactions from Sophtron API +# 2. Check account setup status and collect statistics +# 3. Process transactions for linked accounts +# 4. Schedule balance calculations +# 5. Collect sync statistics and health metrics +# +# This follows the same pattern as other provider syncers (SimpleFIN, Plaid) +# and integrates with the Syncable concern. +class SophtronItem::Syncer + include SyncStats::Collector + + attr_reader :sophtron_item + + # Initializes a new syncer for a Sophtron item. + # + # @param sophtron_item [SophtronItem] The item to sync + def initialize(sophtron_item) + @sophtron_item = sophtron_item + end + + # Performs the complete sync process. + # + # This method orchestrates all phases of the sync: + # - Imports fresh data from Sophtron API + # - Updates linked accounts and creates new account records + # - Processes transactions for linked accounts only + # - Schedules balance calculations + # - Collects statistics and health metrics + # + # @param sync [Sync] The sync record to track progress and status + # @return [void] + # @raise [StandardError] if any phase of the sync fails + def perform_sync(sync) + # Phase 1: Import data from Sophtron API + sync.update!(status_text: t("sophtron_items.syncer.importing_accounts")) if sync.respond_to?(:status_text) + sophtron_item.import_latest_sophtron_data + + # Phase 2: Check account setup status and collect sync statistics + sync.update!(status_text: t("sophtron_items.syncer.checking_account_configuration")) if sync.respond_to?(:status_text) + collect_setup_stats(sync, provider_accounts: sophtron_item.sophtron_accounts) + + # Check for unlinked accounts + linked_accounts = sophtron_item.sophtron_accounts.joins(:account_provider) + unlinked_accounts = sophtron_item.sophtron_accounts.left_joins(:account_provider).where(account_providers: { id: nil }) + + # Set pending_account_setup if there are unlinked accounts + unlinked_count = unlinked_accounts.count + if unlinked_count.positive? + sophtron_item.update!(pending_account_setup: true) + sync.update!(status_text: t("sophtron_items.syncer.accounts_need_setup", count: unlinked_count)) if sync.respond_to?(:status_text) + else + sophtron_item.update!(pending_account_setup: false) + end + + # Phase 3: Process transactions for linked accounts only + if linked_accounts.any? + sync.update!(status_text: t("sophtron_items.syncer.processing_transactions")) if sync.respond_to?(:status_text) + mark_import_started(sync) + Rails.logger.info "SophtronItem::Syncer - Processing #{linked_accounts.count} linked accounts" + sophtron_item.process_accounts + Rails.logger.info "SophtronItem::Syncer - Finished processing accounts" + + # Phase 4: Schedule balance calculations for linked accounts + sync.update!(status_text: t("sophtron_items.syncer.calculating_balances")) if sync.respond_to?(:status_text) + sophtron_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 { |la| la.current_account&.id } + collect_transaction_stats(sync, account_ids: account_ids, source: "sophtron") + else + Rails.logger.info "SophtronItem::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 + + # Performs post-sync cleanup or actions. + # + # Currently a no-op for Sophtron items. Reserved for future use. + # + # @return [void] + def perform_post_sync + # no-op + end +end diff --git a/app/models/sophtron_item/unlinking.rb b/app/models/sophtron_item/unlinking.rb new file mode 100644 index 000000000..998c31b15 --- /dev/null +++ b/app/models/sophtron_item/unlinking.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +module SophtronItem::Unlinking + # Concern that encapsulates unlinking logic for a Sophtron item. + # Mirrors the SimplefinItem::Unlinking behavior. + extend ActiveSupport::Concern + + # Idempotently removes all connections between this Sophtron item and local accounts. + # + # This method: + # - Finds all AccountProvider links for each SophtronAccount + # - Detaches any Holdings associated with those links + # - Destroys the AccountProvider links + # - Returns detailed results for observability + # + # This mirrors the SimplefinItem::Unlinking behavior. + # + # @param dry_run [Boolean] If true, only report what would be unlinked without making changes + # @return [Array] Results for each account with keys: + # - :sfa_id [Integer] The SophtronAccount ID + # - :name [String] The account name + # - :provider_link_ids [Array] IDs of AccountProvider links found + # @example + # item.unlink_all!(dry_run: true) # Preview what would be unlinked + # item.unlink_all! # Actually unlink all accounts + def unlink_all!(dry_run: false) + results = [] + + sophtron_accounts.find_each do |sfa| + links = AccountProvider.where(provider_type: "SophtronAccount", provider_id: sfa.id).to_a + link_ids = links.map(&:id) + result = { + sfa_id: sfa.id, + name: sfa.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 => e + Rails.logger.warn( + "SophtronItem Unlinker: failed to fully unlink SophtronAccount ##{sfa.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/views/accounts/index.html.erb b/app/views/accounts/index.html.erb index ec2fce04e..af9b7e45f 100644 --- a/app/views/accounts/index.html.erb +++ b/app/views/accounts/index.html.erb @@ -17,7 +17,7 @@ ) %> <% end %> -<% if @manual_accounts.empty? && @plaid_items.empty? && @simplefin_items.empty? && @lunchflow_items.empty? && @enable_banking_items.empty? && @coinstats_items.empty? && @coinbase_items.empty? && @mercury_items.empty? && @snaptrade_items.empty? && @indexa_capital_items.empty? %> +<% if @manual_accounts.empty? && @plaid_items.empty? && @simplefin_items.empty? && @lunchflow_items.empty? && @enable_banking_items.empty? && @coinstats_items.empty? && @coinbase_items.empty? && @mercury_items.empty? && @snaptrade_items.empty? && @indexa_capital_items.empty? && @sophtron_items.empty? %> <%= render "empty" %> <% else %>
@@ -41,6 +41,10 @@ <%= render @coinstats_items.sort_by(&:created_at) %> <% end %> + <% if @sophtron_items.any? %> + <%= render @sophtron_items.sort_by(&:created_at) %> + <% end %> + <% if @mercury_items.any? %> <%= render @mercury_items.sort_by(&:created_at) %> <% end %> diff --git a/app/views/settings/bank_sync/_provider_link.html.erb b/app/views/settings/bank_sync/_provider_link.html.erb index 32a2caf59..6cb50df92 100644 --- a/app/views/settings/bank_sync/_provider_link.html.erb +++ b/app/views/settings/bank_sync/_provider_link.html.erb @@ -6,7 +6,8 @@ "Plaid" => "#4da568", "SimpleFin" => "#e99537", "Enable Banking" => "#6471eb", - "CoinStats" => "#FF9332" # https://coinstats.app/press-kit/ + "CoinStats" => "#FF9332", # https://coinstats.app/press-kit/ + "Sophtron" => "#1E90FF" } %> <% provider_color = provider_colors[provider_link[:name]] || "#6B7280" %> diff --git a/app/views/settings/providers/_sophtron_panel.html.erb b/app/views/settings/providers/_sophtron_panel.html.erb new file mode 100644 index 000000000..878d4dd3f --- /dev/null +++ b/app/views/settings/providers/_sophtron_panel.html.erb @@ -0,0 +1,68 @@ +
+
+

<%= t("sophtron_items.sophtron_panel.setup_instructions_title") %>

+
    +
  1. <%= t("sophtron_items.sophtron_panel.setup_instructions.step_1_html", url: "https://www.sophtron.com") %>
  2. +
  3. <%= t("sophtron_items.sophtron_panel.setup_instructions.step_2") %>
  4. +
  5. <%= t("sophtron_items.sophtron_panel.setup_instructions.step_3") %>
  6. +
+ +

<%= t("sophtron_items.sophtron_panel.field_descriptions_title") %>

+
    +
  • <%= t("sophtron_items.sophtron_panel.field_descriptions.user_id_html") %>
  • +
  • <%= t("sophtron_items.sophtron_panel.field_descriptions.access_key_html") %>
  • +
  • <%= t("sophtron_items.sophtron_panel.field_descriptions.base_url_html") %>
  • +
+
+ + <% error_msg = local_assigns[:error_message] || @error_message %> + <% if error_msg.present? %> +
+

<%= error_msg %>

+
+ <% end %> + + <% + # Variables passed from controller + sophtron_item = @sophtron_item || Current.family.sophtron_items.build + is_new_record = @is_new_record || sophtron_item.new_record? + sophtron_items = @sophtron_items + %> + + <%= styled_form_with model: sophtron_item, + url: sophtron_item.new_record? ? sophtron_items_path : sophtron_item_path(sophtron_item), + scope: :sophtron_item, + method: sophtron_item.new_record? ? :post : :patch, + data: { turbo: true }, + class: "space-y-3" do |form| %> + <%= form.text_field :user_id, + label: t("sophtron_items.sophtron_panel.fields.user_id.label"), + placeholder: is_new_record ? t("sophtron_items.sophtron_panel.fields.user_id.placeholder_new") : t("sophtron_items.sophtron_panel.fields.user_id.placeholder_edit"), + type: :password %> + + <%= form.text_field :access_key, + label: t("sophtron_items.sophtron_panel.fields.access_key.label"), + placeholder: is_new_record ? t("sophtron_items.sophtron_panel.fields.access_key.placeholder_new") : t("sophtron_items.sophtron_panel.fields.access_key.placeholder_edit"), + type: :password %> + + <%= form.text_field :base_url, + label: t("sophtron_items.sophtron_panel.fields.base_url.label"), + placeholder: t("sophtron_items.sophtron_panel.fields.base_url.placeholder"), + value: sophtron_item.base_url %> + +
+ <%= form.submit is_new_record ? t("sophtron_items.sophtron_panel.save") : t("sophtron_items.sophtron_panel.update"), + class: "inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium text-inverse bg-inverse hover:bg-inverse-hover focus:outline-none focus:ring-2 focus:ring-primary transition-colors" %> +
+ <% end %> + +
+ <% if Current.family.sophtron_items.any? %> +
+

<%= t("sophtron_items.sophtron_panel.status.configured_html", accounts_path: accounts_path) %>

+ <% else %> +
+

<%= t("sophtron_items.sophtron_panel.status.not_configured") %>

+ <% end %> +
+
\ No newline at end of file diff --git a/app/views/settings/providers/show.html.erb b/app/views/settings/providers/show.html.erb index 4a9d76bdc..9f2b07c09 100644 --- a/app/views/settings/providers/show.html.erb +++ b/app/views/settings/providers/show.html.erb @@ -84,5 +84,11 @@ <%= render "settings/providers/indexa_capital_panel" %> <% end %> + + <%= settings_section title: "Sophtron (alpha)", collapsible: true, open: false do %> + + <%= render "settings/providers/sophtron_panel" %> + + <% end %> <% end %>
diff --git a/app/views/sophtron_items/_api_error.html.erb b/app/views/sophtron_items/_api_error.html.erb new file mode 100644 index 000000000..852fd09b7 --- /dev/null +++ b/app/views/sophtron_items/_api_error.html.erb @@ -0,0 +1,36 @@ +<%# locals: (error_message:, return_path: nil) %> +<%= turbo_frame_tag "modal" do %> + <%= render DS::Dialog.new do |dialog| %> + <% dialog.with_header(title: t("sophtron_items.api_error.title")) %> + <% dialog.with_body do %> +
+
+ <%= icon("alert-circle", class: "text-destructive w-5 h-5 shrink-0 mt-0.5") %> +
+

<%= t("sophtron_items.api_error.unable_to_connect") %>

+

<%= h(error_message) %>

+
+
+ +
+

<%= t("sophtron_items.api_error.common_issues_title") %>

+
    +
  • <%= t("sophtron_items.api_error.incorrect_user_id") %>
  • +
  • <%= t("sophtron_items.api_error.invalid_access_key") %>
  • +
  • <%= t("sophtron_items.api_error.expired_credentials") %>
  • +
  • <%= t("sophtron_items.api_error.network_issue") %>
  • +
  • <%= t("sophtron_items.api_error.service_down") %>
  • +
+
+ +
+ <%= link_to (return_path.presence || settings_providers_path), + class: "inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium text-inverse bg-inverse hover:bg-inverse-hover focus:outline-none focus:ring-2 focus:ring-primary transition-colors", + data: { turbo: false } do %> + <%= t("sophtron_items.api_error.check_provider_settings") %> + <% end %> +
+
+ <% end %> + <% end %> +<% end %> diff --git a/app/views/sophtron_items/_loading.html.erb b/app/views/sophtron_items/_loading.html.erb new file mode 100644 index 000000000..e0ea126c1 --- /dev/null +++ b/app/views/sophtron_items/_loading.html.erb @@ -0,0 +1,16 @@ +<%= turbo_frame_tag "modal" do %> + <%= render DS::Dialog.new do |dialog| %> + <% dialog.with_header(title: t(".loading_title")) %> + + <% dialog.with_body do %> +
+
+ <%= icon("loader-circle", class: "h-8 w-8 animate-spin text-primary") %> +

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

+
+
+ <% end %> + <% end %> +<% end %> diff --git a/app/views/sophtron_items/_setup_required.html.erb b/app/views/sophtron_items/_setup_required.html.erb new file mode 100644 index 000000000..b74b9dacc --- /dev/null +++ b/app/views/sophtron_items/_setup_required.html.erb @@ -0,0 +1,34 @@ +<%= turbo_frame_tag "modal" do %> + <%= render DS::Dialog.new do |dialog| %> + <% dialog.with_header(title: t("sophtron_items.sophtron_setup_required.title")) %> + <% dialog.with_body do %> +
+
+ <%= icon("alert-circle", class: "text-warning w-5 h-5 shrink-0 mt-0.5") %> +
+

<%= t("sophtron_items.sophtron_setup_required.heading") %>

+

<%= t("sophtron_items.sophtron_setup_required.description") %>

+
+
+ +
+

<%= t("sophtron_items.sophtron_setup_required.setup_steps_title") %>

+
    +
  1. <%= t("sophtron_items.sophtron_setup_required.step_1_html") %>
  2. +
  3. <%= t("sophtron_items.sophtron_setup_required.step_2_html") %>
  4. +
  5. <%= t("sophtron_items.sophtron_setup_required.step_3_html") %>
  6. +
  7. <%= t("sophtron_items.sophtron_setup_required.step_4") %>
  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 %> + <%= t("sophtron_items.sophtron_setup_required.go_to_provider_settings") %> + <% end %> +
+
+ <% end %> + <% end %> +<% end %> \ No newline at end of file diff --git a/app/views/sophtron_items/_sophtron_item.html.erb b/app/views/sophtron_items/_sophtron_item.html.erb new file mode 100644 index 000000000..dd4431af6 --- /dev/null +++ b/app/views/sophtron_items/_sophtron_item.html.erb @@ -0,0 +1,130 @@ +<%# locals: (sophtron_item:) %> + +<%= tag.div id: dom_id(sophtron_item) do %> +
+ +
+ <%= icon "chevron-right", class: "group-open:transform group-open:rotate-90" %> + +
+ <% if sophtron_item.logo.attached? %> + <%= image_tag sophtron_item.logo, class: "rounded-full h-full w-full", loading: "lazy" %> + <% else %> +
+ <%= tag.p sophtron_item.name&.first&.upcase || "?", class: "text-orange-600 text-xs font-medium" %> +
+ <% end %> +
+ +
+
+ <%= tag.p sophtron_item.name, class: "font-medium text-primary" %> + <% if sophtron_item.scheduled_for_deletion? %> +

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

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

+ <%= sophtron_item.institution_summary %> +

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

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

+ <% end %> +
+
+ +
+ <% if Rails.env.development? %> + <%= icon( + "refresh-cw", + as_button: true, + href: sync_sophtron_item_path(sophtron_item) + ) %> + <% end %> + + <%= render DS::Menu.new do |menu| %> + <% menu.with_item( + variant: "button", + text: t(".delete"), + icon: "trash-2", + href: sophtron_item_path(sophtron_item), + method: :delete, + confirm: CustomConfirm.for_resource_deletion(sophtron_item.name, high_severity: true) + ) %> + <% end %> +
+
+ + <% unless sophtron_item.scheduled_for_deletion? %> +
+ <% if sophtron_item.accounts.any? %> + <%= render "accounts/index/account_groups", accounts: sophtron_item.accounts %> + <% end %> + + <%# Sync summary (collapsible) - using shared ProviderSyncSummary component %> + <% stats = if defined?(@sophtron_sync_stats_map) && @sophtron_sync_stats_map + @sophtron_sync_stats_map[sophtron_item.id] || {} + else + sophtron_item.syncs.ordered.first&.sync_stats || {} + end %> + <%= render ProviderSyncSummary.new( + stats: stats, + provider_item: sophtron_item, + institutions_count: sophtron_item.connected_institutions.size + ) %> + + <%# Use model methods for consistent counts %> + <% unlinked_count = sophtron_item.unlinked_accounts_count %> + <% linked_count = sophtron_item.linked_accounts_count %> + <% total_count = sophtron_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_sophtron_item_path(sophtron_item), + frame: :modal + ) %> +
+ <% elsif sophtron_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_sophtron_item_path(sophtron_item), + frame: :modal + ) %> +
+ <% end %> +
+ <% end %> +
+<% end %> diff --git a/app/views/sophtron_items/_subtype_select.html.erb b/app/views/sophtron_items/_subtype_select.html.erb new file mode 100644 index 000000000..6ca3bcb8d --- /dev/null +++ b/app/views/sophtron_items/_subtype_select.html.erb @@ -0,0 +1,23 @@ + diff --git a/app/views/sophtron_items/select_accounts.html.erb b/app/views/sophtron_items/select_accounts.html.erb new file mode 100644 index 000000000..6063ac4e6 --- /dev/null +++ b/app/views/sophtron_items/select_accounts.html.erb @@ -0,0 +1,54 @@ +<%= 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| %> + <% Rails.logger.debug "Sophtron account data: #{account.inspect}" %> + <% has_blank_name = account[: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/sophtron_items/select_existing_account.html.erb b/app/views/sophtron_items/select_existing_account.html.erb new file mode 100644 index 000000000..6273a7c1f --- /dev/null +++ b/app/views/sophtron_items/select_existing_account.html.erb @@ -0,0 +1,58 @@ +<%= 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") %> +

+ + <%= form_with url: link_existing_account_sophtron_items_path, + method: :post, + class: "space-y-4", + data: { turbo_frame: "_top" } do %> + <%= hidden_field_tag :account_id, @account.id %> + <%= hidden_field_tag :return_to, @return_to %> + +
+ <% @available_accounts.each do |account| %> + <% has_blank_name = account[: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: "click->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 %> +<% end %> diff --git a/app/views/sophtron_items/setup_accounts.html.erb b/app/views/sophtron_items/setup_accounts.html.erb new file mode 100644 index 000000000..006cc1594 --- /dev/null +++ b/app/views/sophtron_items/setup_accounts.html.erb @@ -0,0 +1,105 @@ +<% content_for :title, "Set Up Sophtron 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_sophtron_item_path(@sophtron_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 @sophtron_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 %> +
+
+
+
+ + <% @sophtron_accounts.each do |sophtron_account| %> +
+
+
+

+ <%= sophtron_account.name %> +

+
+
+ +
+
+ <%= label_tag "account_types[#{sophtron_account.id}]", t(".account_type_label"), + class: "block text-sm font-medium text-primary mb-2" %> + <%= select_tag "account_types[#{sophtron_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 "sophtron_items/subtype_select", account_type: account_type, subtype_config: subtype_config, sophtron_account: sophtron_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? || @sophtron_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/config/locales/views/sophtron_items/en.yml b/config/locales/views/sophtron_items/en.yml new file mode 100644 index 000000000..f5a9c3353 --- /dev/null +++ b/config/locales/views/sophtron_items/en.yml @@ -0,0 +1,228 @@ +--- +en: + sophtron_items: + defaults: + name: Sophtron Connection + new: + title: Connect Sophtron + user_id: User ID + user_id_placeholder: paste your Sophtron User ID + access_key: Access Key + access_key_placeholder: paste your Sophtron Access Key + connect: Connect + cancel: Cancel + create: + success: Sophtron connection created successfully + destroy: + success: Sophtron connection removed + update: + success: Sophtron connection updated successfully! Your accounts are being reconnected. + errors: + blank_user_id: Please enter a Sophtron User ID. + invalid_user_id: Invalid User ID. Please check that you copied the complete User ID from Sophtron. + user_id_compromised: The User ID may be compromised, expired, or already used. Please create a new one. + blank_access_key: Please enter a Sophtron Access Key. + invalid_access_key: Invalid Access Key. Please check that you copied the complete Access Key from Sophtron. + access_key_compromised: The Access Key may be compromised, expired, or already used. Please create a new one. + update_failed: "Failed to update connection: %{message}" + unexpected: An unexpected error occurred. Please try again or contact support. + edit: + user_id: + label: "Sophtron User ID:" + placeholder: "Paste your Sophtron User ID here..." + help_text: "The User ID should be a long string starting with letters and numbers" + access_key: + label: "Sophtron Access Key:" + placeholder: "Paste your Sophtron Access Key here..." + help_text: "The Access Key should be a long string starting with letters and numbers" + index: + title: Sophtron Connections + loading: + loading_message: Loading Sophtron 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 connection error" + 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 + 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" + no_credentials_configured: "Please configure your Sophtron API User ID and Access Key first in Provider Settings." + no_accounts_found: No accounts found. Please check your API key configuration. + no_access_key: Sophtron Access key is not configured. Please configure it in Settings. + no_user_id: Sophtron User ID is not configured. Please configure it in Settings. + sophtron_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 Sophtron 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 + preload_accounts: + preload_accounts: preload accounts + api_error: "API connection error" + unexpected_error: "An unexpected error occurred" + no_credentials_configured: "Please configure your Sophtron API User ID and Access Key first in Provider Settings." + no_accounts_found: No accounts found. Please check your API key configuration. + no_access_key: Sophtron Access key is not configured. Please configure it in Settings. + no_user_id: Sophtron User ID is not configured. Please configure it in Settings. + select_accounts: + accounts_selected: accounts selected + api_error: "API connection error" + unexpected_error: "An unexpected error occurred" + cancel: Cancel + configure_name_in_sophtron: Cannot import - please configure account name in Sophtron + 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_access_key: Sophtron Access key is not configured. Please configure it in Settings. + no_user_id: Sophtron User ID is not configured. Please configure it in Settings. + no_credentials_configured: "Please configure your Sophtron API User ID and Access Key first in Provider Settings." + no_name_placeholder: "(No name)" + title: Select Sophtron Accounts + select_existing_account: + account_already_linked: This account is already linked to a provider + all_accounts_already_linked: All Sophtron accounts are already linked + api_error: "API connection error" + cancel: Cancel + configure_name_in_sophtron: Cannot import - please configure account name in Sophtron + description: Select a Sophtron 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 Sophtron accounts found. Please check your API key configuration. + no_access_key: Sophtron Access key is not configured. Please configure it in Settings. + no_user_id: Sophtron User ID is not configured. Please configure it in Settings. + no_name_placeholder: "(No name)" + title: "Link %{account_name} with Sophtron" + link_existing_account: + account_already_linked: This account is already linked to a provider + api_error: "API connection error" + unexpected_error: "An unexpected error occurred" + invalid_account_name: Cannot link account with blank name + sophtron_account_already_linked: This Sophtron account is already linked to another account + sophtron_account_not_found: Sophtron account not found + missing_parameters: Missing required parameters + success: "Successfully linked %{account_name} with Sophtron" + setup_accounts: + account_type_label: "Account Type:" + all_accounts_linked: "All your Sophtron accounts have already been set up." + api_error: "API connection error" + unexpected_error: "An unexpected error occurred" + fetch_failed: "Failed to Fetch Accounts" + no_accounts_to_setup: "No Accounts to Set Up" + no_access_key: "Sophtron Access key is not configured. Please check your connection settings." + no_user_id: "Sophtron User ID 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." + balance: Balance + cancel: Cancel + choose_account_type: "Choose the correct account type for each Sophtron 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 Sophtron Accounts + complete_account_setup: + all_skipped: "All accounts were skipped. No accounts were created." + creation_failed: "Failed to create accounts" + api_error: "API connection error" + unexpected_error: "An unexpected error occurred" + no_accounts: "No accounts to set up." + success: "Successfully created %{count} account(s)." + sync: + success: Sync started + sophtron_setup_required: + title: Sophtron Setup Required + message: > + To complete the setup of your Sophtron connection, please go to the Provider Settings page and follow the instructions to authorize and configure your Sophtron connection. + go_to_provider_settings: Go to Provider Settings + heading: "User ID and Access Key Not Configured" + description: "Before you can link Sophtron accounts, you need to configure your Sophtron User ID and Access key." + setup_steps_title: "Setup Steps:" + step_1_html: "Go to Settings → Bank Sync Providers" + step_2_html: "Find the Sophtron section" + step_3_html: "Enter your Sophtron User ID and Access key" + step_4: "Return here to link your accounts" + api_error: + title: "Sophtron Connection Error" + unable_to_connect: "Unable to connect to Sophtron" + common_issues_title: "Common Issues:" + incorrect_user_id: "Incorrect User ID: Verify your User ID in Provider Settings" + invalid_access_key: "Invalid Access Key: Check your Access Key in Provider Settings" + expired_credentials: "Expired Credentials: Generate a new User ID and Access Key from Sophtron" + network_issue: "Network Issue: Check your internet connection" + service_down: "Service Down: Sophtron API may be temporarily unavailable" + check_provider_settings: "Check Provider Settings" + select_option: "Select %{type}" + subtype: "subtype" + type: "type" + sophtron_panel: + setup_instructions_title: "Setup instructions:" + setup_instructions: + step_1_html: 'Visit Sophtron to obtain your API credentials' + step_2: "Copy your User ID and Access Key from your Sophtron account settings" + step_3: "Paste the credentials below and click Save to enable Sophtron bank data sync" + field_descriptions_title: "Field descriptions:" + field_descriptions: + user_id_html: "User ID: Your Sophtron User ID credential" + access_key_html: "Access Key: Your Sophtron Access Key credential" + base_url_html: "Base URL: The Sophtron API endpoint URL (usually provided by Sophtron)" + fields: + user_id: + label: "User ID" + placeholder_new: "Paste your Sophtron User ID" + placeholder_edit: "••••••••" + access_key: + label: "Access Key" + placeholder_new: "Paste your Sophtron Access Key" + placeholder_edit: "••••••••" + base_url: + label: "Base URL" + placeholder: "https://api.sophtron.com/v2" + save: "Save Configuration" + update: "Update Configuration" + status: + configured_html: 'Configured and ready to use. Visit the Accounts tab to manage and set up accounts.' + not_configured: "Not configured" + syncer: + importing_accounts: "Importing accounts from Sophtron..." + checking_account_configuration: "Checking account configuration..." + accounts_need_setup: "%{count} account(s) need setup" + processing_transactions: "Processing transactions for linked accounts..." + calculating_balances: "Calculating balances for linked accounts..." + sophtron_entry: + processor: + unknown_transaction: "Unknown transaction" \ No newline at end of file diff --git a/config/routes.rb b/config/routes.rb index 2cb3d6586..544d1c8f0 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -510,6 +510,23 @@ Rails.application.routes.draw do end end + resources :sophtron_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 + post :balances + get :setup_accounts + post :complete_account_setup + end + end + namespace :webhooks do post "plaid" post "plaid_eu" diff --git a/db/migrate/20260109220800_create_sophtron_items_and_accounts.rb b/db/migrate/20260109220800_create_sophtron_items_and_accounts.rb new file mode 100644 index 000000000..8cda529d9 --- /dev/null +++ b/db/migrate/20260109220800_create_sophtron_items_and_accounts.rb @@ -0,0 +1,64 @@ +class CreateSophtronItemsAndAccounts < ActiveRecord::Migration[7.2] + def change + # Create provider items table (stores per-family connection credentials) + create_table :sophtron_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.string :user_id + t.string :access_key + t.string :base_url + + t.timestamps + end + add_index :sophtron_items, :status + + # Create provider accounts table (stores individual account data from provider) + create_table :sophtron_accounts, id: :uuid do |t| + t.references :sophtron_item, null: false, foreign_key: true, type: :uuid + # Account identification + t.string :name, null: false + t.string :account_id, null: false + + # Account details + t.string :currency + t.decimal :balance, precision: 19, scale: 4 + t.decimal :available_balance, precision: 19, scale: 4 + t.string :account_status + t.string :account_type + t.string :account_sub_type + t.datetime :last_updated + + # Metadata and raw data + t.jsonb :institution_metadata + t.jsonb :raw_payload + t.jsonb :raw_transactions_payload + + t.string :customer_id, null: false + t.string :member_id, null: false + + t.timestamps + end + add_index :sophtron_accounts, :account_id + end +end diff --git a/db/schema.rb b/db/schema.rb index e5255b267..1c7ecd693 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -1383,6 +1383,52 @@ ActiveRecord::Schema[7.2].define(version: 2026_04_12_120000) do t.index ["status"], name: "index_snaptrade_items_on_status" end + create_table "sophtron_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "sophtron_item_id", null: false + t.string "name", null: false + t.string "account_id", null: false + t.string "currency" + t.decimal "balance", precision: 19, scale: 4 + t.decimal "available_balance", precision: 19, scale: 4 + t.string "account_status" + t.string "account_type" + t.string "account_sub_type" + t.datetime "last_updated" + t.jsonb "institution_metadata" + t.jsonb "raw_payload" + t.jsonb "raw_transactions_payload" + t.string "customer_id", null: false + t.string "member_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["account_id"], name: "index_sophtron_accounts_on_account_id" + t.index ["sophtron_item_id"], name: "index_sophtron_accounts_on_sophtron_item_id" + t.index ["sophtron_item_id", "account_id"], name: "idx_unique_sophtron_accounts_per_item", unique: true + end + + create_table "sophtron_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.string "user_id", null: false + t.string "access_key", null: false + t.string "base_url" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["family_id"], name: "index_sophtron_items_on_family_id" + t.index ["status"], name: "index_sophtron_items_on_status" + end + create_table "sso_audit_logs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.uuid "user_id" t.string "event_type", null: false @@ -1666,6 +1712,8 @@ ActiveRecord::Schema[7.2].define(version: 2026_04_12_120000) do add_foreign_key "simplefin_items", "families" add_foreign_key "snaptrade_accounts", "snaptrade_items" add_foreign_key "snaptrade_items", "families" + add_foreign_key "sophtron_accounts", "sophtron_items" + add_foreign_key "sophtron_items", "families" add_foreign_key "sso_audit_logs", "users" add_foreign_key "subscriptions", "families" add_foreign_key "syncs", "syncs", column: "parent_id" diff --git a/lib/tasks/dev_sync_stats.rake b/lib/tasks/dev_sync_stats.rake index f7c353a7b..38f217c2d 100644 --- a/lib/tasks/dev_sync_stats.rake +++ b/lib/tasks/dev_sync_stats.rake @@ -124,6 +124,7 @@ namespace :dev do DevSyncStatsHelpers.generate_fake_stats_for_items(LunchflowItem, "lunchflow") DevSyncStatsHelpers.generate_fake_stats_for_items(EnableBankingItem, "enable_banking") DevSyncStatsHelpers.generate_fake_stats_for_items(CoinstatsItem, "coinstats") + DevSyncStatsHelpers.generate_fake_stats_for_items(SophtronItem, "sophtron") puts "Done! Refresh your browser to see the sync summaries." end @@ -154,7 +155,7 @@ namespace :dev do DevSyncStatsHelpers.generate_fake_stats_for_items(LunchflowItem, "lunchflow", include_issues: true) DevSyncStatsHelpers.generate_fake_stats_for_items(EnableBankingItem, "enable_banking", include_issues: true) DevSyncStatsHelpers.generate_fake_stats_for_items(CoinstatsItem, "coinstats", include_issues: true) - + DevSyncStatsHelpers.generate_fake_stats_for_items(SophtronItem, "sophtron", include_issues: true) puts "Done! Refresh your browser to see the sync summaries with issues." end @@ -231,6 +232,27 @@ namespace :dev do end puts " Created 2 LunchflowAccounts" + # Create a fake Sophtron item + sophtron_item = family.sophtron_items.create!( + name: "Test Sophtron Connection", + user_id: "test-user-id-#{SecureRandom.hex(16)}", + access_key: "test-access-key-#{SecureRandom.hex(32)}" + ) + puts " Created SophtronItem: #{sophtron_item.name}" + + # Create fake Sophtron accounts + 2.times do |i| + sophtron_item.sophtron_accounts.create!( + name: "Test Sophtron Account #{i + 1}", + account_id: "test-sophtron-#{SecureRandom.hex(8)}", + customer_id: "test-sophtron-#{SecureRandom.hex(8)}", + member_id: "test-sophtron-#{SecureRandom.hex(8)}", + currency: "USD", + current_balance: rand(1000..50000) + ) + end + puts " Created 2 SophtronAccounts" + # Create a fake CoinStats item coinstats_item = family.coinstats_items.create!( name: "Test CoinStats Connection", @@ -288,6 +310,7 @@ namespace :dev do DevSyncStatsHelpers.generate_fake_stats_for_items(LunchflowItem, "lunchflow", include_issues: false) DevSyncStatsHelpers.generate_fake_stats_for_items(CoinstatsItem, "coinstats", include_issues: true) DevSyncStatsHelpers.generate_fake_stats_for_items(EnableBankingItem, "enable_banking", include_issues: false) + DevSyncStatsHelpers.generate_fake_stats_for_items(SophtronItem, "sophtron", include_issues: false) puts "\nDone! Visit /accounts to see the sync summaries." end @@ -308,6 +331,7 @@ namespace :dev do count += LunchflowItem.where("name LIKE ?", "Test %").destroy_all.count count += CoinstatsItem.where("name LIKE ?", "Test %").destroy_all.count count += EnableBankingItem.where("name LIKE ? OR institution_name LIKE ?", "Test %", "Test %").destroy_all.count + count += SophtronItem.where("name LIKE ?", "Test %").destroy_all.count puts "Removed #{count} test provider items. Done!" end diff --git a/test/models/family/syncer_test.rb b/test/models/family/syncer_test.rb index b164695fb..baada992e 100644 --- a/test/models/family/syncer_test.rb +++ b/test/models/family/syncer_test.rb @@ -60,6 +60,7 @@ class Family::SyncerTest < ActiveSupport::TestCase SimplefinItem.any_instance.stubs(:sync_later) LunchflowItem.any_instance.stubs(:sync_later) EnableBankingItem.any_instance.stubs(:sync_later) + SophtronItem.any_instance.stubs(:sync_later) syncer.perform_sync(family_sync) syncer.perform_post_sync