mirror of
https://github.com/we-promise/sure.git
synced 2026-04-19 12:04:08 +00:00
Remove "value" from the market_value fallback chain in the SimpleFIN HoldingsProcessor and add it to the cost_basis fallback chain instead. Some brokerages (Vanguard, Fidelity) use "value" to represent cost basis, causing the system to display average cost per share as the current price and show massive phantom losses. https://claude.ai/code/session_01V2gC6BPT3sF7Hu4XQgUQT4 Co-authored-by: Claude <noreply@anthropic.com>
192 lines
7.6 KiB
Ruby
192 lines
7.6 KiB
Ruby
class SimplefinAccount::Investments::HoldingsProcessor
|
|
def initialize(simplefin_account)
|
|
@simplefin_account = simplefin_account
|
|
end
|
|
|
|
def process
|
|
return if holdings_data.empty?
|
|
return unless [ "Investment", "Crypto" ].include?(account&.accountable_type)
|
|
|
|
holdings_data.each do |simplefin_holding|
|
|
begin
|
|
symbol = simplefin_holding["symbol"].presence
|
|
holding_id = simplefin_holding["id"]
|
|
description = simplefin_holding["description"].to_s.strip
|
|
|
|
Rails.logger.debug({ event: "simplefin.holding.start", sfa_id: simplefin_account.id, account_id: account&.id, id: holding_id, symbol: symbol, raw: simplefin_holding }.to_json)
|
|
|
|
unless holding_id.present?
|
|
Rails.logger.debug({ event: "simplefin.holding.skip", reason: "missing_id", id: holding_id, symbol: symbol }.to_json)
|
|
next
|
|
end
|
|
|
|
# If symbol is missing but we have a description, create a synthetic ticker
|
|
# This allows tracking holdings like 401k funds that don't have standard symbols
|
|
# Append a hash suffix to ensure uniqueness for similar descriptions
|
|
if symbol.blank? && description.present?
|
|
normalized = description.gsub(/[^a-zA-Z0-9]/, "_").upcase.truncate(24, omission: "")
|
|
hash_suffix = Digest::MD5.hexdigest(description)[0..4].upcase
|
|
symbol = "CUSTOM:#{normalized}_#{hash_suffix}"
|
|
Rails.logger.info("SimpleFin: using synthetic ticker #{symbol} for holding #{holding_id} (#{description})")
|
|
end
|
|
|
|
unless symbol.present?
|
|
Rails.logger.debug({ event: "simplefin.holding.skip", reason: "no_symbol_or_description", id: holding_id }.to_json)
|
|
next
|
|
end
|
|
|
|
security = resolve_security(symbol, simplefin_holding["description"])
|
|
unless security.present?
|
|
Rails.logger.debug({ event: "simplefin.holding.skip", reason: "unresolved_security", id: holding_id, symbol: symbol }.to_json)
|
|
next
|
|
end
|
|
|
|
# Parse provider data with robust fallbacks across SimpleFin sources
|
|
# NOTE: "value" is intentionally excluded from the market_value fallback chain
|
|
# because some brokerages (e.g. Vanguard, Fidelity) use "value" to mean cost basis,
|
|
# which would cause the system to display average cost as current price. (GH #1182)
|
|
qty = parse_decimal(any_of(simplefin_holding, %w[shares quantity qty units]))
|
|
market_value = parse_decimal(any_of(simplefin_holding, %w[market_value current_value]))
|
|
cost_basis = parse_decimal(any_of(simplefin_holding, %w[cost_basis basis total_cost value]))
|
|
|
|
# Derive price from market_value when possible; otherwise fall back to any price field
|
|
fallback_price = parse_decimal(any_of(simplefin_holding, %w[purchase_price price unit_price average_cost avg_cost]))
|
|
price = if qty > 0 && market_value > 0
|
|
market_value / qty
|
|
else
|
|
fallback_price || 0
|
|
end
|
|
|
|
# Compute an amount we can persist (some providers omit market_value)
|
|
computed_amount = if market_value > 0
|
|
market_value
|
|
elsif qty > 0 && price > 0
|
|
qty * price
|
|
else
|
|
0
|
|
end
|
|
|
|
# SimpleFIN holdings represent a current snapshot, not historical positions.
|
|
# Always use today's date regardless of the `created` timestamp (which is when
|
|
# the holding was first seen by SimpleFIN, not when we observed it).
|
|
holding_date = Date.current
|
|
|
|
# Skip zero positions with no value to avoid invisible rows
|
|
next if qty.to_d.zero? && computed_amount.to_d.zero?
|
|
|
|
saved = import_adapter.import_holding(
|
|
security: security,
|
|
quantity: qty,
|
|
amount: computed_amount,
|
|
currency: simplefin_holding["currency"].presence || "USD",
|
|
date: holding_date,
|
|
price: price,
|
|
cost_basis: cost_basis,
|
|
external_id: "simplefin_#{holding_id}",
|
|
account_provider_id: simplefin_account.account_provider&.id,
|
|
source: "simplefin",
|
|
delete_future_holdings: false # SimpleFin tracks each holding uniquely
|
|
)
|
|
|
|
Rails.logger.debug({ event: "simplefin.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)
|
|
rescue => e
|
|
ctx = (defined?(symbol) && symbol.present?) ? " #{symbol}" : ""
|
|
Rails.logger.error "Error processing SimpleFin holding#{ctx}: #{e.message}"
|
|
end
|
|
end
|
|
end
|
|
|
|
private
|
|
attr_reader :simplefin_account
|
|
|
|
def import_adapter
|
|
@import_adapter ||= Account::ProviderImportAdapter.new(account)
|
|
end
|
|
|
|
def account
|
|
simplefin_account.current_account
|
|
end
|
|
|
|
def holdings_data
|
|
# Use the dedicated raw_holdings_payload field
|
|
simplefin_account.raw_holdings_payload || []
|
|
end
|
|
|
|
def resolve_security(symbol, description)
|
|
# Normalize crypto tickers to a distinct namespace so they don't collide with equities
|
|
sym = symbol.to_s.upcase
|
|
is_crypto_account = account&.accountable_type == "Crypto" || simplefin_account.name.to_s.downcase.include?("crypto")
|
|
is_crypto_symbol = %w[BTC ETH SOL DOGE LTC BCH].include?(sym)
|
|
mentions_crypto = description.to_s.downcase.include?("crypto")
|
|
|
|
if !sym.include?(":") && (is_crypto_account || is_crypto_symbol || mentions_crypto)
|
|
sym = "CRYPTO:#{sym}"
|
|
end
|
|
|
|
# Custom tickers (from holdings without symbols) should always be offline
|
|
is_custom = sym.start_with?("CUSTOM:")
|
|
|
|
# Use Security::Resolver to find or create the security, but be resilient
|
|
begin
|
|
if is_custom
|
|
# Skip resolver for custom tickers - create offline security directly
|
|
raise "Custom ticker - skipping resolver"
|
|
end
|
|
Security::Resolver.new(sym).resolve
|
|
rescue => e
|
|
# If provider search fails or any unexpected error occurs, fall back to an offline security
|
|
Rails.logger.warn "SimpleFin: 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_holding_date(created_timestamp)
|
|
return nil unless created_timestamp
|
|
|
|
case created_timestamp
|
|
when Integer
|
|
Time.at(created_timestamp).to_date
|
|
when String
|
|
Date.parse(created_timestamp)
|
|
else
|
|
nil
|
|
end
|
|
rescue ArgumentError => e
|
|
Rails.logger.error "Failed to parse SimpleFin holding date #{created_timestamp}: #{e.message}"
|
|
nil
|
|
end
|
|
|
|
# Returns the first non-empty value for any of the provided keys in the given hash
|
|
def any_of(hash, keys)
|
|
return nil unless hash.respond_to?(:[])
|
|
Array(keys).each do |k|
|
|
# Support symbol or string keys
|
|
v = hash[k]
|
|
v = hash[k.to_s] if v.nil?
|
|
v = hash[k.to_sym] if v.nil?
|
|
return v if !v.nil? && v.to_s.strip != ""
|
|
end
|
|
nil
|
|
end
|
|
|
|
def parse_decimal(value)
|
|
return 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 SimpleFin decimal value #{value}: #{e.message}"
|
|
BigDecimal("0")
|
|
end
|
|
end
|