Files
sure/app/controllers/trades_controller.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

150 lines
5.0 KiB
Ruby

class TradesController < ApplicationController
include EntryableResource
before_action :set_entry_for_unlock, only: :unlock
# Defaults to a buy trade
def new
@account = accessible_accounts.find_by(id: params[:account_id])
@model = Current.family.entries.new(
account: @account,
currency: @account ? @account.currency : Current.family.currency,
entryable: Trade.new
)
end
# Can create a trade, transaction (e.g. "fees"), or transfer (e.g. "withdrawal")
def create
@account = accessible_accounts.find(params[:account_id])
return unless require_account_permission!(@account)
@model = Trade::CreateForm.new(create_params.merge(account: @account)).create
if @model.persisted?
# Mark manually created entries as user-modified to protect from sync
if @model.is_a?(Entry)
@model.lock_saved_attributes!
@model.mark_user_modified!
end
flash[:notice] = t("entries.create.success")
respond_to do |format|
format.html { redirect_back_or_to account_path(@account) }
format.turbo_stream { stream_redirect_back_or_to account_path(@account) }
end
else
render :new, status: :unprocessable_entity
end
end
def update
return unless require_account_permission!(@entry.account)
if @entry.update(update_entry_params)
@entry.lock_saved_attributes!
@entry.mark_user_modified!
@entry.sync_account_later
# Reload to ensure fresh state for turbo stream rendering
@entry.reload
respond_to do |format|
format.html { redirect_back_or_to account_path(@entry.account), notice: t("entries.update.success") }
format.turbo_stream do
render turbo_stream: [
turbo_stream.replace(
dom_id(@entry, :header),
partial: "trades/header",
locals: { entry: @entry }
),
turbo_stream.replace(
dom_id(@entry, :protection),
partial: "entries/protection_indicator",
locals: { entry: @entry, unlock_path: unlock_trade_path(@entry.trade) }
),
turbo_stream.replace(@entry)
]
end
end
else
render :show, status: :unprocessable_entity
end
end
def unlock
return unless require_account_permission!(@entry.account)
@entry.unlock_for_sync!
flash[:notice] = t("entries.unlock.success")
redirect_back_or_to account_path(@entry.account)
end
private
def set_entry_for_unlock
trade = Current.family.trades
.joins(entry: :account)
.merge(Account.accessible_by(Current.user))
.find(params[:id])
@entry = trade.entry
end
def entry_params
params.require(:entry).permit(
:name, :date, :amount, :currency, :excluded, :notes, :nature,
entryable_attributes: [ :id, :qty, :price, :fee, :investment_activity_label ]
)
end
def create_params
params.require(:model).permit(
:date, :amount, :currency, :qty, :price, :fee, :ticker, :manual_ticker, :type, :transfer_account_id
)
end
def update_entry_params
update_params = entry_params
# Income trades (Dividend/Interest) store amounts as negative (inflow convention).
# The form displays the absolute value, so we re-negate before saving.
if %w[Dividend Interest].include?(@entry.trade&.investment_activity_label) && update_params[:amount].present?
update_params = update_params.merge(amount: -update_params[:amount].to_d.abs)
end
return update_params unless update_params[:entryable_attributes].present?
update_params = update_params.merge(entryable_type: "Trade")
qty = update_params[:entryable_attributes][:qty]
price = update_params[:entryable_attributes][:price]
fee = update_params[:entryable_attributes][:fee]
nature = update_params[:nature]
if qty.present? && price.present?
is_sell = nature == "inflow"
qty = is_sell ? -qty.to_d.abs : qty.to_d.abs
fee_val = fee.present? ? fee.to_d : (@entry.trade&.fee || 0)
update_params[:entryable_attributes][:qty] = qty
update_params[:amount] = qty * price.to_d + fee_val
# Sync investment_activity_label with Buy/Sell type if not explicitly set to something else
# Check both the submitted param and the existing record's label
current_label = update_params[:entryable_attributes][:investment_activity_label].presence ||
@entry.trade&.investment_activity_label
if current_label.blank? || current_label == "Buy" || current_label == "Sell"
update_params[:entryable_attributes][:investment_activity_label] = is_sell ? "Sell" : "Buy"
end
# Update entry name to reflect Buy/Sell change
ticker = @entry.trade&.security&.ticker
if ticker.present?
update_params[:name] = Trade.build_name(is_sell ? "sell" : "buy", qty.abs, ticker)
end
end
update_params.except(:nature)
end
end