mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-07-24 15:49:39 +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,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">
|
||||
<%= tag.p "--", class: "font-medium text-sm text-gray-400" %>
|
||||
<% 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",
|
||||
<%= 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 %>)" />
|
||||
</svg>
|
||||
<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>
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue