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 %>
@@ -57,6 +57,10 @@ <%= render @snaptrade_items.sort_by(&:created_at) %> <% end %> + <% if @indexa_capital_items.any? %> + <%= render @indexa_capital_items.sort_by(&:created_at) %> +<% end %> + <% if @manual_accounts.any? %>
<%= render "accounts/index/manual_accounts", accounts: @manual_accounts %> diff --git a/app/views/indexa_capital_items/_indexa_capital_item.html.erb b/app/views/indexa_capital_items/_indexa_capital_item.html.erb new file mode 100644 index 000000000..30946aef5 --- /dev/null +++ b/app/views/indexa_capital_items/_indexa_capital_item.html.erb @@ -0,0 +1,146 @@ +<%# locals: (indexa_capital_item:) %> + +<%= tag.div id: dom_id(indexa_capital_item) do %> +
+ +
+ <%= icon "chevron-right", class: "group-open:transform group-open:rotate-90" %> + +
+
+ <%= tag.p indexa_capital_item.name.first.upcase, class: "text-primary text-xs font-medium" %> +
+
+ + <% unlinked_count = indexa_capital_item.unlinked_accounts_count %> + +
+
+ <%= tag.p indexa_capital_item.name, class: "font-medium text-primary" %> + <% if indexa_capital_item.scheduled_for_deletion? %> +

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

+ <% end %> +
+

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

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

+ <% 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 %> +
+
+ +
+ <% if indexa_capital_item.requires_update? %> + <%= render DS::Link.new( + text: t(".update_credentials"), + icon: "refresh-cw", + variant: "secondary", + href: settings_providers_path, + frame: "_top" + ) %> + <% else %> + <%= icon( + "refresh-cw", + as_button: true, + href: sync_indexa_capital_item_path(indexa_capital_item), + disabled: indexa_capital_item.syncing? + ) %> + <% end %> + + <%= render DS::Menu.new do |menu| %> + <% if unlinked_count > 0 %> + <% menu.with_item( + variant: "link", + text: t(".setup_action"), + icon: "settings", + href: setup_accounts_indexa_capital_item_path(indexa_capital_item), + frame: :modal + ) %> + <% end %> + <% menu.with_item( + variant: "link", + text: t(".update_credentials"), + icon: "cable", + href: settings_providers_path(manage: "1") + ) %> + <% menu.with_item( + variant: "button", + text: t(".delete"), + icon: "trash-2", + href: indexa_capital_item_path(indexa_capital_item), + method: :delete, + confirm: CustomConfirm.for_resource_deletion(indexa_capital_item.name, high_severity: true) + ) %> + <% end %> +
+
+ + <% unless indexa_capital_item.scheduled_for_deletion? %> +
+ <% if indexa_capital_item.accounts.any? %> + <%= render "accounts/index/account_groups", accounts: indexa_capital_item.accounts %> + <% end %> + + <%# Sync summary (collapsible) - using shared ProviderSyncSummary component %> + <% stats = indexa_capital_item.syncs.ordered.first&.sync_stats || {} %> + <% activities_pending = indexa_capital_item.indexa_capital_accounts.any?(&:activities_fetch_pending) %> + <%= render ProviderSyncSummary.new( + stats: stats, + provider_item: indexa_capital_item, + activities_pending: activities_pending + ) %> + + <% if unlinked_count > 0 && indexa_capital_item.accounts.empty? %> + <%# No accounts imported yet - show prominent setup prompt %> +
+

<%= 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 + ) %> +
+ <% elsif unlinked_count > 0 %> + <%# Some accounts imported, more available - show subtle link %> +
+ <%= link_to setup_accounts_indexa_capital_item_path(indexa_capital_item), + data: { turbo_frame: :modal }, + class: "flex items-center gap-2 text-sm text-secondary hover:text-primary transition-colors" do %> + <%= icon "plus", size: "sm" %> + <%= t(".more_accounts_available", count: unlinked_count) %> + <% end %> +
+ <% elsif indexa_capital_item.accounts.empty? && indexa_capital_item.indexa_capital_accounts.none? %> + <%# No provider accounts at all - waiting for sync %> +
+

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

+

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

+
+ <% end %> +
+ <% end %> +
+<% end %> diff --git a/app/views/indexa_capital_items/select_accounts.html.erb b/app/views/indexa_capital_items/select_accounts.html.erb new file mode 100644 index 000000000..d0de98808 --- /dev/null +++ b/app/views/indexa_capital_items/select_accounts.html.erb @@ -0,0 +1,80 @@ +<% content_for :title, t("indexa_capital_items.select_accounts.title") %> + +<%= render DS::Dialog.new do |dialog| %> + <% dialog.with_header(title: t("indexa_capital_items.select_accounts.title")) do %> +
+ <%= icon "trending-up", class: "text-primary" %> + <%= t("indexa_capital_items.select_accounts.description", product_name: "Maybe") %> +
+ <% end %> + + <% dialog.with_body do %> + <% if @indexa_capital_accounts.blank? %> +
+ <%= icon "alert-circle", class: "text-warning mx-auto mb-4", size: "lg" %> +

<%= t("indexa_capital_items.select_accounts.no_accounts_found") %>

+
+
+ <%= render DS::Link.new( + text: t("indexa_capital_items.select_accounts.cancel"), + variant: "secondary", + href: accounts_path, + frame: "_top" + ) %> +
+ <% else %> + <%= form_with url: link_accounts_indexa_capital_items_path, + method: :post, + data: { + controller: "loading-button", + action: "submit->loading-button#showLoading", + loading_button_loading_text_value: t("indexa_capital_items.loading.loading_message"), + turbo_frame: "_top" + } do |form| %> + <%= hidden_field_tag :accountable_type, @accountable_type %> + <%= hidden_field_tag :return_to, @return_to %> + +
+ <% @indexa_capital_accounts.each do |indexa_capital_account| %> +
+
+ + +
+
+ <% end %> +
+ +
+ <%= render DS::Button.new( + text: t("indexa_capital_items.select_accounts.link_accounts"), + variant: "primary", + icon: "plus", + type: "submit", + class: "flex-1", + data: { loading_button_target: "button" } + ) %> + <%= render DS::Link.new( + text: t("indexa_capital_items.select_accounts.cancel"), + variant: "secondary", + href: accounts_path, + frame: "_top" + ) %> +
+ <% end %> + <% end %> + <% end %> +<% end %> diff --git a/app/views/indexa_capital_items/select_existing_account.html.erb b/app/views/indexa_capital_items/select_existing_account.html.erb new file mode 100644 index 000000000..7b0f46764 --- /dev/null +++ b/app/views/indexa_capital_items/select_existing_account.html.erb @@ -0,0 +1,67 @@ +<% content_for :title, t("indexa_capital_items.select_existing_account.title", account_name: @account.name) %> + +<%= render DS::Dialog.new do |dialog| %> + <% dialog.with_header(title: t("indexa_capital_items.select_existing_account.header")) do %> +
+ <%= icon "link", class: "text-primary" %> + <%= t("indexa_capital_items.select_existing_account.subtitle", account_name: @account.name) %> +
+ <% end %> + + <% dialog.with_body do %> + <% if @indexa_capital_accounts.blank? %> +
+ <%= icon "alert-circle", class: "text-warning mx-auto mb-4", size: "lg" %> +

<%= 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" %> +
+ <% else %> +
+
+

+ <%= t("indexa_capital_items.select_existing_account.linking_to") %> + <%= @account.name %> +

+
+ + <% @indexa_capital_accounts.each do |indexa_capital_account| %> + <%= form_with url: link_existing_account_indexa_capital_items_path, + method: :post, + local: true, + class: "border border-primary rounded-lg p-4 hover:bg-surface transition-colors" do |form| %> + <%= hidden_field_tag :account_id, @account.id %> + <%= hidden_field_tag :indexa_capital_account_id, indexa_capital_account.id %> + +
+
+

<%= indexa_capital_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) %> +

+
+ <%= render DS::Button.new( + text: t("indexa_capital_items.select_existing_account.link_button"), + variant: "primary", + size: "sm", + type: "submit" + ) %> +
+ <% end %> + <% end %> +
+ +
+ <%= render DS::Link.new( + text: t("indexa_capital_items.select_existing_account.cancel_button"), + variant: "secondary", + href: account_path(@account) + ) %> +
+ <% end %> + <% end %> +<% end %> diff --git a/app/views/indexa_capital_items/setup_accounts.html.erb b/app/views/indexa_capital_items/setup_accounts.html.erb new file mode 100644 index 000000000..b73b3ed5f --- /dev/null +++ b/app/views/indexa_capital_items/setup_accounts.html.erb @@ -0,0 +1,118 @@ +<% content_for :title, t("indexa_capital_items.setup_accounts.title") %> + +<%= render DS::Dialog.new(disable_click_outside: true) do |dialog| %> + <% dialog.with_header(title: t("indexa_capital_items.setup_accounts.title")) do %> +
+ <%= icon "trending-up", class: "text-primary" %> + <%= t("indexa_capital_items.setup_accounts.subtitle") %> +
+ <% end %> + + <% dialog.with_body do %> +
+
+
+ <%= icon "info", size: "sm", class: "text-primary mt-0.5 flex-shrink-0" %> +
+

+ <%= t("indexa_capital_items.setup_accounts.instructions") %> +

+
+
+
+ + <%= form_with url: complete_account_setup_indexa_capital_item_path(@indexa_capital_item), + method: :post, + data: { + controller: "loading-button", + action: "submit->loading-button#showLoading", + loading_button_loading_text_value: t("indexa_capital_items.setup_accounts.creating"), + turbo_frame: "_top" + } do |form| %> + + <% if @unlinked_accounts.any? %> +
+

<%= t("indexa_capital_items.setup_accounts.accounts_count", count: @unlinked_accounts.count) %>

+ + <% @unlinked_accounts.each do |indexa_capital_account| %> +
+
+ + +
+
+ + +

+ <%= t("indexa_capital_items.setup_accounts.sync_start_date_help") %> +

+
+
+ <% end %> +
+ +
+ <%= render DS::Button.new( + text: t("indexa_capital_items.setup_accounts.import_selected"), + variant: "primary", + icon: "plus", + type: "submit", + class: "flex-1", + data: { loading_button_target: "button" } + ) %> + <%= render DS::Link.new( + text: t("indexa_capital_items.setup_accounts.cancel"), + variant: "secondary", + href: accounts_path, + frame: "_top" + ) %> +
+ <% else %> +
+ <%= icon "alert-circle", size: "lg", class: "text-warning" %> +

+ <%= t("indexa_capital_items.setup_accounts.no_accounts_to_setup") %> +

+

+ <%= t("indexa_capital_items.setup_accounts.no_accounts") %> +

+
+
+ <%= render DS::Link.new( + text: t("indexa_capital_items.setup_accounts.cancel"), + variant: "secondary", + href: accounts_path, + frame: "_top" + ) %> +
+ <% end %> + + <% end %> +
+ <% end %> +<% end %> diff --git a/app/views/settings/providers/_indexa_capital_panel.html.erb b/app/views/settings/providers/_indexa_capital_panel.html.erb new file mode 100644 index 000000000..c31ec5f1c --- /dev/null +++ b/app/views/settings/providers/_indexa_capital_panel.html.erb @@ -0,0 +1,77 @@ +
+
+

<%= t("indexa_capital_items.panel.setup_instructions") %>

+
    +
  1. <%= t("indexa_capital_items.panel.step_1") %>
  2. +
  3. <%= t("indexa_capital_items.panel.step_2") %>
  4. +
  5. <%= t("indexa_capital_items.panel.step_3") %>
  6. +
+
+ + <% error_msg = local_assigns[:error_message] || @error_message %> + <% if error_msg.present? %> +
+

<%= error_msg %>

+
+ <% end %> + + <% + indexa_capital_item = Current.family.indexa_capital_items.first_or_initialize(name: "Indexa Capital Connection") + is_new_record = indexa_capital_item.new_record? + %> + + <%= styled_form_with model: indexa_capital_item, + url: is_new_record ? indexa_capital_items_path : indexa_capital_item_path(indexa_capital_item), + scope: :indexa_capital_item, + method: is_new_record ? :post : :patch, + data: { turbo: true }, + class: "space-y-3" do |form| %> + +
+

<%= 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.alternative_auth") %> + +
+ <%= form.text_field :username, + label: t("indexa_capital_items.panel.fields.username.label"), + placeholder: is_new_record ? t("indexa_capital_items.panel.fields.username.placeholder_new") : t("indexa_capital_items.panel.fields.username.placeholder_update"), + value: indexa_capital_item.username %> + + <%= form.text_field :document, + label: t("indexa_capital_items.panel.fields.document.label"), + placeholder: is_new_record ? t("indexa_capital_items.panel.fields.document.placeholder_new") : t("indexa_capital_items.panel.fields.document.placeholder_update"), + value: indexa_capital_item.document %> + + <%= form.text_field :password, + label: t("indexa_capital_items.panel.fields.password.label"), + placeholder: is_new_record ? t("indexa_capital_items.panel.fields.password.placeholder_new") : t("indexa_capital_items.panel.fields.password.placeholder_update"), + type: :password %> +
+
+ +
+ <%= form.submit is_new_record ? t("indexa_capital_items.panel.save_button") : t("indexa_capital_items.panel.update_button"), + class: "inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium btn btn--primary" %> +
+ <% end %> + + <% items = local_assigns[:indexa_capital_items] || @indexa_capital_items || Current.family.indexa_capital_items.where.not(username: [nil, ""], document: [nil, ""], password: [nil, ""]).or(Current.family.indexa_capital_items.where.not(api_token: [nil, ""])) %> +
+ <% if items&.any? %> +
+

<%= t("indexa_capital_items.panel.status_configured_html", accounts_path: accounts_path).html_safe %>

+ <% else %> +
+

<%= t("indexa_capital_items.panel.status_not_configured") %>

