mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-07-19 05:09:38 +02:00
Show cash + holdings value for investment account view (#1046)
* Handle missing tickers in security price syncs * Show combined cash and holdings value on account page * Improve partial locals
This commit is contained in:
parent
453a54e5e6
commit
ea8309eedd
20 changed files with 168 additions and 33 deletions
14
app/controllers/account/cashes_controller.rb
Normal file
14
app/controllers/account/cashes_controller.rb
Normal file
|
@ -0,0 +1,14 @@
|
|||
class Account::CashesController < ApplicationController
|
||||
layout :with_sidebar
|
||||
|
||||
before_action :set_account
|
||||
|
||||
def index
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_account
|
||||
@account = Current.family.accounts.find(params[:account_id])
|
||||
end
|
||||
end
|
|
@ -13,7 +13,7 @@ class Account::EntriesController < ApplicationController
|
|||
end
|
||||
|
||||
def trades
|
||||
@trades = @account.entries.account_trades.reverse_chronological
|
||||
@trades = @account.entries.where(entryable_type: [ "Account::Transaction", "Account::Trade" ]).reverse_chronological
|
||||
end
|
||||
|
||||
def new
|
||||
|
|
|
@ -31,7 +31,8 @@ class AccountsController < ApplicationController
|
|||
end
|
||||
|
||||
def show
|
||||
@balance_series = @account.series(period: @period)
|
||||
@series = @account.series(period: @period)
|
||||
@trend = @series.trend
|
||||
end
|
||||
|
||||
def edit
|
||||
|
|
13
app/helpers/account/cashes_helper.rb
Normal file
13
app/helpers/account/cashes_helper.rb
Normal file
|
@ -0,0 +1,13 @@
|
|||
module Account::CashesHelper
|
||||
def brokerage_cash(account)
|
||||
currency = Money::Currency.new(account.currency)
|
||||
|
||||
account.holdings.build \
|
||||
date: Date.current,
|
||||
qty: account.balance,
|
||||
price: 1,
|
||||
amount: account.balance,
|
||||
currency: account.currency,
|
||||
security: Security.new(ticker: currency.iso_code, name: currency.name)
|
||||
end
|
||||
end
|
|
@ -25,11 +25,12 @@ module AccountsHelper
|
|||
|
||||
def account_tabs(account)
|
||||
holdings_tab = { key: "holdings", label: t("accounts.show.holdings"), path: account_path(account, tab: "holdings"), content_path: account_holdings_path(account) }
|
||||
cash_tab = { key: "cash", label: t("accounts.show.cash"), path: account_path(account, tab: "cash"), content_path: account_cashes_path(account) }
|
||||
value_tab = { key: "valuations", label: t("accounts.show.value"), path: account_path(account, tab: "valuations"), content_path: valuation_account_entries_path(account) }
|
||||
transactions_tab = { key: "transactions", label: t("accounts.show.transactions"), path: account_path(account, tab: "transactions"), content_path: transaction_account_entries_path(account) }
|
||||
trades_tab = { key: "trades", label: t("accounts.show.trades"), path: account_path(account, tab: "trades"), content_path: trade_account_entries_path(account) }
|
||||
|
||||
return [ holdings_tab, trades_tab ] if account.investment?
|
||||
return [ holdings_tab, cash_tab, trades_tab ] if account.investment?
|
||||
|
||||
[ value_tab, transactions_tab ]
|
||||
end
|
||||
|
|
|
@ -28,8 +28,10 @@ class Account < ApplicationRecord
|
|||
|
||||
delegated_type :accountable, types: Accountable::TYPES, dependent: :destroy
|
||||
|
||||
delegate :value, :series, to: :accountable
|
||||
|
||||
class << self
|
||||
def by_group(period: Period.all, currency: Money.default_currency)
|
||||
def by_group(period: Period.all, currency: Money.default_currency.iso_code)
|
||||
grouped_accounts = { assets: ValueGroup.new("Assets", currency), liabilities: ValueGroup.new("Liabilities", currency) }
|
||||
|
||||
Accountable.by_classification.each do |classification, types|
|
||||
|
@ -82,18 +84,6 @@ class Account < ApplicationRecord
|
|||
classification == "asset" ? "up" : "down"
|
||||
end
|
||||
|
||||
def series(period: Period.all, currency: self.currency)
|
||||
balance_series = balances.in_period(period).where(currency: Money::Currency.new(currency).iso_code)
|
||||
|
||||
if balance_series.empty? && period.date_range.end == Date.current
|
||||
TimeSeries.new([ { date: Date.current, value: balance_money.exchange_to(currency) } ])
|
||||
else
|
||||
TimeSeries.from_collection(balance_series, :balance_money)
|
||||
end
|
||||
rescue Money::ConversionError
|
||||
TimeSeries.new([])
|
||||
end
|
||||
|
||||
def update_balance!(balance)
|
||||
valuation = entries.account_valuations.find_by(date: Date.current)
|
||||
|
||||
|
|
|
@ -10,6 +10,8 @@ class Account::Holding < ApplicationRecord
|
|||
|
||||
scope :chronological, -> { order(:date) }
|
||||
scope :current, -> { where(date: Date.current).order(amount: :desc) }
|
||||
scope :in_period, ->(period) { period.date_range.nil? ? all : where(date: period.date_range) }
|
||||
scope :known_value, -> { where.not(amount: nil) }
|
||||
scope :for, ->(security) { where(security_id: security).order(:date) }
|
||||
|
||||
delegate :name, to: :security
|
||||
|
@ -18,7 +20,7 @@ class Account::Holding < ApplicationRecord
|
|||
def weight
|
||||
return nil unless amount
|
||||
|
||||
portfolio_value = account.holdings.current.where.not(amount: nil).sum(&:amount)
|
||||
portfolio_value = account.holdings.current.known_value.sum(&:amount)
|
||||
portfolio_value.zero? ? 1 : amount / portfolio_value * 100
|
||||
end
|
||||
|
||||
|
|
|
@ -17,4 +17,20 @@ module Accountable
|
|||
included do
|
||||
has_one :account, as: :accountable, touch: true
|
||||
end
|
||||
|
||||
def value
|
||||
account.balance_money
|
||||
end
|
||||
|
||||
def series(period: Period.all, currency: account.currency)
|
||||
balance_series = account.balances.in_period(period).where(currency: currency)
|
||||
|
||||
if balance_series.empty? && period.date_range.end == Date.current
|
||||
TimeSeries.new([ { date: Date.current, value: account.balance_money.exchange_to(currency) } ])
|
||||
else
|
||||
TimeSeries.from_collection(balance_series, :balance_money)
|
||||
end
|
||||
rescue Money::ConversionError
|
||||
TimeSeries.new([])
|
||||
end
|
||||
end
|
||||
|
|
|
@ -13,4 +13,35 @@ class Investment < ApplicationRecord
|
|||
[ "Roth 401k", "roth_401k" ],
|
||||
[ "Angel", "angel" ]
|
||||
].freeze
|
||||
|
||||
def value
|
||||
account.balance_money + holdings_value
|
||||
end
|
||||
|
||||
def holdings_value
|
||||
account.holdings.current.known_value.sum(&:amount) || Money.new(0, account.currency)
|
||||
end
|
||||
|
||||
def series(period: Period.all, currency: account.currency)
|
||||
balance_series = account.balances.in_period(period).where(currency: currency)
|
||||
holding_series = account.holdings.known_value.in_period(period).where(currency: currency)
|
||||
|
||||
holdings_by_date = holding_series.group_by(&:date).transform_values do |holdings|
|
||||
holdings.sum(&:amount)
|
||||
end
|
||||
|
||||
combined_series = balance_series.map do |balance|
|
||||
holding_amount = holdings_by_date[balance.date] || 0
|
||||
|
||||
{ date: balance.date, value: Money.new(balance.balance + holding_amount, currency) }
|
||||
end
|
||||
|
||||
if combined_series.empty? && period.date_range.end == Date.current
|
||||
TimeSeries.new([ { date: Date.current, value: self.value.exchange_to(currency) } ])
|
||||
else
|
||||
TimeSeries.new(combined_series)
|
||||
end
|
||||
rescue Money::ConversionError
|
||||
TimeSeries.new([])
|
||||
end
|
||||
end
|
||||
|
|
|
@ -24,6 +24,11 @@ class Provider::Synth
|
|||
prices: prices,
|
||||
success?: true,
|
||||
raw_response: prices.to_json
|
||||
rescue StandardError => error
|
||||
SecurityPriceResponse.new \
|
||||
success?: false,
|
||||
error: error,
|
||||
raw_response: error
|
||||
end
|
||||
|
||||
def fetch_exchange_rate(from:, to:, date:)
|
||||
|
|
21
app/views/account/cashes/_cash.html.erb
Normal file
21
app/views/account/cashes/_cash.html.erb
Normal file
|
@ -0,0 +1,21 @@
|
|||
<%# locals: (holding:) %>
|
||||
|
||||
<%= turbo_frame_tag dom_id(holding) do %>
|
||||
<div class="grid grid-cols-12 items-center text-gray-900 text-sm font-medium p-4">
|
||||
<div class="col-span-9 flex items-center gap-4">
|
||||
<%= render "shared/circle_logo", name: holding.name %>
|
||||
<div>
|
||||
<%= tag.p holding.name %>
|
||||
<%= tag.p holding.ticker, class: "text-gray-500 text-xs uppercase" %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-span-3 text-right">
|
||||
<% if holding.amount_money %>
|
||||
<%= tag.p format_money holding.amount_money %>
|
||||
<% else %>
|
||||
<%= tag.p "?", class: "text-gray-500" %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
18
app/views/account/cashes/index.html.erb
Normal file
18
app/views/account/cashes/index.html.erb
Normal file
|
@ -0,0 +1,18 @@
|
|||
<%= turbo_frame_tag dom_id(@account, "cash") do %>
|
||||
<div class="bg-white space-y-4 p-5 border border-alpha-black-25 rounded-xl shadow-xs">
|
||||
<div class="flex items-center justify-between">
|
||||
<%= tag.h2 t(".cash"), class: "font-medium text-lg" %>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl bg-gray-25 p-1">
|
||||
<div class="grid grid-cols-12 items-center uppercase text-xs font-medium text-gray-500 px-4 py-2">
|
||||
<%= tag.p t(".name"), class: "col-span-9" %>
|
||||
<%= tag.p t(".value"), class: "col-span-3 justify-self-end" %>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg bg-white border-alpha-black-25 shadow-xs">
|
||||
<%= render partial: "account/cashes/cash", collection: [brokerage_cash(@account)], as: :holding %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
|
@ -1,4 +1,4 @@
|
|||
<%# locals: (date:, entries:, selectable: true, **opts) %>
|
||||
<%# locals: (date:, entries:, selectable: true, combine_transfers: false, **opts) %>
|
||||
<div id="entry-group-<%= date %>" class="bg-gray-25 rounded-xl p-1 w-full" data-bulk-select-target="group">
|
||||
<div class="py-2 px-4 flex items-center justify-between font-medium text-xs text-gray-500">
|
||||
<div class="flex pl-0.5 items-center gap-4">
|
||||
|
@ -15,7 +15,11 @@
|
|||
<%= totals_by_currency(collection: entries, money_method: :amount_money, negate: true) %>
|
||||
</div>
|
||||
<div class="bg-white shadow-xs rounded-md border border-alpha-black-25 divide-y divide-alpha-black-50">
|
||||
<%= render entries.reject { |e| e.transfer_id.present? }, selectable:, **opts %>
|
||||
<%= render transfer_entries(entries), selectable:, **opts %>
|
||||
<% if combine_transfers %>
|
||||
<%= render entries.reject { |e| e.transfer_id.present? }, selectable:, **opts %>
|
||||
<%= render transfer_entries(entries), selectable: false, **opts %>
|
||||
<% else %>
|
||||
<%= render entries, selectable:, **opts %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
<%# locals: (entry:, selectable: true, editable: true, short: false, show_tags: false, **opts) %>
|
||||
<% transaction, account = entry.account_transaction, entry.account %>
|
||||
<% is_investment_transfer = entry.account.investment? && entry.transfer.present? %>
|
||||
|
||||
<div class="grid grid-cols-12 items-center text-gray-900 text-sm font-medium p-4">
|
||||
<% name_col_span = entry.marked_as_transfer? ? "col-span-10" : short ? "col-span-6" : "col-span-4" %>
|
||||
<% name_col_span = unconfirmed_transfer?(entry) ? "col-span-10" : short ? "col-span-6" : "col-span-4" %>
|
||||
<div class="pr-10 flex items-center gap-4 <%= name_col_span %>">
|
||||
<% if selectable %>
|
||||
<%= check_box_tag dom_id(entry, "selection"),
|
||||
|
@ -51,6 +52,12 @@
|
|||
<% end %>
|
||||
</div>
|
||||
|
||||
<% if is_investment_transfer %>
|
||||
<div class="col-span-5 text-right">
|
||||
<%= tag.p entry.inflow? ? t(".deposit") : t(".withdrawal") %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% unless entry.marked_as_transfer? %>
|
||||
<% unless short %>
|
||||
<div class="flex items-center gap-1 <%= show_tags ? "col-span-6" : "col-span-3" %>">
|
||||
|
@ -82,7 +89,7 @@
|
|||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<div class="col-span-2 ml-auto">
|
||||
<div class="<%= is_investment_transfer ? "col-span-3" : "col-span-2" %> ml-auto">
|
||||
<%= content_tag :p,
|
||||
format_money(-entry.amount_money),
|
||||
class: ["text-green-600": entry.inflow?] %>
|
||||
|
|
|
@ -53,13 +53,13 @@
|
|||
<div class="p-4 flex justify-between">
|
||||
<div class="space-y-2">
|
||||
<%= tag.p t(".total_value"), class: "text-sm font-medium text-gray-500" %>
|
||||
<%= tag.p format_money(@account.balance_money, precision: 0), class: "text-gray-900 text-3xl font-medium" %>
|
||||
<%= tag.p format_money(@account.value, precision: 0), class: "text-gray-900 text-3xl font-medium" %>
|
||||
<div>
|
||||
<% if @balance_series.trend.direction.flat? %>
|
||||
<% if @series.trend.direction.flat? %>
|
||||
<%= tag.span t(".no_change"), class: "text-gray-500" %>
|
||||
<% else %>
|
||||
<%= tag.span format_money(@balance_series.trend.value), style: "color: #{@balance_series.trend.color}" %>
|
||||
<%= tag.span "(#{@balance_series.trend.percent}%)", style: "color: #{@balance_series.trend.color}" %>
|
||||
<%= tag.span format_money(@series.trend.value), style: "color: #{@trend.color}" %>
|
||||
<%= tag.span "(#{@trend.percent}%)", style: "color: #{@trend.color}" %>
|
||||
<% end %>
|
||||
|
||||
<%= tag.span period_label(@period), class: "text-gray-500" %>
|
||||
|
@ -70,7 +70,7 @@
|
|||
<% end %>
|
||||
</div>
|
||||
<div class="h-96 flex items-center justify-center text-2xl font-bold">
|
||||
<%= render partial: "shared/line_chart", locals: { series: @balance_series } %>
|
||||
<%= render partial: "shared/line_chart", locals: { series: @series } %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
@ -28,7 +28,7 @@
|
|||
</div>
|
||||
<div class="space-y-6">
|
||||
<% @transaction_entries.group_by(&:date).each do |date, entries| %>
|
||||
<%= render "account/entries/entry_group", date:, entries: %>
|
||||
<%= render "account/entries/entry_group", date:, combine_transfers: true, entries: %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
|
8
config/locales/views/account/cashes/en.yml
Normal file
8
config/locales/views/account/cashes/en.yml
Normal file
|
@ -0,0 +1,8 @@
|
|||
---
|
||||
en:
|
||||
account:
|
||||
cashes:
|
||||
index:
|
||||
cash: Cash
|
||||
name: Name
|
||||
value: Total Balance
|
|
@ -47,9 +47,11 @@ en:
|
|||
settings: Settings
|
||||
tags_label: Select one or more tags
|
||||
transaction:
|
||||
deposit: Deposit
|
||||
remove_transfer: Remove transfer
|
||||
remove_transfer_body: This will remove the transfer from this transaction
|
||||
remove_transfer_confirm: Confirm
|
||||
withdrawal: Withdrawal
|
||||
valuation:
|
||||
form:
|
||||
cancel: Cancel
|
||||
|
@ -70,10 +72,10 @@ en:
|
|||
loading: Loading entries...
|
||||
trades:
|
||||
amount: Amount
|
||||
new: New trade
|
||||
no_trades: No trades for this account yet.
|
||||
trade: trade
|
||||
trades: Trades
|
||||
new: New transaction
|
||||
no_trades: No transactions for this account yet.
|
||||
trade: transaction
|
||||
trades: Transactions
|
||||
type: Type
|
||||
transactions:
|
||||
new: New transaction
|
||||
|
|
|
@ -48,6 +48,7 @@ en:
|
|||
title: Add an account
|
||||
ungrouped: "(none)"
|
||||
show:
|
||||
cash: Cash
|
||||
confirm_accept: Delete "%{name}"
|
||||
confirm_body_html: "<p>By deleting this account, you will erase its value history,
|
||||
affecting various aspects of your overall account. This action will have a
|
||||
|
@ -63,7 +64,7 @@ en:
|
|||
graphs may not reflect accurate values.
|
||||
sync_message_unknown_error: An error has occurred during the sync.
|
||||
total_value: Total Value
|
||||
trades: Trades
|
||||
trades: Transactions
|
||||
transactions: Transactions
|
||||
value: Value
|
||||
summary:
|
||||
|
|
|
@ -79,6 +79,7 @@ Rails.application.routes.draw do
|
|||
resource :logo, only: :show
|
||||
|
||||
resources :holdings, only: %i[ index new show ]
|
||||
resources :cashes, only: :index
|
||||
|
||||
resources :entries, except: :index do
|
||||
collection do
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue