mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-07-19 21:29:38 +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)
|
||||
def calc_start_date
|
||||
account.opening_anchor_date
|
||||
end
|
||||
|
||||
@balances << build_balance(date, next_cash_balance, holdings_value)
|
||||
|
||||
current_cash_balance = next_cash_balance
|
||||
def calc_end_date
|
||||
[ account.entries.order(:date).last&.date, account.holdings.order(:date).last&.date ].compact.max || Date.current
|
||||
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)
|
||||
# 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
|
||||
|
||||
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
|
||||
)
|
||||
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 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)
|
||||
# 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
|
||||
|
||||
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
|
||||
# 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
|
||||
|
||||
current_cash_balance = previous_cash_balance
|
||||
# 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
|
||||
|
||||
@balances
|
||||
end
|
||||
|
||||
def sync_cache
|
||||
@sync_cache ||= Balance::SyncCache.new(account)
|
||||
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
|
||||
)
|
||||
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"
|
||||
|
|
5
db/migrate/20250710225721_add_valuation_kind.rb
Normal file
5
db/migrate/20250710225721_add_valuation_kind.rb
Normal file
|
@ -0,0 +1,5 @@
|
|||
class AddValuationKind < ActiveRecord::Migration[7.2]
|
||||
def change
|
||||
add_column :valuations, :kind, :string, default: "reconciliation", null: false
|
||||
end
|
||||
end
|
3
db/schema.rb
generated
3
db/schema.rb
generated
|
@ -10,7 +10,7 @@
|
|||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.2].define(version: 2025_07_02_173231) do
|
||||
ActiveRecord::Schema[7.2].define(version: 2025_07_10_225721) do
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "pgcrypto"
|
||||
enable_extension "plpgsql"
|
||||
|
@ -779,6 +779,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_07_02_173231) do
|
|||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.jsonb "locked_attributes", default: {}
|
||||
t.string "kind", default: "reconciliation", null: false
|
||||
end
|
||||
|
||||
create_table "vehicles", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
|
|
|
@ -111,4 +111,47 @@ namespace :data_migration do
|
|||
|
||||
puts "✅ Duplicate security migration complete."
|
||||
end
|
||||
|
||||
desc "Migrate account valuation anchors"
|
||||
# 2025-07-10: Set opening_anchor kinds for valuations to support event-sourced ledger model.
|
||||
# Manual accounts get their oldest valuation marked as opening_anchor, which acts as the
|
||||
# starting balance for the account. Current anchors are only used for Plaid accounts.
|
||||
task migrate_account_valuation_anchors: :environment do
|
||||
puts "==> Migrating account valuation anchors..."
|
||||
|
||||
manual_accounts = Account.manual.includes(valuations: :entry)
|
||||
total_accounts = manual_accounts.count
|
||||
accounts_processed = 0
|
||||
opening_anchors_set = 0
|
||||
|
||||
manual_accounts.find_each do |account|
|
||||
accounts_processed += 1
|
||||
|
||||
# Find oldest account entry
|
||||
oldest_entry = account.entries
|
||||
.order("date ASC, created_at ASC")
|
||||
.first
|
||||
|
||||
# Check if it's a valuation that isn't already an anchor
|
||||
if oldest_entry && oldest_entry.valuation?
|
||||
derived_valuation_name = Valuation.build_opening_anchor_name(account.accountable_type)
|
||||
|
||||
Account.transaction do
|
||||
oldest_entry.valuation.update!(kind: "opening_anchor")
|
||||
oldest_entry.update!(name: derived_valuation_name)
|
||||
end
|
||||
opening_anchors_set += 1
|
||||
end
|
||||
|
||||
if accounts_processed % 100 == 0
|
||||
puts "[#{accounts_processed}/#{total_accounts}] Processed #{accounts_processed} accounts..."
|
||||
end
|
||||
rescue => e
|
||||
puts "ERROR processing account #{account.id}: #{e.message}"
|
||||
end
|
||||
|
||||
puts "✅ Account valuation anchor migration complete."
|
||||
puts " Processed: #{accounts_processed} accounts"
|
||||
puts " Opening anchors set: #{opening_anchors_set}"
|
||||
end
|
||||
end
|
||||
|
|
|
@ -11,8 +11,8 @@ class CreditCardsControllerTest < ActionDispatch::IntegrationTest
|
|||
test "creates with credit card details" do
|
||||
assert_difference -> { Account.count } => 1,
|
||||
-> { CreditCard.count } => 1,
|
||||
-> { Valuation.count } => 2,
|
||||
-> { Entry.count } => 2 do
|
||||
-> { Valuation.count } => 1,
|
||||
-> { Entry.count } => 1 do
|
||||
post credit_cards_path, params: {
|
||||
account: {
|
||||
name: "New Credit Card",
|
||||
|
|
|
@ -11,8 +11,8 @@ class LoansControllerTest < ActionDispatch::IntegrationTest
|
|||
test "creates with loan details" do
|
||||
assert_difference -> { Account.count } => 1,
|
||||
-> { Loan.count } => 1,
|
||||
-> { Valuation.count } => 2,
|
||||
-> { Entry.count } => 2 do
|
||||
-> { Valuation.count } => 1,
|
||||
-> { Entry.count } => 1 do
|
||||
post loans_path, params: {
|
||||
account: {
|
||||
name: "New Loan",
|
||||
|
|
|
@ -11,8 +11,8 @@ class VehiclesControllerTest < ActionDispatch::IntegrationTest
|
|||
test "creates with vehicle details" do
|
||||
assert_difference -> { Account.count } => 1,
|
||||
-> { Vehicle.count } => 1,
|
||||
-> { Valuation.count } => 2,
|
||||
-> { Entry.count } => 2 do
|
||||
-> { Valuation.count } => 1,
|
||||
-> { Entry.count } => 1 do
|
||||
post vehicles_path, params: {
|
||||
account: {
|
||||
name: "Vehicle",
|
||||
|
|
5
test/fixtures/imports.yml
vendored
5
test/fixtures/imports.yml
vendored
|
@ -7,3 +7,8 @@ trade:
|
|||
family: dylan_family
|
||||
type: TradeImport
|
||||
status: pending
|
||||
|
||||
account:
|
||||
family: dylan_family
|
||||
type: AccountImport
|
||||
status: pending
|
||||
|
|
4
test/fixtures/valuations.yml
vendored
4
test/fixtures/valuations.yml
vendored
|
@ -1,2 +1,2 @@
|
|||
one: { }
|
||||
two: { }
|
||||
one:
|
||||
kind: reconciliation
|
||||
|
|
153
test/models/account/current_balance_manager_test.rb
Normal file
153
test/models/account/current_balance_manager_test.rb
Normal file
|
@ -0,0 +1,153 @@
|
|||
require "test_helper"
|
||||
|
||||
class Account::CurrentBalanceManagerTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@connected_account = accounts(:connected) # Connected account - can update current balance
|
||||
@manual_account = accounts(:depository) # Manual account - cannot update current balance
|
||||
end
|
||||
|
||||
test "when no existing anchor, creates new anchor" do
|
||||
manager = Account::CurrentBalanceManager.new(@connected_account)
|
||||
|
||||
assert_difference -> { @connected_account.entries.count } => 1,
|
||||
-> { @connected_account.valuations.count } => 1 do
|
||||
result = manager.set_current_balance(1000)
|
||||
|
||||
assert result.success?
|
||||
assert result.changes_made?
|
||||
assert_nil result.error
|
||||
end
|
||||
|
||||
current_anchor = @connected_account.valuations.current_anchor.first
|
||||
assert_not_nil current_anchor
|
||||
assert_equal 1000, current_anchor.entry.amount
|
||||
assert_equal "current_anchor", current_anchor.kind
|
||||
|
||||
entry = current_anchor.entry
|
||||
assert_equal 1000, entry.amount
|
||||
assert_equal Date.current, entry.date
|
||||
assert_equal "Current balance", entry.name # Depository type returns "Current balance"
|
||||
end
|
||||
|
||||
test "updates existing anchor" do
|
||||
# First create a current anchor
|
||||
manager = Account::CurrentBalanceManager.new(@connected_account)
|
||||
result = manager.set_current_balance(1000)
|
||||
assert result.success?
|
||||
|
||||
current_anchor = @connected_account.valuations.current_anchor.first
|
||||
original_id = current_anchor.id
|
||||
original_entry_id = current_anchor.entry.id
|
||||
|
||||
# Travel to tomorrow to ensure date change
|
||||
travel_to Date.current + 1.day do
|
||||
# Now update it
|
||||
assert_no_difference -> { @connected_account.entries.count } do
|
||||
assert_no_difference -> { @connected_account.valuations.count } do
|
||||
result = manager.set_current_balance(2000)
|
||||
assert result.success?
|
||||
assert result.changes_made?
|
||||
end
|
||||
end
|
||||
|
||||
current_anchor.reload
|
||||
assert_equal original_id, current_anchor.id # Same valuation record
|
||||
assert_equal original_entry_id, current_anchor.entry.id # Same entry record
|
||||
assert_equal 2000, current_anchor.entry.amount
|
||||
assert_equal Date.current, current_anchor.entry.date # Should be updated to current date
|
||||
end
|
||||
end
|
||||
|
||||
test "when manual account, raises InvalidOperation error" do
|
||||
manager = Account::CurrentBalanceManager.new(@manual_account)
|
||||
|
||||
error = assert_raises(Account::CurrentBalanceManager::InvalidOperation) do
|
||||
manager.set_current_balance(1000)
|
||||
end
|
||||
|
||||
assert_equal "Manual accounts cannot set current balance anchor. Set opening balance or use a reconciliation instead.", error.message
|
||||
|
||||
# Verify no current anchor was created
|
||||
assert_nil @manual_account.valuations.current_anchor.first
|
||||
end
|
||||
|
||||
test "when no changes made, returns success with no changes made" do
|
||||
# First create a current anchor
|
||||
manager = Account::CurrentBalanceManager.new(@connected_account)
|
||||
result = manager.set_current_balance(1000)
|
||||
assert result.success?
|
||||
assert result.changes_made?
|
||||
|
||||
# Try to set the same value on the same date
|
||||
result = manager.set_current_balance(1000)
|
||||
|
||||
assert result.success?
|
||||
assert_not result.changes_made?
|
||||
assert_nil result.error
|
||||
end
|
||||
|
||||
test "updates only amount when balance changes" do
|
||||
manager = Account::CurrentBalanceManager.new(@connected_account)
|
||||
|
||||
# Create initial anchor
|
||||
result = manager.set_current_balance(1000)
|
||||
assert result.success?
|
||||
|
||||
current_anchor = @connected_account.valuations.current_anchor.first
|
||||
original_date = current_anchor.entry.date
|
||||
|
||||
# Update only the balance
|
||||
result = manager.set_current_balance(1500)
|
||||
assert result.success?
|
||||
assert result.changes_made?
|
||||
|
||||
current_anchor.reload
|
||||
assert_equal 1500, current_anchor.entry.amount
|
||||
assert_equal original_date, current_anchor.entry.date # Date should remain the same if on same day
|
||||
end
|
||||
|
||||
test "updates date when called on different day" do
|
||||
manager = Account::CurrentBalanceManager.new(@connected_account)
|
||||
|
||||
# Create initial anchor
|
||||
result = manager.set_current_balance(1000)
|
||||
assert result.success?
|
||||
|
||||
current_anchor = @connected_account.valuations.current_anchor.first
|
||||
original_amount = current_anchor.entry.amount
|
||||
|
||||
# Travel to tomorrow and update with same balance
|
||||
travel_to Date.current + 1.day do
|
||||
result = manager.set_current_balance(1000)
|
||||
assert result.success?
|
||||
assert result.changes_made? # Should be true because date changed
|
||||
|
||||
current_anchor.reload
|
||||
assert_equal original_amount, current_anchor.entry.amount
|
||||
assert_equal Date.current, current_anchor.entry.date # Should be updated to new current date
|
||||
end
|
||||
end
|
||||
|
||||
test "current_balance returns balance from current anchor" do
|
||||
manager = Account::CurrentBalanceManager.new(@connected_account)
|
||||
|
||||
# Create a current anchor
|
||||
manager.set_current_balance(1500)
|
||||
|
||||
# Should return the anchor's balance
|
||||
assert_equal 1500, manager.current_balance
|
||||
|
||||
# Update the anchor
|
||||
manager.set_current_balance(2500)
|
||||
|
||||
# Should return the updated balance
|
||||
assert_equal 2500, manager.current_balance
|
||||
end
|
||||
|
||||
test "current_balance falls back to account balance when no anchor exists" do
|
||||
manager = Account::CurrentBalanceManager.new(@connected_account)
|
||||
|
||||
# When no current anchor exists, should fall back to account.balance
|
||||
assert_equal @connected_account.balance, manager.current_balance
|
||||
end
|
||||
end
|
|
@ -17,7 +17,7 @@ class EntryTest < ActiveSupport::TestCase
|
|||
existing_valuation = entries :valuation
|
||||
|
||||
new_valuation = Entry.new \
|
||||
entryable: Valuation.new,
|
||||
entryable: Valuation.new(kind: "reconciliation"),
|
||||
account: existing_valuation.account,
|
||||
date: existing_valuation.date, # invalid
|
||||
currency: existing_valuation.currency,
|
||||
|
|
252
test/models/account/opening_balance_manager_test.rb
Normal file
252
test/models/account/opening_balance_manager_test.rb
Normal file
|
@ -0,0 +1,252 @@
|
|||
require "test_helper"
|
||||
|
||||
class Account::OpeningBalanceManagerTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@depository_account = accounts(:depository)
|
||||
@investment_account = accounts(:investment)
|
||||
end
|
||||
|
||||
test "when no existing anchor, creates new anchor" do
|
||||
manager = Account::OpeningBalanceManager.new(@depository_account)
|
||||
|
||||
assert_difference -> { @depository_account.entries.count } => 1,
|
||||
-> { @depository_account.valuations.count } => 1 do
|
||||
result = manager.set_opening_balance(
|
||||
balance: 1000,
|
||||
date: 1.year.ago.to_date
|
||||
)
|
||||
|
||||
assert result.success?
|
||||
assert result.changes_made?
|
||||
assert_nil result.error
|
||||
end
|
||||
|
||||
opening_anchor = @depository_account.valuations.opening_anchor.first
|
||||
assert_not_nil opening_anchor
|
||||
assert_equal 1000, opening_anchor.entry.amount
|
||||
assert_equal "opening_anchor", opening_anchor.kind
|
||||
|
||||
entry = opening_anchor.entry
|
||||
assert_equal 1000, entry.amount
|
||||
assert_equal 1.year.ago.to_date, entry.date
|
||||
assert_equal "Opening balance", entry.name
|
||||
end
|
||||
|
||||
test "when no existing anchor, creates with provided balance" do
|
||||
# Test with Depository account (should default to balance)
|
||||
depository_manager = Account::OpeningBalanceManager.new(@depository_account)
|
||||
|
||||
assert_difference -> { @depository_account.valuations.count } => 1 do
|
||||
result = depository_manager.set_opening_balance(balance: 2000)
|
||||
assert result.success?
|
||||
assert result.changes_made?
|
||||
end
|
||||
|
||||
depository_anchor = @depository_account.valuations.opening_anchor.first
|
||||
assert_equal 2000, depository_anchor.entry.amount
|
||||
|
||||
# Test with Investment account (should default to 0)
|
||||
investment_manager = Account::OpeningBalanceManager.new(@investment_account)
|
||||
|
||||
assert_difference -> { @investment_account.valuations.count } => 1 do
|
||||
result = investment_manager.set_opening_balance(balance: 5000)
|
||||
assert result.success?
|
||||
assert result.changes_made?
|
||||
end
|
||||
|
||||
investment_anchor = @investment_account.valuations.opening_anchor.first
|
||||
assert_equal 5000, investment_anchor.entry.amount
|
||||
end
|
||||
|
||||
test "when no existing anchor and no date provided, provides default based on account type" do
|
||||
# Test with recent entry (less than 2 years ago)
|
||||
@depository_account.entries.create!(
|
||||
date: 30.days.ago.to_date,
|
||||
name: "Test transaction",
|
||||
amount: 100,
|
||||
currency: "USD",
|
||||
entryable: Transaction.new
|
||||
)
|
||||
|
||||
manager = Account::OpeningBalanceManager.new(@depository_account)
|
||||
|
||||
assert_difference -> { @depository_account.valuations.count } => 1 do
|
||||
result = manager.set_opening_balance(balance: 1500)
|
||||
assert result.success?
|
||||
assert result.changes_made?
|
||||
end
|
||||
|
||||
opening_anchor = @depository_account.valuations.opening_anchor.first
|
||||
# Default should be MIN(1 day before oldest entry, 2 years ago) = 2 years ago
|
||||
assert_equal 2.years.ago.to_date, opening_anchor.entry.date
|
||||
|
||||
# Test with old entry (more than 2 years ago)
|
||||
loan_account = accounts(:loan)
|
||||
loan_account.entries.create!(
|
||||
date: 3.years.ago.to_date,
|
||||
name: "Old transaction",
|
||||
amount: 100,
|
||||
currency: "USD",
|
||||
entryable: Transaction.new
|
||||
)
|
||||
|
||||
loan_manager = Account::OpeningBalanceManager.new(loan_account)
|
||||
|
||||
assert_difference -> { loan_account.valuations.count } => 1 do
|
||||
result = loan_manager.set_opening_balance(balance: 5000)
|
||||
assert result.success?
|
||||
assert result.changes_made?
|
||||
end
|
||||
|
||||
loan_anchor = loan_account.valuations.opening_anchor.first
|
||||
# Default should be MIN(3 years ago - 1 day, 2 years ago) = 3 years ago - 1 day
|
||||
assert_equal (3.years.ago.to_date - 1.day), loan_anchor.entry.date
|
||||
|
||||
# Test with account that has no entries
|
||||
property_account = accounts(:property)
|
||||
manager_no_entries = Account::OpeningBalanceManager.new(property_account)
|
||||
|
||||
assert_difference -> { property_account.valuations.count } => 1 do
|
||||
result = manager_no_entries.set_opening_balance(balance: 3000)
|
||||
assert result.success?
|
||||
assert result.changes_made?
|
||||
end
|
||||
|
||||
opening_anchor_no_entries = property_account.valuations.opening_anchor.first
|
||||
# Default should be 2 years ago when no entries exist
|
||||
assert_equal 2.years.ago.to_date, opening_anchor_no_entries.entry.date
|
||||
end
|
||||
|
||||
test "updates existing anchor" do
|
||||
# First create an opening anchor
|
||||
manager = Account::OpeningBalanceManager.new(@depository_account)
|
||||
result = manager.set_opening_balance(
|
||||
balance: 1000,
|
||||
date: 6.months.ago.to_date
|
||||
)
|
||||
assert result.success?
|
||||
|
||||
opening_anchor = @depository_account.valuations.opening_anchor.first
|
||||
original_id = opening_anchor.id
|
||||
original_entry_id = opening_anchor.entry.id
|
||||
|
||||
# Now update it
|
||||
assert_no_difference -> { @depository_account.entries.count } do
|
||||
assert_no_difference -> { @depository_account.valuations.count } do
|
||||
result = manager.set_opening_balance(
|
||||
balance: 2000,
|
||||
date: 8.months.ago.to_date
|
||||
)
|
||||
assert result.success?
|
||||
assert result.changes_made?
|
||||
end
|
||||
end
|
||||
|
||||
opening_anchor.reload
|
||||
assert_equal original_id, opening_anchor.id # Same valuation record
|
||||
assert_equal original_entry_id, opening_anchor.entry.id # Same entry record
|
||||
assert_equal 2000, opening_anchor.entry.amount
|
||||
assert_equal 2000, opening_anchor.entry.amount
|
||||
assert_equal 8.months.ago.to_date, opening_anchor.entry.date
|
||||
end
|
||||
|
||||
test "when existing anchor and no date provided, only update balance" do
|
||||
# First create an opening anchor
|
||||
manager = Account::OpeningBalanceManager.new(@depository_account)
|
||||
result = manager.set_opening_balance(
|
||||
balance: 1000,
|
||||
date: 3.months.ago.to_date
|
||||
)
|
||||
assert result.success?
|
||||
|
||||
opening_anchor = @depository_account.valuations.opening_anchor.first
|
||||
|
||||
# Update without providing date
|
||||
result = manager.set_opening_balance(balance: 1500)
|
||||
assert result.success?
|
||||
assert result.changes_made?
|
||||
|
||||
opening_anchor.reload
|
||||
assert_equal 1500, opening_anchor.entry.amount
|
||||
end
|
||||
|
||||
test "when existing anchor and updating balance only, preserves original date" do
|
||||
# First create an opening anchor with specific date
|
||||
manager = Account::OpeningBalanceManager.new(@depository_account)
|
||||
original_date = 4.months.ago.to_date
|
||||
result = manager.set_opening_balance(
|
||||
balance: 1000,
|
||||
date: original_date
|
||||
)
|
||||
assert result.success?
|
||||
|
||||
opening_anchor = @depository_account.valuations.opening_anchor.first
|
||||
|
||||
# Update without providing date
|
||||
result = manager.set_opening_balance(balance: 2500)
|
||||
assert result.success?
|
||||
assert result.changes_made?
|
||||
|
||||
opening_anchor.reload
|
||||
assert_equal 2500, opening_anchor.entry.amount
|
||||
assert_equal original_date, opening_anchor.entry.date # Should remain unchanged
|
||||
end
|
||||
|
||||
test "when date is equal to or greater than account's oldest entry, returns error result" do
|
||||
# Create an entry with a specific date
|
||||
oldest_date = 60.days.ago.to_date
|
||||
@depository_account.entries.create!(
|
||||
date: oldest_date,
|
||||
name: "Test transaction",
|
||||
amount: 100,
|
||||
currency: "USD",
|
||||
entryable: Transaction.new
|
||||
)
|
||||
|
||||
manager = Account::OpeningBalanceManager.new(@depository_account)
|
||||
|
||||
# Try to set opening balance on the same date as oldest entry
|
||||
result = manager.set_opening_balance(
|
||||
balance: 1000,
|
||||
date: oldest_date
|
||||
)
|
||||
|
||||
assert_not result.success?
|
||||
assert_not result.changes_made?
|
||||
assert_equal "Opening balance date must be before the oldest entry date", result.error
|
||||
|
||||
# Try to set opening balance after the oldest entry
|
||||
result = manager.set_opening_balance(
|
||||
balance: 1000,
|
||||
date: oldest_date + 1.day
|
||||
)
|
||||
|
||||
assert_not result.success?
|
||||
assert_not result.changes_made?
|
||||
assert_equal "Opening balance date must be before the oldest entry date", result.error
|
||||
|
||||
# Verify no opening anchor was created
|
||||
assert_nil @depository_account.valuations.opening_anchor.first
|
||||
end
|
||||
|
||||
test "when no changes made, returns success with no changes made" do
|
||||
# First create an opening anchor
|
||||
manager = Account::OpeningBalanceManager.new(@depository_account)
|
||||
result = manager.set_opening_balance(
|
||||
balance: 1000,
|
||||
date: 2.months.ago.to_date
|
||||
)
|
||||
assert result.success?
|
||||
assert result.changes_made?
|
||||
|
||||
# Try to set the same values
|
||||
result = manager.set_opening_balance(
|
||||
balance: 1000,
|
||||
date: 2.months.ago.to_date
|
||||
)
|
||||
|
||||
assert result.success?
|
||||
assert_not result.changes_made?
|
||||
assert_nil result.error
|
||||
end
|
||||
end
|
92
test/models/account_import_test.rb
Normal file
92
test/models/account_import_test.rb
Normal file
|
@ -0,0 +1,92 @@
|
|||
require "test_helper"
|
||||
|
||||
class AccountImportTest < ActiveSupport::TestCase
|
||||
include ActiveJob::TestHelper, ImportInterfaceTest
|
||||
|
||||
setup do
|
||||
@subject = @import = imports(:account)
|
||||
end
|
||||
|
||||
test "import creates accounts with valuations" do
|
||||
import_csv = <<~CSV
|
||||
type,name,amount,currency
|
||||
depository,Main Checking,1000.00,USD
|
||||
depository,Savings Account,5000.00,USD
|
||||
CSV
|
||||
|
||||
@import.update!(
|
||||
raw_file_str: import_csv,
|
||||
entity_type_col_label: "type",
|
||||
name_col_label: "name",
|
||||
amount_col_label: "amount",
|
||||
currency_col_label: "currency"
|
||||
)
|
||||
|
||||
@import.generate_rows_from_csv
|
||||
|
||||
# Create mappings for account types
|
||||
@import.mappings.create! key: "depository", value: "Depository", type: "Import::AccountTypeMapping"
|
||||
|
||||
@import.reload
|
||||
|
||||
# Store initial counts
|
||||
initial_account_count = Account.count
|
||||
initial_entry_count = Entry.count
|
||||
initial_valuation_count = Valuation.count
|
||||
|
||||
# Perform the import
|
||||
@import.publish
|
||||
|
||||
# Check if import succeeded
|
||||
if @import.failed?
|
||||
fail "Import failed with error: #{@import.error}"
|
||||
end
|
||||
|
||||
assert_equal "complete", @import.status
|
||||
|
||||
# Check the differences
|
||||
assert_equal initial_account_count + 2, Account.count, "Expected 2 new accounts"
|
||||
assert_equal initial_entry_count + 2, Entry.count, "Expected 2 new entries"
|
||||
assert_equal initial_valuation_count + 2, Valuation.count, "Expected 2 new valuations"
|
||||
|
||||
# Verify accounts were created correctly
|
||||
accounts = @import.accounts.order(:name)
|
||||
assert_equal [ "Main Checking", "Savings Account" ], accounts.pluck(:name)
|
||||
assert_equal [ 1000.00, 5000.00 ], accounts.map { |a| a.balance.to_f }
|
||||
|
||||
# Verify valuations were created with correct fields
|
||||
accounts.each do |account|
|
||||
valuation = account.valuations.last
|
||||
assert_not_nil valuation
|
||||
assert_equal "opening_anchor", valuation.kind
|
||||
assert_equal account.balance, valuation.entry.amount
|
||||
end
|
||||
end
|
||||
|
||||
test "column_keys returns expected keys" do
|
||||
assert_equal %i[entity_type name amount currency], @import.column_keys
|
||||
end
|
||||
|
||||
test "required_column_keys returns expected keys" do
|
||||
assert_equal %i[name amount], @import.required_column_keys
|
||||
end
|
||||
|
||||
test "mapping_steps returns account type mapping" do
|
||||
assert_equal [ Import::AccountTypeMapping ], @import.mapping_steps
|
||||
end
|
||||
|
||||
test "dry_run returns expected counts" do
|
||||
@import.rows.create!(
|
||||
entity_type: "depository",
|
||||
name: "Test Account",
|
||||
amount: "1000.00",
|
||||
currency: "USD"
|
||||
)
|
||||
|
||||
assert_equal({ accounts: 1 }, @import.dry_run)
|
||||
end
|
||||
|
||||
test "max_row_count is limited to 50" do
|
||||
assert_equal 50, @import.max_row_count
|
||||
end
|
||||
end
|
|
@ -1,129 +1,349 @@
|
|||
require "test_helper"
|
||||
|
||||
# The "forward calculator" is used for all **manual** accounts where balance tracking is done through entries and NOT from an external data provider.
|
||||
class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
|
||||
include EntriesTestHelper
|
||||
include LedgerTestingHelper
|
||||
|
||||
setup do
|
||||
@account = families(:empty).accounts.create!(
|
||||
name: "Test",
|
||||
balance: 20000,
|
||||
cash_balance: 20000,
|
||||
currency: "USD",
|
||||
accountable: Investment.new
|
||||
)
|
||||
end
|
||||
|
||||
test "balance generation respects user timezone and last generated date is current user date" do
|
||||
# Simulate user in EST timezone
|
||||
Time.use_zone("America/New_York") do
|
||||
# Set current time to 1am UTC on Jan 5, 2025
|
||||
# This would be 8pm EST on Jan 4, 2025 (user's time, and the last date we should generate balances for)
|
||||
travel_to Time.utc(2025, 01, 05, 1, 0, 0)
|
||||
|
||||
# Create a valuation for Jan 3, 2025
|
||||
create_valuation(account: @account, date: "2025-01-03", amount: 17000)
|
||||
|
||||
expected = [ [ "2025-01-02", 0 ], [ "2025-01-03", 17000 ], [ "2025-01-04", 17000 ] ]
|
||||
calculated = Balance::ForwardCalculator.new(@account).calculate
|
||||
|
||||
assert_equal expected, calculated.map { |b| [ b.date.to_s, b.balance ] }
|
||||
end
|
||||
end
|
||||
# ------------------------------------------------------------------------------------------------
|
||||
# General tests for all account types
|
||||
# ------------------------------------------------------------------------------------------------
|
||||
|
||||
# When syncing forwards, we don't care about the account balance. We generate everything based on entries, starting from 0.
|
||||
test "no entries sync" do
|
||||
assert_equal 0, @account.balances.count
|
||||
account = create_account_with_ledger(
|
||||
account: { type: Depository, balance: 20000, cash_balance: 20000, currency: "USD" },
|
||||
entries: []
|
||||
)
|
||||
|
||||
expected = [ 0, 0 ]
|
||||
calculated = Balance::ForwardCalculator.new(@account).calculate
|
||||
assert_equal 0, account.balances.count
|
||||
|
||||
assert_equal expected, calculated.map(&:balance)
|
||||
calculated = Balance::ForwardCalculator.new(account).calculate
|
||||
|
||||
assert_calculated_ledger_balances(
|
||||
calculated_data: calculated,
|
||||
expected_balances: [
|
||||
[ Date.current, { balance: 0, cash_balance: 0 } ]
|
||||
]
|
||||
)
|
||||
end
|
||||
|
||||
test "valuations sync" do
|
||||
create_valuation(account: @account, date: 4.days.ago.to_date, amount: 17000)
|
||||
create_valuation(account: @account, date: 2.days.ago.to_date, amount: 19000)
|
||||
# Our system ensures all manual accounts have an opening anchor (for UX), but we should be able to handle a missing anchor by starting at 0 (i.e. "fresh account with no history")
|
||||
test "account without opening anchor starts at zero balance" do
|
||||
account = create_account_with_ledger(
|
||||
account: { type: Depository, balance: 20000, cash_balance: 20000, currency: "USD" },
|
||||
entries: [
|
||||
{ type: "transaction", date: 2.days.ago.to_date, amount: -1000 }
|
||||
]
|
||||
)
|
||||
|
||||
expected = [ 0, 17000, 17000, 19000, 19000, 19000 ]
|
||||
calculated = Balance::ForwardCalculator.new(@account).calculate.sort_by(&:date).map(&:balance)
|
||||
calculated = Balance::ForwardCalculator.new(account).calculate
|
||||
|
||||
assert_equal expected, calculated
|
||||
# Since we start at 0, this transaction (inflow) simply increases balance from 0 -> 1000
|
||||
assert_calculated_ledger_balances(
|
||||
calculated_data: calculated,
|
||||
expected_balances: [
|
||||
[ 3.days.ago.to_date, { balance: 0, cash_balance: 0 } ],
|
||||
[ 2.days.ago.to_date, { balance: 1000, cash_balance: 1000 } ]
|
||||
]
|
||||
)
|
||||
end
|
||||
|
||||
test "transactions sync" do
|
||||
create_transaction(account: @account, date: 4.days.ago.to_date, amount: -500) # income
|
||||
create_transaction(account: @account, date: 2.days.ago.to_date, amount: 100) # expense
|
||||
test "reconciliation valuation sets absolute balance before applying subsequent transactions" do
|
||||
account = create_account_with_ledger(
|
||||
account: { type: Depository, balance: 20000, cash_balance: 20000, currency: "USD" },
|
||||
entries: [
|
||||
{ type: "reconciliation", date: 3.days.ago.to_date, balance: 18000 },
|
||||
{ type: "transaction", date: 2.days.ago.to_date, amount: -1000 }
|
||||
]
|
||||
)
|
||||
|
||||
expected = [ 0, 500, 500, 400, 400, 400 ]
|
||||
calculated = Balance::ForwardCalculator.new(@account).calculate.sort_by(&:date).map(&:balance)
|
||||
calculated = Balance::ForwardCalculator.new(account).calculate
|
||||
|
||||
assert_equal expected, calculated
|
||||
# First valuation sets balance to 18000, then transaction increases balance to 19000
|
||||
assert_calculated_ledger_balances(
|
||||
calculated_data: calculated,
|
||||
expected_balances: [
|
||||
[ 3.days.ago.to_date, { balance: 18000, cash_balance: 18000 } ],
|
||||
[ 2.days.ago.to_date, { balance: 19000, cash_balance: 19000 } ]
|
||||
]
|
||||
)
|
||||
end
|
||||
|
||||
test "multi-entry sync" do
|
||||
create_transaction(account: @account, date: 8.days.ago.to_date, amount: -5000)
|
||||
create_valuation(account: @account, date: 6.days.ago.to_date, amount: 17000)
|
||||
create_transaction(account: @account, date: 6.days.ago.to_date, amount: -500)
|
||||
create_transaction(account: @account, date: 4.days.ago.to_date, amount: -500)
|
||||
create_valuation(account: @account, date: 3.days.ago.to_date, amount: 17000)
|
||||
create_transaction(account: @account, date: 1.day.ago.to_date, amount: 100)
|
||||
test "cash-only accounts (depository, credit card) use valuations where cash balance equals total balance" do
|
||||
[ Depository, CreditCard ].each do |account_type|
|
||||
account = create_account_with_ledger(
|
||||
account: { type: account_type, balance: 10000, cash_balance: 10000, currency: "USD" },
|
||||
entries: [
|
||||
{ type: "opening_anchor", date: 3.days.ago.to_date, balance: 17000 },
|
||||
{ type: "reconciliation", date: 2.days.ago.to_date, balance: 18000 }
|
||||
]
|
||||
)
|
||||
|
||||
expected = [ 0, 5000, 5000, 17000, 17000, 17500, 17000, 17000, 16900, 16900 ]
|
||||
calculated = Balance::ForwardCalculator.new(@account).calculate.sort_by(&:date).map(&:balance)
|
||||
calculated = Balance::ForwardCalculator.new(account).calculate
|
||||
|
||||
assert_equal expected, calculated
|
||||
assert_calculated_ledger_balances(
|
||||
calculated_data: calculated,
|
||||
expected_balances: [
|
||||
[ 3.days.ago.to_date, { balance: 17000, cash_balance: 17000 } ],
|
||||
[ 2.days.ago.to_date, { balance: 18000, cash_balance: 18000 } ]
|
||||
]
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
test "multi-currency sync" do
|
||||
ExchangeRate.create! date: 1.day.ago.to_date, from_currency: "EUR", to_currency: "USD", rate: 1.2
|
||||
test "non-cash accounts (property, loan) use valuations where cash balance is always zero" do
|
||||
[ Property, Loan ].each do |account_type|
|
||||
account = create_account_with_ledger(
|
||||
account: { type: account_type, balance: 10000, cash_balance: 10000, currency: "USD" },
|
||||
entries: [
|
||||
{ type: "opening_anchor", date: 3.days.ago.to_date, balance: 17000 },
|
||||
{ type: "reconciliation", date: 2.days.ago.to_date, balance: 18000 }
|
||||
]
|
||||
)
|
||||
|
||||
create_transaction(account: @account, date: 3.days.ago.to_date, amount: -100, currency: "USD")
|
||||
create_transaction(account: @account, date: 2.days.ago.to_date, amount: -300, currency: "USD")
|
||||
calculated = Balance::ForwardCalculator.new(account).calculate
|
||||
|
||||
assert_calculated_ledger_balances(
|
||||
calculated_data: calculated,
|
||||
expected_balances: [
|
||||
[ 3.days.ago.to_date, { balance: 17000, cash_balance: 0.0 } ],
|
||||
[ 2.days.ago.to_date, { balance: 18000, cash_balance: 0.0 } ]
|
||||
]
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
test "mixed accounts (investment) use valuations where cash balance is total minus holdings" do
|
||||
account = create_account_with_ledger(
|
||||
account: { type: Investment, balance: 10000, cash_balance: 10000, currency: "USD" },
|
||||
entries: [
|
||||
{ type: "opening_anchor", date: 3.days.ago.to_date, balance: 17000 },
|
||||
{ type: "reconciliation", date: 2.days.ago.to_date, balance: 18000 }
|
||||
]
|
||||
)
|
||||
|
||||
# Without holdings, cash balance equals total balance
|
||||
calculated = Balance::ForwardCalculator.new(account).calculate
|
||||
|
||||
assert_calculated_ledger_balances(
|
||||
calculated_data: calculated,
|
||||
expected_balances: [
|
||||
[ 3.days.ago.to_date, { balance: 17000, cash_balance: 17000 } ],
|
||||
[ 2.days.ago.to_date, { balance: 18000, cash_balance: 18000 } ]
|
||||
]
|
||||
)
|
||||
end
|
||||
|
||||
# ------------------------------------------------------------------------------------------------
|
||||
# All Cash accounts (Depository, CreditCard)
|
||||
# ------------------------------------------------------------------------------------------------
|
||||
|
||||
test "transactions on depository accounts affect cash balance" do
|
||||
account = create_account_with_ledger(
|
||||
account: { type: Depository, balance: 20000, cash_balance: 20000, currency: "USD" },
|
||||
entries: [
|
||||
{ type: "opening_anchor", date: 5.days.ago.to_date, balance: 20000 },
|
||||
{ type: "transaction", date: 4.days.ago.to_date, amount: -500 }, # income
|
||||
{ type: "transaction", date: 2.days.ago.to_date, amount: 100 } # expense
|
||||
]
|
||||
)
|
||||
|
||||
calculated = Balance::ForwardCalculator.new(account).calculate
|
||||
|
||||
assert_calculated_ledger_balances(
|
||||
calculated_data: calculated,
|
||||
expected_balances: [
|
||||
[ 5.days.ago.to_date, { balance: 20000, cash_balance: 20000 } ],
|
||||
[ 4.days.ago.to_date, { balance: 20500, cash_balance: 20500 } ],
|
||||
[ 3.days.ago.to_date, { balance: 20500, cash_balance: 20500 } ],
|
||||
[ 2.days.ago.to_date, { balance: 20400, cash_balance: 20400 } ]
|
||||
]
|
||||
)
|
||||
end
|
||||
|
||||
|
||||
test "transactions on credit card accounts affect cash balance inversely" do
|
||||
account = create_account_with_ledger(
|
||||
account: { type: CreditCard, balance: 10000, cash_balance: 10000, currency: "USD" },
|
||||
entries: [
|
||||
{ type: "opening_anchor", date: 5.days.ago.to_date, balance: 1000 },
|
||||
{ type: "transaction", date: 4.days.ago.to_date, amount: -500 }, # CC payment
|
||||
{ type: "transaction", date: 2.days.ago.to_date, amount: 100 } # expense
|
||||
]
|
||||
)
|
||||
|
||||
calculated = Balance::ForwardCalculator.new(account).calculate
|
||||
|
||||
assert_calculated_ledger_balances(
|
||||
calculated_data: calculated,
|
||||
expected_balances: [
|
||||
[ 5.days.ago.to_date, { balance: 1000, cash_balance: 1000 } ],
|
||||
[ 4.days.ago.to_date, { balance: 500, cash_balance: 500 } ],
|
||||
[ 3.days.ago.to_date, { balance: 500, cash_balance: 500 } ],
|
||||
[ 2.days.ago.to_date, { balance: 600, cash_balance: 600 } ]
|
||||
]
|
||||
)
|
||||
end
|
||||
|
||||
test "depository account with transactions and balance reconciliations" do
|
||||
account = create_account_with_ledger(
|
||||
account: { type: Depository, balance: 20000, cash_balance: 20000, currency: "USD" },
|
||||
entries: [
|
||||
{ type: "opening_anchor", date: 10.days.ago.to_date, balance: 20000 },
|
||||
{ type: "transaction", date: 8.days.ago.to_date, amount: -5000 },
|
||||
{ type: "reconciliation", date: 6.days.ago.to_date, balance: 17000 },
|
||||
{ type: "transaction", date: 6.days.ago.to_date, amount: -500 },
|
||||
{ type: "transaction", date: 4.days.ago.to_date, amount: -500 },
|
||||
{ type: "reconciliation", date: 3.days.ago.to_date, balance: 17000 },
|
||||
{ type: "transaction", date: 1.day.ago.to_date, amount: 100 }
|
||||
]
|
||||
)
|
||||
|
||||
calculated = Balance::ForwardCalculator.new(account).calculate
|
||||
|
||||
assert_calculated_ledger_balances(
|
||||
calculated_data: calculated,
|
||||
expected_balances: [
|
||||
[ 10.days.ago.to_date, { balance: 20000, cash_balance: 20000 } ],
|
||||
[ 9.days.ago.to_date, { balance: 20000, cash_balance: 20000 } ],
|
||||
[ 8.days.ago.to_date, { balance: 25000, cash_balance: 25000 } ],
|
||||
[ 7.days.ago.to_date, { balance: 25000, cash_balance: 25000 } ],
|
||||
[ 6.days.ago.to_date, { balance: 17000, cash_balance: 17000 } ],
|
||||
[ 5.days.ago.to_date, { balance: 17000, cash_balance: 17000 } ],
|
||||
[ 4.days.ago.to_date, { balance: 17500, cash_balance: 17500 } ],
|
||||
[ 3.days.ago.to_date, { balance: 17000, cash_balance: 17000 } ],
|
||||
[ 2.days.ago.to_date, { balance: 17000, cash_balance: 17000 } ],
|
||||
[ 1.day.ago.to_date, { balance: 16900, cash_balance: 16900 } ]
|
||||
]
|
||||
)
|
||||
end
|
||||
|
||||
test "accounts with transactions in multiple currencies convert to the account currency" do
|
||||
account = create_account_with_ledger(
|
||||
account: { type: Depository, balance: 20000, cash_balance: 20000, currency: "USD" },
|
||||
entries: [
|
||||
{ type: "opening_anchor", date: 4.days.ago.to_date, balance: 100 },
|
||||
{ type: "transaction", date: 3.days.ago.to_date, amount: -100 },
|
||||
{ type: "transaction", date: 2.days.ago.to_date, amount: -300 },
|
||||
# Transaction in different currency than the account's main currency
|
||||
create_transaction(account: @account, date: 1.day.ago.to_date, amount: -500, currency: "EUR") # €500 * 1.2 = $600
|
||||
{ type: "transaction", date: 1.day.ago.to_date, amount: -500, currency: "EUR" } # €500 * 1.2 = $600
|
||||
],
|
||||
exchange_rates: [
|
||||
{ date: 1.day.ago.to_date, from: "EUR", to: "USD", rate: 1.2 }
|
||||
]
|
||||
)
|
||||
|
||||
expected = [ 0, 100, 400, 1000, 1000 ]
|
||||
calculated = Balance::ForwardCalculator.new(@account).calculate.sort_by(&:date).map(&:balance)
|
||||
calculated = Balance::ForwardCalculator.new(account).calculate
|
||||
|
||||
assert_equal expected, calculated
|
||||
assert_calculated_ledger_balances(
|
||||
calculated_data: calculated,
|
||||
expected_balances: [
|
||||
[ 4.days.ago.to_date, { balance: 100, cash_balance: 100 } ],
|
||||
[ 3.days.ago.to_date, { balance: 200, cash_balance: 200 } ],
|
||||
[ 2.days.ago.to_date, { balance: 500, cash_balance: 500 } ],
|
||||
[ 1.day.ago.to_date, { balance: 1100, cash_balance: 1100 } ]
|
||||
]
|
||||
)
|
||||
end
|
||||
|
||||
test "holdings and trades sync" do
|
||||
aapl = securities(:aapl)
|
||||
# A loan is a special case where despite being a "non-cash" account, it is typical to have "payment" transactions that reduce the loan principal (non cash balance)
|
||||
test "loan payment transactions affect non cash balance" do
|
||||
account = create_account_with_ledger(
|
||||
account: { type: Loan, balance: 10000, cash_balance: 0, currency: "USD" },
|
||||
entries: [
|
||||
{ type: "opening_anchor", date: 2.days.ago.to_date, balance: 20000 },
|
||||
# "Loan payment" of $2000, which reduces the principal
|
||||
# TODO: We'll eventually need to calculate which portion of the txn was "interest" vs. "principal", but for now we'll just assume it's all principal
|
||||
# since we don't have a first-class way to track interest payments yet.
|
||||
{ type: "transaction", date: 1.day.ago.to_date, amount: -2000 }
|
||||
]
|
||||
)
|
||||
|
||||
# Account starts at a value of $5000
|
||||
create_valuation(account: @account, date: 2.days.ago.to_date, amount: 5000)
|
||||
calculated = Balance::ForwardCalculator.new(account).calculate
|
||||
|
||||
assert_calculated_ledger_balances(
|
||||
calculated_data: calculated,
|
||||
expected_balances: [
|
||||
[ 2.days.ago.to_date, { balance: 20000, cash_balance: 0 } ],
|
||||
[ 1.day.ago.to_date, { balance: 18000, cash_balance: 0 } ]
|
||||
]
|
||||
)
|
||||
end
|
||||
|
||||
test "non cash accounts can only use valuations and transactions will be recorded but ignored for balance calculation" do
|
||||
[ Property, Vehicle, OtherAsset, OtherLiability ].each do |account_type|
|
||||
account = create_account_with_ledger(
|
||||
account: { type: account_type, balance: 10000, cash_balance: 10000, currency: "USD" },
|
||||
entries: [
|
||||
{ type: "opening_anchor", date: 3.days.ago.to_date, balance: 500000 },
|
||||
|
||||
# Will be ignored for balance calculation due to account type of non-cash
|
||||
{ type: "transaction", date: 2.days.ago.to_date, amount: -50000 }
|
||||
]
|
||||
)
|
||||
|
||||
calculated = Balance::ForwardCalculator.new(account).calculate
|
||||
|
||||
assert_calculated_ledger_balances(
|
||||
calculated_data: calculated,
|
||||
expected_balances: [
|
||||
[ 3.days.ago.to_date, { balance: 500000, cash_balance: 0 } ],
|
||||
[ 2.days.ago.to_date, { balance: 500000, cash_balance: 0 } ]
|
||||
]
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
# ------------------------------------------------------------------------------------------------
|
||||
# Hybrid accounts (Investment, Crypto) - these have both cash and non-cash balance components
|
||||
# ------------------------------------------------------------------------------------------------
|
||||
|
||||
# A transaction increases/decreases cash balance (i.e. "deposits" and "withdrawals")
|
||||
# A trade increases/decreases cash balance (i.e. "buys" and "sells", which consume/add "brokerage cash" and create/destroy "holdings")
|
||||
# A valuation can set both cash and non-cash balances to "override" investment account value.
|
||||
# Holdings are calculated separately and fed into the balance calculator; treated as "non-cash"
|
||||
test "investment account calculates balance from transactions and trades and treats holdings as non-cash, additive to balance" do
|
||||
account = create_account_with_ledger(
|
||||
account: { type: Investment, balance: 10000, cash_balance: 10000, currency: "USD" },
|
||||
entries: [
|
||||
# Account starts with brokerage cash of $5000 and no holdings
|
||||
{ type: "opening_anchor", date: 3.days.ago.to_date, balance: 5000 },
|
||||
# Share purchase reduces cash balance by $1000, but keeps overall balance same
|
||||
create_trade(aapl, account: @account, qty: 10, date: 1.day.ago.to_date, price: 100)
|
||||
|
||||
Holding.create!(date: 1.day.ago.to_date, account: @account, security: aapl, qty: 10, price: 100, amount: 1000, currency: "USD")
|
||||
Holding.create!(date: Date.current, account: @account, security: aapl, qty: 10, price: 100, amount: 1000, currency: "USD")
|
||||
{ type: "trade", date: 1.day.ago.to_date, ticker: "AAPL", qty: 10, price: 100 }
|
||||
],
|
||||
holdings: [
|
||||
# Holdings calculator will calculate $1000 worth of holdings
|
||||
{ date: 1.day.ago.to_date, ticker: "AAPL", qty: 10, price: 100, amount: 1000 },
|
||||
{ date: Date.current, ticker: "AAPL", qty: 10, price: 100, amount: 1000 }
|
||||
]
|
||||
)
|
||||
|
||||
# Given constant prices, overall balance (account value) should be constant
|
||||
# (the single trade doesn't affect balance; it just alters cash vs. holdings composition)
|
||||
expected = [ 0, 5000, 5000, 5000 ]
|
||||
calculated = Balance::ForwardCalculator.new(@account).calculate.sort_by(&:date).map(&:balance)
|
||||
calculated = Balance::ForwardCalculator.new(account).calculate
|
||||
|
||||
assert_equal expected, calculated
|
||||
assert_calculated_ledger_balances(
|
||||
calculated_data: calculated,
|
||||
expected_balances: [
|
||||
[ 3.days.ago.to_date, { balance: 5000, cash_balance: 5000 } ],
|
||||
[ 2.days.ago.to_date, { balance: 5000, cash_balance: 5000 } ],
|
||||
[ 1.day.ago.to_date, { balance: 5000, cash_balance: 4000 } ],
|
||||
[ Date.current, { balance: 5000, cash_balance: 4000 } ]
|
||||
]
|
||||
)
|
||||
end
|
||||
|
||||
# Balance calculator is entirely reliant on HoldingCalculator and respects whatever holding records it creates.
|
||||
test "holdings are additive to total balance" do
|
||||
aapl = securities(:aapl)
|
||||
private
|
||||
|
||||
# Account starts at a value of $5000
|
||||
create_valuation(account: @account, date: 2.days.ago.to_date, amount: 5000)
|
||||
def assert_balances(calculated_data:, expected_balances:)
|
||||
# Sort calculated data by date to ensure consistent ordering
|
||||
sorted_data = calculated_data.sort_by(&:date)
|
||||
|
||||
# Even though there are no trades in the history, the calculator will still add the holdings to the total balance
|
||||
Holding.create!(date: 1.day.ago.to_date, account: @account, security: aapl, qty: 10, price: 100, amount: 1000, currency: "USD")
|
||||
Holding.create!(date: Date.current, account: @account, security: aapl, qty: 10, price: 100, amount: 1000, currency: "USD")
|
||||
# Extract actual values as [date, { balance:, cash_balance: }]
|
||||
actual_balances = sorted_data.map do |b|
|
||||
[ b.date, { balance: b.balance, cash_balance: b.cash_balance } ]
|
||||
end
|
||||
|
||||
# Start at zero, then valuation of $5000, then tack on $1000 of holdings for remaining 2 days
|
||||
expected = [ 0, 5000, 6000, 6000 ]
|
||||
calculated = Balance::ForwardCalculator.new(@account).calculate.sort_by(&:date).map(&:balance)
|
||||
|
||||
assert_equal expected, calculated
|
||||
assert_equal expected_balances, actual_balances
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,142 +1,279 @@
|
|||
require "test_helper"
|
||||
|
||||
class Balance::ReverseCalculatorTest < ActiveSupport::TestCase
|
||||
include EntriesTestHelper
|
||||
include LedgerTestingHelper
|
||||
|
||||
setup do
|
||||
@account = families(:empty).accounts.create!(
|
||||
name: "Test",
|
||||
balance: 20000,
|
||||
cash_balance: 20000,
|
||||
currency: "USD",
|
||||
accountable: Investment.new
|
||||
# When syncing backwards, we start with the account balance and generate everything from there.
|
||||
test "when missing anchor and no entries, falls back to cached account balance" do
|
||||
account = create_account_with_ledger(
|
||||
account: { type: Depository, balance: 20000, cash_balance: 20000, currency: "USD" },
|
||||
entries: []
|
||||
)
|
||||
|
||||
assert_equal 20000, account.balance
|
||||
|
||||
calculated = Balance::ReverseCalculator.new(account).calculate
|
||||
|
||||
assert_calculated_ledger_balances(
|
||||
calculated_data: calculated,
|
||||
expected_balances: [
|
||||
[ Date.current, { balance: 20000, cash_balance: 20000 } ]
|
||||
]
|
||||
)
|
||||
end
|
||||
|
||||
# When syncing backwards, we start with the account balance and generate everything from there.
|
||||
test "no entries sync" do
|
||||
assert_equal 0, @account.balances.count
|
||||
# An artificial constraint we put on the reverse sync because it's confusing in both the code and the UI
|
||||
# to think about how an absolute "Valuation" affects balances when syncing backwards. Furthermore, since
|
||||
# this is typically a Plaid sync, we expect Plaid to provide us the history.
|
||||
# Note: while "reconciliation" valuations don't affect balance, `current_anchor` and `opening_anchor` do.
|
||||
test "reconciliation valuations do not affect balance for reverse syncs" do
|
||||
account = create_account_with_ledger(
|
||||
account: { type: Depository, balance: 20000, cash_balance: 20000, currency: "USD" },
|
||||
entries: [
|
||||
{ type: "current_anchor", date: Date.current, balance: 20000 },
|
||||
{ type: "reconciliation", date: 1.day.ago, balance: 17000 }, # Ignored
|
||||
{ type: "reconciliation", date: 2.days.ago, balance: 17000 }, # Ignored
|
||||
{ type: "opening_anchor", date: 4.days.ago, balance: 15000 }
|
||||
]
|
||||
)
|
||||
|
||||
expected = [ @account.balance, @account.balance ]
|
||||
calculated = Balance::ReverseCalculator.new(@account).calculate
|
||||
calculated = Balance::ReverseCalculator.new(account).calculate
|
||||
|
||||
assert_equal expected, calculated.map(&:balance)
|
||||
# The "opening anchor" works slightly differently than most would expect. Since it's an artificial
|
||||
# value provided by the user to set the date/balance of the start of the account, we must assume
|
||||
# that there are "missing" entries following it. Because of this, we cannot "carry forward" this value
|
||||
# like we do for a "forward sync". We simply sync backwards normally, then set the balance on opening
|
||||
# date equal to this anchor. This is not "ideal", but is a constraint put on us since we cannot guarantee
|
||||
# a 100% full entries history.
|
||||
assert_calculated_ledger_balances(
|
||||
calculated_data: calculated,
|
||||
expected_balances: [
|
||||
[ Date.current, { balance: 20000, cash_balance: 20000 } ], # Current anchor
|
||||
[ 1.day.ago, { balance: 20000, cash_balance: 20000 } ],
|
||||
[ 2.days.ago, { balance: 20000, cash_balance: 20000 } ],
|
||||
[ 3.days.ago, { balance: 20000, cash_balance: 20000 } ],
|
||||
[ 4.days.ago, { balance: 15000, cash_balance: 15000 } ] # Opening anchor
|
||||
]
|
||||
)
|
||||
end
|
||||
|
||||
test "balance generation respects user timezone and last generated date is current user date" do
|
||||
# Simulate user in EST timezone
|
||||
Time.use_zone("America/New_York") do
|
||||
# Set current time to 1am UTC on Jan 5, 2025
|
||||
# This would be 8pm EST on Jan 4, 2025 (user's time, and the last date we should generate balances for)
|
||||
travel_to Time.utc(2025, 01, 05, 1, 0, 0)
|
||||
# Investment account balances are made of two components: cash and holdings.
|
||||
test "anchors on investment accounts calculate cash balance dynamically based on holdings value" do
|
||||
account = create_account_with_ledger(
|
||||
account: { type: Investment, balance: 20000, cash_balance: 10000, currency: "USD" },
|
||||
entries: [
|
||||
{ type: "current_anchor", date: Date.current, balance: 20000 }, # "Total account value is $20,000 today"
|
||||
{ type: "opening_anchor", date: 1.day.ago, balance: 15000 } # "Total account value was $15,000 at the start of the account"
|
||||
],
|
||||
holdings: [
|
||||
{ date: Date.current, ticker: "AAPL", qty: 100, price: 100, amount: 10000 },
|
||||
{ date: 1.day.ago, ticker: "AAPL", qty: 100, price: 100, amount: 10000 }
|
||||
]
|
||||
)
|
||||
|
||||
create_valuation(account: @account, date: "2025-01-03", amount: 17000)
|
||||
calculated = Balance::ReverseCalculator.new(account).calculate
|
||||
|
||||
expected = [ [ "2025-01-02", 17000 ], [ "2025-01-03", 17000 ], [ "2025-01-04", @account.balance ] ]
|
||||
calculated = Balance::ReverseCalculator.new(@account).calculate
|
||||
|
||||
assert_equal expected, calculated.sort_by(&:date).map { |b| [ b.date.to_s, b.balance ] }
|
||||
end
|
||||
assert_calculated_ledger_balances(
|
||||
calculated_data: calculated,
|
||||
expected_balances: [
|
||||
[ Date.current, { balance: 20000, cash_balance: 10000 } ], # Since $10,000 of holdings, cash has to be $10,000 to reach $20,000 total value
|
||||
[ 1.day.ago, { balance: 15000, cash_balance: 5000 } ] # Since $10,000 of holdings, cash has to be $5,000 to reach $15,000 total value
|
||||
]
|
||||
)
|
||||
end
|
||||
|
||||
test "valuations sync" do
|
||||
create_valuation(account: @account, date: 4.days.ago.to_date, amount: 17000)
|
||||
create_valuation(account: @account, date: 2.days.ago.to_date, amount: 19000)
|
||||
test "transactions on depository accounts affect cash balance" do
|
||||
account = create_account_with_ledger(
|
||||
account: { type: Depository, balance: 20000, cash_balance: 20000, currency: "USD" },
|
||||
entries: [
|
||||
{ type: "current_anchor", date: Date.current, balance: 20000 },
|
||||
{ type: "transaction", date: 4.days.ago, amount: -500 }, # income
|
||||
{ type: "transaction", date: 2.days.ago, amount: 100 } # expense
|
||||
]
|
||||
)
|
||||
|
||||
expected = [ 17000, 17000, 19000, 19000, 20000, 20000 ]
|
||||
calculated = Balance::ReverseCalculator.new(@account).calculate.sort_by(&:date).map(&:balance)
|
||||
calculated = Balance::ReverseCalculator.new(account).calculate
|
||||
|
||||
assert_equal expected, calculated
|
||||
assert_calculated_ledger_balances(
|
||||
calculated_data: calculated,
|
||||
expected_balances: [
|
||||
[ Date.current, { balance: 20000, cash_balance: 20000 } ], # Current balance
|
||||
[ 1.day.ago, { balance: 20000, cash_balance: 20000 } ], # No change
|
||||
[ 2.days.ago, { balance: 20000, cash_balance: 20000 } ], # After expense (+100)
|
||||
[ 3.days.ago, { balance: 20100, cash_balance: 20100 } ], # Before expense
|
||||
[ 4.days.ago, { balance: 20100, cash_balance: 20100 } ], # After income (-500)
|
||||
[ 5.days.ago, { balance: 19600, cash_balance: 19600 } ] # After income (-500)
|
||||
]
|
||||
)
|
||||
end
|
||||
|
||||
test "transactions sync" do
|
||||
create_transaction(account: @account, date: 4.days.ago.to_date, amount: -500) # income
|
||||
create_transaction(account: @account, date: 2.days.ago.to_date, amount: 100) # expense
|
||||
test "transactions on credit card accounts affect cash balance inversely" do
|
||||
account = create_account_with_ledger(
|
||||
account: { type: CreditCard, balance: 2000, cash_balance: 2000, currency: "USD" },
|
||||
entries: [
|
||||
{ type: "current_anchor", date: Date.current, balance: 2000 },
|
||||
{ type: "transaction", date: 2.days.ago, amount: 100 }, # expense (increases cash balance)
|
||||
{ type: "transaction", date: 4.days.ago, amount: -500 } # CC payment (reduces cash balance)
|
||||
]
|
||||
)
|
||||
|
||||
expected = [ 19600, 20100, 20100, 20000, 20000, 20000 ]
|
||||
calculated = Balance::ReverseCalculator.new(@account).calculate.sort_by(&:date).map(&:balance)
|
||||
calculated = Balance::ReverseCalculator.new(account).calculate
|
||||
|
||||
assert_equal expected, calculated
|
||||
# Reversed order: showing how we work backwards
|
||||
assert_calculated_ledger_balances(
|
||||
calculated_data: calculated,
|
||||
expected_balances: [
|
||||
[ Date.current, { balance: 2000, cash_balance: 2000 } ], # Current balance
|
||||
[ 1.day.ago, { balance: 2000, cash_balance: 2000 } ], # No change
|
||||
[ 2.days.ago, { balance: 2000, cash_balance: 2000 } ], # After expense (+100)
|
||||
[ 3.days.ago, { balance: 1900, cash_balance: 1900 } ], # Before expense
|
||||
[ 4.days.ago, { balance: 1900, cash_balance: 1900 } ], # After CC payment (-500)
|
||||
[ 5.days.ago, { balance: 2400, cash_balance: 2400 } ]
|
||||
]
|
||||
)
|
||||
end
|
||||
|
||||
test "multi-entry sync" do
|
||||
create_transaction(account: @account, date: 8.days.ago.to_date, amount: -5000)
|
||||
create_valuation(account: @account, date: 6.days.ago.to_date, amount: 17000)
|
||||
create_transaction(account: @account, date: 6.days.ago.to_date, amount: -500)
|
||||
create_transaction(account: @account, date: 4.days.ago.to_date, amount: -500)
|
||||
create_valuation(account: @account, date: 3.days.ago.to_date, amount: 17000)
|
||||
create_transaction(account: @account, date: 1.day.ago.to_date, amount: 100)
|
||||
# A loan is a special case where despite being a "non-cash" account, it is typical to have "payment" transactions that reduce the loan principal (non cash balance)
|
||||
test "loan payment transactions affect non cash balance" do
|
||||
account = create_account_with_ledger(
|
||||
account: { type: Loan, balance: 198000, cash_balance: 0, currency: "USD" },
|
||||
entries: [
|
||||
{ type: "current_anchor", date: Date.current, balance: 198000 },
|
||||
# "Loan payment" of $2000, which reduces the principal
|
||||
# TODO: We'll eventually need to calculate which portion of the txn was "interest" vs. "principal", but for now we'll just assume it's all principal
|
||||
# since we don't have a first-class way to track interest payments yet.
|
||||
{ type: "transaction", date: 1.day.ago.to_date, amount: -2000 }
|
||||
]
|
||||
)
|
||||
|
||||
expected = [ 12000, 17000, 17000, 17000, 16500, 17000, 17000, 20100, 20000, 20000 ]
|
||||
calculated = Balance::ReverseCalculator.new(@account).calculate.sort_by(&:date).map(&:balance)
|
||||
calculated = Balance::ReverseCalculator.new(account).calculate
|
||||
|
||||
assert_equal expected, calculated
|
||||
assert_calculated_ledger_balances(
|
||||
calculated_data: calculated,
|
||||
expected_balances: [
|
||||
[ Date.current, { balance: 198000, cash_balance: 0 } ],
|
||||
[ 1.day.ago, { balance: 198000, cash_balance: 0 } ],
|
||||
[ 2.days.ago, { balance: 200000, cash_balance: 0 } ]
|
||||
]
|
||||
)
|
||||
end
|
||||
|
||||
test "non cash accounts can only use valuations and transactions will be recorded but ignored for balance calculation" do
|
||||
[ Property, Vehicle, OtherAsset, OtherLiability ].each do |account_type|
|
||||
account = create_account_with_ledger(
|
||||
account: { type: account_type, balance: 1000, cash_balance: 0, currency: "USD" },
|
||||
entries: [
|
||||
{ type: "current_anchor", date: Date.current, balance: 1000 },
|
||||
|
||||
# Will be ignored for balance calculation due to account type of non-cash
|
||||
{ type: "transaction", date: 1.day.ago, amount: -100 }
|
||||
]
|
||||
)
|
||||
|
||||
calculated = Balance::ReverseCalculator.new(account).calculate
|
||||
|
||||
assert_calculated_ledger_balances(
|
||||
calculated_data: calculated,
|
||||
expected_balances: [
|
||||
[ Date.current, { balance: 1000, cash_balance: 0 } ],
|
||||
[ 1.day.ago, { balance: 1000, cash_balance: 0 } ],
|
||||
[ 2.days.ago, { balance: 1000, cash_balance: 0 } ]
|
||||
]
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
# When syncing backwards, trades from the past should NOT affect the current balance or previous balances.
|
||||
# They should only affect the *cash* component of the historical balances
|
||||
test "holdings and trades sync" do
|
||||
aapl = securities(:aapl)
|
||||
|
||||
# Account starts with $20,000 total value, $19,000 cash, $1,000 in holdings
|
||||
@account.update!(cash_balance: 19000, balance: 20000)
|
||||
|
||||
account = create_account_with_ledger(
|
||||
account: { type: Investment, balance: 20000, cash_balance: 19000, currency: "USD" },
|
||||
entries: [
|
||||
{ type: "current_anchor", date: Date.current, balance: 20000 },
|
||||
# Bought 10 AAPL shares 1 day ago, so cash is $19,000, $1,000 in holdings, total value is $20,000
|
||||
create_trade(aapl, account: @account, qty: 10, date: 1.day.ago.to_date, price: 100)
|
||||
{ type: "trade", date: 1.day.ago.to_date, ticker: "AAPL", qty: 10, price: 100 }
|
||||
],
|
||||
holdings: [
|
||||
{ date: Date.current, ticker: "AAPL", qty: 10, price: 100, amount: 1000 },
|
||||
{ date: 1.day.ago.to_date, ticker: "AAPL", qty: 10, price: 100, amount: 1000 }
|
||||
]
|
||||
)
|
||||
|
||||
Holding.create!(date: Date.current, account: @account, security: aapl, qty: 10, price: 100, amount: 1000, currency: "USD")
|
||||
Holding.create!(date: 1.day.ago.to_date, account: @account, security: aapl, qty: 10, price: 100, amount: 1000, currency: "USD")
|
||||
calculated = Balance::ReverseCalculator.new(account).calculate
|
||||
|
||||
# Given constant prices, overall balance (account value) should be constant
|
||||
# (the single trade doesn't affect balance; it just alters cash vs. holdings composition)
|
||||
expected = [ 20000, 20000, 20000 ]
|
||||
calculated = Balance::ReverseCalculator.new(@account).calculate.sort_by(&:date).map(&:balance)
|
||||
|
||||
assert_equal expected, calculated
|
||||
assert_calculated_ledger_balances(
|
||||
calculated_data: calculated,
|
||||
expected_balances: [
|
||||
[ Date.current, { balance: 20000, cash_balance: 19000 } ], # Current: $19k cash + $1k holdings (anchor)
|
||||
[ 1.day.ago.to_date, { balance: 20000, cash_balance: 19000 } ], # After trade: $19k cash + $1k holdings
|
||||
[ 2.days.ago.to_date, { balance: 20000, cash_balance: 20000 } ] # At first, account is 100% cash, no holdings (no trades)
|
||||
]
|
||||
)
|
||||
end
|
||||
|
||||
# A common scenario with Plaid is they'll give us holding records for today, but no trade history for some of them.
|
||||
# This is because they only supply 2 years worth of historical data. Our system must properly handle this.
|
||||
test "properly calculates balances when a holding has no trade history" do
|
||||
aapl = securities(:aapl)
|
||||
msft = securities(:msft)
|
||||
|
||||
# Account starts with $20,000 total value, $19,000 cash, $1,000 in holdings ($500 AAPL, $500 MSFT)
|
||||
@account.update!(cash_balance: 19000, balance: 20000)
|
||||
account = create_account_with_ledger(
|
||||
account: { type: Investment, balance: 20000, cash_balance: 19000, currency: "USD" },
|
||||
entries: [
|
||||
{ type: "current_anchor", date: Date.current, balance: 20000 },
|
||||
# A holding *with* trade history (5 shares of AAPL, purchased 1 day ago)
|
||||
{ type: "trade", date: 1.day.ago.to_date, ticker: "AAPL", qty: 5, price: 100 }
|
||||
],
|
||||
holdings: [
|
||||
# AAPL holdings
|
||||
{ date: Date.current, ticker: "AAPL", qty: 5, price: 100, amount: 500 },
|
||||
{ date: 1.day.ago.to_date, ticker: "AAPL", qty: 5, price: 100, amount: 500 },
|
||||
# MSFT holdings without trade history - Balance calculator doesn't care how the holdings were created. It just reads them and assumes they are accurate.
|
||||
{ date: Date.current, ticker: "MSFT", qty: 5, price: 100, amount: 500 },
|
||||
{ date: 1.day.ago.to_date, ticker: "MSFT", qty: 5, price: 100, amount: 500 },
|
||||
{ date: 2.days.ago.to_date, ticker: "MSFT", qty: 5, price: 100, amount: 500 }
|
||||
]
|
||||
)
|
||||
|
||||
# A holding *with* trade history (5 shares of AAPL, purchased 1 day ago, results in 2 holdings)
|
||||
Holding.create!(date: Date.current, account: @account, security: aapl, qty: 5, price: 100, amount: 500, currency: "USD")
|
||||
Holding.create!(date: 1.day.ago.to_date, account: @account, security: aapl, qty: 5, price: 100, amount: 500, currency: "USD")
|
||||
create_trade(aapl, account: @account, qty: 5, date: 1.day.ago.to_date, price: 100)
|
||||
calculated = Balance::ReverseCalculator.new(account).calculate
|
||||
|
||||
# A holding *without* trade history (5 shares of MSFT, no trade history, results in 1 holding)
|
||||
# We assume if no history is provided, this holding has existed since beginning of account
|
||||
Holding.create!(date: Date.current, account: @account, security: msft, qty: 5, price: 100, amount: 500, currency: "USD")
|
||||
Holding.create!(date: 1.day.ago.to_date, account: @account, security: msft, qty: 5, price: 100, amount: 500, currency: "USD")
|
||||
Holding.create!(date: 2.days.ago.to_date, account: @account, security: msft, qty: 5, price: 100, amount: 500, currency: "USD")
|
||||
|
||||
expected = [ 20000, 20000, 20000 ]
|
||||
calculated = Balance::ReverseCalculator.new(@account).calculate.sort_by(&:date).map(&:balance)
|
||||
|
||||
assert_equal expected, calculated
|
||||
assert_calculated_ledger_balances(
|
||||
calculated_data: calculated,
|
||||
expected_balances: [
|
||||
[ Date.current, { balance: 20000, cash_balance: 19000 } ], # Current: $19k cash + $1k holdings ($500 MSFT, $500 AAPL)
|
||||
[ 1.day.ago.to_date, { balance: 20000, cash_balance: 19000 } ], # After AAPL trade: $19k cash + $1k holdings
|
||||
[ 2.days.ago.to_date, { balance: 20000, cash_balance: 19500 } ] # Before AAPL trade: $19.5k cash + $500 MSFT
|
||||
]
|
||||
)
|
||||
end
|
||||
|
||||
test "uses provider reported holdings and cash value on current day" do
|
||||
aapl = securities(:aapl)
|
||||
|
||||
# Implied holdings value of $1,000 from provider
|
||||
@account.update!(cash_balance: 19000, balance: 20000)
|
||||
account = create_account_with_ledger(
|
||||
account: { type: Investment, balance: 20000, cash_balance: 19000, currency: "USD" },
|
||||
entries: [
|
||||
{ type: "current_anchor", date: Date.current, balance: 20000 },
|
||||
{ type: "opening_anchor", date: 2.days.ago, balance: 15000 }
|
||||
],
|
||||
holdings: [
|
||||
# Create holdings that differ in value from provider ($2,000 vs. the $1,000 reported by provider)
|
||||
{ date: Date.current, ticker: "AAPL", qty: 10, price: 100, amount: 2000 },
|
||||
{ date: 1.day.ago, ticker: "AAPL", qty: 10, price: 100, amount: 2000 }
|
||||
]
|
||||
)
|
||||
|
||||
# Create a holding that differs in value from provider ($2,000 vs. the $1,000 reported by provider)
|
||||
Holding.create!(date: Date.current, account: @account, security: aapl, qty: 10, price: 100, amount: 2000, currency: "USD")
|
||||
Holding.create!(date: 1.day.ago.to_date, account: @account, security: aapl, qty: 10, price: 100, amount: 2000, currency: "USD")
|
||||
calculated = Balance::ReverseCalculator.new(account).calculate
|
||||
|
||||
# Today reports the provider value. Yesterday, provider won't give us any data, so we MUST look at the generated holdings value
|
||||
# to calculate the end balance ($19,000 cash + $2,000 holdings = $21,000 total value)
|
||||
expected = [ 21000, 20000 ]
|
||||
|
||||
calculated = Balance::ReverseCalculator.new(@account).calculate.sort_by(&:date).map(&:balance)
|
||||
|
||||
assert_equal expected, calculated
|
||||
assert_calculated_ledger_balances(
|
||||
calculated_data: calculated,
|
||||
expected_balances: [
|
||||
# No matter what, we force current day equal to the "anchor" balance (what provider gave us), and let "cash" float based on holdings value
|
||||
# This ensures the user sees the same top-line number reported by the provider (even if it creates a discrepancy in the cash balance)
|
||||
[ Date.current, { balance: 20000, cash_balance: 18000 } ],
|
||||
[ 1.day.ago, { balance: 20000, cash_balance: 18000 } ],
|
||||
[ 2.days.ago, { balance: 15000, cash_balance: 15000 } ] # Opening anchor sets absolute balance
|
||||
]
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -94,10 +94,21 @@ class PlaidAccount::ProcessorTest < ActiveSupport::TestCase
|
|||
test "calculates balance using BalanceCalculator for investment accounts" do
|
||||
@plaid_account.update!(plaid_type: "investment")
|
||||
|
||||
PlaidAccount::Investments::BalanceCalculator.any_instance.expects(:balance).returns(1000).once
|
||||
# Balance is called twice: once for account.balance and once for set_current_balance
|
||||
PlaidAccount::Investments::BalanceCalculator.any_instance.expects(:balance).returns(1000).twice
|
||||
PlaidAccount::Investments::BalanceCalculator.any_instance.expects(:cash_balance).returns(1000).once
|
||||
|
||||
PlaidAccount::Processor.new(@plaid_account).process
|
||||
|
||||
# Verify that the balance was set correctly
|
||||
account = @plaid_account.account
|
||||
assert_equal 1000, account.balance
|
||||
assert_equal 1000, account.cash_balance
|
||||
|
||||
# Verify current balance anchor was created with correct value
|
||||
current_anchor = account.valuations.current_anchor.first
|
||||
assert_not_nil current_anchor
|
||||
assert_equal 1000, current_anchor.entry.amount
|
||||
end
|
||||
|
||||
test "processes credit liability data" do
|
||||
|
@ -142,6 +153,76 @@ class PlaidAccount::ProcessorTest < ActiveSupport::TestCase
|
|||
PlaidAccount::Processor.new(@plaid_account).process
|
||||
end
|
||||
|
||||
test "creates current balance anchor when processing account" do
|
||||
expect_default_subprocessor_calls
|
||||
|
||||
# Clear out accounts to start fresh
|
||||
Account.destroy_all
|
||||
|
||||
@plaid_account.update!(
|
||||
plaid_id: "test_plaid_id",
|
||||
plaid_type: "depository",
|
||||
plaid_subtype: "checking",
|
||||
current_balance: 1500,
|
||||
available_balance: 1500,
|
||||
currency: "USD",
|
||||
name: "Test Account with Anchor",
|
||||
mask: "1234"
|
||||
)
|
||||
|
||||
assert_difference "Account.count", 1 do
|
||||
assert_difference "Entry.count", 1 do
|
||||
assert_difference "Valuation.count", 1 do
|
||||
PlaidAccount::Processor.new(@plaid_account).process
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
account = Account.order(created_at: :desc).first
|
||||
assert_equal 1500, account.balance
|
||||
|
||||
# Verify current balance anchor was created
|
||||
current_anchor = account.valuations.current_anchor.first
|
||||
assert_not_nil current_anchor
|
||||
assert_equal "current_anchor", current_anchor.kind
|
||||
assert_equal 1500, current_anchor.entry.amount
|
||||
assert_equal Date.current, current_anchor.entry.date
|
||||
assert_equal "Current balance", current_anchor.entry.name
|
||||
end
|
||||
|
||||
test "updates existing current balance anchor when reprocessing" do
|
||||
# First process creates the account and anchor
|
||||
expect_default_subprocessor_calls
|
||||
PlaidAccount::Processor.new(@plaid_account).process
|
||||
|
||||
account = @plaid_account.account
|
||||
original_anchor = account.valuations.current_anchor.first
|
||||
assert_not_nil original_anchor
|
||||
original_anchor_id = original_anchor.id
|
||||
original_entry_id = original_anchor.entry.id
|
||||
original_balance = original_anchor.entry.amount
|
||||
|
||||
# Update the plaid account balance
|
||||
@plaid_account.update!(current_balance: 2500)
|
||||
|
||||
# Expect subprocessor calls again for the second processing
|
||||
expect_default_subprocessor_calls
|
||||
|
||||
# Reprocess should update the existing anchor
|
||||
assert_no_difference "Valuation.count" do
|
||||
assert_no_difference "Entry.count" do
|
||||
PlaidAccount::Processor.new(@plaid_account).process
|
||||
end
|
||||
end
|
||||
|
||||
# Verify the anchor was updated
|
||||
original_anchor.reload
|
||||
assert_equal original_anchor_id, original_anchor.id
|
||||
assert_equal original_entry_id, original_anchor.entry.id
|
||||
assert_equal 2500, original_anchor.entry.amount
|
||||
assert_not_equal original_balance, original_anchor.entry.amount
|
||||
end
|
||||
|
||||
private
|
||||
def expect_investment_product_processor_calls
|
||||
PlaidAccount::Investments::TransactionsProcessor.any_instance.expects(:process).once
|
||||
|
|
|
@ -17,6 +17,21 @@ class Valuation::NameTest < ActiveSupport::TestCase
|
|||
assert_equal "Opening account value", name.to_s
|
||||
end
|
||||
|
||||
test "generates opening anchor name for Vehicle" do
|
||||
name = Valuation::Name.new("opening_anchor", "Vehicle")
|
||||
assert_equal "Original purchase price", name.to_s
|
||||
end
|
||||
|
||||
test "generates opening anchor name for Crypto" do
|
||||
name = Valuation::Name.new("opening_anchor", "Crypto")
|
||||
assert_equal "Opening account value", name.to_s
|
||||
end
|
||||
|
||||
test "generates opening anchor name for OtherAsset" do
|
||||
name = Valuation::Name.new("opening_anchor", "OtherAsset")
|
||||
assert_equal "Opening account value", name.to_s
|
||||
end
|
||||
|
||||
test "generates opening anchor name for other account types" do
|
||||
name = Valuation::Name.new("opening_anchor", "Depository")
|
||||
assert_equal "Opening balance", name.to_s
|
||||
|
@ -38,6 +53,21 @@ class Valuation::NameTest < ActiveSupport::TestCase
|
|||
assert_equal "Current account value", name.to_s
|
||||
end
|
||||
|
||||
test "generates current anchor name for Vehicle" do
|
||||
name = Valuation::Name.new("current_anchor", "Vehicle")
|
||||
assert_equal "Current market value", name.to_s
|
||||
end
|
||||
|
||||
test "generates current anchor name for Crypto" do
|
||||
name = Valuation::Name.new("current_anchor", "Crypto")
|
||||
assert_equal "Current account value", name.to_s
|
||||
end
|
||||
|
||||
test "generates current anchor name for OtherAsset" do
|
||||
name = Valuation::Name.new("current_anchor", "OtherAsset")
|
||||
assert_equal "Current account value", name.to_s
|
||||
end
|
||||
|
||||
test "generates current anchor name for other account types" do
|
||||
name = Valuation::Name.new("current_anchor", "Depository")
|
||||
assert_equal "Current balance", name.to_s
|
||||
|
@ -54,6 +84,21 @@ class Valuation::NameTest < ActiveSupport::TestCase
|
|||
assert_equal "Manual value update", name.to_s
|
||||
end
|
||||
|
||||
test "generates recon name for Vehicle" do
|
||||
name = Valuation::Name.new("reconciliation", "Vehicle")
|
||||
assert_equal "Manual value update", name.to_s
|
||||
end
|
||||
|
||||
test "generates recon name for Crypto" do
|
||||
name = Valuation::Name.new("reconciliation", "Crypto")
|
||||
assert_equal "Manual value update", name.to_s
|
||||
end
|
||||
|
||||
test "generates recon name for OtherAsset" do
|
||||
name = Valuation::Name.new("reconciliation", "OtherAsset")
|
||||
assert_equal "Manual value update", name.to_s
|
||||
end
|
||||
|
||||
test "generates recon name for Loan" do
|
||||
name = Valuation::Name.new("reconciliation", "Loan")
|
||||
assert_equal "Manual principal update", name.to_s
|
||||
|
|
|
@ -15,17 +15,50 @@ module EntriesTestHelper
|
|||
Entry.create! entry_defaults.merge(entry_attributes)
|
||||
end
|
||||
|
||||
def create_opening_anchor_valuation(account:, balance:, date:)
|
||||
create_valuation(
|
||||
account: account,
|
||||
kind: "opening_anchor",
|
||||
amount: balance,
|
||||
date: date
|
||||
)
|
||||
end
|
||||
|
||||
def create_reconciliation_valuation(account:, balance:, date:)
|
||||
create_valuation(
|
||||
account: account,
|
||||
kind: "reconciliation",
|
||||
amount: balance,
|
||||
date: date
|
||||
)
|
||||
end
|
||||
|
||||
def create_current_anchor_valuation(account:, balance:, date: Date.current)
|
||||
create_valuation(
|
||||
account: account,
|
||||
kind: "current_anchor",
|
||||
amount: balance,
|
||||
date: date
|
||||
)
|
||||
end
|
||||
|
||||
def create_valuation(attributes = {})
|
||||
entry_attributes = attributes.except(:kind)
|
||||
valuation_attributes = attributes.slice(:kind)
|
||||
|
||||
account = attributes[:account] || accounts(:depository)
|
||||
amount = attributes[:amount] || 5000
|
||||
|
||||
entry_defaults = {
|
||||
account: accounts(:depository),
|
||||
account: account,
|
||||
name: "Valuation",
|
||||
date: 1.day.ago.to_date,
|
||||
currency: "USD",
|
||||
amount: 5000,
|
||||
entryable: Valuation.new
|
||||
amount: amount,
|
||||
entryable: Valuation.new({ kind: "reconciliation" }.merge(valuation_attributes))
|
||||
}
|
||||
|
||||
Entry.create! entry_defaults.merge(attributes)
|
||||
Entry.create! entry_defaults.merge(entry_attributes)
|
||||
end
|
||||
|
||||
def create_trade(security, account:, qty:, date:, price: nil, currency: "USD")
|
||||
|
|
152
test/support/ledger_testing_helper.rb
Normal file
152
test/support/ledger_testing_helper.rb
Normal file
|
@ -0,0 +1,152 @@
|
|||
module LedgerTestingHelper
|
||||
def create_account_with_ledger(account:, entries: [], exchange_rates: [], security_prices: [], holdings: [])
|
||||
# Clear all exchange rates and security prices to ensure clean test environment
|
||||
ExchangeRate.destroy_all
|
||||
Security::Price.destroy_all
|
||||
|
||||
# Create account with specified attributes
|
||||
account_attrs = account.except(:type)
|
||||
account_type = account[:type]
|
||||
|
||||
# Create the account
|
||||
created_account = families(:empty).accounts.create!(
|
||||
name: "Test Account",
|
||||
accountable: account_type.new,
|
||||
**account_attrs
|
||||
)
|
||||
|
||||
# Set up exchange rates if provided
|
||||
exchange_rates.each do |rate_data|
|
||||
ExchangeRate.create!(
|
||||
date: rate_data[:date],
|
||||
from_currency: rate_data[:from],
|
||||
to_currency: rate_data[:to],
|
||||
rate: rate_data[:rate]
|
||||
)
|
||||
end
|
||||
|
||||
# Set up security prices if provided
|
||||
security_prices.each do |price_data|
|
||||
security = Security.find_or_create_by!(ticker: price_data[:ticker]) do |s|
|
||||
s.name = price_data[:ticker]
|
||||
end
|
||||
|
||||
Security::Price.create!(
|
||||
security: security,
|
||||
date: price_data[:date],
|
||||
price: price_data[:price],
|
||||
currency: created_account.currency
|
||||
)
|
||||
end
|
||||
|
||||
# Create entries in the order they were specified
|
||||
entries.each do |entry_data|
|
||||
case entry_data[:type]
|
||||
when "current_anchor", "opening_anchor", "reconciliation"
|
||||
# Create valuation entry
|
||||
created_account.entries.create!(
|
||||
name: "Valuation",
|
||||
date: entry_data[:date],
|
||||
amount: entry_data[:balance],
|
||||
currency: entry_data[:currency] || created_account.currency,
|
||||
entryable: Valuation.new(kind: entry_data[:type])
|
||||
)
|
||||
when "transaction"
|
||||
# Use account currency if not specified
|
||||
currency = entry_data[:currency] || created_account.currency
|
||||
|
||||
created_account.entries.create!(
|
||||
name: "Transaction",
|
||||
date: entry_data[:date],
|
||||
amount: entry_data[:amount],
|
||||
currency: currency,
|
||||
entryable: Transaction.new
|
||||
)
|
||||
when "trade"
|
||||
# Find or create security
|
||||
security = Security.find_or_create_by!(ticker: entry_data[:ticker]) do |s|
|
||||
s.name = entry_data[:ticker]
|
||||
end
|
||||
|
||||
# Use account currency if not specified
|
||||
currency = entry_data[:currency] || created_account.currency
|
||||
|
||||
trade = Trade.new(
|
||||
qty: entry_data[:qty],
|
||||
security: security,
|
||||
price: entry_data[:price],
|
||||
currency: currency
|
||||
)
|
||||
|
||||
created_account.entries.create!(
|
||||
name: "Trade",
|
||||
date: entry_data[:date],
|
||||
amount: entry_data[:qty] * entry_data[:price],
|
||||
currency: currency,
|
||||
entryable: trade
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
# Create holdings if provided
|
||||
holdings.each do |holding_data|
|
||||
# Find or create security
|
||||
security = Security.find_or_create_by!(ticker: holding_data[:ticker]) do |s|
|
||||
s.name = holding_data[:ticker]
|
||||
end
|
||||
|
||||
Holding.create!(
|
||||
account: created_account,
|
||||
security: security,
|
||||
date: holding_data[:date],
|
||||
qty: holding_data[:qty],
|
||||
price: holding_data[:price],
|
||||
amount: holding_data[:amount],
|
||||
currency: holding_data[:currency] || created_account.currency
|
||||
)
|
||||
end
|
||||
|
||||
created_account
|
||||
end
|
||||
|
||||
def assert_calculated_ledger_balances(calculated_data:, expected_balances:)
|
||||
# Convert expected balances to a hash for easier lookup
|
||||
expected_hash = expected_balances.to_h do |date, balance_data|
|
||||
[ date.to_date, balance_data ]
|
||||
end
|
||||
|
||||
# Get all unique dates from both calculated and expected data
|
||||
all_dates = (calculated_data.map(&:date) + expected_hash.keys).uniq.sort
|
||||
|
||||
# Check each date
|
||||
all_dates.each do |date|
|
||||
calculated_balance = calculated_data.find { |b| b.date == date }
|
||||
expected = expected_hash[date]
|
||||
|
||||
if expected
|
||||
assert calculated_balance, "Expected balance for #{date} but none was calculated"
|
||||
|
||||
if expected[:balance]
|
||||
assert_equal expected[:balance], calculated_balance.balance.to_d,
|
||||
"Balance mismatch for #{date}"
|
||||
end
|
||||
|
||||
if expected[:cash_balance]
|
||||
assert_equal expected[:cash_balance], calculated_balance.cash_balance.to_d,
|
||||
"Cash balance mismatch for #{date}"
|
||||
end
|
||||
else
|
||||
assert_nil calculated_balance, "Unexpected balance calculated for #{date}"
|
||||
end
|
||||
end
|
||||
|
||||
# Verify we got all expected dates
|
||||
expected_dates = expected_hash.keys.sort
|
||||
calculated_dates = calculated_data.map(&:date).sort
|
||||
|
||||
expected_dates.each do |date|
|
||||
assert_includes calculated_dates, date,
|
||||
"Expected balance for #{date} was not in calculated data"
|
||||
end
|
||||
end
|
||||
end
|
Loading…
Add table
Add a link
Reference in a new issue