Implement Indexa Capital provider with real API integration (#933)

* Add Indexa Capital provider scaffold

Generate Indexa Capital provider scaffolding and align credential fields with the API authentication requirements.

* Fix PR 926 lint and schema CI failures

* Implement Indexa Capital provider with real API integration

- Rewrite all broken view templates (were meta-ERB from code generator)
- Create missing select_accounts.html.erb template
- Implement real API calls: list_accounts via /users/me, get_holdings
  via /accounts/{number}/fiscal-results, get_account_balance via
  /accounts/{number}/performance
- Add API token auth support (stored token > env token > credentials)
- Add api_token column with encryption support
- Redesign settings panel: API token prominent, credentials collapsible
- Fix account balances display using performance endpoint portfolios
- Fix accounts index empty-state guard missing indexa_capital_items
- Simplify activities fetch job (no activities API endpoint exists)
- Fix i18n interpolation (%%{ -> %{) throughout locale file

* Add tests for Indexa Capital provider integration

- IndexaCapitalItemTest: validations, credentials, scopes, sync status
- IndexaCapitalAccountTest: upsert, holdings, account provider linking
- Provider::IndexaCapitalTest: auth modes, API stubs, error handling
- IndexaCapitalItemsControllerTest: CRUD, setup, linking, authorization
- Fixtures for items (token + credentials) and accounts (mutual + pension)

52 tests, 98 assertions, 0 failures

* Address code review feedback from PR #933

- Fix zero balance bug: use `nil?` instead of `present?` so 0 is stored
- Fix has_indexa_capital_credentials? to check api_token (was ignored)
- Fix build_provider to delegate to Provided concern (was ignoring token)
- Fix IndexaCapital section outside encryption_error guard in settings
- Add account_number sanitization to prevent path traversal in API URLs
- Replace all skipped processor tests with real working tests
- Add zero-balance and path-traversal test coverage

61 tests, 107 assertions, 0 failures

* Address code review round 2: credentials validation, RuboCop, test quality

- Fix RuboCop SpaceInsideArrayLiteralBrackets in credentials check
- Chain where.not calls so all three username/document/password must be present
- Require all three credentials (||) instead of any one (&&) in validate_configuration!
- Move attr_reader to private to avoid exposing credentials publicly
- Parse dates with Date.parse in extract_balance for robustness
- Remove stale TODO and Crypto from supported_account_types
- Order build_provider query deterministically by created_at
- Replace no-op holdings assertion with meaningful assert_difference

* Address code review round 3: JSON parse safety and test precision

- Rescue JSON::ParserError on 2xx responses for clearer error messages
- Fix weak balance assertion: set balance to 0 before processing, assert
  expected value (27093.01 = sum of holdings amounts)

* Include Indexa Capital in automatic family sync

Add indexa_capital_items to Family::Syncer#child_syncables so balances
and holdings refresh on daily auto-sync and login sync, not only on
manual sync button clicks.

---------

Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Juan José Mata <jjmata@jjmata.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
This commit is contained in:
David Gil
2026-02-08 18:19:37 +01:00
committed by GitHub
parent d88c2151cb
commit ba442d5f26
42 changed files with 3920 additions and 6 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -1,4 +1,5 @@
class Family < ApplicationRecord
include IndexaCapitalConnectable
include CoinbaseConnectable, CoinstatsConnectable, SnaptradeConnectable, MercuryConnectable
include PlaidConnectable, SimplefinConnectable, LunchflowConnectable, EnableBankingConnectable, Syncable, AutoTransferMatchable, Subscribeable

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -21,7 +21,7 @@
</div>
</header>
<% 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 %>
<div class="space-y-2">
@@ -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? %>
<div id="manual-accounts">
<%= render "accounts/index/manual_accounts", accounts: @manual_accounts %>

View File

@@ -0,0 +1,146 @@
<%# locals: (indexa_capital_item:) %>
<%= tag.div id: dom_id(indexa_capital_item) do %>
<details open class="group bg-container p-4 shadow-border-xs rounded-xl">
<summary class="flex items-center justify-between gap-2 focus-visible:outline-hidden">
<div class="flex items-center gap-2">
<%= icon "chevron-right", class: "group-open:transform group-open:rotate-90" %>
<div class="flex items-center justify-center h-8 w-8 bg-primary/10 rounded-full">
<div class="flex items-center justify-center">
<%= tag.p indexa_capital_item.name.first.upcase, class: "text-primary text-xs font-medium" %>
</div>
</div>
<% unlinked_count = indexa_capital_item.unlinked_accounts_count %>
<div class="pl-1 text-sm">
<div class="flex items-center gap-2">
<%= tag.p indexa_capital_item.name, class: "font-medium text-primary" %>
<% if indexa_capital_item.scheduled_for_deletion? %>
<p class="text-destructive text-sm animate-pulse"><%= t(".deletion_in_progress") %></p>
<% end %>
</div>
<p class="text-xs text-secondary"><%= t(".provider_name") %></p>
<% if indexa_capital_item.syncing? %>
<div class="text-secondary flex items-center gap-1">
<%= icon "loader", size: "sm", class: "animate-spin" %>
<%= tag.span t(".syncing") %>
</div>
<% elsif indexa_capital_item.requires_update? %>
<div class="text-warning flex items-center gap-1">
<%= icon "alert-triangle", size: "sm", color: "warning" %>
<%= tag.span t(".requires_update") %>
</div>
<% elsif indexa_capital_item.sync_error.present? %>
<div class="text-secondary flex items-center gap-1">
<%= render DS::Tooltip.new(text: indexa_capital_item.sync_error, icon: "alert-circle", size: "sm", color: "destructive") %>
<%= tag.span t(".error"), class: "text-destructive" %>
</div>
<% else %>
<p class="text-secondary">
<% 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 %>
</p>
<% end %>
</div>
</div>
<div class="flex items-center gap-2">
<% 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 %>
</div>
</summary>
<% unless indexa_capital_item.scheduled_for_deletion? %>
<div class="space-y-4 mt-4">
<% 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 %>
<div class="p-4 flex flex-col gap-3 items-center justify-center">
<p class="text-primary font-medium text-sm"><%= t(".setup_needed") %></p>
<p class="text-secondary text-sm"><%= t(".setup_description", linked: indexa_capital_item.linked_accounts_count, total: indexa_capital_item.total_accounts_count) %></p>
<%= render DS::Link.new(
text: t(".setup_action"),
icon: "settings",
variant: "primary",
href: setup_accounts_indexa_capital_item_path(indexa_capital_item),
frame: :modal
) %>
</div>
<% elsif unlinked_count > 0 %>
<%# Some accounts imported, more available - show subtle link %>
<div class="pt-2 border-t border-primary">
<%= 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" %>
<span><%= t(".more_accounts_available", count: unlinked_count) %></span>
<% end %>
</div>
<% elsif indexa_capital_item.accounts.empty? && indexa_capital_item.indexa_capital_accounts.none? %>
<%# No provider accounts at all - waiting for sync %>
<div class="p-4 flex flex-col gap-3 items-center justify-center">
<p class="text-primary font-medium text-sm"><%= t(".no_accounts_title") %></p>
<p class="text-secondary text-sm"><%= t(".no_accounts_description") %></p>
</div>
<% end %>
</div>
<% end %>
</details>
<% end %>

View File

@@ -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 %>
<div class="flex items-center gap-2">
<%= icon "trending-up", class: "text-primary" %>
<span class="text-primary"><%= t("indexa_capital_items.select_accounts.description", product_name: "Maybe") %></span>
</div>
<% end %>
<% dialog.with_body do %>
<% if @indexa_capital_accounts.blank? %>
<div class="text-center py-8">
<%= icon "alert-circle", class: "text-warning mx-auto mb-4", size: "lg" %>
<p class="text-secondary"><%= t("indexa_capital_items.select_accounts.no_accounts_found") %></p>
</div>
<div class="flex gap-3 justify-center">
<%= render DS::Link.new(
text: t("indexa_capital_items.select_accounts.cancel"),
variant: "secondary",
href: accounts_path,
frame: "_top"
) %>
</div>
<% 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 %>
<div class="space-y-4">
<% @indexa_capital_accounts.each do |indexa_capital_account| %>
<div class="border border-primary rounded-lg p-4 hover:bg-surface transition-colors">
<div class="flex items-center gap-3">
<input type="checkbox"
id="account_<%= indexa_capital_account.id %>"
name="selected_account_ids[]"
value="<%= indexa_capital_account.id %>"
checked
class="cursor-pointer">
<label for="account_<%= indexa_capital_account.id %>" class="flex-1 cursor-pointer">
<h4 class="font-medium text-primary"><%= indexa_capital_account.name.presence || t("indexa_capital_items.select_accounts.no_name_placeholder") %></h4>
<p class="text-sm text-secondary">
<% if indexa_capital_account.account_type.present? %>
<%= indexa_capital_account.account_type.titleize %> &middot;
<% end %>
<%= number_to_currency(indexa_capital_account.current_balance || 0, unit: Money::Currency.new(indexa_capital_account.currency || "EUR").symbol) %>
</p>
</label>
</div>
</div>
<% end %>
</div>
<div class="flex gap-3 mt-6">
<%= 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"
) %>
</div>
<% end %>
<% end %>
<% end %>
<% end %>

View File

@@ -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 %>
<div class="flex items-center gap-2">
<%= icon "link", class: "text-primary" %>
<span class="text-primary"><%= t("indexa_capital_items.select_existing_account.subtitle", account_name: @account.name) %></span>
</div>
<% end %>
<% dialog.with_body do %>
<% if @indexa_capital_accounts.blank? %>
<div class="text-center py-8">
<%= icon "alert-circle", class: "text-warning mx-auto mb-4", size: "lg" %>
<p class="text-secondary"><%= t("indexa_capital_items.select_existing_account.no_accounts") %></p>
<p class="text-sm text-secondary mt-2"><%= t("indexa_capital_items.select_existing_account.connect_hint") %></p>
<%= link_to t("indexa_capital_items.select_existing_account.settings_link"), settings_providers_path, class: "btn btn--primary btn--sm mt-4" %>
</div>
<% else %>
<div class="space-y-4">
<div class="bg-surface border border-primary p-4 rounded-lg mb-4">
<p class="text-sm text-primary">
<strong><%= t("indexa_capital_items.select_existing_account.linking_to") %></strong>
<%= @account.name %>
</p>
</div>
<% @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 %>
<div class="flex items-center justify-between">
<div>
<h4 class="font-medium text-primary"><%= indexa_capital_account.name %></h4>
<p class="text-sm text-secondary">
<% if indexa_capital_account.account_type.present? %>
<%= indexa_capital_account.account_type.titleize %> &middot;
<% 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) %>
</p>
</div>
<%= render DS::Button.new(
text: t("indexa_capital_items.select_existing_account.link_button"),
variant: "primary",
size: "sm",
type: "submit"
) %>
</div>
<% end %>
<% end %>
</div>
<div class="mt-6">
<%= render DS::Link.new(
text: t("indexa_capital_items.select_existing_account.cancel_button"),
variant: "secondary",
href: account_path(@account)
) %>
</div>
<% end %>
<% end %>
<% end %>

View File

@@ -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 %>
<div class="flex items-center gap-2">
<%= icon "trending-up", class: "text-primary" %>
<span class="text-primary"><%= t("indexa_capital_items.setup_accounts.subtitle") %></span>
</div>
<% end %>
<% dialog.with_body do %>
<div class="space-y-6">
<div class="bg-surface border border-primary p-4 rounded-lg">
<div class="flex items-start gap-3">
<%= icon "info", size: "sm", class: "text-primary mt-0.5 flex-shrink-0" %>
<div>
<p class="text-sm text-primary">
<%= t("indexa_capital_items.setup_accounts.instructions") %>
</p>
</div>
</div>
</div>
<%= 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? %>
<div class="space-y-4">
<h3 class="font-medium text-primary"><%= t("indexa_capital_items.setup_accounts.accounts_count", count: @unlinked_accounts.count) %></h3>
<% @unlinked_accounts.each do |indexa_capital_account| %>
<div class="border border-primary rounded-lg p-4 hover:bg-surface transition-colors">
<div class="flex items-center gap-3">
<input type="checkbox"
id="account_<%= indexa_capital_account.id %>"
name="account_ids[]"
value="<%= indexa_capital_account.id %>"
checked
class="cursor-pointer">
<label for="account_<%= indexa_capital_account.id %>" class="flex-1 cursor-pointer">
<h4 class="font-medium text-primary"><%= indexa_capital_account.name %></h4>
<p class="text-sm text-secondary">
<% if indexa_capital_account.account_type.present? %>
<%= indexa_capital_account.account_type.titleize %> &middot;
<% end %>
<%= t("indexa_capital_items.setup_accounts.balance") %>:
<%= number_to_currency(indexa_capital_account.current_balance || 0, unit: Money::Currency.new(indexa_capital_account.currency || "EUR").symbol) %>
</p>
<% if indexa_capital_account.account_number.present? %>
<p class="text-xs text-secondary"><%= indexa_capital_account.account_number %></p>
<% end %>
</label>
</div>
<div class="mt-3 pl-7" onclick="event.stopPropagation();">
<label for="sync_start_<%= indexa_capital_account.id %>" class="block text-xs text-secondary mb-1">
<%= t("indexa_capital_items.setup_accounts.sync_start_date_label") %>
</label>
<input type="date"
id="sync_start_<%= indexa_capital_account.id %>"
name="sync_start_dates[<%= indexa_capital_account.id %>]"
value="<%= indexa_capital_account.sync_start_date %>"
onclick="event.stopPropagation();"
autocomplete="off"
class="bg-container border border-primary rounded px-2 py-1 text-sm text-primary">
<p class="text-xs text-secondary mt-1">
<%= t("indexa_capital_items.setup_accounts.sync_start_date_help") %>
</p>
</div>
</div>
<% end %>
</div>
<div class="flex gap-3 mt-6">
<%= 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"
) %>
</div>
<% else %>
<div class="flex flex-col items-center justify-center py-6 space-y-3">
<%= icon "alert-circle", size: "lg", class: "text-warning" %>
<p class="text-primary text-center font-medium">
<%= t("indexa_capital_items.setup_accounts.no_accounts_to_setup") %>
</p>
<p class="text-secondary text-center text-sm">
<%= t("indexa_capital_items.setup_accounts.no_accounts") %>
</p>
</div>
<div class="flex gap-3 justify-center">
<%= render DS::Link.new(
text: t("indexa_capital_items.setup_accounts.cancel"),
variant: "secondary",
href: accounts_path,
frame: "_top"
) %>
</div>
<% end %>
<% end %>
</div>
<% end %>
<% end %>

