mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-08-07 06:25:19 +02:00
Start and end balance anchors for historical account balances (#2455)
* Add kind field to valuation * Fix schema conflict * Add kind to valuation * Scaffold opening balance manager * Opening balance manager implementation * Update account import to use opening balance manager + tests * Update account to use opening balance manager * Fix test assertions, usage of current balance manager * Lint fixes * Add Opening Balance manager, add tests to forward calculator * Add credit card to "all cash" designation * Simplify valuation model * Add current balance manager with tests * Add current balance logic to reverse calculator and plaid sync * Tweaks to initial calc logic * Ledger testing helper, tweak assertions for reverse calculator * Update test assertions * Extract balance transformer, simplify calculators * Algo simplifications * Final tweaks to calculators * Cleanup * Fix error, propagate sync errors up to parent * Update migration script, valuation naming
This commit is contained in:
parent
9110ab27d2
commit
c1d98fe73b
35 changed files with 1903 additions and 355 deletions
|
@ -1,129 +1,349 @@
|
|||
require "test_helper"
|
||||
|
||||
# The "forward calculator" is used for all **manual** accounts where balance tracking is done through entries and NOT from an external data provider.
|
||||
class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
|
||||
include EntriesTestHelper
|
||||
include LedgerTestingHelper
|
||||
|
||||
setup do
|
||||
@account = families(:empty).accounts.create!(
|
||||
name: "Test",
|
||||
balance: 20000,
|
||||
cash_balance: 20000,
|
||||
currency: "USD",
|
||||
accountable: Investment.new
|
||||
)
|
||||
end
|
||||
|
||||
test "balance generation respects user timezone and last generated date is current user date" do
|
||||
# Simulate user in EST timezone
|
||||
Time.use_zone("America/New_York") do
|
||||
# Set current time to 1am UTC on Jan 5, 2025
|
||||
# This would be 8pm EST on Jan 4, 2025 (user's time, and the last date we should generate balances for)
|
||||
travel_to Time.utc(2025, 01, 05, 1, 0, 0)
|
||||
|
||||
# Create a valuation for Jan 3, 2025
|
||||
create_valuation(account: @account, date: "2025-01-03", amount: 17000)
|
||||
|
||||
expected = [ [ "2025-01-02", 0 ], [ "2025-01-03", 17000 ], [ "2025-01-04", 17000 ] ]
|
||||
calculated = Balance::ForwardCalculator.new(@account).calculate
|
||||
|
||||
assert_equal expected, calculated.map { |b| [ b.date.to_s, b.balance ] }
|
||||
end
|
||||
end
|
||||
# ------------------------------------------------------------------------------------------------
|
||||
# General tests for all account types
|
||||
# ------------------------------------------------------------------------------------------------
|
||||
|
||||
# When syncing forwards, we don't care about the account balance. We generate everything based on entries, starting from 0.
|
||||
test "no entries sync" do
|
||||
assert_equal 0, @account.balances.count
|
||||
account = create_account_with_ledger(
|
||||
account: { type: Depository, balance: 20000, cash_balance: 20000, currency: "USD" },
|
||||
entries: []
|
||||
)
|
||||
|
||||
expected = [ 0, 0 ]
|
||||
calculated = Balance::ForwardCalculator.new(@account).calculate
|
||||
assert_equal 0, account.balances.count
|
||||
|
||||
assert_equal expected, calculated.map(&:balance)
|
||||
calculated = Balance::ForwardCalculator.new(account).calculate
|
||||
|
||||
assert_calculated_ledger_balances(
|
||||
calculated_data: calculated,
|
||||
expected_balances: [
|
||||
[ Date.current, { balance: 0, cash_balance: 0 } ]
|
||||
]
|
||||
)
|
||||
end
|
||||
|
||||
test "valuations sync" do
|
||||
create_valuation(account: @account, date: 4.days.ago.to_date, amount: 17000)
|
||||
create_valuation(account: @account, date: 2.days.ago.to_date, amount: 19000)
|
||||
# Our system ensures all manual accounts have an opening anchor (for UX), but we should be able to handle a missing anchor by starting at 0 (i.e. "fresh account with no history")
|
||||
test "account without opening anchor starts at zero balance" do
|
||||
account = create_account_with_ledger(
|
||||
account: { type: Depository, balance: 20000, cash_balance: 20000, currency: "USD" },
|
||||
entries: [
|
||||
{ type: "transaction", date: 2.days.ago.to_date, amount: -1000 }
|
||||
]
|
||||
)
|
||||
|
||||
expected = [ 0, 17000, 17000, 19000, 19000, 19000 ]
|
||||
calculated = Balance::ForwardCalculator.new(@account).calculate.sort_by(&:date).map(&:balance)
|
||||
calculated = Balance::ForwardCalculator.new(account).calculate
|
||||
|
||||
assert_equal expected, calculated
|
||||
# Since we start at 0, this transaction (inflow) simply increases balance from 0 -> 1000
|
||||
assert_calculated_ledger_balances(
|
||||
calculated_data: calculated,
|
||||
expected_balances: [
|
||||
[ 3.days.ago.to_date, { balance: 0, cash_balance: 0 } ],
|
||||
[ 2.days.ago.to_date, { balance: 1000, cash_balance: 1000 } ]
|
||||
]
|
||||
)
|
||||
end
|
||||
|
||||
test "transactions sync" do
|
||||
create_transaction(account: @account, date: 4.days.ago.to_date, amount: -500) # income
|
||||
create_transaction(account: @account, date: 2.days.ago.to_date, amount: 100) # expense
|
||||
test "reconciliation valuation sets absolute balance before applying subsequent transactions" do
|
||||
account = create_account_with_ledger(
|
||||
account: { type: Depository, balance: 20000, cash_balance: 20000, currency: "USD" },
|
||||
entries: [
|
||||
{ type: "reconciliation", date: 3.days.ago.to_date, balance: 18000 },
|
||||
{ type: "transaction", date: 2.days.ago.to_date, amount: -1000 }
|
||||
]
|
||||
)
|
||||
|
||||
expected = [ 0, 500, 500, 400, 400, 400 ]
|
||||
calculated = Balance::ForwardCalculator.new(@account).calculate.sort_by(&:date).map(&:balance)
|
||||
calculated = Balance::ForwardCalculator.new(account).calculate
|
||||
|
||||
assert_equal expected, calculated
|
||||
# First valuation sets balance to 18000, then transaction increases balance to 19000
|
||||
assert_calculated_ledger_balances(
|
||||
calculated_data: calculated,
|
||||
expected_balances: [
|
||||
[ 3.days.ago.to_date, { balance: 18000, cash_balance: 18000 } ],
|
||||
[ 2.days.ago.to_date, { balance: 19000, cash_balance: 19000 } ]
|
||||
]
|
||||
)
|
||||
end
|
||||
|
||||
test "multi-entry sync" do
|
||||
create_transaction(account: @account, date: 8.days.ago.to_date, amount: -5000)
|
||||
create_valuation(account: @account, date: 6.days.ago.to_date, amount: 17000)
|
||||
create_transaction(account: @account, date: 6.days.ago.to_date, amount: -500)
|
||||
create_transaction(account: @account, date: 4.days.ago.to_date, amount: -500)
|
||||
create_valuation(account: @account, date: 3.days.ago.to_date, amount: 17000)
|
||||
create_transaction(account: @account, date: 1.day.ago.to_date, amount: 100)
|
||||
test "cash-only accounts (depository, credit card) use valuations where cash balance equals total balance" do
|
||||
[ Depository, CreditCard ].each do |account_type|
|
||||
account = create_account_with_ledger(
|
||||
account: { type: account_type, balance: 10000, cash_balance: 10000, currency: "USD" },
|
||||
entries: [
|
||||
{ type: "opening_anchor", date: 3.days.ago.to_date, balance: 17000 },
|
||||
{ type: "reconciliation", date: 2.days.ago.to_date, balance: 18000 }
|
||||
]
|
||||
)
|
||||
|
||||
expected = [ 0, 5000, 5000, 17000, 17000, 17500, 17000, 17000, 16900, 16900 ]
|
||||
calculated = Balance::ForwardCalculator.new(@account).calculate.sort_by(&:date).map(&:balance)
|
||||
calculated = Balance::ForwardCalculator.new(account).calculate
|
||||
|
||||
assert_equal expected, calculated
|
||||
assert_calculated_ledger_balances(
|
||||
calculated_data: calculated,
|
||||
expected_balances: [
|
||||
[ 3.days.ago.to_date, { balance: 17000, cash_balance: 17000 } ],
|
||||
[ 2.days.ago.to_date, { balance: 18000, cash_balance: 18000 } ]
|
||||
]
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
test "multi-currency sync" do
|
||||
ExchangeRate.create! date: 1.day.ago.to_date, from_currency: "EUR", to_currency: "USD", rate: 1.2
|
||||
test "non-cash accounts (property, loan) use valuations where cash balance is always zero" do
|
||||
[ Property, Loan ].each do |account_type|
|
||||
account = create_account_with_ledger(
|
||||
account: { type: account_type, balance: 10000, cash_balance: 10000, currency: "USD" },
|
||||
entries: [
|
||||
{ type: "opening_anchor", date: 3.days.ago.to_date, balance: 17000 },
|
||||
{ type: "reconciliation", date: 2.days.ago.to_date, balance: 18000 }
|
||||
]
|
||||
)
|
||||
|
||||
create_transaction(account: @account, date: 3.days.ago.to_date, amount: -100, currency: "USD")
|
||||
create_transaction(account: @account, date: 2.days.ago.to_date, amount: -300, currency: "USD")
|
||||
calculated = Balance::ForwardCalculator.new(account).calculate
|
||||
|
||||
# Transaction in different currency than the account's main currency
|
||||
create_transaction(account: @account, date: 1.day.ago.to_date, amount: -500, currency: "EUR") # €500 * 1.2 = $600
|
||||
|
||||
expected = [ 0, 100, 400, 1000, 1000 ]
|
||||
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: 0.0 } ],
|
||||
[ 2.days.ago.to_date, { balance: 18000, cash_balance: 0.0 } ]
|
||||
]
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
test "holdings and trades sync" do
|
||||
aapl = securities(:aapl)
|
||||
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 }
|
||||
]
|
||||
)
|
||||
|
||||
# Account starts at a value of $5000
|
||||
create_valuation(account: @account, date: 2.days.ago.to_date, amount: 5000)
|
||||
# Without holdings, cash balance equals total balance
|
||||
calculated = Balance::ForwardCalculator.new(account).calculate
|
||||
|
||||
# Share purchase reduces cash balance by $1000, but keeps overall balance same
|
||||
create_trade(aapl, account: @account, qty: 10, date: 1.day.ago.to_date, price: 100)
|
||||
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
|
||||
|
||||
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
|
||||
# (the single trade doesn't affect balance; it just alters cash vs. holdings composition)
|
||||
expected = [ 0, 5000, 5000, 5000 ]
|
||||
calculated = Balance::ForwardCalculator.new(@account).calculate.sort_by(&:date).map(&:balance)
|
||||
calculated = Balance::ForwardCalculator.new(account).calculate
|
||||
|
||||
assert_equal expected, calculated
|
||||
assert_calculated_ledger_balances(
|
||||
calculated_data: calculated,
|
||||
expected_balances: [
|
||||
[ 3.days.ago.to_date, { balance: 5000, cash_balance: 5000 } ],
|
||||
[ 2.days.ago.to_date, { balance: 5000, cash_balance: 5000 } ],
|
||||
[ 1.day.ago.to_date, { balance: 5000, cash_balance: 4000 } ],
|
||||
[ Date.current, { balance: 5000, cash_balance: 4000 } ]
|
||||
]
|
||||
)
|
||||
end
|
||||
|
||||
# Balance calculator is entirely reliant on HoldingCalculator and respects whatever holding records it creates.
|
||||
test "holdings are additive to total balance" do
|
||||
aapl = securities(:aapl)
|
||||
private
|
||||
|
||||
# Account starts at a value of $5000
|
||||
create_valuation(account: @account, date: 2.days.ago.to_date, amount: 5000)
|
||||
def assert_balances(calculated_data:, expected_balances:)
|
||||
# Sort calculated data by date to ensure consistent ordering
|
||||
sorted_data = calculated_data.sort_by(&:date)
|
||||
|
||||
# Even though there are no trades in the history, the calculator will still add the holdings to the total balance
|
||||
Holding.create!(date: 1.day.ago.to_date, account: @account, security: aapl, qty: 10, price: 100, amount: 1000, currency: "USD")
|
||||
Holding.create!(date: Date.current, account: @account, security: aapl, qty: 10, price: 100, amount: 1000, currency: "USD")
|
||||
# Extract actual values as [date, { balance:, cash_balance: }]
|
||||
actual_balances = sorted_data.map do |b|
|
||||
[ b.date, { balance: b.balance, cash_balance: b.cash_balance } ]
|
||||
end
|
||||
|
||||
# Start at zero, then valuation of $5000, then tack on $1000 of holdings for remaining 2 days
|
||||
expected = [ 0, 5000, 6000, 6000 ]
|
||||
calculated = Balance::ForwardCalculator.new(@account).calculate.sort_by(&:date).map(&:balance)
|
||||
|
||||
assert_equal expected, calculated
|
||||
end
|
||||
assert_equal expected_balances, actual_balances
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,142 +1,279 @@
|
|||
require "test_helper"
|
||||
|
||||
class Balance::ReverseCalculatorTest < ActiveSupport::TestCase
|
||||
include EntriesTestHelper
|
||||
include LedgerTestingHelper
|
||||
|
||||
setup do
|
||||
@account = families(:empty).accounts.create!(
|
||||
name: "Test",
|
||||
balance: 20000,
|
||||
cash_balance: 20000,
|
||||
currency: "USD",
|
||||
accountable: Investment.new
|
||||
# When syncing backwards, we start with the account balance and generate everything from there.
|
||||
test "when missing anchor and no entries, falls back to cached account balance" do
|
||||
account = create_account_with_ledger(
|
||||
account: { type: Depository, balance: 20000, cash_balance: 20000, currency: "USD" },
|
||||
entries: []
|
||||
)
|
||||
|
||||
assert_equal 20000, account.balance
|
||||
|
||||
calculated = Balance::ReverseCalculator.new(account).calculate
|
||||
|
||||
assert_calculated_ledger_balances(
|
||||
calculated_data: calculated,
|
||||
expected_balances: [
|
||||
[ Date.current, { balance: 20000, cash_balance: 20000 } ]
|
||||
]
|
||||
)
|
||||
end
|
||||
|
||||
# When syncing backwards, we start with the account balance and generate everything from there.
|
||||
test "no entries sync" do
|
||||
assert_equal 0, @account.balances.count
|
||||
# An artificial constraint we put on the reverse sync because it's confusing in both the code and the UI
|
||||
# to think about how an absolute "Valuation" affects balances when syncing backwards. Furthermore, since
|
||||
# this is typically a Plaid sync, we expect Plaid to provide us the history.
|
||||
# Note: while "reconciliation" valuations don't affect balance, `current_anchor` and `opening_anchor` do.
|
||||
test "reconciliation valuations do not affect balance for reverse syncs" do
|
||||
account = create_account_with_ledger(
|
||||
account: { type: Depository, balance: 20000, cash_balance: 20000, currency: "USD" },
|
||||
entries: [
|
||||
{ type: "current_anchor", date: Date.current, balance: 20000 },
|
||||
{ type: "reconciliation", date: 1.day.ago, balance: 17000 }, # Ignored
|
||||
{ type: "reconciliation", date: 2.days.ago, balance: 17000 }, # Ignored
|
||||
{ type: "opening_anchor", date: 4.days.ago, balance: 15000 }
|
||||
]
|
||||
)
|
||||
|
||||
expected = [ @account.balance, @account.balance ]
|
||||
calculated = Balance::ReverseCalculator.new(@account).calculate
|
||||
calculated = Balance::ReverseCalculator.new(account).calculate
|
||||
|
||||
assert_equal expected, calculated.map(&:balance)
|
||||
# The "opening anchor" works slightly differently than most would expect. Since it's an artificial
|
||||
# value provided by the user to set the date/balance of the start of the account, we must assume
|
||||
# that there are "missing" entries following it. Because of this, we cannot "carry forward" this value
|
||||
# like we do for a "forward sync". We simply sync backwards normally, then set the balance on opening
|
||||
# date equal to this anchor. This is not "ideal", but is a constraint put on us since we cannot guarantee
|
||||
# a 100% full entries history.
|
||||
assert_calculated_ledger_balances(
|
||||
calculated_data: calculated,
|
||||
expected_balances: [
|
||||
[ Date.current, { balance: 20000, cash_balance: 20000 } ], # Current anchor
|
||||
[ 1.day.ago, { balance: 20000, cash_balance: 20000 } ],
|
||||
[ 2.days.ago, { balance: 20000, cash_balance: 20000 } ],
|
||||
[ 3.days.ago, { balance: 20000, cash_balance: 20000 } ],
|
||||
[ 4.days.ago, { balance: 15000, cash_balance: 15000 } ] # Opening anchor
|
||||
]
|
||||
)
|
||||
end
|
||||
|
||||
test "balance generation respects user timezone and last generated date is current user date" do
|
||||
# Simulate user in EST timezone
|
||||
Time.use_zone("America/New_York") do
|
||||
# Set current time to 1am UTC on Jan 5, 2025
|
||||
# This would be 8pm EST on Jan 4, 2025 (user's time, and the last date we should generate balances for)
|
||||
travel_to Time.utc(2025, 01, 05, 1, 0, 0)
|
||||
# Investment account balances are made of two components: cash and holdings.
|
||||
test "anchors on investment accounts calculate cash balance dynamically based on holdings value" do
|
||||
account = create_account_with_ledger(
|
||||
account: { type: Investment, balance: 20000, cash_balance: 10000, currency: "USD" },
|
||||
entries: [
|
||||
{ type: "current_anchor", date: Date.current, balance: 20000 }, # "Total account value is $20,000 today"
|
||||
{ type: "opening_anchor", date: 1.day.ago, balance: 15000 } # "Total account value was $15,000 at the start of the account"
|
||||
],
|
||||
holdings: [
|
||||
{ date: Date.current, ticker: "AAPL", qty: 100, price: 100, amount: 10000 },
|
||||
{ date: 1.day.ago, ticker: "AAPL", qty: 100, price: 100, amount: 10000 }
|
||||
]
|
||||
)
|
||||
|
||||
create_valuation(account: @account, date: "2025-01-03", amount: 17000)
|
||||
calculated = Balance::ReverseCalculator.new(account).calculate
|
||||
|
||||
expected = [ [ "2025-01-02", 17000 ], [ "2025-01-03", 17000 ], [ "2025-01-04", @account.balance ] ]
|
||||
calculated = Balance::ReverseCalculator.new(@account).calculate
|
||||
assert_calculated_ledger_balances(
|
||||
calculated_data: calculated,
|
||||
expected_balances: [
|
||||
[ Date.current, { balance: 20000, cash_balance: 10000 } ], # Since $10,000 of holdings, cash has to be $10,000 to reach $20,000 total value
|
||||
[ 1.day.ago, { balance: 15000, cash_balance: 5000 } ] # Since $10,000 of holdings, cash has to be $5,000 to reach $15,000 total value
|
||||
]
|
||||
)
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
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.
|
||||
# They should only affect the *cash* component of the historical balances
|
||||
test "holdings and trades sync" do
|
||||
aapl = securities(:aapl)
|
||||
|
||||
# Account starts with $20,000 total value, $19,000 cash, $1,000 in holdings
|
||||
@account.update!(cash_balance: 19000, balance: 20000)
|
||||
account = create_account_with_ledger(
|
||||
account: { type: Investment, balance: 20000, cash_balance: 19000, currency: "USD" },
|
||||
entries: [
|
||||
{ type: "current_anchor", date: Date.current, balance: 20000 },
|
||||
# Bought 10 AAPL shares 1 day ago, so cash is $19,000, $1,000 in holdings, total value is $20,000
|
||||
{ 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
|
||||
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")
|
||||
calculated = Balance::ReverseCalculator.new(account).calculate
|
||||
|
||||
# Given constant prices, overall balance (account value) should be constant
|
||||
# (the single trade doesn't affect balance; it just alters cash vs. holdings composition)
|
||||
expected = [ 20000, 20000, 20000 ]
|
||||
calculated = Balance::ReverseCalculator.new(@account).calculate.sort_by(&:date).map(&:balance)
|
||||
|
||||
assert_equal expected, calculated
|
||||
assert_calculated_ledger_balances(
|
||||
calculated_data: calculated,
|
||||
expected_balances: [
|
||||
[ Date.current, { balance: 20000, cash_balance: 19000 } ], # Current: $19k cash + $1k holdings (anchor)
|
||||
[ 1.day.ago.to_date, { balance: 20000, cash_balance: 19000 } ], # After trade: $19k cash + $1k holdings
|
||||
[ 2.days.ago.to_date, { balance: 20000, cash_balance: 20000 } ] # At first, account is 100% cash, no holdings (no trades)
|
||||
]
|
||||
)
|
||||
end
|
||||
|
||||
# A common scenario with Plaid is they'll give us holding records for today, but no trade history for some of them.
|
||||
# This is because they only supply 2 years worth of historical data. Our system must properly handle this.
|
||||
test "properly calculates balances when a holding has no trade history" do
|
||||
aapl = securities(:aapl)
|
||||
msft = securities(:msft)
|
||||
|
||||
# Account starts with $20,000 total value, $19,000 cash, $1,000 in holdings ($500 AAPL, $500 MSFT)
|
||||
@account.update!(cash_balance: 19000, balance: 20000)
|
||||
account = create_account_with_ledger(
|
||||
account: { type: Investment, balance: 20000, cash_balance: 19000, currency: "USD" },
|
||||
entries: [
|
||||
{ type: "current_anchor", date: Date.current, balance: 20000 },
|
||||
# A holding *with* trade history (5 shares of AAPL, purchased 1 day ago)
|
||||
{ type: "trade", date: 1.day.ago.to_date, ticker: "AAPL", qty: 5, price: 100 }
|
||||
],
|
||||
holdings: [
|
||||
# AAPL holdings
|
||||
{ date: Date.current, ticker: "AAPL", qty: 5, price: 100, amount: 500 },
|
||||
{ date: 1.day.ago.to_date, ticker: "AAPL", qty: 5, price: 100, amount: 500 },
|
||||
# MSFT holdings without trade history - Balance calculator doesn't care how the holdings were created. It just reads them and assumes they are accurate.
|
||||
{ date: Date.current, ticker: "MSFT", qty: 5, price: 100, amount: 500 },
|
||||
{ date: 1.day.ago.to_date, ticker: "MSFT", qty: 5, price: 100, amount: 500 },
|
||||
{ date: 2.days.ago.to_date, ticker: "MSFT", qty: 5, price: 100, amount: 500 }
|
||||
]
|
||||
)
|
||||
|
||||
# A holding *with* trade history (5 shares of AAPL, purchased 1 day ago, results in 2 holdings)
|
||||
Holding.create!(date: Date.current, account: @account, security: aapl, qty: 5, price: 100, amount: 500, currency: "USD")
|
||||
Holding.create!(date: 1.day.ago.to_date, account: @account, security: aapl, qty: 5, price: 100, amount: 500, currency: "USD")
|
||||
create_trade(aapl, account: @account, qty: 5, date: 1.day.ago.to_date, price: 100)
|
||||
calculated = Balance::ReverseCalculator.new(account).calculate
|
||||
|
||||
# A holding *without* trade history (5 shares of MSFT, no trade history, results in 1 holding)
|
||||
# We assume if no history is provided, this holding has existed since beginning of account
|
||||
Holding.create!(date: Date.current, account: @account, security: msft, qty: 5, price: 100, amount: 500, currency: "USD")
|
||||
Holding.create!(date: 1.day.ago.to_date, account: @account, security: msft, qty: 5, price: 100, amount: 500, currency: "USD")
|
||||
Holding.create!(date: 2.days.ago.to_date, account: @account, security: msft, qty: 5, price: 100, amount: 500, currency: "USD")
|
||||
|
||||
expected = [ 20000, 20000, 20000 ]
|
||||
calculated = Balance::ReverseCalculator.new(@account).calculate.sort_by(&:date).map(&:balance)
|
||||
|
||||
assert_equal expected, calculated
|
||||
assert_calculated_ledger_balances(
|
||||
calculated_data: calculated,
|
||||
expected_balances: [
|
||||
[ Date.current, { balance: 20000, cash_balance: 19000 } ], # Current: $19k cash + $1k holdings ($500 MSFT, $500 AAPL)
|
||||
[ 1.day.ago.to_date, { balance: 20000, cash_balance: 19000 } ], # After AAPL trade: $19k cash + $1k holdings
|
||||
[ 2.days.ago.to_date, { balance: 20000, cash_balance: 19500 } ] # Before AAPL trade: $19.5k cash + $500 MSFT
|
||||
]
|
||||
)
|
||||
end
|
||||
|
||||
test "uses provider reported holdings and cash value on current day" do
|
||||
aapl = securities(:aapl)
|
||||
|
||||
# Implied holdings value of $1,000 from provider
|
||||
@account.update!(cash_balance: 19000, balance: 20000)
|
||||
account = create_account_with_ledger(
|
||||
account: { type: Investment, balance: 20000, cash_balance: 19000, currency: "USD" },
|
||||
entries: [
|
||||
{ type: "current_anchor", date: Date.current, balance: 20000 },
|
||||
{ type: "opening_anchor", date: 2.days.ago, balance: 15000 }
|
||||
],
|
||||
holdings: [
|
||||
# Create holdings that differ in value from provider ($2,000 vs. the $1,000 reported by provider)
|
||||
{ date: Date.current, ticker: "AAPL", qty: 10, price: 100, amount: 2000 },
|
||||
{ date: 1.day.ago, ticker: "AAPL", qty: 10, price: 100, amount: 2000 }
|
||||
]
|
||||
)
|
||||
|
||||
# Create a holding that differs in value from provider ($2,000 vs. the $1,000 reported by provider)
|
||||
Holding.create!(date: Date.current, account: @account, security: aapl, qty: 10, price: 100, amount: 2000, currency: "USD")
|
||||
Holding.create!(date: 1.day.ago.to_date, account: @account, security: aapl, qty: 10, price: 100, amount: 2000, currency: "USD")
|
||||
calculated = Balance::ReverseCalculator.new(account).calculate
|
||||
|
||||
# Today reports the provider value. Yesterday, provider won't give us any data, so we MUST look at the generated holdings value
|
||||
# to calculate the end balance ($19,000 cash + $2,000 holdings = $21,000 total value)
|
||||
expected = [ 21000, 20000 ]
|
||||
|
||||
calculated = Balance::ReverseCalculator.new(@account).calculate.sort_by(&:date).map(&:balance)
|
||||
|
||||
assert_equal expected, calculated
|
||||
assert_calculated_ledger_balances(
|
||||
calculated_data: calculated,
|
||||
expected_balances: [
|
||||
# No matter what, we force current day equal to the "anchor" balance (what provider gave us), and let "cash" float based on holdings value
|
||||
# This ensures the user sees the same top-line number reported by the provider (even if it creates a discrepancy in the cash balance)
|
||||
[ Date.current, { balance: 20000, cash_balance: 18000 } ],
|
||||
[ 1.day.ago, { balance: 20000, cash_balance: 18000 } ],
|
||||
[ 2.days.ago, { balance: 15000, cash_balance: 15000 } ] # Opening anchor sets absolute balance
|
||||
]
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue