Files
sure/app/models/trade.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

87 lines
2.4 KiB
Ruby

class Trade < ApplicationRecord
include Entryable, Monetizable
monetize :price
monetize :fee
belongs_to :security
belongs_to :category, optional: true
# Use the same activity labels as Transaction
ACTIVITY_LABELS = Transaction::ACTIVITY_LABELS.dup.freeze
validates :qty, presence: true
validates :price, :currency, presence: true
validates :investment_activity_label, inclusion: { in: ACTIVITY_LABELS }, allow_nil: true
# Trade types for categorization
def buy?
qty.positive?
end
def sell?
qty.negative?
end
class << self
def build_name(type, qty, ticker)
prefix = type == "buy" ? "Buy" : "Sell"
"#{prefix} #{qty.to_d.abs} shares of #{ticker}"
end
end
def unrealized_gain_loss
return nil unless qty.positive?
current_price = security.current_price
return nil if current_price.nil?
current_value = current_price * qty.abs
cost_basis = price_money * qty.abs
Trend.new(current: current_value, previous: cost_basis)
end
# Calculates realized gain/loss for sell trades based on avg_cost at time of sale
# Returns nil for buy trades or when cost basis cannot be determined
def realized_gain_loss
return @realized_gain_loss if defined?(@realized_gain_loss)
@realized_gain_loss = calculate_realized_gain_loss
end
# Trades are always excluded from expense budgets
# They represent portfolio management, not living expenses
def excluded_from_budget?
true
end
private
def calculate_realized_gain_loss
return nil unless sell?
# Use preloaded holdings if available (set by reports controller to avoid N+1)
# Treat defined-but-empty preload as authoritative to prevent DB fallback
holding = if defined?(@preloaded_holdings)
# Use select + max_by for deterministic selection regardless of array order
(@preloaded_holdings || [])
.select { |h| h.security_id == security_id && h.date <= entry.date }
.max_by(&:date)
else
# Fall back to database query only when not preloaded
entry.account.holdings
.where(security_id: security_id)
.where("date <= ?", entry.date)
.order(date: :desc)
.first
end
return nil unless holding&.avg_cost
cost_basis = holding.avg_cost * qty.abs
sale_proceeds = price_money * qty.abs
Trend.new(current: sale_proceeds, previous: cost_basis)
end
end