+ <% end %> +
+
diff --git a/app/views/settings/providers/show.html.erb b/app/views/settings/providers/show.html.erb index c7e922f68..3e5603e39 100644 --- a/app/views/settings/providers/show.html.erb +++ b/app/views/settings/providers/show.html.erb @@ -72,5 +72,11 @@ <%= render "settings/providers/snaptrade_panel" %> <% end %> + + <%= settings_section title: "Indexa Capital", collapsible: true, open: false do %> + + <%= render "settings/providers/indexa_capital_panel" %> + + <% end %> <% end %>
diff --git a/config/locales/views/indexa_capital_items/en.yml b/config/locales/views/indexa_capital_items/en.yml new file mode 100644 index 000000000..a3a448b7c --- /dev/null +++ b/config/locales/views/indexa_capital_items/en.yml @@ -0,0 +1,254 @@ +--- +en: + indexa_capital_items: + # Model method strings (i18n for item_model.rb) + sync_status: + no_accounts: "No accounts found" + synced: + one: "%{count} account synced" + other: "%{count} accounts synced" + synced_with_setup: "%{linked} synced, %{unlinked} need setup" + institution_summary: + none: "No institutions connected" + count: + one: "%{count} institution" + other: "%{count} institutions" + errors: + provider_not_configured: "IndexaCapital provider is not configured" + + # Syncer status messages + sync: + status: + importing: "Importing accounts from IndexaCapital..." + processing: "Processing holdings and activities..." + calculating: "Calculating balances..." + importing_data: "Importing account data..." + checking_setup: "Checking account configuration..." + needs_setup: "%{count} accounts need setup..." + success: "Sync started" + + # Panel (settings view) + panel: + setup_instructions: "Setup instructions:" + step_1: "Visit your Indexa Capital dashboard to generate a read-only API token" + step_2: "Paste your API token below and click Save" + step_3: "After a successful connection, go to the Accounts tab to set up new accounts" + field_descriptions: "Field descriptions:" + optional: "(Optional)" + required: "(required)" + optional_with_default: "(optional, defaults to %{default_value})" + alternative_auth: "Or use username/password authentication instead..." + save_button: "Save Configuration" + update_button: "Update Configuration" + status_configured_html: "Configured and ready to use. Visit the Accounts tab to manage and set up accounts." + status_not_configured: "Not configured" + fields: + api_token: + label: "API Token" + description: "Your read-only API token from Indexa Capital dashboard" + placeholder_new: "Paste your API token here" + placeholder_update: "Enter new API token to update" + username: + label: "Username" + description: "Your Indexa Capital username/email" + placeholder_new: "Paste username here" + placeholder_update: "Enter new username to update" + document: + label: "Document ID" + description: "Your Indexa Capital document/ID" + placeholder_new: "Paste document ID here" + placeholder_update: "Enter new document ID to update" + password: + label: "Password" + description: "Your Indexa Capital password" + placeholder_new: "Paste password here" + placeholder_update: "Enter new password to update" + + # CRUD success messages + create: + success: "IndexaCapital connection created successfully" + update: + success: "IndexaCapital connection updated" + destroy: + success: "IndexaCapital connection removed" + index: + title: "IndexaCapital Connections" + + # Loading states + loading: + loading_message: "Loading IndexaCapital accounts..." + loading_title: "Loading" + + # Account linking + link_accounts: + all_already_linked: + one: "The selected account (%{names}) is already linked" + other: "All %{count} selected accounts are already linked: %{names}" + api_error: "API error: %{message}" + invalid_account_names: + one: "Cannot link account with blank name" + other: "Cannot link %{count} accounts with blank names" + link_failed: "Failed to link accounts" + no_accounts_selected: "Please select at least one account" + no_api_key: "IndexaCapital credentials not found. Please configure them in Provider Settings." + partial_invalid: "Successfully linked %{created_count} account(s), %{already_linked_count} were already linked, %{invalid_count} account(s) had invalid names" + partial_success: "Successfully linked %{created_count} account(s). %{already_linked_count} account(s) were already linked: %{already_linked_names}" + success: + one: "Successfully linked %{count} account" + other: "Successfully linked %{count} accounts" + + # Provider item display (used in _item partial) + indexa_capital_item: + accounts_need_setup: "Accounts need setup" + delete: "Delete connection" + deletion_in_progress: "deletion in progress..." + error: "Error" + more_accounts_available: + one: "%{count} more account available" + other: "%{count} more accounts available" + no_accounts_description: "This connection has no linked accounts yet." + no_accounts_title: "No accounts" + provider_name: "IndexaCapital" + requires_update: "Connection needs update" + setup_action: "Set Up New Accounts" + setup_description: "%{linked} of %{total} accounts linked. Choose account types for your newly imported IndexaCapital accounts." + setup_needed: "New accounts ready to set up" + status: "Synced %{timestamp} ago — %{summary}" + status_never: "Never synced" + syncing: "Syncing..." + total: "Total" + unlinked: "Unlinked" + update_credentials: "Update credentials" + + # Select accounts view + select_accounts: + accounts_selected: "accounts selected" + api_error: "API error: %{message}" + cancel: "Cancel" + configure_name_in_provider: "Cannot import - please configure account name in IndexaCapital" + 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 IndexaCapital credentials." + no_api_key: "IndexaCapital credentials are not configured. Please configure them in Settings." + no_credentials_configured: "Please configure your IndexaCapital credentials first in Provider Settings." + no_name_placeholder: "(No name)" + title: "Select IndexaCapital Accounts" + + # Select existing account view + select_existing_account: + account_already_linked: "This account is already linked to a provider" + all_accounts_already_linked: "All IndexaCapital accounts are already linked" + api_error: "API error: %{message}" + balance_label: "Balance:" + cancel: "Cancel" + cancel_button: "Cancel" + configure_name_in_provider: "Cannot import - please configure account name in IndexaCapital" + connect_hint: "Connect a IndexaCapital account to enable automatic syncing." + description: "Select a IndexaCapital account to link with this account. Transactions will be synced and deduplicated automatically." + header: "Link with IndexaCapital" + link_account: "Link account" + link_button: "Link this account" + linking_to: "Linking to:" + no_account_specified: "No account specified" + no_accounts: "No unlinked IndexaCapital accounts found." + no_accounts_found: "No IndexaCapital accounts found. Please check your credentials." + no_api_key: "IndexaCapital credentials are not configured. Please configure them in Settings." + no_credentials_configured: "Please configure your IndexaCapital credentials first in Provider Settings." + no_name_placeholder: "(No name)" + settings_link: "Go to Provider Settings" + subtitle: "Choose a IndexaCapital account" + title: "Link %{account_name} with IndexaCapital" + + # Link existing account + link_existing_account: + account_already_linked: "This account is already linked to a provider" + api_error: "API error: %{message}" + invalid_account_name: "Cannot link account with blank name" + provider_account_already_linked: "This IndexaCapital account is already linked to another account" + provider_account_not_found: "IndexaCapital account not found" + missing_parameters: "Missing required parameters" + no_api_key: "IndexaCapital credentials not found. Please configure them in Provider Settings." + success: "Successfully linked %{account_name} with IndexaCapital" + + # Setup accounts wizard + setup_accounts: + account_type_label: "Account Type:" + accounts_count: + one: "%{count} account available" + other: "%{count} accounts available" + all_accounts_linked: "All your IndexaCapital accounts have already been set up." + api_error: "API error: %{message}" + creating: "Creating accounts..." + fetch_failed: "Failed to Fetch Accounts" + import_selected: "Import selected accounts" + instructions: "Select the accounts you want to import from IndexaCapital. You can choose multiple accounts." + no_accounts: "No unlinked accounts found from this IndexaCapital connection." + no_accounts_to_setup: "No Accounts to Set Up" + no_api_key: "IndexaCapital credentials are not configured. Please check your connection settings." + select_all: "Select all" + account_types: + skip: "Skip this account" + depository: "Checking or Savings Account" + credit_card: "Credit Card" + investment: "Investment Account" + crypto: "Cryptocurrency Account" + loan: "Loan or Mortgage" + other_asset: "Other Asset" + subtype_labels: + depository: "Account Subtype:" + credit_card: "" + investment: "Investment Type:" + crypto: "" + 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." + crypto: "Cryptocurrency accounts will be set up to track holdings and transactions." + subtypes: + depository: + checking: "Checking" + savings: "Savings" + hsa: "Health Savings Account" + cd: "Certificate of Deposit" + money_market: "Money Market" + investment: + brokerage: "Brokerage" + pension: "Pension" + retirement: "Retirement" + "401k": "401(k)" + roth_401k: "Roth 401(k)" + "403b": "403(b)" + tsp: "Thrift Savings Plan" + "529_plan": "529 Plan" + hsa: "Health Savings Account" + mutual_fund: "Mutual Fund" + ira: "Traditional IRA" + roth_ira: "Roth IRA" + angel: "Angel" + loan: + mortgage: "Mortgage" + student: "Student Loan" + auto: "Auto Loan" + other: "Other Loan" + balance: "Balance" + cancel: "Cancel" + choose_account_type: "Choose the correct account type for each IndexaCapital account:" + create_accounts: "Create Accounts" + creating_accounts: "Creating Accounts..." + historical_data_range: "Historical Data Range:" + subtitle: "Choose the correct account types for your imported accounts" + sync_start_date_help: "Select how far back you want to sync transaction history." + sync_start_date_label: "Start syncing transactions from:" + title: "Set Up Your IndexaCapital Accounts" + + # Complete account setup + complete_account_setup: + all_skipped: "All accounts were skipped. No accounts were created." + creation_failed: "Failed to create accounts: %{error}" + no_accounts: "No accounts to set up." + success: "Successfully created %{count} account(s)." + + # Preload accounts + preload_accounts: + no_credentials_configured: "Please configure your IndexaCapital credentials first in Provider Settings." diff --git a/config/routes.rb b/config/routes.rb index 1d10471e4..64decceb5 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -2,6 +2,21 @@ require "sidekiq/web" require "sidekiq/cron/web" Rails.application.routes.draw do + resources :indexa_capital_items, only: [ :index, :new, :create, :show, :edit, :update, :destroy ] do + collection do + get :preload_accounts + get :select_accounts + post :link_accounts + get :select_existing_account + post :link_existing_account + end + + member do + post :sync + get :setup_accounts + post :complete_account_setup + end + end resources :mercury_items, only: %i[index new create show edit update destroy] do collection do get :preload_accounts diff --git a/db/migrate/20260205110328_create_indexa_capital_items_and_accounts.rb b/db/migrate/20260205110328_create_indexa_capital_items_and_accounts.rb new file mode 100644 index 000000000..804594f82 --- /dev/null +++ b/db/migrate/20260205110328_create_indexa_capital_items_and_accounts.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +class CreateIndexaCapitalItemsAndAccounts < ActiveRecord::Migration[7.2] + def change + # Create provider items table (stores per-family connection credentials) + create_table :indexa_capital_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 :username + t.string :document + t.text :password + + t.timestamps + end + + add_index :indexa_capital_items, :status + + # Create provider accounts table (stores individual account data from provider) + create_table :indexa_capital_accounts, id: :uuid do |t| + t.references :indexa_capital_item, null: false, foreign_key: true, type: :uuid + + # Account identification + t.string :name + t.string :indexa_capital_account_id + t.string :account_number + + # Account details + t.string :currency + t.decimal :current_balance, precision: 19, scale: 4 + t.string :account_status + t.string :account_type + t.string :provider + + # Metadata and raw data + t.jsonb :institution_metadata + t.jsonb :raw_payload + + # Investment-specific columns + t.string :indexa_capital_authorization_id + t.decimal :cash_balance, precision: 19, scale: 4, default: 0.0 + t.jsonb :raw_holdings_payload, default: [] + t.jsonb :raw_activities_payload, default: [] + t.datetime :last_holdings_sync + t.datetime :last_activities_sync + t.boolean :activities_fetch_pending, default: false + + # Sync settings + t.date :sync_start_date + + t.timestamps + end + + add_index :indexa_capital_accounts, :indexa_capital_account_id, unique: true + add_index :indexa_capital_accounts, :indexa_capital_authorization_id + end +end diff --git a/db/migrate/20260207231945_add_api_token_to_indexa_capital_items.rb b/db/migrate/20260207231945_add_api_token_to_indexa_capital_items.rb new file mode 100644 index 000000000..5ae9d5975 --- /dev/null +++ b/db/migrate/20260207231945_add_api_token_to_indexa_capital_items.rb @@ -0,0 +1,5 @@ +class AddApiTokenToIndexaCapitalItems < ActiveRecord::Migration[7.2] + def change + add_column :indexa_capital_items, :api_token, :text + end +end diff --git a/db/schema.rb b/db/schema.rb index 6cee63d21..6a5655b1e 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2026_02_03_204605) do +ActiveRecord::Schema[7.2].define(version: 2026_02_07_231945) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -668,6 +668,57 @@ ActiveRecord::Schema[7.2].define(version: 2026_02_03_204605) do t.index ["family_id"], name: "index_imports_on_family_id" end + create_table "indexa_capital_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "indexa_capital_item_id", null: false + t.string "name" + t.string "indexa_capital_account_id" + t.string "account_number" + t.string "currency" + t.decimal "current_balance", precision: 19, scale: 4 + t.string "account_status" + t.string "account_type" + t.string "provider" + t.jsonb "institution_metadata" + t.jsonb "raw_payload" + t.string "indexa_capital_authorization_id" + t.decimal "cash_balance", precision: 19, scale: 4, default: "0.0" + t.jsonb "raw_holdings_payload", default: [] + t.jsonb "raw_activities_payload", default: [] + t.datetime "last_holdings_sync" + t.datetime "last_activities_sync" + t.boolean "activities_fetch_pending", default: false + t.date "sync_start_date" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["indexa_capital_account_id"], name: "index_indexa_capital_accounts_on_indexa_capital_account_id", unique: true + t.index ["indexa_capital_authorization_id"], name: "idx_on_indexa_capital_authorization_id_58db208d52" + t.index ["indexa_capital_item_id"], name: "index_indexa_capital_accounts_on_indexa_capital_item_id" + end + + create_table "indexa_capital_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 "username" + t.string "document" + t.text "password" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.text "api_token" + t.index ["family_id"], name: "index_indexa_capital_items_on_family_id" + t.index ["status"], name: "index_indexa_capital_items_on_status" + end + create_table "investments", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.datetime "created_at", null: false t.datetime "updated_at", null: false @@ -1473,6 +1524,8 @@ ActiveRecord::Schema[7.2].define(version: 2026_02_03_204605) do add_foreign_key "impersonation_sessions", "users", column: "impersonator_id" add_foreign_key "import_rows", "imports" add_foreign_key "imports", "families" + add_foreign_key "indexa_capital_accounts", "indexa_capital_items" + add_foreign_key "indexa_capital_items", "families" add_foreign_key "invitations", "families" add_foreign_key "invitations", "users", column: "inviter_id" add_foreign_key "llm_usages", "families" diff --git a/test/controllers/indexa_capital_items_controller_test.rb b/test/controllers/indexa_capital_items_controller_test.rb new file mode 100644 index 000000000..1f0df601b --- /dev/null +++ b/test/controllers/indexa_capital_items_controller_test.rb @@ -0,0 +1,152 @@ +# frozen_string_literal: true + +require "test_helper" + +class IndexaCapitalItemsControllerTest < ActionDispatch::IntegrationTest + include ActiveJob::TestHelper + + setup do + sign_in users(:family_admin) + @family = families(:dylan_family) + @item = indexa_capital_items(:configured_with_token) + end + + test "should create indexa_capital_item with api_token" do + assert_difference("IndexaCapitalItem.count", 1) do + post indexa_capital_items_url, params: { + indexa_capital_item: { name: "New Connection", api_token: "new_token" } + } + end + + assert_redirected_to settings_providers_path + end + + test "should update indexa_capital_item" do + patch indexa_capital_item_url(@item), params: { + indexa_capital_item: { name: "Updated Name" } + } + + assert_redirected_to settings_providers_path + @item.reload + assert_equal "Updated Name", @item.name + end + + test "should destroy indexa_capital_item" do + assert_difference("IndexaCapitalItem.count", 0) do # doesn't delete immediately + delete indexa_capital_item_url(@item) + end + + assert_redirected_to settings_providers_path + @item.reload + assert @item.scheduled_for_deletion? + end + + test "should sync indexa_capital_item" do + post sync_indexa_capital_item_url(@item) + assert_response :redirect + end + + test "should show setup_accounts page" do + get setup_accounts_indexa_capital_item_url(@item) + assert_response :success + end + + test "complete_account_setup creates accounts for selected indexa_capital_accounts" do + ica = indexa_capital_accounts(:mutual_fund) + + assert_difference "Account.count", 1 do + post complete_account_setup_indexa_capital_item_url(@item), params: { + accounts: { + ica.id => { account_type: "investment", subtype: "brokerage" } + } + } + end + + assert_response :redirect + ica.reload + assert_not_nil ica.current_account + assert_equal "Investment", ica.current_account.accountable_type + end + + test "complete_account_setup skips already linked accounts" do + ica = indexa_capital_accounts(:mutual_fund) + + # Pre-link + account = Account.create!( + family: @family, name: "Existing Fund", balance: 1000, currency: "EUR", + accountable: Investment.new + ) + AccountProvider.create!(account: account, provider: ica) + + assert_no_difference "Account.count" do + post complete_account_setup_indexa_capital_item_url(@item), params: { + accounts: { + ica.id => { account_type: "investment" } + } + } + end + end + + test "complete_account_setup with all skipped redirects to setup" do + ica = indexa_capital_accounts(:mutual_fund) + + assert_no_difference "Account.count" do + post complete_account_setup_indexa_capital_item_url(@item), params: { + accounts: { + ica.id => { account_type: "skip" } + } + } + end + + assert_redirected_to setup_accounts_indexa_capital_item_path(@item) + end + + test "cannot access other family's indexa_capital_item" do + other_item = indexa_capital_items(:configured_with_credentials) + + get setup_accounts_indexa_capital_item_url(other_item) + assert_response :not_found + end + + test "link_existing_account links manual account to indexa_capital_account" do + manual_account = Account.create!( + family: @family, name: "Manual Investment", balance: 0, currency: "EUR", + accountable: Investment.new + ) + + ica = indexa_capital_accounts(:pension_plan) + + assert_difference "AccountProvider.count", 1 do + post link_existing_account_indexa_capital_items_url, params: { + account_id: manual_account.id, + indexa_capital_account_id: ica.id + } + end + + ica.reload + assert_equal manual_account, ica.current_account + end + + test "link_existing_account rejects already linked provider account" do + ica = indexa_capital_accounts(:mutual_fund) + + # Pre-link + account = Account.create!( + family: @family, name: "Linked Fund", balance: 1000, currency: "EUR", + accountable: Investment.new + ) + AccountProvider.create!(account: account, provider: ica) + + target_account = Account.create!( + family: @family, name: "Target", balance: 0, currency: "EUR", + accountable: Investment.new + ) + + assert_no_difference "AccountProvider.count" do + post link_existing_account_indexa_capital_items_url, params: { + account_id: target_account.id, + indexa_capital_account_id: ica.id + } + end + end +end diff --git a/test/fixtures/indexa_capital_accounts.yml b/test/fixtures/indexa_capital_accounts.yml new file mode 100644 index 000000000..b9ff174a9 --- /dev/null +++ b/test/fixtures/indexa_capital_accounts.yml @@ -0,0 +1,29 @@ +# Minimal fixtures for Indexa Capital accounts + +mutual_fund: + indexa_capital_item: configured_with_token + name: "Indexa Capital Mutual Fund (LPYH3MCQ)" + indexa_capital_account_id: "LPYH3MCQ" + account_number: "LPYH3MCQ" + currency: "EUR" + current_balance: 38905.2136 + account_status: "active" + account_type: "mutual" + provider: "Indexa Capital" + raw_payload: {} + raw_holdings_payload: [] + raw_activities_payload: [] + +pension_plan: + indexa_capital_item: configured_with_token + name: "Indexa Capital Pension Plan (DCU8HWEP)" + indexa_capital_account_id: "DCU8HWEP" + account_number: "DCU8HWEP" + currency: "EUR" + current_balance: 70333.18 + account_status: "active" + account_type: "pension" + provider: "Indexa Capital" + raw_payload: {} + raw_holdings_payload: [] + raw_activities_payload: [] diff --git a/test/fixtures/indexa_capital_items.yml b/test/fixtures/indexa_capital_items.yml new file mode 100644 index 000000000..478d53500 --- /dev/null +++ b/test/fixtures/indexa_capital_items.yml @@ -0,0 +1,17 @@ +# Minimal fixtures for Indexa Capital items + +configured_with_token: + family: dylan_family + name: "Indexa Capital Connection" + api_token: "test_api_token_123" + status: good + scheduled_for_deletion: false + +configured_with_credentials: + family: empty + name: "Indexa Capital Credentials" + username: "testuser@example.com" + document: "12345678A" + password: "test_password" + status: good + scheduled_for_deletion: false diff --git a/test/models/indexa_capital_account/data_helpers_test.rb b/test/models/indexa_capital_account/data_helpers_test.rb new file mode 100644 index 000000000..f813fc6fd --- /dev/null +++ b/test/models/indexa_capital_account/data_helpers_test.rb @@ -0,0 +1,180 @@ +# frozen_string_literal: true + +require "test_helper" + +class IndexaCapitalAccount::DataHelpersTest < ActiveSupport::TestCase + # Create a test class that includes the concern + class TestHelper + include IndexaCapitalAccount::DataHelpers + + # Make private methods public for testing + public :parse_decimal, :parse_date, :resolve_security, :extract_currency, :extract_security_name + end + + setup do + @helper = TestHelper.new + end + + # ========================================================================== + # parse_decimal tests + # ========================================================================== + + test "parse_decimal returns nil for nil input" do + assert_nil @helper.parse_decimal(nil) + end + + test "parse_decimal parses string to BigDecimal" do + result = @helper.parse_decimal("123.45") + assert_instance_of BigDecimal, result + assert_equal BigDecimal("123.45"), result + end + + test "parse_decimal handles integer input" do + result = @helper.parse_decimal(100) + assert_instance_of BigDecimal, result + assert_equal BigDecimal("100"), result + end + + test "parse_decimal handles float input" do + result = @helper.parse_decimal(99.99) + assert_instance_of BigDecimal, result + assert_in_delta 99.99, result.to_f, 0.001 + end + + test "parse_decimal returns BigDecimal unchanged" do + input = BigDecimal("50.25") + result = @helper.parse_decimal(input) + assert_equal input, result + end + + test "parse_decimal returns nil for invalid string" do + assert_nil @helper.parse_decimal("not a number") + end + + # ========================================================================== + # parse_date tests + # ========================================================================== + + test "parse_date returns nil for nil input" do + assert_nil @helper.parse_date(nil) + end + + test "parse_date returns Date unchanged" do + input = Date.new(2024, 6, 15) + result = @helper.parse_date(input) + assert_equal input, result + end + + test "parse_date parses ISO date string" do + result = @helper.parse_date("2024-06-15") + assert_instance_of Date, result + assert_equal Date.new(2024, 6, 15), result + end + + test "parse_date parses datetime string to date" do + result = @helper.parse_date("2024-06-15T10:30:00Z") + assert_instance_of Date, result + assert_equal Date.new(2024, 6, 15), result + end + + test "parse_date converts Time to Date" do + input = Time.zone.parse("2024-06-15 10:30:00") + result = @helper.parse_date(input) + assert_instance_of Date, result + assert_equal Date.new(2024, 6, 15), result + end + + test "parse_date returns nil for invalid string" do + assert_nil @helper.parse_date("not a date") + end + + # ========================================================================== + # extract_currency tests + # ========================================================================== + + test "extract_currency returns fallback for nil currency" do + result = @helper.extract_currency({}, fallback: "USD") + assert_equal "USD", result + end + + test "extract_currency extracts string currency" do + result = @helper.extract_currency({ currency: "cad" }) + assert_equal "CAD", result + end + + test "extract_currency extracts currency from hash with code key" do + result = @helper.extract_currency({ currency: { code: "EUR" } }) + assert_equal "EUR", result + end + + test "extract_currency handles indifferent access" do + result = @helper.extract_currency({ "currency" => { "code" => "GBP" } }) + assert_equal "GBP", result + end + + # ========================================================================== + # resolve_security tests (investment providers only) + # ========================================================================== + + test "resolve_security returns nil for blank ticker" do + assert_nil @helper.resolve_security("") + assert_nil @helper.resolve_security(" ") + assert_nil @helper.resolve_security(nil) + end + + test "resolve_security finds existing security" do + existing = Security.create!(ticker: "XYZTEST", name: "Test Security Inc") + + result = @helper.resolve_security("xyztest") + assert_equal existing, result + end + + test "resolve_security creates new security when not found" do + symbol_data = { name: "Test Company Inc" } + + result = @helper.resolve_security("TEST", symbol_data) + + assert_not_nil result + assert_equal "TEST", result.ticker + assert_equal "Test Company Inc", result.name + end + + test "resolve_security upcases ticker" do + symbol_data = { name: "Lowercase Test" } + + result = @helper.resolve_security("lower", symbol_data) + + assert_equal "LOWER", result.ticker + end + + test "resolve_security uses ticker as fallback name" do + # Use short ticker (<=4 chars) to avoid titleize behavior + result = @helper.resolve_security("XYZ1", {}) + + assert_equal "XYZ1", result.name + end + + # ========================================================================== + # extract_security_name tests (investment providers only) + # ========================================================================== + + test "extract_security_name uses name field" do + result = @helper.extract_security_name({ name: "Apple Inc" }, "AAPL") + assert_equal "Apple Inc", result + end + + test "extract_security_name falls back to description" do + result = @helper.extract_security_name({ description: "Microsoft Corp" }, "MSFT") + assert_equal "Microsoft Corp", result + end + + test "extract_security_name uses ticker as fallback" do + result = @helper.extract_security_name({}, "GOOG") + assert_equal "GOOG", result + end + + test "extract_security_name ignores generic type descriptions" do + result = @helper.extract_security_name({ name: "COMMON STOCK" }, "IBM") + assert_equal "IBM", result + end +end diff --git a/test/models/indexa_capital_account/processor_test.rb b/test/models/indexa_capital_account/processor_test.rb new file mode 100644 index 000000000..f22678cf3 --- /dev/null +++ b/test/models/indexa_capital_account/processor_test.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +require "test_helper" + +class IndexaCapitalAccount::ProcessorTest < ActiveSupport::TestCase + setup do + @family = families(:dylan_family) + @item = indexa_capital_items(:configured_with_token) + @indexa_capital_account = indexa_capital_accounts(:mutual_fund) + + @account = @family.accounts.create!( + name: "Test Investment", + balance: 10000, + currency: "EUR", + accountable: Investment.new + ) + + @indexa_capital_account.ensure_account_provider!(@account) + @indexa_capital_account.reload + end + + # ========================================================================== + # Processor tests + # ========================================================================== + + test "processor initializes with indexa_capital_account" do + processor = IndexaCapitalAccount::Processor.new(@indexa_capital_account) + assert_not_nil processor + end + + test "processor skips processing when no linked account" do + unlinked = indexa_capital_accounts(:pension_plan) + + processor = IndexaCapitalAccount::Processor.new(unlinked) + assert_nothing_raised { processor.process } + end + + test "processor updates account balance from holdings value" do + @indexa_capital_account.update!( + current_balance: 38905.21, + raw_holdings_payload: [ + { + "amount" => 16333.96, + "titles" => 32.26, + "price" => 506.32, + "instrument" => { "identifier" => "IE00BFPM9V94", "name" => "Vanguard US 500" } + }, + { + "amount" => 10759.05, + "titles" => 40.34, + "price" => 266.71, + "instrument" => { "identifier" => "IE00BFPM9L96", "name" => "Vanguard European" } + } + ] + ) + + @account.update!(balance: 0) + + processor = IndexaCapitalAccount::Processor.new(@indexa_capital_account) + processor.process + + @account.reload + assert_in_delta 27093.01, @account.balance.to_f, 0.01 + end + + # ========================================================================== + # HoldingsProcessor tests + # ========================================================================== + + test "holdings processor creates holdings from fiscal-results payload" do + @indexa_capital_account.update!(raw_holdings_payload: [ + { + "amount" => 16333.96, + "titles" => 32.26, + "price" => 506.32, + "cost_price" => 390.60, + "instrument" => { + "identifier" => "IE00BFPM9V94", + "name" => "Vanguard US 500 Stk Idx Eur -Ins Plus", + "isin_code" => "IE00BFPM9V94" + } + } + ]) + + processor = IndexaCapitalAccount::HoldingsProcessor.new(@indexa_capital_account) + + assert_difference "@account.holdings.count", 1 do + processor.process + end + + holding = @account.holdings.order(created_at: :desc).first + assert_equal "IE00BFPM9V94", holding.security.ticker + assert_equal 32.26, holding.qty.to_f + end + + test "holdings processor skips entries without instrument identifier" do + @indexa_capital_account.update!(raw_holdings_payload: [ + { "amount" => 100, "titles" => 1, "price" => 100, "instrument" => {} } + ]) + + processor = IndexaCapitalAccount::HoldingsProcessor.new(@indexa_capital_account) + assert_nothing_raised { processor.process } + end + + test "holdings processor handles empty payload" do + @indexa_capital_account.update!(raw_holdings_payload: []) + + processor = IndexaCapitalAccount::HoldingsProcessor.new(@indexa_capital_account) + assert_nothing_raised { processor.process } + end +end diff --git a/test/models/indexa_capital_account_test.rb b/test/models/indexa_capital_account_test.rb new file mode 100644 index 000000000..7620608b7 --- /dev/null +++ b/test/models/indexa_capital_account_test.rb @@ -0,0 +1,126 @@ +# frozen_string_literal: true + +require "test_helper" + +class IndexaCapitalAccountTest < ActiveSupport::TestCase + setup do + @family = families(:dylan_family) + @item = indexa_capital_items(:configured_with_token) + @account = indexa_capital_accounts(:mutual_fund) + end + + test "belongs to indexa_capital_item" do + assert_equal @item, @account.indexa_capital_item + end + + test "validates presence of name" do + @account.name = nil + assert_not @account.valid? + end + + test "validates presence of currency" do + @account.currency = nil + assert_not @account.valid? + end + + test "upsert_from_indexa_capital! updates from API data" do + data = { + account_number: "NEWACCT1", + name: "New Account", + type: "mutual", + status: "active", + currency: "EUR", + current_balance: 12345.67 + } + + new_account = @item.indexa_capital_accounts.create!( + name: "Placeholder", currency: "EUR", + indexa_capital_account_id: "NEWACCT1" + ) + new_account.upsert_from_indexa_capital!(data) + + new_account.reload + assert_equal "NEWACCT1", new_account.indexa_capital_account_id + assert_equal "New Account", new_account.name + assert_equal "mutual", new_account.account_type + assert_equal "active", new_account.account_status + assert_equal 12345.67, new_account.current_balance.to_f + end + + test "upsert_from_indexa_capital! without balance does not overwrite existing" do + assert_equal 38905.2136, @account.current_balance.to_f + + data = { + account_number: "LPYH3MCQ", + name: "Updated Name", + type: "mutual", + status: "active", + currency: "EUR" + # No current_balance + } + @account.upsert_from_indexa_capital!(data) + @account.reload + + assert_equal "Updated Name", @account.name + assert_equal 38905.2136, @account.current_balance.to_f + end + + test "upsert_from_indexa_capital! stores zero balance correctly" do + data = { + account_number: "LPYH3MCQ", + name: "Zero Balance Account", + type: "mutual", + status: "active", + currency: "EUR", + current_balance: 0 + } + @account.upsert_from_indexa_capital!(data) + @account.reload + + assert_equal 0, @account.current_balance.to_f + end + + test "upsert_holdings_snapshot! stores holdings data" do + holdings = [ { instrument: { identifier: "IE00BFPM9V94" }, titles: 32, price: 506.32, amount: 16333.96 } ] + @account.upsert_holdings_snapshot!(holdings) + + @account.reload + assert_equal 1, @account.raw_holdings_payload.size + assert_not_nil @account.last_holdings_sync + end + + test "upsert_holdings_snapshot! skips when empty" do + @account.update!(last_holdings_sync: 1.day.ago) + original_sync = @account.last_holdings_sync + + @account.upsert_holdings_snapshot!([]) + @account.reload + + assert_equal original_sync, @account.last_holdings_sync + end + + test "ensure_account_provider! creates link" do + linked_account = Account.create!( + family: @family, name: "My Fund", balance: 1000, currency: "EUR", + accountable: Investment.new + ) + + assert_nil @account.account_provider + @account.ensure_account_provider!(linked_account) + + assert_not_nil @account.account_provider + assert_equal linked_account, @account.account + end + + test "ensure_account_provider! is idempotent" do + linked_account = Account.create!( + family: @family, name: "My Fund", balance: 1000, currency: "EUR", + accountable: Investment.new + ) + + @account.ensure_account_provider!(linked_account) + assert_no_difference "AccountProvider.count" do + @account.ensure_account_provider!(linked_account) + end + end +end diff --git a/test/models/indexa_capital_item_test.rb b/test/models/indexa_capital_item_test.rb new file mode 100644 index 000000000..76bc77a22 --- /dev/null +++ b/test/models/indexa_capital_item_test.rb @@ -0,0 +1,143 @@ +# frozen_string_literal: true + +require "test_helper" + +class IndexaCapitalItemTest < ActiveSupport::TestCase + setup do + @family = families(:dylan_family) + @item = indexa_capital_items(:configured_with_token) + end + + test "belongs to family" do + assert_equal @family, @item.family + end + + test "has many indexa_capital_accounts" do + assert_includes @item.indexa_capital_accounts, indexa_capital_accounts(:mutual_fund) + end + + test "has good status by default" do + assert_equal "good", @item.status + end + + test "validates presence of name" do + item = IndexaCapitalItem.new(family: @family, api_token: "test") + assert_not item.valid? + assert_includes item.errors[:name], "can't be blank" + end + + test "valid with api_token only" do + item = IndexaCapitalItem.new(family: @family, name: "Test", api_token: "test_token") + assert item.valid? + end + + test "valid with username/document/password credentials" do + item = IndexaCapitalItem.new( + family: @family, name: "Test", + username: "user@example.com", document: "12345678A", password: "secret" + ) + assert item.valid? + end + + test "invalid without any credentials on create" do + item = IndexaCapitalItem.new(family: @family, name: "Test") + assert_not item.valid? + assert item.errors[:base].any? + end + + test "credentials_configured? returns true with api_token" do + assert @item.credentials_configured? + end + + test "credentials_configured? returns true with username/document/password" do + item = indexa_capital_items(:configured_with_credentials) + assert item.credentials_configured? + end + + test "credentials_configured? returns false when nothing set" do + item = IndexaCapitalItem.new(family: @family, name: "Test") + refute item.credentials_configured? + end + + test "indexa_capital_provider returns nil when not configured" do + item = IndexaCapitalItem.new(family: @family, name: "Test") + assert_nil item.indexa_capital_provider + end + + test "indexa_capital_provider returns provider with token auth" do + provider = @item.indexa_capital_provider + assert_instance_of Provider::IndexaCapital, provider + end + + test "indexa_capital_provider returns provider with credentials auth" do + item = indexa_capital_items(:configured_with_credentials) + provider = item.indexa_capital_provider + assert_instance_of Provider::IndexaCapital, provider + end + + test "can be marked for deletion" do + refute @item.scheduled_for_deletion? + @item.destroy_later + assert @item.scheduled_for_deletion? + end + + test "is syncable" do + assert_respond_to @item, :sync_later + assert_respond_to @item, :syncing? + end + + test "scopes work correctly" do + item_for_deletion = IndexaCapitalItem.create!( + family: @family, name: "Delete Me", api_token: "test", + scheduled_for_deletion: true, created_at: 1.day.ago + ) + + active_items = @family.indexa_capital_items.active + assert_includes active_items, @item + refute_includes active_items, item_for_deletion + end + + test "linked_accounts_count returns count of accounts with providers" do + assert_equal 0, @item.linked_accounts_count + + account = Account.create!( + family: @family, name: "Linked Fund", balance: 1000, currency: "EUR", + accountable: Investment.new + ) + AccountProvider.create!(account: account, provider: indexa_capital_accounts(:mutual_fund)) + + assert_equal 1, @item.linked_accounts_count + end + + test "unlinked_accounts_count returns count of accounts without providers" do + assert_equal 2, @item.unlinked_accounts_count + end + + test "sync_status_summary with no accounts" do + item = IndexaCapitalItem.create!(family: @family, name: "Empty", api_token: "test") + assert_equal I18n.t("indexa_capital_items.sync_status.no_accounts"), item.sync_status_summary + end + + test "sync_status_summary with all linked" do + # Link both accounts + [ indexa_capital_accounts(:mutual_fund), indexa_capital_accounts(:pension_plan) ].each do |ica| + account = Account.create!( + family: @family, name: ica.name, balance: 1000, currency: "EUR", + accountable: Investment.new + ) + AccountProvider.create!(account: account, provider: ica) + end + + assert_equal I18n.t("indexa_capital_items.sync_status.synced", count: 2), @item.sync_status_summary + end + + test "sync_status_summary with partial setup" do + account = Account.create!( + family: @family, name: "Fund", balance: 1000, currency: "EUR", + accountable: Investment.new + ) + AccountProvider.create!(account: account, provider: indexa_capital_accounts(:mutual_fund)) + + assert_equal I18n.t("indexa_capital_items.sync_status.synced_with_setup", linked: 1, unlinked: 1), @item.sync_status_summary + end +end diff --git a/test/models/provider/indexa_capital_test.rb b/test/models/provider/indexa_capital_test.rb new file mode 100644 index 000000000..02a7da3b6 --- /dev/null +++ b/test/models/provider/indexa_capital_test.rb @@ -0,0 +1,156 @@ +# frozen_string_literal: true + +require "test_helper" + +class Provider::IndexaCapitalTest < ActiveSupport::TestCase + test "initializes with api_token" do + provider = Provider::IndexaCapital.new(api_token: "test_token") + assert_instance_of Provider::IndexaCapital, provider + end + + test "initializes with username/document/password" do + provider = Provider::IndexaCapital.new( + username: "user@example.com", + document: "12345678A", + password: "secret" + ) + assert_instance_of Provider::IndexaCapital, provider + end + + test "raises ConfigurationError without credentials" do + assert_raises Provider::IndexaCapital::ConfigurationError do + Provider::IndexaCapital.new + end + end + + test "raises ConfigurationError with partial credentials" do + assert_raises Provider::IndexaCapital::ConfigurationError do + Provider::IndexaCapital.new(username: "user@example.com") + end + + assert_raises Provider::IndexaCapital::ConfigurationError do + Provider::IndexaCapital.new(username: "user@example.com", document: "12345678A") + end + end + + test "list_accounts calls API and returns accounts" do + provider = Provider::IndexaCapital.new(api_token: "test_token") + + stub_response = OpenStruct.new( + code: 200, + body: { + accounts: [ + { account_number: "ABC12345", type: "mutual", status: "active" }, + { account_number: "DEF67890", type: "pension", status: "active" } + ] + }.to_json + ) + + Provider::IndexaCapital.stubs(:get).returns(stub_response) + + accounts = provider.list_accounts + assert_equal 2, accounts.size + assert_equal "ABC12345", accounts[0][:account_number] + assert_equal "Indexa Capital Mutual Fund (ABC12345)", accounts[0][:name] + assert_equal "EUR", accounts[0][:currency] + assert_equal "DEF67890", accounts[1][:account_number] + assert_equal "Indexa Capital Pension Plan (DEF67890)", accounts[1][:name] + end + + test "get_holdings calls fiscal-results endpoint" do + provider = Provider::IndexaCapital.new(api_token: "test_token") + + stub_response = OpenStruct.new( + code: 200, + body: { + fiscal_results: [ + { amount: 1814.77, titles: 9.14, price: 175.34, instrument: { identifier: "IE00BFPM9P35" } } + ], + total_fiscal_results: [] + }.to_json + ) + + Provider::IndexaCapital.stubs(:get).returns(stub_response) + + data = provider.get_holdings(account_number: "ABC12345") + assert data[:fiscal_results].is_a?(Array) + assert_equal 1, data[:fiscal_results].size + end + + test "get_account_balance extracts total_amount from portfolios" do + provider = Provider::IndexaCapital.new(api_token: "test_token") + + stub_response = OpenStruct.new( + code: 200, + body: { + portfolios: [ + { date: "2026-02-05", total_amount: 38000.0 }, + { date: "2026-02-06", total_amount: 38905.21 } + ] + }.to_json + ) + + Provider::IndexaCapital.stubs(:get).returns(stub_response) + + balance = provider.get_account_balance(account_number: "ABC12345") + assert_equal 38905.21.to_d, balance + end + + test "get_account_balance returns 0 when no portfolios" do + provider = Provider::IndexaCapital.new(api_token: "test_token") + + stub_response = OpenStruct.new( + code: 200, + body: { portfolios: [] }.to_json + ) + + Provider::IndexaCapital.stubs(:get).returns(stub_response) + + balance = provider.get_account_balance(account_number: "ABC12345") + assert_equal 0, balance + end + + test "get_activities returns empty array" do + provider = Provider::IndexaCapital.new(api_token: "test_token") + result = provider.get_activities(account_number: "ABC12345") + assert_equal [], result + end + + test "raises AuthenticationError on 401" do + provider = Provider::IndexaCapital.new(api_token: "bad_token") + + stub_response = OpenStruct.new(code: 401, body: "Unauthorized") + Provider::IndexaCapital.stubs(:get).returns(stub_response) + + assert_raises Provider::IndexaCapital::AuthenticationError do + provider.list_accounts + end + end + + test "rejects invalid account_number with path traversal" do + provider = Provider::IndexaCapital.new(api_token: "test_token") + + assert_raises Provider::IndexaCapital::Error do + provider.get_holdings(account_number: "../admin") + end + end + + test "rejects blank account_number" do + provider = Provider::IndexaCapital.new(api_token: "test_token") + + assert_raises Provider::IndexaCapital::Error do + provider.get_holdings(account_number: "") + end + end + + test "raises Error on server error" do + provider = Provider::IndexaCapital.new(api_token: "test_token") + + stub_response = OpenStruct.new(code: 500, body: "Internal Server Error") + Provider::IndexaCapital.stubs(:get).returns(stub_response) + + assert_raises Provider::IndexaCapital::Error do + provider.list_accounts + end + end +end