mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-07-18 20:59:39 +02:00
Start and end balance anchors for historical account balances (#2455)
* Add kind field to valuation * Fix schema conflict * Add kind to valuation * Scaffold opening balance manager * Opening balance manager implementation * Update account import to use opening balance manager + tests * Update account to use opening balance manager * Fix test assertions, usage of current balance manager * Lint fixes * Add Opening Balance manager, add tests to forward calculator * Add credit card to "all cash" designation * Simplify valuation model * Add current balance manager with tests * Add current balance logic to reverse calculator and plaid sync * Tweaks to initial calc logic * Ledger testing helper, tweak assertions for reverse calculator * Update test assertions * Extract balance transformer, simplify calculators * Algo simplifications * Final tweaks to calculators * Cleanup * Fix error, propagate sync errors up to parent * Update migration script, valuation naming
This commit is contained in:
parent
9110ab27d2
commit
c1d98fe73b
35 changed files with 1903 additions and 355 deletions
|
@ -1,6 +1,5 @@
|
|||
class Account < ApplicationRecord
|
||||
include Syncable, Monetizable, Chartable, Linkable, Enrichable
|
||||
include AASM
|
||||
include AASM, Syncable, Monetizable, Chartable, Linkable, Enrichable, Anchorable
|
||||
|
||||
validates :name, :balance, :currency, presence: true
|
||||
|
||||
|
@ -59,26 +58,14 @@ class Account < ApplicationRecord
|
|||
def create_and_sync(attributes)
|
||||
attributes[:accountable_attributes] ||= {} # Ensure accountable is created, even if empty
|
||||
account = new(attributes.merge(cash_balance: attributes[:balance]))
|
||||
initial_balance = attributes.dig(:accountable_attributes, :initial_balance)&.to_d || 0
|
||||
initial_balance = attributes.dig(:accountable_attributes, :initial_balance)&.to_d
|
||||
|
||||
transaction do
|
||||
# Create 2 valuations for new accounts to establish a value history for users to see
|
||||
account.entries.build(
|
||||
name: Valuation.build_current_anchor_name(account.accountable_type),
|
||||
date: Date.current,
|
||||
amount: account.balance,
|
||||
currency: account.currency,
|
||||
entryable: Valuation.new
|
||||
)
|
||||
account.entries.build(
|
||||
name: Valuation.build_opening_anchor_name(account.accountable_type),
|
||||
date: 1.day.ago.to_date,
|
||||
amount: initial_balance,
|
||||
currency: account.currency,
|
||||
entryable: Valuation.new
|
||||
)
|
||||
|
||||
account.save!
|
||||
|
||||
manager = Account::OpeningBalanceManager.new(account)
|
||||
result = manager.set_opening_balance(balance: initial_balance || account.balance)
|
||||
raise result.error if result.error
|
||||
end
|
||||
|
||||
account.sync_later
|
||||
|
|
52
app/models/account/anchorable.rb
Normal file
52
app/models/account/anchorable.rb
Normal file
|
@ -0,0 +1,52 @@
|
|||
# All accounts are "anchored" with start/end valuation records, with transactions,
|
||||
# trades, and reconciliations between them.
|
||||
module Account::Anchorable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
include Monetizable
|
||||
|
||||
monetize :opening_balance
|
||||
end
|
||||
|
||||
def set_opening_anchor_balance(**opts)
|
||||
opening_balance_manager.set_opening_balance(**opts)
|
||||
end
|
||||
|
||||
def opening_anchor_date
|
||||
opening_balance_manager.opening_date
|
||||
end
|
||||
|
||||
def opening_anchor_balance
|
||||
opening_balance_manager.opening_balance
|
||||
end
|
||||
|
||||
def has_opening_anchor?
|
||||
opening_balance_manager.has_opening_anchor?
|
||||
end
|
||||
|
||||
def set_current_anchor_balance(balance)
|
||||
current_balance_manager.set_current_balance(balance)
|
||||
end
|
||||
|
||||
def current_anchor_balance
|
||||
current_balance_manager.current_balance
|
||||
end
|
||||
|
||||
def current_anchor_date
|
||||
current_balance_manager.current_date
|
||||
end
|
||||
|
||||
def has_current_anchor?
|
||||
current_balance_manager.has_current_anchor?
|
||||
end
|
||||
|
||||
private
|
||||
def opening_balance_manager
|
||||
@opening_balance_manager ||= Account::OpeningBalanceManager.new(self)
|
||||
end
|
||||
|
||||
def current_balance_manager
|
||||
@current_balance_manager ||= Account::CurrentBalanceManager.new(self)
|
||||
end
|
||||
end
|
|
@ -18,7 +18,7 @@ class Account::BalanceUpdater
|
|||
end
|
||||
|
||||
valuation_entry = account.entries.valuations.find_or_initialize_by(date: date) do |entry|
|
||||
entry.entryable = Valuation.new
|
||||
entry.entryable = Valuation.new(kind: "reconciliation")
|
||||
end
|
||||
|
||||
valuation_entry.amount = balance
|
||||
|
|
86
app/models/account/current_balance_manager.rb
Normal file
86
app/models/account/current_balance_manager.rb
Normal file
|
@ -0,0 +1,86 @@
|
|||
class Account::CurrentBalanceManager
|
||||
InvalidOperation = Class.new(StandardError)
|
||||
|
||||
Result = Struct.new(:success?, :changes_made?, :error, keyword_init: true)
|
||||
|
||||
def initialize(account)
|
||||
@account = account
|
||||
end
|
||||
|
||||
def has_current_anchor?
|
||||
current_anchor_valuation.present?
|
||||
end
|
||||
|
||||
# Our system should always make sure there is a current anchor, and that it is up to date.
|
||||
# The fallback is provided for backwards compatibility, but should not be relied on since account.balance is a "cached/derived" value.
|
||||
def current_balance
|
||||
if current_anchor_valuation
|
||||
current_anchor_valuation.entry.amount
|
||||
else
|
||||
Rails.logger.warn "No current balance anchor found for account #{account.id}. Using cached balance instead, which may be out of date."
|
||||
account.balance
|
||||
end
|
||||
end
|
||||
|
||||
def current_date
|
||||
if current_anchor_valuation
|
||||
current_anchor_valuation.entry.date
|
||||
else
|
||||
Date.current
|
||||
end
|
||||
end
|
||||
|
||||
def set_current_balance(balance)
|
||||
# A current balance anchor implies there is an external data source that will keep it updated. Since manual accounts
|
||||
# are tracked by the user, a current balance anchor is not appropriate.
|
||||
raise InvalidOperation, "Manual accounts cannot set current balance anchor. Set opening balance or use a reconciliation instead." if account.manual?
|
||||
|
||||
if current_anchor_valuation
|
||||
changes_made = update_current_anchor(balance)
|
||||
Result.new(success?: true, changes_made?: changes_made, error: nil)
|
||||
else
|
||||
create_current_anchor(balance)
|
||||
Result.new(success?: true, changes_made?: true, error: nil)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
attr_reader :account
|
||||
|
||||
def current_anchor_valuation
|
||||
@current_anchor_valuation ||= account.valuations.current_anchor.includes(:entry).first
|
||||
end
|
||||
|
||||
def create_current_anchor(balance)
|
||||
account.entries.create!(
|
||||
date: Date.current,
|
||||
name: Valuation.build_current_anchor_name(account.accountable_type),
|
||||
amount: balance,
|
||||
currency: account.currency,
|
||||
entryable: Valuation.new(kind: "current_anchor")
|
||||
)
|
||||
end
|
||||
|
||||
def update_current_anchor(balance)
|
||||
changes_made = false
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
# Update associated entry attributes
|
||||
entry = current_anchor_valuation.entry
|
||||
|
||||
if entry.amount != balance
|
||||
entry.amount = balance
|
||||
changes_made = true
|
||||
end
|
||||
|
||||
if entry.date != Date.current
|
||||
entry.date = Date.current
|
||||
changes_made = true
|
||||
end
|
||||
|
||||
entry.save! if entry.changed?
|
||||
end
|
||||
|
||||
changes_made
|
||||
end
|
||||
end
|
|
@ -15,4 +15,5 @@ module Account::Linkable
|
|||
def unlinked?
|
||||
!linked?
|
||||
end
|
||||
alias_method :manual?, :unlinked?
|
||||
end
|
||||
|
|
99
app/models/account/opening_balance_manager.rb
Normal file
99
app/models/account/opening_balance_manager.rb
Normal file
|
@ -0,0 +1,99 @@
|
|||
class Account::OpeningBalanceManager
|
||||
Result = Struct.new(:success?, :changes_made?, :error, keyword_init: true)
|
||||
|
||||
def initialize(account)
|
||||
@account = account
|
||||
end
|
||||
|
||||
def has_opening_anchor?
|
||||
opening_anchor_valuation.present?
|
||||
end
|
||||
|
||||
# Most accounts should have an opening anchor. If not, we derive the opening date from the oldest entry date
|
||||
def opening_date
|
||||
return opening_anchor_valuation.entry.date if opening_anchor_valuation.present?
|
||||
|
||||
[
|
||||
account.entries.valuations.order(:date).first&.date,
|
||||
account.entries.where.not(entryable_type: "Valuation").order(:date).first&.date&.prev_day
|
||||
].compact.min || Date.current
|
||||
end
|
||||
|
||||
def opening_balance
|
||||
opening_anchor_valuation&.entry&.amount || 0
|
||||
end
|
||||
|
||||
def set_opening_balance(balance:, date: nil)
|
||||
resolved_date = date || default_date
|
||||
|
||||
# Validate date is before oldest entry
|
||||
if date && oldest_entry_date && resolved_date >= oldest_entry_date
|
||||
return Result.new(success?: false, changes_made?: false, error: "Opening balance date must be before the oldest entry date")
|
||||
end
|
||||
|
||||
if opening_anchor_valuation.nil?
|
||||
create_opening_anchor(
|
||||
balance: balance,
|
||||
date: resolved_date
|
||||
)
|
||||
Result.new(success?: true, changes_made?: true, error: nil)
|
||||
else
|
||||
changes_made = update_opening_anchor(balance: balance, date: date)
|
||||
Result.new(success?: true, changes_made?: changes_made, error: nil)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
attr_reader :account
|
||||
|
||||
def opening_anchor_valuation
|
||||
@opening_anchor_valuation ||= account.valuations.opening_anchor.includes(:entry).first
|
||||
end
|
||||
|
||||
def oldest_entry_date
|
||||
@oldest_entry_date ||= account.entries.minimum(:date)
|
||||
end
|
||||
|
||||
def default_date
|
||||
if oldest_entry_date
|
||||
[ oldest_entry_date - 1.day, 2.years.ago.to_date ].min
|
||||
else
|
||||
2.years.ago.to_date
|
||||
end
|
||||
end
|
||||
|
||||
def create_opening_anchor(balance:, date:)
|
||||
account.entries.create!(
|
||||
date: date,
|
||||
name: Valuation.build_opening_anchor_name(account.accountable_type),
|
||||
amount: balance,
|
||||
currency: account.currency,
|
||||
entryable: Valuation.new(
|
||||
kind: "opening_anchor"
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
def update_opening_anchor(balance:, date: nil)
|
||||
changes_made = false
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
# Update associated entry attributes
|
||||
entry = opening_anchor_valuation.entry
|
||||
|
||||
if entry.amount != balance
|
||||
entry.amount = balance
|
||||
changes_made = true
|
||||
end
|
||||
|
||||
if date.present? && entry.date != date
|
||||
entry.date = date
|
||||
changes_made = true
|
||||
end
|
||||
|
||||
entry.save! if entry.changed?
|
||||
end
|
||||
|
||||
changes_made
|
||||
end
|
||||
end
|
|
@ -1,4 +1,6 @@
|
|||
class AccountImport < Import
|
||||
OpeningBalanceError = Class.new(StandardError)
|
||||
|
||||
def import!
|
||||
transaction do
|
||||
rows.each do |row|
|
||||
|
@ -15,13 +17,13 @@ class AccountImport < Import
|
|||
|
||||
account.save!
|
||||
|
||||
account.entries.create!(
|
||||
amount: row.amount,
|
||||
currency: row.currency,
|
||||
date: 2.years.ago.to_date,
|
||||
name: Valuation.build_opening_anchor_name(account.accountable_type),
|
||||
entryable: Valuation.new
|
||||
)
|
||||
manager = Account::OpeningBalanceManager.new(account)
|
||||
result = manager.set_opening_balance(balance: row.amount.to_d)
|
||||
|
||||
# Re-raise since we should never have an error here
|
||||
if result.error
|
||||
raise OpeningBalanceError, result.error
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
82
app/models/balance/base_calculator.rb
Normal file
82
app/models/balance/base_calculator.rb
Normal file
|
@ -0,0 +1,82 @@
|
|||
class Balance::BaseCalculator
|
||||
attr_reader :account
|
||||
|
||||
def initialize(account)
|
||||
@account = account
|
||||
end
|
||||
|
||||
def calculate
|
||||
raise NotImplementedError, "Subclasses must implement this method"
|
||||
end
|
||||
|
||||
private
|
||||
def sync_cache
|
||||
@sync_cache ||= Balance::SyncCache.new(account)
|
||||
end
|
||||
|
||||
def holdings_value_for_date(date)
|
||||
holdings = sync_cache.get_holdings(date)
|
||||
holdings.sum(&:amount)
|
||||
end
|
||||
|
||||
def derive_cash_balance_on_date_from_total(total_balance:, date:)
|
||||
if balance_type == :investment
|
||||
total_balance - holdings_value_for_date(date)
|
||||
elsif balance_type == :cash
|
||||
total_balance
|
||||
else
|
||||
0
|
||||
end
|
||||
end
|
||||
|
||||
def derive_cash_balance(cash_balance, date)
|
||||
entries = sync_cache.get_entries(date)
|
||||
|
||||
if balance_type == :non_cash
|
||||
0
|
||||
else
|
||||
cash_balance + signed_entry_flows(entries)
|
||||
end
|
||||
end
|
||||
|
||||
def derive_non_cash_balance(non_cash_balance, date, direction: :forward)
|
||||
entries = sync_cache.get_entries(date)
|
||||
# Loans are a special case (loan payment reducing principal, which is non-cash)
|
||||
if balance_type == :non_cash && account.accountable_type == "Loan"
|
||||
non_cash_balance + signed_entry_flows(entries)
|
||||
elsif balance_type == :investment
|
||||
# For reverse calculations, we need the previous day's holdings
|
||||
target_date = direction == :forward ? date : date.prev_day
|
||||
holdings_value_for_date(target_date)
|
||||
else
|
||||
non_cash_balance
|
||||
end
|
||||
end
|
||||
|
||||
def signed_entry_flows(entries)
|
||||
raise NotImplementedError, "Directional calculators must implement this method"
|
||||
end
|
||||
|
||||
def balance_type
|
||||
case account.accountable_type
|
||||
when "Depository", "CreditCard"
|
||||
:cash
|
||||
when "Property", "Vehicle", "OtherAsset", "Loan", "OtherLiability"
|
||||
:non_cash
|
||||
when "Investment", "Crypto"
|
||||
:investment
|
||||
else
|
||||
raise "Unknown account type: #{account.accountable_type}"
|
||||
end
|
||||
end
|
||||
|
||||
def build_balance(date:, cash_balance:, non_cash_balance:)
|
||||
Balance.new(
|
||||
account_id: account.id,
|
||||
date: date,
|
||||
balance: non_cash_balance + cash_balance,
|
||||
cash_balance: cash_balance,
|
||||
currency: account.currency
|
||||
)
|
||||
end
|
||||
end
|
|
@ -1,61 +1,66 @@
|
|||
class Balance::ForwardCalculator
|
||||
attr_reader :account
|
||||
|
||||
def initialize(account)
|
||||
@account = account
|
||||
end
|
||||
|
||||
class Balance::ForwardCalculator < Balance::BaseCalculator
|
||||
def calculate
|
||||
Rails.logger.tagged("Balance::ForwardCalculator") do
|
||||
calculate_balances
|
||||
start_cash_balance = derive_cash_balance_on_date_from_total(
|
||||
total_balance: account.opening_anchor_balance,
|
||||
date: account.opening_anchor_date
|
||||
)
|
||||
start_non_cash_balance = account.opening_anchor_balance - start_cash_balance
|
||||
|
||||
calc_start_date.upto(calc_end_date).map do |date|
|
||||
valuation = sync_cache.get_reconciliation_valuation(date)
|
||||
|
||||
if valuation
|
||||
end_cash_balance = derive_cash_balance_on_date_from_total(
|
||||
total_balance: valuation.amount,
|
||||
date: date
|
||||
)
|
||||
end_non_cash_balance = valuation.amount - end_cash_balance
|
||||
else
|
||||
end_cash_balance = derive_end_cash_balance(start_cash_balance: start_cash_balance, date: date)
|
||||
end_non_cash_balance = derive_end_non_cash_balance(start_non_cash_balance: start_non_cash_balance, date: date)
|
||||
end
|
||||
|
||||
output_balance = build_balance(
|
||||
date: date,
|
||||
cash_balance: end_cash_balance,
|
||||
non_cash_balance: end_non_cash_balance
|
||||
)
|
||||
|
||||
# Set values for the next iteration
|
||||
start_cash_balance = end_cash_balance
|
||||
start_non_cash_balance = end_non_cash_balance
|
||||
|
||||
output_balance
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def calculate_balances
|
||||
current_cash_balance = 0
|
||||
next_cash_balance = nil
|
||||
|
||||
@balances = []
|
||||
|
||||
account.start_date.upto(Date.current).each do |date|
|
||||
entries = sync_cache.get_entries(date)
|
||||
holdings = sync_cache.get_holdings(date)
|
||||
holdings_value = holdings.sum(&:amount)
|
||||
valuation = sync_cache.get_valuation(date)
|
||||
|
||||
next_cash_balance = if valuation
|
||||
valuation.amount - holdings_value
|
||||
else
|
||||
calculate_next_balance(current_cash_balance, entries, direction: :forward)
|
||||
end
|
||||
|
||||
@balances << build_balance(date, next_cash_balance, holdings_value)
|
||||
|
||||
current_cash_balance = next_cash_balance
|
||||
end
|
||||
|
||||
@balances
|
||||
def calc_start_date
|
||||
account.opening_anchor_date
|
||||
end
|
||||
|
||||
def sync_cache
|
||||
@sync_cache ||= Balance::SyncCache.new(account)
|
||||
def calc_end_date
|
||||
[ account.entries.order(:date).last&.date, account.holdings.order(:date).last&.date ].compact.max || Date.current
|
||||
end
|
||||
|
||||
def build_balance(date, cash_balance, holdings_value)
|
||||
Balance.new(
|
||||
account_id: account.id,
|
||||
date: date,
|
||||
balance: holdings_value + cash_balance,
|
||||
cash_balance: cash_balance,
|
||||
currency: account.currency
|
||||
)
|
||||
# Negative entries amount on an "asset" account means, "account value has increased"
|
||||
# Negative entries amount on a "liability" account means, "account debt has decreased"
|
||||
# Positive entries amount on an "asset" account means, "account value has decreased"
|
||||
# Positive entries amount on a "liability" account means, "account debt has increased"
|
||||
def signed_entry_flows(entries)
|
||||
entry_flows = entries.sum(&:amount)
|
||||
account.asset? ? -entry_flows : entry_flows
|
||||
end
|
||||
|
||||
def calculate_next_balance(prior_balance, transactions, direction: :forward)
|
||||
flows = transactions.sum(&:amount)
|
||||
negated = direction == :forward ? account.asset? : account.liability?
|
||||
flows *= -1 if negated
|
||||
prior_balance + flows
|
||||
# Derives cash balance, starting from the start-of-day, applying entries in forward to get the end-of-day balance
|
||||
def derive_end_cash_balance(start_cash_balance:, date:)
|
||||
derive_cash_balance(start_cash_balance, date)
|
||||
end
|
||||
|
||||
# Derives non-cash balance, starting from the start-of-day, applying entries in forward to get the end-of-day balance
|
||||
def derive_end_non_cash_balance(start_non_cash_balance:, date:)
|
||||
derive_non_cash_balance(start_non_cash_balance, date, direction: :forward)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,71 +1,79 @@
|
|||
class Balance::ReverseCalculator
|
||||
attr_reader :account
|
||||
|
||||
def initialize(account)
|
||||
@account = account
|
||||
end
|
||||
|
||||
class Balance::ReverseCalculator < Balance::BaseCalculator
|
||||
def calculate
|
||||
Rails.logger.tagged("Balance::ReverseCalculator") do
|
||||
calculate_balances
|
||||
# Since it's a reverse sync, we're starting with the "end of day" balance components and
|
||||
# calculating backwards to derive the "start of day" balance components.
|
||||
end_cash_balance = derive_cash_balance_on_date_from_total(
|
||||
total_balance: account.current_anchor_balance,
|
||||
date: account.current_anchor_date
|
||||
)
|
||||
end_non_cash_balance = account.current_anchor_balance - end_cash_balance
|
||||
|
||||
# Calculates in reverse-chronological order (End of day -> Start of day)
|
||||
account.current_anchor_date.downto(account.opening_anchor_date).map do |date|
|
||||
if use_opening_anchor_for_date?(date)
|
||||
end_cash_balance = derive_cash_balance_on_date_from_total(
|
||||
total_balance: account.opening_anchor_balance,
|
||||
date: date
|
||||
)
|
||||
end_non_cash_balance = account.opening_anchor_balance - end_cash_balance
|
||||
|
||||
start_cash_balance = end_cash_balance
|
||||
start_non_cash_balance = end_non_cash_balance
|
||||
|
||||
build_balance(
|
||||
date: date,
|
||||
cash_balance: end_cash_balance,
|
||||
non_cash_balance: end_non_cash_balance
|
||||
)
|
||||
else
|
||||
start_cash_balance = derive_start_cash_balance(end_cash_balance: end_cash_balance, date: date)
|
||||
start_non_cash_balance = derive_start_non_cash_balance(end_non_cash_balance: end_non_cash_balance, date: date)
|
||||
|
||||
# Even though we've just calculated "start" balances, we set today equal to end of day, then use those
|
||||
# in our next iteration (slightly confusing, but just the nature of a "reverse" sync)
|
||||
output_balance = build_balance(
|
||||
date: date,
|
||||
cash_balance: end_cash_balance,
|
||||
non_cash_balance: end_non_cash_balance
|
||||
)
|
||||
|
||||
end_cash_balance = start_cash_balance
|
||||
end_non_cash_balance = start_non_cash_balance
|
||||
|
||||
output_balance
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def calculate_balances
|
||||
current_cash_balance = account.cash_balance
|
||||
previous_cash_balance = nil
|
||||
|
||||
@balances = []
|
||||
|
||||
Date.current.downto(account.start_date).map do |date|
|
||||
entries = sync_cache.get_entries(date)
|
||||
holdings = sync_cache.get_holdings(date)
|
||||
holdings_value = holdings.sum(&:amount)
|
||||
valuation = sync_cache.get_valuation(date)
|
||||
|
||||
previous_cash_balance = if valuation
|
||||
valuation.amount - holdings_value
|
||||
else
|
||||
calculate_next_balance(current_cash_balance, entries, direction: :reverse)
|
||||
end
|
||||
|
||||
if valuation.present?
|
||||
@balances << build_balance(date, previous_cash_balance, holdings_value)
|
||||
else
|
||||
# If date is today, we don't distinguish cash vs. total since provider's are inconsistent with treatment
|
||||
# of the cash component. Instead, just set the balance equal to the "total value" reported by the provider
|
||||
if date == Date.current
|
||||
@balances << build_balance(date, account.cash_balance, account.balance - account.cash_balance)
|
||||
else
|
||||
@balances << build_balance(date, current_cash_balance, holdings_value)
|
||||
end
|
||||
end
|
||||
|
||||
current_cash_balance = previous_cash_balance
|
||||
end
|
||||
|
||||
@balances
|
||||
# Negative entries amount on an "asset" account means, "account value has increased"
|
||||
# Negative entries amount on a "liability" account means, "account debt has decreased"
|
||||
# Positive entries amount on an "asset" account means, "account value has decreased"
|
||||
# Positive entries amount on a "liability" account means, "account debt has increased"
|
||||
def signed_entry_flows(entries)
|
||||
entry_flows = entries.sum(&:amount)
|
||||
account.asset? ? entry_flows : -entry_flows
|
||||
end
|
||||
|
||||
def sync_cache
|
||||
@sync_cache ||= Balance::SyncCache.new(account)
|
||||
# Reverse syncs are a bit different than forward syncs because we do not allow "reconciliation" valuations
|
||||
# to be used at all. This is primarily to keep the code and the UI easy to understand. For a more detailed
|
||||
# explanation, see the test suite.
|
||||
def use_opening_anchor_for_date?(date)
|
||||
account.has_opening_anchor? && date == account.opening_anchor_date
|
||||
end
|
||||
|
||||
def build_balance(date, cash_balance, holdings_value)
|
||||
Balance.new(
|
||||
account_id: account.id,
|
||||
date: date,
|
||||
balance: holdings_value + cash_balance,
|
||||
cash_balance: cash_balance,
|
||||
currency: account.currency
|
||||
)
|
||||
# Alias method, for algorithmic clarity
|
||||
# Derives cash balance, starting from the end-of-day, applying entries in reverse to get the start-of-day balance
|
||||
def derive_start_cash_balance(end_cash_balance:, date:)
|
||||
derive_cash_balance(end_cash_balance, date)
|
||||
end
|
||||
|
||||
def calculate_next_balance(prior_balance, transactions, direction: :forward)
|
||||
flows = transactions.sum(&:amount)
|
||||
negated = direction == :forward ? account.asset? : account.liability?
|
||||
flows *= -1 if negated
|
||||
prior_balance + flows
|
||||
# Alias method, for algorithmic clarity
|
||||
# Derives non-cash balance, starting from the end-of-day, applying entries in reverse to get the start-of-day balance
|
||||
def derive_start_non_cash_balance(end_non_cash_balance:, date:)
|
||||
derive_non_cash_balance(end_non_cash_balance, date, direction: :reverse)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -3,8 +3,8 @@ class Balance::SyncCache
|
|||
@account = account
|
||||
end
|
||||
|
||||
def get_valuation(date)
|
||||
converted_entries.find { |e| e.date == date && e.valuation? }
|
||||
def get_reconciliation_valuation(date)
|
||||
converted_entries.find { |e| e.date == date && e.valuation? && e.valuation.reconciliation? }
|
||||
end
|
||||
|
||||
def get_holdings(date)
|
||||
|
|
|
@ -18,7 +18,7 @@ class Balance::TrendCalculator
|
|||
BalanceTrend.new(
|
||||
trend: Trend.new(
|
||||
current: Money.new(balance.balance, balance.currency),
|
||||
previous: Money.new(prior_balance.balance, balance.currency),
|
||||
previous: prior_balance.present? ? Money.new(prior_balance.balance, balance.currency) : nil,
|
||||
favorable_direction: balance.account.favorable_direction
|
||||
),
|
||||
cash: Money.new(balance.cash_balance, balance.currency),
|
||||
|
|
|
@ -47,7 +47,7 @@ module Syncable
|
|||
end
|
||||
|
||||
def sync_error
|
||||
latest_sync&.error
|
||||
latest_sync&.error || latest_sync&.children&.map(&:error)&.compact&.first
|
||||
end
|
||||
|
||||
def last_synced_at
|
||||
|
|
|
@ -1174,7 +1174,7 @@ class Demo::Generator
|
|||
|
||||
# Property valuations (these accounts are valued, not transaction-driven)
|
||||
@home.entries.create!(
|
||||
entryable: Valuation.new,
|
||||
entryable: Valuation.new(kind: "current_anchor"),
|
||||
amount: 350_000,
|
||||
name: Valuation.build_current_anchor_name(@home.accountable_type),
|
||||
currency: "USD",
|
||||
|
@ -1183,7 +1183,7 @@ class Demo::Generator
|
|||
|
||||
# Vehicle valuations (these depreciate over time)
|
||||
@honda_accord.entries.create!(
|
||||
entryable: Valuation.new,
|
||||
entryable: Valuation.new(kind: "current_anchor"),
|
||||
amount: 18_000,
|
||||
name: Valuation.build_current_anchor_name(@honda_accord.accountable_type),
|
||||
currency: "USD",
|
||||
|
@ -1191,7 +1191,7 @@ class Demo::Generator
|
|||
)
|
||||
|
||||
@tesla_model3.entries.create!(
|
||||
entryable: Valuation.new,
|
||||
entryable: Valuation.new(kind: "current_anchor"),
|
||||
amount: 4_500,
|
||||
name: Valuation.build_current_anchor_name(@tesla_model3.accountable_type),
|
||||
currency: "USD",
|
||||
|
@ -1199,7 +1199,7 @@ class Demo::Generator
|
|||
)
|
||||
|
||||
@jewelry.entries.create!(
|
||||
entryable: Valuation.new,
|
||||
entryable: Valuation.new(kind: "reconciliation"),
|
||||
amount: 2000,
|
||||
name: Valuation.build_reconciliation_name(@jewelry.accountable_type),
|
||||
currency: "USD",
|
||||
|
@ -1207,7 +1207,7 @@ class Demo::Generator
|
|||
)
|
||||
|
||||
@personal_loc.entries.create!(
|
||||
entryable: Valuation.new,
|
||||
entryable: Valuation.new(kind: "reconciliation"),
|
||||
amount: 800,
|
||||
name: Valuation.build_reconciliation_name(@personal_loc.accountable_type),
|
||||
currency: "USD",
|
||||
|
|
|
@ -51,6 +51,13 @@ class PlaidAccount::Processor
|
|||
)
|
||||
|
||||
account.save!
|
||||
|
||||
# Create or update the current balance anchor valuation for event-sourced ledger
|
||||
# Note: This is a partial implementation. In the future, we'll introduce HoldingValuation
|
||||
# to properly track the holdings vs. cash breakdown, but for now we're only tracking
|
||||
# the total balance in the current anchor. The cash_balance field on the account model
|
||||
# is still being used for the breakdown.
|
||||
account.set_current_anchor_balance(balance_calculator.balance)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -1,6 +1,12 @@
|
|||
class Valuation < ApplicationRecord
|
||||
include Entryable
|
||||
|
||||
enum :kind, {
|
||||
reconciliation: "reconciliation",
|
||||
opening_anchor: "opening_anchor",
|
||||
current_anchor: "current_anchor"
|
||||
}, validate: true, default: "reconciliation"
|
||||
|
||||
class << self
|
||||
def build_reconciliation_name(accountable_type)
|
||||
Valuation::Name.new("reconciliation", accountable_type).to_s
|
||||
|
@ -14,10 +20,4 @@ class Valuation < ApplicationRecord
|
|||
Valuation::Name.new("current_anchor", accountable_type).to_s
|
||||
end
|
||||
end
|
||||
|
||||
# TODO: Remove this method when `kind` column is added to valuations table
|
||||
# This is a temporary implementation until the database migration is complete
|
||||
def kind
|
||||
"reconciliation"
|
||||
end
|
||||
end
|
||||
|
|
|
@ -20,11 +20,11 @@ class Valuation::Name
|
|||
|
||||
def opening_anchor_name
|
||||
case accountable_type
|
||||
when "Property"
|
||||
when "Property", "Vehicle"
|
||||
"Original purchase price"
|
||||
when "Loan"
|
||||
"Original principal"
|
||||
when "Investment"
|
||||
when "Investment", "Crypto", "OtherAsset"
|
||||
"Opening account value"
|
||||
else
|
||||
"Opening balance"
|
||||
|
@ -33,11 +33,11 @@ class Valuation::Name
|
|||
|
||||
def current_anchor_name
|
||||
case accountable_type
|
||||
when "Property"
|
||||
when "Property", "Vehicle"
|
||||
"Current market value"
|
||||
when "Loan"
|
||||
"Current loan balance"
|
||||
when "Investment"
|
||||
when "Investment", "Crypto", "OtherAsset"
|
||||
"Current account value"
|
||||
else
|
||||
"Current balance"
|
||||
|
@ -46,7 +46,7 @@ class Valuation::Name
|
|||
|
||||
def recon_name
|
||||
case accountable_type
|
||||
when "Property", "Investment"
|
||||
when "Property", "Investment", "Vehicle", "Crypto", "OtherAsset"
|
||||
"Manual value update"
|
||||
when "Loan"
|
||||
"Manual principal update"
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue