1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-07-19 13:19:39 +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:
Zach Gollwitzer 2024-08-02 17:09:25 -04:00 committed by GitHub
parent 453a54e5e6
commit ea8309eedd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 168 additions and 33 deletions

View 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

View file

@ -13,7 +13,7 @@ class Account::EntriesController < ApplicationController
end end
def trades def trades
@trades = @account.entries.account_trades.reverse_chronological @trades = @account.entries.where(entryable_type: [ "Account::Transaction", "Account::Trade" ]).reverse_chronological
end end
def new def new

View file

@ -31,7 +31,8 @@ class AccountsController < ApplicationController
end end
def show def show
@balance_series = @account.series(period: @period) @series = @account.series(period: @period)
@trend = @series.trend
end end
def edit def edit

View 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

View file

@ -25,11 +25,12 @@ module AccountsHelper
def account_tabs(account) 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) } 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) } 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) } 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) } 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 ] [ value_tab, transactions_tab ]
end end

View file

@ -28,8 +28,10 @@ class Account < ApplicationRecord
delegated_type :accountable, types: Accountable::TYPES, dependent: :destroy delegated_type :accountable, types: Accountable::TYPES, dependent: :destroy
delegate :value, :series, to: :accountable
class << self 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) } grouped_accounts = { assets: ValueGroup.new("Assets", currency), liabilities: ValueGroup.new("Liabilities", currency) }
Accountable.by_classification.each do |classification, types| Accountable.by_classification.each do |classification, types|
@ -82,18 +84,6 @@ class Account < ApplicationRecord
classification == "asset" ? "up" : "down" classification == "asset" ? "up" : "down"
end 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) def update_balance!(balance)
valuation = entries.account_valuations.find_by(date: Date.current) valuation = entries.account_valuations.find_by(date: Date.current)

View file

@ -10,6 +10,8 @@ class Account::Holding < ApplicationRecord
scope :chronological, -> { order(:date) } scope :chronological, -> { order(:date) }
scope :current, -> { where(date: Date.current).order(amount: :desc) } 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) } scope :for, ->(security) { where(security_id: security).order(:date) }
delegate :name, to: :security delegate :name, to: :security
@ -18,7 +20,7 @@ class Account::Holding < ApplicationRecord
def weight def weight
return nil unless amount 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 portfolio_value.zero? ? 1 : amount / portfolio_value * 100
end end

View file

@ -17,4 +17,20 @@ module Accountable
included do included do
has_one :account, as: :accountable, touch: true has_one :account, as: :accountable, touch: true
end 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 end

View file

@ -13,4 +13,35 @@ class Investment < ApplicationRecord
[ "Roth 401k", "roth_401k" ], [ "Roth 401k", "roth_401k" ],
[ "Angel", "angel" ] [ "Angel", "angel" ]
].freeze ].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 end

View file

@ -24,6 +24,11 @@ class Provider::Synth
prices: prices, prices: prices,
success?: true, success?: true,
raw_response: prices.to_json raw_response: prices.to_json
rescue StandardError => error
SecurityPriceResponse.new \
success?: false,
error: error,
raw_response: error
end end
def fetch_exchange_rate(from:, to:, date:) def fetch_exchange_rate(from:, to:, date:)

View 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 %>

View 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 %>

View file

@ -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 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="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"> <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) %> <%= totals_by_currency(collection: entries, money_method: :amount_money, negate: true) %>
</div> </div>
<div class="bg-white shadow-xs rounded-md border border-alpha-black-25 divide-y divide-alpha-black-50"> <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 %> <% if combine_transfers %>
<%= render transfer_entries(entries), selectable:, **opts %> <%= 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>
</div> </div>

View file

@ -1,8 +1,9 @@
<%# locals: (entry:, selectable: true, editable: true, short: false, show_tags: false, **opts) %> <%# locals: (entry:, selectable: true, editable: true, short: false, show_tags: false, **opts) %>
<% transaction, account = entry.account_transaction, entry.account %> <% 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"> <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 %>"> <div class="pr-10 flex items-center gap-4 <%= name_col_span %>">
<% if selectable %> <% if selectable %>
<%= check_box_tag dom_id(entry, "selection"), <%= check_box_tag dom_id(entry, "selection"),
@ -51,6 +52,12 @@
<% end %> <% end %>
</div> </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 entry.marked_as_transfer? %>
<% unless short %> <% unless short %>
<div class="flex items-center gap-1 <%= show_tags ? "col-span-6" : "col-span-3" %>"> <div class="flex items-center gap-1 <%= show_tags ? "col-span-6" : "col-span-3" %>">
@ -82,7 +89,7 @@
<% end %> <% end %>
<% 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, <%= content_tag :p,
format_money(-entry.amount_money), format_money(-entry.amount_money),
class: ["text-green-600": entry.inflow?] %> class: ["text-green-600": entry.inflow?] %>

