Files
sure/app/components/UI/account/chart.rb
Antoine 🚀🏃‍♂️ 6b6ae0ca31 feat(accounts): add Gains / ROI chart view for investment accounts (#2660)
* feat(accounts): add Gains / ROI chart view for investment accounts

Adds a fourth chart view to the account details page showing the
historical unrealized gains series (market value - cost basis per
holding, summed daily with LOCF and FX conversion), following the
same ChartSeriesBuilder/Series pipeline as the existing views.

Holdings without a usable cost basis (nil, or unlocked zero from
providers) contribute zero gain, matching Holding#avg_cost semantics.

* fix(accounts): carry cost basis forward over gap-filled holdings in gains series

Gap-filled holding rows (weekends, price-history gaps) are persisted
without cost_basis even though the position and basis are unchanged,
which zeroed the gains series on those dates. Look up the basis from
the latest snapshot that has a usable one instead of reading it from
the current row, so already-persisted gap-filled rows are handled too.

* docs(accounts): add method docstrings for gains chart view code

Satisfies the pre-merge docstring coverage check on methods added or
touched by the Gains / ROI feature.

* fix(accounts): sign converted amount like main indicator in gains view

Extracts the gains sign-prefix logic into a shared signed_format helper
so the family-currency converted amount shown on foreign-currency
accounts matches the main indicator (+€79.53 / +$85.00), and adds
component tests covering the positive, negative, non-gains and
foreign-currency formatting paths.

* test(accounts): cover FX conversion path in gains series

The gains_series tests only used USD holdings against a USD target,
leaving the exchange_rates LATERAL join untested. Adds a case with EUR
holdings converted to USD, including LOCF rate carry-forward.

---------

Co-authored-by: Antoine GUYON <agy@ibanfirst.com>
2026-07-25 20:14:56 +02:00

125 lines
3.4 KiB
Ruby

class UI::Account::Chart < ApplicationComponent
attr_reader :account
def initialize(account:, period: nil, view: nil)
@account = account
@period = period
@view = view
end
def period
@period ||= Period.last_30_days
end
def holdings_value_money
account.balance_money - account.cash_balance_money
end
# Money value shown as the main indicator for the selected chart view.
def view_balance_money
case view
when "balance"
account.balance_money
when "holdings_balance"
holdings_value_money
when "cash_balance"
account.cash_balance_money
when "gains"
gains_money
end
end
# Formatted main indicator. Gains are signed explicitly (e.g. "+€79.53") since
# a gain of zero-or-more is otherwise indistinguishable from a balance.
def view_balance_display
signed_format(view_balance_money)
end
# Formatted family-currency amount for foreign-currency accounts, signed the
# same way as the main indicator. Nil when no conversion applies.
def converted_balance_display
converted_balance_money&.then { |money| signed_format(money) }
end
# Label displayed above the main indicator, based on account type and chart view.
def title
case account.accountable_type
when "Investment", "Crypto"
case view
when "balance"
I18n.t("UI.account.chart.title.total_account_value")
when "holdings_balance"
I18n.t("UI.account.chart.title.holdings_value")
when "cash_balance"
I18n.t("UI.account.chart.title.cash_value")
when "gains"
I18n.t("UI.account.chart.title.total_gains")
end
when "Property"
I18n.t("UI.account.chart.title.estimated_property_value")
when "Vehicle"
I18n.t("UI.account.chart.title.estimated_vehicle_value")
when "CreditCard", "OtherLiability"
I18n.t("UI.account.chart.title.debt_balance")
when "Loan"
I18n.t("UI.account.chart.title.remaining_principal_balance")
else
I18n.t("UI.account.chart.title.balance")
end
end
def foreign_currency?
account.currency != account.family.currency
end
# Main indicator converted to the family currency for foreign-currency accounts,
# or nil when no conversion applies (same currency or missing exchange rate).
def converted_balance_money
return nil unless foreign_currency?
begin
base_money = view == "gains" ? gains_money : account.balance_money
base_money.exchange_to(account.family.currency)
rescue Money::ConversionError
nil
end
end
def view
@view ||= "balance"
end
def series
account.balance_series(period: period, view: view)
end
# Current total unrealized gains, taken from the series so the main indicator
# always matches the last point of the chart (there is no stored gains column).
def gains_money
series.values.last&.value || Money.new(0, account.currency)
end
def trend
series.trend
end
def comparison_label
start_date = series.start_date
return period.comparison_label if start_date.blank?
if start_date > period.start_date
I18n.t("UI.account.chart.vs_available_history")
else
period.comparison_label
end
end
private
# Prefixes positive gains with "+"; other views keep plain Money formatting.
def signed_format(money)
return money.format unless view == "gains" && money.amount.positive?
"+#{money.format}"
end
end