Files
sure/app/models/security.rb
Serge L ab9b97639b Record dividends and interest as Trades in investment accounts (#1311)
* Record dividends and interest as Trades in investment accounts

All investment income (dividends and interest) is now modeled as a
Trade with qty: 0 and price: 0, keeping security_id NOT NULL on trades
intact. Dividends require a security; interest falls back to a
per-account synthetic cash security (kind: "cash", offline: true) when
none is selected, matching how brokerages handle uninvested cash
internally.

- Add `kind` column to securities ("standard" | "cash") with DB check
  constraint; `Security.cash_for(account)` lazily finds or creates the
  synthetic cash security; `scope :standard` excludes synthetic
  securities from user-facing pickers
- Trade::CreateForm: new `dividend` type (security required); `interest`
  now creates a Trade instead of a Transaction
- Trade form: Dividend and Interest in the type dropdown with a security
  combobox (required for dividend, optional for interest)
- transactions table: untouched

* UI fixes

* HealthChecker — both scopes now chain .standard to exclude cash securities from provider health checks.

DB query moved to model — Account#traded_standard_securities in app/models/account.rb, view uses account.traded_standard_securities.

DRY income creation — create_income_trade(sec:, label:, name:) extracted as shared private method; create_dividend_income and create_interest_income delegate to it.

show.html.erb blocks merged — single unless trade.qty.zero? block covers qty/price/fee fields.

Test extended — assert_response :unprocessable_entity added after the assert_no_difference block.

* Hide cash account ticker from no-security trade detail

* Fix CodeRabbit review issues from PR #1311

- Remove duplicate YAML keys in translation files (de, es, fr)
- Add error handling for security resolution in create_dividend_income
- Extract income trade check to reduce duplication in header template

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>

* Include holdings in dividend/interest security picker

The security picker for dividend/interest trades should include all securities
in holdings, not just those with trade history. This fixes the issue where
accounts with imported holdings (e.g., SimpleFIN) but no trades would have an
empty picker and be unable to record dividends.

Uses UNION to combine securities from both trades and holdings.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>

* scope picker to holdings only (a trade creates a holding anyway)

---------

Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
2026-03-29 10:08:54 +02:00

108 lines
3.0 KiB
Ruby

class Security < ApplicationRecord
include Provided, PlanRestrictionTracker
# 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
before_validation :upcase_symbols
before_save :generate_logo_url_from_brandfetch, if: :should_generate_logo?
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 }
scope :online, -> { where(offline: false) }
scope :standard, -> { where(kind: "standard") }
# Lazily finds or creates a synthetic cash security for an account.
# Used as fallback when creating an interest Trade without a user-selected security.
def self.cash_for(account)
ticker = "CASH-#{account.id}".upcase
find_or_create_by!(ticker: ticker, kind: "cash") do |s|
s.name = "Cash"
s.offline = true
end
end
def cash?
kind == "cash"
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
)
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?
url = brandfetch_icon_url
return false unless url.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 = brandfetch_icon_url
end
end