Add gains by tax treatment to investment report with grouped subtype dropdown (#701)

* Add tax treatment metrics to reports, forms, and models

- Implement `build_gains_by_tax_treatment` for grouping gains by tax treatment
- Update investment performance view with tax treatment breakdown
- Add tax treatment field to crypto and investments forms
- Introduce `realized_gain_loss` calculation in the Trade model
- Group investment subtypes by region for improved dropdown organization

* Optimize investment performance report by reducing N+1 queries

- Eager-load associations in `build_gains_by_tax_treatment` to minimize database queries
- Preload holdings for realized gain/loss calculations in trades
- Refactor views to standardize "no data" placeholder using translations
- Adjust styling in tax treatment breakdown for improved layout

* Enhance investment performance translations and optimize holdings lookup logic

- Update `holdings_count` and `sells_count` translations to handle pluralization
- Refactor views to use pluralized translation keys with count interpolation
- Optimize preloaded holdings lookup in `Trade` to ensure deterministic selection using `select` and `max_by`

* Refine preloaded holdings logic in `Trade` model

- Treat empty preloaded holdings as authoritative to prevent unnecessary DB queries
- Add explicit fallback behavior for database query when holdings are not preloaded

---------

Co-authored-by: luckyPipewrench <luckypipewrench@proton.me>
This commit is contained in:
LPW
2026-01-19 09:44:49 -05:00
committed by GitHub
parent 7f0781179c
commit bf9bcae600
10 changed files with 261 additions and 5 deletions

View File

@@ -415,10 +415,75 @@ class ReportsController < ApplicationController
period_contributions: period_totals.contributions,
period_withdrawals: period_totals.withdrawals,
top_holdings: investment_statement.top_holdings(limit: 5),
accounts: investment_accounts.to_a
accounts: investment_accounts.to_a,
gains_by_tax_treatment: build_gains_by_tax_treatment(investment_statement)
}
end
def build_gains_by_tax_treatment(investment_statement)
currency = Current.family.currency
# Eager-load account and accountable to avoid N+1 when accessing tax_treatment
current_holdings = investment_statement.current_holdings
.includes(account: :accountable)
.to_a
# Group holdings by tax treatment (from account)
holdings_by_treatment = current_holdings.group_by { |h| h.account.tax_treatment || :taxable }
# Get sell trades in period with realized gains
# Eager-load security, account, and accountable to avoid N+1
sell_trades = Current.family.trades
.joins(:entry)
.where(entries: { date: @period.date_range })
.where("trades.qty < 0")
.includes(:security, entry: { account: :accountable })
.to_a
# Preload holdings for all accounts that have sell trades to avoid N+1 in realized_gain_loss
account_ids = sell_trades.map { |t| t.entry.account_id }.uniq
holdings_by_account = Holding
.where(account_id: account_ids)
.where("date <= ?", @period.date_range.end)
.order(date: :desc)
.group_by(&:account_id)
# Inject preloaded holdings into trades for realized_gain_loss calculation
sell_trades.each do |trade|
trade.instance_variable_set(:@preloaded_holdings, holdings_by_account[trade.entry.account_id] || [])
end
trades_by_treatment = sell_trades.group_by { |t| t.entry.account.tax_treatment || :taxable }
# Build metrics per treatment
%i[taxable tax_deferred tax_exempt tax_advantaged].each_with_object({}) do |treatment, hash|
holdings = holdings_by_treatment[treatment] || []
trades = trades_by_treatment[treatment] || []
# Sum unrealized gains from holdings (only those with known cost basis)
unrealized = holdings.sum do |h|
trend = h.trend
trend ? trend.value : 0
end
# Sum realized gains from sell trades
realized = trades.sum do |t|
gain = t.realized_gain_loss
gain ? gain.value : 0
end
# Only include treatment groups that have some activity
next if holdings.empty? && trades.empty?
hash[treatment] = {
holdings: holdings,
sell_trades: trades,
unrealized_gain: Money.new(unrealized, currency),
realized_gain: Money.new(realized, currency),
total_gain: Money.new(unrealized + realized, currency)
}
end
end
def build_net_worth_metrics
balance_sheet = Current.family.balance_sheet
currency = Current.family.currency