diff --git a/app/components/UI/account/activity_date.html.erb b/app/components/UI/account/activity_date.html.erb index a1a330588..f563801ab 100644 --- a/app/components/UI/account/activity_date.html.erb +++ b/app/components/UI/account/activity_date.html.erb @@ -20,7 +20,7 @@
- <%= end_balance_money.format %> + <%= end_balance_money.format %> <%= render DS::Tooltip.new(text: "The end of day balance, after all transactions and adjustments", placement: "left", size: "sm") %>
<%= helpers.icon "chevron-down", class: "group-open:rotate-180" %> diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index efe9bec08..4b4f34a31 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -20,6 +20,7 @@ def index @mercury_items = visible_provider_items(family.mercury_items.ordered.includes(:syncs, :mercury_accounts)) @coinbase_items = visible_provider_items(family.coinbase_items.ordered.includes(:coinbase_accounts, :accounts, :syncs)) @snaptrade_items = visible_provider_items(family.snaptrade_items.ordered.includes(:syncs, :snaptrade_accounts)) + @ibkr_items = visible_provider_items(family.ibkr_items.ordered.includes(:syncs, :ibkr_accounts)) @indexa_capital_items = visible_provider_items(family.indexa_capital_items.ordered.includes(:syncs, :indexa_capital_accounts)) @sophtron_items = visible_provider_items(family.sophtron_items.ordered.includes(:syncs, :sophtron_accounts)) @@ -47,13 +48,14 @@ def show @chart_view = params[:chart_view] || "balance" @tab = params[:tab] @q = params.fetch(:q, {}).permit(:search, status: []) - entries = @account.entries.where(excluded: false).search(@q).reverse_chronological + entries = @account.entries.where(excluded: false).search(@q).reverse_chronological.includes(:entryable) @pagy, @entries = pagy( entries, limit: safe_per_page, params: request.query_parameters.except("tab").merge("tab" => "activity") ) + Transaction::ActivitySecurityPreloader.new(@entries).preload @activity_feed_data = Account::ActivityFeedData.new(@account, @entries) end diff --git a/app/controllers/ibkr_items_controller.rb b/app/controllers/ibkr_items_controller.rb new file mode 100644 index 000000000..34936995b --- /dev/null +++ b/app/controllers/ibkr_items_controller.rb @@ -0,0 +1,236 @@ +class IbkrItemsController < ApplicationController + before_action :set_ibkr_item, only: [ :update, :destroy, :sync, :setup_accounts, :complete_account_setup ] + before_action :require_admin!, only: [ :create, :select_accounts, :select_existing_account, :link_existing_account, :update, :destroy, :sync, :setup_accounts, :complete_account_setup ] + + def create + @ibkr_item = Current.family.ibkr_items.build(ibkr_item_params) + @ibkr_item.name ||= t("ibkr_items.defaults.name") + + if @ibkr_item.save + @ibkr_item.sync_later + + if turbo_frame_request? + flash.now[:notice] = t(".success") + render turbo_stream: [ + turbo_stream.replace( + "ibkr-providers-panel", + partial: "settings/providers/ibkr_panel" + ), + *flash_notification_stream_items + ] + else + redirect_to accounts_path, notice: t(".success"), status: :see_other + end + else + @error_message = @ibkr_item.errors.full_messages.join(", ") + + if turbo_frame_request? + render turbo_stream: turbo_stream.replace( + "ibkr-providers-panel", + partial: "settings/providers/ibkr_panel", + locals: { error_message: @error_message } + ), status: :unprocessable_entity + else + redirect_to settings_providers_path, alert: @error_message, status: :see_other + end + end + end + + def update + attrs = ibkr_item_params.to_h + attrs["query_id"] = @ibkr_item.query_id if attrs["query_id"].blank? + attrs["token"] = @ibkr_item.token if attrs["token"].blank? + + if @ibkr_item.update(attrs.merge(status: :good)) + @ibkr_item.sync_later unless @ibkr_item.syncing? + + if turbo_frame_request? + flash.now[:notice] = t(".success") + render turbo_stream: [ + turbo_stream.replace( + "ibkr-providers-panel", + partial: "settings/providers/ibkr_panel" + ), + *flash_notification_stream_items + ] + else + redirect_to accounts_path, notice: t(".success"), status: :see_other + end + else + @error_message = @ibkr_item.errors.full_messages.join(", ") + + if turbo_frame_request? + render turbo_stream: turbo_stream.replace( + "ibkr-providers-panel", + partial: "settings/providers/ibkr_panel", + locals: { error_message: @error_message } + ), status: :unprocessable_entity + else + redirect_to settings_providers_path, alert: @error_message, status: :see_other + end + end + end + + def destroy + begin + @ibkr_item.unlink_all!(dry_run: false) + rescue => e + Rails.logger.warn("IBKR unlink during destroy failed: #{e.class} - #{e.message}") + end + + @ibkr_item.destroy_later + redirect_to settings_providers_path, notice: t(".success"), status: :see_other + end + + def sync + @ibkr_item.sync_later unless @ibkr_item.syncing? + + respond_to do |format| + format.html { redirect_back_or_to accounts_path } + format.json { head :ok } + end + end + + def select_accounts + ibkr_item = current_ibkr_item + unless ibkr_item + redirect_to settings_providers_path, alert: t(".not_configured") + return + end + + redirect_to setup_accounts_ibkr_item_path(ibkr_item) + end + + def select_existing_account + @account = Current.family.accounts.find(params[:account_id]) + @available_ibkr_accounts = Current.family.ibkr_items + .includes(ibkr_accounts: { account_provider: :account }) + .flat_map(&:ibkr_accounts) + .select { |ibkr_account| ibkr_account.account_provider.nil? } + .sort_by { |ibkr_account| ibkr_account.updated_at || ibkr_account.created_at } + .reverse + + render :select_existing_account, layout: false + end + + def link_existing_account + account = Current.family.accounts.find_by(id: params[:account_id]) + ibkr_account = Current.family.ibkr_items + .joins(:ibkr_accounts) + .where(ibkr_accounts: { id: params[:ibkr_account_id] }) + .first + &.ibkr_accounts + &.find_by(id: params[:ibkr_account_id]) + + if account.blank? || ibkr_account.blank? + redirect_to settings_providers_path, alert: t(".not_found") + return + end + + if account.accountable_type != "Investment" || account.account_providers.any? || account.plaid_account_id.present? || account.simplefin_account_id.present? + redirect_to account_path(account), alert: t(".only_manual_investment") + return + end + + provider = nil + + ibkr_account.with_lock do + if ibkr_account.current_account.present? + redirect_to account_path(account), alert: t(".already_linked") + return + end + + provider = ibkr_account.ensure_account_provider!(account) + end + + raise "Failed to create AccountProvider link" unless provider + + begin + IbkrAccount::Processor.new(ibkr_account.reload).process + rescue => e + Rails.logger.error("Failed to process linked IBKR account #{ibkr_account.id}: #{e.class} - #{e.message}") + end + + ibkr_account.ibkr_item.sync_later unless ibkr_account.ibkr_item.syncing? + redirect_to account_path(account), notice: t(".success"), status: :see_other + rescue => e + Rails.logger.error("Failed to link existing IBKR account: #{e.class} - #{e.message}") + redirect_to settings_providers_path, alert: t(".failed"), status: :see_other + end + + def setup_accounts + @ibkr_accounts = @ibkr_item.ibkr_accounts.includes(account_provider: :account) + @linked_accounts = @ibkr_accounts.select { |ibkr_account| ibkr_account.current_account.present? } + @unlinked_accounts = @ibkr_accounts.reject { |ibkr_account| ibkr_account.current_account.present? } + + no_accounts = @linked_accounts.blank? && @unlinked_accounts.blank? + latest_sync = @ibkr_item.syncs.ordered.first + should_sync = latest_sync.nil? || !latest_sync.completed? + + if no_accounts && !@ibkr_item.syncing? && should_sync + @ibkr_item.sync_later + end + + @linkable_accounts = Current.family.accounts + .visible + .where(accountable_type: "Investment") + .left_joins(:account_providers) + .where(account_providers: { id: nil }) + .order(:name) + + @syncing = @ibkr_item.syncing? + @waiting_for_sync = no_accounts && @syncing + @no_accounts_found = no_accounts && !@syncing && @ibkr_item.last_synced_at.present? + end + + def complete_account_setup + selected_accounts = Array(params[:account_ids]).reject(&:blank?) + created_accounts = [] + + selected_accounts.each do |ibkr_account_id| + ibkr_account = @ibkr_item.ibkr_accounts.find_by(id: ibkr_account_id) + next unless ibkr_account + + ibkr_account.with_lock do + next if ibkr_account.current_account.present? + + account = Account.create_from_ibkr_account(ibkr_account) + ibkr_account.ensure_account_provider!(account) + created_accounts << account + end + + begin + IbkrAccount::Processor.new(ibkr_account.reload).process + rescue => e + Rails.logger.error("Failed to process IBKR account #{ibkr_account.id} after setup: #{e.class} - #{e.message}") + end + end + + @ibkr_item.update!(pending_account_setup: @ibkr_item.unlinked_accounts_count.positive?) + @ibkr_item.sync_later if created_accounts.any? + + if created_accounts.any? + redirect_to accounts_path, notice: t(".success", count: created_accounts.count), status: :see_other + elsif selected_accounts.empty? + redirect_to setup_accounts_ibkr_item_path(@ibkr_item), alert: t(".none_selected"), status: :see_other + else + redirect_to setup_accounts_ibkr_item_path(@ibkr_item), alert: t(".none_created"), status: :see_other + end + end + + private + + def set_ibkr_item + @ibkr_item = Current.family.ibkr_items.find(params[:id]) + end + + def current_ibkr_item + active_items = Current.family.ibkr_items.active + + active_items.syncable.ordered.first || active_items.ordered.first + end + + def ibkr_item_params + params.require(:ibkr_item).permit(:name, :query_id, :token) + end +end diff --git a/app/controllers/settings/providers_controller.rb b/app/controllers/settings/providers_controller.rb index a3feda136..b8852065f 100644 --- a/app/controllers/settings/providers_controller.rb +++ b/app/controllers/settings/providers_controller.rb @@ -191,6 +191,7 @@ def reload_provider_configs(updated_fields) { key: "binance", title: "Binance", turbo_id: "binance", partial: "binance_panel" }, { key: "kraken", title: "Kraken", turbo_id: "kraken", partial: "kraken_panel" }, { key: "snaptrade", title: "SnapTrade", turbo_id: "snaptrade", partial: "snaptrade_panel", auto_open: "manage" }, + { key: "ibkr", title: "Interactive Brokers", turbo_id: "ibkr", partial: "ibkr_panel" }, { key: "indexa_capital", title: "Indexa Capital", turbo_id: "indexa_capital", partial: "indexa_capital_panel" }, { key: "sophtron", title: "Sophtron", turbo_id: "sophtron", partial: "sophtron_panel" } ].freeze @@ -208,6 +209,7 @@ def reload_provider_configs(updated_fields) "binance" => "BinanceItem", "kraken" => "KrakenItem", "snaptrade" => "SnaptradeItem", + "ibkr" => "IbkrItem", "indexa_capital" => "IndexaCapitalItem", "sophtron" => "SophtronItem" }.freeze @@ -232,6 +234,8 @@ def load_provider_items(provider_key) @kraken_items = Current.family.kraken_items.active.ordered when "snaptrade" @snaptrade_items = Current.family.snaptrade_items.includes(:snaptrade_accounts).ordered + when "ibkr" + @ibkr_items = Current.family.ibkr_items.ordered when "indexa_capital" @indexa_capital_items = Current.family.indexa_capital_items.ordered when "sophtron" @@ -257,6 +261,7 @@ def prepare_show_context @mercury_items = Current.family.mercury_items.active.ordered @coinbase_items = Current.family.coinbase_items.ordered # Coinbase panel needs name and sync info for status display @snaptrade_items = Current.family.snaptrade_items.ordered + @ibkr_items = Current.family.ibkr_items.ordered.select(:id) @indexa_capital_items = Current.family.indexa_capital_items.ordered.select(:id) @binance_items = Current.family.binance_items.active.ordered @kraken_items = Current.family.kraken_items.active.ordered @@ -286,6 +291,7 @@ def family_panel_items "binance" => @binance_items, "kraken" => @kraken_items, "snaptrade" => @snaptrade_items, + "ibkr" => @ibkr_items, "indexa_capital" => @indexa_capital_items, "sophtron" => @sophtron_items } diff --git a/app/controllers/transactions_controller.rb b/app/controllers/transactions_controller.rb index d88ee1bcf..8ba56d8e1 100644 --- a/app/controllers/transactions_controller.rb +++ b/app/controllers/transactions_controller.rb @@ -27,6 +27,7 @@ def index ) @pagy, @transactions = pagy(base_scope, limit: safe_per_page) + Transaction::ActivitySecurityPreloader.new(@transactions).preload # Preload split parent data entry_ids = @transactions.map { |t| t.entry.id } diff --git a/app/helpers/settings_helper.rb b/app/helpers/settings_helper.rb index 84aba4b58..b4e927292 100644 --- a/app/helpers/settings_helper.rb +++ b/app/helpers/settings_helper.rb @@ -102,6 +102,9 @@ def provider_summary(provider_key) return { status: :warn, meta: t("settings.providers.meta.registration_needed") } end sync_based_summary(key) + when "ibkr" + return { status: :off } unless @ibkr_items&.any? + sync_based_summary(key) when "indexa_capital" return { status: :off } unless @indexa_capital_items&.any? sync_based_summary(key) diff --git a/app/javascript/controllers/time_series_chart_controller.js b/app/javascript/controllers/time_series_chart_controller.js index 98baffc17..d4f4988af 100644 --- a/app/javascript/controllers/time_series_chart_controller.js +++ b/app/javascript/controllers/time_series_chart_controller.js @@ -289,7 +289,7 @@ export default class extends Controller { .append("div") .attr( "class", - "bg-container text-sm font-sans absolute p-2 border border-secondary rounded-lg pointer-events-none opacity-0 top-0", + "bg-container text-sm font-sans absolute p-2 border border-secondary rounded-lg pointer-events-none opacity-0 top-0 privacy-sensitive", ); } diff --git a/app/models/account.rb b/app/models/account.rb index c5363ced4..13c06c58e 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -274,6 +274,29 @@ def create_from_binance_account(binance_account) create_and_sync(attributes, skip_initial_sync: true) end + def create_from_ibkr_account(ibkr_account) + family = ibkr_account.ibkr_item.family + default_name = if ibkr_account.ibkr_account_id.present? + "Interactive Brokers (#{ibkr_account.ibkr_account_id})" + else + "Interactive Brokers" + end + + attributes = { + family: family, + name: default_name, + balance: 0, + cash_balance: 0, + currency: ibkr_account.currency.presence || family.currency, + accountable_type: "Investment", + accountable_attributes: { + subtype: "brokerage" + } + } + + create_and_sync(attributes, skip_initial_sync: true) + end + def create_from_kraken_account(kraken_account) family = kraken_account.kraken_item.family @@ -293,7 +316,6 @@ def create_from_kraken_account(kraken_account) create_and_sync(attributes, skip_initial_sync: true) end - private def build_simplefin_accountable_attributes(simplefin_account, account_type, subtype) @@ -363,15 +385,23 @@ def destroy end def current_holdings - holdings - .where(currency: currency) - .where.not(qty: 0) - .where( - id: holdings.select("DISTINCT ON (security_id) id") - .where(currency: currency) - .order(:security_id, date: :desc) - ) - .order(amount: :desc) + if (provider_snapshot_date = latest_provider_holdings_snapshot_date) + holdings + .where.not(account_provider_id: nil) + .where(date: provider_snapshot_date) + .where.not(qty: 0) + .order(amount: :desc) + else + holdings + .where(currency: currency) + .where.not(qty: 0) + .where( + id: holdings.select("DISTINCT ON (security_id) id") + .where(currency: currency) + .order(:security_id, date: :desc) + ) + .order(amount: :desc) + end end def latest_provider_holdings_snapshot_date diff --git a/app/models/account/opening_balance_manager.rb b/app/models/account/opening_balance_manager.rb index 95597cdaa..3ad6818e0 100644 --- a/app/models/account/opening_balance_manager.rb +++ b/app/models/account/opening_balance_manager.rb @@ -51,7 +51,11 @@ def opening_anchor_valuation end def oldest_entry_date - @oldest_entry_date ||= account.entries.minimum(:date) + if opening_anchor_valuation&.entry + account.entries.where.not(id: opening_anchor_valuation.entry.id).minimum(:date) + else + account.entries.minimum(:date) + end end def default_date diff --git a/app/models/account/provider_import_adapter.rb b/app/models/account/provider_import_adapter.rb index 633c26f1b..a207468aa 100644 --- a/app/models/account/provider_import_adapter.rb +++ b/app/models/account/provider_import_adapter.rb @@ -551,8 +551,9 @@ def import_holding(security:, quantity:, amount:, currency:, date:, price: nil, # @param external_id [String, nil] Provider's unique ID (optional, for deduplication) # @param source [String] Provider name # @param activity_label [String, nil] Investment activity label (e.g., "Buy", "Sell", "Reinvestment") + # @param exchange_rate [BigDecimal, Numeric, nil] Optional provider-supplied FX rate into the account currency # @return [Entry] The created entry with trade - def import_trade(security:, quantity:, price:, amount:, currency:, date:, name: nil, external_id: nil, source:, activity_label: nil) + def import_trade(security:, quantity:, price:, amount:, currency:, date:, name: nil, external_id: nil, source:, activity_label: nil, exchange_rate: nil) raise ArgumentError, "security is required" if security.nil? raise ArgumentError, "source is required" if source.blank? @@ -585,13 +586,16 @@ def import_trade(security:, quantity:, price:, amount:, currency:, date:, name: end # Always update Trade attributes (works for both new and existing records) - entry.entryable.assign_attributes( + trade_attributes = { security: security, qty: quantity, price: price, currency: currency, investment_activity_label: activity_label || (quantity > 0 ? "Buy" : "Sell") - ) + } + trade_attributes[:exchange_rate] = exchange_rate unless exchange_rate.nil? + + entry.entryable.assign_attributes(trade_attributes) entry.assign_attributes( date: date, diff --git a/app/models/account/syncer.rb b/app/models/account/syncer.rb index 3b6a4c9b0..874091f81 100644 --- a/app/models/account/syncer.rb +++ b/app/models/account/syncer.rb @@ -9,6 +9,7 @@ def perform_sync(sync) Rails.logger.info("Processing balances (#{account.linked? ? 'reverse' : 'forward'})") import_market_data materialize_balances(window_start_date: sync.window_start_date) + apply_provider_balance_overrides end def perform_post_sync @@ -34,4 +35,16 @@ def import_market_data Rails.logger.error("Error syncing market data for account #{account.id}: #{e.message}") Sentry.capture_exception(e) end + + def apply_provider_balance_overrides + return unless account.linked_to?("IbkrAccount") + + ibkr_account = account.account_providers.find_by(provider_type: "IbkrAccount")&.provider + return unless ibkr_account + + IbkrAccount::HistoricalBalancesSync.new(ibkr_account).sync! + rescue => e + Rails.logger.error("Error syncing IBKR historical balances for account #{account.id}: #{e.class} - #{e.message}") + Sentry.capture_exception(e) + end end diff --git a/app/models/balance/sync_cache.rb b/app/models/balance/sync_cache.rb index 1f1a93a68..58caf70ad 100644 --- a/app/models/balance/sync_cache.rb +++ b/app/models/balance/sync_cache.rb @@ -37,10 +37,7 @@ def converted_entries @converted_entries ||= account.entries.excluding_split_parents.includes(:entryable).order(:date).to_a.map do |e| converted_entry = e.dup - # Extract custom exchange rate if present on Transaction - custom_rate = if e.entryable.is_a?(Transaction) - e.entryable.extra&.dig("exchange_rate") - end + custom_rate = e.entryable.exchange_rate if e.entryable.respond_to?(:exchange_rate) # Use Money#exchange_to with custom rate if available, standard lookup otherwise converted_entry.amount = converted_entry.amount_money.exchange_to( diff --git a/app/models/data_enrichment.rb b/app/models/data_enrichment.rb index db09f81b5..817b05fb2 100644 --- a/app/models/data_enrichment.rb +++ b/app/models/data_enrichment.rb @@ -1,5 +1,18 @@ 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", indexa_capital: "indexa_capital", sophtron: "sophtron" } + 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", + sophtron: "sophtron", + ibkr: "ibkr" + } end diff --git a/app/models/family.rb b/app/models/family.rb index fa7d1222d..7ebec6c5d 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -2,7 +2,7 @@ class Family < ApplicationRecord include Syncable, AutoTransferMatchable, Subscribeable, VectorSearchable include PlaidConnectable, SimplefinConnectable, LunchflowConnectable, EnableBankingConnectable include CoinbaseConnectable, BinanceConnectable, KrakenConnectable, CoinstatsConnectable, SnaptradeConnectable, MercuryConnectable, SophtronConnectable - include IndexaCapitalConnectable + include IndexaCapitalConnectable, IbkrConnectable DATE_FORMATS = [ [ "MM-DD-YYYY", "%m-%d-%Y" ], diff --git a/app/models/family/ibkr_connectable.rb b/app/models/family/ibkr_connectable.rb new file mode 100644 index 000000000..bebfcb1e8 --- /dev/null +++ b/app/models/family/ibkr_connectable.rb @@ -0,0 +1,22 @@ +module Family::IbkrConnectable + extend ActiveSupport::Concern + + included do + has_many :ibkr_items, dependent: :destroy + end + + def can_connect_ibkr? + true + end + + def create_ibkr_item!(query_id:, token:, item_name: nil) + ibkr_item = ibkr_items.create!( + name: item_name.presence || "Interactive Brokers", + query_id: query_id, + token: token + ) + + ibkr_item.sync_later + ibkr_item + end +end diff --git a/app/models/holding.rb b/app/models/holding.rb index 0b18b3e9c..ac8c37c26 100644 --- a/app/models/holding.rb +++ b/app/models/holding.rb @@ -38,7 +38,7 @@ def weight return nil unless amount return 0 if amount.zero? - account.balance.zero? ? 1 : amount / account.balance * 100 + account.balance.zero? ? 1 : amount_in_account_currency / account.balance * 100 end # Returns average cost per share, or nil if unknown. @@ -256,6 +256,14 @@ def cost_basis_source_label end private + def amount_in_account_currency + return amount if currency == account.currency + + Money.new(amount, currency).exchange_to(account.currency, date: date).amount + rescue Money::ConversionError + amount + end + def calculate_trend return nil unless amount_money return nil if avg_cost.nil? # Can't calculate trend without cost basis (0 is valid for airdrops) diff --git a/app/models/holding/materializer.rb b/app/models/holding/materializer.rb index 522508599..5832e2033 100644 --- a/app/models/holding/materializer.rb +++ b/app/models/holding/materializer.rb @@ -22,10 +22,10 @@ def materialize_holdings # securities are still needed to derive sane balance charts between sync snapshots. cleanup_shadowed_calculated_holdings - # Also remove calculated rows on the provider's latest snapshot date when those - # securities are no longer present in the provider payload. This keeps "current" - # holdings/balance composition aligned with the provider snapshot while preserving - # older calculated history. + # Also remove non-provider rows on the provider's latest snapshot date for securities + # that appear in the provider snapshot. The provider snapshot is authoritative for + # those securities on that day, even when it is denominated in a different currency + # than the account or the reverse-calculated holdings. cleanup_stale_calculated_rows_on_latest_provider_snapshot # Reload holdings association to clear any cached stale data @@ -152,17 +152,12 @@ def cleanup_stale_calculated_rows_on_latest_provider_snapshot .where(date: provider_snapshot_date) .distinct .pluck(:security_id) + return if provider_security_ids.empty? - scope = account.holdings - .where(account_provider_id: nil, date: provider_snapshot_date) - - scope = if provider_security_ids.any? - scope.where.not(security_id: provider_security_ids) - else - scope - end + deleted_count = account.holdings + .where(account_provider_id: nil, date: provider_snapshot_date, security_id: provider_security_ids) + .delete_all - deleted_count = scope.delete_all Rails.logger.info("Cleaned up #{deleted_count} stale calculated holdings on latest provider snapshot date") if deleted_count > 0 end diff --git a/app/models/holding/portfolio_cache.rb b/app/models/holding/portfolio_cache.rb index ca6febe2e..4ea52920e 100644 --- a/app/models/holding/portfolio_cache.rb +++ b/app/models/holding/portfolio_cache.rb @@ -40,7 +40,7 @@ def get_price(security_id, date, source: nil) price_money = Money.new(price.price, price.currency) begin - converted_amount = price_money.exchange_to(account.currency).amount + converted_amount = price_money.exchange_to(account.currency, date: date).amount rescue Money::ConversionError converted_amount = price.price end diff --git a/app/models/ibkr_account.rb b/app/models/ibkr_account.rb new file mode 100644 index 000000000..eb491f96a --- /dev/null +++ b/app/models/ibkr_account.rb @@ -0,0 +1,78 @@ +class IbkrAccount < ApplicationRecord + include CurrencyNormalizable, Encryptable + include IbkrAccount::DataHelpers + + if encryption_ready? + encrypts :raw_holdings_payload + encrypts :raw_activities_payload + encrypts :raw_cash_report_payload + encrypts :raw_equity_summary_payload + end + + belongs_to :ibkr_item + + 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 + validates :ibkr_account_id, uniqueness: { scope: :ibkr_item_id, allow_nil: true } + + def current_account + account || linked_account + end + + def ensure_account_provider!(account = nil) + if account_provider.present? + account_provider.update!(account: account) if account && account_provider.account_id != account.id + return account_provider + end + + acct = account || current_account + return nil unless acct + + provider = AccountProvider + .find_or_initialize_by(provider_type: "IbkrAccount", provider_id: id) + .tap do |record| + record.account = acct + record.save! + end + + reload_account_provider + provider + rescue => e + Rails.logger.warn("IbkrAccount##{id}: failed to ensure AccountProvider link: #{e.class} - #{e.message}") + nil + end + + def upsert_from_ibkr_statement!(account_data) + data = account_data.with_indifferent_access + + update!( + ibkr_account_id: data[:ibkr_account_id], + name: data[:name], + currency: parse_currency(data[:currency]) || "USD", + current_balance: data[:current_balance], + cash_balance: data[:cash_balance], + institution_metadata: { + provider_name: "Interactive Brokers", + statement_from_date: data.dig(:statement, :from_date), + statement_to_date: data.dig(:statement, :to_date) + }.compact, + report_date: data[:report_date], + raw_holdings_payload: data[:open_positions] || [], + raw_activities_payload: { + trades: data[:trades] || [], + cash_transactions: data[:cash_transactions] || [] + }, + raw_cash_report_payload: data[:cash_report] || [], + raw_equity_summary_payload: data[:equity_summary_in_base] || [], + last_holdings_sync: Time.current, + last_activities_sync: Time.current + ) + end + + def ibkr_provider + ibkr_item.ibkr_provider + end +end diff --git a/app/models/ibkr_account/activities_processor.rb b/app/models/ibkr_account/activities_processor.rb new file mode 100644 index 000000000..3b33c224d --- /dev/null +++ b/app/models/ibkr_account/activities_processor.rb @@ -0,0 +1,221 @@ +class IbkrAccount::ActivitiesProcessor + include IbkrAccount::DataHelpers + + SUPPORTED_CASH_TRANSACTION_TYPES = [ "DEPOSITS/WITHDRAWALS", "DIVIDENDS" ].freeze + + def initialize(ibkr_account) + @ibkr_account = ibkr_account + end + + def process + return { trades: 0, transactions: 0 } unless account.present? + + activities = (@ibkr_account.raw_activities_payload || {}).with_indifferent_access + trades = Array(activities[:trades]) + cash_transactions = Array(activities[:cash_transactions]) + @fee_transactions_count = 0 + + trades_count = trades.sum { |trade| process_trade(trade.with_indifferent_access) ? 1 : 0 } + cash_transactions_count = cash_transactions.sum { |cash_transaction| process_cash_transaction(cash_transaction.with_indifferent_access) ? 1 : 0 } + + { + trades: trades_count, + transactions: cash_transactions_count + @fee_transactions_count + } + end + + private + + def account + @ibkr_account.current_account + end + + def import_adapter + @import_adapter ||= Account::ProviderImportAdapter.new(account) + end + + def process_trade(row) + return false unless supported_trade?(row) + + security = resolve_security(row) + return false unless security + + quantity = parse_decimal(row[:quantity]) + native_price = parse_decimal(row[:trade_price]) + return false if quantity.nil? || native_price.nil? + + buy_sell = row[:buy_sell].to_s.upcase + signed_quantity = buy_sell == "SELL" ? -quantity.abs : quantity.abs + native_amount = buy_sell == "SELL" ? -(native_price * quantity.abs) : (native_price * quantity.abs) + currency = extract_currency(row, fallback: @ibkr_account.currency) + date = trade_date_for(row) + external_id = "ibkr_trade_#{row[:trade_id]}" + + import_adapter.import_trade( + external_id: external_id, + security: security, + quantity: signed_quantity, + price: native_price, + amount: native_amount, + currency: currency, + date: date, + name: build_trade_name(security.ticker, signed_quantity), + source: "ibkr", + activity_label: buy_sell == "SELL" ? "Sell" : "Buy", + exchange_rate: parse_decimal(row[:fx_rate_to_base])&.to_f + ) + + import_commission_transaction(row, security, date) + true + rescue => e + Rails.logger.error("IbkrAccount::ActivitiesProcessor - Failed to process trade #{row[:trade_id]}: #{e.message}") + false + end + + def process_cash_transaction(row) + return false unless supported_cash_transaction?(row) + + amount = parse_decimal(row[:amount]) + return false if amount.nil? || amount.zero? + + label, signed_amount = classify_cash_transaction(row, amount) + return false unless label + currency = extract_currency(row, fallback: @ibkr_account.currency) + security = resolve_security_for_cash_transaction(row) + + import_adapter.import_transaction( + external_id: "ibkr_cash_#{row[:transaction_id]}", + amount: signed_amount, + currency: currency, + date: parse_date(row[:report_date]), + name: build_cash_transaction_name(row, label, security), + source: "ibkr", + investment_activity_label: label, + extra: { + exchange_rate: parse_decimal(row[:fx_rate_to_base])&.to_f, + security_id: security&.id, + ibkr: { + transaction_id: row[:transaction_id], + type: row[:type], + conid: row[:conid], + amount: row[:amount], + currency: row[:currency], + fx_rate_to_base: row[:fx_rate_to_base], + report_date: row[:report_date] + }.compact + } + ) + + true + rescue => e + Rails.logger.error("IbkrAccount::ActivitiesProcessor - Failed to process cash transaction #{row[:transaction_id]}: #{e.message}") + false + end + + def import_commission_transaction(row, security, date) + commission = parse_decimal(row[:ib_commission]) + return if commission.nil? || commission.zero? + currency = row.with_indifferent_access[:ib_commission_currency].to_s.upcase.presence || @ibkr_account.currency + ticker = security&.ticker || row.with_indifferent_access[:symbol] + + result = import_adapter.import_transaction( + external_id: "ibkr_trade_fee_#{row[:trade_id]}", + amount: commission.abs, + currency: currency, + date: date, + name: "Trade Commission for #{ticker}", + source: "ibkr", + investment_activity_label: "Fee", + extra: { + exchange_rate: parse_decimal(row[:fx_rate_to_base])&.to_f, + security_id: security&.id, + ibkr: { + trade_id: row[:trade_id], + transaction_id: row[:transaction_id], + ib_commission: row[:ib_commission], + ib_commission_currency: row[:ib_commission_currency], + fx_rate_to_base: row[:fx_rate_to_base] + }.compact + } + ) + + @fee_transactions_count += 1 if result + end + + def build_trade_name(ticker, signed_quantity) + action = signed_quantity.negative? ? "Sell" : "Buy" + "#{action} #{signed_quantity.abs} shares of #{ticker}" + end + + def supported_trade?(row) + row[:asset_category].to_s == "STK" && + row[:buy_sell].present? && + row[:conid].present? && + row[:currency].present? && + row[:quantity].present? && + row[:symbol].present? && + row[:trade_date].present? && + row[:trade_id].present? && + row[:trade_price].present? && + row[:transaction_id].present? && + fx_rate_available?(row) + end + + def supported_cash_transaction?(row) + type = row[:type].to_s.upcase.strip + return false unless SUPPORTED_CASH_TRANSACTION_TYPES.include?(type) + return false unless row[:transaction_id].present? && row[:amount].present? && row[:currency].present? && row[:report_date].present? + return false unless fx_rate_available?(row) + + type != "DIVIDENDS" || row[:conid].present? + end + + def classify_cash_transaction(row, amount) + type = row[:type].to_s.upcase.strip + + case type + when "DEPOSITS/WITHDRAWALS" + amount.positive? ? [ "Contribution", -amount.abs ] : [ "Withdrawal", amount.abs ] + when "DIVIDENDS" + [ "Dividend", -amount.abs ] + else + [ nil, nil ] + end + end + + def build_cash_transaction_name(row, label, security = nil) + return label unless label == "Dividend" + + ticker = security&.ticker || security_symbol_for_conid(row[:conid]) || row[:conid] + "Dividend from #{ticker}" + end + + def resolve_security_for_cash_transaction(row) + symbol = security_symbol_for_conid(row[:conid]) + return nil if symbol.blank? + + resolve_security({ symbol: symbol }) + end + + def security_symbol_for_conid(conid) + return nil if conid.blank? + + holding_symbol = Array(@ibkr_account.raw_holdings_payload).find do |holding| + holding.with_indifferent_access[:conid].to_s == conid.to_s + end&.with_indifferent_access&.dig(:symbol) + return holding_symbol if holding_symbol.present? + + Array(@ibkr_account.raw_activities_payload&.dig("trades") || @ibkr_account.raw_activities_payload&.dig(:trades)).find do |trade| + trade.with_indifferent_access[:conid].to_s == conid.to_s + end&.with_indifferent_access&.dig(:symbol) + end + + + def fx_rate_available?(row) + source_currency = extract_currency(row, fallback: nil) + return false if source_currency.blank? + return true if source_currency == @ibkr_account.currency + + row[:fx_rate_to_base].present? + end +end diff --git a/app/models/ibkr_account/data_helpers.rb b/app/models/ibkr_account/data_helpers.rb new file mode 100644 index 000000000..c3416d74b --- /dev/null +++ b/app/models/ibkr_account/data_helpers.rb @@ -0,0 +1,78 @@ +module IbkrAccount::DataHelpers + extend ActiveSupport::Concern + + private + + def parse_decimal(value) + return nil if value.nil? + + normalized = value.is_a?(String) ? value.delete(",").strip : value.to_s + return nil if normalized.blank? || normalized == "-" + + BigDecimal(normalized) + rescue ArgumentError + nil + end + + def parse_date(value) + return nil if value.blank? + + case value + when Date + value + when Time, DateTime, ActiveSupport::TimeWithZone + value.to_date + else + normalized = value.to_s.tr(";", " ") + Time.zone.parse(normalized)&.to_date || Date.parse(normalized) + end + rescue ArgumentError, TypeError + nil + end + + def parse_datetime(value) + return nil if value.blank? + + case value + when Time, DateTime, ActiveSupport::TimeWithZone + value.in_time_zone + when Date + value.in_time_zone + else + Time.zone.parse(value.to_s.tr(";", " ")) + end + rescue ArgumentError, TypeError + nil + end + + def resolve_security(row) + data = row.with_indifferent_access + ticker = data[:symbol].to_s.strip.upcase + return nil if ticker.blank? + + Security.find_by(ticker: ticker) || create_security_from_row(ticker) + rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotUnique + Security.find_by(ticker: ticker) + end + + def trade_date_for(row) + data = row.with_indifferent_access + parsed_trade_date = parse_date(data[:trade_date]) + return parsed_trade_date if parsed_trade_date + + Rails.logger.warn( + "IbkrAccount::DataHelpers - Missing or invalid trade_date, falling back to Date.current. " \ + "trade_id=#{data[:trade_id].inspect}" + ) + Date.current + end + + def extract_currency(row, fallback: nil) + value = row.with_indifferent_access[:currency] + value.present? ? value.to_s.upcase : fallback + end + + def create_security_from_row(ticker) + Security.create!(ticker: ticker, name: ticker) + end +end diff --git a/app/models/ibkr_account/historical_balances_sync.rb b/app/models/ibkr_account/historical_balances_sync.rb new file mode 100644 index 000000000..a7a0bc363 --- /dev/null +++ b/app/models/ibkr_account/historical_balances_sync.rb @@ -0,0 +1,79 @@ +class IbkrAccount::HistoricalBalancesSync + include IbkrAccount::DataHelpers + + attr_reader :ibkr_account + + def initialize(ibkr_account) + @ibkr_account = ibkr_account + end + + def sync! + return unless account.present? + return if normalized_rows.empty? + + account.balances.upsert_all( + balance_rows, + unique_by: %i[account_id date currency] + ) + end + + private + def account + ibkr_account.current_account + end + + def normalized_rows + @normalized_rows ||= Array(ibkr_account.raw_equity_summary_payload) + .filter_map do |row| + next unless row.is_a?(Hash) + + data = row.with_indifferent_access + currency = data[:currency].presence&.upcase + account_currency = ibkr_account.currency.to_s.upcase + next if currency.present? && currency != account_currency + + date = parse_date(data[:report_date]) + total = parse_decimal(data[:total]) + cash = parse_decimal(data[:cash]) || BigDecimal("0") + next unless date && total + + { + date: date, + total: total, + cash: cash, + non_cash: total - cash + } + end + .sort_by { |row| row[:date] } + end + + def balance_rows + current_time = Time.current + + normalized_rows.each_with_index.map do |row, index| + previous_row = index.zero? ? nil : normalized_rows[index - 1] + start_cash_balance = previous_row ? previous_row[:cash] : row[:cash] + start_non_cash_balance = previous_row ? previous_row[:non_cash] : row[:non_cash] + + { + account_id: account.id, + date: row[:date], + balance: row[:total], + cash_balance: row[:cash], + currency: account.currency, + start_cash_balance: start_cash_balance, + start_non_cash_balance: start_non_cash_balance, + cash_inflows: 0, + cash_outflows: 0, + non_cash_inflows: 0, + non_cash_outflows: 0, + net_market_flows: 0, + cash_adjustments: row[:cash] - start_cash_balance, + non_cash_adjustments: row[:non_cash] - start_non_cash_balance, + flows_factor: 1, + created_at: current_time, + updated_at: current_time + } + end + end +end diff --git a/app/models/ibkr_account/holdings_processor.rb b/app/models/ibkr_account/holdings_processor.rb new file mode 100644 index 000000000..9ff657f3f --- /dev/null +++ b/app/models/ibkr_account/holdings_processor.rb @@ -0,0 +1,105 @@ +class IbkrAccount::HoldingsProcessor + include IbkrAccount::DataHelpers + + def initialize(ibkr_account) + @ibkr_account = ibkr_account + end + + def process + return unless account.present? + + grouped_positions.each_value do |group| + process_group(group) + end + end + + private + + def account + @ibkr_account.current_account + end + + def import_adapter + @import_adapter ||= Account::ProviderImportAdapter.new(account) + end + + def grouped_positions + Array(@ibkr_account.raw_holdings_payload).each_with_object({}) do |position, groups| + data = position.with_indifferent_access + next unless supported_position?(data) + + symbol_key = data[:conid].presence || data[:symbol].presence || data[:security_id].presence + currency = extract_currency(data, fallback: @ibkr_account.currency) + report_date = parse_date(data[:report_date]) || @ibkr_account.report_date || Date.current + key = [ symbol_key, currency, report_date ] + groups[key] ||= [] + groups[key] << data + end + end + + def process_group(rows) + sample = rows.first + security = resolve_security(sample) + return unless security + + quantity = rows.sum { |row| parse_decimal(row[:position]) || BigDecimal("0") } + return if quantity.zero? + + price = parse_decimal(sample[:mark_price]) + cost_basis = weighted_cost_basis_for(rows) + return unless price && cost_basis + + amount = quantity.abs * price + + currency = extract_currency(sample, fallback: @ibkr_account.currency) + report_date = parse_date(sample[:report_date]) || @ibkr_account.report_date || Date.current + external_id = [ "ibkr", @ibkr_account.ibkr_account_id, sample[:conid].presence || security.ticker, report_date, currency ].join("_") + + import_adapter.import_holding( + security: security, + quantity: quantity, + amount: amount, + currency: currency, + date: report_date, + price: price || BigDecimal("0"), + cost_basis: cost_basis, + external_id: external_id, + source: "ibkr", + account_provider_id: @ibkr_account.account_provider&.id, + delete_future_holdings: false + ) + end + + def weighted_cost_basis_for(rows) + total_quantity = BigDecimal("0") + total_cost = BigDecimal("0") + + rows.each do |row| + row_quantity = parse_decimal(row[:position]) + row_cost_basis = parse_decimal(row[:cost_basis_price]) + return nil unless row_quantity && row_cost_basis + + total_quantity += row_quantity.abs + total_cost += row_quantity.abs * row_cost_basis + end + + return nil if total_quantity.zero? + + total_cost / total_quantity + end + + def supported_position?(row) + row[:asset_category].to_s == "STK" && + row[:side].to_s == "Long" && + row[:conid].present? && + row[:security_id].present? && + row[:security_id_type].present? && + row[:symbol].present? && + row[:currency].present? && + row[:fx_rate_to_base].present? && + row[:position].present? && + row[:mark_price].present? && + row[:cost_basis_price].present? && + row[:report_date].present? + end +end diff --git a/app/models/ibkr_account/processor.rb b/app/models/ibkr_account/processor.rb new file mode 100644 index 000000000..2fa30e4e2 --- /dev/null +++ b/app/models/ibkr_account/processor.rb @@ -0,0 +1,56 @@ +class IbkrAccount::Processor + attr_reader :ibkr_account + + def initialize(ibkr_account) + @ibkr_account = ibkr_account + end + + def process + return unless ibkr_account.current_account.present? + + update_account_balance! + IbkrAccount::HoldingsProcessor.new(ibkr_account).process + IbkrAccount::ActivitiesProcessor.new(ibkr_account).process + repair_default_opening_anchor! + + ibkr_account.current_account.broadcast_sync_complete + end + + private + + def update_account_balance! + account = ibkr_account.current_account + + total_balance = ibkr_account.current_balance || ibkr_account.cash_balance || 0 + cash_balance = ibkr_account.cash_balance || 0 + + account.assign_attributes( + balance: total_balance, + cash_balance: cash_balance, + currency: ibkr_account.currency + ) + account.save! + account.set_current_balance(total_balance) + end + + def repair_default_opening_anchor! + account = ibkr_account.current_account + return unless account&.linked_to?("IbkrAccount") + return unless account.has_opening_anchor? + + opening_anchor_entry = account.valuations.opening_anchor.includes(:entry).first&.entry + return unless opening_anchor_entry + return unless opening_anchor_entry.created_at.to_date == account.created_at.to_date + return unless account.entries.where.not(entryable_type: "Valuation").exists? + + imported_current_balance = (ibkr_account.current_balance || ibkr_account.cash_balance || 0).to_d + return unless opening_anchor_entry.amount.to_d == imported_current_balance + + result = Account::OpeningBalanceManager.new(account).set_opening_balance( + balance: 0, + date: opening_anchor_entry.date + ) + + raise result.error if result.error + end +end diff --git a/app/models/ibkr_item.rb b/app/models/ibkr_item.rb new file mode 100644 index 000000000..aca60d1b2 --- /dev/null +++ b/app/models/ibkr_item.rb @@ -0,0 +1,124 @@ +class IbkrItem < ApplicationRecord + include Syncable, Provided, Unlinking, Encryptable + + enum :status, { good: "good", requires_update: "requires_update" }, default: :good + + if encryption_ready? + encrypts :query_id, deterministic: true + encrypts :token + encrypts :raw_payload + end + + belongs_to :family + has_one_attached :logo, dependent: :purge_later + + has_many :ibkr_accounts, dependent: :destroy + + validates :name, presence: true + validates :query_id, presence: true, on: :create + validates :token, presence: true, on: :create + + scope :active, -> { where(scheduled_for_deletion: false) } + scope :syncable, -> { active.where.not(query_id: [ nil, "" ]).where.not(token: nil) } + scope :ordered, -> { order(created_at: :desc) } + scope :needs_update, -> { where(status: :requires_update) } + + def destroy_later + update!(scheduled_for_deletion: true) + DestroyJob.perform_later(self) + end + + def credentials_configured? + query_id.present? && token.present? + end + + def import_latest_ibkr_data + provider = ibkr_provider + raise StandardError, "IBKR provider is not configured" unless provider + + IbkrItem::Importer.new(self, ibkr_provider: provider).import + rescue => e + Rails.logger.error("IbkrItem #{id} - Failed to import data: #{e.message}") + raise + end + + def process_accounts + return [] if ibkr_accounts.empty? + + linked_ibkr_accounts.includes(account_provider: :account).each_with_object([]) do |ibkr_account, results| + account = ibkr_account.current_account + next unless account + next if account.pending_deletion? || account.disabled? + + begin + result = IbkrAccount::Processor.new(ibkr_account).process + results << { ibkr_account_id: ibkr_account.id, success: true, result: result } + rescue => e + Rails.logger.error("IbkrItem #{id} - Failed to process account #{ibkr_account.id}: #{e.message}") + results << { ibkr_account_id: ibkr_account.id, success: false, error: e.message } + end + end + end + + def schedule_account_syncs(parent_sync: nil, window_start_date: nil, window_end_date: nil) + accounts.reject { |account| account.pending_deletion? || account.disabled? }.each_with_object([]) do |account, results| + 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("IbkrItem #{id} - Failed to schedule sync for account #{account.id}: #{e.message}") + results << { account_id: account.id, success: false, error: e.message } + end + end + end + + def upsert_ibkr_snapshot!(payload) + update!(raw_payload: payload, status: :good) + end + + def accounts + ibkr_accounts.includes(account_provider: :account).filter_map(&:current_account).uniq + end + + def linked_ibkr_accounts + ibkr_accounts.joins(:account_provider) + end + + def linked_accounts_count + ibkr_accounts.joins(:account_provider).count + end + + def unlinked_accounts_count + ibkr_accounts.left_joins(:account_provider).where(account_providers: { id: nil }).count + end + + def total_accounts_count + ibkr_accounts.count + end + + def has_completed_initial_setup? + accounts.any? + end + + def sync_status_summary + total_accounts = total_accounts_count + linked_count = linked_accounts_count + unlinked_count = unlinked_accounts_count + + if total_accounts.zero? + I18n.t("ibkr_items.sync_status.no_accounts") + elsif unlinked_count.zero? + I18n.t("ibkr_items.sync_status.all_linked", count: linked_count) + else + I18n.t("ibkr_items.sync_status.partial", linked: linked_count, unlinked: unlinked_count) + end + end + + def institution_display_name + I18n.t("ibkr_items.defaults.name") + end +end diff --git a/app/models/ibkr_item/importer.rb b/app/models/ibkr_item/importer.rb new file mode 100644 index 000000000..30a5916da --- /dev/null +++ b/app/models/ibkr_item/importer.rb @@ -0,0 +1,33 @@ +class IbkrItem::Importer + attr_reader :ibkr_item, :ibkr_provider + + def initialize(ibkr_item, ibkr_provider:) + @ibkr_item = ibkr_item + @ibkr_provider = ibkr_provider + end + + def import + xml_body = ibkr_provider.download_statement + parsed_report = IbkrItem::ReportParser.new(xml_body).parse + + accounts_imported = 0 + ibkr_item.transaction do + ibkr_item.upsert_ibkr_snapshot!(parsed_report[:metadata].merge("fetched_at" => Time.current.iso8601)) + + parsed_report[:accounts].each do |account_data| + next if account_data[:ibkr_account_id].blank? + + ibkr_account = ibkr_item.ibkr_accounts.find_or_initialize_by(ibkr_account_id: account_data[:ibkr_account_id]) + ibkr_account.upsert_from_ibkr_statement!(account_data) + accounts_imported += 1 + end + + ibkr_item.update!(status: :good) + end + + { + success: true, + accounts_imported: accounts_imported + } + end +end diff --git a/app/models/ibkr_item/provided.rb b/app/models/ibkr_item/provided.rb new file mode 100644 index 000000000..55c6d43fb --- /dev/null +++ b/app/models/ibkr_item/provided.rb @@ -0,0 +1,9 @@ +module IbkrItem::Provided + extend ActiveSupport::Concern + + def ibkr_provider + return nil unless credentials_configured? + + Provider::IbkrFlex.new(query_id: query_id, token: token) + end +end diff --git a/app/models/ibkr_item/report_parser.rb b/app/models/ibkr_item/report_parser.rb new file mode 100644 index 000000000..edab81812 --- /dev/null +++ b/app/models/ibkr_item/report_parser.rb @@ -0,0 +1,143 @@ +class IbkrItem::ReportParser + include IbkrAccount::DataHelpers + + class ParseError < StandardError; end + + POSITION_VALUE_CONTAINER_NAMES = %w[ChangeInPositionValues].freeze + POSITION_VALUE_ROW_NAMES = %w[ChangeInPositionValue].freeze + CASH_REPORT_CONTAINER_NAMES = %w[CashReport CashReports].freeze + CASH_REPORT_ROW_NAMES = %w[CashReport CashReportCurrency CashReportRow].freeze + EQUITY_SUMMARY_CONTAINER_NAMES = %w[EquitySummaryInBase].freeze + EQUITY_SUMMARY_ROW_NAMES = %w[EquitySummaryByReportDateInBase].freeze + OPEN_POSITION_CONTAINER_NAMES = %w[OpenPositions].freeze + OPEN_POSITION_ROW_NAMES = %w[OpenPosition].freeze + TRADES_CONTAINER_NAMES = %w[Trades].freeze + TRADE_ROW_NAMES = %w[Trade].freeze + CASH_TRANSACTION_CONTAINER_NAMES = %w[CashTransactions].freeze + CASH_TRANSACTION_ROW_NAMES = %w[CashTransaction].freeze + + def initialize(xml_body) + @document = Nokogiri::XML(xml_body.to_s) { |config| config.strict.noblanks } + rescue Nokogiri::XML::SyntaxError => e + raise ParseError, "Invalid IBKR Flex XML: #{e.message}" + end + + def parse + validate_document! + + { + metadata: root_metadata, + accounts: flex_statements.map { |statement| parse_statement(statement) } + } + end + + private + + def validate_document! + raise ParseError, "Invalid IBKR Flex XML: missing FlexQueryResponse root." unless @document.at_xpath("//FlexQueryResponse") + raise ParseError, "Invalid IBKR Flex XML: no FlexStatement nodes found." if flex_statements.empty? + end + + def flex_statements + @document.xpath("//FlexStatement") + end + + def root_metadata + node_attributes(@document.at_xpath("//FlexQueryResponse")) + end + + def parse_statement(statement) + statement_data = node_attributes(statement) + account_information = node_attributes(statement.at_xpath("./AccountInformation")) + position_values = section_rows(statement, POSITION_VALUE_CONTAINER_NAMES, POSITION_VALUE_ROW_NAMES) + cash_report = section_rows(statement, CASH_REPORT_CONTAINER_NAMES, CASH_REPORT_ROW_NAMES) + equity_summary_in_base = section_rows(statement, EQUITY_SUMMARY_CONTAINER_NAMES, EQUITY_SUMMARY_ROW_NAMES) + open_positions = section_rows(statement, OPEN_POSITION_CONTAINER_NAMES, OPEN_POSITION_ROW_NAMES) + trades = section_rows(statement, TRADES_CONTAINER_NAMES, TRADE_ROW_NAMES) + cash_transactions = section_rows(statement, CASH_TRANSACTION_CONTAINER_NAMES, CASH_TRANSACTION_ROW_NAMES) + account_id = account_information["account_id"].presence || statement_data["account_id"] + + raise ParseError, "Invalid IBKR Flex XML: missing account identifier in FlexStatement." if account_id.blank? + + currency = account_information["currency"].presence&.upcase || "USD" + report_date = open_positions.filter_map { |row| parse_date(row["report_date"]) }.max || + equity_summary_in_base.filter_map { |row| parse_date(row["report_date"]) }.max || + parse_date(statement_data["to_date"]) || + Date.current + + { + ibkr_account_id: account_id, + name: account_id, + currency: currency, + cash_balance: extract_cash_balance(cash_report, currency), + current_balance: extract_total_balance(position_values, cash_report, currency), + report_date: report_date, + statement: statement_data, + cash_report: cash_report, + equity_summary_in_base: equity_summary_in_base, + open_positions: open_positions, + trades: trades, + cash_transactions: cash_transactions, + raw_payload: { + statement: statement_data, + cash_report: cash_report, + equity_summary_in_base: equity_summary_in_base, + open_positions: open_positions, + trades: trades, + cash_transactions: cash_transactions + } + } + end + + def section_rows(statement, container_names, row_names) + rows = [] + + container_names.each do |container_name| + statement.xpath("./#{container_name}").each do |container| + children = container.element_children + + if children.any? + rows.concat(children.select { |child| row_names.include?(child.name) }) + elsif row_names.include?(container.name) + rows << container + end + end + end + + if rows.empty? + row_names.each do |row_name| + rows.concat(statement.xpath("./#{row_name}")) + end + end + + rows.map { |row| node_attributes(row) }.reject(&:blank?) + end + + def node_attributes(node) + return {} unless node + + node.attribute_nodes.each_with_object({}) do |attribute, result| + result[attribute.name.underscore] = attribute.value + end + end + + def extract_cash_balance(cash_rows, account_currency) + base_summary = cash_rows.find { |row| row["currency"] == "BASE_SUMMARY" } + account_row = cash_rows.find { |row| row["currency"] == account_currency } + row = base_summary || account_row + + parse_decimal(row&.fetch("ending_cash", nil)) || BigDecimal("0") + end + + def extract_current_balance(position_values, account_currency) + base_summary = position_values.find { |row| row["currency"] == "BASE_SUMMARY" } + account_row = position_values.find { |row| row["currency"] == account_currency } + row = base_summary || account_row + + parse_decimal(row&.fetch("end_of_period_value", nil)) || BigDecimal("0") + end + + def extract_total_balance(position_values, cash_rows, account_currency) + extract_current_balance(position_values, account_currency) + extract_cash_balance(cash_rows, account_currency) + end +end diff --git a/app/models/ibkr_item/sync_complete_event.rb b/app/models/ibkr_item/sync_complete_event.rb new file mode 100644 index 000000000..46ebe39ac --- /dev/null +++ b/app/models/ibkr_item/sync_complete_event.rb @@ -0,0 +1,22 @@ +class IbkrItem::SyncCompleteEvent + attr_reader :ibkr_item + + def initialize(ibkr_item) + @ibkr_item = ibkr_item + end + + def broadcast + ibkr_item.accounts.each do |account| + account.broadcast_sync_complete + end + + ibkr_item.broadcast_replace_to( + ibkr_item.family, + target: "ibkr_item_#{ibkr_item.id}", + partial: "ibkr_items/ibkr_item", + locals: { ibkr_item: ibkr_item } + ) + + ibkr_item.family.broadcast_sync_complete + end +end diff --git a/app/models/ibkr_item/syncer.rb b/app/models/ibkr_item/syncer.rb new file mode 100644 index 000000000..003c26855 --- /dev/null +++ b/app/models/ibkr_item/syncer.rb @@ -0,0 +1,68 @@ +class IbkrItem::Syncer + include SyncStats::Collector + + attr_reader :ibkr_item + + def initialize(ibkr_item) + @ibkr_item = ibkr_item + end + + def perform_sync(sync) + sync.update!(status_text: "Checking IBKR credentials...") if sync.respond_to?(:status_text) + unless ibkr_item.credentials_configured? + ibkr_item.update!(status: :requires_update) + raise Provider::IbkrFlex::ConfigurationError, "IBKR credentials are missing." + end + + sync.update!(status_text: "Importing IBKR accounts...") if sync.respond_to?(:status_text) + ibkr_item.import_latest_ibkr_data + + sync.update!(status_text: "Checking account configuration...") if sync.respond_to?(:status_text) + collect_setup_stats(sync, provider_accounts: ibkr_item.ibkr_accounts.to_a) + + unlinked_accounts = ibkr_item.ibkr_accounts.left_joins(:account_provider).where(account_providers: { id: nil }) + linked_accounts = ibkr_item.ibkr_accounts.joins(:account).merge(Account.visible) + + if unlinked_accounts.any? + ibkr_item.update!(pending_account_setup: true) + sync.update!(status_text: "#{unlinked_accounts.count} IBKR account(s) need setup...") if sync.respond_to?(:status_text) + else + ibkr_item.update!(pending_account_setup: false) + end + + if linked_accounts.any? + sync.update!(status_text: "Processing holdings and activity...") if sync.respond_to?(:status_text) + ibkr_item.process_accounts + + sync.update!(status_text: "Calculating balances...") if sync.respond_to?(:status_text) + ibkr_item.schedule_account_syncs( + parent_sync: sync, + window_start_date: sync.window_start_date, + window_end_date: sync.window_end_date + ) + + account_ids = linked_accounts.includes(:account).filter_map { |provider_account| provider_account.account&.id } + collect_transaction_stats(sync, account_ids: account_ids, source: "ibkr") if account_ids.any? + collect_trades_stats(sync, account_ids: account_ids, source: "ibkr") if account_ids.any? + collect_holdings_stats(sync, holdings_count: count_holdings, label: "processed") + end + + collect_health_stats(sync, errors: nil) + rescue Provider::IbkrFlex::AuthenticationError, Provider::IbkrFlex::ConfigurationError => e + ibkr_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 + + def perform_post_sync + end + + private + + def count_holdings + ibkr_item.ibkr_accounts.sum { |account| Array(account.raw_holdings_payload).size } + end +end diff --git a/app/models/ibkr_item/unlinking.rb b/app/models/ibkr_item/unlinking.rb new file mode 100644 index 000000000..ddc827a56 --- /dev/null +++ b/app/models/ibkr_item/unlinking.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module IbkrItem::Unlinking + extend ActiveSupport::Concern + + def unlink_all!(dry_run: false) + results = [] + + ibkr_accounts.find_each do |provider_account| + links = AccountProvider.where(provider_type: "IbkrAccount", 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 + Holding.where(account_provider_id: link_ids).update_all(account_provider_id: nil) if link_ids.any? + links.each(&:destroy!) + end + rescue => e + Rails.logger.warn( + "IbkrItem Unlinker: failed to fully unlink provider account ##{provider_account.id} " \ + "(links=#{link_ids.inspect}): #{e.class} - #{e.message}" + ) + result[:error] = e.message + end + end + + results + end +end diff --git a/app/models/provider/ibkr_adapter.rb b/app/models/provider/ibkr_adapter.rb new file mode 100644 index 000000000..639831937 --- /dev/null +++ b/app/models/provider/ibkr_adapter.rb @@ -0,0 +1,59 @@ +class Provider::IbkrAdapter < Provider::Base + include Provider::Syncable + include Provider::InstitutionMetadata + + Provider::Factory.register("IbkrAccount", self) + + def self.supported_account_types + %w[Investment] + end + + def self.connection_configs(family:) + return [] unless family.can_connect_ibkr? + + [ { + key: "ibkr", + name: I18n.t("providers.ibkr.name"), + description: I18n.t("providers.ibkr.connection_description"), + can_connect: true, + new_account_path: ->(_accountable_type, _return_to) { + Rails.application.routes.url_helpers.select_accounts_ibkr_items_path + }, + existing_account_path: ->(account_id) { + Rails.application.routes.url_helpers.select_existing_account_ibkr_items_path(account_id: account_id) + } + } ] + end + + def provider_name + "ibkr" + end + + def sync_path + Rails.application.routes.url_helpers.sync_ibkr_item_path(item) + end + + def item + provider_account.ibkr_item + end + + def can_delete_holdings? + false + end + + def institution_domain + "interactivebrokers.com" + end + + def institution_name + I18n.t("providers.ibkr.institution_name") + end + + def institution_url + "https://www.interactivebrokers.com" + end + + def institution_color + "#D32F2F" + end +end diff --git a/app/models/provider/ibkr_flex.rb b/app/models/provider/ibkr_flex.rb new file mode 100644 index 000000000..16da5b803 --- /dev/null +++ b/app/models/provider/ibkr_flex.rb @@ -0,0 +1,144 @@ +class Provider::IbkrFlex + include HTTParty + extend SslConfigurable + + class Error < StandardError; end + class AuthenticationError < Error; end + class ConfigurationError < Error; end + class ApiError < Error + attr_reader :status_code, :response_body, :error_code + + def initialize(message, status_code: nil, response_body: nil, error_code: nil) + super(message) + @status_code = status_code + @response_body = response_body + @error_code = error_code + end + end + + base_uri "https://ndcdyn.interactivebrokers.com/AccountManagement/FlexWebService" + headers "User-Agent" => "Sure Finance IBKR Flex Client" + default_options.merge!({ timeout: 120 }.merge(httparty_ssl_options)) + + MAX_RETRIES = 3 + INITIAL_RETRY_DELAY = 2 + MAX_RETRY_DELAY = 30 + POLL_INTERVAL = 3 + MAX_POLL_ATTEMPTS = 20 + PENDING_ERROR_CODES = %w[1004 1019].freeze + + RETRYABLE_ERRORS = [ + SocketError, + Net::OpenTimeout, + Net::ReadTimeout, + Errno::ECONNRESET, + Errno::ECONNREFUSED, + Errno::ETIMEDOUT, + EOFError + ].freeze + + attr_reader :query_id, :token + + def initialize(query_id:, token:) + raise ConfigurationError, "query_id is required" if query_id.blank? + raise ConfigurationError, "token is required" if token.blank? + + @query_id = query_id.to_s.strip + @token = token.to_s.strip + end + + def download_statement + reference_code = request_reference_code + poll_statement(reference_code) + end + + private + + def request_reference_code + response = with_retries("SendRequest") do + self.class.get("/SendRequest", query: { t: token, q: query_id, v: 3 }) + end + + xml = parse_xml(response.body) + error = response_error(xml, response) + raise error if error + + reference_code = xml.at_xpath("//ReferenceCode")&.text.to_s.strip + raise ApiError.new("IBKR Flex did not return a reference code.", status_code: response.code, response_body: response.body) if reference_code.blank? + + reference_code + end + + def poll_statement(reference_code) + attempts = 0 + + loop do + attempts += 1 + response = with_retries("GetStatement") do + self.class.get("/GetStatement", query: { t: token, q: reference_code, v: 3 }) + end + + xml = parse_xml(response.body) + return response.body if xml.at_xpath("//FlexQueryResponse") + + error = response_error(xml, response) + if error.is_a?(ApiError) && PENDING_ERROR_CODES.include?(error.error_code.to_s) + raise ApiError.new("IBKR Flex statement is still being generated.", error_code: error.error_code) if attempts >= MAX_POLL_ATTEMPTS + + sleep(POLL_INTERVAL) + next + end + + raise(error || ApiError.new("IBKR Flex returned an unexpected response.", status_code: response.code, response_body: response.body)) + end + end + + def response_error(xml, response) + error_code = xml.at_xpath("//ErrorCode")&.text.to_s.strip.presence + error_message = xml.at_xpath("//ErrorMessage")&.text.to_s.strip.presence + + return nil if error_code.blank? && response.success? + + message = error_message.presence || "IBKR Flex request failed" + + case error_code + when "1012", "1015" + AuthenticationError.new(message) + when "1014" + ConfigurationError.new(message) + else + ApiError.new(message, status_code: response.code, response_body: response.body, error_code: error_code) + end + end + + def parse_xml(body) + Nokogiri::XML(body.to_s) + 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( + "IBKR Flex: #{operation_name} failed (attempt #{retries}/#{max_retries}): #{e.class}: #{e.message}. Retrying in #{delay}s..." + ) + sleep(delay) + retry + end + + raise ApiError.new("Network error after #{max_retries} retries: #{e.message}") + 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, MAX_RETRY_DELAY ].min + end +end diff --git a/app/models/provider/metadata.rb b/app/models/provider/metadata.rb index 0850c524c..3d8472d70 100644 --- a/app/models/provider/metadata.rb +++ b/app/models/provider/metadata.rb @@ -10,6 +10,7 @@ module Metadata binance: { region: "Global", kind: "Crypto", maturity: :beta, logo_text: "BI", logo_bg: "bg-yellow-600" }, kraken: { region: "Global", kind: "Crypto", maturity: :beta, logo_text: "KR", logo_bg: "bg-violet-600" }, snaptrade: { region: "US / CA", kind: "Investment", maturity: :beta, logo_text: "ST", logo_bg: "bg-green-600" }, + ibkr: { region: "Global", kind: "Investment", maturity: :beta, logo_text: "IB", logo_bg: "bg-red-600" }, indexa_capital: { region: "ES", kind: "Investment", maturity: :alpha, logo_text: "IC", logo_bg: "bg-red-600" }, sophtron: { region: "US", kind: "Bank", maturity: :alpha, logo_text: "SO", logo_bg: "bg-teal-600" }, plaid: { region: "US", kind: "Bank", tier: "Paid", maturity: :stable, logo_text: "PL", logo_bg: "bg-indigo-600" }, diff --git a/app/models/provider_connection_status.rb b/app/models/provider_connection_status.rb index 6ed6f4a34..47ce145ea 100644 --- a/app/models/provider_connection_status.rb +++ b/app/models/provider_connection_status.rb @@ -11,6 +11,7 @@ class ProviderConnectionStatus { key: "kraken", type: "KrakenItem", association: :kraken_items, accounts: :kraken_accounts }, { key: "coinstats", type: "CoinstatsItem", association: :coinstats_items, accounts: :coinstats_accounts }, { key: "snaptrade", type: "SnaptradeItem", association: :snaptrade_items, accounts: :snaptrade_accounts, linked_accounts: :linked_accounts }, + { key: "ibkr", type: "IbkrItem", association: :ibkr_items, accounts: :ibkr_accounts }, { key: "mercury", type: "MercuryItem", association: :mercury_items, accounts: :mercury_accounts }, { key: "sophtron", type: "SophtronItem", association: :sophtron_items, accounts: :sophtron_accounts }, { key: "indexa_capital", type: "IndexaCapitalItem", association: :indexa_capital_items, accounts: :indexa_capital_accounts } diff --git a/app/models/snaptrade_account/processor.rb b/app/models/snaptrade_account/processor.rb index b5edcb5e8..a20893e63 100644 --- a/app/models/snaptrade_account/processor.rb +++ b/app/models/snaptrade_account/processor.rb @@ -71,6 +71,11 @@ def update_account_balance(account) end def calculate_total_balance + if use_api_total_balance? + Rails.logger.debug "SnaptradeAccount::Processor - Using API total for multi-currency holdings for snaptrade_account=#{snaptrade_account.id}" + return snaptrade_account.current_balance || 0 + end + # Calculate total from holdings + cash for accuracy # SnapTrade's current_balance can sometimes be stale or just the cash value holdings_value = calculate_holdings_value @@ -109,4 +114,24 @@ def calculate_holdings_value units * price end end + + def use_api_total_balance? + return false unless snaptrade_account.current_balance.present? + + holdings_currencies.any? { |currency| currency.present? && currency != snaptrade_account.currency } + end + + def holdings_currencies + Array(snaptrade_account.raw_holdings_payload).filter_map do |holding| + data = holding.respond_to?(:with_indifferent_access) ? holding.with_indifferent_access : {} + extract_currency(data, extract_symbol_data(data), snaptrade_account.currency) + end.uniq + end + + def extract_symbol_data(data) + symbol_wrapper = data[:symbol].is_a?(Hash) ? data[:symbol].with_indifferent_access : {} + raw_symbol_data = symbol_wrapper[:symbol] + + raw_symbol_data.is_a?(Hash) ? raw_symbol_data.with_indifferent_access : {} + end end diff --git a/app/models/trade.rb b/app/models/trade.rb index dbdb90f31..a0c6df58a 100644 --- a/app/models/trade.rb +++ b/app/models/trade.rb @@ -14,6 +14,27 @@ class Trade < ApplicationRecord validates :price, :currency, presence: true validates :investment_activity_label, inclusion: { in: ACTIVITY_LABELS }, allow_nil: true + def exchange_rate + extra&.dig("exchange_rate") + end + + def exchange_rate=(value) + if value.blank? + self.extra = (extra || {}).merge("exchange_rate" => nil, "exchange_rate_invalid" => false) + else + begin + normalized_value = Float(value) + raise ArgumentError unless normalized_value.finite? + + self.extra = (extra || {}).merge("exchange_rate" => normalized_value, "exchange_rate_invalid" => false) + rescue ArgumentError, TypeError + self.extra = (extra || {}).merge("exchange_rate" => value, "exchange_rate_invalid" => true) + end + end + end + + validate :exchange_rate_must_be_valid + # Trade types for categorization def buy? qty.positive? @@ -57,6 +78,17 @@ def excluded_from_budget? private + def exchange_rate_must_be_valid + if extra&.dig("exchange_rate_invalid") + errors.add(:exchange_rate, "must be a number") + elsif exchange_rate.present? + numeric_rate = Float(exchange_rate) rescue nil + if numeric_rate.nil? || !numeric_rate.finite? || numeric_rate <= 0 + errors.add(:exchange_rate, "must be greater than 0") + end + end + end + def calculate_realized_gain_loss return nil unless sell? diff --git a/app/models/transaction.rb b/app/models/transaction.rb index 6ebc807be..0334298d1 100644 --- a/app/models/transaction.rb +++ b/app/models/transaction.rb @@ -35,11 +35,13 @@ def exchange_rate def exchange_rate=(value) if value.blank? - self.extra = (extra || {}).merge("exchange_rate" => nil) + self.extra = (extra || {}).merge("exchange_rate" => nil, "exchange_rate_invalid" => false) else begin normalized_value = Float(value) - self.extra = (extra || {}).merge("exchange_rate" => normalized_value) + raise ArgumentError unless normalized_value.finite? + + self.extra = (extra || {}).merge("exchange_rate" => normalized_value, "exchange_rate_invalid" => false) rescue ArgumentError, TypeError # Store the raw value for validation error reporting self.extra = (extra || {}).merge("exchange_rate" => value, "exchange_rate_invalid" => true) @@ -55,9 +57,8 @@ def exchange_rate_must_be_valid if extra&.dig("exchange_rate_invalid") errors.add(:exchange_rate, "must be a number") elsif exchange_rate.present? - # Convert to float for comparison - numeric_rate = exchange_rate.to_d rescue nil - if numeric_rate.nil? || numeric_rate <= 0 + numeric_rate = Float(exchange_rate) rescue nil + if numeric_rate.nil? || !numeric_rate.finite? || numeric_rate <= 0 errors.add(:exchange_rate, "must be greater than 0") end end @@ -151,6 +152,24 @@ def pending? false end + def activity_security_id + extra&.dig("security_id").presence || extra&.dig("security", "id").presence + end + + def activity_security + security_id = activity_security_id.to_s + return @activity_security = nil if security_id.blank? + return @activity_security if defined?(@activity_security_id) && @activity_security_id == security_id + + @activity_security_id = security_id + @activity_security = Security.find_by(id: security_id) + end + + def set_preloaded_activity_security(security) + @activity_security_id = security&.id&.to_s + @activity_security = security + end + # Potential duplicate matching methods # These help users review and resolve fuzzy-matched pending/posted pairs diff --git a/app/models/transaction/activity_security_preloader.rb b/app/models/transaction/activity_security_preloader.rb new file mode 100644 index 000000000..2939cf29d --- /dev/null +++ b/app/models/transaction/activity_security_preloader.rb @@ -0,0 +1,36 @@ +class Transaction::ActivitySecurityPreloader + def initialize(records) + @records = Array(records) + end + + def preload + transactions.each do |transaction| + transaction.set_preloaded_activity_security(securities_by_id[transaction.activity_security_id.to_s]) + end + + records + end + + private + attr_reader :records + + def transactions + @transactions ||= records.filter_map do |record| + case record + when Transaction + record + when Entry + record.transaction? ? record.entryable : nil + end + end + end + + def securities_by_id + @securities_by_id ||= begin + security_ids = transactions.filter_map(&:activity_security_id).uniq + return {} if security_ids.empty? + + Security.where(id: security_ids).index_by { |security| security.id.to_s } + end + end +end diff --git a/app/views/accounts/index.html.erb b/app/views/accounts/index.html.erb index 2dcd38118..bd9d4b3af 100644 --- a/app/views/accounts/index.html.erb +++ b/app/views/accounts/index.html.erb @@ -17,7 +17,7 @@ ) %> <% end %> -<% 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? && @sophtron_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? && @ibkr_items.empty? && @indexa_capital_items.empty? && @sophtron_items.empty? %> <%= render "empty" %> <% else %>
@@ -57,6 +57,10 @@ <%= render @snaptrade_items.sort_by(&:created_at) %> <% end %> + <% if @ibkr_items.any? %> + <%= render @ibkr_items.sort_by(&:created_at) %> + <% end %> + <% if @indexa_capital_items.any? %> <%= render @indexa_capital_items.sort_by(&:created_at) %> <% end %> diff --git a/app/views/holdings/_cash.html.erb b/app/views/holdings/_cash.html.erb index 6fde392d7..72e8ceaf4 100644 --- a/app/views/holdings/_cash.html.erb +++ b/app/views/holdings/_cash.html.erb @@ -29,7 +29,7 @@
- <%= tag.p format_money account.cash_balance_money %> + <%= tag.p format_money(account.cash_balance_money), class: "privacy-sensitive" %>
diff --git a/app/views/holdings/_cost_basis_cell.html.erb b/app/views/holdings/_cost_basis_cell.html.erb index 7bc19c237..8d595baee 100644 --- a/app/views/holdings/_cost_basis_cell.html.erb +++ b/app/views/holdings/_cost_basis_cell.html.erb @@ -12,7 +12,7 @@ <% if holding.cost_basis_locked? && !editable %> <%# Locked and not editable (from holdings list) - just show value, right-aligned %>
- <%= tag.span format_money(holding.avg_cost) %> + <%= tag.span format_money(holding.avg_cost), class: "privacy-sensitive" %> <%= icon "lock", size: "xs", class: "text-secondary" %>
<% else %> @@ -21,7 +21,7 @@ <% menu.with_button(class: "hover:text-primary cursor-pointer group") do %> <% if holding.avg_cost %>
- <%= tag.span format_money(holding.avg_cost) %> + <%= tag.span format_money(holding.avg_cost), class: "privacy-sensitive" %> <% if holding.cost_basis_locked? %> <%= icon "lock", size: "xs", class: "text-secondary" %> <% end %> diff --git a/app/views/holdings/_holding.html.erb b/app/views/holdings/_holding.html.erb index 8d6adbfdb..533b2a7a6 100644 --- a/app/views/holdings/_holding.html.erb +++ b/app/views/holdings/_holding.html.erb @@ -39,7 +39,7 @@ <% else %> <%= tag.p "--", class: "text-secondary" %> <% end %> - <%= tag.p t(".shares", qty: format_quantity(holding.qty)), class: "font-normal text-secondary" %> + <%= tag.p t(".shares", qty: format_quantity(holding.qty)), class: "font-normal text-secondary privacy-sensitive" %>
diff --git a/app/views/ibkr_items/_ibkr_item.html.erb b/app/views/ibkr_items/_ibkr_item.html.erb new file mode 100644 index 000000000..511550b5e --- /dev/null +++ b/app/views/ibkr_items/_ibkr_item.html.erb @@ -0,0 +1,114 @@ +<%# locals: (ibkr_item:) %> + +<%= tag.div id: dom_id(ibkr_item) do %> + <% unlinked_count = ibkr_item.unlinked_accounts_count %> + +
+ +
+ <%= icon "chevron-right", class: "group-open:transform group-open:rotate-90" %> + +
+ IB +
+ +
+
+ <%= tag.p ibkr_item.institution_display_name, class: "font-medium text-primary" %> + <% if ibkr_item.scheduled_for_deletion? %> +

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

