mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-07-19 05:09:38 +02:00
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
This commit is contained in:
parent
a59ca5b7c6
commit
49c353e10c
72 changed files with 1152 additions and 1046 deletions
1
Gemfile
1
Gemfile
|
@ -50,7 +50,6 @@ gem "csv"
|
|||
gem "redcarpet"
|
||||
gem "stripe"
|
||||
gem "intercom-rails"
|
||||
gem "holidays"
|
||||
gem "plaid"
|
||||
|
||||
group :development, :test do
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -1,7 +0,0 @@
|
|||
class Account::CashesController < ApplicationController
|
||||
layout :with_sidebar
|
||||
|
||||
def index
|
||||
@account = Current.family.accounts.find(params[:account_id])
|
||||
end
|
||||
end
|
|
@ -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
|
||||
|
|
|
@ -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 @@ class AccountsController < ApplicationController
|
|||
@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
|
||||
|
||||
|
|
|
@ -3,6 +3,7 @@ module Localize
|
|||
|
||||
included do
|
||||
around_action :switch_locale
|
||||
around_action :switch_timezone
|
||||
end
|
||||
|
||||
private
|
||||
|
@ -10,4 +11,9 @@ module Localize
|
|||
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
|
||||
|
|
|
@ -41,7 +41,7 @@ class UsersController < ApplicationController
|
|||
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
|
||||
|
||||
|
|
|
@ -13,17 +13,12 @@ module Account::EntriesHelper
|
|||
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
|
||||
|
||||
|
|
|
@ -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
|
|
@ -363,4 +363,8 @@ module LanguagesHelper
|
|||
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
|
||||
|
|
|
@ -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 @@ class Account < ApplicationRecord
|
|||
|
||||
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 @@ class Account < ApplicationRecord
|
|||
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)
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
121
app/models/account/balance_calculator.rb
Normal file
121
app/models/account/balance_calculator.rb
Normal file
|
@ -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
|
94
app/models/account/balance_trend_calculator.rb
Normal file
94
app/models/account/balance_trend_calculator.rb
Normal file
|
@ -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
|
|
@ -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 @@ class Account::Entry < ApplicationRecord
|
|||
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 @@ class Account::Entry < ApplicationRecord
|
|||
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
|
||||
|
|
|
@ -22,9 +22,9 @@ class Account::Holding < ApplicationRecord
|
|||
|
||||
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
|
||||
|
|
|
@ -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
|
154
app/models/account/holding_calculator.rb
Normal file
154
app/models/account/holding_calculator.rb
Normal file
|
@ -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
|
104
app/models/account/syncer.rb
Normal file
104
app/models/account/syncer.rb
Normal file
|
@ -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
|
|
@ -59,7 +59,7 @@ class Account::TradeBuilder
|
|||
)
|
||||
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,
|
||||
|
|
|
@ -10,44 +10,4 @@ class Account::Valuation < ApplicationRecord
|
|||
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
|
||||
|
|
|
@ -18,26 +18,7 @@ module Accountable
|
|||
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}",
|
||||
|
|
|
@ -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
|
|
@ -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 @@ class Investment < ApplicationRecord
|
|||
end
|
||||
|
||||
def post_sync
|
||||
broadcast_remove_to(account, target: "syncing-notice")
|
||||
|
||||
broadcast_replace_to(
|
||||
account,
|
||||
target: "chart_account_#{account.id}",
|
||||
|
|
|
@ -167,6 +167,7 @@ class PlaidAccount < ApplicationRecord
|
|||
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,
|
||||
|
|
|
@ -1,21 +0,0 @@
|
|||
<%# locals: (holding:) %>
|
||||
|
||||
<%= turbo_frame_tag dom_id(holding) do %>
|
||||
<div class="grid grid-cols-12 items-center text-gray-900 text-sm font-medium p-4">
|
||||
<div class="col-span-9 flex items-center gap-4">
|
||||
<%= render "shared/circle_logo", name: holding.name %>
|
||||
<div>
|
||||
<%= tag.p holding.name %>
|
||||
<%= tag.p holding.ticker, class: "text-gray-500 text-xs uppercase" %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-span-3 text-right">
|
||||
<% if holding.amount_money %>
|
||||
<%= tag.p format_money holding.amount_money %>
|
||||
<% else %>
|
||||
<%= tag.p "?", class: "text-gray-500" %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
|
@ -1,18 +0,0 @@
|
|||
<%= turbo_frame_tag dom_id(@account, "cash") do %>
|
||||
<div class="bg-white space-y-4 p-5 border border-alpha-black-25 rounded-xl shadow-xs">
|
||||
<div class="flex items-center justify-between">
|
||||
<%= tag.h2 t(".cash"), class: "font-medium text-lg" %>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl bg-gray-25 p-1">
|
||||
<div class="grid grid-cols-12 items-center uppercase text-xs font-medium text-gray-500 px-4 py-2">
|
||||
<%= tag.p t(".name"), class: "col-span-9" %>
|
||||
<%= tag.p t(".value"), class: "col-span-3 justify-self-end" %>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg bg-white border-alpha-black-25 shadow-xs">
|
||||
<%= render partial: "account/cashes/cash", collection: [brokerage_cash(@account)], as: :holding %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
|
@ -1,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 %>
|
||||
|
|
|
@ -73,13 +73,14 @@
|
|||
|
||||
<div>
|
||||
<div class="rounded-tl-lg rounded-tr-lg bg-white border-alpha-black-25 shadow-xs">
|
||||
|
||||
<div class="space-y-4">
|
||||
<% 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 %>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="p-4 bg-white rounded-bl-lg rounded-br-lg">
|
||||
|
|
32
app/views/account/holdings/_cash.html.erb
Normal file
32
app/views/account/holdings/_cash.html.erb
Normal file
|
@ -0,0 +1,32 @@
|
|||
<%# locals: (account:) %>
|
||||
|
||||
<% currency = Money::Currency.new(account.currency) %>
|
||||
|
||||
<div class="grid grid-cols-12 items-center text-gray-900 text-sm font-medium p-4">
|
||||
<div class="col-span-4 flex items-center gap-4">
|
||||
<%= render "shared/circle_logo", name: currency.iso_code %>
|
||||
|
||||
<div class="space-y-0.5">
|
||||
<%= tag.p t(".brokerage_cash"), class: "text-gray-900" %>
|
||||
<%= tag.p account.currency, class: "text-gray-500 text-xs uppercase" %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-span-2 flex justify-end items-center gap-2">
|
||||
<% 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) %>
|
||||
</div>
|
||||
|
||||
<div class="col-span-2 text-right">
|
||||
<%= tag.p "--", class: "text-gray-500" %>
|
||||
</div>
|
||||
|
||||
<div class="col-span-2 text-right">
|
||||
<%= tag.p format_money account.cash_balance %>
|
||||
</div>
|
||||
|
||||
<div class="col-span-2 text-right">
|
||||
<%= tag.p "--", class: "text-gray-500" %>
|
||||
</div>
|
||||
</div>
|
|
@ -2,7 +2,7 @@
|
|||
<div class="bg-white space-y-4 p-5 border border-alpha-black-25 rounded-xl shadow-xs">
|
||||
<div class="flex items-center justify-between">
|
||||
<%= tag.h2 t(".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 @@
|
|||
</div>
|
||||
|
||||
<div class="rounded-lg bg-white border-alpha-black-25 shadow-xs">
|
||||
<% 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 %>
|
||||
<p class="text-gray-500 text-sm p-4"><%= t(".no_holdings") %></p>
|
||||
<% end %>
|
||||
|
|
|
@ -32,7 +32,7 @@
|
|||
</div>
|
||||
<% 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 %>
|
||||
|
|
|
@ -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 @@
|
|||
</div>
|
||||
|
||||
<div class="col-span-2 justify-self-end">
|
||||
<% if balance_trend&.trend %>
|
||||
<div class="flex items-center gap-2">
|
||||
<%= tag.p format_money(balance_trend.trend.current), class: "font-medium text-sm text-gray-900" %>
|
||||
</div>
|
||||
<% else %>
|
||||
<%= tag.p "--", class: "font-medium text-sm text-gray-400" %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -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" %>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
|
|
|
@ -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 %>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
|
|
|
@ -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 %>
|
||||
|
||||
<div class="grid grid-cols-12 items-center <%= entry.excluded ? "text-gray-400 bg-gray-25" : "text-gray-900" %> text-sm font-medium p-4">
|
||||
|
@ -34,7 +34,7 @@
|
|||
</div>
|
||||
|
||||
<% if entry.transfer.present? %>
|
||||
<% unless show_balance %>
|
||||
<% unless balance_trend %>
|
||||
<div class="col-span-2"></div>
|
||||
<% end %>
|
||||
|
||||
|
@ -46,7 +46,7 @@
|
|||
<%= render "categories/menu", transaction: transaction %>
|
||||
</div>
|
||||
|
||||
<% 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?] %>
|
||||
</div>
|
||||
|
||||
<% if show_balance %>
|
||||
<% if balance_trend %>
|
||||
<div class="col-span-2 justify-self-end">
|
||||
<% 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 %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
|
|
@ -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 %>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
<% end %>
|
||||
|
||||
<div class="space-y-3">
|
||||
<%= 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 %>
|
||||
</div>
|
||||
|
||||
|
|
|
@ -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" %>
|
||||
|
||||
<div class="p-4 grid grid-cols-12 items-center text-gray-900 text-sm font-medium">
|
||||
<div class="col-span-8 flex items-center gap-4">
|
||||
|
@ -12,15 +12,15 @@
|
|||
<% end %>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<%= 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 %>
|
||||
|
||||
<div class="truncate text-gray-900">
|
||||
<% 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 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-span-2 justify-self-end font-medium text-sm" style="color: <%= valuation.color %>">
|
||||
<%= tag.span format_money(entry.trend.value) %>
|
||||
<div class="col-span-2 justify-self-end font-medium text-sm">
|
||||
<% 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 %>
|
||||
</div>
|
||||
|
||||
<div class="col-span-2 justify-self-end">
|
||||
|
|
|
@ -11,7 +11,7 @@
|
|||
<%= tooltip %>
|
||||
</div>
|
||||
|
||||
<%= 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" %>
|
||||
</div>
|
||||
|
||||
<%= form_with url: request.path, method: :get, data: { controller: "auto-submit-form" } do |form| %>
|
||||
|
|
|
@ -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 %>
|
|
@ -1,21 +0,0 @@
|
|||
<%# locals: (account:, **args) %>
|
||||
|
||||
<div id="<%= dom_id(account, :chart) %>" class="bg-white shadow-xs rounded-xl border border-alpha-black-25 rounded-lg">
|
||||
<div class="p-4 flex justify-between">
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center gap-1">
|
||||
<%= tag.p t(".value"), class: "text-sm font-medium text-gray-500" %>
|
||||
</div>
|
||||
|
||||
<%= tag.p format_money(account.value), class: "text-gray-900 text-3xl font-medium" %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative h-64 flex items-center justify-center">
|
||||
<%= image_tag "placeholder-graph.svg", class: "w-full h-full object-cover rounded-bl-lg rounded-br-lg opacity-50" %>
|
||||
<div class="absolute top-0 left-0 w-full h-full flex flex-col items-center justify-center space-y-1">
|
||||
<p class="text-gray-900 text-sm">Historical investment data coming soon.</p>
|
||||
<p class="text-gray-500 text-sm">We're working to bring you the full picture.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
|
@ -1,4 +1,4 @@
|
|||
<%# locals: (value:, cash:) %>
|
||||
<%# locals: (balance:, holdings:, cash:) %>
|
||||
|
||||
<div data-controller="tooltip" data-tooltip-placement-value="right" data-tooltip-offset-value=10 data-tooltip-cross-axis-value=50>
|
||||
<%= lucide_icon("info", class: "w-4 h-4 shrink-0 text-gray-500") %>
|
||||
|
@ -7,14 +7,6 @@
|
|||
<%= t(".total_value_tooltip") %>
|
||||
</div>
|
||||
<div class="flex pt-3">
|
||||
<div class="text-gray-300">
|
||||
<%= t(".holdings") %>
|
||||
</div>
|
||||
<div class="text-white ml-auto">
|
||||
<%= tag.p format_money(value, precision: 0) %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex">
|
||||
<div class="text-gray-300">
|
||||
<%= t(".cash") %>
|
||||
</div>
|
||||
|
@ -22,5 +14,24 @@
|
|||
<%= tag.p format_money(cash, precision: 0) %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex">
|
||||
<div class="text-gray-300">
|
||||
<%= t(".holdings") %>
|
||||
</div>
|
||||
<div class="text-white ml-auto">
|
||||
<%= tag.p format_money(holdings, precision: 0) %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="my-2 border-gray-500">
|
||||
|
||||
<div class="flex">
|
||||
<div class="text-gray-300">
|
||||
<%= t(".total") %>
|
||||
</div>
|
||||
<div class="text-white font-bold ml-auto">
|
||||
<%= tag.p format_money(balance, precision: 0) %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -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",
|
||||
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 %>
|
||||
|
||||
<div class="min-h-[800px]">
|
||||
<%= 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) },
|
||||
] %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
|
|
@ -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") },
|
||||
|
|
|
@ -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
|
||||
%>
|
||||
|
||||
<svg width="<%= radius * 2 + stroke %>" height="<%= radius * 2 + stroke %>">
|
||||
<!-- Background Circle -->
|
||||
<circle class="fill-transparent stroke-current text-gray-300" r="<%= radius %>" cx="<%= radius + stroke / 2 %>" cy="<%= radius + stroke / 2 %>" stroke-width="<%= stroke %>" />
|
||||
<circle
|
||||
class="fill-transparent stroke-current text-gray-300"
|
||||
r="<%= radius %>"
|
||||
cx="<%= center %>"
|
||||
cy="<%= center %>"
|
||||
stroke-width="<%= stroke %>"
|
||||
></circle>
|
||||
|
||||
<!-- Foreground Circle -->
|
||||
<circle class="fill-transparent stroke-current <%= text_class %>" r="<%= radius %>" cx="<%= radius + stroke / 2 %>" cy="<%= radius + stroke / 2 %>" stroke-width="<%= stroke %>" stroke-dasharray="<%= circumference %>" stroke-dashoffset="<%= stroke_dashoffset %>" transform="rotate(-90, <%= radius + stroke / 2 %>, <%= radius + stroke / 2 %>)" />
|
||||
<circle
|
||||
class="fill-transparent stroke-current <%= text_class %>"
|
||||
r="<%= radius %>"
|
||||
cx="<%= center %>"
|
||||
cy="<%= center %>"
|
||||
stroke-width="<%= stroke %>"
|
||||
stroke-dasharray="<%= circumference %>"
|
||||
stroke-dashoffset="<%= stroke_dashoffset %>"
|
||||
transform="rotate(-90, <%= center %>, <%= center %>)"
|
||||
></circle>
|
||||
</svg>
|
||||
|
|
|
@ -13,7 +13,7 @@ et:
|
|||
- E
|
||||
- T
|
||||
- K
|
||||
- N
|
||||
- "N"
|
||||
- R
|
||||
- L
|
||||
abbr_month_names:
|
||||
|
|
|
@ -1,8 +0,0 @@
|
|||
---
|
||||
en:
|
||||
account:
|
||||
cashes:
|
||||
index:
|
||||
cash: Cash
|
||||
name: Name
|
||||
value: Total Balance
|
|
@ -2,6 +2,8 @@
|
|||
en:
|
||||
account:
|
||||
holdings:
|
||||
cash:
|
||||
brokerage_cash: Brokerage cash
|
||||
destroy:
|
||||
success: Holding deleted
|
||||
holding:
|
||||
|
|
|
@ -2,6 +2,8 @@
|
|||
en:
|
||||
account:
|
||||
valuations:
|
||||
valuation:
|
||||
balance_update: Balance update
|
||||
form:
|
||||
amount: Amount
|
||||
submit: Add balance update
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -1,8 +1,6 @@
|
|||
---
|
||||
en:
|
||||
shared:
|
||||
syncing_notice:
|
||||
syncing: Syncing accounts data...
|
||||
confirm_modal:
|
||||
accept: Confirm
|
||||
body_html: "<p>You will not be able to undo this decision</p>"
|
||||
|
@ -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
|
||||
|
|
|
@ -75,7 +75,6 @@ Rails.application.routes.draw do
|
|||
|
||||
namespace :account do
|
||||
resources :holdings, only: %i[index new show destroy]
|
||||
resources :cashes, only: :index
|
||||
|
||||
resources :entries, only: :index
|
||||
|
||||
|
|
6
db/migrate/20241204235400_add_balance_components.rb
Normal file
6
db/migrate/20241204235400_add_balance_components.rb
Normal file
|
@ -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
|
5
db/migrate/20241207002408_add_family_timezone.rb
Normal file
5
db/migrate/20241207002408_add_family_timezone.rb
Normal file
|
@ -0,0 +1,5 @@
|
|||
class AddFamilyTimezone < ActiveRecord::Migration[7.2]
|
||||
def change
|
||||
add_column :families, :timezone, :string
|
||||
end
|
||||
end
|
5
db/schema.rb
generated
5
db/schema.rb
generated
|
@ -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 @@ ActiveRecord::Schema[7.2].define(version: 2024_11_26_211249) do
|
|||
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 @@ ActiveRecord::Schema[7.2].define(version: 2024_11_26_211249) do
|
|||
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 @@ ActiveRecord::Schema[7.2].define(version: 2024_11_26_211249) do
|
|||
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|
|
||||
|
|
1
test/fixtures/accounts.yml
vendored
1
test/fixtures/accounts.yml
vendored
|
@ -43,6 +43,7 @@ investment:
|
|||
family: dylan_family
|
||||
name: Robinhood Brokerage Account
|
||||
balance: 10000
|
||||
cash_balance: 5000
|
||||
currency: USD
|
||||
accountable_type: Investment
|
||||
accountable: one
|
||||
|
|
|
@ -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
|
156
test/models/account/balance_calculator_test.rb
Normal file
156
test/models/account/balance_calculator_test.rb
Normal file
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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
|
231
test/models/account/holding_calculator_test.rb
Normal file
231
test/models/account/holding_calculator_test.rb
Normal file
|
@ -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
|
|
@ -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
|
||||
|
|
54
test/models/account/syncer_test.rb
Normal file
54
test/models/account/syncer_test.rb
Normal file
|
@ -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
|
|
@ -8,7 +8,7 @@ class TradesTest < ApplicationSystemTestCase
|
|||
|
||||
@account = accounts(:investment)
|
||||
|
||||
visit_account_trades
|
||||
visit_account_portfolio
|
||||
|
||||
Security.stubs(:search).returns([
|
||||
Security.new(
|
||||
|
@ -66,17 +66,18 @@ class TradesTest < ApplicationSystemTestCase
|
|||
private
|
||||
|
||||
def open_new_trade_modal
|
||||
within "[data-testid='activity-menu']" do
|
||||
click_on "New"
|
||||
click_on "New transaction"
|
||||
end
|
||||
end
|
||||
|
||||
def within_trades(&block)
|
||||
within "#" + dom_id(@account, "entries"), &block
|
||||
end
|
||||
|
||||
def visit_account_trades
|
||||
visit account_path(@account, tab: "activity")
|
||||
end
|
||||
|
||||
def visit_account_portfolio
|
||||
visit account_path(@account)
|
||||
end
|
||||
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue