mirror of
https://github.com/we-promise/sure.git
synced 2026-04-15 01:54:07 +00:00
185 lines
6.1 KiB
Ruby
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
|