+ <% end %> +
+

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

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

+ <% if ibkr_item.last_synced_at %> + <%= t(".synced", time: time_ago_in_words(ibkr_item.last_synced_at), summary: ibkr_item.sync_status_summary) %> + <% else %> + <%= t(".never_synced") %> + <% end %> +

+ <% end %> +
+
+ + <% if Current.user&.admin? %> +
+ <%= icon( + "refresh-cw", + as_button: true, + href: sync_ibkr_item_path(ibkr_item), + disabled: ibkr_item.syncing? + ) %> + + <%= render DS::Menu.new do |menu| %> + <% if unlinked_count > 0 %> + <% menu.with_item( + variant: "link", + text: t(".setup_accounts"), + icon: "settings", + href: setup_accounts_ibkr_item_path(ibkr_item), + frame: :modal + ) %> + <% end %> + <% menu.with_item( + variant: "button", + text: t(".delete"), + icon: "trash-2", + href: ibkr_item_path(ibkr_item), + method: :delete, + confirm: CustomConfirm.for_resource_deletion(ibkr_item.institution_display_name, high_severity: true) + ) %> + <% end %> +
+ <% end %> +
+ + <% unless ibkr_item.scheduled_for_deletion? %> +
+ <% if ibkr_item.accounts.any? %> + <%= render "accounts/index/account_groups", accounts: ibkr_item.accounts %> + <% end %> + + <% stats = ibkr_item.syncs.ordered.first&.sync_stats || {} %> + <%= render ProviderSyncSummary.new(stats: stats, provider_item: ibkr_item) %> + + <% if Current.user&.admin? %> + <% if unlinked_count > 0 && ibkr_item.accounts.empty? %> +
+

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

