mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-07-18 20:59:39 +02:00
* 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
349 lines
15 KiB
Ruby
349 lines
15 KiB
Ruby
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 LedgerTestingHelper
|
|
|
|
# ------------------------------------------------------------------------------------------------
|
|
# 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: []
|
|
)
|
|
|
|
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
|
|
|
|
# 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 }
|
|
]
|
|
)
|
|
|
|
calculated = Balance::ForwardCalculator.new(account).calculate
|
|
|
|
# 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 "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 }
|
|
]
|
|
)
|
|
|
|
calculated = Balance::ForwardCalculator.new(account).calculate
|
|
|
|
# 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 "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
|
|
|
|
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 }
|
|
]
|
|
)
|
|
|
|
# Without holdings, cash balance equals total balance
|
|
calculated = Balance::ForwardCalculator.new(account).calculate
|
|
|
|
assert_calculated_ledger_balances(
|
|
calculated_data: calculated,
|
|
expected_balances: [
|
|
[ 3.days.ago.to_date, { balance: 17000, cash_balance: 17000 } ],
|
|
[ 2.days.ago.to_date, { balance: 18000, cash_balance: 18000 } ]
|
|
]
|
|
)
|
|
end
|
|
|
|
# ------------------------------------------------------------------------------------------------
|
|
# All Cash accounts (Depository, CreditCard)
|
|
# ------------------------------------------------------------------------------------------------
|
|
|
|
test "transactions on depository accounts affect cash balance" do
|
|
account = create_account_with_ledger(
|
|
account: { type: Depository, balance: 20000, cash_balance: 20000, currency: "USD" },
|
|
entries: [
|
|
{ type: "opening_anchor", date: 5.days.ago.to_date, balance: 20000 },
|
|
{ type: "transaction", date: 4.days.ago.to_date, amount: -500 }, # income
|
|
{ type: "transaction", date: 2.days.ago.to_date, amount: 100 } # expense
|
|
]
|
|
)
|
|
|
|
calculated = Balance::ForwardCalculator.new(account).calculate
|
|
|
|
assert_calculated_ledger_balances(
|
|
calculated_data: calculated,
|
|
expected_balances: [
|
|
[ 5.days.ago.to_date, { balance: 20000, cash_balance: 20000 } ],
|
|
[ 4.days.ago.to_date, { balance: 20500, cash_balance: 20500 } ],
|
|
[ 3.days.ago.to_date, { balance: 20500, cash_balance: 20500 } ],
|
|
[ 2.days.ago.to_date, { balance: 20400, cash_balance: 20400 } ]
|
|
]
|
|
)
|
|
end
|
|
|
|
|
|
test "transactions on credit card accounts affect cash balance inversely" do
|
|
account = create_account_with_ledger(
|
|
account: { type: CreditCard, balance: 10000, cash_balance: 10000, currency: "USD" },
|
|
entries: [
|
|
{ type: "opening_anchor", date: 5.days.ago.to_date, balance: 1000 },
|
|
{ type: "transaction", date: 4.days.ago.to_date, amount: -500 }, # CC payment
|
|
{ type: "transaction", date: 2.days.ago.to_date, amount: 100 } # expense
|
|
]
|
|
)
|
|
|
|
calculated = Balance::ForwardCalculator.new(account).calculate
|
|
|
|
assert_calculated_ledger_balances(
|
|
calculated_data: calculated,
|
|
expected_balances: [
|
|
[ 5.days.ago.to_date, { balance: 1000, cash_balance: 1000 } ],
|
|
[ 4.days.ago.to_date, { balance: 500, cash_balance: 500 } ],
|
|
[ 3.days.ago.to_date, { balance: 500, cash_balance: 500 } ],
|
|
[ 2.days.ago.to_date, { balance: 600, cash_balance: 600 } ]
|
|
]
|
|
)
|
|
end
|
|
|
|
test "depository account with transactions and balance reconciliations" do
|
|
account = create_account_with_ledger(
|
|
account: { type: Depository, balance: 20000, cash_balance: 20000, currency: "USD" },
|
|
entries: [
|
|
{ type: "opening_anchor", date: 10.days.ago.to_date, balance: 20000 },
|
|
{ type: "transaction", date: 8.days.ago.to_date, amount: -5000 },
|
|
{ type: "reconciliation", date: 6.days.ago.to_date, balance: 17000 },
|
|
{ type: "transaction", date: 6.days.ago.to_date, amount: -500 },
|
|
{ type: "transaction", date: 4.days.ago.to_date, amount: -500 },
|
|
{ type: "reconciliation", date: 3.days.ago.to_date, balance: 17000 },
|
|
{ type: "transaction", date: 1.day.ago.to_date, amount: 100 }
|
|
]
|
|
)
|
|
|
|
calculated = Balance::ForwardCalculator.new(account).calculate
|
|
|
|
assert_calculated_ledger_balances(
|
|
calculated_data: calculated,
|
|
expected_balances: [
|
|
[ 10.days.ago.to_date, { balance: 20000, cash_balance: 20000 } ],
|
|
[ 9.days.ago.to_date, { balance: 20000, cash_balance: 20000 } ],
|
|
[ 8.days.ago.to_date, { balance: 25000, cash_balance: 25000 } ],
|
|
[ 7.days.ago.to_date, { balance: 25000, cash_balance: 25000 } ],
|
|
[ 6.days.ago.to_date, { balance: 17000, cash_balance: 17000 } ],
|
|
[ 5.days.ago.to_date, { balance: 17000, cash_balance: 17000 } ],
|
|
[ 4.days.ago.to_date, { balance: 17500, cash_balance: 17500 } ],
|
|
[ 3.days.ago.to_date, { balance: 17000, cash_balance: 17000 } ],
|
|
[ 2.days.ago.to_date, { balance: 17000, cash_balance: 17000 } ],
|
|
[ 1.day.ago.to_date, { balance: 16900, cash_balance: 16900 } ]
|
|
]
|
|
)
|
|
end
|
|
|
|
test "accounts with transactions in multiple currencies convert to the account currency" do
|
|
account = create_account_with_ledger(
|
|
account: { type: Depository, balance: 20000, cash_balance: 20000, currency: "USD" },
|
|
entries: [
|
|
{ type: "opening_anchor", date: 4.days.ago.to_date, balance: 100 },
|
|
{ type: "transaction", date: 3.days.ago.to_date, amount: -100 },
|
|
{ type: "transaction", date: 2.days.ago.to_date, amount: -300 },
|
|
# Transaction in different currency than the account's main currency
|
|
{ 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)
|
|
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 } ]
|
|
]
|
|
)
|
|
end
|
|
|
|
private
|
|
|
|
def assert_balances(calculated_data:, expected_balances:)
|
|
# Sort calculated data by date to ensure consistent ordering
|
|
sorted_data = calculated_data.sort_by(&:date)
|
|
|
|
# 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
|
|
|
|
assert_equal expected_balances, actual_balances
|
|
end
|
|
end
|