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