+

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

+ <%= render DS::Link.new( + text: t(".setup_accounts"), + icon: "settings", + variant: "primary", + href: setup_accounts_ibkr_item_path(ibkr_item), + frame: :modal + ) %> +
+ <% elsif ibkr_item.ibkr_accounts.none? %> +
+

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

+

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

+
+ <% end %> + <% end %> +
+ <% end %> +
+<% end %> diff --git a/app/views/ibkr_items/select_existing_account.html.erb b/app/views/ibkr_items/select_existing_account.html.erb new file mode 100644 index 000000000..3598da843 --- /dev/null +++ b/app/views/ibkr_items/select_existing_account.html.erb @@ -0,0 +1,39 @@ +<%= turbo_frame_tag "modal" do %> + <%= render DS::Dialog.new do |dialog| %> + <% dialog.with_header(title: "Link Interactive Brokers account") %> + + <% dialog.with_body do %> + <% if @available_ibkr_accounts.blank? %> +
+

No unlinked Interactive Brokers accounts are available yet.

+
    +
  • Run a sync from Settings > Providers after updating your Flex query.
  • +
  • Wait for the account discovery sync to finish.
  • +
+
+ <% else %> + <%= form_with url: link_existing_account_ibkr_items_path, method: :post, data: { turbo_frame: "_top" }, class: "space-y-4" do %> + <%= hidden_field_tag :account_id, @account.id %> +
+ <% @available_ibkr_accounts.each do |ibkr_account| %> + + <% end %> +
+ +
+ <%= render DS::Button.new(text: "Link", variant: :primary, icon: "link-2", type: :submit) %> + <%= render DS::Link.new(text: "Cancel", variant: :secondary, href: accounts_path, data: { turbo_frame: "_top" }) %> +
+ <% end %> + <% end %> + <% end %> + <% end %> +<% end %> diff --git a/app/views/ibkr_items/setup_accounts.html.erb b/app/views/ibkr_items/setup_accounts.html.erb new file mode 100644 index 000000000..80fb4d1e3 --- /dev/null +++ b/app/views/ibkr_items/setup_accounts.html.erb @@ -0,0 +1,174 @@ +<% content_for :title, t(".page_title") %> + +<%= render DS::Dialog.new(disable_click_outside: true) do |dialog| %> + <% dialog.with_header(title: t(".dialog_title")) do %> +
+ <%= icon "chart-line", class: "text-primary" %> + <%= t(".subtitle") %> +
+ <% end %> + + <% dialog.with_body do %> +
+
+
+ <%= icon "info", size: "sm", class: "text-primary mt-0.5 flex-shrink-0" %> +
+

