Files
sure/app/models/security.rb
T
buzzromainandClaude Opus 5 d7bf401cc7 fix(onchain-wallets): call transfers transfers, and drop the address swap (#3153)
* fix(onchain-wallets): call transfers transfers, and drop the address swap

Three things reported from real use.

**A transfer was announced as a purchase.** A movement was imported with
`activity_label: "Buy"/"Sell"` and named "Buy 1.5 FAKE" — but coins arriving at
an address were not bought there, and nothing here knows whether they were ever
bought at all. The trade shape stays, because it is what carries quantity and
cost basis in this ledger, but the label is now "Transfer" and the name is the
one the movement already had while it was unpriced. The old wording also made
the same event rename itself the day a price turned up for it.

Worth pairing with the change to trades/_header.html.erb, which until now read
the amount's sign and would still say "Buy" whatever the label.

**Changing a tracked address is gone.** It repointed the rows at a new address
while keeping their accounts, holdings and history — so trades reconstructed
from address A stayed under an account presented as address B. Its own help
text said so out loud: "The accounts, holdings and history stay as they are."
Removing the address and adding the new one is not just simpler, it is the only
one of the two that is honest, because it takes the old history with the old
address.

**The buttons follow the app's conventions now.** Actions that repeat per row
belong in a menu here — accounts/_account.html.erb renders its own that way —
not in a row of labelled buttons, which is what a provider panel does when it
has a single connection to act on. So the per-address actions are a menu, the
per-asset disconnect is icon-only, and the accounts-page card gains the actions
menu every other provider card already had.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(onchain-wallets): give a tracked asset its icon on the accounts page

Account#logo_url asks its provider adapter for one, and ours answered nothing:
there is no institution behind a self-custody wallet, and nothing attaches a
file, so every tracked asset showed a blank where every other account shows an
icon. Fixing Security#crypto_base_asset covered the holdings list, which reads
the security directly — this is the other path, and it went through the adapter.

Built from the symbol rather than looked up. The accounts page renders one of
these per account, so resolving a Security each would be a query per row, and
the symbol is all Brandfetch's crypto endpoint needs. It answers nil without a
client id, which is the same nothing the page shows today.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(onchain-wallets): follow the moved banner and the menu in the browser

The system suite still expected the settings panel to carry the read-only
reassurance, and to find "Review tokens" as a visible button. Both moved in the
previous commit: the banner into the linking modal, where the question it
answers is actually asked, and the per-address actions into a menu, as repeated
row actions are rendered everywhere else in this app.

Caught by CI rather than by me — the per-branch checks I ran covered
`bin/rails test` and not `test:system`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(onchain-wallets): pin the reassurance to the frame it moved into

The assertion proved the banner was somewhere on the page, which is exactly
what the change does not claim: the point is where it lives. Now it asserts the
text is absent from the settings panel and present inside the modal, so the
test fails if the banner drifts back or never arrives.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(onchain-wallets): rename movements imported before transfers had a name

Review on #3153.

Wallets synced before this change keep their `Buy`/`Sell` labels and their
"Buy 1.5 shares of CRYPTO:BTC" wording, and nothing was rewriting them:
`perform_sync` returns early when no address changed on chain, and the
repair pass only ever looked at display-only `Transaction` rows. A cold
address would have shown the old wording indefinitely — which for a wallet
nobody touches is most of them.

The repair now relabels this processor's own trades too, scoped to its
`external_id` prefix and to `source: SOURCE` so a trade the user entered by
hand is never renamed. It runs from `perform_post_sync`, which is the pass
that already runs for every linked asset rather than only the changed ones.
Idempotent, so a nightly sync does not rewrite the same rows forever.

Separately: `Security.brandfetch_crypto_url` interpolated the symbol
straight into a URL path, and `Onchain::AssetSymbol.canonical` only upcases
and trims. An on-chain token can be called whatever its deployer chose, so
a slash pointed the path elsewhere on the CDN and a hash pushed the client
id into a fragment Brandfetch never sees. Guarded in the helper rather than
at the call site — six callers reach it from four providers.

Also from review: the icon test saves and restores
`Setting.brand_fetch_client_id` instead of hard-coding nil in its `ensure`,
which was erasing whatever the suite had configured.

The "one query" claim in a repair test's name was never asserted, and this
change adds a second query. Renamed to what it actually checks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye

* test(onchain): drop the last change_address test with its feature

#3182 added a `change_address` test while this branch was removing the
feature it exercises. The rebase kept both, leaving a test calling a route
this branch deletes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 21:07:58 +02:00

203 lines
7.7 KiB
Ruby

class Security < ApplicationRecord
include Provided, PlanRestrictionTracker
# Transient attribute for search results -- not persisted
attr_accessor :search_currency
# ISO 10383 MIC codes mapped to user-friendly exchange names
# Source: https://www.iso20022.org/market-identifier-codes
# Data stored in config/exchanges.yml
EXCHANGES = YAML.safe_load_file(Rails.root.join("config", "exchanges.yml")).freeze
KINDS = %w[standard cash].freeze
# Known securities provider keys — derived from the registry so adding a new
# provider to Registry#available_providers automatically allows it here.
# Evaluated at runtime (not boot) so runtime-enabled providers are accepted.
def self.valid_price_providers
Provider::Registry.for_concept(:securities).provider_keys.map(&:to_s)
end
# Builds the Brandfetch crypto URL for a base asset (e.g. "BTC"). Returns
# nil when Brandfetch isn't configured.
# The symbol goes into a URL path segment, and it comes from provider data —
# an on-chain token can be called anything its deployer chose. A slash, a
# question mark or a hash would not merely break the link: they would point
# the path elsewhere on the CDN, or push the client id into a fragment where
# Brandfetch never sees it. Guarded here rather than at each call site, since
# six of them reach this method from four different providers.
SAFE_CRYPTO_SYMBOL = /\A[A-Za-z0-9][A-Za-z0-9.\-]{0,31}\z/
def self.brandfetch_crypto_url(base_asset)
return nil if base_asset.blank?
return nil unless base_asset.to_s.match?(SAFE_CRYPTO_SYMBOL)
return nil unless Setting.brand_fetch_client_id.present?
size = Setting.brand_fetch_logo_size
"https://cdn.brandfetch.io/crypto/#{base_asset}/icon/fallback/lettermark/w/#{size}/h/#{size}?c=#{Setting.brand_fetch_client_id}"
end
before_validation :upcase_symbols
before_save :generate_logo_url_from_brandfetch, if: :should_generate_logo?
before_save :reset_first_provider_price_on_if_provider_changed
has_many :trades, dependent: :nullify, class_name: "Trade"
has_many :prices, dependent: :destroy
validates :ticker, presence: true
validates :ticker, uniqueness: { scope: :exchange_operating_mic, case_sensitive: false }
validates :kind, inclusion: { in: KINDS }
validates :price_provider, inclusion: { in: ->(_) { Security.valid_price_providers } }, allow_nil: true
scope :online, -> { where(offline: false) }
scope :standard, -> { where(kind: "standard") }
# Parses the combobox ID format "SYMBOL|EXCHANGE|PROVIDER" into a hash.
def self.parse_combobox_id(value)
parts = value.to_s.split("|", 3)
{ ticker: parts[0].presence, exchange_operating_mic: parts[1].presence, price_provider: parts[2].presence }
end
# Lazily finds or creates a synthetic cash security for an account.
# Used as fallback when creating an interest Trade without a user-selected
# security, and to represent non-primary-currency cash positions as holdings
# (issue #1809). When a currency that differs from the account's primary
# currency is given, a distinct per-currency security is created so balances
# in different currencies don't collide.
def self.cash_for(account, currency: nil)
distinct = currency.present? && currency.to_s.upcase != account.currency.to_s.upcase
ticker = (distinct ? "CASH-#{account.id}-#{currency}" : "CASH-#{account.id}").upcase
find_or_create_by!(ticker: ticker, kind: "cash") do |s|
s.name = distinct ? "Cash (#{currency.to_s.upcase})" : "Cash"
s.offline = true
end
end
def cash?
kind == "cash"
end
# True when this security represents a crypto asset. Today the only signal
# is the Binance ISO MIC — when we add a second crypto provider, extend
# this check rather than duplicating the test at every call site.
def crypto?
exchange_operating_mic == Provider::BinancePublic::BINANCE_MIC
end
# Strips the display-currency suffix from a crypto ticker (BTCUSD -> BTC,
# ETHEUR -> ETH). Returns nil for non-crypto securities or when the ticker
# doesn't end in a supported quote.
def crypto_base_asset
return nil unless crypto?
# Delegated rather than parsed here: this stripped a fiat suffix only, so it
# answered nil for the "CRYPTO:BTC" form the holdings processors store — the
# form every crypto integration writes — and those securities carried no
# logo at all. The provider already parses every shape it accepts.
Provider::BinancePublic.parse_ticker(ticker)&.dig(:base)
end
# Single source of truth for which logo URL the UI should render.
# - Crypto keeps its dedicated Brandfetch-crypto shape.
# - When a website domain is known, Brandfetch (consistent client_id + size)
# wins, falling back to any stored logo_url.
# - With no domain, a stored provider logo (e.g. T-Invest's CDN for MOEX
# instruments) is authoritative and beats the ticker-only Brandfetch
# lettermark placeholder.
def display_logo_url
if crypto?
self.class.brandfetch_crypto_url(crypto_base_asset).presence || logo_url.presence
elsif website_url.present?
brandfetch_icon_url.presence || logo_url.presence
else
logo_url.presence || brandfetch_icon_url.presence
end
end
# Returns user-friendly exchange name for a MIC code
def self.exchange_name_for(mic)
return nil if mic.blank?
EXCHANGES.dig(mic.upcase, "name") || mic.upcase
end
def exchange_name
self.class.exchange_name_for(exchange_operating_mic)
end
def current_price
@current_price ||= find_or_fetch_price
return nil if @current_price.nil?
Money.new(@current_price.price, @current_price.currency)
end
def to_combobox_option
ComboboxOption.new(
symbol: ticker,
name: name,
logo_url: logo_url,
exchange_operating_mic: exchange_operating_mic,
country_code: country_code,
price_provider: price_provider,
currency: search_currency
)
end
def brandfetch_icon_url(width: nil, height: nil)
return nil unless Setting.brand_fetch_client_id.present?
w = width || Setting.brand_fetch_logo_size
h = height || Setting.brand_fetch_logo_size
identifier = extract_domain(website_url) if website_url.present?
identifier ||= ticker
return nil unless identifier.present?
"https://cdn.brandfetch.io/#{identifier}/icon/fallback/lettermark/w/#{w}/h/#{h}?c=#{Setting.brand_fetch_client_id}"
end
private
def extract_domain(url)
uri = URI.parse(url)
host = uri.host || url
host.sub(/\Awww\./, "")
rescue URI::InvalidURIError
nil
end
def upcase_symbols
self.ticker = ticker.upcase
self.exchange_operating_mic = exchange_operating_mic.upcase if exchange_operating_mic.present?
end
def should_generate_logo?
return false if cash?
return false unless Setting.brand_fetch_client_id.present?
return true if logo_url.blank?
return false unless logo_url.include?("cdn.brandfetch.io")
website_url_changed? || ticker_changed?
end
def generate_logo_url_from_brandfetch
self.logo_url = if crypto?
self.class.brandfetch_crypto_url(crypto_base_asset)
else
brandfetch_icon_url
end
end
# When a user remaps a security to a different provider (via the holdings
# remap combobox or Security::Resolver), the previously-discovered
# first_provider_price_on belongs to the OLD provider and may no longer
# reflect what the new provider can serve. Reset it so the next sync's
# fallback rediscovers the correct earliest date for the new provider.
# Skip when the caller explicitly set both columns in the same save.
def reset_first_provider_price_on_if_provider_changed
return unless price_provider_changed?
return if first_provider_price_on_changed?
self.first_provider_price_on = nil
end
end