Files
sure/app/controllers/indexa_capital_items_controller.rb
GermanDZ c5503320af Fix IndexaCapital sync, account setup, and balance/type bugs (#1562)
* Add missing IndexaCapitalItem::SyncCompleteEvent

Syncable#sync_broadcaster instantiates self.class::SyncCompleteEvent,
which is implemented for every other provider (Plaid, Lunchflow,
Mercury, etc.) but was missing for IndexaCapitalItem. The error was
swallowed by Sync#perform_post_sync's rescue, so syncs appeared to
succeed but post-sync UI broadcasts never fired:

  Error performing post-sync for IndexaCapitalItem (...):
  uninitialized constant IndexaCapitalItem::SyncCompleteEvent

This adds the class, modeled on LunchflowItem::SyncCompleteEvent,
restoring per-account and per-item Turbo broadcasts after Indexa
Capital syncs.

* Fix IndexaCapital account setup never creating accounts

complete_account_setup read params[:accounts], but the form in
setup_accounts.html.erb submits account_ids[] (array) and
sync_start_dates[<id>] (hash). The hash was always empty, so every
submit hit the empty-config branch and bounced back with
"No accounts to set up." — accounts were never created.

The controller also branched on config[:account_type] / config[:subtype]
even though the form has no account-type picker (Indexa Capital is an
investment-only broker). Rewrote complete_account_setup to consume the
form's actual params and infer the accountable type as Investment from
indexa_capital_account.account_type.

* Fix IndexaCapital balance double-count and account type

Two more issues in the IndexaCapital flow that surfaced once accounts
could actually be created (see prior commit):

1. Accountable type was inferred from indexa_capital_account.account_type
   ("mutual" / "pension"), but infer_accountable_type doesn't recognize
   those values and falls through to "Depository". The result: every
   imported Indexa account showed up as a Cash depository account
   instead of an Investment account, hiding holdings/trades surfaces.
   Indexa Capital is investment-only, so hard-code the accountable
   type to Investment.

2. Account::Processor#calculate_total_balance summed every row in
   raw_holdings_payload. Indexa returns a time series — one row per
   security per date — so the naive sum double-counts (observed:
   reported €91,633 became stored balance €180,039). Trust the API's
   current_balance when present, and if we have to fall back to a
   computed total, dedupe by instrument and take the latest-dated
   amount per security.

* Fix IndexaCapital holdings reflecting oldest snapshot per security

HoldingsProcessor#process iterated every row in raw_holdings_payload.
Indexa returns a time series (many rows per security across dates),
and each iteration upserts the same (account, security, today) holding
row, so the LAST row processed wins. The payload is ordered with
newer dates first, so the last row processed is the OLDEST snapshot —
the holdings shown in the UI reflected tiny early positions instead
of the current ones (e.g. 3.8 shares of US 500 stored vs 62.34 actual).

Reduce the payload to one row per security (latest date) before
processing. The cost-basis update is now also driven by the latest
snapshot for the same reason.

* Fix IndexaCapital holdings using per-lot detail instead of totals

Importer#normalize_holdings_response read data[:fiscal_results], which
the Indexa API returns as per-tax-lot detail — many rows per security
covering each subscription_date, plus virtual sell/buy rows generated
by rebalances. Iterating it produced wildly wrong stored holdings:
e.g. 9.61 shares stored for Vanguard US 500 vs 62.34 actual; total
weights summed to ~10% instead of 100%.

The same response also includes data[:total_fiscal_results] — one
aggregated row per security with current titles/amount/cost matching
the Indexa UI and the user-downloadable positions CSV. Prefer it,
falling back to the per-lot field only when the totals are absent.

* Address CodeRabbit review on IndexaCapital fixes

Four review items, all fixed:

* Share instrument-key extraction
  HoldingsProcessor#extract_ticker and Processor#calculate_holdings_value
  used different fallback orders (one looked at :isin, the other at
  :isin_code), so they could disagree on which rows referred to the same
  security. Moved a single extract_instrument_key helper into
  IndexaCapitalAccount::DataHelpers and routed both callers through it.

* Simplify Processor#calculate_holdings_value
  The date-based dedupe was a workaround for the bug already fixed in
  the importer (which now stores total_fiscal_results — one row per
  security). Replaced the date comparison with a per-security map
  populated via the shared key extractor. Same end result, fewer
  moving parts, no fragile string-date comparison.

* Drop dead config key passed to create_account_from_indexa_capital
  create_account_from_indexa_capital only reads :subtype and :balance
  from its config arg. Passing :sync_start_date there was inert.

* Don't mark created accounts as skipped on post-create errors
  In complete_account_setup, ensure_account_provider! and
  update!(sync_start_date:) ran inside the same begin/rescue as the
  Account.create!. If either raised after the Account row was already
  persisted, control jumped to the rescue with created_count not yet
  incremented and the account was wrongly counted as skipped. Now:
  parse the form-supplied sync_start_date up front (a malformed value
  is silently dropped instead of bubbling out of the loop), bump
  created_count immediately after persisted?, and isolate the post-
  create steps in their own rescue so failures there are logged but
  don't desync the success counter.

* Fall back to /portfolio so pension plans get holdings imported

Indexa's /accounts/{id}/fiscal-results endpoint returns
{fiscal_results: [], total_fiscal_results: []} for pension plan
accounts (e.g. type "pension"). The same positions are exposed via
/accounts/{id}/portfolio in instrument_accounts[].positions[] for
both mutual funds and pensions, so use it as a fallback when
fiscal-results is empty.

The portfolio response uses the same field names HoldingsProcessor
already understands (instrument, titles, price, amount, cost_amount)
plus a derived cost_price (cost_amount / titles) added during
adaptation. No HoldingsProcessor changes needed.

Verified against the user-downloadable "Posiciones" CSV for an
SH71ZPMY pension account: two positions (N5138 Acciones, N5137
Bonos) and balance €8,273.56 match exactly.

* Fix CI: update tests for new IndexaCapital flow + rubocop blank line

* Lint: drop trailing blank line before `end` in
  IndexaCapitalAccount::Processor (Layout/EmptyLinesAroundClassBody).

* Controller test: complete_account_setup#creates was posting
  params: { accounts: { id => { account_type:, subtype: } } } against
  the old controller schema. The new endpoint reads
  params[:account_ids] and infers Investment for Indexa Capital, so
  switch the test to that shape (and update the matching skip-already-
  linked / no-selected-accounts cases).

* Processor test: "updates account balance from holdings value" set
  current_balance: 38905.21 alongside holdings summing to 27093.01
  and asserted the latter wins. After the fix
  (calculate_total_balance prefers the API-reported current_balance
  when present), the API value is the right answer. Renamed to
  "trusts API current_balance over holdings sum when present" and
  added a sibling test that nils current_balance to exercise the
  holdings-sum fallback path explicitly (still asserts 27093.01).

* Wrap account creation+linking in a transaction to avoid orphans

complete_account_setup created the Account row first, incremented
created_count, and only then called ensure_account_provider! / the
sync_start_date update inside an inner rescue. If the link or the
sync_start_date update raised after the Account was already persisted,
control fell into the inner rescue: the orphaned Account row stayed
in the database, the failure was silently logged, and the success
counter was inflated.

Wrap creation, ensure_account_provider!, and the optional
sync_start_date update in a single ActiveRecord::Base.transaction.
Increment created_count only after the transaction commits; on any
exception the outer rescue rolls the whole step into skipped_count
with a clear log line tagged with the indexa_capital_account id.
2026-04-27 18:33:22 +02:00

393 lines
14 KiB
Ruby

# 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 ]
before_action :require_admin!, only: [ :new, :create, :preload_accounts, :select_accounts, :link_accounts, :select_existing_account, :link_existing_account, :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_ids = Array(params[:account_ids]).reject(&:blank?)
sync_start_dates = params[:sync_start_dates] || {}
if account_ids.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_ids.each do |indexa_capital_account_id|
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?
# Parse the form-supplied date up front so a malformed value is silently
# dropped rather than aborting the loop body after the account is
# already persisted (which would mark a successfully-created account
# as "skipped").
raw_sync_start_date = sync_start_dates[indexa_capital_account_id]
sync_start_date = (Date.parse(raw_sync_start_date.to_s) rescue nil) if raw_sync_start_date.present?
# Wrap creation, provider link, and sync_start_date persistence in a
# single transaction so a failure in ensure_account_provider! (or the
# sync_start_date update) rolls back the Account row instead of leaving
# an orphan that is also wrongly counted as "created".
ActiveRecord::Base.transaction do
account = create_account_from_indexa_capital(indexa_capital_account, "Investment", {})
indexa_capital_account.ensure_account_provider!(account)
indexa_capital_account.update!(sync_start_date: sync_start_date) if sync_start_date
end
created_count += 1
rescue => e
Rails.logger.error "IndexaCapitalItemsController#complete_account_setup - Error linking #{indexa_capital_account_id}: #{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
)
account.auto_share_with_family! if Current.family.share_all_by_default?
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
account = 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)
)
account.auto_share_with_family! if Current.family.share_all_by_default?
account
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