2025-03-07 17:35:55 -05:00
require " test_helper "
2025-07-15 11:42:41 -04:00
# The "forward calculator" is used for all **manual** accounts where balance tracking is done through entries and NOT from an external data provider.
2025-04-14 11:40:34 -04:00
class Balance :: ForwardCalculatorTest < ActiveSupport :: TestCase
2025-07-15 11:42:41 -04:00
include LedgerTestingHelper
2025-03-07 17:35:55 -05:00
2025-07-15 11:42:41 -04:00
# ------------------------------------------------------------------------------------------------
# General tests for all account types
# ------------------------------------------------------------------------------------------------
# When syncing forwards, we don't care about the account balance. We generate everything based on entries, starting from 0.
test " no entries sync " do
account = create_account_with_ledger (
account : { type : Depository , balance : 20000 , cash_balance : 20000 , currency : " USD " } ,
entries : [ ]
2025-03-07 17:35:55 -05:00
)
2025-07-15 11:42:41 -04:00
assert_equal 0 , account . balances . count
calculated = Balance :: ForwardCalculator . new ( account ) . calculate
assert_calculated_ledger_balances (
calculated_data : calculated ,
expected_balances : [
[ Date . current , { balance : 0 , cash_balance : 0 } ]
]
)
end
2025-05-06 09:25:49 -04:00
2025-07-15 11:42:41 -04:00
# Our system ensures all manual accounts have an opening anchor (for UX), but we should be able to handle a missing anchor by starting at 0 (i.e. "fresh account with no history")
test " account without opening anchor starts at zero balance " do
account = create_account_with_ledger (
account : { type : Depository , balance : 20000 , cash_balance : 20000 , currency : " USD " } ,
entries : [
{ type : " transaction " , date : 2 . days . ago . to_date , amount : - 1000 }
]
)
2025-05-06 09:25:49 -04:00
2025-07-15 11:42:41 -04:00
calculated = Balance :: ForwardCalculator . new ( account ) . calculate
2025-05-06 09:25:49 -04:00
2025-07-15 11:42:41 -04:00
# 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 } ]
]
)
2025-05-06 09:25:49 -04:00
end
2025-07-15 11:42:41 -04:00
test " reconciliation valuation sets absolute balance before applying subsequent transactions " do
account = create_account_with_ledger (
account : { type : Depository , balance : 20000 , cash_balance : 20000 , currency : " USD " } ,
entries : [
{ type : " reconciliation " , date : 3 . days . ago . to_date , balance : 18000 } ,
{ type : " transaction " , date : 2 . days . ago . to_date , amount : - 1000 }
]
)
2025-03-07 17:35:55 -05:00
2025-07-15 11:42:41 -04:00
calculated = Balance :: ForwardCalculator . new ( account ) . calculate
2025-03-07 17:35:55 -05:00
2025-07-15 11:42:41 -04:00
# 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 } ]
]
)
2025-03-07 17:35:55 -05:00
end
2025-07-15 11:42:41 -04:00
test " cash-only accounts (depository, credit card) use valuations where cash balance equals total balance " do
[ Depository , CreditCard ] . each do | account_type |
account = create_account_with_ledger (
account : { type : account_type , balance : 10000 , cash_balance : 10000 , currency : " USD " } ,
entries : [
{ type : " opening_anchor " , date : 3 . days . ago . to_date , balance : 17000 } ,
{ type : " reconciliation " , date : 2 . days . ago . to_date , balance : 18000 }
]
)
calculated = Balance :: ForwardCalculator . new ( account ) . calculate
assert_calculated_ledger_balances (
calculated_data : calculated ,
expected_balances : [
[ 3 . days . ago . to_date , { balance : 17000 , cash_balance : 17000 } ] ,
[ 2 . days . ago . to_date , { balance : 18000 , cash_balance : 18000 } ]
]
)
end
end
2025-03-07 17:35:55 -05:00
2025-07-15 11:42:41 -04:00
test " non-cash accounts (property, loan) use valuations where cash balance is always zero " do
[ Property , Loan ] . each do | account_type |
account = create_account_with_ledger (
account : { type : account_type , balance : 10000 , cash_balance : 10000 , currency : " USD " } ,
entries : [
{ type : " opening_anchor " , date : 3 . days . ago . to_date , balance : 17000 } ,
{ type : " reconciliation " , date : 2 . days . ago . to_date , balance : 18000 }
]
)
calculated = Balance :: ForwardCalculator . new ( account ) . calculate
assert_calculated_ledger_balances (
calculated_data : calculated ,
expected_balances : [
[ 3 . days . ago . to_date , { balance : 17000 , cash_balance : 0 . 0 } ] ,
[ 2 . days . ago . to_date , { balance : 18000 , cash_balance : 0 . 0 } ]
]
)
end
end
test " mixed accounts (investment) use valuations where cash balance is total minus holdings " do
account = create_account_with_ledger (
account : { type : Investment , balance : 10000 , cash_balance : 10000 , currency : " USD " } ,
entries : [
{ type : " opening_anchor " , date : 3 . days . ago . to_date , balance : 17000 } ,
{ type : " reconciliation " , date : 2 . days . ago . to_date , balance : 18000 }
]
)
2025-03-07 17:35:55 -05:00
2025-07-15 11:42:41 -04:00
# Without holdings, cash balance equals total balance
calculated = Balance :: ForwardCalculator . new ( account ) . calculate
assert_calculated_ledger_balances (
calculated_data : calculated ,
expected_balances : [
[ 3 . days . ago . to_date , { balance : 17000 , cash_balance : 17000 } ] ,
[ 2 . days . ago . to_date , { balance : 18000 , cash_balance : 18000 } ]
]
)
2025-03-07 17:35:55 -05:00
end
2025-07-15 11:42:41 -04:00
# ------------------------------------------------------------------------------------------------
# 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
]
)
2025-03-07 17:35:55 -05:00
2025-07-15 11:42:41 -04:00
calculated = Balance :: ForwardCalculator . new ( account ) . calculate
2025-03-07 17:35:55 -05:00
2025-07-15 11:42:41 -04:00
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 } ]
]
)
2025-03-07 17:35:55 -05:00
end
2025-07-15 11:42:41 -04:00
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
2025-03-07 17:35:55 -05:00
2025-07-15 11:42:41 -04:00
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 } ]
]
)
2025-03-07 17:35:55 -05:00
end
2025-07-15 11:42:41 -04:00
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 }
]
)
2025-03-07 17:35:55 -05:00
2025-07-15 11:42:41 -04:00
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
2025-03-07 17:35:55 -05:00
2025-07-15 11:42:41 -04:00
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 }
]
)
2025-03-07 17:35:55 -05:00
2025-07-15 11:42:41 -04:00
calculated = Balance :: ForwardCalculator . new ( account ) . calculate
2025-03-07 17:35:55 -05:00
2025-07-15 11:42:41 -04:00
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 } ]
]
)
2025-03-07 17:35:55 -05:00
end
2025-05-06 09:25:49 -04:00
2025-07-15 11:42:41 -04:00
# 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
2025-05-06 09:25:49 -04:00
2025-07-15 11:42:41 -04:00
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
2025-05-06 09:25:49 -04:00
2025-07-15 11:42:41 -04:00
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
2025-05-06 09:25:49 -04:00
2025-07-15 11:42:41 -04:00
# ------------------------------------------------------------------------------------------------
# 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 }
]
)
2025-05-06 09:25:49 -04:00
# Given constant prices, overall balance (account value) should be constant
# (the single trade doesn't affect balance; it just alters cash vs. holdings composition)
2025-07-15 11:42:41 -04:00
calculated = Balance :: ForwardCalculator . new ( account ) . calculate
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 } ]
]
)
2025-05-06 09:25:49 -04:00
end
2025-07-15 11:42:41 -04:00
private
2025-05-06 09:25:49 -04:00
2025-07-15 11:42:41 -04:00
def assert_balances ( calculated_data : , expected_balances : )
# Sort calculated data by date to ensure consistent ordering
sorted_data = calculated_data . sort_by ( & :date )
2025-05-06 09:25:49 -04:00
2025-07-15 11:42:41 -04:00
# Extract actual values as [date, { balance:, cash_balance: }]
actual_balances = sorted_data . map do | b |
[ b . date , { balance : b . balance , cash_balance : b . cash_balance } ]
end
2025-05-06 09:25:49 -04:00
2025-07-15 11:42:41 -04:00
assert_equal expected_balances , actual_balances
end
2025-03-07 17:35:55 -05:00
end