From 49c353e10c10dae25e45da7d73699f73d044399f Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Tue, 10 Dec 2024 17:41:20 -0500 Subject: [PATCH] Plaid portfolio sync algorithm and calculation improvements (#1526) * Start tests rework * Cash balance on schema * Add reverse syncer * Reverse balance sync with holdings * Reverse holdings sync * Reverse holdings sync should work with only trade entries * Consolidate brokerage cash * Add forward sync option * Update new balance info after syncs * Intraday balance calculator and sync fixes * Show only balance for trade entries * Tests passing * Update Gemfile.lock * Cleanup, performance improvements * Remove account reloads for reliable sync outputs * Simplify valuation view logic * Special handling for Plaid cash holding --- Gemfile | 1 - Gemfile.lock | 2 - app/controllers/account/cashes_controller.rb | 7 - .../account/holdings_controller.rb | 2 - app/controllers/accounts_controller.rb | 6 +- app/controllers/concerns/localize.rb | 6 + app/controllers/users_controller.rb | 2 +- app/helpers/account/entries_helper.rb | 11 +- .../{cashes_helper.rb => holdings_helper.rb} | 10 +- app/helpers/languages_helper.rb | 4 + app/models/account.rb | 24 +- app/models/account/balance/calculator.rb | 57 ----- app/models/account/balance/converter.rb | 46 ---- app/models/account/balance/loader.rb | 42 ---- app/models/account/balance/syncer.rb | 51 ---- app/models/account/balance_calculator.rb | 121 +++++++++ .../account/balance_trend_calculator.rb | 94 +++++++ app/models/account/entry.rb | 78 ++---- app/models/account/holding.rb | 4 +- app/models/account/holding/syncer.rb | 136 ----------- app/models/account/holding_calculator.rb | 154 ++++++++++++ app/models/account/syncer.rb | 104 ++++++++ app/models/account/trade_builder.rb | 2 +- app/models/account/valuation.rb | 40 --- app/models/concerns/accountable.rb | 19 -- app/models/gapfiller.rb | 48 ---- app/models/investment.rb | 33 --- app/models/plaid_account.rb | 1 + app/views/account/cashes/_cash.html.erb | 21 -- app/views/account/cashes/index.html.erb | 18 -- app/views/account/entries/_entry.html.erb | 4 +- app/views/account/entries/index.html.erb | 7 +- app/views/account/holdings/_cash.html.erb | 32 +++ app/views/account/holdings/index.html.erb | 8 +- app/views/account/trades/_form.html.erb | 2 +- app/views/account/trades/_trade.html.erb | 10 +- app/views/account/trades/show.html.erb | 2 +- app/views/account/transactions/_form.html.erb | 2 +- .../transactions/_transaction.html.erb | 14 +- app/views/account/transfers/_form.html.erb | 2 +- app/views/account/valuations/_form.html.erb | 2 +- .../account/valuations/_valuation.html.erb | 20 +- app/views/accounts/show/_chart.html.erb | 2 +- app/views/investments/_cash_tab.html.erb | 5 - app/views/investments/_chart.html.erb | 21 -- app/views/investments/_value_tooltip.html.erb | 21 +- app/views/investments/show.html.erb | 14 +- app/views/settings/preferences/show.html.erb | 5 + app/views/shared/_progress_circle.html.erb | 33 ++- config/locales/defaults/et.yml | 2 +- config/locales/views/account/cashes/en.yml | 8 - config/locales/views/account/holdings/en.yml | 2 + .../locales/views/account/valuations/en.yml | 2 + config/locales/views/accounts/en.yml | 4 +- config/locales/views/investments/en.yml | 7 +- config/locales/views/settings/en.yml | 1 + config/locales/views/shared/en.yml | 4 +- config/routes.rb | 1 - .../20241204235400_add_balance_components.rb | 6 + .../20241207002408_add_family_timezone.rb | 5 + db/schema.rb | 5 +- test/fixtures/accounts.yml | 1 + test/fixtures/investments.yml | 2 +- test/models/account/balance/syncer_test.rb | 153 ------------ .../models/account/balance_calculator_test.rb | 156 ++++++++++++ test/models/account/entry_test.rb | 31 +-- test/models/account/holding/syncer_test.rb | 145 ----------- .../models/account/holding_calculator_test.rb | 231 ++++++++++++++++++ test/models/account/holding_test.rb | 7 +- test/models/account/syncer_test.rb | 54 ++++ test/system/trades_test.rb | 11 +- test/system/transactions_test.rb | 2 +- 72 files changed, 1148 insertions(+), 1042 deletions(-) delete mode 100644 app/controllers/account/cashes_controller.rb rename app/helpers/account/{cashes_helper.rb => holdings_helper.rb} (55%) delete mode 100644 app/models/account/balance/calculator.rb delete mode 100644 app/models/account/balance/converter.rb delete mode 100644 app/models/account/balance/loader.rb delete mode 100644 app/models/account/balance/syncer.rb create mode 100644 app/models/account/balance_calculator.rb create mode 100644 app/models/account/balance_trend_calculator.rb delete mode 100644 app/models/account/holding/syncer.rb create mode 100644 app/models/account/holding_calculator.rb create mode 100644 app/models/account/syncer.rb delete mode 100644 app/models/gapfiller.rb delete mode 100644 app/views/account/cashes/_cash.html.erb delete mode 100644 app/views/account/cashes/index.html.erb create mode 100644 app/views/account/holdings/_cash.html.erb delete mode 100644 app/views/investments/_cash_tab.html.erb delete mode 100644 app/views/investments/_chart.html.erb delete mode 100644 config/locales/views/account/cashes/en.yml create mode 100644 db/migrate/20241204235400_add_balance_components.rb create mode 100644 db/migrate/20241207002408_add_family_timezone.rb delete mode 100644 test/models/account/balance/syncer_test.rb create mode 100644 test/models/account/balance_calculator_test.rb delete mode 100644 test/models/account/holding/syncer_test.rb create mode 100644 test/models/account/holding_calculator_test.rb create mode 100644 test/models/account/syncer_test.rb diff --git a/Gemfile b/Gemfile index 093377c5608..10ad4ba60ac 100644 --- a/Gemfile +++ b/Gemfile @@ -50,7 +50,6 @@ gem "csv" gem "redcarpet" gem "stripe" gem "intercom-rails" -gem "holidays" gem "plaid" group :development, :test do diff --git a/Gemfile.lock b/Gemfile.lock index d5c8621f523..7e2318e0527 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -185,7 +185,6 @@ GEM thor (>= 1.0.0) hashdiff (1.1.1) highline (3.0.1) - holidays (8.8.0) hotwire-livereload (1.4.1) actioncable (>= 6.0.0) listen (>= 3.0.0) @@ -493,7 +492,6 @@ DEPENDENCIES faraday-multipart faraday-retry good_job - holidays hotwire-livereload hotwire_combobox i18n-tasks diff --git a/app/controllers/account/cashes_controller.rb b/app/controllers/account/cashes_controller.rb deleted file mode 100644 index f94582ce5f4..00000000000 --- a/app/controllers/account/cashes_controller.rb +++ /dev/null @@ -1,7 +0,0 @@ -class Account::CashesController < ApplicationController - layout :with_sidebar - - def index - @account = Current.family.accounts.find(params[:account_id]) - end -end diff --git a/app/controllers/account/holdings_controller.rb b/app/controllers/account/holdings_controller.rb index c316b854c69..174d45c6cbc 100644 --- a/app/controllers/account/holdings_controller.rb +++ b/app/controllers/account/holdings_controller.rb @@ -5,8 +5,6 @@ class Account::HoldingsController < ApplicationController def index @account = Current.family.accounts.find(params[:account_id]) - @holdings = Current.family.holdings.current - @holdings = @holdings.where(account: @account) if @account end def show diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index 8d0c27c9b15..4adcf7105be 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -4,8 +4,8 @@ class AccountsController < ApplicationController before_action :set_account, only: %i[sync] def index - @manual_accounts = Current.family.accounts.manual.alphabetically - @plaid_items = Current.family.plaid_items.ordered + @manual_accounts = Current.family.accounts.where(scheduled_for_deletion: false).manual.alphabetically + @plaid_items = Current.family.plaid_items.where(scheduled_for_deletion: false).ordered end def summary @@ -14,7 +14,7 @@ def summary @net_worth_series = snapshot[:net_worth_series] @asset_series = snapshot[:asset_series] @liability_series = snapshot[:liability_series] - @accounts = Current.family.accounts + @accounts = Current.family.accounts.active @account_groups = @accounts.by_group(period: @period, currency: Current.family.currency) end diff --git a/app/controllers/concerns/localize.rb b/app/controllers/concerns/localize.rb index 5a54934734a..f3b558c1b40 100644 --- a/app/controllers/concerns/localize.rb +++ b/app/controllers/concerns/localize.rb @@ -3,6 +3,7 @@ module Localize included do around_action :switch_locale + around_action :switch_timezone end private @@ -10,4 +11,9 @@ def switch_locale(&action) locale = Current.family.try(:locale) || I18n.default_locale I18n.with_locale(locale, &action) end + + def switch_timezone(&action) + timezone = Current.family.try(:timezone) || Time.zone + Time.use_zone(timezone, &action) + end end diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 2dfae62353e..beb85197c16 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -41,7 +41,7 @@ def should_purge_profile_image? def user_params params.require(:user).permit( :first_name, :last_name, :profile_image, :redirect_to, :delete_profile_image, :onboarded_at, - family_attributes: [ :name, :currency, :country, :locale, :date_format, :id ] + family_attributes: [ :name, :currency, :country, :locale, :date_format, :timezone, :id ] ) end diff --git a/app/helpers/account/entries_helper.rb b/app/helpers/account/entries_helper.rb index 148a497bdd4..359a241a3aa 100644 --- a/app/helpers/account/entries_helper.rb +++ b/app/helpers/account/entries_helper.rb @@ -13,17 +13,12 @@ def transfer_entries(entries) end def entries_by_date(entries, selectable: true, totals: false) - entries.group_by(&:date).map do |date, grouped_entries| - # Valuations always go first, then sort by created_at desc - sorted_entries = grouped_entries.sort_by do |entry| - [ entry.account_valuation? ? 0 : 1, -entry.created_at.to_i ] - end - + entries.reverse_chronological.group_by(&:date).map do |date, grouped_entries| content = capture do - yield sorted_entries + yield grouped_entries end - render partial: "account/entries/entry_group", locals: { date:, entries: sorted_entries, content:, selectable:, totals: } + render partial: "account/entries/entry_group", locals: { date:, entries: grouped_entries, content:, selectable:, totals: } end.join.html_safe end diff --git a/app/helpers/account/cashes_helper.rb b/app/helpers/account/holdings_helper.rb similarity index 55% rename from app/helpers/account/cashes_helper.rb rename to app/helpers/account/holdings_helper.rb index ed8c2dfcf7b..c9ed7e03922 100644 --- a/app/helpers/account/cashes_helper.rb +++ b/app/helpers/account/holdings_helper.rb @@ -1,13 +1,13 @@ -module Account::CashesHelper - def brokerage_cash(account) +module Account::HoldingsHelper + def brokerage_cash_holding(account) currency = Money::Currency.new(account.currency) account.holdings.build \ date: Date.current, - qty: account.balance, + qty: account.cash_balance, price: 1, - amount: account.balance, - currency: account.currency, + amount: account.cash_balance, + currency: currency.iso_code, security: Security.new(ticker: currency.iso_code, name: currency.name) end end diff --git a/app/helpers/languages_helper.rb b/app/helpers/languages_helper.rb index af4a33f87c4..883bfad6a80 100644 --- a/app/helpers/languages_helper.rb +++ b/app/helpers/languages_helper.rb @@ -363,4 +363,8 @@ def language_options end .sort_by { |label, locale| label } end + + def timezone_options + ActiveSupport::TimeZone.all.map { |tz| [ tz.name + " (#{tz.tzinfo.identifier})", tz.tzinfo.identifier ] } + end end diff --git a/app/models/account.rb b/app/models/account.rb index 50fa6f56c70..feccd7d6e1f 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -16,7 +16,7 @@ class Account < ApplicationRecord has_many :balances, dependent: :destroy has_many :issues, as: :issuable, dependent: :destroy - monetize :balance + monetize :balance, :cash_balance enum :classification, { asset: "asset", liability: "liability" }, validate: { allow_nil: true } @@ -32,8 +32,6 @@ class Account < ApplicationRecord accepts_nested_attributes_for :accountable, update_only: true - delegate :value, :series, to: :accountable - class << self def by_group(period: Period.all, currency: Money.default_currency.iso_code) grouped_accounts = { assets: ValueGroup.new("Assets", currency), liabilities: ValueGroup.new("Liabilities", currency) } @@ -59,7 +57,7 @@ def by_group(period: Period.all, currency: Money.default_currency.iso_code) def create_and_sync(attributes) attributes[:accountable_attributes] ||= {} # Ensure accountable is created, even if empty - account = new(attributes) + account = new(attributes.merge(cash_balance: attributes[:balance])) transaction do # Create 2 valuations for new accounts to establish a value history for users to see @@ -94,15 +92,27 @@ def destroy_later def sync_data(start_date: nil) update!(last_synced_at: Time.current) - resolve_stale_issues - Balance::Syncer.new(self, start_date: start_date).run - Holding::Syncer.new(self, start_date: start_date).run + Syncer.new(self, start_date: start_date).run end def post_sync + broadcast_remove_to(family, target: "syncing-notice") + resolve_stale_issues accountable.post_sync end + def series(period: Period.last_30_days, currency: nil) + balance_series = balances.in_period(period).where(currency: currency || self.currency) + + if balance_series.empty? && period.date_range.end == Date.current + TimeSeries.new([ { date: Date.current, value: balance_money.exchange_to(currency || self.currency) } ]) + else + TimeSeries.from_collection(balance_series, :balance_money, favorable_direction: asset? ? "up" : "down") + end + rescue Money::ConversionError + TimeSeries.new([]) + end + def original_balance balance_amount = balances.chronological.first&.balance || balance Money.new(balance_amount, currency) diff --git a/app/models/account/balance/calculator.rb b/app/models/account/balance/calculator.rb deleted file mode 100644 index 04dfb38154e..00000000000 --- a/app/models/account/balance/calculator.rb +++ /dev/null @@ -1,57 +0,0 @@ -class Account::Balance::Calculator - def initialize(account, sync_start_date) - @account = account - @sync_start_date = sync_start_date - end - - def calculate(is_partial_sync: false) - cached_entries = account.entries.where("date >= ?", sync_start_date).to_a - sync_starting_balance = is_partial_sync ? find_start_balance_for_partial_sync : find_start_balance_for_full_sync(cached_entries) - - prior_balance = sync_starting_balance - - (sync_start_date..Date.current).map do |date| - current_balance = calculate_balance_for_date(date, entries: cached_entries, prior_balance:) - - prior_balance = current_balance - - build_balance(date, current_balance) - end - end - - private - attr_reader :account, :sync_start_date - - def find_start_balance_for_partial_sync - account.balances.find_by(currency: account.currency, date: sync_start_date - 1.day)&.balance - end - - def find_start_balance_for_full_sync(cached_entries) - account.balance + net_entry_flows(cached_entries.select { |e| e.account_transaction? }) - end - - def calculate_balance_for_date(date, entries:, prior_balance:) - valuation = entries.find { |e| e.date == date && e.account_valuation? } - - return valuation.amount if valuation - - entries = entries.select { |e| e.date == date } - - prior_balance - net_entry_flows(entries) - end - - def net_entry_flows(entries, target_currency = account.currency) - converted_entry_amounts = entries.map { |t| t.amount_money.exchange_to(target_currency, date: t.date) } - - flows = converted_entry_amounts.sum(&:amount) - - account.liability? ? flows * -1 : flows - end - - def build_balance(date, balance, currency = nil) - account.balances.build \ - date: date, - balance: balance, - currency: currency || account.currency - end -end diff --git a/app/models/account/balance/converter.rb b/app/models/account/balance/converter.rb deleted file mode 100644 index f5e55749369..00000000000 --- a/app/models/account/balance/converter.rb +++ /dev/null @@ -1,46 +0,0 @@ -class Account::Balance::Converter - def initialize(account, sync_start_date) - @account = account - @sync_start_date = sync_start_date - end - - def convert(balances) - calculate_converted_balances(balances) - end - - private - attr_reader :account, :sync_start_date - - def calculate_converted_balances(balances) - from_currency = account.currency - to_currency = account.family.currency - - if ExchangeRate.exchange_rates_provider.nil? - account.observe_missing_exchange_rate_provider - return [] - end - - exchange_rates = ExchangeRate.find_rates from: from_currency, - to: to_currency, - start_date: sync_start_date - - missing_exchange_rates = balances.map(&:date) - exchange_rates.map(&:date) - - if missing_exchange_rates.any? - account.observe_missing_exchange_rates(from: from_currency, to: to_currency, dates: missing_exchange_rates) - return [] - end - - balances.map do |balance| - exchange_rate = exchange_rates.find { |er| er.date == balance.date } - build_balance(balance.date, exchange_rate.rate * balance.balance, to_currency) - end - end - - def build_balance(date, balance, currency = nil) - account.balances.build \ - date: date, - balance: balance, - currency: currency || account.currency - end -end diff --git a/app/models/account/balance/loader.rb b/app/models/account/balance/loader.rb deleted file mode 100644 index 56c02011fd7..00000000000 --- a/app/models/account/balance/loader.rb +++ /dev/null @@ -1,42 +0,0 @@ -class Account::Balance::Loader - def initialize(account) - @account = account - end - - def load(balances, start_date) - Account::Balance.transaction do - upsert_balances!(balances) - purge_stale_balances!(start_date) - - account.reload - - update_account_balance!(balances) - end - end - - private - attr_reader :account - - def update_account_balance!(balances) - last_balance = balances.select { |db| db.currency == account.currency }.last&.balance - - if account.plaid_account.present? - account.update! balance: account.plaid_account.current_balance || last_balance - else - account.update! balance: last_balance if last_balance.present? - end - end - - def upsert_balances!(balances) - current_time = Time.now - balances_to_upsert = balances.map do |balance| - balance.attributes.slice("date", "balance", "currency").merge("updated_at" => current_time) - end - - account.balances.upsert_all(balances_to_upsert, unique_by: %i[account_id date currency]) - end - - def purge_stale_balances!(start_date) - account.balances.delete_by("date < ?", start_date) - end -end diff --git a/app/models/account/balance/syncer.rb b/app/models/account/balance/syncer.rb deleted file mode 100644 index 24a43f8b1fe..00000000000 --- a/app/models/account/balance/syncer.rb +++ /dev/null @@ -1,51 +0,0 @@ -class Account::Balance::Syncer - def initialize(account, start_date: nil) - @account = account - @provided_start_date = start_date - @sync_start_date = calculate_sync_start_date(start_date) - @loader = Account::Balance::Loader.new(account) - @converter = Account::Balance::Converter.new(account, sync_start_date) - @calculator = Account::Balance::Calculator.new(account, sync_start_date) - end - - def run - daily_balances = calculator.calculate(is_partial_sync: is_partial_sync?) - daily_balances += converter.convert(daily_balances) if account.currency != account.family.currency - - loader.load(daily_balances, account_start_date) - rescue Money::ConversionError => e - account.observe_missing_exchange_rates(from: e.from_currency, to: e.to_currency, dates: [ e.date ]) - end - - private - - attr_reader :sync_start_date, :provided_start_date, :account, :loader, :converter, :calculator - - def account_start_date - @account_start_date ||= begin - oldest_entry = account.entries.chronological.first - - return Date.current unless oldest_entry.present? - - if oldest_entry.account_valuation? - oldest_entry.date - else - oldest_entry.date - 1.day - end - end - end - - def calculate_sync_start_date(provided_start_date) - return provided_start_date if provided_start_date.present? && prior_balance_available?(provided_start_date) - - account_start_date - end - - def prior_balance_available?(date) - account.balances.find_by(currency: account.currency, date: date - 1.day).present? - end - - def is_partial_sync? - sync_start_date == provided_start_date - end -end diff --git a/app/models/account/balance_calculator.rb b/app/models/account/balance_calculator.rb new file mode 100644 index 00000000000..55e094ede60 --- /dev/null +++ b/app/models/account/balance_calculator.rb @@ -0,0 +1,121 @@ +class Account::BalanceCalculator + def initialize(account, holdings: nil) + @account = account + @holdings = holdings || [] + end + + def calculate(reverse: false, start_date: nil) + cash_balances = reverse ? reverse_cash_balances : forward_cash_balances + + cash_balances.map do |balance| + holdings_value = converted_holdings.select { |h| h.date == balance.date }.sum(&:amount) + balance.balance = balance.balance + holdings_value + balance + end + end + + private + attr_reader :account, :holdings + + def oldest_date + converted_entries.first ? converted_entries.first.date - 1.day : Date.current + end + + def reverse_cash_balances + prior_balance = account.cash_balance + + Date.current.downto(oldest_date).map do |date| + entries_for_date = converted_entries.select { |e| e.date == date } + holdings_for_date = converted_holdings.select { |h| h.date == date } + + valuation = entries_for_date.find { |e| e.account_valuation? } + + current_balance = if valuation + # To get this to a cash valuation, we back out holdings value on day + valuation.amount - holdings_for_date.sum(&:amount) + else + transactions = entries_for_date.select { |e| e.account_transaction? || e.account_trade? } + + calculate_balance(prior_balance, transactions) + end + + balance_record = Account::Balance.new( + account: account, + date: date, + balance: valuation ? current_balance : prior_balance, + cash_balance: valuation ? current_balance : prior_balance, + currency: account.currency + ) + + prior_balance = current_balance + + balance_record + end + end + + def forward_cash_balances + prior_balance = 0 + current_balance = nil + + oldest_date.upto(Date.current).map do |date| + entries_for_date = converted_entries.select { |e| e.date == date } + holdings_for_date = converted_holdings.select { |h| h.date == date } + + valuation = entries_for_date.find { |e| e.account_valuation? } + + current_balance = if valuation + # To get this to a cash valuation, we back out holdings value on day + valuation.amount - holdings_for_date.sum(&:amount) + else + transactions = entries_for_date.select { |e| e.account_transaction? || e.account_trade? } + + calculate_balance(prior_balance, transactions, inverse: true) + end + + balance_record = Account::Balance.new( + account: account, + date: date, + balance: current_balance, + cash_balance: current_balance, + currency: account.currency + ) + + prior_balance = current_balance + + balance_record + end + end + + def converted_entries + @converted_entries ||= @account.entries.order(:date).to_a.map do |e| + converted_entry = e.dup + converted_entry.amount = converted_entry.amount_money.exchange_to( + account.currency, + date: e.date, + fallback_rate: 1 + ).amount + converted_entry.currency = account.currency + converted_entry + end + end + + def converted_holdings + @converted_holdings ||= holdings.map do |h| + converted_holding = h.dup + converted_holding.amount = converted_holding.amount_money.exchange_to( + account.currency, + date: h.date, + fallback_rate: 1 + ).amount + converted_holding.currency = account.currency + converted_holding + end + end + + def calculate_balance(prior_balance, transactions, inverse: false) + flows = transactions.sum(&:amount) + negated = inverse ? account.asset? : account.liability? + flows *= -1 if negated + prior_balance + flows + end +end diff --git a/app/models/account/balance_trend_calculator.rb b/app/models/account/balance_trend_calculator.rb new file mode 100644 index 00000000000..a2e89e1d1e3 --- /dev/null +++ b/app/models/account/balance_trend_calculator.rb @@ -0,0 +1,94 @@ +# The current system calculates a single, end-of-day balance every day for each account for simplicity. +# In most cases, this is sufficient. However, for the "Activity View", we need to show intraday balances +# to show users how each entry affects their balances. This class calculates intraday balances by +# interpolating between end-of-day balances. +class Account::BalanceTrendCalculator + BalanceTrend = Struct.new(:trend, :cash, keyword_init: true) + + class << self + def for(entries) + return nil if entries.blank? + + account = entries.first.account + + date_range = entries.minmax_by(&:date) + min_entry_date, max_entry_date = date_range.map(&:date) + + # In case view is filtered and there are entry gaps, refetch all entries in range + all_entries = account.entries.where(date: min_entry_date..max_entry_date).chronological.to_a + balances = account.balances.where(date: (min_entry_date - 1.day)..max_entry_date).chronological.to_a + holdings = account.holdings.where(date: (min_entry_date - 1.day)..max_entry_date).to_a + + new(all_entries, balances, holdings) + end + end + + def initialize(entries, balances, holdings) + @entries = entries + @balances = balances + @holdings = holdings + end + + def trend_for(entry) + intraday_balance = nil + intraday_cash_balance = nil + + start_of_day_balance = balances.find { |b| b.date == entry.date - 1.day && b.currency == entry.currency } + end_of_day_balance = balances.find { |b| b.date == entry.date && b.currency == entry.currency } + + return BalanceTrend.new(trend: nil) if start_of_day_balance.blank? || end_of_day_balance.blank? + + todays_holdings_value = holdings.select { |h| h.date == entry.date }.sum(&:amount) + + prior_balance = start_of_day_balance.balance + prior_cash_balance = start_of_day_balance.cash_balance + current_balance = nil + current_cash_balance = nil + + todays_entries = entries.select { |e| e.date == entry.date } + + todays_entries.each_with_index do |e, idx| + if e.account_valuation? + current_balance = e.amount + current_cash_balance = e.amount + else + multiplier = e.account.liability? ? 1 : -1 + balance_change = e.account_trade? ? 0 : multiplier * e.amount + cash_change = multiplier * e.amount + + current_balance = prior_balance + balance_change + current_cash_balance = prior_cash_balance + cash_change + end + + if e.id == entry.id + # Final entry should always match the end-of-day balances + if idx == todays_entries.size - 1 + intraday_balance = end_of_day_balance.balance + intraday_cash_balance = end_of_day_balance.cash_balance + else + intraday_balance = current_balance + intraday_cash_balance = current_cash_balance + end + + break + else + prior_balance = current_balance + prior_cash_balance = current_cash_balance + end + end + + return BalanceTrend.new(trend: nil) unless intraday_balance.present? + + BalanceTrend.new( + trend: TimeSeries::Trend.new( + current: Money.new(intraday_balance, entry.currency), + previous: Money.new(prior_balance, entry.currency), + favorable_direction: entry.account.favorable_direction + ), + cash: Money.new(intraday_cash_balance, entry.currency), + ) + end + + private + attr_reader :entries, :balances, :holdings +end diff --git a/app/models/account/entry.rb b/app/models/account/entry.rb index 2addf3be6dd..1801fb9ede4 100644 --- a/app/models/account/entry.rb +++ b/app/models/account/entry.rb @@ -14,9 +14,22 @@ class Account::Entry < ApplicationRecord validates :date, uniqueness: { scope: [ :account_id, :entryable_type ] }, if: -> { account_valuation? } validates :date, comparison: { greater_than: -> { min_supported_date } } - scope :chronological, -> { order(:date, :created_at) } - scope :not_account_valuations, -> { where.not(entryable_type: "Account::Valuation") } - scope :reverse_chronological, -> { order(date: :desc, created_at: :desc) } + scope :chronological, -> { + order( + date: :asc, + Arel.sql("CASE WHEN entryable_type = 'Account::Valuation' THEN 1 ELSE 0 END") => :asc, + created_at: :asc + ) + } + + scope :reverse_chronological, -> { + order( + date: :desc, + Arel.sql("CASE WHEN entryable_type = 'Account::Valuation' THEN 1 ELSE 0 END") => :desc, + created_at: :desc + ) + } + scope :without_transfers, -> { where(marked_as_transfer: false) } scope :with_converted_amount, ->(currency) { # Join with exchange rates to convert the amount to the given currency @@ -30,12 +43,7 @@ class Account::Entry < ApplicationRecord } def sync_account_later - sync_start_date = if destroyed? - previous_entry&.date - else - [ date_previously_was, date ].compact.min - end - + sync_start_date = [ date_previously_was, date ].compact.min unless destroyed? account.sync_later(start_date: sync_start_date) end @@ -51,45 +59,8 @@ def entryable_name_short entryable_type.demodulize.underscore end - def prior_balance - account.balances.find_by(date: date - 1)&.balance || 0 - end - - def prior_entry_balance - entries_on_entry_date - .not_account_valuations - .last - &.balance_after_entry || 0 - end - - def balance_after_entry - if account_valuation? - Money.new(amount, currency) - else - new_balance = prior_balance - entries_on_entry_date.each do |e| - next if e.account_valuation? - - change = e.amount - change = account.liability? ? change : -change - new_balance += change - break if e == self - end - - Money.new(new_balance, currency) - end - end - - def trend - TimeSeries::Trend.new( - current: balance_after_entry, - previous: Money.new(prior_entry_balance, currency), - favorable_direction: account.favorable_direction - ) - end - - def entries_on_entry_date - account.entries.where(date: date).order(created_at: :asc) + def balance_trend(entries, balances) + Account::BalanceTrendCalculator.new(self, entries, balances).trend end class << self @@ -233,15 +204,4 @@ def entryable_search(params) entryable_ids end end - - private - - def previous_entry - @previous_entry ||= account - .entries - .where("date < ?", date) - .where("entryable_type = ?", entryable_type) - .order(date: :desc) - .first - end end diff --git a/app/models/account/holding.rb b/app/models/account/holding.rb index 819cd5b2454..432ec2deab8 100644 --- a/app/models/account/holding.rb +++ b/app/models/account/holding.rb @@ -22,9 +22,9 @@ def name def weight return nil unless amount + return 0 if amount.zero? - portfolio_value = account.holdings.current.known_value.sum(&:amount) - portfolio_value.zero? ? 1 : amount / portfolio_value * 100 + account.balance.zero? ? 1 : amount / account.balance * 100 end # Basic approximation of cost-basis diff --git a/app/models/account/holding/syncer.rb b/app/models/account/holding/syncer.rb deleted file mode 100644 index de5462fa856..00000000000 --- a/app/models/account/holding/syncer.rb +++ /dev/null @@ -1,136 +0,0 @@ -class Account::Holding::Syncer - def initialize(account, start_date: nil) - @account = account - end_date = account.plaid_account.present? ? 1.day.ago.to_date : Date.current - @sync_date_range = calculate_sync_start_date(start_date)..end_date - @portfolio = {} - - load_prior_portfolio if start_date - end - - def run - holdings = [] - - sync_date_range.each do |date| - holdings += build_holdings_for_date(date) - end - - upsert_holdings holdings - end - - private - - attr_reader :account, :sync_date_range - - def sync_entries - @sync_entries ||= account.entries - .account_trades - .includes(entryable: :security) - .where("date >= ?", sync_date_range.begin) - .order(:date) - end - - def get_cached_price(ticker, date) - return nil unless security_prices.key?(ticker) - - price = security_prices[ticker].find { |p| p.date == date } - price ? price[:price] : nil - end - - def security_prices - @security_prices ||= begin - prices = {} - ticker_securities = {} - - sync_entries.each do |entry| - security = entry.account_trade.security - unless ticker_securities[security.ticker] - ticker_securities[security.ticker] = { - security: security, - start_date: entry.date - } - end - end - - ticker_securities.each do |ticker, data| - fetched_prices = Security::Price.find_prices( - security: data[:security], - start_date: data[:start_date], - end_date: Date.current - ) - gapfilled_prices = Gapfiller.new(fetched_prices, start_date: data[:start_date], end_date: Date.current, cache: false).run - prices[ticker] = gapfilled_prices - end - - prices - end - end - - def build_holdings_for_date(date) - trades = sync_entries.select { |trade| trade.date == date } - - @portfolio = generate_next_portfolio(@portfolio, trades) - - @portfolio.map do |ticker, holding| - trade = trades.find { |trade| trade.account_trade.security_id == holding[:security_id] } - trade_price = trade&.account_trade&.price - - price = get_cached_price(ticker, date) || trade_price - - account.holdings.build \ - date: date, - security_id: holding[:security_id], - qty: holding[:qty], - price: price, - amount: price ? (price * holding[:qty]) : nil, - currency: holding[:currency] - end - end - - def generate_next_portfolio(prior_portfolio, trade_entries) - trade_entries.each_with_object(prior_portfolio) do |entry, new_portfolio| - trade = entry.account_trade - - price = trade.price - prior_qty = prior_portfolio.dig(trade.security.ticker, :qty) || 0 - new_qty = prior_qty + trade.qty - - new_portfolio[trade.security.ticker] = { - qty: new_qty, - price: price, - amount: new_qty * price, - currency: entry.currency, - security_id: trade.security_id - } - end - end - - def upsert_holdings(holdings) - current_time = Time.now - holdings_to_upsert = holdings.map do |holding| - holding.attributes - .slice("date", "currency", "qty", "price", "amount", "security_id") - .merge("updated_at" => current_time) - end - - account.holdings.upsert_all(holdings_to_upsert, unique_by: %i[account_id security_id date currency]) - end - - def load_prior_portfolio - prior_day_holdings = account.holdings.where(date: sync_date_range.begin - 1.day) - - prior_day_holdings.each do |holding| - @portfolio[holding.security.ticker] = { - qty: holding.qty, - price: holding.price, - amount: holding.amount, - currency: holding.currency, - security_id: holding.security_id - } - end - end - - def calculate_sync_start_date(start_date) - start_date || account.entries.account_trades.order(:date).first.try(:date) || Date.current - end -end diff --git a/app/models/account/holding_calculator.rb b/app/models/account/holding_calculator.rb new file mode 100644 index 00000000000..815e3fdf679 --- /dev/null +++ b/app/models/account/holding_calculator.rb @@ -0,0 +1,154 @@ +class Account::HoldingCalculator + def initialize(account) + @account = account + @securities_cache = {} + end + + def calculate(reverse: false) + preload_securities + calculated_holdings = reverse ? reverse_holdings : forward_holdings + gapfill_holdings(calculated_holdings) + end + + private + attr_reader :account, :securities_cache + + def reverse_holdings + current_holding_quantities = load_current_holding_quantities + prior_holding_quantities = {} + + holdings = [] + + Date.current.downto(portfolio_start_date).map do |date| + today_trades = trades.select { |t| t.date == date } + prior_holding_quantities = calculate_portfolio(current_holding_quantities, today_trades) + holdings += generate_holding_records(current_holding_quantities, date) + current_holding_quantities = prior_holding_quantities + end + + holdings + end + + def forward_holdings + prior_holding_quantities = load_empty_holding_quantities + current_holding_quantities = {} + + holdings = [] + + portfolio_start_date.upto(Date.current).map do |date| + today_trades = trades.select { |t| t.date == date } + current_holding_quantities = calculate_portfolio(prior_holding_quantities, today_trades, inverse: true) + holdings += generate_holding_records(current_holding_quantities, date) + prior_holding_quantities = current_holding_quantities + end + + holdings + end + + def generate_holding_records(portfolio, date) + portfolio.map do |security_id, qty| + security = securities_cache[security_id] + price = security.dig(:prices)&.find { |p| p.date == date } + + next if price.blank? + + account.holdings.build( + security: security.dig(:security), + date: date, + qty: qty, + price: price.price, + currency: price.currency, + amount: qty * price.price + ) + end.compact + end + + def gapfill_holdings(holdings) + filled_holdings = [] + + holdings.group_by { |h| h.security_id }.each do |security_id, security_holdings| + next if security_holdings.empty? + + sorted = security_holdings.sort_by(&:date) + previous_holding = sorted.first + + sorted.first.date.upto(Date.current) do |date| + holding = security_holdings.find { |h| h.date == date } + + if holding + filled_holdings << holding + previous_holding = holding + else + # Create a new holding based on the previous day's data + filled_holdings << account.holdings.build( + security: previous_holding.security, + date: date, + qty: previous_holding.qty, + price: previous_holding.price, + currency: previous_holding.currency, + amount: previous_holding.amount + ) + end + end + end + + filled_holdings + end + + def trades + @trades ||= account.entries.includes(entryable: :security).account_trades.to_a + end + + def portfolio_start_date + trades.first ? trades.first.date - 1.day : Date.current + end + + def preload_securities + securities = trades.map(&:entryable).map(&:security).uniq + + securities.each do |security| + prices = Security::Price.find_prices( + security: security, + start_date: portfolio_start_date, + end_date: Date.current + ) + + @securities_cache[security.id] = { + security: security, + prices: prices + } + end + end + + def calculate_portfolio(holding_quantities, today_trades, inverse: false) + new_quantities = holding_quantities.dup + + today_trades.each do |trade| + security_id = trade.entryable.security_id + qty_change = inverse ? trade.entryable.qty : -trade.entryable.qty + new_quantities[security_id] = (new_quantities[security_id] || 0) + qty_change + end + + new_quantities + end + + def load_empty_holding_quantities + holding_quantities = {} + + trades.map { |t| t.entryable.security_id }.uniq.each do |security_id| + holding_quantities[security_id] = 0 + end + + holding_quantities + end + + def load_current_holding_quantities + holding_quantities = load_empty_holding_quantities + + account.holdings.where(date: Date.current).map do |holding| + holding_quantities[holding.security_id] = holding.qty + end + + holding_quantities + end +end diff --git a/app/models/account/syncer.rb b/app/models/account/syncer.rb new file mode 100644 index 00000000000..43ac980e37d --- /dev/null +++ b/app/models/account/syncer.rb @@ -0,0 +1,104 @@ +class Account::Syncer + def initialize(account, start_date: nil) + @account = account + @start_date = start_date + end + + def run + holdings = sync_holdings + balances = sync_balances(holdings) + update_account_info(balances, holdings) unless account.plaid_account_id.present? + convert_foreign_records(balances) + end + + private + attr_reader :account, :start_date + + def account_start_date + @account_start_date ||= (account.entries.chronological.first&.date || Date.current) - 1.day + end + + def update_account_info(balances, holdings) + new_balance = balances.sort_by(&:date).last.balance + new_holdings_value = holdings.select { |h| h.date == Date.current }.sum(&:amount) + new_cash_balance = new_balance - new_holdings_value + + account.update!( + balance: new_balance, + cash_balance: new_cash_balance + ) + end + + def sync_holdings + calculator = Account::HoldingCalculator.new(account) + calculated_holdings = calculator.calculate(reverse: account.plaid_account_id.present?) + + current_time = Time.now + + Account.transaction do + account.holdings.upsert_all( + calculated_holdings.map { |h| h.attributes + .slice("date", "currency", "qty", "price", "amount", "security_id") + .merge("updated_at" => current_time) }, + unique_by: %i[account_id security_id date currency] + ) if calculated_holdings.any? + + # Purge outdated holdings + account.holdings.delete_by("date < ? OR security_id NOT IN (?)", account_start_date, calculated_holdings.map(&:security_id)) + end + + calculated_holdings + end + + def sync_balances(holdings) + calculator = Account::BalanceCalculator.new(account, holdings: holdings) + calculated_balances = calculator.calculate(reverse: account.plaid_account_id.present?, start_date: start_date) + + Account.transaction do + load_balances(calculated_balances) + + # Purge outdated balances + account.balances.delete_by("date < ?", account_start_date) + end + + calculated_balances + end + + def convert_foreign_records(balances) + converted_balances = convert_balances(balances) + load_balances(converted_balances) + end + + def load_balances(balances) + current_time = Time.now + account.balances.upsert_all( + balances.map { |b| b.attributes + .slice("date", "balance", "cash_balance", "currency") + .merge("updated_at" => current_time) }, + unique_by: %i[account_id date currency] + ) if balances.any? + end + + def convert_balances(balances) + return [] if account.currency == account.family.currency + + from_currency = account.currency + to_currency = account.family.currency + + exchange_rates = ExchangeRate.find_rates( + from: from_currency, + to: to_currency, + start_date: balances.first.date + ) + + balances.map do |balance| + exchange_rate = exchange_rates.find { |er| er.date == balance.date } + + account.balances.build( + date: balance.date, + balance: exchange_rate.rate * balance.balance, + currency: to_currency + ) if exchange_rate.present? + end + end +end diff --git a/app/models/account/trade_builder.rb b/app/models/account/trade_builder.rb index dd6b966ca98..191d810029e 100644 --- a/app/models/account/trade_builder.rb +++ b/app/models/account/trade_builder.rb @@ -59,7 +59,7 @@ def build_transfer ) else account.entries.build( - name: signed_amount < 0 ? "Deposit from #{account.name}" : "Withdrawal to #{account.name}", + name: signed_amount < 0 ? "Deposit to #{account.name}" : "Withdrawal from #{account.name}", date: date, amount: signed_amount, currency: currency, diff --git a/app/models/account/valuation.rb b/app/models/account/valuation.rb index 653c11e2fe3..93ebf5ffb1f 100644 --- a/app/models/account/valuation.rb +++ b/app/models/account/valuation.rb @@ -10,44 +10,4 @@ def requires_search?(_params) false end end - - def name - entry.name || (oldest? ? "Initial balance" : "Balance update") - end - - def trend - @trend ||= create_trend - end - - def icon - oldest? ? "plus" : entry.trend.icon - end - - def color - oldest? ? "#D444F1" : entry.trend.color - end - - private - def oldest? - @oldest ||= account.entries.where("date < ?", entry.date).empty? - end - - def account - @account ||= entry.account - end - - def create_trend - TimeSeries::Trend.new( - current: entry.amount_money, - previous: prior_balance&.balance_money, - favorable_direction: account.favorable_direction - ) - end - - def prior_balance - @prior_balance ||= account.balances - .where("date < ?", entry.date) - .order(date: :desc) - .first - end end diff --git a/app/models/concerns/accountable.rb b/app/models/concerns/accountable.rb index 6c93a8f8812..73e5ef694f1 100644 --- a/app/models/concerns/accountable.rb +++ b/app/models/concerns/accountable.rb @@ -18,26 +18,7 @@ def self.by_classification 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, favorable_direction: account.asset? ? "up" : "down") - end - rescue Money::ConversionError - TimeSeries.new([]) - end - def post_sync - broadcast_remove_to(account.family, target: "syncing-notice") - - # Broadcast a simple replace event that the controller can handle broadcast_replace_to( account, target: "chart_account_#{account.id}", diff --git a/app/models/gapfiller.rb b/app/models/gapfiller.rb deleted file mode 100644 index f4f050f9a5d..00000000000 --- a/app/models/gapfiller.rb +++ /dev/null @@ -1,48 +0,0 @@ -class Gapfiller - attr_reader :series - - def initialize(series, start_date:, end_date:, cache:) - @series = series - @date_range = start_date..end_date - @cache = cache - end - - def run - gapfilled_records = [] - - date_range.each do |date| - record = series.find { |r| r.date == date } - - if should_gapfill?(date, record) - prev_record = gapfilled_records.find { |r| r.date == date - 1.day } - - if prev_record - new_record = create_gapfilled_record(prev_record, date) - gapfilled_records << new_record - end - else - gapfilled_records << record if record - end - end - - gapfilled_records - end - - private - attr_reader :date_range, :cache - - def should_gapfill?(date, record) - (date.on_weekend? || holiday?(date)) && record.nil? - end - - def holiday?(date) - Holidays.on(date, :federalreserve, :us, :observed, :informal).any? - end - - def create_gapfilled_record(prev_record, date) - new_record = prev_record.class.new(prev_record.attributes.except("id", "created_at", "updated_at")) - new_record.date = date - new_record.save! if cache - new_record - end -end diff --git a/app/models/investment.rb b/app/models/investment.rb index 90519da314a..29148f06385 100644 --- a/app/models/investment.rb +++ b/app/models/investment.rb @@ -16,37 +16,6 @@ class Investment < ApplicationRecord [ "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 - def color "#1570EF" end @@ -56,8 +25,6 @@ def icon end def post_sync - broadcast_remove_to(account, target: "syncing-notice") - broadcast_replace_to( account, target: "chart_account_#{account.id}", diff --git a/app/models/plaid_account.rb b/app/models/plaid_account.rb index 240b7c182e4..5a6d03e7b91 100644 --- a/app/models/plaid_account.rb +++ b/app/models/plaid_account.rb @@ -167,6 +167,7 @@ def get_security(plaid_security, securities) end return nil if security.nil? || security.ticker_symbol.blank? + return nil if security.ticker_symbol == "CUR:USD" # Internally, we do not consider cash a "holding" and track it separately Security.find_or_create_by!( ticker: security.ticker_symbol, diff --git a/app/views/account/cashes/_cash.html.erb b/app/views/account/cashes/_cash.html.erb deleted file mode 100644 index e5e2065d27f..00000000000 --- a/app/views/account/cashes/_cash.html.erb +++ /dev/null @@ -1,21 +0,0 @@ -<%# locals: (holding:) %> - -<%= turbo_frame_tag dom_id(holding) do %> -
-
- <%= render "shared/circle_logo", name: holding.name %> -
- <%= tag.p holding.name %> - <%= tag.p holding.ticker, class: "text-gray-500 text-xs uppercase" %> -
-
- -
- <% if holding.amount_money %> - <%= tag.p format_money holding.amount_money %> - <% else %> - <%= tag.p "?", class: "text-gray-500" %> - <% end %> -
-
-<% end %> diff --git a/app/views/account/cashes/index.html.erb b/app/views/account/cashes/index.html.erb deleted file mode 100644 index 1d0b8c823fe..00000000000 --- a/app/views/account/cashes/index.html.erb +++ /dev/null @@ -1,18 +0,0 @@ -<%= turbo_frame_tag dom_id(@account, "cash") do %> -
-
- <%= tag.h2 t(".cash"), class: "font-medium text-lg" %> -
- -
-
- <%= tag.p t(".name"), class: "col-span-9" %> - <%= tag.p t(".value"), class: "col-span-3 justify-self-end" %> -
- -
- <%= render partial: "account/cashes/cash", collection: [brokerage_cash(@account)], as: :holding %> -
-
-
-<% end %> diff --git a/app/views/account/entries/_entry.html.erb b/app/views/account/entries/_entry.html.erb index 9bfe063a39e..0007f2c64fc 100644 --- a/app/views/account/entries/_entry.html.erb +++ b/app/views/account/entries/_entry.html.erb @@ -1,5 +1,5 @@ -<%# locals: (entry:, selectable: true, show_balance: false) %> +<%# locals: (entry:, selectable: true, balance_trend: nil) %> <%= turbo_frame_tag dom_id(entry) do %> - <%= render partial: entry.entryable.to_partial_path, locals: { entry:, selectable:, show_balance: } %> + <%= render partial: entry.entryable.to_partial_path, locals: { entry:, selectable:, balance_trend: } %> <% end %> diff --git a/app/views/account/entries/index.html.erb b/app/views/account/entries/index.html.erb index 1ce474093a5..c659d97f532 100644 --- a/app/views/account/entries/index.html.erb +++ b/app/views/account/entries/index.html.erb @@ -73,13 +73,14 @@
-
+ <% calculator = Account::BalanceTrendCalculator.for(@entries) %> <%= entries_by_date(@entries) do |entries| %> - <%= render entries, show_balance: true %> + <% entries.each do |entry| %> + <%= render entry, balance_trend: calculator&.trend_for(entry) %> + <% end %> <% end %>
-
diff --git a/app/views/account/holdings/_cash.html.erb b/app/views/account/holdings/_cash.html.erb new file mode 100644 index 00000000000..cc135a0696c --- /dev/null +++ b/app/views/account/holdings/_cash.html.erb @@ -0,0 +1,32 @@ +<%# locals: (account:) %> + +<% currency = Money::Currency.new(account.currency) %> + +
+
+ <%= render "shared/circle_logo", name: currency.iso_code %> + +
+ <%= tag.p t(".brokerage_cash"), class: "text-gray-900" %> + <%= tag.p account.currency, class: "text-gray-500 text-xs uppercase" %> +
+
+ +
+ <% cash_weight = account.balance.zero? ? 0 : account.cash_balance / account.balance * 100 %> + <%= render "shared/progress_circle", progress: cash_weight, text_class: "text-blue-500" %> + <%= tag.p number_to_percentage(cash_weight, precision: 1) %> +
+ +
+ <%= tag.p "--", class: "text-gray-500" %> +
+ +
+ <%= tag.p format_money account.cash_balance %> +
+ +
+ <%= tag.p "--", class: "text-gray-500" %> +
+
diff --git a/app/views/account/holdings/index.html.erb b/app/views/account/holdings/index.html.erb index af87088b767..8e96fa9531e 100644 --- a/app/views/account/holdings/index.html.erb +++ b/app/views/account/holdings/index.html.erb @@ -2,7 +2,7 @@
<%= tag.h2 t(".holdings"), class: "font-medium text-lg" %> - <%= link_to new_account_trade_path(@account), + <%= link_to new_account_trade_path(account_id: @account.id), id: dom_id(@account, "new_trade"), data: { turbo_frame: :modal }, class: "flex gap-1 font-medium items-center bg-gray-50 text-gray-900 p-2 rounded-lg" do %> @@ -21,8 +21,10 @@
- <% if @holdings.any? %> - <%= render partial: "account/holdings/holding", collection: @holdings, spacer_template: "ruler" %> + <% if @account.holdings.current.any? %> + <%= render "account/holdings/cash", account: @account %> + <%= render "account/holdings/ruler" %> + <%= render partial: "account/holdings/holding", collection: @account.holdings.current, spacer_template: "ruler" %> <% else %>

<%= t(".no_holdings") %>

<% end %> diff --git a/app/views/account/trades/_form.html.erb b/app/views/account/trades/_form.html.erb index fbb288df003..aaaf690031f 100644 --- a/app/views/account/trades/_form.html.erb +++ b/app/views/account/trades/_form.html.erb @@ -32,7 +32,7 @@
<% end %> - <%= form.date_field :date, label: true, value: Date.today, required: true %> + <%= form.date_field :date, label: true, value: Date.current, required: true %> <% unless %w[buy sell].include?(type) %> <%= form.money_field :amount, label: t(".amount"), required: true %> diff --git a/app/views/account/trades/_trade.html.erb b/app/views/account/trades/_trade.html.erb index bd91d14784f..2215cd94bcc 100644 --- a/app/views/account/trades/_trade.html.erb +++ b/app/views/account/trades/_trade.html.erb @@ -1,4 +1,4 @@ -<%# locals: (entry:, selectable: true, show_balance: false) %> +<%# locals: (entry:, selectable: true, balance_trend: nil) %> <% trade, account = entry.account_trade, entry.account %> @@ -37,6 +37,12 @@
- <%= tag.p "--", class: "font-medium text-sm text-gray-400" %> + <% if balance_trend&.trend %> +
+ <%= tag.p format_money(balance_trend.trend.current), class: "font-medium text-sm text-gray-900" %> +
+ <% else %> + <%= tag.p "--", class: "font-medium text-sm text-gray-400" %> + <% end %>
diff --git a/app/views/account/trades/show.html.erb b/app/views/account/trades/show.html.erb index 1a1d8cf6bdf..affd2952b66 100644 --- a/app/views/account/trades/show.html.erb +++ b/app/views/account/trades/show.html.erb @@ -13,7 +13,7 @@ data: { controller: "auto-submit-form" } do |f| %> <%= f.date_field :date, label: t(".date_label"), - max: Date.today, + max: Date.current, "data-auto-submit-form-target": "auto" %>
diff --git a/app/views/account/transactions/_form.html.erb b/app/views/account/transactions/_form.html.erb index 10c09d30943..47a0f01c32f 100644 --- a/app/views/account/transactions/_form.html.erb +++ b/app/views/account/transactions/_form.html.erb @@ -28,7 +28,7 @@ <%= f.fields_for :entryable do |ef| %> <%= ef.collection_select :category_id, Current.family.categories.alphabetically, :id, :name, { prompt: t(".category_prompt"), label: t(".category") } %> <% end %> - <%= f.date_field :date, label: t(".date"), required: true, min: Account::Entry.min_supported_date, max: Date.today, value: Date.today %> + <%= f.date_field :date, label: t(".date"), required: true, min: Account::Entry.min_supported_date, max: Date.current, value: Date.current %>
diff --git a/app/views/account/transactions/_transaction.html.erb b/app/views/account/transactions/_transaction.html.erb index 9a300440f40..dc2a026ace3 100644 --- a/app/views/account/transactions/_transaction.html.erb +++ b/app/views/account/transactions/_transaction.html.erb @@ -1,4 +1,4 @@ -<%# locals: (entry:, selectable: true, show_balance: false) %> +<%# locals: (entry:, selectable: true, balance_trend: nil) %> <% transaction, account = entry.account_transaction, entry.account %>
text-sm font-medium p-4"> @@ -34,7 +34,7 @@
<% if entry.transfer.present? %> - <% unless show_balance %> + <% unless balance_trend %>
<% end %> @@ -46,7 +46,7 @@ <%= render "categories/menu", transaction: transaction %>
- <% unless show_balance %> + <% unless balance_trend %> <%= tag.div class: "col-span-2 overflow-hidden truncate" do %> <% if entry.new_record? %> <%= tag.p account.name %> @@ -66,12 +66,12 @@ class: ["text-green-600": entry.inflow?] %>
- <% if show_balance %> + <% if balance_trend %>
- <% if entry.account.investment? %> - <%= tag.p "--", class: "font-medium text-sm text-gray-400" %> + <% if balance_trend.trend %> + <%= tag.p format_money(balance_trend.trend.current), class: "font-medium text-sm text-gray-900" %> <% else %> - <%= tag.p format_money(entry.trend.current), class: "font-medium text-sm text-gray-900" %> + <%= tag.p "--", class: "font-medium text-sm text-gray-400" %> <% end %>
<% end %> diff --git a/app/views/account/transfers/_form.html.erb b/app/views/account/transfers/_form.html.erb index 0124d3ead8c..b55544a70bc 100644 --- a/app/views/account/transfers/_form.html.erb +++ b/app/views/account/transfers/_form.html.erb @@ -29,7 +29,7 @@ <%= f.collection_select :from_account_id, Current.family.accounts.manual.alphabetically, :id, :name, { prompt: t(".select_account"), label: t(".from") }, required: true %> <%= f.collection_select :to_account_id, Current.family.accounts.manual.alphabetically, :id, :name, { prompt: t(".select_account"), label: t(".to") }, required: true %> <%= f.number_field :amount, label: t(".amount"), required: true, min: 0, placeholder: "100", step: 0.00000001 %> - <%= f.date_field :date, value: transfer.date || Date.today, label: t(".date"), required: true, max: Date.current %> + <%= f.date_field :date, value: transfer.date || Date.current, label: t(".date"), required: true, max: Date.current %>
diff --git a/app/views/account/valuations/_form.html.erb b/app/views/account/valuations/_form.html.erb index 68345c73c01..b56e847bbbb 100644 --- a/app/views/account/valuations/_form.html.erb +++ b/app/views/account/valuations/_form.html.erb @@ -8,7 +8,7 @@ <% end %>
- <%= form.date_field :date, label: true, required: true, value: Date.today, min: Account::Entry.min_supported_date, max: Date.today %> + <%= form.date_field :date, label: true, required: true, value: Date.current, min: Account::Entry.min_supported_date, max: Date.current %> <%= form.money_field :amount, label: t(".amount"), required: true %>
diff --git a/app/views/account/valuations/_valuation.html.erb b/app/views/account/valuations/_valuation.html.erb index b3e9caf43ce..fc4c05d012b 100644 --- a/app/views/account/valuations/_valuation.html.erb +++ b/app/views/account/valuations/_valuation.html.erb @@ -1,7 +1,7 @@ -<%# locals: (entry:, selectable: true, show_balance: false) %> +<%# locals: (entry:, selectable: true, balance_trend: nil) %> -<% account = entry.account %> -<% valuation = entry.account_valuation %> +<% color = balance_trend&.trend&.color || "#D444F1" %> +<% icon = balance_trend&.trend&.icon || "plus" %>
@@ -12,15 +12,15 @@ <% end %>
- <%= tag.div class: "w-8 h-8 rounded-full p-1.5 flex items-center justify-center", style: mixed_hex_styles(valuation.color) do %> - <%= lucide_icon valuation.icon, class: "w-4 h-4" %> + <%= tag.div class: "w-8 h-8 rounded-full p-1.5 flex items-center justify-center", style: mixed_hex_styles(color) do %> + <%= lucide_icon icon, class: "w-4 h-4" %> <% end %>
<% if entry.new_record? %> <%= content_tag :p, entry.name %> <% else %> - <%= link_to valuation.name, + <%= link_to entry.name || t(".balance_update"), account_entry_path(entry), data: { turbo_frame: "drawer", turbo_prefetch: false }, class: "hover:underline hover:text-gray-800" %> @@ -29,8 +29,12 @@
-
- <%= tag.span format_money(entry.trend.value) %> +
+ <% if balance_trend&.trend %> + <%= tag.span format_money(balance_trend.trend.value), style: "color: #{balance_trend.trend.color}" %> + <% else %> + <%= tag.span "--", class: "text-gray-400" %> + <% end %>
diff --git a/app/views/accounts/show/_chart.html.erb b/app/views/accounts/show/_chart.html.erb index 7546748da9c..168182fd005 100644 --- a/app/views/accounts/show/_chart.html.erb +++ b/app/views/accounts/show/_chart.html.erb @@ -11,7 +11,7 @@ <%= tooltip %>
- <%= tag.p format_money(account.value), class: "text-gray-900 text-3xl font-medium" %> + <%= tag.p format_money(account.balance_money), class: "text-gray-900 text-3xl font-medium" %>
<%= form_with url: request.path, method: :get, data: { controller: "auto-submit-form" } do |form| %> diff --git a/app/views/investments/_cash_tab.html.erb b/app/views/investments/_cash_tab.html.erb deleted file mode 100644 index 2ebd3126949..00000000000 --- a/app/views/investments/_cash_tab.html.erb +++ /dev/null @@ -1,5 +0,0 @@ -<%# locals: (account:) %> - -<%= turbo_frame_tag dom_id(account, :cash), src: account_cashes_path(account_id: account.id) do %> - <%= render "account/entries/loading" %> -<% end %> diff --git a/app/views/investments/_chart.html.erb b/app/views/investments/_chart.html.erb deleted file mode 100644 index 9ac9868d523..00000000000 --- a/app/views/investments/_chart.html.erb +++ /dev/null @@ -1,21 +0,0 @@ -<%# locals: (account:, **args) %> - -
-
-
-
- <%= tag.p t(".value"), class: "text-sm font-medium text-gray-500" %> -
- - <%= tag.p format_money(account.value), class: "text-gray-900 text-3xl font-medium" %> -
-
- -
- <%= image_tag "placeholder-graph.svg", class: "w-full h-full object-cover rounded-bl-lg rounded-br-lg opacity-50" %> -
-

Historical investment data coming soon.

-

We're working to bring you the full picture.

-
-
-
diff --git a/app/views/investments/_value_tooltip.html.erb b/app/views/investments/_value_tooltip.html.erb index c624afbc1b9..be275222460 100644 --- a/app/views/investments/_value_tooltip.html.erb +++ b/app/views/investments/_value_tooltip.html.erb @@ -1,4 +1,4 @@ -<%# locals: (value:, cash:) %> +<%# locals: (balance:, holdings:, cash:) %>
<%= lucide_icon("info", class: "w-4 h-4 shrink-0 text-gray-500") %> @@ -8,18 +8,29 @@
- <%= t(".holdings") %> + <%= t(".cash") %>
- <%= tag.p format_money(value, precision: 0) %> + <%= tag.p format_money(cash, precision: 0) %>
- <%= t(".cash") %> + <%= t(".holdings") %>
- <%= tag.p format_money(cash, precision: 0) %> + <%= tag.p format_money(holdings, precision: 0) %> +
+
+ +
+ +
+
+ <%= t(".total") %> +
+
+ <%= tag.p format_money(balance, precision: 0) %>
diff --git a/app/views/investments/show.html.erb b/app/views/investments/show.html.erb index 36f1b934f65..bc271e68001 100644 --- a/app/views/investments/show.html.erb +++ b/app/views/investments/show.html.erb @@ -4,24 +4,20 @@ <%= tag.div class: "space-y-4" do %> <%= render "accounts/show/header", account: @account %> - <% if @account.plaid_account_id.present? %> - <%= render "investments/chart", account: @account %> - <% else %> - <%= render "accounts/show/chart", + <%= render "accounts/show/chart", account: @account, title: t(".chart_title"), tooltip: render( "investments/value_tooltip", - value: @account.value, - cash: @account.balance_money + balance: @account.balance_money, + holdings: @account.balance - @account.cash_balance, + cash: @account.cash_balance ) %> - <% end %>
<%= render "accounts/show/tabs", account: @account, tabs: [ - { key: "activity", contents: render("accounts/show/activity", account: @account) }, { key: "holdings", contents: render("investments/holdings_tab", account: @account) }, - { key: "cash", contents: render("investments/cash_tab", account: @account) } + { key: "activity", contents: render("accounts/show/activity", account: @account) }, ] %>
<% end %> diff --git a/app/views/settings/preferences/show.html.erb b/app/views/settings/preferences/show.html.erb index b0dfcde7feb..225e5d96ed2 100644 --- a/app/views/settings/preferences/show.html.erb +++ b/app/views/settings/preferences/show.html.erb @@ -20,6 +20,11 @@ { label: t(".language") }, { data: { auto_submit_form_target: "auto" } } %> + <%= family_form.select :timezone, + timezone_options, + { label: t(".timezone") }, + { data: { auto_submit_form_target: "auto" } } %> + <%= family_form.select :date_format, date_format_options, { label: t(".date_format") }, diff --git a/app/views/shared/_progress_circle.html.erb b/app/views/shared/_progress_circle.html.erb index 3f7ec528379..a3529193e88 100644 --- a/app/views/shared/_progress_circle.html.erb +++ b/app/views/shared/_progress_circle.html.erb @@ -1,10 +1,31 @@ <%# locals: (progress:, radius: 7, stroke: 2, text_class: "text-green-500") %> -<% circumference = Math::PI * 2 * radius %> -<% progress_percent = progress.clamp(0, 100) %> -<% stroke_dashoffset = ((100 - progress_percent) * circumference) / 100 %> + +<% + circumference = Math::PI * 2 * radius + progress_percent = progress.clamp(0, 100) + stroke_dashoffset = ((100 - progress_percent) * circumference) / 100 + center = radius + stroke / 2 +%> + - + + - - + + diff --git a/config/locales/defaults/et.yml b/config/locales/defaults/et.yml index 337b5a87781..6b0e17c6f74 100644 --- a/config/locales/defaults/et.yml +++ b/config/locales/defaults/et.yml @@ -13,7 +13,7 @@ et: - E - T - K - - N + - "N" - R - L abbr_month_names: diff --git a/config/locales/views/account/cashes/en.yml b/config/locales/views/account/cashes/en.yml deleted file mode 100644 index 96da5309b6b..00000000000 --- a/config/locales/views/account/cashes/en.yml +++ /dev/null @@ -1,8 +0,0 @@ ---- -en: - account: - cashes: - index: - cash: Cash - name: Name - value: Total Balance diff --git a/config/locales/views/account/holdings/en.yml b/config/locales/views/account/holdings/en.yml index b2e3fe252fc..08a1baf30a6 100644 --- a/config/locales/views/account/holdings/en.yml +++ b/config/locales/views/account/holdings/en.yml @@ -2,6 +2,8 @@ en: account: holdings: + cash: + brokerage_cash: Brokerage cash destroy: success: Holding deleted holding: diff --git a/config/locales/views/account/valuations/en.yml b/config/locales/views/account/valuations/en.yml index c157b6d62b4..ba8637c6ac2 100644 --- a/config/locales/views/account/valuations/en.yml +++ b/config/locales/views/account/valuations/en.yml @@ -2,6 +2,8 @@ en: account: valuations: + valuation: + balance_update: Balance update form: amount: Amount submit: Add balance update diff --git a/config/locales/views/accounts/en.yml b/config/locales/views/accounts/en.yml index 8e7359f51de..3180b37ec15 100644 --- a/config/locales/views/accounts/en.yml +++ b/config/locales/views/accounts/en.yml @@ -6,6 +6,8 @@ en: troubleshoot: Troubleshoot account_list: new_account: New %{type} + chart: + no_change: no change create: success: "%{type} account created" destroy: @@ -31,8 +33,6 @@ en: manual_entry: Enter account balance title: How would you like to add it? title: What would you like to add? - chart: - no_change: no change show: chart: balance: Balance diff --git a/config/locales/views/investments/en.yml b/config/locales/views/investments/en.yml index ec2cbe86d9d..451609e3b38 100644 --- a/config/locales/views/investments/en.yml +++ b/config/locales/views/investments/en.yml @@ -1,8 +1,6 @@ --- en: investments: - chart: - value: Total value edit: edit: Edit %{account} form: @@ -15,5 +13,6 @@ en: value_tooltip: cash: Cash holdings: Holdings - total_value_tooltip: The total value is the sum of cash balance and your holdings - value, minus margin loans. + total: Portfolio balance + total_value_tooltip: The total portfolio balance is the sum of brokerage cash + (available for trading) and the current market value of your holdings. diff --git a/config/locales/views/settings/en.yml b/config/locales/views/settings/en.yml index 9d8532e2d1f..aa8141b82a5 100644 --- a/config/locales/views/settings/en.yml +++ b/config/locales/views/settings/en.yml @@ -39,6 +39,7 @@ en: theme_subtitle: Choose a preferred theme for the app (coming soon...) theme_system: System theme_title: Theme + timezone: Timezone profiles: show: confirm_delete: diff --git a/config/locales/views/shared/en.yml b/config/locales/views/shared/en.yml index 0e020f424ea..5862d7c029b 100644 --- a/config/locales/views/shared/en.yml +++ b/config/locales/views/shared/en.yml @@ -1,8 +1,6 @@ --- en: shared: - syncing_notice: - syncing: Syncing accounts data... confirm_modal: accept: Confirm body_html: "

You will not be able to undo this decision

" @@ -15,6 +13,8 @@ en: no_account_subtitle: Since no accounts have been added, there's no data to display. Add your first accounts to start viewing dashboard data. no_account_title: No accounts yet + syncing_notice: + syncing: Syncing accounts data... upgrade_notification: app_upgraded: The app has been upgraded to %{version}. dismiss: Dismiss diff --git a/config/routes.rb b/config/routes.rb index c7c14d91fbc..de480f356a9 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -75,7 +75,6 @@ namespace :account do resources :holdings, only: %i[index new show destroy] - resources :cashes, only: :index resources :entries, only: :index diff --git a/db/migrate/20241204235400_add_balance_components.rb b/db/migrate/20241204235400_add_balance_components.rb new file mode 100644 index 00000000000..4b5dd2064c1 --- /dev/null +++ b/db/migrate/20241204235400_add_balance_components.rb @@ -0,0 +1,6 @@ +class AddBalanceComponents < ActiveRecord::Migration[7.2] + def change + add_column :accounts, :cash_balance, :decimal, precision: 19, scale: 4, default: 0 + add_column :account_balances, :cash_balance, :decimal, precision: 19, scale: 4, default: 0 + end +end diff --git a/db/migrate/20241207002408_add_family_timezone.rb b/db/migrate/20241207002408_add_family_timezone.rb new file mode 100644 index 00000000000..b8d3fb7617a --- /dev/null +++ b/db/migrate/20241207002408_add_family_timezone.rb @@ -0,0 +1,5 @@ +class AddFamilyTimezone < ActiveRecord::Migration[7.2] + def change + add_column :families, :timezone, :string + end +end diff --git a/db/schema.rb b/db/schema.rb index 945f18b1c7a..311837330c8 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2024_11_26_211249) do +ActiveRecord::Schema[7.2].define(version: 2024_12_07_002408) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -27,6 +27,7 @@ t.string "currency", default: "USD", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.decimal "cash_balance", precision: 19, scale: 4, default: "0.0" t.index ["account_id", "date", "currency"], name: "index_account_balances_on_account_id_date_currency_unique", unique: true t.index ["account_id"], name: "index_account_balances_on_account_id" end @@ -112,6 +113,7 @@ t.uuid "plaid_account_id" t.boolean "scheduled_for_deletion", default: false t.datetime "last_synced_at" + t.decimal "cash_balance", precision: 19, scale: 4, default: "0.0" t.index ["accountable_id", "accountable_type"], name: "index_accounts_on_accountable_id_and_accountable_type" t.index ["accountable_type"], name: "index_accounts_on_accountable_type" t.index ["family_id", "accountable_type"], name: "index_accounts_on_family_id_and_accountable_type" @@ -218,6 +220,7 @@ t.string "date_format", default: "%m-%d-%Y" t.string "country", default: "US" t.datetime "last_synced_at" + t.string "timezone" end create_table "good_job_batches", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| diff --git a/test/fixtures/accounts.yml b/test/fixtures/accounts.yml index 1bb48c6e857..810dbf41a38 100644 --- a/test/fixtures/accounts.yml +++ b/test/fixtures/accounts.yml @@ -43,6 +43,7 @@ investment: family: dylan_family name: Robinhood Brokerage Account balance: 10000 + cash_balance: 5000 currency: USD accountable_type: Investment accountable: one diff --git a/test/fixtures/investments.yml b/test/fixtures/investments.yml index e0553ab036a..386cd707da2 100644 --- a/test/fixtures/investments.yml +++ b/test/fixtures/investments.yml @@ -1 +1 @@ -one: { } \ No newline at end of file +one: {} \ No newline at end of file diff --git a/test/models/account/balance/syncer_test.rb b/test/models/account/balance/syncer_test.rb deleted file mode 100644 index 528863be109..00000000000 --- a/test/models/account/balance/syncer_test.rb +++ /dev/null @@ -1,153 +0,0 @@ -require "test_helper" - -class Account::Balance::SyncerTest < ActiveSupport::TestCase - include Account::EntriesTestHelper - - setup do - @account = families(:empty).accounts.create!(name: "Test", balance: 20000, currency: "USD", accountable: Depository.new) - @investment_account = families(:empty).accounts.create!(name: "Test Investment", balance: 50000, currency: "USD", accountable: Investment.new) - end - - test "syncs account with no entries" do - assert_equal 0, @account.balances.count - - run_sync_for @account - - assert_equal [ @account.balance ], @account.balances.chronological.map(&:balance) - end - - test "syncs account with valuations only" do - create_valuation(account: @account, date: 2.days.ago.to_date, amount: 22000) - - run_sync_for @account - - assert_equal 22000, @account.balance - assert_equal [ 22000, 22000, 22000 ], @account.balances.chronological.map(&:balance) - end - - test "syncs account with transactions only" do - create_transaction(account: @account, date: 4.days.ago.to_date, amount: 100) - create_transaction(account: @account, date: 2.days.ago.to_date, amount: -500) - - run_sync_for @account - - assert_equal 20000, @account.balance - assert_equal [ 19600, 19500, 19500, 20000, 20000, 20000 ], @account.balances.chronological.map(&:balance) - end - - test "syncs account with valuations and transactions when valuation starts" do - create_valuation(account: @account, date: 5.days.ago.to_date, amount: 20000) - create_transaction(account: @account, date: 3.days.ago.to_date, amount: -500) - create_transaction(account: @account, date: 2.days.ago.to_date, amount: 100) - create_valuation(account: @account, date: 1.day.ago.to_date, amount: 25000) - - run_sync_for(@account) - - assert_equal 25000, @account.balance - assert_equal [ 20000, 20000, 20500, 20400, 25000, 25000 ], @account.balances.chronological.map(&:balance) - end - - test "syncs account with valuations and transactions when transaction starts" do - new_account = families(:empty).accounts.create!(name: "Test Account", balance: 1000, currency: "USD", accountable: Depository.new) - create_transaction(account: new_account, date: 2.days.ago.to_date, amount: 250) - create_valuation(account: new_account, date: Date.current, amount: 1000) - - run_sync_for(new_account) - - assert_equal 1000, new_account.balance - assert_equal [ 1250, 1000, 1000, 1000 ], new_account.balances.chronological.map(&:balance) - end - - test "syncs account with transactions in multiple currencies" do - ExchangeRate.create! date: 1.day.ago.to_date, from_currency: "EUR", to_currency: "USD", rate: 1.2 - - create_transaction(account: @account, date: 3.days.ago.to_date, amount: 100, currency: "USD") - create_transaction(account: @account, date: 2.days.ago.to_date, amount: 300, currency: "USD") - create_transaction(account: @account, date: 1.day.ago.to_date, amount: 500, currency: "EUR") # €500 * 1.2 = $600 - - run_sync_for(@account) - - assert_equal 20000, @account.balance - assert_equal [ 21000, 20900, 20600, 20000, 20000 ], @account.balances.chronological.map(&:balance) - end - - test "converts foreign account balances to family currency" do - @account.update! currency: "EUR" - - create_transaction(date: 1.day.ago.to_date, amount: 1000, account: @account, currency: "EUR") - - create_exchange_rate(2.days.ago.to_date, from: "EUR", to: "USD", rate: 2) - create_exchange_rate(1.day.ago.to_date, from: "EUR", to: "USD", rate: 2) - create_exchange_rate(Date.current, from: "EUR", to: "USD", rate: 2) - - with_env_overrides SYNTH_API_KEY: ENV["SYNTH_API_KEY"] || "fookey" do - run_sync_for(@account) - end - - usd_balances = @account.balances.where(currency: "USD").chronological.map(&:balance) - eur_balances = @account.balances.where(currency: "EUR").chronological.map(&:balance) - - assert_equal 20000, @account.balance - assert_equal [ 21000, 20000, 20000 ], eur_balances # native account balances - assert_equal [ 42000, 40000, 40000 ], usd_balances # converted balances at rate of 2:1 - end - - test "raises issue if missing exchange rates" do - create_transaction(date: Date.current, account: @account, currency: "EUR") - - ExchangeRate.expects(:find_rate).with(from: "EUR", to: "USD", date: Date.current).returns(nil) - @account.expects(:observe_missing_exchange_rates).with(from: "EUR", to: "USD", dates: [ Date.current ]) - - syncer = Account::Balance::Syncer.new(@account) - - syncer.run - end - - # Account is able to calculate balances in its own currency (i.e. can still show a historical graph), but - # doesn't have exchange rates available to convert those calculated balances to the family currency - test "observes issue if exchange rate provider is not configured" do - @account.update! currency: "EUR" - - syncer = Account::Balance::Syncer.new(@account) - - @account.expects(:observe_missing_exchange_rate_provider) - - with_env_overrides SYNTH_API_KEY: nil do - syncer.run - end - end - - test "overwrites existing balances and purges stale balances" do - assert_equal 0, @account.balances.size - - @account.balances.create! date: Date.current, currency: "USD", balance: 30000 # incorrect balance, will be updated - @account.balances.create! date: 10.years.ago.to_date, currency: "USD", balance: 35000 # Out of range balance, will be deleted - - assert_equal 2, @account.balances.size - - run_sync_for(@account) - - assert_equal [ @account.balance ], @account.balances.chronological.map(&:balance) - end - - test "partial sync does not affect balances prior to sync start date" do - existing_balance = @account.balances.create! date: 2.days.ago.to_date, currency: "USD", balance: 30000 - - transaction = create_transaction(account: @account, date: 1.day.ago.to_date, amount: 100, currency: "USD") - - run_sync_for(@account, start_date: 1.day.ago.to_date) - - assert_equal [ existing_balance.balance, existing_balance.balance - transaction.amount, @account.balance ], @account.balances.chronological.map(&:balance) - end - - private - - def run_sync_for(account, start_date: nil) - syncer = Account::Balance::Syncer.new(account, start_date: start_date) - syncer.run - end - - def create_exchange_rate(date, from:, to:, rate:) - ExchangeRate.create! date: date, from_currency: from, to_currency: to, rate: rate - end -end diff --git a/test/models/account/balance_calculator_test.rb b/test/models/account/balance_calculator_test.rb new file mode 100644 index 00000000000..334a647884e --- /dev/null +++ b/test/models/account/balance_calculator_test.rb @@ -0,0 +1,156 @@ +require "test_helper" + +class Account::BalanceCalculatorTest < ActiveSupport::TestCase + include Account::EntriesTestHelper + + setup do + @account = families(:empty).accounts.create!( + name: "Test", + balance: 20000, + cash_balance: 20000, + currency: "USD", + accountable: Investment.new + ) + end + + # When syncing backwards, we start with the account balance and generate everything from there. + test "reverse no entries sync" do + assert_equal 0, @account.balances.count + + expected = [ @account.balance ] + calculated = Account::BalanceCalculator.new(@account).calculate(reverse: true) + + assert_equal expected, calculated.map(&:balance) + end + + # When syncing forwards, we don't care about the account balance. We generate everything based on entries, starting from 0. + test "forward no entries sync" do + assert_equal 0, @account.balances.count + + expected = [ 0 ] + calculated = Account::BalanceCalculator.new(@account).calculate + + assert_equal expected, calculated.map(&:balance) + end + + test "forward valuations sync" do + create_valuation(account: @account, date: 4.days.ago.to_date, amount: 17000) + create_valuation(account: @account, date: 2.days.ago.to_date, amount: 19000) + + expected = [ 0, 17000, 17000, 19000, 19000, 19000 ] + calculated = Account::BalanceCalculator.new(@account).calculate.sort_by(&:date).map(&:balance) + + assert_equal expected, calculated + end + + test "reverse valuations sync" do + create_valuation(account: @account, date: 4.days.ago.to_date, amount: 17000) + create_valuation(account: @account, date: 2.days.ago.to_date, amount: 19000) + + expected = [ 17000, 17000, 19000, 19000, 20000, 20000 ] + calculated = Account::BalanceCalculator.new(@account).calculate(reverse: true).sort_by(&:date).map(&:balance) + + assert_equal expected, calculated + end + + test "forward transactions sync" do + create_transaction(account: @account, date: 4.days.ago.to_date, amount: -500) # income + create_transaction(account: @account, date: 2.days.ago.to_date, amount: 100) # expense + + expected = [ 0, 500, 500, 400, 400, 400 ] + calculated = Account::BalanceCalculator.new(@account).calculate.sort_by(&:date).map(&:balance) + + assert_equal expected, calculated + end + + test "reverse transactions sync" do + create_transaction(account: @account, date: 4.days.ago.to_date, amount: -500) # income + create_transaction(account: @account, date: 2.days.ago.to_date, amount: 100) # expense + + expected = [ 19600, 20100, 20100, 20000, 20000, 20000 ] + calculated = Account::BalanceCalculator.new(@account).calculate(reverse: true).sort_by(&:date).map(&:balance) + + assert_equal expected, calculated + end + + test "reverse multi-entry sync" do + create_transaction(account: @account, date: 8.days.ago.to_date, amount: -5000) + create_valuation(account: @account, date: 6.days.ago.to_date, amount: 17000) + create_transaction(account: @account, date: 6.days.ago.to_date, amount: -500) + create_transaction(account: @account, date: 4.days.ago.to_date, amount: -500) + create_valuation(account: @account, date: 3.days.ago.to_date, amount: 17000) + create_transaction(account: @account, date: 1.day.ago.to_date, amount: 100) + + expected = [ 12000, 17000, 17000, 17000, 16500, 17000, 17000, 20100, 20000, 20000 ] + calculated = Account::BalanceCalculator.new(@account).calculate(reverse: true) .sort_by(&:date).map(&:balance) + + assert_equal expected, calculated + end + + test "forward multi-entry sync" do + create_transaction(account: @account, date: 8.days.ago.to_date, amount: -5000) + create_valuation(account: @account, date: 6.days.ago.to_date, amount: 17000) + create_transaction(account: @account, date: 6.days.ago.to_date, amount: -500) + create_transaction(account: @account, date: 4.days.ago.to_date, amount: -500) + create_valuation(account: @account, date: 3.days.ago.to_date, amount: 17000) + create_transaction(account: @account, date: 1.day.ago.to_date, amount: 100) + + expected = [ 0, 5000, 5000, 17000, 17000, 17500, 17000, 17000, 16900, 16900 ] + calculated = Account::BalanceCalculator.new(@account).calculate.sort_by(&:date).map(&:balance) + + assert_equal expected, calculated + end + + test "investment balance sync" do + @account.update!(cash_balance: 18000) + + # Transactions represent deposits / withdrawals from the brokerage account + # Ex: We deposit $20,000 into the brokerage account + create_transaction(account: @account, date: 2.days.ago.to_date, amount: -20000) + + # Trades either consume cash (buy) or generate cash (sell). They do NOT change total balance, but do affect composition of cash/holdings. + # Ex: We buy 20 shares of MSFT at $100 for a total of $2000 + create_trade(securities(:msft), account: @account, date: 1.day.ago.to_date, qty: 20, price: 100) + + holdings = [ + Account::Holding.new(date: Date.current, security: securities(:msft), amount: 2000), + Account::Holding.new(date: 1.day.ago.to_date, security: securities(:msft), amount: 2000), + Account::Holding.new(date: 2.days.ago.to_date, security: securities(:msft), amount: 0) + ] + + expected = [ 0, 20000, 20000, 20000 ] + calculated_backwards = Account::BalanceCalculator.new(@account, holdings: holdings).calculate(reverse: true).sort_by(&:date).map(&:balance) + calculated_forwards = Account::BalanceCalculator.new(@account, holdings: holdings).calculate.sort_by(&:date).map(&:balance) + + assert_equal calculated_forwards, calculated_backwards + assert_equal expected, calculated_forwards + end + + test "multi-currency sync" do + ExchangeRate.create! date: 1.day.ago.to_date, from_currency: "EUR", to_currency: "USD", rate: 1.2 + + create_transaction(account: @account, date: 3.days.ago.to_date, amount: -100, currency: "USD") + create_transaction(account: @account, date: 2.days.ago.to_date, amount: -300, currency: "USD") + + # Transaction in different currency than the account's main currency + create_transaction(account: @account, date: 1.day.ago.to_date, amount: -500, currency: "EUR") # €500 * 1.2 = $600 + + expected = [ 0, 100, 400, 1000, 1000 ] + calculated = Account::BalanceCalculator.new(@account).calculate.sort_by(&:date).map(&:balance) + + assert_equal expected, calculated + end + + private + def create_holding(date:, security:, amount:) + Account::Holding.create!( + account: @account, + security: security, + date: date, + qty: 0, # not used + price: 0, # not used + amount: amount, + currency: @account.currency + ) + end +end diff --git a/test/models/account/entry_test.rb b/test/models/account/entry_test.rb index b5711d293f5..9c541b6bbe9 100644 --- a/test/models/account/entry_test.rb +++ b/test/models/account/entry_test.rb @@ -43,13 +43,10 @@ class Account::EntryTest < ActiveSupport::TestCase end test "triggers sync with correct start date when transaction deleted" do - current_entry = create_transaction(date: 1.day.ago.to_date) - prior_entry = create_transaction(date: current_entry.date - 1.day) + @entry.destroy! - current_entry.destroy! - - current_entry.account.expects(:sync_later).with(start_date: prior_entry.date) - current_entry.sync_account_later + @entry.account.expects(:sync_later).with(start_date: nil) + @entry.sync_account_later end test "can search entries" do @@ -99,26 +96,4 @@ class Account::EntryTest < ActiveSupport::TestCase assert create_transaction(amount: -10).inflow? assert create_transaction(amount: 10).outflow? end - - test "balance_after_entry skips account valuations" do - family = families(:empty) - account = family.accounts.create! name: "Test", balance: 0, currency: "USD", accountable: Depository.new - - new_valuation = create_valuation(account: account, amount: 1) - transaction = create_transaction(date: new_valuation.date, account: account, amount: -100) - - - assert_equal Money.new(100), transaction.balance_after_entry - end - - test "prior_entry_balance returns last transaction entry balance" do - family = families(:empty) - account = family.accounts.create! name: "Test", balance: 0, currency: "USD", accountable: Depository.new - - new_valuation = create_valuation(account: account, amount: 1) - transaction = create_transaction(date: new_valuation.date, account: account, amount: -100) - - - assert_equal Money.new(100), transaction.prior_entry_balance - end end diff --git a/test/models/account/holding/syncer_test.rb b/test/models/account/holding/syncer_test.rb deleted file mode 100644 index 6d04fb3e999..00000000000 --- a/test/models/account/holding/syncer_test.rb +++ /dev/null @@ -1,145 +0,0 @@ -require "test_helper" - -class Account::Holding::SyncerTest < ActiveSupport::TestCase - include Account::EntriesTestHelper, SecuritiesTestHelper - - setup do - @account = families(:empty).accounts.create!(name: "Test Brokerage", balance: 20000, currency: "USD", accountable: Investment.new) - end - - test "account with no trades has no holdings" do - run_sync_for(@account) - - assert_equal [], @account.holdings - end - - test "can buy and sell securities" do - # First create securities with their prices - security1 = create_security("AMZN", prices: [ - { date: 2.days.ago.to_date, price: 214 }, - { date: 1.day.ago.to_date, price: 215 }, - { date: Date.current, price: 216 } - ]) - - security2 = create_security("NVDA", prices: [ - { date: 1.day.ago.to_date, price: 122 }, - { date: Date.current, price: 124 } - ]) - - # Then create trades after prices exist - create_trade(security1, account: @account, qty: 10, date: 2.days.ago.to_date) # buy 10 shares of AMZN - create_trade(security1, account: @account, qty: 2, date: 1.day.ago.to_date) # buy 2 shares of AMZN - create_trade(security2, account: @account, qty: 20, date: 1.day.ago.to_date) # buy 20 shares of NVDA - create_trade(security1, account: @account, qty: -10, date: Date.current) # sell 10 shares of AMZN - - expected = [ - { security: security1, qty: 10, price: 214, amount: 10 * 214, date: 2.days.ago.to_date }, - { security: security1, qty: 12, price: 215, amount: 12 * 215, date: 1.day.ago.to_date }, - { security: security1, qty: 2, price: 216, amount: 2 * 216, date: Date.current }, - { security: security2, qty: 20, price: 122, amount: 20 * 122, date: 1.day.ago.to_date }, - { security: security2, qty: 20, price: 124, amount: 20 * 124, date: Date.current } - ] - - run_sync_for(@account) - - assert_holdings(expected) - end - - test "generates holdings with prices" do - provider = mock - Security::Price.stubs(:security_prices_provider).returns(provider) - - provider.expects(:fetch_security_prices).never - - amzn = create_security("AMZN", prices: [ { date: Date.current, price: 215 } ]) - create_trade(amzn, account: @account, qty: 10, date: Date.current, price: 215) - - expected = [ - { security: amzn, qty: 10, price: 215, amount: 10 * 215, date: Date.current } - ] - - run_sync_for(@account) - - assert_holdings(expected) - end - - test "generates all holdings even when missing security prices" do - amzn = create_security("AMZN", prices: []) - - create_trade(amzn, account: @account, qty: 10, date: 2.days.ago.to_date, price: 210) - - # 2 days ago — no daily price found, but since this is day of entry, we fall back to entry price - # 1 day ago — finds daily price, uses it - # Today — no daily price, no entry, so price and amount are `nil` - expected = [ - { security: amzn, qty: 10, price: 210, amount: 10 * 210, date: 2.days.ago.to_date }, - { security: amzn, qty: 10, price: 215, amount: 10 * 215, date: 1.day.ago.to_date }, - { security: amzn, qty: 10, price: nil, amount: nil, date: Date.current } - ] - - fetched_prices = [ Security::Price.new(ticker: "AMZN", date: 1.day.ago.to_date, price: 215) ] - - Gapfiller.any_instance.expects(:run).returns(fetched_prices) - Security::Price.expects(:find_prices) - .with(security: amzn, start_date: 2.days.ago.to_date, end_date: Date.current) - .once - .returns(fetched_prices) - - run_sync_for(@account) - - assert_holdings(expected) - end - - # It is common for data providers to not provide prices for weekends, so we need to carry the last observation forward - test "uses locf gapfilling when price is missing" do - friday = Date.new(2024, 9, 27) # A known Friday - saturday = friday + 1.day # weekend - sunday = saturday + 1.day # weekend - monday = sunday + 1.day # known Monday - - # Prices should be gapfilled like this: 210, 210, 210, 220 - tm = create_security("TM", prices: [ - { date: friday, price: 210 }, - { date: monday, price: 220 } - ]) - - create_trade(tm, account: @account, qty: 10, date: friday, price: 210) - - expected = [ - { security: tm, qty: 10, price: 210, amount: 10 * 210, date: friday }, - { security: tm, qty: 10, price: 210, amount: 10 * 210, date: saturday }, - { security: tm, qty: 10, price: 210, amount: 10 * 210, date: sunday }, - { security: tm, qty: 10, price: 220, amount: 10 * 220, date: monday } - ] - - run_sync_for(@account) - - assert_holdings(expected) - end - - private - - def assert_holdings(expected_holdings) - holdings = @account.holdings.includes(:security).to_a - expected_holdings.each do |expected_holding| - actual_holding = holdings.find { |holding| - holding.security == expected_holding[:security] && - holding.date == expected_holding[:date] - } - date = expected_holding[:date] - expected_price = expected_holding[:price] - expected_qty = expected_holding[:qty] - expected_amount = expected_holding[:amount] - ticker = expected_holding[:security].ticker - - assert actual_holding, "expected #{ticker} holding on date: #{date}" - assert_equal expected_holding[:qty], actual_holding.qty, "expected #{expected_qty} qty for holding #{ticker} on date: #{date}" - assert_equal expected_holding[:amount].to_d, actual_holding.amount.to_d, "expected #{expected_amount} amount for holding #{ticker} on date: #{date}" - assert_equal expected_holding[:price].to_d, actual_holding.price.to_d, "expected #{expected_price} price for holding #{ticker} on date: #{date}" - end - end - - def run_sync_for(account) - Account::Holding::Syncer.new(account).run - end -end diff --git a/test/models/account/holding_calculator_test.rb b/test/models/account/holding_calculator_test.rb new file mode 100644 index 00000000000..154c8afe1f9 --- /dev/null +++ b/test/models/account/holding_calculator_test.rb @@ -0,0 +1,231 @@ +require "test_helper" + +class Account::HoldingCalculatorTest < ActiveSupport::TestCase + include Account::EntriesTestHelper + + setup do + @account = families(:empty).accounts.create!( + name: "Test", + balance: 20000, + cash_balance: 20000, + currency: "USD", + accountable: Investment.new + ) + end + + test "no holdings" do + forward = Account::HoldingCalculator.new(@account).calculate + reverse = Account::HoldingCalculator.new(@account).calculate(reverse: true) + assert_equal forward, reverse + assert_equal [], forward + end + + # Should be able to handle this case, although we should not be reverse-syncing an account without provided current day holdings + test "reverse portfolio with trades but without current day holdings" do + voo = Security.create!(ticker: "VOO", name: "Vanguard S&P 500 ETF") + Security::Price.create!(security: voo, date: Date.current, price: 470) + Security::Price.create!(security: voo, date: 1.day.ago.to_date, price: 470) + + create_trade(voo, qty: -10, date: Date.current, price: 470, account: @account) + + calculated = Account::HoldingCalculator.new(@account).calculate(reverse: true) + assert_equal 2, calculated.length + end + + test "reverse portfolio calculation" do + load_today_portfolio + + # Build up to 10 shares of VOO (current value $5000) + create_trade(@voo, qty: 20, date: 3.days.ago.to_date, price: 470, account: @account) + create_trade(@voo, qty: -15, date: 2.days.ago.to_date, price: 480, account: @account) + create_trade(@voo, qty: 5, date: 1.day.ago.to_date, price: 490, account: @account) + + # Amazon won't exist in current holdings because qty is zero, but should show up in historical portfolio + create_trade(@amzn, qty: 1, date: 2.days.ago.to_date, price: 200, account: @account) + create_trade(@amzn, qty: -1, date: 1.day.ago.to_date, price: 200, account: @account) + + # Build up to 100 shares of WMT (current value $10000) + create_trade(@wmt, qty: 100, date: 1.day.ago.to_date, price: 100, account: @account) + + expected = [ + # 4 days ago + Account::Holding.new(security: @voo, date: 4.days.ago.to_date, qty: 0, price: 460, amount: 0), + Account::Holding.new(security: @wmt, date: 4.days.ago.to_date, qty: 0, price: 100, amount: 0), + Account::Holding.new(security: @amzn, date: 4.days.ago.to_date, qty: 0, price: 200, amount: 0), + + # 3 days ago + Account::Holding.new(security: @voo, date: 3.days.ago.to_date, qty: 20, price: 470, amount: 9400), + Account::Holding.new(security: @wmt, date: 3.days.ago.to_date, qty: 0, price: 100, amount: 0), + Account::Holding.new(security: @amzn, date: 3.days.ago.to_date, qty: 0, price: 200, amount: 0), + + # 2 days ago + Account::Holding.new(security: @voo, date: 2.days.ago.to_date, qty: 5, price: 480, amount: 2400), + Account::Holding.new(security: @wmt, date: 2.days.ago.to_date, qty: 0, price: 100, amount: 0), + Account::Holding.new(security: @amzn, date: 2.days.ago.to_date, qty: 1, price: 200, amount: 200), + + # 1 day ago + Account::Holding.new(security: @voo, date: 1.day.ago.to_date, qty: 10, price: 490, amount: 4900), + Account::Holding.new(security: @wmt, date: 1.day.ago.to_date, qty: 100, price: 100, amount: 10000), + Account::Holding.new(security: @amzn, date: 1.day.ago.to_date, qty: 0, price: 200, amount: 0), + + # Today + Account::Holding.new(security: @voo, date: Date.current, qty: 10, price: 500, amount: 5000), + Account::Holding.new(security: @wmt, date: Date.current, qty: 100, price: 100, amount: 10000), + Account::Holding.new(security: @amzn, date: Date.current, qty: 0, price: 200, amount: 0) + ] + + calculated = Account::HoldingCalculator.new(@account).calculate(reverse: true) + + assert_equal expected.length, calculated.length + + expected.each do |expected_entry| + calculated_entry = calculated.find { |c| c.security == expected_entry.security && c.date == expected_entry.date } + + assert_equal expected_entry.qty, calculated_entry.qty, "Qty mismatch for #{expected_entry.security.ticker} on #{expected_entry.date}" + assert_equal expected_entry.price, calculated_entry.price, "Price mismatch for #{expected_entry.security.ticker} on #{expected_entry.date}" + assert_equal expected_entry.amount, calculated_entry.amount, "Amount mismatch for #{expected_entry.security.ticker} on #{expected_entry.date}" + end + end + + test "forward portfolio calculation" do + load_prices + + # Build up to 10 shares of VOO (current value $5000) + create_trade(@voo, qty: 20, date: 3.days.ago.to_date, price: 470, account: @account) + create_trade(@voo, qty: -15, date: 2.days.ago.to_date, price: 480, account: @account) + create_trade(@voo, qty: 5, date: 1.day.ago.to_date, price: 490, account: @account) + + # Amazon won't exist in current holdings because qty is zero, but should show up in historical portfolio + create_trade(@amzn, qty: 1, date: 2.days.ago.to_date, price: 200, account: @account) + create_trade(@amzn, qty: -1, date: 1.day.ago.to_date, price: 200, account: @account) + + # Build up to 100 shares of WMT (current value $10000) + create_trade(@wmt, qty: 100, date: 1.day.ago.to_date, price: 100, account: @account) + + expected = [ + # 4 days ago + Account::Holding.new(security: @voo, date: 4.days.ago.to_date, qty: 0, price: 460, amount: 0), + Account::Holding.new(security: @wmt, date: 4.days.ago.to_date, qty: 0, price: 100, amount: 0), + Account::Holding.new(security: @amzn, date: 4.days.ago.to_date, qty: 0, price: 200, amount: 0), + + # 3 days ago + Account::Holding.new(security: @voo, date: 3.days.ago.to_date, qty: 20, price: 470, amount: 9400), + Account::Holding.new(security: @wmt, date: 3.days.ago.to_date, qty: 0, price: 100, amount: 0), + Account::Holding.new(security: @amzn, date: 3.days.ago.to_date, qty: 0, price: 200, amount: 0), + + # 2 days ago + Account::Holding.new(security: @voo, date: 2.days.ago.to_date, qty: 5, price: 480, amount: 2400), + Account::Holding.new(security: @wmt, date: 2.days.ago.to_date, qty: 0, price: 100, amount: 0), + Account::Holding.new(security: @amzn, date: 2.days.ago.to_date, qty: 1, price: 200, amount: 200), + + # 1 day ago + Account::Holding.new(security: @voo, date: 1.day.ago.to_date, qty: 10, price: 490, amount: 4900), + Account::Holding.new(security: @wmt, date: 1.day.ago.to_date, qty: 100, price: 100, amount: 10000), + Account::Holding.new(security: @amzn, date: 1.day.ago.to_date, qty: 0, price: 200, amount: 0), + + # Today + Account::Holding.new(security: @voo, date: Date.current, qty: 10, price: 500, amount: 5000), + Account::Holding.new(security: @wmt, date: Date.current, qty: 100, price: 100, amount: 10000), + Account::Holding.new(security: @amzn, date: Date.current, qty: 0, price: 200, amount: 0) + ] + + calculated = Account::HoldingCalculator.new(@account).calculate + + assert_equal expected.length, calculated.length + assert_holdings(expected, calculated) + end + + # Carries the previous record forward if no holding exists for a date + # to ensure that net worth historical rollups have a value for every date + test "uses locf to fill missing holdings" do + load_prices + + create_trade(@wmt, qty: 100, date: 1.day.ago.to_date, price: 100, account: @account) + + expected = [ + Account::Holding.new(security: @wmt, date: 2.days.ago.to_date, qty: 0, price: 100, amount: 0), + Account::Holding.new(security: @wmt, date: 1.day.ago.to_date, qty: 100, price: 100, amount: 10000), + Account::Holding.new(security: @wmt, date: Date.current, qty: 100, price: 100, amount: 10000) + ] + + # Price missing today, so we should carry forward the holding from 1 day ago + Security.stubs(:find).returns(@wmt) + Security::Price.stubs(:find_price).with(security: @wmt, date: 2.days.ago.to_date).returns(Security::Price.new(price: 100)) + Security::Price.stubs(:find_price).with(security: @wmt, date: 1.day.ago.to_date).returns(Security::Price.new(price: 100)) + Security::Price.stubs(:find_price).with(security: @wmt, date: Date.current).returns(nil) + + calculated = Account::HoldingCalculator.new(@account).calculate + + assert_equal expected.length, calculated.length + + assert_holdings(expected, calculated) + end + + private + def assert_holdings(expected, calculated) + expected.each do |expected_entry| + calculated_entry = calculated.find { |c| c.security == expected_entry.security && c.date == expected_entry.date } + + assert_equal expected_entry.qty, calculated_entry.qty, "Qty mismatch for #{expected_entry.security.ticker} on #{expected_entry.date}" + assert_equal expected_entry.price, calculated_entry.price, "Price mismatch for #{expected_entry.security.ticker} on #{expected_entry.date}" + assert_equal expected_entry.amount, calculated_entry.amount, "Amount mismatch for #{expected_entry.security.ticker} on #{expected_entry.date}" + end + end + + def load_prices + @voo = Security.create!(ticker: "VOO", name: "Vanguard S&P 500 ETF") + Security::Price.create!(security: @voo, date: 4.days.ago.to_date, price: 460) + Security::Price.create!(security: @voo, date: 3.days.ago.to_date, price: 470) + Security::Price.create!(security: @voo, date: 2.days.ago.to_date, price: 480) + Security::Price.create!(security: @voo, date: 1.day.ago.to_date, price: 490) + Security::Price.create!(security: @voo, date: Date.current, price: 500) + + @wmt = Security.create!(ticker: "WMT", name: "Walmart Inc.") + Security::Price.create!(security: @wmt, date: 4.days.ago.to_date, price: 100) + Security::Price.create!(security: @wmt, date: 3.days.ago.to_date, price: 100) + Security::Price.create!(security: @wmt, date: 2.days.ago.to_date, price: 100) + Security::Price.create!(security: @wmt, date: 1.day.ago.to_date, price: 100) + Security::Price.create!(security: @wmt, date: Date.current, price: 100) + + @amzn = Security.create!(ticker: "AMZN", name: "Amazon.com Inc.") + Security::Price.create!(security: @amzn, date: 4.days.ago.to_date, price: 200) + Security::Price.create!(security: @amzn, date: 3.days.ago.to_date, price: 200) + Security::Price.create!(security: @amzn, date: 2.days.ago.to_date, price: 200) + Security::Price.create!(security: @amzn, date: 1.day.ago.to_date, price: 200) + Security::Price.create!(security: @amzn, date: Date.current, price: 200) + end + + # Portfolio holdings: + # +--------+-----+--------+---------+ + # | Ticker | Qty | Price | Amount | + # +--------+-----+--------+---------+ + # | VOO | 10 | $500 | $5,000 | + # | WMT | 100 | $100 | $10,000 | + # +--------+-----+--------+---------+ + # Brokerage Cash: $5,000 + # Holdings Value: $15,000 + # Total Balance: $20,000 + def load_today_portfolio + @account.update!(cash_balance: 5000) + + load_prices + + @account.holdings.create!( + date: Date.current, + price: 500, + qty: 10, + amount: 5000, + currency: "USD", + security: @voo + ) + + @account.holdings.create!( + date: Date.current, + price: 100, + qty: 100, + amount: 10000, + currency: "USD", + security: @wmt + ) + end +end diff --git a/test/models/account/holding_test.rb b/test/models/account/holding_test.rb index 5feb0562d2d..dc521801009 100644 --- a/test/models/account/holding_test.rb +++ b/test/models/account/holding_test.rb @@ -5,16 +5,15 @@ class Account::HoldingTest < ActiveSupport::TestCase include Account::EntriesTestHelper, SecuritiesTestHelper setup do - @account = families(:empty).accounts.create!(name: "Test Brokerage", balance: 20000, currency: "USD", accountable: Investment.new) + @account = families(:empty).accounts.create!(name: "Test Brokerage", balance: 20000, cash_balance: 0, currency: "USD", accountable: Investment.new) # Current day holding instances @amzn, @nvda = load_holdings end test "calculates portfolio weight" do - expected_portfolio_value = 6960.0 - expected_amzn_weight = 3240.0 / expected_portfolio_value * 100 - expected_nvda_weight = 3720.0 / expected_portfolio_value * 100 + expected_amzn_weight = 3240.0 / @account.balance * 100 + expected_nvda_weight = 3720.0 / @account.balance * 100 assert_in_delta expected_amzn_weight, @amzn.weight, 0.001 assert_in_delta expected_nvda_weight, @nvda.weight, 0.001 diff --git a/test/models/account/syncer_test.rb b/test/models/account/syncer_test.rb new file mode 100644 index 00000000000..b03ee3d9ee5 --- /dev/null +++ b/test/models/account/syncer_test.rb @@ -0,0 +1,54 @@ +require "test_helper" + +class Account::SyncerTest < ActiveSupport::TestCase + include Account::EntriesTestHelper + + setup do + @account = families(:empty).accounts.create!( + name: "Test", + balance: 20000, + cash_balance: 20000, + currency: "USD", + accountable: Investment.new + ) + end + + test "converts foreign account balances to family currency" do + @account.family.update! currency: "USD" + @account.update! currency: "EUR" + + ExchangeRate.create!(date: 1.day.ago.to_date, from_currency: "EUR", to_currency: "USD", rate: 1.2) + ExchangeRate.create!(date: Date.current, from_currency: "EUR", to_currency: "USD", rate: 2) + + Account::BalanceCalculator.any_instance.expects(:calculate).returns( + [ + Account::Balance.new(date: 1.day.ago.to_date, balance: 1000, cash_balance: 1000, currency: "EUR"), + Account::Balance.new(date: Date.current, balance: 1000, cash_balance: 1000, currency: "EUR") + ] + ) + + Account::Syncer.new(@account).run + + assert_equal [ 1000, 1000 ], @account.balances.where(currency: "EUR").chronological.map(&:balance) + assert_equal [ 1200, 2000 ], @account.balances.where(currency: "USD").chronological.map(&:balance) + end + + test "purges stale balances and holdings" do + # Old, out of range holdings and balances + @account.holdings.create!(security: securities(:aapl), date: 10.years.ago.to_date, currency: "USD", qty: 100, price: 100, amount: 10000) + @account.balances.create!(date: 10.years.ago.to_date, currency: "USD", balance: 10000, cash_balance: 10000) + + assert_equal 1, @account.holdings.count + assert_equal 1, @account.balances.count + + Account::Syncer.new(@account).run + + @account.reload + + assert_equal 0, @account.holdings.count + + # Balance sync always creates 1 balance if no entries present. + assert_equal 1, @account.balances.count + assert_equal 0, @account.balances.first.balance + end +end diff --git a/test/system/trades_test.rb b/test/system/trades_test.rb index bc8d3965925..79945a3e8bc 100644 --- a/test/system/trades_test.rb +++ b/test/system/trades_test.rb @@ -8,7 +8,7 @@ class TradesTest < ApplicationSystemTestCase @account = accounts(:investment) - visit_account_trades + visit_account_portfolio Security.stubs(:search).returns([ Security.new( @@ -66,10 +66,7 @@ class TradesTest < ApplicationSystemTestCase private def open_new_trade_modal - within "[data-testid='activity-menu']" do - click_on "New" - click_on "New transaction" - end + click_on "New transaction" end def within_trades(&block) @@ -77,6 +74,10 @@ def within_trades(&block) end def visit_account_trades + visit account_path(@account, tab: "activity") + end + + def visit_account_portfolio visit account_path(@account) end diff --git a/test/system/transactions_test.rb b/test/system/transactions_test.rb index 0552db92765..26db48c3720 100644 --- a/test/system/transactions_test.rb +++ b/test/system/transactions_test.rb @@ -182,7 +182,7 @@ class TransactionsTest < ApplicationSystemTestCase investment_account = accounts(:investment) investment_account.entries.create!(name: "Investment account", date: Date.current, amount: 1000, currency: "USD", entryable: Account::Transaction.new) transfer_date = Date.current - visit account_url(investment_account) + visit account_url(investment_account, tab: "activity") within "[data-testid='activity-menu']" do click_on "New" click_on "New transaction"