diff --git a/app/components/UI/account/activity_date.html.erb b/app/components/UI/account/activity_date.html.erb index 672c6d77c..a1a330588 100644 --- a/app/components/UI/account/activity_date.html.erb +++ b/app/components/UI/account/activity_date.html.erb @@ -6,7 +6,7 @@ <%= check_box_tag "#{date}_entries_selection", class: ["checkbox checkbox--light hidden lg:block", "lg:hidden": entries.size == 0], id: "selection_entry_#{date}", - data: { + data: { action: "bulk-select#toggleGroupSelection", checkbox_toggle_target: "selectionEntry" } %> diff --git a/app/components/UI/account/activity_feed.html.erb b/app/components/UI/account/activity_feed.html.erb index 362e1af06..f46053401 100644 --- a/app/components/UI/account/activity_feed.html.erb +++ b/app/components/UI/account/activity_feed.html.erb @@ -77,7 +77,7 @@
<%= check_box_tag "selection_entry", class: "checkbox checkbox--light hidden lg:block", - data: { + data: { action: "bulk-select#togglePageSelection", checkbox_toggle_target: "selectionEntry" } %> diff --git a/app/models/assistant/configurable.rb b/app/models/assistant/configurable.rb index 1da95d14b..a2898c30b 100644 --- a/app/models/assistant/configurable.rb +++ b/app/models/assistant/configurable.rb @@ -17,6 +17,7 @@ module Assistant::Configurable [ Assistant::Function::GetTransactions, Assistant::Function::GetAccounts, + Assistant::Function::GetHoldings, Assistant::Function::GetBalanceSheet, Assistant::Function::GetIncomeStatement ] diff --git a/app/models/assistant/function/get_holdings.rb b/app/models/assistant/function/get_holdings.rb new file mode 100644 index 000000000..515888e8a --- /dev/null +++ b/app/models/assistant/function/get_holdings.rb @@ -0,0 +1,167 @@ +class Assistant::Function::GetHoldings < Assistant::Function + include Pagy::Backend + + SUPPORTED_ACCOUNT_TYPES = %w[Investment Crypto].freeze + + class << self + def default_page_size + 50 + end + + def name + "get_holdings" + end + + def description + <<~INSTRUCTIONS + Use this to search user's investment holdings by using various optional filters. + + This function is great for things like: + - Finding specific holdings or securities + - Getting portfolio composition and allocation + - Viewing investment performance and cost basis + + Note: This function only returns holdings from Investment and Crypto accounts. + + Note on pagination: + + This function can be paginated. You can expect the following properties in the response: + + - `total_pages`: The total number of pages of results + - `page`: The current page of results + - `page_size`: The number of results per page (this will always be #{default_page_size}) + - `total_results`: The total number of results for the given filters + - `total_value`: The total value of all holdings for the given filters + + Simple example (all current holdings): + + ``` + get_holdings({ + page: 1 + }) + ``` + + More complex example (various filters): + + ``` + get_holdings({ + page: 1, + accounts: ["Brokerage Account"], + securities: ["AAPL", "GOOGL"] + }) + ``` + INSTRUCTIONS + end + end + + def strict_mode? + false + end + + def params_schema + build_schema( + required: [ "page" ], + properties: { + page: { + type: "integer", + description: "Page number" + }, + accounts: { + type: "array", + description: "Filter holdings by account name (only Investment and Crypto accounts are supported)", + items: { enum: investment_account_names }, + minItems: 1, + uniqueItems: true + }, + securities: { + type: "array", + description: "Filter holdings by security ticker symbol", + items: { enum: family_security_tickers }, + minItems: 1, + uniqueItems: true + } + } + ) + end + + def call(params = {}) + holdings_query = build_holdings_query(params) + + pagy, paginated_holdings = pagy( + holdings_query.includes(:security, :account).order(amount: :desc), + page: params["page"] || 1, + limit: default_page_size + ) + + total_value = holdings_query.sum(:amount) + + normalized_holdings = paginated_holdings.map do |holding| + { + ticker: holding.ticker, + name: holding.name, + quantity: holding.qty.to_f, + price: holding.price.to_f, + currency: holding.currency, + amount: holding.amount.to_f, + formatted_amount: holding.amount_money.format, + weight: holding.weight&.round(2), + average_cost: holding.avg_cost.to_f, + formatted_average_cost: holding.avg_cost.format, + account: holding.account.name, + date: holding.date + } + end + + { + holdings: normalized_holdings, + total_results: pagy.count, + page: pagy.page, + page_size: default_page_size, + total_pages: pagy.pages, + total_value: Money.new(total_value, family.currency).format + } + end + + private + def default_page_size + self.class.default_page_size + end + + def build_holdings_query(params) + accounts = investment_accounts + + if params["accounts"].present? + accounts = accounts.where(name: params["accounts"]) + end + + holdings = Holding.where(account: accounts) + .where( + id: Holding.where(account: accounts) + .select("DISTINCT ON (account_id, security_id) id") + .where.not(qty: 0) + .order(:account_id, :security_id, date: :desc) + ) + + if params["securities"].present? + security_ids = family.securities.where(ticker: params["securities"]).pluck(:id) + holdings = holdings.where(security_id: security_ids) + end + + holdings + end + + def investment_accounts + family.accounts.visible.where(accountable_type: SUPPORTED_ACCOUNT_TYPES) + end + + def investment_account_names + @investment_account_names ||= investment_accounts.pluck(:name) + end + + def family_security_tickers + @family_security_tickers ||= Security + .where(id: Holding.where(account_id: investment_accounts.select(:id)).select(:security_id)) + .distinct + .pluck(:ticker) + end +end diff --git a/app/models/lunchflow_account/investments/holdings_processor.rb b/app/models/lunchflow_account/investments/holdings_processor.rb new file mode 100644 index 000000000..a972f6f44 --- /dev/null +++ b/app/models/lunchflow_account/investments/holdings_processor.rb @@ -0,0 +1,184 @@ +class LunchflowAccount::Investments::HoldingsProcessor + def initialize(lunchflow_account) + @lunchflow_account = lunchflow_account + end + + def process + return if holdings_data.empty? + return unless [ "Investment", "Crypto" ].include?(account&.accountable_type) + + holdings_data.each do |lunchflow_holding| + begin + process_holding(lunchflow_holding) + rescue => e + symbol = lunchflow_holding.dig(:security, :tickerSymbol) rescue nil + ctx = symbol.present? ? " #{symbol}" : "" + Rails.logger.error "Error processing Lunchflow holding#{ctx}: #{e.message}" + end + end + end + + private + attr_reader :lunchflow_account + + def process_holding(lunchflow_holding) + # Support both symbol and string keys (JSONB returns string keys) + holding = lunchflow_holding.is_a?(Hash) ? lunchflow_holding.with_indifferent_access : {} + security_data = (holding[:security] || {}).with_indifferent_access + raw_data = holding[:raw] || {} + + symbol = security_data[:tickerSymbol].presence + security_name = security_data[:name].to_s.strip + + # Extract holding ID from nested raw data (e.g., raw.quiltt.id) + holding_id = extract_holding_id(raw_data) || generate_holding_id(holding) + + Rails.logger.debug({ + event: "lunchflow.holding.start", + lfa_id: lunchflow_account.id, + account_id: account&.id, + id: holding_id, + symbol: symbol, + name: security_name + }.to_json) + + # If symbol is missing but we have a name, create a synthetic ticker + if symbol.blank? && security_name.present? + normalized = security_name.gsub(/[^a-zA-Z0-9]/, "_").upcase.truncate(24, omission: "") + hash_suffix = Digest::MD5.hexdigest(security_name)[0..4].upcase + symbol = "CUSTOM:#{normalized}_#{hash_suffix}" + Rails.logger.info("Lunchflow: using synthetic ticker #{symbol} for holding #{holding_id} (#{security_name})") + end + + unless symbol.present? + Rails.logger.debug({ event: "lunchflow.holding.skip", reason: "no_symbol_or_name", id: holding_id }.to_json) + return + end + + security = resolve_security(symbol, security_name, security_data) + unless security.present? + Rails.logger.debug({ event: "lunchflow.holding.skip", reason: "unresolved_security", id: holding_id, symbol: symbol }.to_json) + return + end + + # Parse holding data from API response + qty = parse_decimal(holding[:quantity]) + price = parse_decimal(holding[:price]) + amount = parse_decimal(holding[:value]) + cost_basis = parse_decimal(holding[:costBasis]) + currency = holding[:currency].presence || security_data[:currency].presence || "USD" + + # Skip zero positions with no value + if qty.to_d.zero? && amount.to_d.zero? + Rails.logger.debug({ event: "lunchflow.holding.skip", reason: "zero_position", id: holding_id }.to_json) + return + end + + saved = import_adapter.import_holding( + security: security, + quantity: qty, + amount: amount, + currency: currency, + date: Date.current, + price: price, + cost_basis: cost_basis, + external_id: "lunchflow_#{holding_id}", + account_provider_id: lunchflow_account.account_provider&.id, + source: "lunchflow", + delete_future_holdings: false + ) + + Rails.logger.debug({ + event: "lunchflow.holding.saved", + account_id: account&.id, + holding_id: saved.id, + security_id: saved.security_id, + qty: saved.qty.to_s, + amount: saved.amount.to_s, + currency: saved.currency, + date: saved.date, + external_id: saved.external_id + }.to_json) + end + + def import_adapter + @import_adapter ||= Account::ProviderImportAdapter.new(account) + end + + def account + lunchflow_account.current_account + end + + def holdings_data + lunchflow_account.raw_holdings_payload || [] + end + + def extract_holding_id(raw_data) + # Try to find ID in nested provider data (e.g., raw.quiltt.id, raw.plaid.id, etc.) + return nil unless raw_data.is_a?(Hash) + + raw_data.each_value do |provider_data| + next unless provider_data.is_a?(Hash) + id = provider_data[:id] || provider_data["id"] + return id.to_s if id.present? + end + + nil + end + + def generate_holding_id(holding) + # Generate a stable ID based on holding content + # holding should already be with_indifferent_access from process_holding + security = holding[:security] || {} + content = [ + security[:tickerSymbol] || security["tickerSymbol"], + security[:name] || security["name"], + holding[:quantity], + holding[:value] + ].compact.join("-") + Digest::MD5.hexdigest(content)[0..11] + end + + def resolve_security(symbol, description, security_data) + # Normalize crypto tickers to a distinct namespace + sym = symbol.to_s.upcase + is_crypto_account = account&.accountable_type == "Crypto" + is_crypto_symbol = %w[BTC ETH SOL DOGE LTC BCH XRP ADA DOT AVAX].include?(sym) + + if !sym.include?(":") && (is_crypto_account || is_crypto_symbol) + sym = "CRYPTO:#{sym}" + end + + is_custom = sym.start_with?("CUSTOM:") + + begin + if is_custom + raise "Custom ticker - skipping resolver" + end + Security::Resolver.new(sym).resolve + rescue => e + Rails.logger.warn "Lunchflow: resolver failed for symbol=#{sym}: #{e.class} - #{e.message}; falling back to offline security" unless is_custom + Security.find_or_initialize_by(ticker: sym).tap do |sec| + sec.offline = true if sec.respond_to?(:offline) && sec.offline != true + sec.name = description.presence if sec.name.blank? && description.present? + sec.save! if sec.changed? + end + end + end + + def parse_decimal(value) + return BigDecimal("0") unless value.present? + + case value + when String + BigDecimal(value) + when Numeric + BigDecimal(value.to_s) + else + BigDecimal("0") + end + rescue ArgumentError => e + Rails.logger.error "Failed to parse Lunchflow decimal value #{value}: #{e.message}" + BigDecimal("0") + end +end diff --git a/app/models/lunchflow_account/processor.rb b/app/models/lunchflow_account/processor.rb index 4431080b7..b9c6b2184 100644 --- a/app/models/lunchflow_account/processor.rb +++ b/app/models/lunchflow_account/processor.rb @@ -25,6 +25,7 @@ class LunchflowAccount::Processor end process_transactions + process_investments end private @@ -67,6 +68,16 @@ class LunchflowAccount::Processor report_exception(e, "transactions") end + def process_investments + # Only process holdings for investment/crypto accounts with holdings support + return unless lunchflow_account.holdings_supported? + return unless [ "Investment", "Crypto" ].include?(lunchflow_account.current_account&.accountable_type) + + LunchflowAccount::Investments::HoldingsProcessor.new(lunchflow_account).process + rescue => e + report_exception(e, "holdings") + end + def report_exception(error, context) Sentry.capture_exception(error) do |scope| scope.set_tags( diff --git a/app/models/lunchflow_item/importer.rb b/app/models/lunchflow_item/importer.rb index 6d4c63e90..64af4089d 100644 --- a/app/models/lunchflow_item/importer.rb +++ b/app/models/lunchflow_item/importer.rb @@ -242,6 +242,14 @@ class LunchflowItem::Importer Rails.logger.warn "LunchflowItem::Importer - Failed to update balance for account #{lunchflow_account.account_id}: #{e.message}" end + # Fetch holdings for investment/crypto accounts + begin + fetch_and_store_holdings(lunchflow_account) + rescue => e + # Log but don't fail sync if holdings fetch fails + Rails.logger.warn "LunchflowItem::Importer - Failed to fetch holdings for account #{lunchflow_account.account_id}: #{e.message}" + end + { success: true, transactions_count: transactions_count } rescue Provider::Lunchflow::LunchflowError => e Rails.logger.error "LunchflowItem::Importer - Lunchflow API error for account #{lunchflow_account.id}: #{e.message}" @@ -299,6 +307,53 @@ class LunchflowItem::Importer end end + def fetch_and_store_holdings(lunchflow_account) + # Only fetch holdings for investment/crypto accounts + account = lunchflow_account.current_account + return unless account.present? + return unless [ "Investment", "Crypto" ].include?(account.accountable_type) + + # Skip if holdings are not supported for this account + unless lunchflow_account.holdings_supported? + Rails.logger.debug "LunchflowItem::Importer - Skipping holdings fetch for account #{lunchflow_account.account_id} (holdings not supported)" + return + end + + Rails.logger.info "LunchflowItem::Importer - Fetching holdings for account #{lunchflow_account.account_id}" + + begin + holdings_data = lunchflow_provider.get_account_holdings(lunchflow_account.account_id) + + # Validate response structure + unless holdings_data.is_a?(Hash) + Rails.logger.error "LunchflowItem::Importer - Invalid holdings_data format for account #{lunchflow_account.account_id}" + return + end + + # Check if holdings are not supported (501 response) + if holdings_data[:holdings_not_supported] + Rails.logger.info "LunchflowItem::Importer - Holdings not supported for account #{lunchflow_account.account_id}, disabling future requests" + lunchflow_account.update!(holdings_supported: false) + return + end + + # Store holdings payload for processing + holdings_array = holdings_data[:holdings] || [] + Rails.logger.info "LunchflowItem::Importer - Fetched #{holdings_array.count} holdings for account #{lunchflow_account.account_id}" + + lunchflow_account.update!(raw_holdings_payload: holdings_array) + rescue Provider::Lunchflow::LunchflowError => e + Rails.logger.error "LunchflowItem::Importer - Lunchflow API error fetching holdings for account #{lunchflow_account.id}: #{e.message}" + # Don't fail if holdings fetch fails + rescue ActiveRecord::RecordInvalid => e + Rails.logger.error "LunchflowItem::Importer - Failed to save holdings for account #{lunchflow_account.id}: #{e.message}" + # Don't fail if holdings save fails + rescue => e + Rails.logger.error "LunchflowItem::Importer - Unexpected error fetching holdings for account #{lunchflow_account.id}: #{e.class} - #{e.message}" + # Don't fail if holdings fetch fails + end + end + def determine_sync_start_date(lunchflow_account) # Check if this account has any stored transactions # If not, treat it as a first sync for this account even if the item has been synced before diff --git a/app/models/lunchflow_item/syncer.rb b/app/models/lunchflow_item/syncer.rb index 4d10b2578..b39b5586e 100644 --- a/app/models/lunchflow_item/syncer.rb +++ b/app/models/lunchflow_item/syncer.rb @@ -31,9 +31,9 @@ class LunchflowItem::Syncer lunchflow_item.update!(pending_account_setup: false) end - # Phase 3: Process transactions for linked accounts only + # Phase 3: Process transactions and holdings for linked accounts only if linked_accounts.any? - sync.update!(status_text: "Processing transactions...") if sync.respond_to?(:status_text) + sync.update!(status_text: "Processing transactions and holdings...") if sync.respond_to?(:status_text) Rails.logger.info "LunchflowItem::Syncer - Processing #{linked_accounts.count} linked accounts" lunchflow_item.process_accounts Rails.logger.info "LunchflowItem::Syncer - Finished processing accounts" diff --git a/app/models/provider/lunchflow.rb b/app/models/provider/lunchflow.rb index dfd5f5109..8ff44d682 100644 --- a/app/models/provider/lunchflow.rb +++ b/app/models/provider/lunchflow.rb @@ -78,6 +78,31 @@ class Provider::Lunchflow raise LunchflowError.new("Exception during GET request: #{e.message}", :request_failed) end + # Get holdings for a specific account (investment accounts only) + # Returns: { holdings: [...], totalValue: N, currency: "USD" } + # Returns { holdings_not_supported: true } if API returns 501 + def get_account_holdings(account_id) + path = "/accounts/#{ERB::Util.url_encode(account_id.to_s)}/holdings" + + response = self.class.get( + "#{@base_url}#{path}", + headers: auth_headers + ) + + # Handle 501 specially - indicates holdings not supported for this account + if response.code == 501 + return { holdings_not_supported: true } + end + + handle_response(response) + rescue SocketError, Net::OpenTimeout, Net::ReadTimeout => e + Rails.logger.error "Lunch Flow API: GET #{path} failed: #{e.class}: #{e.message}" + raise LunchflowError.new("Exception during GET request: #{e.message}", :request_failed) + rescue => e + Rails.logger.error "Lunch Flow API: Unexpected error during GET #{path}: #{e.class}: #{e.message}" + raise LunchflowError.new("Exception during GET request: #{e.message}", :request_failed) + end + private def auth_headers diff --git a/app/views/accounts/show/_activity.html.erb b/app/views/accounts/show/_activity.html.erb index 7bd35639b..dbed8ad52 100644 --- a/app/views/accounts/show/_activity.html.erb +++ b/app/views/accounts/show/_activity.html.erb @@ -67,7 +67,7 @@
<%= check_box_tag "selection_entry", class: "checkbox checkbox--light hidden lg:block", - data: { + data: { action: "bulk-select#togglePageSelection", checkbox_toggle_target: "selectionEntry" } %> diff --git a/app/views/budget_categories/_budget_category.html.erb b/app/views/budget_categories/_budget_category.html.erb index 3ce90e6f4..a241101d2 100644 --- a/app/views/budget_categories/_budget_category.html.erb +++ b/app/views/budget_categories/_budget_category.html.erb @@ -6,7 +6,7 @@ <% if budget_category.initialized? %> <%# Category Header with Status Badge %>
-
<% end %> -
\ No newline at end of file +
diff --git a/app/views/entries/_entry_group.html.erb b/app/views/entries/_entry_group.html.erb index 7a0d14470..5499c9f84 100644 --- a/app/views/entries/_entry_group.html.erb +++ b/app/views/entries/_entry_group.html.erb @@ -6,7 +6,7 @@ <%= check_box_tag "#{date}_entries_selection", class: ["checkbox checkbox--light hidden lg:block", "lg:hidden": entries.size == 0], id: "selection_entry_#{date}", - data: { + data: { action: "bulk-select#toggleGroupSelection", checkbox_toggle_target: "selectionEntry" } %> diff --git a/app/views/import/configurations/_rule_import.html.erb b/app/views/import/configurations/_rule_import.html.erb index 7089a40ad..eb0f8be8b 100644 --- a/app/views/import/configurations/_rule_import.html.erb +++ b/app/views/import/configurations/_rule_import.html.erb @@ -12,4 +12,3 @@ <%= form.submit t("import.configurations.rule_import.process_button"), disabled: import.complete? %> <% end %>
- diff --git a/app/views/pages/dashboard/_outflows_donut.html.erb b/app/views/pages/dashboard/_outflows_donut.html.erb index 2657662c7..4b11de35c 100644 --- a/app/views/pages/dashboard/_outflows_donut.html.erb +++ b/app/views/pages/dashboard/_outflows_donut.html.erb @@ -78,7 +78,7 @@ action: "mouseenter->donut-chart#highlightSegment mouseleave->donut-chart#unhighlightSegment" } do %>
-

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

- <% unless @family.recurring_transactions_disabled? %> + <% unless @family.recurring_transactions_disabled? %> <%= render DS::Menu.new do |menu| %> <% menu.with_item( variant: "button", diff --git a/app/views/settings/llm_usages/show.html.erb b/app/views/settings/llm_usages/show.html.erb index 2e32e2f4c..387ebaf56 100644 --- a/app/views/settings/llm_usages/show.html.erb +++ b/app/views/settings/llm_usages/show.html.erb @@ -117,7 +117,7 @@ <% @llm_usages.each do |usage| %> - + "> <%= usage.created_at.strftime("%b %d, %Y %I:%M %p") %> diff --git a/app/views/settings/providers/show.html.erb b/app/views/settings/providers/show.html.erb index 738c86069..f02401171 100644 --- a/app/views/settings/providers/show.html.erb +++ b/app/views/settings/providers/show.html.erb @@ -26,7 +26,6 @@ <% end %> - <%= settings_section title: "Enable Banking (beta)", collapsible: true, open: false do %> <%= render "settings/providers/enable_banking_panel" %> diff --git a/app/views/shared/_demo_warning.html.erb b/app/views/shared/_demo_warning.html.erb index e1d003dac..ba3c08f23 100644 --- a/app/views/shared/_demo_warning.html.erb +++ b/app/views/shared/_demo_warning.html.erb @@ -10,4 +10,3 @@
- diff --git a/app/views/transactions/index.html.erb b/app/views/transactions/index.html.erb index 58055f342..7251c4c0a 100644 --- a/app/views/transactions/index.html.erb +++ b/app/views/transactions/index.html.erb @@ -63,7 +63,7 @@
<%= check_box_tag "selection_entry", class: "checkbox checkbox--light hidden lg:block", - data: { + data: { action: "bulk-select#togglePageSelection", checkbox_toggle_target: "selectionEntry" } %> diff --git a/db/migrate/20260109100000_add_holdings_columns_to_lunchflow_accounts.rb b/db/migrate/20260109100000_add_holdings_columns_to_lunchflow_accounts.rb new file mode 100644 index 000000000..6ded6aca2 --- /dev/null +++ b/db/migrate/20260109100000_add_holdings_columns_to_lunchflow_accounts.rb @@ -0,0 +1,6 @@ +class AddHoldingsColumnsToLunchflowAccounts < ActiveRecord::Migration[7.2] + def change + add_column :lunchflow_accounts, :holdings_supported, :boolean, default: true, null: false + add_column :lunchflow_accounts, :raw_holdings_payload, :jsonb + end +end diff --git a/db/schema.rb b/db/schema.rb index 90a40f52a..48fbe8ef7 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_01_08_131034) do +ActiveRecord::Schema[7.2].define(version: 2026_01_09_100000) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -653,6 +653,8 @@ ActiveRecord::Schema[7.2].define(version: 2026_01_08_131034) do t.jsonb "raw_transactions_payload" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.boolean "holdings_supported", default: true, null: false + t.jsonb "raw_holdings_payload" t.index ["account_id"], name: "index_lunchflow_accounts_on_account_id" t.index ["lunchflow_item_id"], name: "index_lunchflow_accounts_on_lunchflow_item_id" end diff --git a/test/fixtures/lunchflow_accounts.yml b/test/fixtures/lunchflow_accounts.yml new file mode 100644 index 000000000..d95dfb9d5 --- /dev/null +++ b/test/fixtures/lunchflow_accounts.yml @@ -0,0 +1,6 @@ +investment_account: + lunchflow_item: one + account_id: "lf_acc_investment_1" + name: "Test Investment Account" + currency: USD + holdings_supported: true diff --git a/test/fixtures/lunchflow_items.yml b/test/fixtures/lunchflow_items.yml new file mode 100644 index 000000000..fa7e12824 --- /dev/null +++ b/test/fixtures/lunchflow_items.yml @@ -0,0 +1,5 @@ +one: + family: dylan_family + name: "Test Lunchflow Connection" + api_key: "test_api_key_123" + status: good diff --git a/test/models/lunchflow_account/investments/holdings_processor_test.rb b/test/models/lunchflow_account/investments/holdings_processor_test.rb new file mode 100644 index 000000000..229f8cb0c --- /dev/null +++ b/test/models/lunchflow_account/investments/holdings_processor_test.rb @@ -0,0 +1,265 @@ +require "test_helper" + +class LunchflowAccount::Investments::HoldingsProcessorTest < ActiveSupport::TestCase + setup do + @lunchflow_account = lunchflow_accounts(:investment_account) + @account = accounts(:investment) + + # Create account_provider to link lunchflow_account to account + @account_provider = AccountProvider.create!( + account: @account, + provider: @lunchflow_account + ) + + # Reload to ensure associations are loaded + @lunchflow_account.reload + end + + test "creates holding records from Lunchflow holdings snapshot" do + # Verify setup is correct + assert_not_nil @lunchflow_account.current_account, "Account should be linked" + assert_equal "Investment", @lunchflow_account.current_account.accountable_type + + # Use unique dates to avoid conflicts with existing fixture holdings + test_holdings_payload = [ + { + "security" => { + "name" => "iShares Inc MSCI Brazil", + "currency" => "USD", + "tickerSymbol" => "NEWTEST1", + "figi" => nil, + "cusp" => nil, + "isin" => nil + }, + "quantity" => 5, + "price" => 42.15, + "value" => 210.75, + "costBasis" => 100.0, + "currency" => "USD", + "raw" => { + "quiltt" => { + "id" => "hld_test_123" + } + } + }, + { + "security" => { + "name" => "Test Security", + "currency" => "USD", + "tickerSymbol" => "NEWTEST2", + "figi" => nil, + "cusp" => nil, + "isin" => nil + }, + "quantity" => 10, + "price" => 150.0, + "value" => 1500.0, + "costBasis" => 1200.0, + "currency" => "USD", + "raw" => { + "quiltt" => { + "id" => "hld_test_456" + } + } + } + ] + + @lunchflow_account.update!(raw_holdings_payload: test_holdings_payload) + + processor = LunchflowAccount::Investments::HoldingsProcessor.new(@lunchflow_account) + + assert_difference "Holding.count", 2 do + processor.process + end + + holdings = Holding.where(account: @account).where.not(external_id: nil).order(:created_at) + + assert_equal 2, holdings.count + assert_equal "USD", holdings.first.currency + assert_equal "lunchflow_hld_test_123", holdings.first.external_id + end + + test "skips processing for non-investment accounts" do + # Create a depository account + depository_account = accounts(:depository) + depository_lunchflow_account = LunchflowAccount.create!( + lunchflow_item: lunchflow_items(:one), + account_id: "lf_depository", + name: "Depository", + currency: "USD" + ) + + AccountProvider.create!( + account: depository_account, + provider: depository_lunchflow_account + ) + depository_lunchflow_account.reload + + test_holdings_payload = [ + { + "security" => { "name" => "Test", "tickerSymbol" => "TEST", "currency" => "USD" }, + "quantity" => 10, + "price" => 100.0, + "value" => 1000.0, + "costBasis" => nil, + "currency" => "USD", + "raw" => { "quiltt" => { "id" => "hld_skip" } } + } + ] + + depository_lunchflow_account.update!(raw_holdings_payload: test_holdings_payload) + + processor = LunchflowAccount::Investments::HoldingsProcessor.new(depository_lunchflow_account) + + assert_no_difference "Holding.count" do + processor.process + end + end + + test "creates synthetic ticker when tickerSymbol is missing" do + test_holdings_payload = [ + { + "security" => { + "name" => "Custom 401k Fund", + "currency" => "USD", + "tickerSymbol" => nil, + "figi" => nil, + "cusp" => nil, + "isin" => nil + }, + "quantity" => 100, + "price" => 50.0, + "value" => 5000.0, + "costBasis" => 4500.0, + "currency" => "USD", + "raw" => { + "quiltt" => { + "id" => "hld_custom_123" + } + } + } + ] + + @lunchflow_account.update!(raw_holdings_payload: test_holdings_payload) + + processor = LunchflowAccount::Investments::HoldingsProcessor.new(@lunchflow_account) + + assert_difference "Holding.count", 1 do + processor.process + end + + holding = Holding.where(account: @account).where.not(external_id: nil).last + assert_equal "lunchflow_hld_custom_123", holding.external_id + assert_equal 100, holding.qty + assert_equal 5000.0, holding.amount + end + + test "skips zero value holdings" do + test_holdings_payload = [ + { + "security" => { + "name" => "Zero Position", + "currency" => "USD", + "tickerSymbol" => "ZERO", + "figi" => nil, + "cusp" => nil, + "isin" => nil + }, + "quantity" => 0, + "price" => 0, + "value" => 0, + "costBasis" => nil, + "currency" => "USD", + "raw" => { + "quiltt" => { + "id" => "hld_zero" + } + } + } + ] + + @lunchflow_account.update!(raw_holdings_payload: test_holdings_payload) + + Security::Resolver.any_instance.stubs(:resolve).returns(securities(:aapl)) + + processor = LunchflowAccount::Investments::HoldingsProcessor.new(@lunchflow_account) + + assert_no_difference "Holding.count" do + processor.process + end + end + + test "handles empty holdings payload gracefully" do + @lunchflow_account.update!(raw_holdings_payload: []) + + processor = LunchflowAccount::Investments::HoldingsProcessor.new(@lunchflow_account) + + assert_no_difference "Holding.count" do + processor.process + end + end + + test "handles nil holdings payload gracefully" do + @lunchflow_account.update!(raw_holdings_payload: nil) + + processor = LunchflowAccount::Investments::HoldingsProcessor.new(@lunchflow_account) + + assert_no_difference "Holding.count" do + processor.process + end + end + + test "continues processing other holdings when one fails" do + test_holdings_payload = [ + { + "security" => { + "name" => "Good Holding", + "currency" => "USD", + "tickerSymbol" => "GOODTEST", + "figi" => nil, + "cusp" => nil, + "isin" => nil + }, + "quantity" => 10, + "price" => 100.0, + "value" => 1000.0, + "costBasis" => nil, + "currency" => "USD", + "raw" => { + "quiltt" => { + "id" => "hld_good" + } + } + }, + { + "security" => { + "name" => nil, # This will cause it to skip (no name, no symbol) + "currency" => "USD", + "tickerSymbol" => nil, + "figi" => nil, + "cusp" => nil, + "isin" => nil + }, + "quantity" => 5, + "price" => 50.0, + "value" => 250.0, + "costBasis" => nil, + "currency" => "USD", + "raw" => { + "quiltt" => { + "id" => "hld_bad" + } + } + } + ] + + @lunchflow_account.update!(raw_holdings_payload: test_holdings_payload) + + processor = LunchflowAccount::Investments::HoldingsProcessor.new(@lunchflow_account) + + # Should create 1 holding (the good one) + assert_difference "Holding.count", 1 do + processor.process + end + end +end