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