View File

@@ -0,0 +1,77 @@
<div class="space-y-4">
<div class="prose prose-sm text-secondary">
<p class="text-primary font-medium"><%= t("indexa_capital_items.panel.setup_instructions") %></p>
<ol>
<li><%= t("indexa_capital_items.panel.step_1") %></li>
<li><%= t("indexa_capital_items.panel.step_2") %></li>
<li><%= t("indexa_capital_items.panel.step_3") %></li>
</ol>
</div>
<% error_msg = local_assigns[:error_message] || @error_message %>
<% if error_msg.present? %>
<div class="p-2 rounded-md bg-destructive/10 text-destructive text-sm overflow-hidden">
<p class="line-clamp-3" title="<%= error_msg %>"><%= error_msg %></p>
</div>
<% 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| %>
<div class="bg-surface border border-primary p-3 rounded-lg">
<p class="text-sm font-medium text-primary mb-2"><%= t("indexa_capital_items.panel.fields.api_token.label") %></p>
<p class="text-xs text-secondary mb-2"><%= t("indexa_capital_items.panel.fields.api_token.description") %></p>
<%= 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 %>
</div>
<details class="group">
<summary class="text-sm text-secondary cursor-pointer hover:text-primary transition-colors">
<%= t("indexa_capital_items.panel.alternative_auth") %>
</summary>
<div class="mt-3 space-y-3 pt-3 border-t border-primary">
<%= 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 %>
</div>
</details>
<div class="flex justify-end">
<%= 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" %>
</div>
<% 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, ""])) %>
<div class="flex items-center gap-2">
<% if items&.any? %>
<div class="w-2 h-2 bg-success rounded-full"></div>
<p class="text-sm text-secondary"><%= t("indexa_capital_items.panel.status_configured_html", accounts_path: accounts_path).html_safe %></p>
<% else %>
<div class="w-2 h-2 bg-gray-400 rounded-full"></div>
<p class="text-sm text-secondary"><%= t("indexa_capital_items.panel.status_not_configured") %></p>
<% end %>
</div>
</div>

View File

@@ -72,5 +72,11 @@
<%= render "settings/providers/snaptrade_panel" %>
</turbo-frame>
<% end %>
<%= settings_section title: "Indexa Capital", collapsible: true, open: false do %>
<turbo-frame id="indexa_capital-providers-panel">
<%= render "settings/providers/indexa_capital_panel" %>
</turbo-frame>
<% end %>
<% end %>
</div>

View File

@@ -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 <a href=\"%{accounts_path}\" class=\"link\">Accounts</a> 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."

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,5 @@
class AddApiTokenToIndexaCapitalItems < ActiveRecord::Migration[7.2]
def change
add_column :indexa_capital_items, :api_token, :text
end
end

55
db/schema.rb generated
View File

@@ -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"

View File

@@ -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

View File

@@ -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: []

17
test/fixtures/indexa_capital_items.yml vendored Normal file
View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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