<%= t(".info_box.title") %>

+
    +
  • <%= t(".info_box.items.item_1") %>
  • +
  • <%= t(".info_box.items.item_2") %>
  • +
  • <%= t(".info_box.items.item_3") %>
  • +
+

+ <%= icon "alert-triangle", size: "xs", class: "inline-block mr-1" %> + <%= t(".info_box.warning") %> +

+
+
+
+ + <% if @waiting_for_sync %> +
+
+

<%= t(".status.fetching_accounts") %>

+
+
+ <%= render DS::Link.new( + text: t(".buttons.refresh"), + variant: "secondary", + icon: "refresh-cw", + href: setup_accounts_ibkr_item_path(@ibkr_item), + frame: "_top" + ) %> + <%= render DS::Link.new( + text: t(".buttons.cancel"), + variant: "ghost", + href: accounts_path, + frame: "_top" + ) %> +
+ <% elsif @no_accounts_found %> +
+ <%= icon "alert-circle", size: "lg", class: "text-warning" %> +

<%= t(".status.no_accounts_found_title") %>

+

<%= t(".status.no_accounts_found_description") %>

+
+
+ <%= render DS::Link.new( + text: t(".buttons.back_to_settings"), + variant: "secondary", + href: settings_providers_path, + frame: "_top" + ) %> +
+ <% else %> + <%= form_with url: complete_account_setup_ibkr_item_path(@ibkr_item), method: :post, data: { turbo_frame: "_top" } do %> + <% if @unlinked_accounts.any? %> +
+

