mirror of
https://github.com/we-promise/sure.git
synced 2026-04-20 20:44:08 +00:00
Implement holdings for lunch flow (#590)
* Implement holdings for lunch flow * Implement holdings function call
This commit is contained in:
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(
|
||||
|
||||
Reference in New Issue
Block a user