mirror of
https://github.com/we-promise/sure.git
synced 2026-04-09 15:24:48 +00:00
* 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>
150 lines
5.0 KiB
Ruby
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
|