<%= t(".available_accounts.title") %>

+ + <% @unlinked_accounts.each do |ibkr_account| %> +
+
+ + +
+
+ <% end %> +
+ +
+ <%= render DS::Button.new( + text: t(".buttons.create_selected_accounts"), + variant: "primary", + icon: "plus", + type: "submit", + class: "flex-1" + ) %> + <%= render DS::Link.new( + text: t(".buttons.cancel"), + variant: "secondary", + href: accounts_path, + frame: "_top" + ) %> +
+ <% end %> + <% end %> + + <% if @unlinked_accounts.any? && @linkable_accounts.any? %> +
+

<%= t(".link_existing.description") %>

+
+ <% @unlinked_accounts.each do |ibkr_account| %> + <%= form_with url: link_existing_account_ibkr_items_path, method: :post, data: { turbo_frame: "_top" } do |link_form| %> + <%= link_form.hidden_field :ibkr_account_id, value: ibkr_account.id %> +

<%= ibkr_account.name %>

+
+ <%= link_form.select :account_id, + options_for_select(@linkable_accounts.map { |account| [t(".link_existing.manual_account_option", name: account.name, balance: number_to_currency(account.balance, unit: Money::Currency.new(account.currency || 'USD').symbol)), account.id] }), + { prompt: t(".link_existing.select_prompt") }, + class: "bg-container border border-primary rounded px-2 py-1 text-sm text-primary flex-1 min-w-0" %> + <%= render DS::Button.new( + text: t(".buttons.link"), + variant: "secondary", + size: "sm", + type: "submit" + ) %> +
+ <% end %> + <% end %> +
+
+ <% end %> + + <% if @linked_accounts.any? %> +
+

<%= t(".linked_accounts.title") %>

+ <% @linked_accounts.each do |ibkr_account| %> +
+
+
+ <%= icon "check-circle", class: "text-success" %> +
+

<%= ibkr_account.name %>

+

<%= t(".linked_accounts.linked_to_html", account: link_to(ibkr_account.current_account.name, account_path(ibkr_account.current_account), class: "link")) %>

+
+
+
+
+ <% end %> +
+ + <% if @unlinked_accounts.blank? %> +
+ <%= render DS::Link.new( + text: t(".buttons.done"), + variant: "primary", + href: accounts_path, + frame: "_top" + ) %> +
+ <% end %> + <% end %> + <% end %> +
+ <% end %> +<% end %> diff --git a/app/views/settings/providers/_ibkr_panel.html.erb b/app/views/settings/providers/_ibkr_panel.html.erb new file mode 100644 index 000000000..d20d03f31 --- /dev/null +++ b/app/views/settings/providers/_ibkr_panel.html.erb @@ -0,0 +1,152 @@ +
+ <% error_msg = local_assigns[:error_message] || @error_message %> + <% if error_msg.present? %> + <%= render DS::Alert.new(message: error_msg, variant: :error) %> + <% end %> + + <%= render "settings/providers/setup_steps", + steps: [ + t(".steps.step_1"), + t(".steps.step_2"), + t(".steps.step_3"), + t(".steps.step_4"), + t(".steps.step_5") + ] %> + +
+ +
+
+

<%= t(".flex_query_details.eyebrow") %>

+

<%= t(".flex_query_details.title") %>

+

<%= t(".flex_query_details.summary") %>

+
+ <%= icon "chevron-down", class: "mt-0.5 text-secondary transition-transform group-open:rotate-180" %> +
+
+ +
+
+

<%= t(".flex_query_details.sections_heading") %>

+
    +
  • + <%= t(".sections.account_information") %> +
  • +
  • + <%= t(".sections.cash_report") %> +
      +
    • <%= t(".sections.cash_report_options") %>
    • +
    • <%= t(".sections.cash_report_fields") %>
    • +
    +
  • +
  • + <%= t(".sections.cash_transactions") %> +
      +
    • <%= t(".sections.cash_transactions_options") %>
    • +
    • <%= t(".sections.cash_transactions_fields") %>
    • +
    +
  • +
  • + <%= t(".sections.change_in_position_value_summary") %> +
  • +
  • + <%= t(".sections.net_asset_value") %> +
      +
    • <%= t(".sections.net_asset_value_options") %>
    • +
    • <%= t(".sections.net_asset_value_fields") %>
    • +
    +
  • +
  • + <%= t(".sections.open_positions") %> +
      +
    • <%= t(".sections.open_positions_options") %>
    • +
    • <%= t(".sections.open_positions_fields") %>
    • +
    +
  • +
  • + <%= t(".sections.trades") %> +
      +
    • <%= t(".sections.trades_options") %>
    • +
    • <%= t(".sections.trades_fields") %>
    • +
    +
  • +
+
+ +
+

<%= t(".flex_query_details.configuration_heading") %>

+
    +
  • <%= t(".configuration.models") %>
  • +
  • <%= t(".configuration.format") %>
  • +
  • <%= t(".configuration.period") %>
  • +
  • <%= t(".configuration.date_format") %>
  • +
  • <%= t(".configuration.time_format") %>
  • +
  • <%= t(".configuration.date_time_separator") %>
  • +
  • <%= t(".configuration.profit_and_loss") %>
  • +
  • <%= t(".configuration.all_other_options") %>
  • +
+
+ +

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

+
+
+ + <% + ibkr_item = Current.family.ibkr_items.first_or_initialize(name: "Interactive Brokers") + is_new_record = ibkr_item.new_record? + %> + + <% if ibkr_item.persisted? %> +
+ <%= button_to sync_ibkr_item_path(ibkr_item), + method: :post, + class: "inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium text-secondary hover:text-primary border border-secondary rounded-lg hover:border-primary", + disabled: ibkr_item.syncing? do %> + <%= icon "refresh-cw", size: "sm" %> + <%= t(".sync") %> + <% end %> + + <%= button_to ibkr_item_path(ibkr_item), + method: :delete, + class: "inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium text-destructive hover:bg-destructive/10 rounded-lg", + data: { turbo_confirm: t(".disconnect_confirm") } do %> + <%= icon "trash-2", size: "sm" %> + <% end %> +
+ <% end %> + + <%= styled_form_with model: ibkr_item, + url: is_new_record ? ibkr_items_path : ibkr_item_path(ibkr_item), + scope: :ibkr_item, + method: is_new_record ? :post : :patch, + data: { turbo: true }, + class: "space-y-3" do |form| %> + <%= form.text_field :query_id, + label: t(".query_id_label"), + placeholder: is_new_record ? t(".query_id_placeholder_new") : t(".query_id_placeholder_existing"), + type: :password %> + + <%= form.text_field :token, + label: t(".token_label"), + placeholder: is_new_record ? t(".token_placeholder_new") : t(".token_placeholder_existing"), + type: :password %> + +
+ <%= form.submit(is_new_record ? t(".save_configuration") : t(".update_configuration"), class: "btn btn--primary") %> +
+ <% end %> + +
+ <% if ibkr_item.persisted? && ibkr_item.credentials_configured? %> +
+

+ <%= t(".status_configured_prefix", summary: ibkr_item.sync_status_summary) %> + <%= link_to t(".accounts_tab"), accounts_path, class: "link" %> + <%= t(".status_configured_suffix") %> +

+ <% else %> +
+

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

+ <% end %> +
+
diff --git a/app/views/trades/_trade.html.erb b/app/views/trades/_trade.html.erb index 04b67e645..162a7da09 100644 --- a/app/views/trades/_trade.html.erb +++ b/app/views/trades/_trade.html.erb @@ -1,6 +1,7 @@ <%# locals: (entry:, balance_trend: nil, **) %> <% trade = entry.entryable %> +<% trade_logo_url = trade.security&.display_logo_url %> <%= turbo_frame_tag dom_id(entry) do %> <%= turbo_frame_tag dom_id(trade) do %> @@ -17,14 +18,20 @@ } %>
- <%= tag.div class: ["flex items-center gap-2 min-w-0"] do %> + <%= tag.div class: ["flex items-center gap-3 lg:gap-4 min-w-0"] do %>
diff --git a/app/views/transactions/_transaction.html.erb b/app/views/transactions/_transaction.html.erb index 8ee5761c1..659591bff 100644 --- a/app/views/transactions/_transaction.html.erb +++ b/app/views/transactions/_transaction.html.erb @@ -1,6 +1,7 @@ <%# locals: (entry:, balance_trend: nil, view_ctx: "global", in_split_group: false) %> <% transaction = entry.entryable %> +<% transaction_security_logo_url = transaction.activity_security&.display_logo_url %> <%= turbo_frame_tag dom_id(entry) do %> <%= turbo_frame_tag dom_id(transaction) do %> @@ -20,7 +21,11 @@
<%= render "transactions/transaction_category", transaction: transaction, variant: "mobile", in_split_group: in_split_group %> - <% if transaction.merchant&.logo_url.present? %> + <% if transaction_security_logo_url.present? %> + <%= image_tag Setting.transform_brand_fetch_url(transaction_security_logo_url), + class: "w-5 h-5 rounded-full absolute -bottom-1 -right-1 border border-secondary pointer-events-none", + loading: "lazy" %> + <% elsif transaction.merchant&.logo_url.present? %> <%= image_tag Setting.transform_brand_fetch_url(transaction.merchant.logo_url), class: "w-5 h-5 rounded-full absolute -bottom-1 -right-1 border border-secondary pointer-events-none", loading: "lazy" %> @@ -37,6 +42,10 @@ size: "lg", rounded: true ) %> + <% elsif transaction_security_logo_url.present? %> + <%= image_tag Setting.transform_brand_fetch_url(transaction_security_logo_url), + class: "w-9 h-9 rounded-full border border-secondary", + loading: "lazy" %> <% elsif transaction.merchant&.logo_url.present? %> <%= image_tag Setting.transform_brand_fetch_url(transaction.merchant.logo_url), class: "w-9 h-9 rounded-full border border-secondary", diff --git a/config/locales/views/ibkr_items/en.yml b/config/locales/views/ibkr_items/en.yml new file mode 100644 index 000000000..a5d3bae44 --- /dev/null +++ b/config/locales/views/ibkr_items/en.yml @@ -0,0 +1,84 @@ +--- +en: + providers: + ibkr: + name: Interactive Brokers + connection_description: Connect an Interactive Brokers Flex Web Service report + institution_name: Interactive Brokers + ibkr_items: + defaults: + name: Interactive Brokers + ibkr_item: + deletion_in_progress: Deletion in progress + flex_web_service: Flex Web Service + syncing: Syncing + requires_update: Credentials need attention + error: Error + synced: Synced %{time} ago. %{summary}. + never_synced: Never synced. + setup_accounts: Set up accounts + delete: Delete + accounts_need_setup: Accounts need setup + accounts_need_setup_description: Some accounts from IBKR need to be linked to Sure accounts. + no_accounts_discovered: No IBKR accounts discovered yet. + no_accounts_discovered_description: Run a sync after configuring your Flex query to discover accounts. + setup_accounts: + page_title: Set Up Interactive Brokers Accounts + dialog_title: Set Up Your Interactive Brokers Accounts + subtitle: Select which IBKR brokerage accounts to link. + info_box: + title: IBKR Flex Query Import + items: + item_1: Holdings with current prices and quantities + item_2: Cost basis per position + item_3: Trades, dividends, commissions, and cash deposits or withdrawals + warning: Historical activity is limited to the report window of the Flex Query + status: + fetching_accounts: Fetching accounts from Interactive Brokers... + no_accounts_found_title: No accounts found. + no_accounts_found_description: Sure could not find any IBKR accounts in the latest Flex report. + available_accounts: + title: Available accounts + account_type_investment: Investment + account_summary: "%{account_type} • Balance: %{balance}" + account_id: "Account ID: %{account_id}" + link_existing: + description: Or link a discovered IBKR account to an existing manual investment account. + manual_account_option: "%{name} (%{balance})" + select_prompt: Select an account... + linked_accounts: + title: Already linked + linked_to_html: "Linked to: %{account}" + buttons: + refresh: Refresh + cancel: Cancel + back_to_settings: Back to Settings + create_selected_accounts: Create selected accounts + link: Link + done: Done + sync_status: + no_accounts: No IBKR accounts discovered yet + all_linked: + one: 1 account linked + other: "%{count} accounts linked" + partial: "%{linked} linked, %{unlinked} need setup" + create: + success: Successfully configured Interactive Brokers. + update: + success: Successfully updated Interactive Brokers configuration. + destroy: + success: Scheduled Interactive Brokers connection for deletion. + select_accounts: + not_configured: Interactive Brokers is not configured. + link_existing_account: + not_found: Account or Interactive Brokers configuration not found. + only_manual_investment: Only manual investment accounts can be linked to Interactive Brokers. + already_linked: This Interactive Brokers account is already linked. + success: Successfully linked to Interactive Brokers account. + failed: Failed to link Interactive Brokers account. + complete_account_setup: + success: + one: Successfully created %{count} Interactive Brokers account. + other: Successfully created %{count} Interactive Brokers accounts. + none_selected: No accounts were selected. + none_created: No accounts were created. diff --git a/config/locales/views/settings/en.yml b/config/locales/views/settings/en.yml index 87114313b..13887befc 100644 --- a/config/locales/views/settings/en.yml +++ b/config/locales/views/settings/en.yml @@ -237,6 +237,7 @@ en: binance: Sync your Binance spot balances using a read-only API key. kraken: Sync Kraken balances and spot trade fills using a read-only API key. snaptrade: Connect brokerage accounts via the SnapTrade aggregation network. + ibkr: Sync Interactive Brokers investment accounts via Flex Query imports. indexa_capital: Track your Indexa Capital automated investment portfolio. sophtron: Connect US & Canadian banks and utilities. plaid: Connect thousands of US financial institutions via Plaid. @@ -329,3 +330,58 @@ en: step_1_html: "Open the %{link} and copy your EU Client ID and Secret Key." not_found: Provider not found. sync_provider_no_items: No connections available to sync. + ibkr_panel: + steps: + step_1: 'In your IBKR Client Portal, navigate to "Performance & Reports" > "Flex Queries".' + step_2: 'Click the "+" icon in the "Activity Flex Query" section to create a new query.' + step_3: 'Name your query (e.g., "Sure Sync"), then review the Flex Query details below and enable the listed sections, fields, and configuration options.' + step_4: 'Save the query, note your "Query ID", then use the gear icon in the "Flex Web Service Configuration" section to generate an access Token.' + step_5: "Paste your Query ID and Token below, save the configuration, then head to Accounts to link discovered IBKR accounts." + flex_query_details: + eyebrow: Flex Query + title: Sections, fields, and configuration + summary: Expand to see the exact sections, fields, and settings your IBKR Activity Flex Query must include. + sections_heading: Enable these sections and fields + configuration_heading: Set these query options + sections: + account_information: "Account Information: Account ID, Currency" + cash_report: "Cash Report:" + cash_report_options: "Options: None" + cash_report_fields: "Fields: Currency, Ending Cash" + cash_transactions: "Cash Transactions:" + cash_transactions_options: "Options: Dividends, Deposits & Withdrawals, Detail" + cash_transactions_fields: "Fields: Amount, Conid, Currency, FX Rate To Base, Report Date, Transaction ID, Type" + change_in_position_value_summary: "Change In Position Value Summary: Currency, End Of Period Value" + net_asset_value: "Net Asset Value (NAV) in Base:" + net_asset_value_options: "Options: None" + net_asset_value_fields: "Fields: Currency, Report Date, Total" + open_positions: "Open Positions:" + open_positions_options: "Options: Summary" + open_positions_fields: "Fields: Asset Class, Conid, Cost Basis Price, Currency, FX Rate To Base, Mark Price, Quantity, Report Date, Security ID, Security ID Type, Side, Symbol" + trades: "Trades:" + trades_options: "Options: Execution" + trades_fields: "Fields: Asset Class, Buy/Sell, Conid, Currency, FX Rate To Base, IB Commission, IB Commission Currency, Quantity, Symbol, Trade Date, Trade ID, TradePrice, Transaction ID" + configuration: + models: "Models: Optional" + format: "Format: XML" + period: "Period: Last 365 Calendar Days" + date_format: "Date Format: yyyy-MM-dd" + time_format: "Time Format: HH:mm:ss" + date_time_separator: "Date/Time Separator: ; (semi-colon)" + profit_and_loss: "Profit and Loss: Default" + all_other_options: 'All other configuration options: "No"' + report_window_note: "IBKR Flex reports are limited to the query window you configured in IBKR. Sure will import full current holdings plus up to the last 365 days of activity from this report." + sync: Sync + disconnect_confirm: Disconnect Interactive Brokers? + query_id_label: Query ID + query_id_placeholder_new: Enter your IBKR Flex Query ID + query_id_placeholder_existing: Leave blank to keep the current Query ID + token_label: Token + token_placeholder_new: Enter your IBKR Flex Web Service Token + token_placeholder_existing: Leave blank to keep the current Token + save_configuration: Save Configuration + update_configuration: Update Configuration + status_configured_prefix: "%{summary}. Visit the" + accounts_tab: Accounts + status_configured_suffix: tab to manage discovered accounts. + not_configured: Not configured. diff --git a/config/routes.rb b/config/routes.rb index a9fee8dd1..ba00f86f4 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -100,6 +100,20 @@ end end + resources :ibkr_items, only: [ :create, :update, :destroy ] do + collection do + get :select_accounts + get :select_existing_account + post :link_existing_account + end + + member do + post :sync + get :setup_accounts + post :complete_account_setup + end + end + # CoinStats routes resources :coinstats_items, only: [ :index, :new, :create, :update, :destroy ] do collection do diff --git a/db/migrate/20260512210600_create_ibkr_items_and_accounts.rb b/db/migrate/20260512210600_create_ibkr_items_and_accounts.rb new file mode 100644 index 000000000..b1daaeb2b --- /dev/null +++ b/db/migrate/20260512210600_create_ibkr_items_and_accounts.rb @@ -0,0 +1,41 @@ +class CreateIbkrItemsAndAccounts < ActiveRecord::Migration[7.2] + def change + create_table :ibkr_items, id: :uuid do |t| + t.references :family, null: false, foreign_key: true, type: :uuid + t.string :name + t.string :status, default: "good", null: false + t.boolean :scheduled_for_deletion, default: false, null: false + t.boolean :pending_account_setup, default: false, null: false + t.jsonb :raw_payload + t.string :query_id + t.string :token + + t.timestamps + end + + add_index :ibkr_items, :status + + create_table :ibkr_accounts, id: :uuid do |t| + t.references :ibkr_item, null: false, foreign_key: true, type: :uuid + t.string :name + t.string :ibkr_account_id + t.string :currency + t.decimal :current_balance, precision: 19, scale: 4 + t.decimal :cash_balance, precision: 19, scale: 4 + t.jsonb :institution_metadata + t.jsonb :raw_holdings_payload, default: [], null: false + t.jsonb :raw_activities_payload, default: {}, null: false + t.jsonb :raw_cash_report_payload, default: [], null: false + t.date :report_date + t.datetime :last_holdings_sync + t.datetime :last_activities_sync + + t.timestamps + end + + add_index :ibkr_accounts, [ :ibkr_item_id, :ibkr_account_id ], + unique: true, + where: "(ibkr_account_id IS NOT NULL)", + name: "index_ibkr_accounts_on_item_and_ibkr_account_id" + end +end diff --git a/db/migrate/20260512211000_add_extra_to_trades.rb b/db/migrate/20260512211000_add_extra_to_trades.rb new file mode 100644 index 000000000..2e27be775 --- /dev/null +++ b/db/migrate/20260512211000_add_extra_to_trades.rb @@ -0,0 +1,6 @@ +class AddExtraToTrades < ActiveRecord::Migration[7.2] + def change + add_column :trades, :extra, :jsonb, default: {}, null: false + add_index :trades, :extra, using: :gin + end +end diff --git a/db/migrate/20260512211200_add_raw_equity_summary_payload_to_ibkr_accounts.rb b/db/migrate/20260512211200_add_raw_equity_summary_payload_to_ibkr_accounts.rb new file mode 100644 index 000000000..d3f9e7969 --- /dev/null +++ b/db/migrate/20260512211200_add_raw_equity_summary_payload_to_ibkr_accounts.rb @@ -0,0 +1,5 @@ +class AddRawEquitySummaryPayloadToIbkrAccounts < ActiveRecord::Migration[7.2] + def change + add_column :ibkr_accounts, :raw_equity_summary_payload, :jsonb, default: [], null: false + end +end diff --git a/db/schema.rb b/db/schema.rb index ecd756f91..f013a0ed2 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2026_05_11_090000) do +ActiveRecord::Schema[7.2].define(version: 2026_05_12_211200) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -659,6 +659,42 @@ t.index ["security_id"], name: "index_holdings_on_security_id" end + create_table "ibkr_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "ibkr_item_id", null: false + t.string "name" + t.string "ibkr_account_id" + t.string "currency" + t.decimal "current_balance", precision: 19, scale: 4 + t.decimal "cash_balance", precision: 19, scale: 4 + t.jsonb "institution_metadata" + t.jsonb "raw_holdings_payload", default: [] + t.jsonb "raw_activities_payload", default: {} + t.jsonb "raw_cash_report_payload", default: [] + t.date "report_date" + t.datetime "last_holdings_sync" + t.datetime "last_activities_sync" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.jsonb "raw_equity_summary_payload", default: [], null: false + t.index ["ibkr_item_id", "ibkr_account_id"], name: "index_ibkr_accounts_on_item_and_ibkr_account_id", unique: true, where: "(ibkr_account_id IS NOT NULL)" + t.index ["ibkr_item_id"], name: "index_ibkr_accounts_on_ibkr_item_id" + end + + create_table "ibkr_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "family_id", null: false + t.string "name" + t.string "status", default: "good" + t.boolean "scheduled_for_deletion", default: false + t.boolean "pending_account_setup", default: false, null: false + t.jsonb "raw_payload" + t.string "query_id" + t.string "token" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["family_id"], name: "index_ibkr_items_on_family_id" + t.index ["status"], name: "index_ibkr_items_on_status" + end + create_table "impersonation_session_logs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.uuid "impersonation_session_id", null: false t.string "controller" @@ -1603,6 +1639,8 @@ t.jsonb "locked_attributes", default: {} t.string "investment_activity_label" t.decimal "fee", precision: 19, scale: 4, default: "0.0", null: false + t.jsonb "extra", default: {}, null: false + t.index ["extra"], name: "index_trades_on_extra", using: :gin t.index ["investment_activity_label"], name: "index_trades_on_investment_activity_label" t.index ["security_id"], name: "index_trades_on_security_id" end @@ -1709,9 +1747,9 @@ t.datetime "last_used_at" t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.check_constraint "sign_count >= 0", name: "chk_webauthn_credentials_sign_count_non_negative" t.index ["credential_id"], name: "index_webauthn_credentials_on_credential_id", unique: true t.index ["user_id"], name: "index_webauthn_credentials_on_user_id" + t.check_constraint "sign_count >= 0", name: "chk_webauthn_credentials_sign_count_non_negative" end add_foreign_key "account_providers", "accounts", on_delete: :cascade @@ -1754,6 +1792,8 @@ add_foreign_key "holdings", "accounts", on_delete: :cascade add_foreign_key "holdings", "securities" add_foreign_key "holdings", "securities", column: "provider_security_id" + add_foreign_key "ibkr_accounts", "ibkr_items" + add_foreign_key "ibkr_items", "families" add_foreign_key "impersonation_session_logs", "impersonation_sessions" add_foreign_key "impersonation_sessions", "users", column: "impersonated_id" add_foreign_key "impersonation_sessions", "users", column: "impersonator_id" diff --git a/test/controllers/ibkr_items_controller_test.rb b/test/controllers/ibkr_items_controller_test.rb new file mode 100644 index 000000000..fbb0bccb0 --- /dev/null +++ b/test/controllers/ibkr_items_controller_test.rb @@ -0,0 +1,100 @@ +require "test_helper" + +class IbkrItemsControllerTest < ActionDispatch::IntegrationTest + setup do + sign_in @user = users(:family_admin) + @ibkr_item = ibkr_items(:configured_item) + end + + test "select_existing_account renders available ibkr accounts" do + get select_existing_account_ibkr_items_url, params: { account_id: accounts(:investment).id } + + assert_response :success + assert_includes response.body, ibkr_accounts(:main_account).name + end + + test "create redirects to accounts on success" do + assert_difference "IbkrItem.count", 1 do + post ibkr_items_url, params: { + ibkr_item: { + query_id: "QUERYNEW", + token: "TOKENNEW" + } + } + end + + assert_redirected_to accounts_path + end + + test "update redirects to accounts on success" do + patch ibkr_item_url(@ibkr_item), params: { + ibkr_item: { + query_id: "", + token: "" + } + } + + assert_redirected_to accounts_path + end + + test "complete_account_setup creates investment account and provider link" do + assert_difference "Account.count", 1 do + assert_difference "AccountProvider.count", 1 do + post complete_account_setup_ibkr_item_url(@ibkr_item), params: { + account_ids: [ ibkr_accounts(:main_account).id ] + } + end + end + + created_account = Account.order(created_at: :desc).first + assert_equal "Investment", created_account.accountable_type + assert_equal "brokerage", created_account.accountable.subtype + assert_redirected_to accounts_path + + ibkr_accounts(:main_account).reload + assert_equal created_account, ibkr_accounts(:main_account).current_account + end + + test "link_existing_account links manual investment account" do + account = accounts(:investment) + + assert_difference "AccountProvider.count", 1 do + post link_existing_account_ibkr_items_url, params: { + account_id: account.id, + ibkr_account_id: ibkr_accounts(:main_account).id + } + end + + assert_redirected_to account_path(account) + ibkr_accounts(:main_account).reload + assert_equal account, ibkr_accounts(:main_account).current_account + end + + test "link_existing_account rejects already linked ibkr account" do + original_account = accounts(:investment) + ibkr_account = ibkr_accounts(:main_account) + AccountProvider.create!(account: original_account, provider: ibkr_account) + + replacement_account = Account.create!( + family: @ibkr_item.family, + owner: @user, + name: "Replacement Brokerage Account", + balance: 2500, + cash_balance: 2500, + currency: "USD", + accountable: Investment.create!(subtype: "brokerage") + ) + + assert_no_difference "AccountProvider.count" do + post link_existing_account_ibkr_items_url, params: { + account_id: replacement_account.id, + ibkr_account_id: ibkr_account.id + } + end + + assert_redirected_to account_path(replacement_account) + assert_equal "This Interactive Brokers account is already linked.", flash[:alert] + ibkr_account.reload + assert_equal original_account, ibkr_account.current_account + end +end diff --git a/test/controllers/settings/providers_controller_test.rb b/test/controllers/settings/providers_controller_test.rb index 00b98e893..f000bed7b 100644 --- a/test/controllers/settings/providers_controller_test.rb +++ b/test/controllers/settings/providers_controller_test.rb @@ -355,6 +355,37 @@ class Settings::ProvidersControllerTest < ActionDispatch::IntegrationTest assert_match(/Sync started/i, response.body) end + test "GET show includes Interactive Brokers in bank sync providers" do + get settings_providers_url + + assert_response :success + assert_match(/Interactive Brokers/i, response.body) + assert_match(/Flex Query/i, response.body) + end + + test "GET connect_form renders Interactive Brokers panel" do + get connect_form_settings_providers_path(provider_key: "ibkr") + + assert_response :success + assert_match(/Interactive Brokers/i, response.body) + assert_match(/Query ID/i, response.body) + end + + test "POST sync for ibkr without an active Ibkr sync enqueues SyncJob" do + item = ibkr_items(:configured_item) + Sync.where(syncable_type: "IbkrItem", syncable_id: item.id).delete_all + + assert_enqueued_jobs 1, only: SyncJob do + post sync_provider_settings_providers_path(provider_key: "ibkr") + end + + assert_redirected_to settings_providers_path + + follow_redirect! + assert_response :success + assert_match(/Sync started/i, response.body) + end + test "non-admin users cannot update providers" do with_self_hosting do sign_in users(:family_member) diff --git a/test/fixtures/files/ibkr/flex_statement.xml b/test/fixtures/files/ibkr/flex_statement.xml new file mode 100644 index 000000000..ffc2c5f7f --- /dev/null +++ b/test/fixtures/files/ibkr/flex_statement.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/fixtures/ibkr_accounts.yml b/test/fixtures/ibkr_accounts.yml new file mode 100644 index 000000000..8ef3649d3 --- /dev/null +++ b/test/fixtures/ibkr_accounts.yml @@ -0,0 +1,27 @@ +main_account: + ibkr_item: configured_item + name: Main IBKR + ibkr_account_id: U1234567 + currency: CHF + current_balance: 3351.0 + cash_balance: 1000.5 + institution_metadata: + provider_name: Interactive Brokers + raw_holdings_payload: [] + raw_activities_payload: {} + raw_cash_report_payload: [] + report_date: 2026-05-08 + +secondary_account: + ibkr_item: configured_item + name: Retirement IBKR + ibkr_account_id: U7654321 + currency: USD + current_balance: 250 + cash_balance: 250 + institution_metadata: + provider_name: Interactive Brokers + raw_holdings_payload: [] + raw_activities_payload: {} + raw_cash_report_payload: [] + report_date: 2026-05-08 diff --git a/test/fixtures/ibkr_items.yml b/test/fixtures/ibkr_items.yml new file mode 100644 index 000000000..f03973935 --- /dev/null +++ b/test/fixtures/ibkr_items.yml @@ -0,0 +1,15 @@ +configured_item: + family: dylan_family + name: Interactive Brokers + status: good + query_id: QUERY123 + token: TOKEN123 + pending_account_setup: true + +empty_item: + family: empty + name: Interactive Brokers + status: good + query_id: QUERYEMPTY + token: TOKENEMPTY + pending_account_setup: false diff --git a/test/models/account/opening_balance_manager_test.rb b/test/models/account/opening_balance_manager_test.rb index 67becb60a..78411bd00 100644 --- a/test/models/account/opening_balance_manager_test.rb +++ b/test/models/account/opening_balance_manager_test.rb @@ -192,6 +192,75 @@ class Account::OpeningBalanceManagerTest < ActiveSupport::TestCase assert_equal original_date, opening_anchor.entry.date # Should remain unchanged end + test "when existing anchor date is before later activity, update can preserve anchor date" do + manager = Account::OpeningBalanceManager.new(@depository_account) + original_date = 4.months.ago.to_date + + result = manager.set_opening_balance( + balance: 1000, + date: original_date + ) + assert result.success? + + @depository_account.entries.create!( + date: 2.months.ago.to_date, + name: "Later transaction", + amount: 100, + currency: "USD", + entryable: Transaction.new + ) + + result = manager.set_opening_balance( + balance: 0, + date: original_date + ) + + assert result.success? + assert result.changes_made? + + opening_anchor = @depository_account.valuations.opening_anchor.first + opening_anchor.reload + assert_equal 0, opening_anchor.entry.amount + assert_equal original_date, opening_anchor.entry.date + end + + test "recomputes oldest entry date when older activity is added after manager initialization" do + oldest_date = 60.days.ago.to_date + @depository_account.entries.create!( + date: oldest_date, + name: "Existing transaction", + amount: 100, + currency: "USD", + entryable: Transaction.new + ) + + manager = Account::OpeningBalanceManager.new(@depository_account) + + result = manager.set_opening_balance( + balance: 1000, + date: oldest_date - 1.day + ) + assert result.success? + + newly_oldest_date = oldest_date - 2.days + @depository_account.entries.create!( + date: newly_oldest_date, + name: "New older transaction", + amount: 50, + currency: "USD", + entryable: Transaction.new + ) + + result = manager.set_opening_balance( + balance: 1200, + date: newly_oldest_date + ) + + assert_not result.success? + assert_not result.changes_made? + assert_equal "Opening balance date must be before the oldest entry date", result.error + end + test "when date is equal to or greater than account's oldest entry, returns error result" do # Create an entry with a specific date oldest_date = 60.days.ago.to_date diff --git a/test/models/account/provider_import_adapter_test.rb b/test/models/account/provider_import_adapter_test.rb index 2f004632f..bbfa31b08 100644 --- a/test/models/account/provider_import_adapter_test.rb +++ b/test/models/account/provider_import_adapter_test.rb @@ -280,6 +280,25 @@ class Account::ProviderImportAdapterTest < ActiveSupport::TestCase end end + test "imports trade with provider exchange rate" do + investment_account = accounts(:investment) + adapter = Account::ProviderImportAdapter.new(investment_account) + security = securities(:aapl) + + entry = adapter.import_trade( + security: security, + quantity: 5, + price: 150.00, + amount: 750.00, + currency: "USD", + date: Date.today, + source: "plaid", + exchange_rate: 0.91 + ) + + assert_equal 0.91, entry.entryable.exchange_rate + end + test "raises error when security is missing for trade import" do exception = assert_raises(ArgumentError) do @adapter.import_trade( @@ -489,7 +508,8 @@ class Account::ProviderImportAdapterTest < ActiveSupport::TestCase amount: 2000.00, currency: "USD", date: Date.today, - source: "plaid" + source: "plaid", + exchange_rate: 0.95 ) assert_equal entry.id, updated_entry.id @@ -498,11 +518,46 @@ class Account::ProviderImportAdapterTest < ActiveSupport::TestCase assert_equal 10, updated_entry.entryable.qty assert_equal 200.00, updated_entry.entryable.price assert_equal "USD", updated_entry.entryable.currency + assert_equal 0.95, updated_entry.entryable.exchange_rate # Entry attributes should also be updated assert_equal 2000.00, updated_entry.amount end end + test "preserves existing exchange rate when reimport omits it" do + investment_account = accounts(:investment) + adapter = Account::ProviderImportAdapter.new(investment_account) + aapl = securities(:aapl) + + entry = adapter.import_trade( + external_id: "plaid_trade_exchange_rate_preserved", + security: aapl, + quantity: 5, + price: 150.00, + amount: 750.00, + currency: "USD", + date: Date.today, + source: "plaid", + exchange_rate: 0.95 + ) + + assert_no_difference "investment_account.entries.count" do + updated_entry = adapter.import_trade( + external_id: "plaid_trade_exchange_rate_preserved", + security: aapl, + quantity: 10, + price: 200.00, + amount: 2000.00, + currency: "USD", + date: Date.today, + source: "plaid" + ) + + assert_equal entry.id, updated_entry.id + assert_equal 0.95, updated_entry.entryable.exchange_rate + end + end + test "raises error when external_id collision occurs across different entryable types for transaction" do investment_account = accounts(:investment) adapter = Account::ProviderImportAdapter.new(investment_account) diff --git a/test/models/account/syncer_test.rb b/test/models/account/syncer_test.rb new file mode 100644 index 000000000..6eeaec117 --- /dev/null +++ b/test/models/account/syncer_test.rb @@ -0,0 +1,31 @@ +require "test_helper" +require "ostruct" + +class Account::SyncerTest < ActiveSupport::TestCase + test "applies IBKR historical balance overrides after materialization" do + family = families(:empty) + account = family.accounts.create!( + name: "IBKR Brokerage", + balance: 0, + cash_balance: 0, + currency: "CHF", + accountable: Investment.new(subtype: "brokerage") + ) + ibkr_account = family.ibkr_items.create!( + name: "IBKR", + query_id: "QUERY123", + token: "TOKEN123" + ).ibkr_accounts.create!( + name: "Main", + ibkr_account_id: "U1234567", + currency: "CHF" + ) + ibkr_account.ensure_account_provider!(account) + + Account::MarketDataImporter.any_instance.expects(:import_all).once + Balance::Materializer.any_instance.expects(:materialize_balances).once + IbkrAccount::HistoricalBalancesSync.any_instance.expects(:sync!).once + + Account::Syncer.new(account).perform_sync(OpenStruct.new(window_start_date: nil)) + end +end diff --git a/test/models/account_ibkr_creation_test.rb b/test/models/account_ibkr_creation_test.rb new file mode 100644 index 000000000..2ab8f6fcf --- /dev/null +++ b/test/models/account_ibkr_creation_test.rb @@ -0,0 +1,30 @@ +require "test_helper" + +class AccountIbkrCreationTest < ActiveSupport::TestCase + fixtures :families, :ibkr_items, :ibkr_accounts + + test "uses interactive brokers account id as part of the default name" do + ibkr_account = ibkr_accounts(:main_account) + + account = Account.create_from_ibkr_account(ibkr_account) + + assert_equal "Interactive Brokers (U1234567)", account.name + assert_equal "Investment", account.accountable_type + assert_equal "CHF", account.currency + end + + test "falls back to provider name when ibkr account id is missing" do + family = families(:empty) + ibkr_item = ibkr_items(:empty_item) + ibkr_account = ibkr_item.ibkr_accounts.create!( + name: "Imported IBKR Account", + ibkr_account_id: nil, + currency: "USD" + ) + + account = Account.create_from_ibkr_account(ibkr_account) + + assert_equal "Interactive Brokers", account.name + assert_equal family, account.family + end +end diff --git a/test/models/account_test.rb b/test/models/account_test.rb index 48bfbb6c3..766b5ab2f 100644 --- a/test/models/account_test.rb +++ b/test/models/account_test.rb @@ -288,4 +288,55 @@ class AccountTest < ActiveSupport::TestCase assert_equal "read_write", share.permission assert share.include_in_finances? end + + test "current_holdings prefers latest provider snapshot holdings across currencies" do + account = @family.accounts.create!( + owner: @admin, + name: "Linked Brokerage", + balance: 1000, + currency: "USD", + accountable: Investment.new + ) + + coinstats_item = @family.coinstats_items.create!(name: "CoinStats", api_key: "test-key") + coinstats_account = coinstats_item.coinstats_accounts.create!(name: "Brokerage", currency: "USD") + account_provider = AccountProvider.create!(account: account, provider: coinstats_account) + + eur_security = Security.create!(ticker: "ASML", name: "ASML") + chf_security = Security.create!(ticker: "NOVN", name: "Novartis") + + provider_holding = account.holdings.create!( + security: eur_security, + date: Date.current, + qty: 2, + price: 500, + amount: 1000, + currency: "EUR", + account_provider: account_provider, + cost_basis: 450 + ) + + account.holdings.create!( + security: eur_security, + date: Date.current, + qty: 2, + price: 540, + amount: 1080, + currency: "USD" + ) + + second_provider_holding = account.holdings.create!( + security: chf_security, + date: Date.current, + qty: 3, + price: 90, + amount: 270, + currency: "CHF", + account_provider: account_provider, + cost_basis: 80 + ) + + assert_equal [ provider_holding.id, second_provider_holding.id ].sort, account.current_holdings.pluck(:id).sort + assert_equal %w[CHF EUR], account.current_holdings.pluck(:currency).sort + end end diff --git a/test/models/balance/sync_cache_test.rb b/test/models/balance/sync_cache_test.rb index 775a730d3..cbc14df7b 100644 --- a/test/models/balance/sync_cache_test.rb +++ b/test/models/balance/sync_cache_test.rb @@ -60,6 +60,31 @@ class Balance::SyncCacheTest < ActiveSupport::TestCase assert_equal 120.0, converted_entry.amount # 100 * 1.2 = 120 end + test "uses custom exchange rate from trade when present" do + security = Security.create!(ticker: "TST", name: "Test") + + _entry = @account.entries.create!( + date: Date.current, + name: "Test Trade", + amount: 100, + currency: "EUR", + entryable: Trade.new( + security: security, + qty: 1, + price: 100, + currency: "EUR", + exchange_rate: 1.5 + ) + ) + + sync_cache = Balance::SyncCache.new(@account) + converted_entries = sync_cache.send(:converted_entries) + + converted_entry = converted_entries.first + assert_equal "USD", converted_entry.currency + assert_equal 150.0, converted_entry.amount + end + test "converts multiple entries with correct rates" do # Create exchange rates ExchangeRate.create!( @@ -197,4 +222,35 @@ class Balance::SyncCacheTest < ActiveSupport::TestCase # Should use custom rate (1.5), not fetched rate (1.2) assert_equal 150.0, converted_entry.amount # 100 * 1.5, not 100 * 1.2 end + + test "prioritizes trade custom rate over fetched rate" do + ExchangeRate.create!( + from_currency: "EUR", + to_currency: "USD", + date: Date.current, + rate: 1.2 + ) + + security = Security.create!(ticker: "TST2", name: "Test 2") + + _entry = @account.entries.create!( + date: Date.current, + name: "EUR Trade with custom rate", + amount: 100, + currency: "EUR", + entryable: Trade.new( + security: security, + qty: 1, + price: 100, + currency: "EUR", + exchange_rate: 1.5 + ) + ) + + sync_cache = Balance::SyncCache.new(@account) + converted_entries = sync_cache.send(:converted_entries) + + converted_entry = converted_entries.first + assert_equal 150.0, converted_entry.amount + end end diff --git a/test/models/holding/materializer_test.rb b/test/models/holding/materializer_test.rb index 4d14a26c8..605f00478 100644 --- a/test/models/holding/materializer_test.rb +++ b/test/models/holding/materializer_test.rb @@ -7,6 +7,7 @@ class Holding::MaterializerTest < ActiveSupport::TestCase @family = families(:empty) @account = @family.accounts.create!(name: "Test", balance: 20000, cash_balance: 20000, currency: "USD", accountable: Investment.new) @aapl = securities(:aapl) + @msft = securities(:msft) end test "syncs holdings" do @@ -123,4 +124,83 @@ class Holding::MaterializerTest < ActiveSupport::TestCase assert_equal BigDecimal("10"), yesterday_holding.qty assert_equal yesterday_holding.qty * yesterday_holding.price, yesterday_holding.amount end + + test "cleans up calculated current-day holdings when a provider snapshot exists in another currency" do + ExchangeRate.create!(from_currency: "EUR", to_currency: "USD", date: Date.current, rate: 1.2) + + coinstats_item = @family.coinstats_items.create!(name: "CoinStats", api_key: "test-key") + coinstats_account = coinstats_item.coinstats_accounts.create!( + name: "Brokerage", + currency: "USD" + ) + account_provider = AccountProvider.create!(account: @account, provider: coinstats_account) + + Holding.create!( + account: @account, + security: @aapl, + qty: 10, + price: 200, + amount: 2000, + currency: "EUR", + date: Date.current, + account_provider: account_provider, + cost_basis: 150 + ) + + Holding::Materializer.new(@account, strategy: :reverse).materialize_holdings + + today_holdings = @account.holdings.where(security: @aapl, date: Date.current).order(:currency) + + assert_equal [ "EUR" ], today_holdings.pluck(:currency) + assert_equal [ account_provider.id ], today_holdings.pluck(:account_provider_id) + end + + test "preserves same-day non-provider holdings for securities absent from the provider snapshot" do + ExchangeRate.create!(from_currency: "EUR", to_currency: "USD", date: Date.current, rate: 1.2) + + coinstats_item = @family.coinstats_items.create!(name: "CoinStats", api_key: "test-key") + coinstats_account = coinstats_item.coinstats_accounts.create!( + name: "Brokerage", + currency: "USD" + ) + account_provider = AccountProvider.create!(account: @account, provider: coinstats_account) + + Holding.create!( + account: @account, + security: @aapl, + qty: 10, + price: 200, + amount: 2000, + currency: "EUR", + date: Date.current, + account_provider: account_provider, + cost_basis: 150 + ) + + manual_holding = Holding.create!( + account: @account, + security: @msft, + qty: 3, + price: 250, + amount: 750, + currency: "USD", + date: Date.current, + cost_basis: 225, + cost_basis_source: "manual", + cost_basis_locked: true + ) + + Holding::Materializer.new(@account, strategy: :reverse).materialize_holdings + + assert_equal manual_holding.id, manual_holding.reload.id + assert_equal @msft.id, manual_holding.security_id + assert_nil manual_holding.account_provider_id + + today_holdings = @account.holdings.where(date: Date.current) + + assert_equal( + [ [ @aapl.id, "EUR" ], [ @msft.id, "USD" ] ].sort, + today_holdings.pluck(:security_id, :currency).sort + ) + end end diff --git a/test/models/holding/portfolio_cache_test.rb b/test/models/holding/portfolio_cache_test.rb index 4677fc411..fed652165 100644 --- a/test/models/holding/portfolio_cache_test.rb +++ b/test/models/holding/portfolio_cache_test.rb @@ -56,4 +56,40 @@ class Holding::PortfolioCacheTest < ActiveSupport::TestCase cache = Holding::PortfolioCache.new(@account, use_holdings: true) assert_equal holding.price, cache.get_price(@security.id, holding.date).price end + + test "converts historical prices using the requested date exchange rate" do + account = families(:empty).accounts.create!( + name: "CHF Brokerage", + balance: 10000, + currency: "CHF", + accountable: Investment.new + ) + holding_date = 2.days.ago.to_date + + ExchangeRate.create!(from_currency: "USD", to_currency: "CHF", date: holding_date, rate: 0.80) + ExchangeRate.create!(from_currency: "USD", to_currency: "CHF", date: Date.current, rate: 0.95) + + Holding.create!( + security: @security, + account: account, + date: holding_date, + qty: 1, + price: 100, + amount: 100, + currency: "USD" + ) + + Security::Price.create!( + security: @security, + date: holding_date, + price: 100, + currency: "USD" + ) + + cache = Holding::PortfolioCache.new(account, use_holdings: true) + converted_price = cache.get_price(@security.id, holding_date) + + assert_equal BigDecimal("80.0"), converted_price.price + assert_equal "CHF", converted_price.currency + end end diff --git a/test/models/holding_test.rb b/test/models/holding_test.rb index 39fdf6ac1..32eb1b072 100644 --- a/test/models/holding_test.rb +++ b/test/models/holding_test.rb @@ -20,6 +20,22 @@ class HoldingTest < ActiveSupport::TestCase assert_in_delta expected_nvda_weight, @nvda.weight, 0.001 end + test "calculates portfolio weight after converting foreign-currency holdings" do + ExchangeRate.create!(from_currency: "EUR", to_currency: "USD", date: Date.current, rate: 1.5) + + foreign_security = Security.create!(ticker: "ASML", name: "ASML") + foreign_holding = @account.holdings.create!( + security: foreign_security, + date: Date.current, + qty: 1, + price: 100, + amount: 100, + currency: "EUR" + ) + + assert_in_delta 0.75, foreign_holding.weight, 0.001 + end + test "calculates average cost basis" do create_trade(@amzn.security, account: @account, qty: 10, price: 212.00, date: 1.day.ago.to_date) create_trade(@amzn.security, account: @account, qty: 15, price: 216.00, date: Date.current) diff --git a/test/models/ibkr_account/historical_balances_sync_test.rb b/test/models/ibkr_account/historical_balances_sync_test.rb new file mode 100644 index 000000000..22d082284 --- /dev/null +++ b/test/models/ibkr_account/historical_balances_sync_test.rb @@ -0,0 +1,116 @@ +require "test_helper" + +class IbkrAccount::HistoricalBalancesSyncTest < ActiveSupport::TestCase + setup do + @family = families(:empty) + @account = @family.accounts.create!( + name: "IBKR Brokerage", + balance: 0, + cash_balance: 0, + currency: "CHF", + accountable: Investment.new(subtype: "brokerage") + ) + @ibkr_account = @family.ibkr_items.create!( + name: "IBKR", + query_id: "QUERY123", + token: "TOKEN123" + ).ibkr_accounts.create!( + name: "Main", + ibkr_account_id: "U1234567", + currency: "CHF", + current_balance: 3351, + cash_balance: 1000.5, + raw_equity_summary_payload: [ + { + currency: "CHF", + report_date: "2026-05-07", + cash: "900.50", + stock: "2300.50", + total: "3201.00" + }, + { + currency: "CHF", + report_date: "2026-05-08", + cash: "1000.50", + stock: "2350.50", + total: "3351.00" + } + ] + ) + @ibkr_account.ensure_account_provider!(@account) + end + + test "upserts historical balances without creating activity entries" do + @account.balances.create!( + date: Date.new(2026, 5, 7), + balance: 0, + cash_balance: 0, + currency: "CHF", + start_cash_balance: 0, + start_non_cash_balance: 0, + cash_inflows: 0, + cash_outflows: 0, + non_cash_inflows: 0, + non_cash_outflows: 0, + net_market_flows: 0, + cash_adjustments: 0, + non_cash_adjustments: 0, + flows_factor: 1 + ) + + assert_no_difference "@account.entries.count" do + IbkrAccount::HistoricalBalancesSync.new(@ibkr_account).sync! + end + + first_balance = @account.balances.find_by!(date: Date.new(2026, 5, 7), currency: "CHF") + second_balance = @account.balances.find_by!(date: Date.new(2026, 5, 8), currency: "CHF") + + assert_equal BigDecimal("3201.0"), first_balance.end_balance + assert_equal BigDecimal("900.5"), first_balance.end_cash_balance + assert_equal BigDecimal("2300.5"), first_balance.end_non_cash_balance + + assert_equal BigDecimal("3351.0"), second_balance.end_balance + assert_equal BigDecimal("1000.5"), second_balance.end_cash_balance + assert_equal BigDecimal("2350.5"), second_balance.end_non_cash_balance + assert_equal BigDecimal("900.5"), second_balance.start_cash_balance + assert_equal BigDecimal("2300.5"), second_balance.start_non_cash_balance + end + + test "accepts equity summary rows when stored account currency casing differs" do + @ibkr_account.update!(currency: "chf") + + IbkrAccount::HistoricalBalancesSync.new(@ibkr_account).sync! + + first_balance = @account.balances.find_by!(date: Date.new(2026, 5, 7), currency: "CHF") + second_balance = @account.balances.find_by!(date: Date.new(2026, 5, 8), currency: "CHF") + + assert_equal BigDecimal("3201.0"), first_balance.end_balance + assert_equal BigDecimal("3351.0"), second_balance.end_balance + end + + test "skips malformed equity summary rows and still imports valid rows" do + @ibkr_account.update!( + raw_equity_summary_payload: [ + nil, + "bad-row", + [], + { + currency: "CHF", + report_date: "2026-05-09", + cash: "1100.50", + total: "3400.00" + } + ] + ) + + assert_nothing_raised do + IbkrAccount::HistoricalBalancesSync.new(@ibkr_account).sync! + end + + balance = @account.balances.find_by!(date: Date.new(2026, 5, 9), currency: "CHF") + + assert_equal BigDecimal("3400.0"), balance.end_balance + assert_equal BigDecimal("1100.5"), balance.end_cash_balance + assert_equal BigDecimal("2299.5"), balance.end_non_cash_balance + end +end diff --git a/test/models/ibkr_account_processor_test.rb b/test/models/ibkr_account_processor_test.rb new file mode 100644 index 000000000..94555bb61 --- /dev/null +++ b/test/models/ibkr_account_processor_test.rb @@ -0,0 +1,281 @@ +require "test_helper" + +class IbkrAccountProcessorTest < ActiveSupport::TestCase + fixtures :families, :ibkr_items, :ibkr_accounts, :accounts, :securities + + setup do + @family = families(:dylan_family) + @ibkr_account = ibkr_accounts(:main_account) + + @account = @family.accounts.create!( + name: "IBKR Investment", + balance: 0, + cash_balance: 0, + currency: "CHF", + accountable: Investment.new(subtype: "brokerage") + ) + @ibkr_account.ensure_account_provider!(@account) + @ibkr_account.update!( + raw_holdings_payload: [ + { + "asset_category" => "STK", + "conid" => "265598", + "security_id" => "US0378331005", + "security_id_type" => "ISIN", + "symbol" => securities(:aapl).ticker, + "position" => "10", + "mark_price" => "150.00", + "currency" => "USD", + "fx_rate_to_base" => "0.90", + "cost_basis_price" => "125.50", + "report_date" => Date.current.to_s, + "side" => "Long" + } + ], + raw_activities_payload: { + trades: [ + { + "asset_category" => "STK", + "trade_id" => "1001", + "transaction_id" => "1001a", + "conid" => "265598", + "symbol" => securities(:aapl).ticker, + "quantity" => "2", + "trade_price" => "140.00", + "currency" => "USD", + "fx_rate_to_base" => "0.90", + "buy_sell" => "BUY", + "trade_date" => Date.current.to_s, + "ib_commission" => "-1.25", + "ib_commission_currency" => "USD" + }, + { + "asset_category" => "STK", + "trade_id" => "1002", + "transaction_id" => "1002a", + "conid" => "265598", + "symbol" => securities(:aapl).ticker, + "quantity" => "-1", + "trade_price" => "155.00", + "currency" => "USD", + "fx_rate_to_base" => "0.92", + "buy_sell" => "SELL", + "trade_date" => Date.current.to_s, + "ib_commission" => "-1.10", + "ib_commission_currency" => "USD" + } + ], + cash_transactions: [ + { + "transaction_id" => "4001", + "type" => "Deposits/Withdrawals", + "amount" => "500.00", + "currency" => "CHF", + "fx_rate_to_base" => "1", + "report_date" => Date.current.to_s + }, + { + "transaction_id" => "4002", + "type" => "Dividends", + "amount" => "2.50", + "currency" => "USD", + "fx_rate_to_base" => "0.91", + "report_date" => Date.current.to_s, + "conid" => "265598" + } + ] + }, + report_date: Date.current, + current_balance: BigDecimal("3351.00"), + cash_balance: BigDecimal("1000.50"), + currency: "CHF" + ) + end + + test "processor imports holdings, trades, cash transactions, and commissions" do + IbkrAccount::Processor.new(@ibkr_account).process + + @account.reload + assert_equal BigDecimal("3351.00"), @account.balance + assert_equal BigDecimal("1000.50"), @account.cash_balance + assert_equal "CHF", @account.currency + + holding = @account.holdings.find_by(security: securities(:aapl), date: Date.current) + assert_not_nil holding + assert_equal BigDecimal("10"), holding.qty + assert_equal BigDecimal("150.00"), holding.price + assert_equal BigDecimal("125.50"), holding.cost_basis + assert_equal "USD", holding.currency + + buy_trade = @account.entries.find_by(external_id: "ibkr_trade_1001") + sell_trade = @account.entries.find_by(external_id: "ibkr_trade_1002") + assert_not_nil buy_trade + assert_not_nil sell_trade + assert_equal "Buy", buy_trade.entryable.investment_activity_label + assert_equal "Sell", sell_trade.entryable.investment_activity_label + assert_equal BigDecimal("2"), buy_trade.entryable.qty + assert_equal BigDecimal("-1"), sell_trade.entryable.qty + assert_equal BigDecimal("280.0"), buy_trade.amount + assert_equal BigDecimal("-155.0"), sell_trade.amount + assert_equal "USD", buy_trade.currency + assert_equal "USD", sell_trade.currency + assert_equal 0.9, buy_trade.entryable.exchange_rate + assert_equal 0.92, sell_trade.entryable.exchange_rate + + dividend = @account.entries.find_by(external_id: "ibkr_cash_4002") + assert_not_nil dividend + assert_equal "Dividend", dividend.entryable.investment_activity_label + assert_equal BigDecimal("-2.5"), dividend.amount + assert_equal securities(:aapl).id, dividend.entryable.extra["security_id"] + + commission_one = @account.entries.find_by(external_id: "ibkr_trade_fee_1001") + commission_two = @account.entries.find_by(external_id: "ibkr_trade_fee_1002") + assert_not_nil commission_one + assert_not_nil commission_two + assert_equal BigDecimal("1.25"), commission_one.amount + assert_equal BigDecimal("1.1"), commission_two.amount + assert_equal "USD", commission_one.currency + assert_equal "USD", commission_two.currency + assert_equal securities(:aapl).id, commission_one.entryable.extra["security_id"] + assert_equal securities(:aapl).id, commission_two.entryable.extra["security_id"] + + deposit = @account.entries.find_by(external_id: "ibkr_cash_4001") + + assert_not_nil deposit + assert_equal "Contribution", deposit.entryable.investment_activity_label + assert_equal BigDecimal("-500"), deposit.amount + assert_equal "CHF", deposit.currency + + assert_equal "USD", dividend.currency + end + + test "processor computes weighted provider cost basis for grouped lots" do + @ibkr_account.update!( + raw_holdings_payload: [ + { + "asset_category" => "STK", + "conid" => "265598", + "security_id" => "US0378331005", + "security_id_type" => "ISIN", + "symbol" => securities(:aapl).ticker, + "position" => "10", + "mark_price" => "150.00", + "currency" => "USD", + "fx_rate_to_base" => "0.90", + "cost_basis_price" => "125.50", + "report_date" => Date.current.to_s, + "side" => "Long" + }, + { + "asset_category" => "STK", + "conid" => "265598", + "security_id" => "US0378331005", + "security_id_type" => "ISIN", + "symbol" => securities(:aapl).ticker, + "position" => "20", + "mark_price" => "150.00", + "currency" => "USD", + "fx_rate_to_base" => "0.90", + "cost_basis_price" => "122.00", + "report_date" => Date.current.to_s, + "side" => "Long" + } + ] + ) + + IbkrAccount::Processor.new(@ibkr_account).process + + holding = @account.holdings.find_by(security: securities(:aapl), date: Date.current) + + assert_not_nil holding + assert_equal BigDecimal("30"), holding.qty + assert_equal BigDecimal("123.1667"), holding.cost_basis + end + + test "processor repairs default opening anchor after importing activity entries" do + result = Account::OpeningBalanceManager.new(@account).set_opening_balance( + balance: @ibkr_account.current_balance, + date: 2.years.ago.to_date + ) + + assert result.success? + + opening_anchor = @account.valuations.opening_anchor.includes(:entry).first + assert_not_nil opening_anchor + assert_equal @ibkr_account.current_balance.to_d, opening_anchor.entry.amount.to_d + + IbkrAccount::Processor.new(@ibkr_account).process + + opening_anchor.reload + assert_equal BigDecimal("0"), opening_anchor.entry.amount.to_d + end + + test "processor imports commission-free trades without creating fee entries" do + @ibkr_account.update!( + raw_activities_payload: { + trades: [ + { + "asset_category" => "STK", + "trade_id" => "1003", + "transaction_id" => "1003a", + "conid" => "265598", + "symbol" => securities(:aapl).ticker, + "quantity" => "3", + "trade_price" => "145.00", + "currency" => "USD", + "fx_rate_to_base" => "0.91", + "buy_sell" => "BUY", + "trade_date" => Date.current.to_s + } + ], + cash_transactions: [] + } + ) + + IbkrAccount::Processor.new(@ibkr_account).process + + trade = @account.entries.find_by(external_id: "ibkr_trade_1003") + fee = @account.entries.find_by(external_id: "ibkr_trade_fee_1003") + + assert_not_nil trade + assert_equal BigDecimal("3"), trade.entryable.qty + assert_equal BigDecimal("435.0"), trade.amount + assert_equal "USD", trade.currency + assert_nil fee + end + + test "processor logs and falls back to current date for invalid trade_date" do + @ibkr_account.update!( + raw_activities_payload: { + trades: [ + { + "asset_category" => "STK", + "trade_id" => "1004", + "transaction_id" => "1004a", + "conid" => "265598", + "symbol" => securities(:aapl).ticker, + "quantity" => "1", + "trade_price" => "146.00", + "currency" => "USD", + "fx_rate_to_base" => "0.91", + "buy_sell" => "BUY", + "trade_date" => "not-a-date" + } + ], + cash_transactions: [] + } + ) + + Rails.logger.expects(:warn).with do |message| + message.include?("IbkrAccount::DataHelpers - Missing or invalid trade_date") && + message.include?("1004") + end + + IbkrAccount::Processor.new(@ibkr_account).process + + trade = @account.entries.find_by(external_id: "ibkr_trade_1004") + + assert_not_nil trade + assert_equal Date.current, trade.date + end +end diff --git a/test/models/ibkr_item/sync_complete_event_test.rb b/test/models/ibkr_item/sync_complete_event_test.rb new file mode 100644 index 000000000..5fe628434 --- /dev/null +++ b/test/models/ibkr_item/sync_complete_event_test.rb @@ -0,0 +1,23 @@ +require "test_helper" + +class IbkrItem::SyncCompleteEventTest < ActiveSupport::TestCase + fixtures :families, :ibkr_items + + test "broadcast refreshes linked accounts, provider item, and family stream" do + ibkr_item = ibkr_items(:configured_item) + family = ibkr_item.family + account = mock("account") + + ibkr_item.stubs(:accounts).returns([ account ]) + account.expects(:broadcast_sync_complete).once + ibkr_item.expects(:broadcast_replace_to).with( + family, + target: "ibkr_item_#{ibkr_item.id}", + partial: "ibkr_items/ibkr_item", + locals: { ibkr_item: ibkr_item } + ).once + family.expects(:broadcast_sync_complete).once + + IbkrItem::SyncCompleteEvent.new(ibkr_item).broadcast + end +end diff --git a/test/models/ibkr_item/syncer_test.rb b/test/models/ibkr_item/syncer_test.rb new file mode 100644 index 000000000..ecb6786bb --- /dev/null +++ b/test/models/ibkr_item/syncer_test.rb @@ -0,0 +1,26 @@ +require "test_helper" + +class IbkrItem::SyncerTest < ActiveSupport::TestCase + fixtures :families, :ibkr_items + + setup do + @ibkr_item = ibkr_items(:configured_item) + end + + test "perform_sync records a single auth error when credentials are missing" do + @ibkr_item.update!(token: nil) + syncer = IbkrItem::Syncer.new(@ibkr_item) + sync = @ibkr_item.syncs.create! + + error = assert_raises(Provider::IbkrFlex::ConfigurationError) do + syncer.perform_sync(sync) + end + + assert_equal "IBKR credentials are missing.", error.message + assert_equal "requires_update", @ibkr_item.reload.status + + stats = sync.reload.sync_stats + assert_equal 1, stats["total_errors"] + assert_equal [ { "message" => "IBKR credentials are missing.", "category" => "auth_error" } ], stats["errors"] + end +end diff --git a/test/models/ibkr_item_importer_test.rb b/test/models/ibkr_item_importer_test.rb new file mode 100644 index 000000000..c91d967e5 --- /dev/null +++ b/test/models/ibkr_item_importer_test.rb @@ -0,0 +1,42 @@ +require "test_helper" + +class IbkrItemImporterTest < ActiveSupport::TestCase + setup do + @family = families(:empty) + @ibkr_item = @family.ibkr_items.create!( + name: "Interactive Brokers", + query_id: "QUERY123", + token: "TOKEN123" + ) + end + + test "imports accounts from parsed flex statement" do + provider = mock("ibkr_provider") + provider.expects(:download_statement).returns(file_fixture("ibkr/flex_statement.xml").read) + + assert_difference "IbkrAccount.count", 2 do + result = IbkrItem::Importer.new(@ibkr_item, ibkr_provider: provider).import + assert_equal true, result[:success] + assert_equal 2, result[:accounts_imported] + end + + primary_account = @ibkr_item.ibkr_accounts.find_by!(ibkr_account_id: "U1234567") + assert_equal "CHF", primary_account.currency + assert_equal BigDecimal("3351.0"), primary_account.current_balance + assert_equal 2, primary_account.raw_equity_summary_payload.size + assert_equal 1, primary_account.raw_holdings_payload.size + assert_equal 2, primary_account.raw_activities_payload["trades"].size + assert_equal 2, primary_account.raw_activities_payload["cash_transactions"].size + end + + test "raises parse error for malformed flex statement xml" do + provider = mock("ibkr_provider") + provider.expects(:download_statement).returns("") + + error = assert_raises(IbkrItem::ReportParser::ParseError) do + IbkrItem::Importer.new(@ibkr_item, ibkr_provider: provider).import + end + + assert_match "Invalid IBKR Flex XML", error.message + end +end diff --git a/test/models/ibkr_item_report_parser_test.rb b/test/models/ibkr_item_report_parser_test.rb new file mode 100644 index 000000000..b2c252221 --- /dev/null +++ b/test/models/ibkr_item_report_parser_test.rb @@ -0,0 +1,58 @@ +require "test_helper" + +class IbkrItemReportParserTest < ActiveSupport::TestCase + test "parses accounts, balances, and positions from flex xml" do + parsed = IbkrItem::ReportParser.new(file_fixture("ibkr/flex_statement.xml").read).parse + + assert_equal "Sure Test", parsed[:metadata]["query_name"] + assert_equal 2, parsed[:accounts].size + + first_account = parsed[:accounts].first + assert_equal "U1234567", first_account[:ibkr_account_id] + assert_equal "CHF", first_account[:currency] + assert_equal BigDecimal("1000.50"), first_account[:cash_balance] + assert_equal BigDecimal("3351.00"), first_account[:current_balance] + assert_equal 2, first_account[:equity_summary_in_base].size + assert_equal 1, first_account[:open_positions].size + assert_equal 2, first_account[:trades].size + assert_equal 2, first_account[:cash_transactions].size + + second_account = parsed[:accounts].second + assert_equal "U7654321", second_account[:ibkr_account_id] + assert_equal BigDecimal("250"), second_account[:cash_balance] + assert_equal BigDecimal("250"), second_account[:current_balance] + assert_equal 1, second_account[:equity_summary_in_base].size + end + + test "raises parse error for malformed xml" do + error = assert_raises(IbkrItem::ReportParser::ParseError) do + IbkrItem::ReportParser.new("").parse + end + + assert_match "Invalid IBKR Flex XML", error.message + end + + test "raises parse error when flex statements are missing" do + error = assert_raises(IbkrItem::ReportParser::ParseError) do + IbkrItem::ReportParser.new('').parse + end + + assert_equal "Invalid IBKR Flex XML: no FlexStatement nodes found.", error.message + end + + test "raises parse error when flex statement account id is missing" do + xml = <<~XML + + + + + + XML + + error = assert_raises(IbkrItem::ReportParser::ParseError) do + IbkrItem::ReportParser.new(xml).parse + end + + assert_equal "Invalid IBKR Flex XML: missing account identifier in FlexStatement.", error.message + end +end diff --git a/test/models/ibkr_item_test.rb b/test/models/ibkr_item_test.rb new file mode 100644 index 000000000..53737a120 --- /dev/null +++ b/test/models/ibkr_item_test.rb @@ -0,0 +1,20 @@ +require "test_helper" + +class IbkrItemTest < ActiveSupport::TestCase + fixtures :families, :ibkr_items + + test "syncable excludes items without token" do + item = IbkrItem.create!( + family: families(:empty), + name: "Interactive Brokers", + query_id: "QUERYNEW", + token: "TOKENNEW" + ) + + item.token = nil + item.save!(validate: false) + + assert_includes IbkrItem.syncable, ibkr_items(:configured_item) + refute_includes IbkrItem.syncable, item + end +end diff --git a/test/models/snaptrade_account_processor_test.rb b/test/models/snaptrade_account_processor_test.rb index 154b0690c..afc90c787 100644 --- a/test/models/snaptrade_account_processor_test.rb +++ b/test/models/snaptrade_account_processor_test.rb @@ -129,6 +129,36 @@ class SnaptradeAccountProcessorTest < ActiveSupport::TestCase assert_equal 0, @account.holdings.count end + test "processor trusts API total for multi-currency holdings" do + security = securities(:aapl) + Account.any_instance.stubs(:set_current_balance) + + @snaptrade_account.update!( + currency: "CHF", + current_balance: BigDecimal("15000.00"), + cash_balance: BigDecimal("1000.00"), + raw_holdings_payload: [ + { + "symbol" => { + "symbol" => { "symbol" => security.ticker, "description" => security.name } + }, + "units" => "10", + "price" => "150.00", + "currency" => "USD", + "average_purchase_price" => "125.50" + } + ], + raw_activities_payload: [] + ) + + SnaptradeAccount::Processor.new(@snaptrade_account).process + + @account.reload + assert_equal BigDecimal("15000.00"), @account.balance + assert_equal BigDecimal("1000.00"), @account.cash_balance + assert_equal "CHF", @account.currency + end + # === ActivitiesProcessor Tests === test "activities processor maps BUY type to Buy label" do diff --git a/test/models/trade_test.rb b/test/models/trade_test.rb index bcf09b192..6223a5fc7 100644 --- a/test/models/trade_test.rb +++ b/test/models/trade_test.rb @@ -52,6 +52,30 @@ class TradeTest < ActiveSupport::TestCase assert_equal 0, trade.fee end + test "exchange_rate setter stores normalized numeric value in extra" do + trade = Trade.new + trade.exchange_rate = "0.91" + + assert_equal 0.91, trade.exchange_rate + assert_equal 0.91, trade.extra["exchange_rate"] + end + + test "exchange_rate validation rejects invalid values" do + trade = Trade.new + trade.exchange_rate = "invalid" + + assert_not trade.valid? + assert_includes trade.errors[:exchange_rate], "must be a number" + end + + test "exchange_rate validation rejects non-finite values" do + trade = Trade.new + trade.exchange_rate = "NaN" + + assert_not trade.valid? + assert_includes trade.errors[:exchange_rate], "must be a number" + end + test "price is rounded to 10 decimal places" do security = Security.create!(ticker: "TEST", exchange_operating_mic: "XNAS") diff --git a/test/models/transaction/activity_security_preloader_test.rb b/test/models/transaction/activity_security_preloader_test.rb new file mode 100644 index 000000000..017dfb865 --- /dev/null +++ b/test/models/transaction/activity_security_preloader_test.rb @@ -0,0 +1,28 @@ +require "test_helper" + +class Transaction::ActivitySecurityPreloaderTest < ActiveSupport::TestCase + test "preloads activity securities for transactions" do + transaction = Transaction.new(extra: { "security_id" => securities(:aapl).id }) + + Transaction::ActivitySecurityPreloader.new([ transaction ]).preload + + assert_equal securities(:aapl), transaction.activity_security + end + + test "preloads activity securities for entry collections" do + transaction = Transaction.new(extra: { "security_id" => securities(:aapl).id }) + entry = Entry.new(account: accounts(:depository), entryable: transaction, date: Date.current, name: "Dividend", amount: 10, currency: "USD") + + Transaction::ActivitySecurityPreloader.new([ entry ]).preload + + assert_equal securities(:aapl), transaction.activity_security + end + + test "sets nil when the referenced security cannot be found" do + transaction = Transaction.new(extra: { "security_id" => SecureRandom.uuid }) + + Transaction::ActivitySecurityPreloader.new([ transaction ]).preload + + assert_nil transaction.activity_security + end +end diff --git a/test/models/transaction_test.rb b/test/models/transaction_test.rb index b6086d5c7..dfbf8e96b 100644 --- a/test/models/transaction_test.rb +++ b/test/models/transaction_test.rb @@ -100,6 +100,23 @@ class TransactionTest < ActiveSupport::TestCase assert transaction.extra["exchange_rate_invalid"] end + test "exchange_rate setter rejects non-finite input" do + transaction = Transaction.new + transaction.exchange_rate = "Infinity" + + assert_equal "Infinity", transaction.extra["exchange_rate"] + assert transaction.extra["exchange_rate_invalid"] + end + + test "exchange_rate setter clears invalid flag for valid input" do + transaction = Transaction.new + transaction.exchange_rate = "not a number" + transaction.exchange_rate = "1.5" + + assert_equal 1.5, transaction.exchange_rate + assert_equal false, transaction.extra["exchange_rate_invalid"] + end + test "exchange_rate validation rejects non-numeric input" do transaction = Transaction.new( category: categories(:income), @@ -139,4 +156,27 @@ class TransactionTest < ActiveSupport::TestCase assert transaction.valid? end + + test "activity_security returns the referenced security from extra metadata" do + security = securities(:aapl) + transaction = Transaction.new(extra: { "security_id" => security.id }) + + assert_equal security, transaction.activity_security + end + + test "activity_security returns nil when no security metadata is present" do + transaction = Transaction.new(extra: {}) + + assert_nil transaction.activity_security + end + + test "activity_security refreshes when security metadata changes on the same instance" do + transaction = Transaction.new(extra: { "security_id" => securities(:aapl).id }) + + assert_equal securities(:aapl), transaction.activity_security + + transaction.extra["security_id"] = securities(:msft).id + + assert_equal securities(:msft), transaction.activity_security + end end