1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-07-18 20:59:39 +02:00

Start and end balance anchors for historical account balances (#2455)
Some checks are pending
Publish Docker image / ci (push) Waiting to run
Publish Docker image / Build docker image (push) Blocked by required conditions

* 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:
Zach Gollwitzer 2025-07-15 11:42:41 -04:00 committed by GitHub
parent 9110ab27d2
commit c1d98fe73b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 1903 additions and 355 deletions

View file

@ -1,6 +1,5 @@
class Account < ApplicationRecord class Account < ApplicationRecord
include Syncable, Monetizable, Chartable, Linkable, Enrichable include AASM, Syncable, Monetizable, Chartable, Linkable, Enrichable, Anchorable
include AASM
validates :name, :balance, :currency, presence: true validates :name, :balance, :currency, presence: true
@ -59,26 +58,14 @@ class Account < ApplicationRecord
def create_and_sync(attributes) def create_and_sync(attributes)
attributes[:accountable_attributes] ||= {} # Ensure accountable is created, even if empty attributes[:accountable_attributes] ||= {} # Ensure accountable is created, even if empty
account = new(attributes.merge(cash_balance: attributes[:balance])) 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 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! account.save!
manager = Account::OpeningBalanceManager.new(account)
result = manager.set_opening_balance(balance: initial_balance || account.balance)
raise result.error if result.error
end end
account.sync_later account.sync_later

View 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

View file

@ -18,7 +18,7 @@ class Account::BalanceUpdater
end end
valuation_entry = account.entries.valuations.find_or_initialize_by(date: date) do |entry| valuation_entry = account.entries.valuations.find_or_initialize_by(date: date) do |entry|
entry.entryable = Valuation.new entry.entryable = Valuation.new(kind: "reconciliation")
end end
valuation_entry.amount = balance valuation_entry.amount = balance

View 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

View file

@ -15,4 +15,5 @@ module Account::Linkable
def unlinked? def unlinked?
!linked? !linked?
end end
alias_method :manual?, :unlinked?
end end

View 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

View file

@ -1,4 +1,6 @@
class AccountImport < Import class AccountImport < Import
OpeningBalanceError = Class.new(StandardError)
def import! def import!
transaction do transaction do
rows.each do |row| rows.each do |row|
@ -15,13 +17,13 @@ class AccountImport < Import
account.save! account.save!
account.entries.create!( manager = Account::OpeningBalanceManager.new(account)
amount: row.amount, result = manager.set_opening_balance(balance: row.amount.to_d)
currency: row.currency,
date: 2.years.ago.to_date, # Re-raise since we should never have an error here
name: Valuation.build_opening_anchor_name(account.accountable_type), if result.error
entryable: Valuation.new raise OpeningBalanceError, result.error
) end
end end
end end
end end

View 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

View file

@ -1,61 +1,66 @@
class Balance::ForwardCalculator class Balance::ForwardCalculator < Balance::BaseCalculator
attr_reader :account
def initialize(account)
@account = account
end
def calculate def calculate
Rails.logger.tagged("Balance::ForwardCalculator") do 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
end end
private private
def calculate_balances def calc_start_date
current_cash_balance = 0 account.opening_anchor_date
next_cash_balance = nil
@balances = []
account.start_date.upto(Date.current).each do |date|
entries = sync_cache.get_entries(date)
holdings = sync_cache.get_holdings(date)
holdings_value = holdings.sum(&:amount)
valuation = sync_cache.get_valuation(date)
next_cash_balance = if valuation
valuation.amount - holdings_value
else
calculate_next_balance(current_cash_balance, entries, direction: :forward)
end
@balances << build_balance(date, next_cash_balance, holdings_value)
current_cash_balance = next_cash_balance
end
@balances
end end
def sync_cache def calc_end_date
@sync_cache ||= Balance::SyncCache.new(account) [ account.entries.order(:date).last&.date, account.holdings.order(:date).last&.date ].compact.max || Date.current
end end
def build_balance(date, cash_balance, holdings_value) # Negative entries amount on an "asset" account means, "account value has increased"
Balance.new( # Negative entries amount on a "liability" account means, "account debt has decreased"
account_id: account.id, # Positive entries amount on an "asset" account means, "account value has decreased"
date: date, # Positive entries amount on a "liability" account means, "account debt has increased"
balance: holdings_value + cash_balance, def signed_entry_flows(entries)
cash_balance: cash_balance, entry_flows = entries.sum(&:amount)
currency: account.currency account.asset? ? -entry_flows : entry_flows
)
end end
def calculate_next_balance(prior_balance, transactions, direction: :forward) # Derives cash balance, starting from the start-of-day, applying entries in forward to get the end-of-day balance
flows = transactions.sum(&:amount) def derive_end_cash_balance(start_cash_balance:, date:)
negated = direction == :forward ? account.asset? : account.liability? derive_cash_balance(start_cash_balance, date)
flows *= -1 if negated end
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
end end

View file

@ -1,71 +1,79 @@
class Balance::ReverseCalculator class Balance::ReverseCalculator < Balance::BaseCalculator
attr_reader :account
def initialize(account)
@account = account
end
def calculate def calculate
Rails.logger.tagged("Balance::ReverseCalculator") do 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
end end
private private
def calculate_balances
current_cash_balance = account.cash_balance
previous_cash_balance = nil
@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"
Date.current.downto(account.start_date).map do |date| # Positive entries amount on an "asset" account means, "account value has decreased"
entries = sync_cache.get_entries(date) # Positive entries amount on a "liability" account means, "account debt has increased"
holdings = sync_cache.get_holdings(date) def signed_entry_flows(entries)
holdings_value = holdings.sum(&:amount) entry_flows = entries.sum(&:amount)
valuation = sync_cache.get_valuation(date) account.asset? ? entry_flows : -entry_flows
previous_cash_balance = if valuation
valuation.amount - holdings_value
else
calculate_next_balance(current_cash_balance, entries, direction: :reverse)
end
if valuation.present?
@balances << build_balance(date, previous_cash_balance, holdings_value)
else
# If date is today, we don't distinguish cash vs. total since provider's are inconsistent with treatment
# of the cash component. Instead, just set the balance equal to the "total value" reported by the provider
if date == Date.current
@balances << build_balance(date, account.cash_balance, account.balance - account.cash_balance)
else
@balances << build_balance(date, current_cash_balance, holdings_value)
end
end
current_cash_balance = previous_cash_balance
end
@balances
end end
def sync_cache # Reverse syncs are a bit different than forward syncs because we do not allow "reconciliation" valuations
@sync_cache ||= Balance::SyncCache.new(account) # 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 end
def build_balance(date, cash_balance, holdings_value) # Alias method, for algorithmic clarity
Balance.new( # Derives cash balance, starting from the end-of-day, applying entries in reverse to get the start-of-day balance
account_id: account.id, def derive_start_cash_balance(end_cash_balance:, date:)
date: date, derive_cash_balance(end_cash_balance, date)
balance: holdings_value + cash_balance,
cash_balance: cash_balance,
currency: account.currency
)
end end
def calculate_next_balance(prior_balance, transactions, direction: :forward) # Alias method, for algorithmic clarity
flows = transactions.sum(&:amount) # Derives non-cash balance, starting from the end-of-day, applying entries in reverse to get the start-of-day balance
negated = direction == :forward ? account.asset? : account.liability? def derive_start_non_cash_balance(end_non_cash_balance:, date:)
flows *= -1 if negated derive_non_cash_balance(end_non_cash_balance, date, direction: :reverse)
prior_balance + flows
end end
end end

View file

@ -3,8 +3,8 @@ class Balance::SyncCache
@account = account @account = account
end end
def get_valuation(date) def get_reconciliation_valuation(date)
converted_entries.find { |e| e.date == date && e.valuation? } converted_entries.find { |e| e.date == date && e.valuation? && e.valuation.reconciliation? }
end end
def get_holdings(date) def get_holdings(date)

View file

@ -18,7 +18,7 @@ class Balance::TrendCalculator
BalanceTrend.new( BalanceTrend.new(
trend: Trend.new( trend: Trend.new(
current: Money.new(balance.balance, balance.currency), 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 favorable_direction: balance.account.favorable_direction
), ),
cash: Money.new(balance.cash_balance, balance.currency), cash: Money.new(balance.cash_balance, balance.currency),

View file

@ -47,7 +47,7 @@ module Syncable
end end
def sync_error def sync_error
latest_sync&.error latest_sync&.error || latest_sync&.children&.map(&:error)&.compact&.first
end end
def last_synced_at def last_synced_at

View file

@ -1174,7 +1174,7 @@ class Demo::Generator
# Property valuations (these accounts are valued, not transaction-driven) # Property valuations (these accounts are valued, not transaction-driven)
@home.entries.create!( @home.entries.create!(
entryable: Valuation.new, entryable: Valuation.new(kind: "current_anchor"),
amount: 350_000, amount: 350_000,
name: Valuation.build_current_anchor_name(@home.accountable_type), name: Valuation.build_current_anchor_name(@home.accountable_type),
currency: "USD", currency: "USD",
@ -1183,7 +1183,7 @@ class Demo::Generator
# Vehicle valuations (these depreciate over time) # Vehicle valuations (these depreciate over time)
@honda_accord.entries.create!( @honda_accord.entries.create!(
entryable: Valuation.new, entryable: Valuation.new(kind: "current_anchor"),
amount: 18_000, amount: 18_000,
name: Valuation.build_current_anchor_name(@honda_accord.accountable_type), name: Valuation.build_current_anchor_name(@honda_accord.accountable_type),
currency: "USD", currency: "USD",
@ -1191,7 +1191,7 @@ class Demo::Generator
) )
@tesla_model3.entries.create!( @tesla_model3.entries.create!(
entryable: Valuation.new, entryable: Valuation.new(kind: "current_anchor"),
amount: 4_500, amount: 4_500,
name: Valuation.build_current_anchor_name(@tesla_model3.accountable_type), name: Valuation.build_current_anchor_name(@tesla_model3.accountable_type),
currency: "USD", currency: "USD",
@ -1199,7 +1199,7 @@ class Demo::Generator
) )
@jewelry.entries.create!( @jewelry.entries.create!(
entryable: Valuation.new, entryable: Valuation.new(kind: "reconciliation"),
amount: 2000, amount: 2000,
name: Valuation.build_reconciliation_name(@jewelry.accountable_type), name: Valuation.build_reconciliation_name(@jewelry.accountable_type),
currency: "USD", currency: "USD",
@ -1207,7 +1207,7 @@ class Demo::Generator
) )
@personal_loc.entries.create!( @personal_loc.entries.create!(
entryable: Valuation.new, entryable: Valuation.new(kind: "reconciliation"),
amount: 800, amount: 800,
name: Valuation.build_reconciliation_name(@personal_loc.accountable_type), name: Valuation.build_reconciliation_name(@personal_loc.accountable_type),
currency: "USD", currency: "USD",

View file

@ -51,6 +51,13 @@ class PlaidAccount::Processor
) )
account.save! 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
end end

View file

@ -1,6 +1,12 @@
class Valuation < ApplicationRecord class Valuation < ApplicationRecord
include Entryable include Entryable
enum :kind, {
reconciliation: "reconciliation",
opening_anchor: "opening_anchor",
current_anchor: "current_anchor"
}, validate: true, default: "reconciliation"
class << self class << self
def build_reconciliation_name(accountable_type) def build_reconciliation_name(accountable_type)
Valuation::Name.new("reconciliation", accountable_type).to_s Valuation::Name.new("reconciliation", accountable_type).to_s
@ -14,10 +20,4 @@ class Valuation < ApplicationRecord
Valuation::Name.new("current_anchor", accountable_type).to_s Valuation::Name.new("current_anchor", accountable_type).to_s
end end
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 end

View file

@ -20,11 +20,11 @@ class Valuation::Name
def opening_anchor_name def opening_anchor_name
case accountable_type case accountable_type
when "Property" when "Property", "Vehicle"
"Original purchase price" "Original purchase price"
when "Loan" when "Loan"
"Original principal" "Original principal"
when "Investment" when "Investment", "Crypto", "OtherAsset"
"Opening account value" "Opening account value"
else else
"Opening balance" "Opening balance"
@ -33,11 +33,11 @@ class Valuation::Name
def current_anchor_name def current_anchor_name
case accountable_type case accountable_type
when "Property" when "Property", "Vehicle"
"Current market value" "Current market value"
when "Loan" when "Loan"
"Current loan balance" "Current loan balance"
when "Investment" when "Investment", "Crypto", "OtherAsset"
"Current account value" "Current account value"
else else
"Current balance" "Current balance"
@ -46,7 +46,7 @@ class Valuation::Name
def recon_name def recon_name
case accountable_type case accountable_type
when "Property", "Investment" when "Property", "Investment", "Vehicle", "Crypto", "OtherAsset"
"Manual value update" "Manual value update"
when "Loan" when "Loan"
"Manual principal update" "Manual principal update"

View 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
View file

@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # 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 # These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto" enable_extension "pgcrypto"
enable_extension "plpgsql" 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 "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.jsonb "locked_attributes", default: {} t.jsonb "locked_attributes", default: {}
t.string "kind", default: "reconciliation", null: false
end end
create_table "vehicles", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| create_table "vehicles", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|

View file

@ -111,4 +111,47 @@ namespace :data_migration do
puts "✅ Duplicate security migration complete." puts "✅ Duplicate security migration complete."
end 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 end

View file

@ -11,8 +11,8 @@ class CreditCardsControllerTest < ActionDispatch::IntegrationTest
test "creates with credit card details" do test "creates with credit card details" do
assert_difference -> { Account.count } => 1, assert_difference -> { Account.count } => 1,
-> { CreditCard.count } => 1, -> { CreditCard.count } => 1,
-> { Valuation.count } => 2, -> { Valuation.count } => 1,
-> { Entry.count } => 2 do -> { Entry.count } => 1 do
post credit_cards_path, params: { post credit_cards_path, params: {
account: { account: {
name: "New Credit Card", name: "New Credit Card",

View file

@ -11,8 +11,8 @@ class LoansControllerTest < ActionDispatch::IntegrationTest
test "creates with loan details" do test "creates with loan details" do
assert_difference -> { Account.count } => 1, assert_difference -> { Account.count } => 1,
-> { Loan.count } => 1, -> { Loan.count } => 1,
-> { Valuation.count } => 2, -> { Valuation.count } => 1,
-> { Entry.count } => 2 do -> { Entry.count } => 1 do
post loans_path, params: { post loans_path, params: {
account: { account: {
name: "New Loan", name: "New Loan",

View file

@ -11,8 +11,8 @@ class VehiclesControllerTest < ActionDispatch::IntegrationTest
test "creates with vehicle details" do test "creates with vehicle details" do
assert_difference -> { Account.count } => 1, assert_difference -> { Account.count } => 1,
-> { Vehicle.count } => 1, -> { Vehicle.count } => 1,
-> { Valuation.count } => 2, -> { Valuation.count } => 1,
-> { Entry.count } => 2 do -> { Entry.count } => 1 do
post vehicles_path, params: { post vehicles_path, params: {
account: { account: {
name: "Vehicle", name: "Vehicle",

View file

@ -7,3 +7,8 @@ trade:
family: dylan_family family: dylan_family
type: TradeImport type: TradeImport
status: pending status: pending
account:
family: dylan_family
type: AccountImport
status: pending

View file

@ -1,2 +1,2 @@
one: { } one:
two: { } kind: reconciliation

View 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

View file

@ -17,7 +17,7 @@ class EntryTest < ActiveSupport::TestCase
existing_valuation = entries :valuation existing_valuation = entries :valuation
new_valuation = Entry.new \ new_valuation = Entry.new \
entryable: Valuation.new, entryable: Valuation.new(kind: "reconciliation"),
account: existing_valuation.account, account: existing_valuation.account,
date: existing_valuation.date, # invalid date: existing_valuation.date, # invalid
currency: existing_valuation.currency, currency: existing_valuation.currency,

View 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

View 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

View file

@ -1,129 +1,349 @@
require "test_helper" 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 class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
include EntriesTestHelper include LedgerTestingHelper
setup do # ------------------------------------------------------------------------------------------------
@account = families(:empty).accounts.create!( # General tests for all account types
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
# When syncing forwards, we don't care about the account balance. We generate everything based on entries, starting from 0. # 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 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 ] assert_equal 0, account.balances.count
calculated = Balance::ForwardCalculator.new(@account).calculate
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 end
test "valuations sync" do # 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")
create_valuation(account: @account, date: 4.days.ago.to_date, amount: 17000) test "account without opening anchor starts at zero balance" do
create_valuation(account: @account, date: 2.days.ago.to_date, amount: 19000) 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
calculated = Balance::ForwardCalculator.new(@account).calculate.sort_by(&:date).map(&:balance)
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 end
test "transactions sync" do test "reconciliation valuation sets absolute balance before applying subsequent transactions" do
create_transaction(account: @account, date: 4.days.ago.to_date, amount: -500) # income account = create_account_with_ledger(
create_transaction(account: @account, date: 2.days.ago.to_date, amount: 100) # expense 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
calculated = Balance::ForwardCalculator.new(@account).calculate.sort_by(&:date).map(&:balance)
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 end
test "multi-entry sync" do test "cash-only accounts (depository, credit card) use valuations where cash balance equals total balance" do
create_transaction(account: @account, date: 8.days.ago.to_date, amount: -5000) [ Depository, CreditCard ].each do |account_type|
create_valuation(account: @account, date: 6.days.ago.to_date, amount: 17000) account = create_account_with_ledger(
create_transaction(account: @account, date: 6.days.ago.to_date, amount: -500) account: { type: account_type, balance: 10000, cash_balance: 10000, currency: "USD" },
create_transaction(account: @account, date: 4.days.ago.to_date, amount: -500) entries: [
create_valuation(account: @account, date: 3.days.ago.to_date, amount: 17000) { type: "opening_anchor", date: 3.days.ago.to_date, balance: 17000 },
create_transaction(account: @account, date: 1.day.ago.to_date, amount: 100) { 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
calculated = Balance::ForwardCalculator.new(@account).calculate.sort_by(&:date).map(&:balance)
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 end
test "multi-currency sync" do test "non-cash accounts (property, loan) use valuations where cash balance is always zero" do
ExchangeRate.create! date: 1.day.ago.to_date, from_currency: "EUR", to_currency: "USD", rate: 1.2 [ 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") calculated = Balance::ForwardCalculator.new(account).calculate
create_transaction(account: @account, date: 2.days.ago.to_date, amount: -300, currency: "USD")
# Transaction in different currency than the account's main currency assert_calculated_ledger_balances(
create_transaction(account: @account, date: 1.day.ago.to_date, amount: -500, currency: "EUR") # €500 * 1.2 = $600 calculated_data: calculated,
expected_balances: [
expected = [ 0, 100, 400, 1000, 1000 ] [ 3.days.ago.to_date, { balance: 17000, cash_balance: 0.0 } ],
calculated = Balance::ForwardCalculator.new(@account).calculate.sort_by(&:date).map(&:balance) [ 2.days.ago.to_date, { balance: 18000, cash_balance: 0.0 } ]
]
assert_equal expected, calculated )
end
end end
test "holdings and trades sync" do test "mixed accounts (investment) use valuations where cash balance is total minus holdings" do
aapl = securities(:aapl) 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 }
]
)
# Account starts at a value of $5000 # Without holdings, cash balance equals total balance
create_valuation(account: @account, date: 2.days.ago.to_date, amount: 5000) calculated = Balance::ForwardCalculator.new(account).calculate
# Share purchase reduces cash balance by $1000, but keeps overall balance same assert_calculated_ledger_balances(
create_trade(aapl, account: @account, qty: 10, date: 1.day.ago.to_date, price: 100) 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
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") # 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
{ 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 }
]
)
calculated = Balance::ForwardCalculator.new(account).calculate
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
# 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 }
]
)
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
{ 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 # Given constant prices, overall balance (account value) should be constant
# (the single trade doesn't affect balance; it just alters cash vs. holdings composition) # (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
calculated = Balance::ForwardCalculator.new(@account).calculate.sort_by(&:date).map(&:balance)
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 end
# Balance calculator is entirely reliant on HoldingCalculator and respects whatever holding records it creates. private
test "holdings are additive to total balance" do
aapl = securities(:aapl)
# Account starts at a value of $5000 def assert_balances(calculated_data:, expected_balances:)
create_valuation(account: @account, date: 2.days.ago.to_date, amount: 5000) # 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 # Extract actual values as [date, { balance:, cash_balance: }]
Holding.create!(date: 1.day.ago.to_date, account: @account, security: aapl, qty: 10, price: 100, amount: 1000, currency: "USD") actual_balances = sorted_data.map do |b|
Holding.create!(date: Date.current, account: @account, security: aapl, qty: 10, price: 100, amount: 1000, currency: "USD") [ 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 assert_equal expected_balances, actual_balances
expected = [ 0, 5000, 6000, 6000 ] end
calculated = Balance::ForwardCalculator.new(@account).calculate.sort_by(&:date).map(&:balance)
assert_equal expected, calculated
end
end end

View file

@ -1,142 +1,279 @@
require "test_helper" require "test_helper"
class Balance::ReverseCalculatorTest < ActiveSupport::TestCase class Balance::ReverseCalculatorTest < ActiveSupport::TestCase
include EntriesTestHelper include LedgerTestingHelper
setup do # When syncing backwards, we start with the account balance and generate everything from there.
@account = families(:empty).accounts.create!( test "when missing anchor and no entries, falls back to cached account balance" do
name: "Test", account = create_account_with_ledger(
balance: 20000, account: { type: Depository, balance: 20000, cash_balance: 20000, currency: "USD" },
cash_balance: 20000, entries: []
currency: "USD", )
accountable: Investment.new
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 end
# When syncing backwards, we start with the account balance and generate everything from there. # An artificial constraint we put on the reverse sync because it's confusing in both the code and the UI
test "no entries sync" do # to think about how an absolute "Valuation" affects balances when syncing backwards. Furthermore, since
assert_equal 0, @account.balances.count # 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 end
test "balance generation respects user timezone and last generated date is current user date" do # Investment account balances are made of two components: cash and holdings.
# Simulate user in EST timezone test "anchors on investment accounts calculate cash balance dynamically based on holdings value" do
Time.use_zone("America/New_York") do account = create_account_with_ledger(
# Set current time to 1am UTC on Jan 5, 2025 account: { type: Investment, balance: 20000, cash_balance: 10000, currency: "USD" },
# This would be 8pm EST on Jan 4, 2025 (user's time, and the last date we should generate balances for) entries: [
travel_to Time.utc(2025, 01, 05, 1, 0, 0) { 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 ] ] assert_calculated_ledger_balances(
calculated = Balance::ReverseCalculator.new(@account).calculate 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
assert_equal expected, calculated.sort_by(&:date).map { |b| [ b.date.to_s, b.balance ] } 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
]
)
calculated = Balance::ReverseCalculator.new(account).calculate
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 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)
]
)
calculated = Balance::ReverseCalculator.new(account).calculate
# 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
# 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 }
]
)
calculated = Balance::ReverseCalculator.new(account).calculate
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
end 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)
expected = [ 17000, 17000, 19000, 19000, 20000, 20000 ]
calculated = Balance::ReverseCalculator.new(@account).calculate.sort_by(&:date).map(&:balance)
assert_equal expected, calculated
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
expected = [ 19600, 20100, 20100, 20000, 20000, 20000 ]
calculated = Balance::ReverseCalculator.new(@account).calculate.sort_by(&:date).map(&:balance)
assert_equal expected, calculated
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)
expected = [ 12000, 17000, 17000, 17000, 16500, 17000, 17000, 20100, 20000, 20000 ]
calculated = Balance::ReverseCalculator.new(@account).calculate.sort_by(&:date).map(&:balance)
assert_equal expected, calculated
end
# When syncing backwards, trades from the past should NOT affect the current balance or previous balances. # 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 # They should only affect the *cash* component of the historical balances
test "holdings and trades sync" do test "holdings and trades sync" do
aapl = securities(:aapl)
# Account starts with $20,000 total value, $19,000 cash, $1,000 in holdings # 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
{ 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 }
]
)
# Bought 10 AAPL shares 1 day ago, so cash is $19,000, $1,000 in holdings, total value is $20,000 calculated = Balance::ReverseCalculator.new(account).calculate
create_trade(aapl, account: @account, qty: 10, date: 1.day.ago.to_date, price: 100)
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")
# Given constant prices, overall balance (account value) should be constant # Given constant prices, overall balance (account value) should be constant
# (the single trade doesn't affect balance; it just alters cash vs. holdings composition) # (the single trade doesn't affect balance; it just alters cash vs. holdings composition)
expected = [ 20000, 20000, 20000 ] assert_calculated_ledger_balances(
calculated = Balance::ReverseCalculator.new(@account).calculate.sort_by(&:date).map(&:balance) calculated_data: calculated,
expected_balances: [
assert_equal expected, calculated [ 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 end
# A common scenario with Plaid is they'll give us holding records for today, but no trade history for some of them. # 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. # 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 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 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) calculated = Balance::ReverseCalculator.new(account).calculate
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)
# A holding *without* trade history (5 shares of MSFT, no trade history, results in 1 holding) assert_calculated_ledger_balances(
# We assume if no history is provided, this holding has existed since beginning of account calculated_data: calculated,
Holding.create!(date: Date.current, account: @account, security: msft, qty: 5, price: 100, amount: 500, currency: "USD") expected_balances: [
Holding.create!(date: 1.day.ago.to_date, account: @account, security: msft, qty: 5, price: 100, amount: 500, currency: "USD") [ Date.current, { balance: 20000, cash_balance: 19000 } ], # Current: $19k cash + $1k holdings ($500 MSFT, $500 AAPL)
Holding.create!(date: 2.days.ago.to_date, account: @account, security: msft, qty: 5, price: 100, amount: 500, currency: "USD") [ 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
expected = [ 20000, 20000, 20000 ] ]
calculated = Balance::ReverseCalculator.new(@account).calculate.sort_by(&:date).map(&:balance) )
assert_equal expected, calculated
end end
test "uses provider reported holdings and cash value on current day" do test "uses provider reported holdings and cash value on current day" do
aapl = securities(:aapl)
# Implied holdings value of $1,000 from provider # 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) calculated = Balance::ReverseCalculator.new(account).calculate
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")
# Today reports the provider value. Yesterday, provider won't give us any data, so we MUST look at the generated holdings value assert_calculated_ledger_balances(
# to calculate the end balance ($19,000 cash + $2,000 holdings = $21,000 total value) calculated_data: calculated,
expected = [ 21000, 20000 ] 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
calculated = Balance::ReverseCalculator.new(@account).calculate.sort_by(&:date).map(&:balance) # 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 } ],
assert_equal expected, calculated [ 1.day.ago, { balance: 20000, cash_balance: 18000 } ],
[ 2.days.ago, { balance: 15000, cash_balance: 15000 } ] # Opening anchor sets absolute balance
]
)
end end
end end

View file

@ -94,10 +94,21 @@ class PlaidAccount::ProcessorTest < ActiveSupport::TestCase
test "calculates balance using BalanceCalculator for investment accounts" do test "calculates balance using BalanceCalculator for investment accounts" do
@plaid_account.update!(plaid_type: "investment") @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::Investments::BalanceCalculator.any_instance.expects(:cash_balance).returns(1000).once
PlaidAccount::Processor.new(@plaid_account).process 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 end
test "processes credit liability data" do test "processes credit liability data" do
@ -142,6 +153,76 @@ class PlaidAccount::ProcessorTest < ActiveSupport::TestCase
PlaidAccount::Processor.new(@plaid_account).process PlaidAccount::Processor.new(@plaid_account).process
end 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 private
def expect_investment_product_processor_calls def expect_investment_product_processor_calls
PlaidAccount::Investments::TransactionsProcessor.any_instance.expects(:process).once PlaidAccount::Investments::TransactionsProcessor.any_instance.expects(:process).once

View file

@ -17,6 +17,21 @@ class Valuation::NameTest < ActiveSupport::TestCase
assert_equal "Opening account value", name.to_s assert_equal "Opening account value", name.to_s
end 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 test "generates opening anchor name for other account types" do
name = Valuation::Name.new("opening_anchor", "Depository") name = Valuation::Name.new("opening_anchor", "Depository")
assert_equal "Opening balance", name.to_s assert_equal "Opening balance", name.to_s
@ -38,6 +53,21 @@ class Valuation::NameTest < ActiveSupport::TestCase
assert_equal "Current account value", name.to_s assert_equal "Current account value", name.to_s
end 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 test "generates current anchor name for other account types" do
name = Valuation::Name.new("current_anchor", "Depository") name = Valuation::Name.new("current_anchor", "Depository")
assert_equal "Current balance", name.to_s assert_equal "Current balance", name.to_s
@ -54,6 +84,21 @@ class Valuation::NameTest < ActiveSupport::TestCase
assert_equal "Manual value update", name.to_s assert_equal "Manual value update", name.to_s
end 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 test "generates recon name for Loan" do
name = Valuation::Name.new("reconciliation", "Loan") name = Valuation::Name.new("reconciliation", "Loan")
assert_equal "Manual principal update", name.to_s assert_equal "Manual principal update", name.to_s

View file

@ -15,17 +15,50 @@ module EntriesTestHelper
Entry.create! entry_defaults.merge(entry_attributes) Entry.create! entry_defaults.merge(entry_attributes)
end 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 = {}) 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 = { entry_defaults = {
account: accounts(:depository), account: account,
name: "Valuation", name: "Valuation",
date: 1.day.ago.to_date, date: 1.day.ago.to_date,
currency: "USD", currency: "USD",
amount: 5000, amount: amount,
entryable: Valuation.new entryable: Valuation.new({ kind: "reconciliation" }.merge(valuation_attributes))
} }
Entry.create! entry_defaults.merge(attributes) Entry.create! entry_defaults.merge(entry_attributes)
end end
def create_trade(security, account:, qty:, date:, price: nil, currency: "USD") def create_trade(security, account:, qty:, date:, price: nil, currency: "USD")

View 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