mirror of
https://github.com/we-promise/sure.git
synced 2026-06-03 09:49:02 +00:00
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:
@@ -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
|
||||
|
||||
380
app/controllers/indexa_capital_items_controller.rb
Normal file
380
app/controllers/indexa_capital_items_controller.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
43
app/jobs/indexa_capital_activities_fetch_job.rb
Normal file
43
app/jobs/indexa_capital_activities_fetch_job.rb
Normal 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
|
||||
55
app/jobs/indexa_capital_connection_cleanup_job.rb
Normal file
55
app/jobs/indexa_capital_connection_cleanup_job.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
class Family < ApplicationRecord
|
||||
include IndexaCapitalConnectable
|
||||
include CoinbaseConnectable, CoinstatsConnectable, SnaptradeConnectable, MercuryConnectable
|
||||
include PlaidConnectable, SimplefinConnectable, LunchflowConnectable, EnableBankingConnectable, Syncable, AutoTransferMatchable, Subscribeable
|
||||
|
||||
|
||||
33
app/models/family/indexa_capital_connectable.rb
Normal file
33
app/models/family/indexa_capital_connectable.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
99
app/models/indexa_capital_account.rb
Normal file
99
app/models/indexa_capital_account.rb
Normal 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
|
||||
229
app/models/indexa_capital_account/activities_processor.rb
Normal file
229
app/models/indexa_capital_account/activities_processor.rb
Normal 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
|
||||
156
app/models/indexa_capital_account/data_helpers.rb
Normal file
156
app/models/indexa_capital_account/data_helpers.rb
Normal 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
|
||||
130
app/models/indexa_capital_account/holdings_processor.rb
Normal file
130
app/models/indexa_capital_account/holdings_processor.rb
Normal 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
|
||||
116
app/models/indexa_capital_account/processor.rb
Normal file
116
app/models/indexa_capital_account/processor.rb
Normal 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
|
||||
180
app/models/indexa_capital_item.rb
Normal file
180
app/models/indexa_capital_item.rb
Normal 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
|
||||
157
app/models/indexa_capital_item/importer.rb
Normal file
157
app/models/indexa_capital_item/importer.rb
Normal 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
|
||||
37
app/models/indexa_capital_item/provided.rb
Normal file
37
app/models/indexa_capital_item/provided.rb
Normal 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
|
||||
86
app/models/indexa_capital_item/syncer.rb
Normal file
86
app/models/indexa_capital_item/syncer.rb
Normal 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
|
||||
49
app/models/indexa_capital_item/unlinking.rb
Normal file
49
app/models/indexa_capital_item/unlinking.rb
Normal 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
|
||||
237
app/models/provider/indexa_capital.rb
Normal file
237
app/models/provider/indexa_capital.rb
Normal 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
|
||||
100
app/models/provider/indexa_capital_adapter.rb
Normal file
100
app/models/provider/indexa_capital_adapter.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
146
app/views/indexa_capital_items/_indexa_capital_item.html.erb
Normal file
146
app/views/indexa_capital_items/_indexa_capital_item.html.erb
Normal 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 %>
|
||||
80
app/views/indexa_capital_items/select_accounts.html.erb
Normal file
80
app/views/indexa_capital_items/select_accounts.html.erb
Normal 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 %> ·
|
||||
<% 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 %>
|
||||
@@ -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 %> ·
|
||||
<% 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 %>
|
||||
118
app/views/indexa_capital_items/setup_accounts.html.erb
Normal file
118
app/views/indexa_capital_items/setup_accounts.html.erb
Normal 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 %> ·
|
||||
<% 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 %>
|
||||
77
app/views/settings/providers/_indexa_capital_panel.html.erb
Normal file
77
app/views/settings/providers/_indexa_capital_panel.html.erb
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
254
config/locales/views/indexa_capital_items/en.yml
Normal file
254
config/locales/views/indexa_capital_items/en.yml
Normal 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."
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
55
db/schema.rb
generated
@@ -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"
|
||||
|
||||
152
test/controllers/indexa_capital_items_controller_test.rb
Normal file
152
test/controllers/indexa_capital_items_controller_test.rb
Normal 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
|
||||
29
test/fixtures/indexa_capital_accounts.yml
vendored
Normal file
29
test/fixtures/indexa_capital_accounts.yml
vendored
Normal 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
17
test/fixtures/indexa_capital_items.yml
vendored
Normal 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
|
||||
180
test/models/indexa_capital_account/data_helpers_test.rb
Normal file
180
test/models/indexa_capital_account/data_helpers_test.rb
Normal 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
|
||||
111
test/models/indexa_capital_account/processor_test.rb
Normal file
111
test/models/indexa_capital_account/processor_test.rb
Normal 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
|
||||
126
test/models/indexa_capital_account_test.rb
Normal file
126
test/models/indexa_capital_account_test.rb
Normal 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
|
||||
143
test/models/indexa_capital_item_test.rb
Normal file
143
test/models/indexa_capital_item_test.rb
Normal 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
|
||||
156
test/models/provider/indexa_capital_test.rb
Normal file
156
test/models/provider/indexa_capital_test.rb
Normal 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
|
||||
Reference in New Issue
Block a user