Files
sure/app/models/lunchflow_account/investments/holdings_processor.rb
soky srm ca4fb7995c Implement holdings for lunch flow (#590)
* Implement holdings for lunch flow

* Implement holdings function call
2026-01-09 13:14:14 +01:00

185 lines
6.1 KiB
Ruby

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