View file

@ -53,13 +53,13 @@
<div class="p-4 flex justify-between"> <div class="p-4 flex justify-between">
<div class="space-y-2"> <div class="space-y-2">
<%= tag.p t(".total_value"), class: "text-sm font-medium text-gray-500" %> <%= 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> <div>
<% if @balance_series.trend.direction.flat? %> <% if @series.trend.direction.flat? %>
<%= tag.span t(".no_change"), class: "text-gray-500" %> <%= tag.span t(".no_change"), class: "text-gray-500" %>
<% else %> <% else %>
<%= tag.span format_money(@balance_series.trend.value), style: "color: #{@balance_series.trend.color}" %> <%= tag.span format_money(@series.trend.value), style: "color: #{@trend.color}" %>
<%= tag.span "(#{@balance_series.trend.percent}%)", style: "color: #{@balance_series.trend.color}" %> <%= tag.span "(#{@trend.percent}%)", style: "color: #{@trend.color}" %>
<% end %> <% end %>
<%= tag.span period_label(@period), class: "text-gray-500" %> <%= tag.span period_label(@period), class: "text-gray-500" %>
@ -70,7 +70,7 @@
<% end %> <% end %>
</div> </div>
<div class="h-96 flex items-center justify-center text-2xl font-bold"> <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>
</div> </div>

View file

@ -28,7 +28,7 @@
</div> </div>
<div class="space-y-6"> <div class="space-y-6">
<% @transaction_entries.group_by(&:date).each do |date, entries| %> <% @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 %> <% end %>
</div> </div>
</div> </div>

View file

@ -0,0 +1,8 @@
---
en:
account:
cashes:
index:
cash: Cash
name: Name
value: Total Balance

View file

@ -47,9 +47,11 @@ en:
settings: Settings settings: Settings
tags_label: Select one or more tags tags_label: Select one or more tags
transaction: transaction:
deposit: Deposit
remove_transfer: Remove transfer remove_transfer: Remove transfer
remove_transfer_body: This will remove the transfer from this transaction remove_transfer_body: This will remove the transfer from this transaction
remove_transfer_confirm: Confirm remove_transfer_confirm: Confirm
withdrawal: Withdrawal
valuation: valuation:
form: form:
cancel: Cancel cancel: Cancel
@ -70,10 +72,10 @@ en:
loading: Loading entries... loading: Loading entries...
trades: trades:
amount: Amount amount: Amount
new: New trade new: New transaction
no_trades: No trades for this account yet. no_trades: No transactions for this account yet.
trade: trade trade: transaction
trades: Trades trades: Transactions
type: Type type: Type
transactions: transactions:
new: New transaction new: New transaction

View file

@ -48,6 +48,7 @@ en:
title: Add an account title: Add an account
ungrouped: "(none)" ungrouped: "(none)"
show: show:
cash: Cash
confirm_accept: Delete "%{name}" confirm_accept: Delete "%{name}"
confirm_body_html: "<p>By deleting this account, you will erase its value history, 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 affecting various aspects of your overall account. This action will have a
@ -63,7 +64,7 @@ en:
graphs may not reflect accurate values. graphs may not reflect accurate values.
sync_message_unknown_error: An error has occurred during the sync. sync_message_unknown_error: An error has occurred during the sync.
total_value: Total Value total_value: Total Value
trades: Trades trades: Transactions
transactions: Transactions transactions: Transactions
value: Value value: Value
summary: summary:

View file

@ -79,6 +79,7 @@ Rails.application.routes.draw do
resource :logo, only: :show resource :logo, only: :show
resources :holdings, only: %i[ index new show ] resources :holdings, only: %i[ index new show ]
resources :cashes, only: :index
resources :entries, except: :index do resources :entries, except: :index do
collection do collection do