mirror of
https://github.com/we-promise/sure.git
synced 2026-05-29 15:34:58 +00:00
Implement holdings for lunch flow (#590)
* Implement holdings for lunch flow * Implement holdings function call
This commit is contained in:
@@ -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"
|
||||
} %>
|
||||
|
||||
@@ -77,7 +77,7 @@
|
||||
<div class="pl-0.5 col-span-8 flex items-center gap-4">
|
||||
<%= check_box_tag "selection_entry",
|
||||
class: "checkbox checkbox--light hidden lg:block",
|
||||
data: {
|
||||
data: {
|
||||
action: "bulk-select#togglePageSelection",
|
||||
checkbox_toggle_target: "selectionEntry"
|
||||
} %>
|
||||
|
||||
@@ -17,6 +17,7 @@ module Assistant::Configurable
|
||||
[
|
||||
Assistant::Function::GetTransactions,
|
||||
Assistant::Function::GetAccounts,
|
||||
Assistant::Function::GetHoldings,
|
||||
Assistant::Function::GetBalanceSheet,
|
||||
Assistant::Function::GetIncomeStatement
|
||||
]
|
||||
|
||||
167
app/models/assistant/function/get_holdings.rb
Normal file
167
app/models/assistant/function/get_holdings.rb
Normal file
@@ -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
|
||||
184
app/models/lunchflow_account/investments/holdings_processor.rb
Normal file
184
app/models/lunchflow_account/investments/holdings_processor.rb
Normal file
@@ -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
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
<div class="pl-0.5 col-span-8 flex items-center gap-4">
|
||||
<%= check_box_tag "selection_entry",
|
||||
class: "checkbox checkbox--light hidden lg:block",
|
||||
data: {
|
||||
data: {
|
||||
action: "bulk-select#togglePageSelection",
|
||||
checkbox_toggle_target: "selectionEntry"
|
||||
} %>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<% if budget_category.initialized? %>
|
||||
<%# Category Header with Status Badge %>
|
||||
<div class="flex items-center lg:justify-between gap-2 mb-3">
|
||||
<div class="h-9 w-9 flex-shrink-0 group-hover:scale-105 transition-all duration-300 rounded-full flex justify-center items-center"
|
||||
<div class="h-9 w-9 flex-shrink-0 group-hover:scale-105 transition-all duration-300 rounded-full flex justify-center items-center"
|
||||
style="
|
||||
background-color: color-mix(in oklab, <%= budget_category.category.color %> 10%, transparent);
|
||||
border-color: color-mix(in oklab, <%= budget_category.category.color %> 10%, transparent);
|
||||
|
||||
@@ -11,4 +11,4 @@
|
||||
<%= icon category.lucide_icon, size: "sm", color: "current" %>
|
||||
<% end %>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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"
|
||||
} %>
|
||||
|
||||
@@ -12,4 +12,3 @@
|
||||
<%= form.submit t("import.configurations.rule_import.process_button"), disabled: import.complete? %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@
|
||||
action: "mouseenter->donut-chart#highlightSegment mouseleave->donut-chart#unhighlightSegment"
|
||||
} do %>
|
||||
<div class="flex items-center gap-3 flex-1 min-w-0">
|
||||
<div class="h-6 w-6 flex-shrink-0 group-hover:scale-105 transition-all duration-300 rounded-full flex justify-center items-center"
|
||||
<div class="h-6 w-6 flex-shrink-0 group-hover:scale-105 transition-all duration-300 rounded-full flex justify-center items-center"
|
||||
style="
|
||||
background-color: color-mix(in oklab, <%= category[:color] %> 10%, transparent);
|
||||
border-color: color-mix(in oklab, <%= category[:color] %> 10%, transparent);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<header class="flex justify-between items-center text-primary font-medium">
|
||||
<h1 class="text-xl"><%= t("recurring_transactions.title") %></h1>
|
||||
<div class="flex items-center gap-2">
|
||||
<% unless @family.recurring_transactions_disabled? %>
|
||||
<% unless @family.recurring_transactions_disabled? %>
|
||||
<%= render DS::Menu.new do |menu| %>
|
||||
<% menu.with_item(
|
||||
variant: "button",
|
||||
|
||||
@@ -117,7 +117,7 @@
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
<% @llm_usages.each do |usage| %>
|
||||
<tr class="<%= 'bg-red-50 theme-dark:bg-red-950/30' if usage.failed? %>">
|
||||
<tr class="<%= "bg-red-50 theme-dark:bg-red-950/30" if usage.failed? %>">
|
||||
<td class="px-4 py-3 text-sm text-primary whitespace-nowrap">
|
||||
<%= usage.created_at.strftime("%b %d, %Y %I:%M %p") %>
|
||||
</td>
|
||||
|
||||
@@ -26,7 +26,6 @@
|
||||
</turbo-frame>
|
||||
<% end %>
|
||||
|
||||
|
||||
<%= settings_section title: "Enable Banking (beta)", collapsible: true, open: false do %>
|
||||
<turbo-frame id="enable_banking-providers-panel">
|
||||
<%= render "settings/providers/enable_banking_panel" %>
|
||||
|
||||
@@ -10,4 +10,3 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@
|
||||
<div class="pl-0.5 col-span-8 flex items-center gap-4">
|
||||
<%= check_box_tag "selection_entry",
|
||||
class: "checkbox checkbox--light hidden lg:block",
|
||||
data: {
|
||||
data: {
|
||||
action: "bulk-select#togglePageSelection",
|
||||
checkbox_toggle_target: "selectionEntry"
|
||||
} %>
|
||||
|
||||
@@ -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
|
||||
4
db/schema.rb
generated
4
db/schema.rb
generated
@@ -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
|
||||
|
||||
6
test/fixtures/lunchflow_accounts.yml
vendored
Normal file
6
test/fixtures/lunchflow_accounts.yml
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
investment_account:
|
||||
lunchflow_item: one
|
||||
account_id: "lf_acc_investment_1"
|
||||
name: "Test Investment Account"
|
||||
currency: USD
|
||||
holdings_supported: true
|
||||
5
test/fixtures/lunchflow_items.yml
vendored
Normal file
5
test/fixtures/lunchflow_items.yml
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
one:
|
||||
family: dylan_family
|
||||
name: "Test Lunchflow Connection"
|
||||
api_key: "test_api_key_123"
|
||||
status: good
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user