diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index d76578381..d0b69547b 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -14,6 +14,7 @@ class AccountsController < ApplicationController @mercury_items = family.mercury_items.ordered.includes(:syncs, :mercury_accounts) @coinbase_items = family.coinbase_items.ordered.includes(:coinbase_accounts, :accounts, :syncs) @snaptrade_items = family.snaptrade_items.ordered.includes(:syncs, :snaptrade_accounts) + @indexa_capital_items = family.indexa_capital_items.ordered.includes(:syncs, :indexa_capital_accounts) # Build sync stats maps for all providers build_sync_stats_maps @@ -269,5 +270,12 @@ class AccountsController < ApplicationController .count @coinbase_unlinked_count_map[item.id] = count end + + # IndexaCapital sync stats + @indexa_capital_sync_stats_map = {} + @indexa_capital_items.each do |item| + latest_sync = item.syncs.ordered.first + @indexa_capital_sync_stats_map[item.id] = latest_sync&.sync_stats || {} + end end end diff --git a/app/controllers/indexa_capital_items_controller.rb b/app/controllers/indexa_capital_items_controller.rb new file mode 100644 index 000000000..ddc4145e8 --- /dev/null +++ b/app/controllers/indexa_capital_items_controller.rb @@ -0,0 +1,380 @@ +# frozen_string_literal: true + +class IndexaCapitalItemsController < ApplicationController + ALLOWED_ACCOUNTABLE_TYPES = %w[Depository CreditCard Investment Loan OtherAsset OtherLiability Crypto Property Vehicle].freeze + + before_action :set_indexa_capital_item, only: [ :show, :edit, :update, :destroy, :sync, :setup_accounts, :complete_account_setup ] + + def index + @indexa_capital_items = Current.family.indexa_capital_items.ordered + end + + def show + end + + def new + @indexa_capital_item = Current.family.indexa_capital_items.build + end + + def edit + end + + def create + @indexa_capital_item = Current.family.indexa_capital_items.build(indexa_capital_item_params) + @indexa_capital_item.name ||= "IndexaCapital Connection" + + if @indexa_capital_item.save + if turbo_frame_request? + flash.now[:notice] = t(".success", default: "Successfully configured IndexaCapital.") + @indexa_capital_items = Current.family.indexa_capital_items.ordered + render turbo_stream: [ + turbo_stream.replace( + "indexa_capital-providers-panel", + partial: "settings/providers/indexa_capital_panel", + locals: { indexa_capital_items: @indexa_capital_items } + ), + *flash_notification_stream_items + ] + else + redirect_to settings_providers_path, notice: t(".success"), status: :see_other + end + else + @error_message = @indexa_capital_item.errors.full_messages.join(", ") + + if turbo_frame_request? + render turbo_stream: turbo_stream.replace( + "indexa_capital-providers-panel", + partial: "settings/providers/indexa_capital_panel", + locals: { error_message: @error_message } + ), status: :unprocessable_entity + else + redirect_to settings_providers_path, alert: @error_message, status: :unprocessable_entity + end + end + end + + def update + if @indexa_capital_item.update(indexa_capital_item_params) + if turbo_frame_request? + flash.now[:notice] = t(".success", default: "Successfully updated IndexaCapital configuration.") + @indexa_capital_items = Current.family.indexa_capital_items.ordered + render turbo_stream: [ + turbo_stream.replace( + "indexa_capital-providers-panel", + partial: "settings/providers/indexa_capital_panel", + locals: { indexa_capital_items: @indexa_capital_items } + ), + *flash_notification_stream_items + ] + else + redirect_to settings_providers_path, notice: t(".success"), status: :see_other + end + else + @error_message = @indexa_capital_item.errors.full_messages.join(", ") + + if turbo_frame_request? + render turbo_stream: turbo_stream.replace( + "indexa_capital-providers-panel", + partial: "settings/providers/indexa_capital_panel", + locals: { error_message: @error_message } + ), status: :unprocessable_entity + else + redirect_to settings_providers_path, alert: @error_message, status: :unprocessable_entity + end + end + end + + def destroy + @indexa_capital_item.destroy_later + redirect_to settings_providers_path, notice: t(".success", default: "Scheduled IndexaCapital connection for deletion.") + end + + def sync + unless @indexa_capital_item.syncing? + @indexa_capital_item.sync_later + end + + respond_to do |format| + format.html { redirect_back_or_to accounts_path } + format.json { head :ok } + end + end + + # Collection actions for account linking flow + + def preload_accounts + # Trigger a sync to fetch accounts from the provider + indexa_capital_item = Current.family.indexa_capital_items.first + unless indexa_capital_item&.credentials_configured? + redirect_to settings_providers_path, alert: t(".no_credentials_configured") + return + end + + indexa_capital_item.sync_later unless indexa_capital_item.syncing? + redirect_to select_accounts_indexa_capital_items_path(accountable_type: params[:accountable_type], return_to: params[:return_to]) + end + + def select_accounts + @accountable_type = params[:accountable_type] + @return_to = params[:return_to] + + indexa_capital_item = Current.family.indexa_capital_items.first + unless indexa_capital_item&.credentials_configured? + redirect_to settings_providers_path, alert: t(".no_credentials_configured") + return + end + + # Always fetch fresh data (accounts + balances) when user visits this page + fetch_accounts_synchronously(indexa_capital_item) + + @indexa_capital_accounts = indexa_capital_item.indexa_capital_accounts + .left_joins(:account_provider) + .where(account_providers: { id: nil }) + .order(:name) + end + + def link_accounts + indexa_capital_item = Current.family.indexa_capital_items.first + unless indexa_capital_item&.credentials_configured? + redirect_to settings_providers_path, alert: t(".no_api_key") + return + end + + selected_ids = params[:selected_account_ids] || [] + if selected_ids.empty? + redirect_to select_accounts_indexa_capital_items_path, alert: t(".no_accounts_selected") + return + end + + accountable_type = params[:accountable_type] || "Depository" + created_count = 0 + already_linked_count = 0 + invalid_count = 0 + + indexa_capital_item.indexa_capital_accounts.where(id: selected_ids).find_each do |indexa_capital_account| + # Skip if already linked + if indexa_capital_account.account_provider.present? + already_linked_count += 1 + next + end + + # Skip if invalid name + if indexa_capital_account.name.blank? + invalid_count += 1 + next + end + + # Create Sure account and link + link_indexa_capital_account(indexa_capital_account, accountable_type) + created_count += 1 + rescue => e + Rails.logger.error "IndexaCapitalItemsController#link_accounts - Failed to link account: #{e.message}" + end + + if created_count > 0 + indexa_capital_item.sync_later unless indexa_capital_item.syncing? + redirect_to accounts_path, notice: t(".success", count: created_count) + else + redirect_to select_accounts_indexa_capital_items_path, alert: t(".link_failed") + end + end + + def select_existing_account + @account = Current.family.accounts.find(params[:account_id]) + @indexa_capital_item = Current.family.indexa_capital_items.first + + unless @indexa_capital_item&.credentials_configured? + redirect_to settings_providers_path, alert: t(".no_credentials_configured") + return + end + + @indexa_capital_accounts = @indexa_capital_item.indexa_capital_accounts + .left_joins(:account_provider) + .where(account_providers: { id: nil }) + .order(:name) + end + + def link_existing_account + account = Current.family.accounts.find(params[:account_id]) + indexa_capital_item = Current.family.indexa_capital_items.first + + unless indexa_capital_item&.credentials_configured? + redirect_to settings_providers_path, alert: t(".no_api_key") + return + end + + indexa_capital_account = indexa_capital_item.indexa_capital_accounts.find(params[:indexa_capital_account_id]) + + if indexa_capital_account.account_provider.present? + redirect_to account_path(account), alert: t(".provider_account_already_linked") + return + end + + indexa_capital_account.ensure_account_provider!(account) + indexa_capital_item.sync_later unless indexa_capital_item.syncing? + + redirect_to account_path(account), notice: t(".success", account_name: account.name) + end + + def setup_accounts + @unlinked_accounts = @indexa_capital_item.unlinked_indexa_capital_accounts.order(:name) + end + + def complete_account_setup + account_configs = params[:accounts] || {} + + if account_configs.empty? + redirect_to setup_accounts_indexa_capital_item_path(@indexa_capital_item), alert: t(".no_accounts") + return + end + + created_count = 0 + skipped_count = 0 + + account_configs.each do |indexa_capital_account_id, config| + next if config[:account_type] == "skip" + + indexa_capital_account = @indexa_capital_item.indexa_capital_accounts.find_by(id: indexa_capital_account_id) + next unless indexa_capital_account + next if indexa_capital_account.account_provider.present? + + accountable_type = infer_accountable_type(config[:account_type], config[:subtype]) + account = create_account_from_indexa_capital(indexa_capital_account, accountable_type, config) + + if account&.persisted? + indexa_capital_account.ensure_account_provider!(account) + indexa_capital_account.update!(sync_start_date: config[:sync_start_date]) if config[:sync_start_date].present? + created_count += 1 + else + skipped_count += 1 + end + rescue => e + Rails.logger.error "IndexaCapitalItemsController#complete_account_setup - Error: #{e.message}" + skipped_count += 1 + end + + if created_count > 0 + @indexa_capital_item.sync_later unless @indexa_capital_item.syncing? + redirect_to accounts_path, notice: t(".success", count: created_count) + elsif skipped_count > 0 && created_count == 0 + redirect_to accounts_path, notice: t(".all_skipped") + else + redirect_to setup_accounts_indexa_capital_item_path(@indexa_capital_item), alert: t(".creation_failed", error: "Unknown error") + end + end + + private + + def set_indexa_capital_item + @indexa_capital_item = Current.family.indexa_capital_items.find(params[:id]) + end + + def indexa_capital_item_params + params.require(:indexa_capital_item).permit( + :name, + :sync_start_date, + :api_token, + :username, + :document, + :password + ) + end + + def link_indexa_capital_account(indexa_capital_account, accountable_type) + accountable_class = validated_accountable_class(accountable_type) + + account = Current.family.accounts.create!( + name: indexa_capital_account.name, + balance: indexa_capital_account.current_balance || 0, + currency: indexa_capital_account.currency || "EUR", + accountable: accountable_class.new + ) + + indexa_capital_account.ensure_account_provider!(account) + account + end + + def create_account_from_indexa_capital(indexa_capital_account, accountable_type, config) + accountable_class = validated_accountable_class(accountable_type) + accountable_attrs = {} + + # Set subtype if the accountable supports it + if config[:subtype].present? && accountable_class.respond_to?(:subtypes) + accountable_attrs[:subtype] = config[:subtype] + end + + Current.family.accounts.create!( + name: indexa_capital_account.name, + balance: config[:balance].present? ? config[:balance].to_d : (indexa_capital_account.current_balance || 0), + currency: indexa_capital_account.currency || "EUR", + accountable: accountable_class.new(accountable_attrs) + ) + end + + def infer_accountable_type(account_type, subtype = nil) + case account_type&.downcase + when "depository" + "Depository" + when "credit_card" + "CreditCard" + when "investment" + "Investment" + when "loan" + "Loan" + when "other_asset" + "OtherAsset" + when "other_liability" + "OtherLiability" + when "crypto" + "Crypto" + when "property" + "Property" + when "vehicle" + "Vehicle" + else + "Depository" + end + end + + def validated_accountable_class(accountable_type) + unless ALLOWED_ACCOUNTABLE_TYPES.include?(accountable_type) + raise ArgumentError, "Invalid accountable type: #{accountable_type}" + end + + accountable_type.constantize + end + + def fetch_accounts_synchronously(indexa_capital_item) + provider = indexa_capital_item.indexa_capital_provider + return unless provider + + accounts_data = provider.list_accounts + + accounts_data.each do |account_data| + account_number = account_data[:account_number].to_s + next if account_number.blank? + + # Fetch current balance from performance endpoint + balance = provider.get_account_balance(account_number: account_number) + account_data[:current_balance] = balance + rescue => e + Rails.logger.warn "IndexaCapitalItemsController - Failed to fetch balance for #{account_number}: #{e.message}" + end + + accounts_data.each do |account_data| + account_number = account_data[:account_number].to_s + next if account_number.blank? + + indexa_capital_account = indexa_capital_item.indexa_capital_accounts.find_or_initialize_by( + indexa_capital_account_id: account_number + ) + indexa_capital_account.upsert_from_indexa_capital!(account_data) + end + rescue Provider::IndexaCapital::AuthenticationError => e + Rails.logger.error "IndexaCapitalItemsController - Auth failed during sync: #{e.message}" + flash.now[:alert] = t("indexa_capital_items.select_accounts.api_error", message: e.message) + rescue Provider::IndexaCapital::Error => e + Rails.logger.error "IndexaCapitalItemsController - API error during sync: #{e.message}" + flash.now[:alert] = t("indexa_capital_items.select_accounts.api_error", message: e.message) + end +end diff --git a/app/controllers/settings/providers_controller.rb b/app/controllers/settings/providers_controller.rb index e93cb1695..b8c07784b 100644 --- a/app/controllers/settings/providers_controller.rb +++ b/app/controllers/settings/providers_controller.rb @@ -129,7 +129,8 @@ class Settings::ProvidersController < ApplicationController config.provider_key.to_s.casecmp("coinstats").zero? || \ config.provider_key.to_s.casecmp("mercury").zero? || \ config.provider_key.to_s.casecmp("coinbase").zero? || \ - config.provider_key.to_s.casecmp("snaptrade").zero? + config.provider_key.to_s.casecmp("snaptrade").zero? || \ + config.provider_key.to_s.casecmp("indexa_capital").zero? end # Providers page only needs to know whether any SimpleFin/Lunchflow connections exist with valid credentials @@ -140,5 +141,6 @@ class Settings::ProvidersController < ApplicationController @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 @snaptrade_items = Current.family.snaptrade_items.includes(:snaptrade_accounts).ordered + @indexa_capital_items = Current.family.indexa_capital_items.ordered.select(:id) end end diff --git a/app/jobs/indexa_capital_activities_fetch_job.rb b/app/jobs/indexa_capital_activities_fetch_job.rb new file mode 100644 index 000000000..8c525f697 --- /dev/null +++ b/app/jobs/indexa_capital_activities_fetch_job.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +class IndexaCapitalActivitiesFetchJob < ApplicationJob + include Sidekiq::Throttled::Job + + queue_as :default + + sidekiq_options lock: :until_executed, + lock_args_method: ->(args) { args.first }, + on_conflict: :log + + # Indexa Capital API does not provide an activities/transactions endpoint. + # This job simply clears the pending flag and broadcasts updates. + def perform(indexa_capital_account, start_date: nil, retry_count: 0) + @indexa_capital_account = indexa_capital_account + return clear_pending_flag unless @indexa_capital_account&.indexa_capital_item + + Rails.logger.info "IndexaCapitalActivitiesFetchJob - No activities endpoint available for Indexa Capital, clearing pending flag" + clear_pending_flag + broadcast_updates + rescue => e + Rails.logger.error("IndexaCapitalActivitiesFetchJob error: #{e.class} - #{e.message}") + clear_pending_flag + raise + end + + private + + def clear_pending_flag + @indexa_capital_account&.update!(activities_fetch_pending: false) + end + + def broadcast_updates + @indexa_capital_account.current_account&.broadcast_sync_complete + @indexa_capital_account.indexa_capital_item&.broadcast_replace_to( + @indexa_capital_account.indexa_capital_item.family, + target: "indexa_capital_item_#{@indexa_capital_account.indexa_capital_item.id}", + partial: "indexa_capital_items/indexa_capital_item" + ) + rescue => e + Rails.logger.warn("IndexaCapitalActivitiesFetchJob - Broadcast failed: #{e.message}") + end +end diff --git a/app/jobs/indexa_capital_connection_cleanup_job.rb b/app/jobs/indexa_capital_connection_cleanup_job.rb new file mode 100644 index 000000000..7de7f7a07 --- /dev/null +++ b/app/jobs/indexa_capital_connection_cleanup_job.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +class IndexaCapitalConnectionCleanupJob < ApplicationJob + queue_as :default + + def perform(indexa_capital_item_id:, authorization_id:, account_id:) + Rails.logger.info( + "IndexaCapitalConnectionCleanupJob - Cleaning up connection #{authorization_id} " \ + "for former account #{account_id}" + ) + + indexa_capital_item = IndexaCapitalItem.find_by(id: indexa_capital_item_id) + return unless indexa_capital_item + + # Check if other accounts still use this connection + if indexa_capital_item.indexa_capital_accounts + .where(indexa_capital_authorization_id: authorization_id) + .exists? + Rails.logger.info("IndexaCapitalConnectionCleanupJob - Connection still in use, skipping") + return + end + + # Delete from provider API + delete_connection(indexa_capital_item, authorization_id) + + Rails.logger.info("IndexaCapitalConnectionCleanupJob - Connection #{authorization_id} deleted") + rescue => e + Rails.logger.warn( + "IndexaCapitalConnectionCleanupJob - Failed: #{e.class} - #{e.message}" + ) + # Don't raise - cleanup failures shouldn't block other operations + end + + private + + def delete_connection(indexa_capital_item, authorization_id) + provider = indexa_capital_item.indexa_capital_provider + return unless provider + + credentials = indexa_capital_item.indexa_capital_credentials + return unless credentials + + # TODO: Implement API call to delete connection + # Example: + # provider.delete_connection( + # authorization_id: authorization_id, + # **credentials + # ) + nil # Placeholder until provider.delete_connection is implemented + rescue => e + Rails.logger.warn( + "IndexaCapitalConnectionCleanupJob - API delete failed: #{e.message}" + ) + end +end diff --git a/app/models/data_enrichment.rb b/app/models/data_enrichment.rb index 2639dd451..5c720cc1f 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" } + 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" } end diff --git a/app/models/family.rb b/app/models/family.rb index e4fef288b..691d1886d 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -1,4 +1,5 @@ class Family < ApplicationRecord + include IndexaCapitalConnectable include CoinbaseConnectable, CoinstatsConnectable, SnaptradeConnectable, MercuryConnectable include PlaidConnectable, SimplefinConnectable, LunchflowConnectable, EnableBankingConnectable, Syncable, AutoTransferMatchable, Subscribeable diff --git a/app/models/family/indexa_capital_connectable.rb b/app/models/family/indexa_capital_connectable.rb new file mode 100644 index 000000000..3bec0d89c --- /dev/null +++ b/app/models/family/indexa_capital_connectable.rb @@ -0,0 +1,33 @@ +module Family::IndexaCapitalConnectable + extend ActiveSupport::Concern + + included do + has_many :indexa_capital_items, dependent: :destroy + end + + def can_connect_indexa_capital? + # Families can configure their own IndexaCapital credentials + true + end + + def create_indexa_capital_item!(username:, document:, password:, item_name: nil) + indexa_capital_item = indexa_capital_items.create!( + name: item_name || "Indexa Capital Connection", + username: username, + document: document, + password: password + ) + + indexa_capital_item.sync_later + + indexa_capital_item + end + + def has_indexa_capital_credentials? + indexa_capital_items.where.not(api_token: [ nil, "" ]).or( + indexa_capital_items.where.not(username: [ nil, "" ]) + .where.not(document: [ nil, "" ]) + .where.not(password: [ nil, "" ]) + ).exists? + end +end diff --git a/app/models/family/syncer.rb b/app/models/family/syncer.rb index 09faa462f..80d910cc1 100644 --- a/app/models/family/syncer.rb +++ b/app/models/family/syncer.rb @@ -26,6 +26,6 @@ class Family::Syncer private def child_syncables - family.plaid_items + family.simplefin_items.active + family.lunchflow_items.active + family.enable_banking_items.active + family.accounts.manual + family.plaid_items + family.simplefin_items.active + family.lunchflow_items.active + family.enable_banking_items.active + family.indexa_capital_items + family.accounts.manual end end diff --git a/app/models/indexa_capital_account.rb b/app/models/indexa_capital_account.rb new file mode 100644 index 000000000..4e600a0b7 --- /dev/null +++ b/app/models/indexa_capital_account.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +class IndexaCapitalAccount < ApplicationRecord + include CurrencyNormalizable + include IndexaCapitalAccount::DataHelpers + + belongs_to :indexa_capital_item + + # Association through account_providers + has_one :account_provider, as: :provider, dependent: :destroy + has_one :account, through: :account_provider, source: :account + has_one :linked_account, through: :account_provider, source: :account + + validates :name, :currency, presence: true + + # Scopes + scope :with_linked, -> { joins(:account_provider) } + scope :without_linked, -> { left_joins(:account_provider).where(account_providers: { id: nil }) } + scope :ordered, -> { order(created_at: :desc) } + + # Callbacks + after_destroy :enqueue_connection_cleanup + + # Helper to get account using account_providers system + def current_account + account + end + + # Idempotently create or update AccountProvider link + # CRITICAL: After creation, reload association to avoid stale nil + def ensure_account_provider!(linked_account) + return nil unless linked_account + + provider = account_provider || build_account_provider + provider.account = linked_account + provider.save! + + # Reload to clear cached nil value + reload_account_provider + account_provider + end + + def upsert_from_indexa_capital!(account_data) + data = sdk_object_to_hash(account_data).with_indifferent_access + + # Indexa Capital API field mapping: + # account_number → unique account identifier + # name → display name (constructed by provider) + # type → mutual / pension / epsv + # status → active / inactive + # currency → always EUR for Indexa Capital + attrs = { + indexa_capital_account_id: data[:account_number]&.to_s, + account_number: data[:account_number]&.to_s, + name: data[:name] || "Indexa Capital Account", + currency: data[:currency] || "EUR", + account_status: data[:status], + account_type: data[:type], + provider: "Indexa Capital", + raw_payload: account_data + } + attrs[:current_balance] = data[:current_balance].to_d unless data[:current_balance].nil? + + update!(attrs) + end + + # Store holdings snapshot - return early if empty to avoid setting timestamps incorrectly + def upsert_holdings_snapshot!(holdings_data) + return if holdings_data.blank? + + update!( + raw_holdings_payload: holdings_data, + last_holdings_sync: Time.current + ) + end + + # Store activities snapshot - return early if empty to avoid setting timestamps incorrectly + def upsert_activities_snapshot!(activities_data) + return if activities_data.blank? + + update!( + raw_activities_payload: activities_data, + last_activities_sync: Time.current + ) + end + + private + + def enqueue_connection_cleanup + return unless indexa_capital_item + return unless indexa_capital_authorization_id.present? + + IndexaCapitalConnectionCleanupJob.perform_later( + indexa_capital_item_id: indexa_capital_item.id, + authorization_id: indexa_capital_authorization_id, + account_id: id + ) + end +end diff --git a/app/models/indexa_capital_account/activities_processor.rb b/app/models/indexa_capital_account/activities_processor.rb new file mode 100644 index 000000000..0a4331bc4 --- /dev/null +++ b/app/models/indexa_capital_account/activities_processor.rb @@ -0,0 +1,229 @@ +# frozen_string_literal: true + +class IndexaCapitalAccount::ActivitiesProcessor + include IndexaCapitalAccount::DataHelpers + + # Map provider activity types to Sure activity labels + # TODO: Customize for your provider's activity types + ACTIVITY_TYPE_TO_LABEL = { + "BUY" => "Buy", + "SELL" => "Sell", + "DIVIDEND" => "Dividend", + "DIV" => "Dividend", + "CONTRIBUTION" => "Contribution", + "WITHDRAWAL" => "Withdrawal", + "TRANSFER_IN" => "Transfer", + "TRANSFER_OUT" => "Transfer", + "TRANSFER" => "Transfer", + "INTEREST" => "Interest", + "FEE" => "Fee", + "TAX" => "Fee", + "REINVEST" => "Reinvestment", + "SPLIT" => "Other", + "MERGER" => "Other", + "OTHER" => "Other" + }.freeze + + # Activity types that result in Trade records (involves securities) + TRADE_TYPES = %w[BUY SELL REINVEST].freeze + + # Sell-side activity types (quantity should be negative) + SELL_SIDE_TYPES = %w[SELL].freeze + + # Activity types that result in Transaction records (cash movements) + CASH_TYPES = %w[DIVIDEND DIV CONTRIBUTION WITHDRAWAL TRANSFER_IN TRANSFER_OUT TRANSFER INTEREST FEE TAX].freeze + + def initialize(indexa_capital_account) + @indexa_capital_account = indexa_capital_account + end + + def process + activities_data = @indexa_capital_account.raw_activities_payload + return { trades: 0, transactions: 0 } if activities_data.blank? + + Rails.logger.info "IndexaCapitalAccount::ActivitiesProcessor - Processing #{activities_data.size} activities" + + @trades_count = 0 + @transactions_count = 0 + + activities_data.each do |activity_data| + process_activity(activity_data.with_indifferent_access) + rescue => e + Rails.logger.error "IndexaCapitalAccount::ActivitiesProcessor - Failed to process activity: #{e.message}" + Rails.logger.error e.backtrace.first(5).join("\n") if e.backtrace + end + + { trades: @trades_count, transactions: @transactions_count } + end + + private + + def account + @indexa_capital_account.current_account + end + + def import_adapter + @import_adapter ||= Account::ProviderImportAdapter.new(account) + end + + def process_activity(data) + # TODO: Customize activity type field name + activity_type = (data[:type] || data[:activity_type])&.upcase + return if activity_type.blank? + + # Get external ID for deduplication + external_id = (data[:id] || data[:transaction_id]).to_s + return if external_id.blank? + + Rails.logger.info "IndexaCapitalAccount::ActivitiesProcessor - Processing activity: type=#{activity_type}, id=#{external_id}" + + # Determine if this is a trade or cash activity + if trade_activity?(activity_type) + process_trade(data, activity_type, external_id) + else + process_cash_activity(data, activity_type, external_id) + end + end + + def trade_activity?(activity_type) + TRADE_TYPES.include?(activity_type) + end + + def process_trade(data, activity_type, external_id) + # TODO: Customize ticker extraction based on your provider's format + ticker = data[:symbol] || data[:ticker] + if ticker.blank? + Rails.logger.warn "IndexaCapitalAccount::ActivitiesProcessor - Skipping trade without symbol: #{external_id}" + return + end + + # Resolve security + security = resolve_security(ticker, data) + return unless security + + # TODO: Customize field names based on your provider's format + quantity = parse_decimal(data[:units]) || parse_decimal(data[:quantity]) + price = parse_decimal(data[:price]) + + if quantity.nil? + Rails.logger.warn "IndexaCapitalAccount::ActivitiesProcessor - Skipping trade without quantity: #{external_id}" + return + end + + # Determine sign based on activity type (sell-side should be negative) + quantity = if SELL_SIDE_TYPES.include?(activity_type) + -quantity.abs + else + quantity.abs + end + + # Calculate amount + amount = if price + quantity * price + else + parse_decimal(data[:amount]) || parse_decimal(data[:trade_value]) + end + + if amount.nil? + Rails.logger.warn "IndexaCapitalAccount::ActivitiesProcessor - Skipping trade without amount: #{external_id}" + return + end + + # Get the activity date + # TODO: Customize date field names + activity_date = parse_date(data[:settlement_date]) || + parse_date(data[:trade_date]) || + parse_date(data[:date]) || + Date.current + + currency = extract_currency(data, fallback: account.currency) + description = data[:description] || "#{activity_type} #{ticker}" + + Rails.logger.info "IndexaCapitalAccount::ActivitiesProcessor - Importing trade: #{ticker} qty=#{quantity} price=#{price} date=#{activity_date}" + + result = import_adapter.import_trade( + external_id: external_id, + security: security, + quantity: quantity, + price: price, + amount: amount, + currency: currency, + date: activity_date, + name: description, + source: "indexa_capital", + activity_label: label_from_type(activity_type) + ) + @trades_count += 1 if result + end + + def process_cash_activity(data, activity_type, external_id) + # TODO: Customize amount field names + amount = parse_decimal(data[:amount]) || + parse_decimal(data[:net_amount]) + return if amount.nil? + # Note: Zero-amount transactions (splits, free shares) are allowed + + # Get the activity date + # TODO: Customize date field names + activity_date = parse_date(data[:settlement_date]) || + parse_date(data[:trade_date]) || + parse_date(data[:date]) || + Date.current + + # Build description + symbol = data[:symbol] || data[:ticker] + description = data[:description] || build_description(activity_type, symbol) + + # Normalize amount sign for certain activity types + amount = normalize_cash_amount(amount, activity_type) + + currency = extract_currency(data, fallback: account.currency) + + Rails.logger.info "IndexaCapitalAccount::ActivitiesProcessor - Importing cash activity: type=#{activity_type} amount=#{amount} date=#{activity_date}" + + result = import_adapter.import_transaction( + external_id: external_id, + amount: amount, + currency: currency, + date: activity_date, + name: description, + source: "indexa_capital", + investment_activity_label: label_from_type(activity_type) + ) + @transactions_count += 1 if result + end + + def normalize_cash_amount(amount, activity_type) + case activity_type + when "WITHDRAWAL", "TRANSFER_OUT", "FEE", "TAX" + -amount.abs # These should be negative (money out) + when "CONTRIBUTION", "TRANSFER_IN", "DIVIDEND", "DIV", "INTEREST" + amount.abs # These should be positive (money in) + else + amount + end + end + + def build_description(activity_type, symbol) + type_label = label_from_type(activity_type) + if symbol.present? + "#{type_label} - #{symbol}" + else + type_label + end + end + + def label_from_type(activity_type) + normalized_type = activity_type&.upcase + label = ACTIVITY_TYPE_TO_LABEL[normalized_type] + + if label.nil? && normalized_type.present? + Rails.logger.warn( + "IndexaCapitalAccount::ActivitiesProcessor - Unmapped activity type '#{normalized_type}' " \ + "for account #{@indexa_capital_account.id}. Consider adding to ACTIVITY_TYPE_TO_LABEL mapping." + ) + end + + label || "Other" + end +end diff --git a/app/models/indexa_capital_account/data_helpers.rb b/app/models/indexa_capital_account/data_helpers.rb new file mode 100644 index 000000000..b7a86b8cd --- /dev/null +++ b/app/models/indexa_capital_account/data_helpers.rb @@ -0,0 +1,156 @@ +# frozen_string_literal: true + +module IndexaCapitalAccount::DataHelpers + extend ActiveSupport::Concern + + private + + # Convert SDK objects to hashes via JSON round-trip + # Many SDKs return objects that don't have proper #to_h methods + def sdk_object_to_hash(obj) + return obj if obj.is_a?(Hash) + + if obj.respond_to?(:to_json) + JSON.parse(obj.to_json) + elsif obj.respond_to?(:to_h) + obj.to_h + else + obj + end + rescue JSON::ParserError, TypeError + obj.respond_to?(:to_h) ? obj.to_h : {} + end + + def parse_decimal(value) + return nil if value.nil? + + case value + when BigDecimal + value + when String + BigDecimal(value) + when Numeric + BigDecimal(value.to_s) + else + nil + end + rescue ArgumentError => e + Rails.logger.error("IndexaCapitalAccount::DataHelpers - Failed to parse decimal value: #{value.inspect} - #{e.message}") + nil + end + + def parse_date(date_value) + return nil if date_value.nil? + + case date_value + when Date + date_value + when String + # Use Time.zone.parse for external timestamps (Rails timezone guidelines) + Time.zone.parse(date_value)&.to_date + when Time, DateTime, ActiveSupport::TimeWithZone + date_value.to_date + else + nil + end + rescue ArgumentError, TypeError => e + Rails.logger.error("IndexaCapitalAccount::DataHelpers - Failed to parse date: #{date_value.inspect} - #{e.message}") + nil + end + + # Find or create security with race condition handling + def resolve_security(symbol, symbol_data = {}) + ticker = symbol.to_s.upcase.strip + return nil if ticker.blank? + + security = Security.find_by(ticker: ticker) + + # If security exists but has a bad name (looks like a hash), update it + if security && security.name&.start_with?("{") + new_name = extract_security_name(symbol_data, ticker) + Rails.logger.info "IndexaCapitalAccount::DataHelpers - Fixing security name: #{security.name.first(50)}... -> #{new_name}" + security.update!(name: new_name) + end + + return security if security + + # Create new security + security_name = extract_security_name(symbol_data, ticker) + + Rails.logger.info "IndexaCapitalAccount::DataHelpers - Creating security: ticker=#{ticker}, name=#{security_name}" + + Security.create!( + ticker: ticker, + name: security_name, + exchange_mic: extract_exchange(symbol_data), + country_code: extract_country_code(symbol_data) + ) + rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotUnique => e + # Handle race condition - another process may have created it + Rails.logger.error "IndexaCapitalAccount::DataHelpers - Failed to create security #{ticker}: #{e.message}" + Security.find_by(ticker: ticker) + end + + def extract_security_name(symbol_data, fallback_ticker) + symbol_data = symbol_data.with_indifferent_access if symbol_data.respond_to?(:with_indifferent_access) + + # Try various paths where the name might be + name = symbol_data[:name] || symbol_data[:description] + + # If description is missing or looks like a type description, use ticker + if name.blank? || name.is_a?(Hash) || name =~ /^(COMMON STOCK|CRYPTOCURRENCY|ETF|MUTUAL FUND)$/i + name = fallback_ticker + end + + # Titleize for readability if it's all caps + name = name.titleize if name == name.upcase && name.length > 4 + + name + end + + def extract_exchange(symbol_data) + symbol_data = symbol_data.with_indifferent_access if symbol_data.respond_to?(:with_indifferent_access) + + exchange = symbol_data[:exchange] + return nil unless exchange.is_a?(Hash) + + exchange.with_indifferent_access[:mic_code] || exchange.with_indifferent_access[:id] + end + + def extract_country_code(symbol_data) + symbol_data = symbol_data.with_indifferent_access if symbol_data.respond_to?(:with_indifferent_access) + + # Try to extract country from currency or exchange + currency = symbol_data[:currency] + currency = currency.dig(:code) if currency.is_a?(Hash) + + case currency + when "USD" + "US" + when "CAD" + "CA" + when "GBP", "GBX" + "GB" + when "EUR" + nil # Could be many countries + else + nil + end + end + + # Handle currency as string or object (API inconsistency) + def extract_currency(data, fallback: nil) + data = data.with_indifferent_access if data.respond_to?(:with_indifferent_access) + + currency_data = data[:currency] + return fallback if currency_data.blank? + + if currency_data.is_a?(Hash) + currency_data.with_indifferent_access[:code] || fallback + elsif currency_data.is_a?(String) + currency_data.upcase + else + fallback + end + end +end diff --git a/app/models/indexa_capital_account/holdings_processor.rb b/app/models/indexa_capital_account/holdings_processor.rb new file mode 100644 index 000000000..f39f8677b --- /dev/null +++ b/app/models/indexa_capital_account/holdings_processor.rb @@ -0,0 +1,130 @@ +# frozen_string_literal: true + +class IndexaCapitalAccount::HoldingsProcessor + include IndexaCapitalAccount::DataHelpers + + def initialize(indexa_capital_account) + @indexa_capital_account = indexa_capital_account + end + + def process + return unless account.present? + + holdings_data = @indexa_capital_account.raw_holdings_payload + return if holdings_data.blank? + + Rails.logger.info "IndexaCapitalAccount::HoldingsProcessor - Processing #{holdings_data.size} holdings" + + holdings_data.each_with_index do |holding_data, idx| + Rails.logger.info "IndexaCapitalAccount::HoldingsProcessor - Processing holding #{idx + 1}/#{holdings_data.size}" + process_holding(holding_data.with_indifferent_access) + rescue => e + Rails.logger.error "IndexaCapitalAccount::HoldingsProcessor - Failed to process holding #{idx + 1}: #{e.class} - #{e.message}" + Rails.logger.error e.backtrace.first(5).join("\n") if e.backtrace + end + end + + private + + def account + @indexa_capital_account.current_account + end + + def import_adapter + @import_adapter ||= Account::ProviderImportAdapter.new(account) + end + + # Indexa Capital fiscal-results field mapping: + # instrument.identifier (ISIN) → ticker + # instrument.name → security name + # titles → quantity (number of shares/units) + # price → current price per unit + # amount → total market value + # cost_price → average purchase price (cost basis per unit) + # cost_amount → total cost basis + # profit_loss → unrealized P&L + # subscription_date → purchase date + def process_holding(data) + ticker = extract_ticker(data) + return if ticker.blank? + + Rails.logger.info "IndexaCapitalAccount::HoldingsProcessor - Processing holding for ticker: #{ticker}" + + security = resolve_security(ticker, data) + return unless security + + quantity = parse_decimal(data[:titles]) || parse_decimal(data[:quantity]) || parse_decimal(data[:units]) + price = parse_decimal(data[:price]) + return if quantity.nil? || price.nil? + + amount = parse_decimal(data[:amount]) || (quantity * price) + currency = "EUR" # Indexa Capital is EUR-only + holding_date = Date.current + + Rails.logger.info "IndexaCapitalAccount::HoldingsProcessor - Importing holding: #{ticker} qty=#{quantity} price=#{price} currency=#{currency}" + + import_adapter.import_holding( + security: security, + quantity: quantity, + amount: amount, + currency: currency, + date: holding_date, + price: price, + account_provider_id: @indexa_capital_account.account_provider&.id, + source: "indexa_capital", + delete_future_holdings: false + ) + + # Store cost basis from cost_price (average purchase price per unit) + cost_price = parse_decimal(data[:cost_price]) + update_holding_cost_basis(security, cost_price) if cost_price.present? + end + + # Extract ISIN from instrument data as ticker + def extract_ticker(data) + # Indexa Capital uses ISIN codes nested under instrument + instrument = data[:instrument] + if instrument.is_a?(Hash) + instrument = instrument.with_indifferent_access + return instrument[:identifier] || instrument[:isin] + end + + # Fallback to flat fields + data[:isin] || data[:identifier] || data[:symbol] || data[:ticker] + end + + # Override security name extraction for Indexa Capital + def extract_security_name(symbol_data, fallback_ticker) + symbol_data = symbol_data.with_indifferent_access if symbol_data.respond_to?(:with_indifferent_access) + + instrument = symbol_data[:instrument] + if instrument.is_a?(Hash) + instrument = instrument.with_indifferent_access + name = instrument[:name] || instrument[:description] + return name if name.present? + end + + name = symbol_data[:name] || symbol_data[:description] + return fallback_ticker if name.blank? || name.is_a?(Hash) + + name + end + + def update_holding_cost_basis(security, cost_price) + holding = account.holdings + .where(security: security) + .where("cost_basis_source != 'manual' OR cost_basis_source IS NULL") + .order(date: :desc) + .first + + return unless holding + + cost_basis = parse_decimal(cost_price) + return if cost_basis.nil? + + holding.update!( + cost_basis: cost_basis, + cost_basis_source: "provider" + ) + end +end diff --git a/app/models/indexa_capital_account/processor.rb b/app/models/indexa_capital_account/processor.rb new file mode 100644 index 000000000..b128f0557 --- /dev/null +++ b/app/models/indexa_capital_account/processor.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true + +class IndexaCapitalAccount::Processor + include IndexaCapitalAccount::DataHelpers + + attr_reader :indexa_capital_account + + def initialize(indexa_capital_account) + @indexa_capital_account = indexa_capital_account + end + + def process + account = indexa_capital_account.current_account + return unless account + + Rails.logger.info "IndexaCapitalAccount::Processor - Processing account #{indexa_capital_account.id} -> Sure account #{account.id}" + + # Update account balance FIRST (before processing transactions/holdings/activities) + update_account_balance(account) + + # Process holdings + holdings_count = indexa_capital_account.raw_holdings_payload&.size || 0 + Rails.logger.info "IndexaCapitalAccount::Processor - Holdings payload has #{holdings_count} items" + + if indexa_capital_account.raw_holdings_payload.present? + Rails.logger.info "IndexaCapitalAccount::Processor - Processing holdings..." + IndexaCapitalAccount::HoldingsProcessor.new(indexa_capital_account).process + else + Rails.logger.warn "IndexaCapitalAccount::Processor - No holdings payload to process" + end + + # Process activities (trades, dividends, etc.) + activities_count = indexa_capital_account.raw_activities_payload&.size || 0 + Rails.logger.info "IndexaCapitalAccount::Processor - Activities payload has #{activities_count} items" + + if indexa_capital_account.raw_activities_payload.present? + Rails.logger.info "IndexaCapitalAccount::Processor - Processing activities..." + IndexaCapitalAccount::ActivitiesProcessor.new(indexa_capital_account).process + else + Rails.logger.warn "IndexaCapitalAccount::Processor - No activities payload to process" + end + + # Trigger immediate UI refresh so entries appear in the activity feed + account.broadcast_sync_complete + Rails.logger.info "IndexaCapitalAccount::Processor - Broadcast sync complete for account #{account.id}" + + { holdings_processed: holdings_count > 0, activities_processed: activities_count > 0 } + end + + private + + def update_account_balance(account) + # Calculate total balance and cash balance from provider data + total_balance = calculate_total_balance + cash_balance = calculate_cash_balance + + Rails.logger.info "IndexaCapitalAccount::Processor - Balance update: total=#{total_balance}, cash=#{cash_balance}" + + # Update the cached fields on the account + account.assign_attributes( + balance: total_balance, + cash_balance: cash_balance, + currency: indexa_capital_account.currency || account.currency + ) + account.save! + + # Create or update the current balance anchor valuation for linked accounts + # This is critical for reverse sync to work correctly + account.set_current_balance(total_balance) + end + + def calculate_total_balance + # Calculate total from holdings + cash for accuracy + holdings_value = calculate_holdings_value + cash_value = indexa_capital_account.cash_balance || 0 + + calculated_total = holdings_value + cash_value + + # Use calculated total if we have holdings, otherwise trust API value + if holdings_value > 0 + Rails.logger.info "IndexaCapitalAccount::Processor - Using calculated total: holdings=#{holdings_value} + cash=#{cash_value} = #{calculated_total}" + calculated_total + elsif indexa_capital_account.current_balance.present? + Rails.logger.info "IndexaCapitalAccount::Processor - Using API total: #{indexa_capital_account.current_balance}" + indexa_capital_account.current_balance + else + calculated_total + end + end + + def calculate_cash_balance + # Use provider's cash_balance directly + # Note: Can be negative for margin accounts + cash = indexa_capital_account.cash_balance + Rails.logger.info "IndexaCapitalAccount::Processor - Cash balance from API: #{cash.inspect}" + cash || BigDecimal("0") + end + + def calculate_holdings_value + holdings_data = indexa_capital_account.raw_holdings_payload || [] + return 0 if holdings_data.empty? + + holdings_data.sum do |holding| + data = holding.is_a?(Hash) ? holding.with_indifferent_access : {} + # Indexa Capital: amount = total market value, or titles * price + amount = parse_decimal(data[:amount]) + if amount + amount + else + titles = parse_decimal(data[:titles] || data[:quantity] || data[:units]) || 0 + price = parse_decimal(data[:price]) || 0 + titles * price + end + end + end +end diff --git a/app/models/indexa_capital_item.rb b/app/models/indexa_capital_item.rb new file mode 100644 index 000000000..8272a256e --- /dev/null +++ b/app/models/indexa_capital_item.rb @@ -0,0 +1,180 @@ +# frozen_string_literal: true + +class IndexaCapitalItem < ApplicationRecord + include Syncable, Provided, Unlinking + + enum :status, { good: "good", requires_update: "requires_update" }, default: :good + + # Helper to detect if ActiveRecord Encryption is configured for this app + def self.encryption_ready? + creds_ready = Rails.application.credentials.active_record_encryption.present? + env_ready = ENV["ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY"].present? && + ENV["ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY"].present? && + ENV["ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT"].present? + creds_ready || env_ready + end + + # Encrypt sensitive credentials if ActiveRecord encryption is configured + if encryption_ready? + encrypts :password, deterministic: true + encrypts :api_token, deterministic: true + end + + validates :name, presence: true + validate :credentials_present_on_create, on: :create + + belongs_to :family + has_one_attached :logo, dependent: :purge_later + + has_many :indexa_capital_accounts, dependent: :destroy + has_many :accounts, through: :indexa_capital_accounts + + scope :active, -> { where(scheduled_for_deletion: false) } + scope :ordered, -> { order(created_at: :desc) } + scope :needs_update, -> { where(status: :requires_update) } + + def syncer + IndexaCapitalItem::Syncer.new(self) + end + + def destroy_later + update!(scheduled_for_deletion: true) + DestroyJob.perform_later(self) + end + + # Override syncing? to include background activities fetch + def syncing? + super || indexa_capital_accounts.where(activities_fetch_pending: true).exists? + end + + # Import data from provider API + def import_latest_indexa_capital_data(sync: nil) + provider = indexa_capital_provider + unless provider + Rails.logger.error "IndexaCapitalItem #{id} - Cannot import: provider is not configured" + raise StandardError, I18n.t("indexa_capital_items.errors.provider_not_configured") + end + + IndexaCapitalItem::Importer.new(self, indexa_capital_provider: provider, sync: sync).import + rescue => e + Rails.logger.error "IndexaCapitalItem #{id} - Failed to import data: #{e.message}" + raise + end + + # Process linked accounts after data import + def process_accounts + return [] if indexa_capital_accounts.empty? + + results = [] + linked_indexa_capital_accounts.includes(account_provider: :account).each do |indexa_capital_account| + begin + result = IndexaCapitalAccount::Processor.new(indexa_capital_account).process + results << { indexa_capital_account_id: indexa_capital_account.id, success: true, result: result } + rescue => e + Rails.logger.error "IndexaCapitalItem #{id} - Failed to process account #{indexa_capital_account.id}: #{e.message}" + results << { indexa_capital_account_id: indexa_capital_account.id, success: false, error: e.message } + end + end + + results + end + + # Schedule sync jobs for all linked accounts + def schedule_account_syncs(parent_sync: nil, window_start_date: nil, window_end_date: nil) + return [] if accounts.empty? + + results = [] + accounts.visible.each do |account| + begin + account.sync_later( + parent_sync: parent_sync, + window_start_date: window_start_date, + window_end_date: window_end_date + ) + results << { account_id: account.id, success: true } + rescue => e + Rails.logger.error "IndexaCapitalItem #{id} - Failed to schedule sync for account #{account.id}: #{e.message}" + results << { account_id: account.id, success: false, error: e.message } + end + end + + results + end + + def upsert_indexa_capital_snapshot!(accounts_snapshot) + assign_attributes( + raw_payload: accounts_snapshot + ) + + save! + end + + def has_completed_initial_setup? + accounts.any? + end + + # Linked accounts (have AccountProvider association) + def linked_indexa_capital_accounts + indexa_capital_accounts.joins(:account_provider) + end + + # Unlinked accounts (no AccountProvider association) + def unlinked_indexa_capital_accounts + indexa_capital_accounts.left_joins(:account_provider).where(account_providers: { id: nil }) + end + + def sync_status_summary + total_accounts = total_accounts_count + linked_count = linked_accounts_count + unlinked_count = unlinked_accounts_count + + if total_accounts == 0 + I18n.t("indexa_capital_items.sync_status.no_accounts") + elsif unlinked_count == 0 + I18n.t("indexa_capital_items.sync_status.synced", count: linked_count) + else + I18n.t("indexa_capital_items.sync_status.synced_with_setup", linked: linked_count, unlinked: unlinked_count) + end + end + + def linked_accounts_count + indexa_capital_accounts.joins(:account_provider).count + end + + def unlinked_accounts_count + indexa_capital_accounts.left_joins(:account_provider).where(account_providers: { id: nil }).count + end + + def total_accounts_count + indexa_capital_accounts.count + end + + def institution_display_name + institution_name.presence || institution_domain.presence || name + end + + def connected_institutions + indexa_capital_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 + I18n.t("indexa_capital_items.institution_summary.none") + else + I18n.t("indexa_capital_items.institution_summary.count", count: institutions.count) + end + end + + private + + def credentials_present_on_create + return if credentials_configured? + + errors.add(:base, "Either INDEXA_API_TOKEN env var or username/document/password credentials are required") + end +end diff --git a/app/models/indexa_capital_item/importer.rb b/app/models/indexa_capital_item/importer.rb new file mode 100644 index 000000000..0980919b8 --- /dev/null +++ b/app/models/indexa_capital_item/importer.rb @@ -0,0 +1,157 @@ +# frozen_string_literal: true + +class IndexaCapitalItem::Importer + include SyncStats::Collector + include IndexaCapitalAccount::DataHelpers + + attr_reader :indexa_capital_item, :indexa_capital_provider, :sync + + def initialize(indexa_capital_item, indexa_capital_provider:, sync: nil) + @indexa_capital_item = indexa_capital_item + @indexa_capital_provider = indexa_capital_provider + @sync = sync + end + + class CredentialsError < StandardError; end + + def import + Rails.logger.info "IndexaCapitalItem::Importer - Starting import for item #{indexa_capital_item.id}" + + unless indexa_capital_provider + raise CredentialsError, "No IndexaCapital provider configured for item #{indexa_capital_item.id}" + end + + # Step 1: Fetch and store all accounts + import_accounts + + # Step 2: For LINKED accounts only, fetch holdings data + linked_accounts = IndexaCapitalAccount + .where(indexa_capital_item_id: indexa_capital_item.id) + .joins(:account_provider) + + Rails.logger.info "IndexaCapitalItem::Importer - Found #{linked_accounts.count} linked accounts to process" + + linked_accounts.each do |indexa_capital_account| + Rails.logger.info "IndexaCapitalItem::Importer - Processing linked account #{indexa_capital_account.id}" + import_holdings(indexa_capital_account) + end + + # Update raw payload on the item + indexa_capital_item.upsert_indexa_capital_snapshot!(stats) + rescue Provider::IndexaCapital::AuthenticationError + indexa_capital_item.update!(status: :requires_update) + raise + end + + private + + def stats + @stats ||= {} + end + + def persist_stats! + return unless sync&.respond_to?(:sync_stats) + merged = (sync.sync_stats || {}).merge(stats) + sync.update_columns(sync_stats: merged) + end + + def import_accounts + Rails.logger.info "IndexaCapitalItem::Importer - Fetching accounts from Indexa Capital API" + + accounts_data = indexa_capital_provider.list_accounts + + stats["api_requests"] = stats.fetch("api_requests", 0) + 1 + stats["total_accounts"] = accounts_data.size + + upstream_account_ids = [] + + accounts_data.each do |account_data| + import_account(account_data) + upstream_account_ids << account_data[:account_number].to_s if account_data[:account_number] + rescue => e + Rails.logger.error "IndexaCapitalItem::Importer - Failed to import account: #{e.message}" + stats["accounts_skipped"] = stats.fetch("accounts_skipped", 0) + 1 + register_error(e, account_data: account_data) + end + + persist_stats! + + # Clean up accounts that no longer exist upstream + prune_removed_accounts(upstream_account_ids) + end + + def import_account(account_data) + account_number = account_data[:account_number].to_s + return if account_number.blank? + + # Fetch current balance from performance endpoint + begin + balance = indexa_capital_provider.get_account_balance(account_number: account_number) + account_data[:current_balance] = balance + stats["api_requests"] = stats.fetch("api_requests", 0) + 1 + rescue => e + Rails.logger.warn "IndexaCapitalItem::Importer - Failed to fetch balance for #{account_number}: #{e.message}" + end + + indexa_capital_account = indexa_capital_item.indexa_capital_accounts.find_or_initialize_by( + indexa_capital_account_id: account_number + ) + + indexa_capital_account.upsert_from_indexa_capital!(account_data) + + stats["accounts_imported"] = stats.fetch("accounts_imported", 0) + 1 + end + + def import_holdings(indexa_capital_account) + account_number = indexa_capital_account.indexa_capital_account_id + Rails.logger.info "IndexaCapitalItem::Importer - Fetching holdings for account #{account_number}" + + begin + holdings_data = indexa_capital_provider.get_holdings(account_number: account_number) + + stats["api_requests"] = stats.fetch("api_requests", 0) + 1 + + # The API returns fiscal-results which may be a hash with an array inside + holdings_array = normalize_holdings_response(holdings_data) + + if holdings_array.any? + holdings_hashes = holdings_array.map { |h| sdk_object_to_hash(h) } + indexa_capital_account.upsert_holdings_snapshot!(holdings_hashes) + stats["holdings_found"] = stats.fetch("holdings_found", 0) + holdings_array.size + end + rescue => e + Rails.logger.warn "IndexaCapitalItem::Importer - Failed to fetch holdings: #{e.message}" + register_error(e, context: "holdings", account_id: indexa_capital_account.id) + end + end + + # fiscal-results response may be an array or a hash containing an array + def normalize_holdings_response(data) + return data if data.is_a?(Array) + return [] if data.nil? + + # Try common response shapes + data[:fiscal_results] || data[:results] || data[:positions] || data[:data] || [] + end + + def prune_removed_accounts(upstream_account_ids) + return if upstream_account_ids.empty? + + removed = indexa_capital_item.indexa_capital_accounts + .where.not(indexa_capital_account_id: upstream_account_ids) + + if removed.any? + Rails.logger.info "IndexaCapitalItem::Importer - Pruning #{removed.count} removed accounts" + removed.destroy_all + end + end + + def register_error(error, **context) + stats["errors"] ||= [] + stats["errors"] << { + message: error.message, + context: context.to_s, + timestamp: Time.current.iso8601 + } + end +end diff --git a/app/models/indexa_capital_item/provided.rb b/app/models/indexa_capital_item/provided.rb new file mode 100644 index 000000000..1fa7f2d57 --- /dev/null +++ b/app/models/indexa_capital_item/provided.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module IndexaCapitalItem::Provided + extend ActiveSupport::Concern + + def indexa_capital_provider + return nil unless credentials_configured? + + token = resolved_api_token + if token.present? + Provider::IndexaCapital.new(api_token: token) + else + Provider::IndexaCapital.new( + username: username, + document: document, + password: password + ) + end + end + + def indexa_capital_credentials + return nil unless credentials_configured? + + { username: username, document: document, password: password } + end + + def credentials_configured? + resolved_api_token.present? || (username.present? && document.present? && password.present?) + end + + private + + # Priority: stored token > env token + def resolved_api_token + api_token.presence || ENV["INDEXA_API_TOKEN"].presence + end +end diff --git a/app/models/indexa_capital_item/syncer.rb b/app/models/indexa_capital_item/syncer.rb new file mode 100644 index 000000000..9d0499c3c --- /dev/null +++ b/app/models/indexa_capital_item/syncer.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +class IndexaCapitalItem::Syncer + include SyncStats::Collector + + attr_reader :indexa_capital_item + + def initialize(indexa_capital_item) + @indexa_capital_item = indexa_capital_item + end + + def perform_sync(sync) + Rails.logger.info "IndexaCapitalItem::Syncer - Starting sync for item #{indexa_capital_item.id}" + + # Phase 1: Import data from provider API + sync.update!(status_text: I18n.t("indexa_capital_items.sync.status.importing")) if sync.respond_to?(:status_text) + indexa_capital_item.import_latest_indexa_capital_data(sync: sync) + + # Phase 2: Collect setup statistics + finalize_setup_counts(sync) + + # Phase 3: Process data for linked accounts + linked_indexa_capital_accounts = indexa_capital_item.linked_indexa_capital_accounts.includes(account_provider: :account) + if linked_indexa_capital_accounts.any? + sync.update!(status_text: I18n.t("indexa_capital_items.sync.status.processing")) if sync.respond_to?(:status_text) + mark_import_started(sync) + indexa_capital_item.process_accounts + + # Phase 4: Schedule balance calculations + sync.update!(status_text: I18n.t("indexa_capital_items.sync.status.calculating")) if sync.respond_to?(:status_text) + indexa_capital_item.schedule_account_syncs( + parent_sync: sync, + window_start_date: sync.window_start_date, + window_end_date: sync.window_end_date + ) + + # Phase 5: Collect statistics + account_ids = linked_indexa_capital_accounts.filter_map { |pa| pa.current_account&.id } + collect_transaction_stats(sync, account_ids: account_ids, source: "indexa_capital") + collect_trades_stats(sync, account_ids: account_ids, source: "indexa_capital") + collect_holdings_stats(sync, holdings_count: count_holdings, label: "processed") + end + + # Mark sync health + collect_health_stats(sync, errors: nil) + rescue Provider::IndexaCapital::AuthenticationError => e + indexa_capital_item.update!(status: :requires_update) + collect_health_stats(sync, errors: [ { message: e.message, category: "auth_error" } ]) + raise + rescue => e + collect_health_stats(sync, errors: [ { message: e.message, category: "sync_error" } ]) + raise + end + + # Public: called by Sync after finalization + def perform_post_sync + # Override for post-sync cleanup if needed + end + + private + + def count_holdings + indexa_capital_item.linked_indexa_capital_accounts.sum { |pa| Array(pa.raw_holdings_payload).size } + end + + def mark_import_started(sync) + # Mark that we're now processing imported data + sync.update!(status_text: I18n.t("indexa_capital_items.sync.status.importing_data")) if sync.respond_to?(:status_text) + end + + def finalize_setup_counts(sync) + sync.update!(status_text: I18n.t("indexa_capital_items.sync.status.checking_setup")) if sync.respond_to?(:status_text) + + unlinked_count = indexa_capital_item.unlinked_accounts_count + + if unlinked_count > 0 + indexa_capital_item.update!(pending_account_setup: true) + sync.update!(status_text: I18n.t("indexa_capital_items.sync.status.needs_setup", count: unlinked_count)) if sync.respond_to?(:status_text) + else + indexa_capital_item.update!(pending_account_setup: false) + end + + # Collect setup stats + collect_setup_stats(sync, provider_accounts: indexa_capital_item.indexa_capital_accounts) + end +end diff --git a/app/models/indexa_capital_item/unlinking.rb b/app/models/indexa_capital_item/unlinking.rb new file mode 100644 index 000000000..4caebb71d --- /dev/null +++ b/app/models/indexa_capital_item/unlinking.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +module IndexaCapitalItem::Unlinking + # Concern that encapsulates unlinking logic for a IndexaCapital item. + extend ActiveSupport::Concern + + # Idempotently remove all connections between this IndexaCapital item and local accounts. + # - Detaches any AccountProvider links for each IndexaCapitalAccount + # - Detaches Holdings that point at the AccountProvider links + # Returns a per-account result payload for observability + def unlink_all!(dry_run: false) + results = [] + + indexa_capital_accounts.find_each do |provider_account| + links = AccountProvider.where(provider_type: "IndexaCapitalAccount", provider_id: provider_account.id).to_a + link_ids = links.map(&:id) + result = { + provider_account_id: provider_account.id, + name: provider_account.name, + provider_link_ids: link_ids + } + results << result + + next if dry_run + + begin + ActiveRecord::Base.transaction do + # Detach holdings for any provider links found + if link_ids.any? + Holding.where(account_provider_id: link_ids).update_all(account_provider_id: nil) + end + + # Destroy all provider links + links.each do |ap| + ap.destroy! + end + end + rescue StandardError => e + Rails.logger.warn( + "IndexaCapitalItem Unlinker: failed to fully unlink provider account ##{provider_account.id} (links=#{link_ids.inspect}): #{e.class} - #{e.message}" + ) + # Record error for observability; continue with other accounts + result[:error] = e.message + end + end + + results + end +end diff --git a/app/models/provider/indexa_capital.rb b/app/models/provider/indexa_capital.rb new file mode 100644 index 000000000..ad7fc6ca6 --- /dev/null +++ b/app/models/provider/indexa_capital.rb @@ -0,0 +1,237 @@ +# frozen_string_literal: true + +class Provider::IndexaCapital + include HTTParty + + headers "User-Agent" => "Sure Finance IndexaCapital Client" + default_options.merge!(verify: true, ssl_verify_mode: OpenSSL::SSL::VERIFY_PEER, timeout: 120) + + class Error < StandardError + attr_reader :error_type + + def initialize(message, error_type = :unknown) + super(message) + @error_type = error_type + end + end + + class ConfigurationError < Error; end + class AuthenticationError < Error; end + + BASE_URL = "https://api.indexacapital.com" + + # Supports two auth modes: + # 1. Username/document/password credentials (authenticates via /auth/authenticate) + # 2. Pre-generated API token (from env or user dashboard) + def initialize(username: nil, document: nil, password: nil, api_token: nil) + @username = username + @document = document + @password = password + @api_token = api_token + validate_configuration! + end + + # GET /users/me → list of accounts + def list_accounts + with_retries("list_accounts") do + response = self.class.get( + "#{base_url}/users/me", + headers: auth_headers + ) + data = handle_response(response) + extract_accounts(data) + end + end + + # GET /accounts/{account_number}/fiscal-results → holdings (positions with cost basis) + def get_holdings(account_number:) + sanitize_account_number!(account_number) + with_retries("get_holdings") do + response = self.class.get( + "#{base_url}/accounts/#{account_number}/fiscal-results", + headers: auth_headers + ) + handle_response(response) + end + end + + # GET /accounts/{account_number}/performance → latest portfolio total_amount + def get_account_balance(account_number:) + sanitize_account_number!(account_number) + with_retries("get_account_balance") do + response = self.class.get( + "#{base_url}/accounts/#{account_number}/performance", + headers: auth_headers + ) + data = handle_response(response) + extract_balance(data) + end + end + + # No activities/transactions endpoint exists in the Indexa Capital API. + # Returns empty array to keep the interface consistent. + def get_activities(account_number:, start_date: nil, end_date: nil) + Rails.logger.info "Provider::IndexaCapital - No activities endpoint available for Indexa Capital API" + [] + end + + private + + RETRYABLE_ERRORS = [ + SocketError, Net::OpenTimeout, Net::ReadTimeout, + Errno::ECONNRESET, Errno::ECONNREFUSED, Errno::ETIMEDOUT, EOFError + ].freeze + + MAX_RETRIES = 3 + INITIAL_RETRY_DELAY = 2 # seconds + + # Indexa Capital account numbers are 8-char alphanumeric (e.g., "LPYH3MCQ") + def sanitize_account_number!(account_number) + unless account_number.present? && account_number.match?(/\A[A-Za-z0-9]+\z/) + raise Error.new("Invalid account number format: #{account_number}", :bad_request) + end + end + + attr_reader :username, :document, :password, :api_token + + def validate_configuration! + return if @api_token.present? + + if @username.blank? || @document.blank? || @password.blank? + raise ConfigurationError, "Either API token or all three username/document/password credentials are required" + end + end + + def token_auth? + @api_token.present? + end + + def with_retries(operation_name, max_retries: MAX_RETRIES) + retries = 0 + + begin + yield + rescue *RETRYABLE_ERRORS => e + retries += 1 + + if retries <= max_retries + delay = calculate_retry_delay(retries) + Rails.logger.warn( + "IndexaCapital API: #{operation_name} failed (attempt #{retries}/#{max_retries}): " \ + "#{e.class}: #{e.message}. Retrying in #{delay}s..." + ) + sleep(delay) + retry + else + Rails.logger.error( + "IndexaCapital API: #{operation_name} failed after #{max_retries} retries: " \ + "#{e.class}: #{e.message}" + ) + raise Error.new("Network error after #{max_retries} retries: #{e.message}", :network_error) + end + end + end + + def calculate_retry_delay(retry_count) + base_delay = INITIAL_RETRY_DELAY * (2 ** (retry_count - 1)) + jitter = base_delay * rand * 0.25 + [ base_delay + jitter, 30 ].min + end + + def base_url + BASE_URL + end + + def base_headers + { + "Content-Type" => "application/json", + "Accept" => "application/json" + } + end + + def auth_headers + base_headers.merge("X-AUTH-TOKEN" => token) + end + + def token + @token ||= token_auth? ? @api_token : authenticate! + end + + def authenticate! + response = self.class.post( + "#{base_url}/auth/authenticate", + headers: base_headers, + body: { + username: username, + document: document, + password: password + }.to_json + ) + payload = handle_response(response) + jwt = payload[:token] + raise AuthenticationError.new("Authentication token missing in response", :unauthorized) if jwt.blank? + + jwt + end + + def handle_response(response) + case response.code + when 200, 201 + begin + JSON.parse(response.body, symbolize_names: true) + rescue JSON::ParserError => e + raise Error.new("Invalid JSON in response: #{e.message}", :bad_response) + end + when 400 + Rails.logger.error "IndexaCapital API: Bad request - #{response.body}" + raise Error.new("Bad request: #{response.body}", :bad_request) + when 401 + raise AuthenticationError.new("Invalid credentials", :unauthorized) + when 403 + raise AuthenticationError.new("Access forbidden - check your permissions", :access_forbidden) + when 404 + raise Error.new("Resource not found", :not_found) + when 429 + raise Error.new("Rate limit exceeded. Please try again later.", :rate_limited) + when 500..599 + raise Error.new("IndexaCapital server error (#{response.code}). Please try again later.", :server_error) + else + Rails.logger.error "IndexaCapital API: Unexpected response - Code: #{response.code}, Body: #{response.body}" + raise Error.new("Unexpected error: #{response.code} - #{response.body}", :unknown) + end + end + + # Extract accounts array from /users/me response + # API returns: { accounts: [{ account_number: "ABC12345", type: "mutual", status: "active", ... }] } + def extract_accounts(user_data) + accounts = user_data[:accounts] || [] + accounts.map do |acct| + { + account_number: acct[:account_number], + name: account_display_name(acct), + type: acct[:type], + status: acct[:status], + currency: "EUR", + raw: acct + }.with_indifferent_access + end + end + + def account_display_name(acct) + type_label = case acct[:type] + when "mutual" then "Mutual Fund" + when "pension", "epsv" then "Pension Plan" + else acct[:type]&.titleize || "Account" + end + "Indexa Capital #{type_label} (#{acct[:account_number]})" + end + + # Extract current balance from performance endpoint's portfolios array + def extract_balance(performance_data) + portfolios = performance_data[:portfolios] + return 0 unless portfolios.is_a?(Array) && portfolios.any? + + latest = portfolios.max_by { |p| Date.parse(p[:date].to_s) rescue Date.new } + latest[:total_amount].to_d + end +end diff --git a/app/models/provider/indexa_capital_adapter.rb b/app/models/provider/indexa_capital_adapter.rb new file mode 100644 index 000000000..3b8bbd5bd --- /dev/null +++ b/app/models/provider/indexa_capital_adapter.rb @@ -0,0 +1,100 @@ +class Provider::IndexaCapitalAdapter < Provider::Base + include Provider::Syncable + include Provider::InstitutionMetadata + + # Register this adapter with the factory + Provider::Factory.register("IndexaCapitalAccount", self) + + # Indexa Capital supports index fund and pension plan investments + def self.supported_account_types + %w[Investment] + end + + # Returns connection configurations for this provider + def self.connection_configs(family:) + return [] unless family.can_connect_indexa_capital? + + [ { + key: "indexa_capital", + name: "Indexa Capital", + description: "Connect to your Indexa Capital account", + can_connect: true, + new_account_path: ->(accountable_type, return_to) { + Rails.application.routes.url_helpers.select_accounts_indexa_capital_items_path( + accountable_type: accountable_type, + return_to: return_to + ) + }, + existing_account_path: ->(account_id) { + Rails.application.routes.url_helpers.select_existing_account_indexa_capital_items_path( + account_id: account_id + ) + } + } ] + end + + def provider_name + "indexa_capital" + end + + # Build a IndexaCapital provider instance with family-specific credentials + # @param family [Family] The family to get credentials for (required) + # @return [Provider::IndexaCapital, nil] Returns nil if credentials are not configured + def self.build_provider(family: nil) + return nil unless family.present? + + indexa_capital_item = family.indexa_capital_items.order(created_at: :desc).first + return nil unless indexa_capital_item&.credentials_configured? + + indexa_capital_item.indexa_capital_provider + end + + def sync_path + Rails.application.routes.url_helpers.sync_indexa_capital_item_path(item) + end + + def item + provider_account.indexa_capital_item + end + + def can_delete_holdings? + false + end + + def institution_domain + metadata = provider_account.institution_metadata + return nil unless metadata.present? + + domain = metadata["domain"] + url = metadata["url"] + + # Derive domain from URL if missing + if domain.blank? && url.present? + begin + domain = URI.parse(url).host&.gsub(/^www\./, "") + rescue URI::InvalidURIError + Rails.logger.warn("Invalid institution URL for IndexaCapital 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 7a9eca82f..16f389585 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" } + enum :source, { plaid: "plaid", simplefin: "simplefin", lunchflow: "lunchflow", synth: "synth", ai: "ai", enable_banking: "enable_banking", coinstats: "coinstats", mercury: "mercury", indexa_capital: "indexa_capital" } validates :name, uniqueness: { scope: [ :source ] } validates :source, presence: true diff --git a/app/views/accounts/index.html.erb b/app/views/accounts/index.html.erb index 6370f5a18..375d4f65d 100644 --- a/app/views/accounts/index.html.erb +++ b/app/views/accounts/index.html.erb @@ -21,7 +21,7 @@ -<% if @manual_accounts.empty? && @plaid_items.empty? && @simplefin_items.empty? && @lunchflow_items.empty? && @enable_banking_items.empty? && @coinstats_items.empty? && @coinbase_items.empty? && @mercury_items.empty? && @snaptrade_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? %> <%= render "empty" %> <% else %>
<%= t(".deletion_in_progress") %>
+ <% end %> +<%= t(".provider_name") %>
+ <% if indexa_capital_item.syncing? %> ++ <% if indexa_capital_item.last_synced_at %> + <%= t(".status", timestamp: time_ago_in_words(indexa_capital_item.last_synced_at), summary: indexa_capital_item.sync_status_summary) %> + <% else %> + <%= t(".status_never") %> + <% end %> +
+ <% end %> +<%= t(".setup_needed") %>
+<%= t(".setup_description", linked: indexa_capital_item.linked_accounts_count, total: indexa_capital_item.total_accounts_count) %>
+ <%= render DS::Link.new( + text: t(".setup_action"), + icon: "settings", + variant: "primary", + href: setup_accounts_indexa_capital_item_path(indexa_capital_item), + frame: :modal + ) %> +<%= t(".no_accounts_title") %>
+<%= t(".no_accounts_description") %>
+<%= t("indexa_capital_items.select_accounts.no_accounts_found") %>
+<%= t("indexa_capital_items.select_existing_account.no_accounts") %>
+<%= t("indexa_capital_items.select_existing_account.connect_hint") %>
+ <%= link_to t("indexa_capital_items.select_existing_account.settings_link"), settings_providers_path, class: "btn btn--primary btn--sm mt-4" %> ++ <%= t("indexa_capital_items.select_existing_account.linking_to") %> + <%= @account.name %> +
++ <% if indexa_capital_account.account_type.present? %> + <%= indexa_capital_account.account_type.titleize %> · + <% end %> + <%= t("indexa_capital_items.select_existing_account.balance_label") %> + <%= number_to_currency(indexa_capital_account.current_balance || 0, unit: Money::Currency.new(indexa_capital_account.currency || "EUR").symbol) %> +
++ <%= t("indexa_capital_items.setup_accounts.instructions") %> +
++ <%= t("indexa_capital_items.setup_accounts.sync_start_date_help") %> +
++ <%= t("indexa_capital_items.setup_accounts.no_accounts_to_setup") %> +
++ <%= t("indexa_capital_items.setup_accounts.no_accounts") %> +
+<%= t("indexa_capital_items.panel.setup_instructions") %>
+<%= error_msg %>
+<%= t("indexa_capital_items.panel.fields.api_token.label") %>
+<%= t("indexa_capital_items.panel.fields.api_token.description") %>
+ <%= form.text_field :api_token, + label: t("indexa_capital_items.panel.fields.api_token.label"), + placeholder: is_new_record ? t("indexa_capital_items.panel.fields.api_token.placeholder_new") : t("indexa_capital_items.panel.fields.api_token.placeholder_update"), + type: :password %> +<%= t("indexa_capital_items.panel.status_configured_html", accounts_path: accounts_path).html_safe %>
+ <% else %> + +<%= t("indexa_capital_items.panel.status_not_configured") %>
+ <% end %> +