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

152 lines
4.3 KiB
Ruby

class Trade::CreateForm
include ActiveModel::Model
attr_accessor :account, :date, :amount, :currency, :qty,
:price, :fee, :ticker, :manual_ticker, :type, :transfer_account_id
# Either creates a trade, transaction, or transfer based on type
# Returns the model, regardless of success or failure
def create
case type
when "buy", "sell"
create_trade
when "dividend"
create_dividend_income
when "interest"
create_interest_income
when "deposit", "withdrawal"
create_transfer
end
end
private
# Users can either look up a ticker from a provider or enter a manual, "offline" ticker (that we won't fetch prices for)
def security
ticker_symbol, exchange_operating_mic = ticker.present? ? ticker.split("|") : [ manual_ticker, nil ]
Security::Resolver.new(
ticker_symbol,
exchange_operating_mic: exchange_operating_mic
).resolve
end
def ticker_present?
ticker.present? || manual_ticker.present?
end
def create_trade
signed_qty = type == "sell" ? -qty.to_d : qty.to_d
signed_amount = signed_qty * price.to_d + fee.to_d
trade_entry = account.entries.new(
name: Trade.build_name(type, qty, security.ticker),
date: date,
amount: signed_amount,
currency: currency,
entryable: Trade.new(
qty: signed_qty,
price: price,
fee: fee.to_d,
currency: currency,
security: security,
investment_activity_label: type.capitalize # "buy" → "Buy", "sell" → "Sell"
)
)
if trade_entry.save
trade_entry.lock_saved_attributes!
account.sync_later
end
trade_entry
end
# Dividends are always a Trade. Security is required.
def create_dividend_income
unless ticker_present?
entry = account.entries.build(entryable: Trade.new)
entry.errors.add(:base, I18n.t("trades.form.dividend_requires_security"))
return entry
end
begin
sec = security
create_income_trade(sec: sec, label: "Dividend", name: "Dividend: #{sec.ticker}")
rescue => e
Rails.logger.warn("Dividend security resolution failed: #{e.class} - #{e.message}")
entry = account.entries.build(entryable: Trade.new)
entry.errors.add(:base, I18n.t("trades.form.dividend_requires_security"))
entry
end
end
# Interest in an investment account is always a Trade.
# Falls back to a synthetic cash security when none is selected.
def create_interest_income
sec = ticker_present? ? security : Security.cash_for(account)
name = sec.cash? ? "Interest" : "Interest: #{sec.ticker}"
create_income_trade(sec: sec, label: "Interest", name: name)
end
def create_income_trade(sec:, label:, name:)
entry = account.entries.build(
name: name,
date: date,
amount: amount.to_d * -1,
currency: currency,
entryable: Trade.new(
qty: 0,
price: 0,
fee: 0,
currency: currency,
security: sec,
investment_activity_label: label
)
)
if entry.save
entry.lock_saved_attributes!
account.sync_later
end
entry
end
def create_transfer
if transfer_account_id.present?
from_account_id = type == "withdrawal" ? account.id : transfer_account_id
to_account_id = type == "withdrawal" ? transfer_account_id : account.id
Transfer::Creator.new(
family: account.family,
source_account_id: from_account_id,
destination_account_id: to_account_id,
date: date,
amount: amount
).create
else
create_unlinked_transfer
end
end
# If user doesn't provide the reciprocal account, it's a regular transaction
def create_unlinked_transfer
signed_amount = type == "deposit" ? amount.to_d * -1 : amount.to_d
entry = account.entries.build(
name: signed_amount < 0 ? "Deposit to #{account.name}" : "Withdrawal from #{account.name}",
date: date,
amount: signed_amount,
currency: currency,
entryable: Transaction.new
)
if entry.save
entry.lock_saved_attributes!
account.sync_later
end
entry
end
end