Skip to content

Commit

Permalink
Show cash + holdings value for investment account view (#1046)
Browse files Browse the repository at this point in the history
* Handle missing tickers in security price syncs

* Show combined cash and holdings value on account page

* Improve partial locals
  • Loading branch information
zachgoll authored Aug 2, 2024
1 parent 453a54e commit ea8309e
Show file tree
Hide file tree
Showing 20 changed files with 168 additions and 33 deletions.
14 changes: 14 additions & 0 deletions app/controllers/account/cashes_controller.rb
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion app/controllers/account/entries_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ def valuations
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
Expand Down
3 changes: 2 additions & 1 deletion app/controllers/accounts_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ def new
end

def show
@balance_series = @account.series(period: @period)
@series = @account.series(period: @period)
@trend = @series.trend
end

def edit
Expand Down
13 changes: 13 additions & 0 deletions app/helpers/account/cashes_helper.rb
Original file line number Diff line number Diff line change
@@ -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
3 changes: 2 additions & 1 deletion app/helpers/accounts_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,12 @@ def accountable_color(accountable_type)

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
Expand Down
16 changes: 3 additions & 13 deletions app/models/account.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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|
Expand Down Expand Up @@ -82,18 +84,6 @@ def favorable_direction
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)

Expand Down
4 changes: 3 additions & 1 deletion app/models/account/holding.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
16 changes: 16 additions & 0 deletions app/models/concerns/accountable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,20 @@ def self.by_classification
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
31 changes: 31 additions & 0 deletions app/models/investment.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
5 changes: 5 additions & 0 deletions app/models/provider/synth.rb
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ def fetch_security_prices(ticker:, start_date:, end_date:)
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:)
Expand Down
21 changes: 21 additions & 0 deletions app/views/account/cashes/_cash.html.erb
Original file line number Diff line number Diff line change
@@ -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 changes: 18 additions & 0 deletions app/views/account/cashes/index.html.erb
Original file line number Diff line number Diff line change
@@ -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 %>
10 changes: 7 additions & 3 deletions app/views/account/entries/_entry_group.html.erb
Original file line number Diff line number Diff line change
@@ -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">
Expand All @@ -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>
Original file line number Diff line number Diff line change
@@ -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"),
Expand Down Expand Up @@ -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" %>">
Expand Down Expand Up @@ -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?] %>
Expand Down
10 changes: 5 additions & 5 deletions app/views/accounts/show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -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" %>
Expand All @@ -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>

Expand Down
2 changes: 1 addition & 1 deletion app/views/transactions/index.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
8 changes: 8 additions & 0 deletions config/locales/views/account/cashes/en.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
en:
account:
cashes:
index:
cash: Cash
name: Name
value: Total Balance
10 changes: 6 additions & 4 deletions config/locales/views/account/entries/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
3 changes: 2 additions & 1 deletion config/locales/views/accounts/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down
1 change: 1 addition & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@
resource :logo, only: :show

resources :holdings, only: %i[ index new show ]
resources :cashes, only: :index

resources :entries, except: :index do
collection do
Expand Down

0 comments on commit ea8309e

Please sign in to comment.