1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-08-02 20:15:22 +02:00

Additional cache columns on balances for activity view breakdowns (#2505)

* Initial schema iteration

* Add new balance components

* Add existing data migrator to backfill components

* Update calculator test assertions for new balance components

* Update flow assertions for forward calculator

* Update reverse calculator flows assumptions

* Forward calculator tests passing

* Get all calculator tests passing

* Assert flows factor
This commit is contained in:
Zach Gollwitzer 2025-07-23 10:06:25 -04:00 committed by GitHub
parent 347c0a7906
commit da2045dbd8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 1159 additions and 177 deletions

View file

@ -0,0 +1,59 @@
class BalanceComponentMigrator
def self.run
ActiveRecord::Base.transaction do
# Step 1: Update flows factor
ActiveRecord::Base.connection.execute <<~SQL
UPDATE balances SET
flows_factor = CASE WHEN a.classification = 'asset' THEN 1 ELSE -1 END
FROM accounts a
WHERE a.id = balances.account_id
SQL
# Step 2: Set start values using LOCF (Last Observation Carried Forward)
ActiveRecord::Base.connection.execute <<~SQL
UPDATE balances b1
SET
start_cash_balance = COALESCE(prev.cash_balance, 0),
start_non_cash_balance = COALESCE(prev.balance - prev.cash_balance, 0)
FROM balances b1_inner
LEFT JOIN LATERAL (
SELECT
b2.cash_balance,
b2.balance
FROM balances b2
WHERE b2.account_id = b1_inner.account_id
AND b2.currency = b1_inner.currency
AND b2.date < b1_inner.date
ORDER BY b2.date DESC
LIMIT 1
) prev ON true
WHERE b1.id = b1_inner.id
SQL
# Step 3: Calculate net inflows
# A slight workaround to the fact that we can't easily derive inflows/outflows from our current data model, and
# the tradeoff not worth it since each new sync will fix it. So instead, we sum up *net* flows, and throw the signed
# amount in the "inflows" column, and zero-out the "outflows" column so our math works correctly with incomplete data.
ActiveRecord::Base.connection.execute <<~SQL
UPDATE balances SET
cash_inflows = (cash_balance - start_cash_balance) * flows_factor,
cash_outflows = 0,
non_cash_inflows = ((balance - cash_balance) - start_non_cash_balance) * flows_factor,
non_cash_outflows = 0,
net_market_flows = 0
SQL
# Verify data integrity
# All end_balance values should match the original balance
invalid_count = ActiveRecord::Base.connection.select_value(<<~SQL)
SELECT COUNT(*)
FROM balances b
WHERE ABS(b.balance - b.end_balance) > 0.0001
SQL
if invalid_count > 0
raise "Data migration failed validation: #{invalid_count} balances have incorrect end_balance values"
end
end
end
end

View file

@ -2,8 +2,16 @@ class Balance < ApplicationRecord
include Monetizable
belongs_to :account
validates :account, :date, :balance, presence: true
monetize :balance, :cash_balance
validates :flows_factor, inclusion: { in: [ -1, 1 ] }
monetize :balance, :cash_balance,
:start_cash_balance, :start_non_cash_balance, :start_balance,
:cash_inflows, :cash_outflows, :non_cash_inflows, :non_cash_outflows, :net_market_flows,
:cash_adjustments, :non_cash_adjustments,
:end_cash_balance, :end_non_cash_balance, :end_balance
scope :in_period, ->(period) { period.nil? ? all : where(date: period.date_range) }
scope :chronological, -> { order(:date) }
end

View file

@ -15,8 +15,8 @@ class Balance::BaseCalculator
end
def holdings_value_for_date(date)
holdings = sync_cache.get_holdings(date)
holdings.sum(&:amount)
@holdings_value_for_date ||= {}
@holdings_value_for_date[date] ||= sync_cache.get_holdings(date).sum(&:amount)
end
def derive_cash_balance_on_date_from_total(total_balance:, date:)
@ -29,6 +29,67 @@ class Balance::BaseCalculator
end
end
def cash_adjustments_for_date(start_cash, net_cash_flows, valuation)
return 0 unless valuation && account.balance_type != :non_cash
valuation.amount - start_cash - net_cash_flows
end
def non_cash_adjustments_for_date(start_non_cash, non_cash_flows, valuation)
return 0 unless valuation && account.balance_type == :non_cash
valuation.amount - start_non_cash - non_cash_flows
end
# If holdings value goes from $100 -> $200 (change_holdings_value is $100)
# And non-cash flows (i.e. "buys") for day are +$50 (net_buy_sell_value is $50)
# That means value increased by $100, where $50 of that is due to the change in holdings value, and $50 is due to the buy/sell
def market_value_change_on_date(date, flows)
return 0 unless account.balance_type == :investment
start_of_day_holdings_value = holdings_value_for_date(date.prev_day)
end_of_day_holdings_value = holdings_value_for_date(date)
change_holdings_value = end_of_day_holdings_value - start_of_day_holdings_value
net_buy_sell_value = flows[:non_cash_inflows] - flows[:non_cash_outflows]
change_holdings_value - net_buy_sell_value
end
def flows_for_date(date)
entries = sync_cache.get_entries(date)
cash_inflows = 0
cash_outflows = 0
non_cash_inflows = 0
non_cash_outflows = 0
txn_inflow_sum = entries.select { |e| e.amount < 0 && e.transaction? }.sum(&:amount)
txn_outflow_sum = entries.select { |e| e.amount >= 0 && e.transaction? }.sum(&:amount)
trade_cash_inflow_sum = entries.select { |e| e.amount < 0 && e.trade? }.sum(&:amount)
trade_cash_outflow_sum = entries.select { |e| e.amount >= 0 && e.trade? }.sum(&:amount)
if account.balance_type == :non_cash && account.accountable_type == "Loan"
non_cash_inflows = txn_inflow_sum.abs
non_cash_outflows = txn_outflow_sum
elsif account.balance_type != :non_cash
cash_inflows = txn_inflow_sum.abs + trade_cash_inflow_sum.abs
cash_outflows = txn_outflow_sum + trade_cash_outflow_sum
# Trades are inverse (a "buy" is outflow of cash, but "inflow" of non-cash, aka "holdings")
non_cash_outflows = trade_cash_inflow_sum.abs
non_cash_inflows = trade_cash_outflow_sum
end
{
cash_inflows: cash_inflows,
cash_outflows: cash_outflows,
non_cash_inflows: non_cash_inflows,
non_cash_outflows: non_cash_outflows
}
end
def derive_cash_balance(cash_balance, date)
entries = sync_cache.get_entries(date)
@ -57,13 +118,23 @@ class Balance::BaseCalculator
raise NotImplementedError, "Directional calculators must implement this method"
end
def build_balance(date:, cash_balance:, non_cash_balance:)
def build_balance(date:, **args)
Balance.new(
account_id: account.id,
currency: account.currency,
date: date,
balance: non_cash_balance + cash_balance,
cash_balance: cash_balance,
currency: account.currency
balance: args[:balance],
cash_balance: args[:cash_balance],
start_cash_balance: args[:start_cash_balance] || 0,
start_non_cash_balance: args[:start_non_cash_balance] || 0,
cash_inflows: args[:cash_inflows] || 0,
cash_outflows: args[:cash_outflows] || 0,
non_cash_inflows: args[:non_cash_inflows] || 0,
non_cash_outflows: args[:non_cash_outflows] || 0,
cash_adjustments: args[:cash_adjustments] || 0,
non_cash_adjustments: args[:non_cash_adjustments] || 0,
net_market_flows: args[:net_market_flows] || 0,
flows_factor: account.classification == "asset" ? 1 : -1
)
end
end

View file

@ -2,13 +2,13 @@ class Balance::ForwardCalculator < Balance::BaseCalculator
def calculate
Rails.logger.tagged("Balance::ForwardCalculator") do
start_cash_balance = derive_cash_balance_on_date_from_total(
total_balance: account.opening_anchor_balance,
total_balance: 0,
date: account.opening_anchor_date
)
start_non_cash_balance = account.opening_anchor_balance - start_cash_balance
start_non_cash_balance = 0
calc_start_date.upto(calc_end_date).map do |date|
valuation = sync_cache.get_reconciliation_valuation(date)
valuation = sync_cache.get_valuation(date)
if valuation
end_cash_balance = derive_cash_balance_on_date_from_total(
@ -21,10 +21,22 @@ class Balance::ForwardCalculator < Balance::BaseCalculator
end_non_cash_balance = derive_end_non_cash_balance(start_non_cash_balance: start_non_cash_balance, date: date)
end
flows = flows_for_date(date)
market_value_change = market_value_change_on_date(date, flows)
output_balance = build_balance(
date: date,
balance: end_cash_balance + end_non_cash_balance,
cash_balance: end_cash_balance,
non_cash_balance: end_non_cash_balance
start_cash_balance: start_cash_balance,
start_non_cash_balance: start_non_cash_balance,
cash_inflows: flows[:cash_inflows],
cash_outflows: flows[:cash_outflows],
non_cash_inflows: flows[:non_cash_inflows],
non_cash_outflows: flows[:non_cash_outflows],
cash_adjustments: cash_adjustments_for_date(start_cash_balance, flows[:cash_inflows] - flows[:cash_outflows], valuation),
non_cash_adjustments: non_cash_adjustments_for_date(start_non_cash_balance, flows[:non_cash_inflows] - flows[:non_cash_outflows], valuation),
net_market_flows: market_value_change
)
# Set values for the next iteration

View file

@ -11,6 +11,8 @@ class Balance::ReverseCalculator < Balance::BaseCalculator
# Calculates in reverse-chronological order (End of day -> Start of day)
account.current_anchor_date.downto(account.opening_anchor_date).map do |date|
flows = flows_for_date(date)
if use_opening_anchor_for_date?(date)
end_cash_balance = derive_cash_balance_on_date_from_total(
total_balance: account.opening_anchor_balance,
@ -20,29 +22,30 @@ class Balance::ReverseCalculator < Balance::BaseCalculator
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
)
market_value_change = 0
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
market_value_change = market_value_change_on_date(date, flows)
end
output_balance = build_balance(
date: date,
balance: end_cash_balance + end_non_cash_balance,
cash_balance: end_cash_balance,
start_cash_balance: start_cash_balance,
start_non_cash_balance: start_non_cash_balance,
cash_inflows: flows[:cash_inflows],
cash_outflows: flows[:cash_outflows],
non_cash_inflows: flows[:non_cash_inflows],
non_cash_outflows: flows[:non_cash_outflows],
net_market_flows: market_value_change
)
end_cash_balance = start_cash_balance
end_non_cash_balance = start_non_cash_balance
output_balance
end
end
end
@ -58,13 +61,6 @@ class Balance::ReverseCalculator < Balance::BaseCalculator
account.asset? ? entry_flows : -entry_flows
end
# Reverse syncs are a bit different than forward syncs because we do not allow "reconciliation" valuations
# to be used at all. This is primarily to keep the code and the UI easy to understand. For a more detailed
# explanation, see the test suite.
def use_opening_anchor_for_date?(date)
account.has_opening_anchor? && date == account.opening_anchor_date
end
# Alias method, for algorithmic clarity
# Derives cash balance, starting from the end-of-day, applying entries in reverse to get the start-of-day balance
def derive_start_cash_balance(end_cash_balance:, date:)
@ -76,4 +72,11 @@ class Balance::ReverseCalculator < Balance::BaseCalculator
def derive_start_non_cash_balance(end_non_cash_balance:, date:)
derive_non_cash_balance(end_non_cash_balance, date, direction: :reverse)
end
# Reverse syncs are a bit different than forward syncs because we do not allow "reconciliation" valuations
# to be used at all. This is primarily to keep the code and the UI easy to understand. For a more detailed
# explanation, see the test suite.
def use_opening_anchor_for_date?(date)
account.has_opening_anchor? && date == account.opening_anchor_date
end
end

View file

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

View file

@ -0,0 +1,72 @@
class AddStartEndColumnsToBalances < ActiveRecord::Migration[7.2]
def up
# Add new columns for balance tracking
add_column :balances, :start_cash_balance, :decimal, precision: 19, scale: 4, null: false, default: 0.0
add_column :balances, :start_non_cash_balance, :decimal, precision: 19, scale: 4, null: false, default: 0.0
# Flow tracking columns (absolute values)
add_column :balances, :cash_inflows, :decimal, precision: 19, scale: 4, null: false, default: 0.0
add_column :balances, :cash_outflows, :decimal, precision: 19, scale: 4, null: false, default: 0.0
add_column :balances, :non_cash_inflows, :decimal, precision: 19, scale: 4, null: false, default: 0.0
add_column :balances, :non_cash_outflows, :decimal, precision: 19, scale: 4, null: false, default: 0.0
# Market value changes
add_column :balances, :net_market_flows, :decimal, precision: 19, scale: 4, null: false, default: 0.0
# Manual adjustments from valuations
add_column :balances, :cash_adjustments, :decimal, precision: 19, scale: 4, null: false, default: 0.0
add_column :balances, :non_cash_adjustments, :decimal, precision: 19, scale: 4, null: false, default: 0.0
# Flows factor determines *how* the flows affect the balance.
# Inflows increase asset accounts, while inflows decrease liability accounts (reducing debt via "payment")
add_column :balances, :flows_factor, :integer, null: false, default: 1
# Add generated columns
change_table :balances do |t|
t.virtual :start_balance, type: :decimal, precision: 19, scale: 4, stored: true,
as: "start_cash_balance + start_non_cash_balance"
t.virtual :end_cash_balance, type: :decimal, precision: 19, scale: 4, stored: true,
as: "start_cash_balance + ((cash_inflows - cash_outflows) * flows_factor) + cash_adjustments"
t.virtual :end_non_cash_balance, type: :decimal, precision: 19, scale: 4, stored: true,
as: "start_non_cash_balance + ((non_cash_inflows - non_cash_outflows) * flows_factor) + net_market_flows + non_cash_adjustments"
# Postgres doesn't support generated columns depending on other generated columns,
# but we want the integrity of the data to happen at the DB level, so this is the full formula.
# Formula: (cash components) + (non-cash components)
t.virtual :end_balance, type: :decimal, precision: 19, scale: 4, stored: true,
as: <<~SQL.squish
(
start_cash_balance +
((cash_inflows - cash_outflows) * flows_factor) +
cash_adjustments
) + (
start_non_cash_balance +
((non_cash_inflows - non_cash_outflows) * flows_factor) +
net_market_flows +
non_cash_adjustments
)
SQL
end
end
def down
# Remove generated columns first (PostgreSQL requirement)
remove_column :balances, :start_balance
remove_column :balances, :end_cash_balance
remove_column :balances, :end_non_cash_balance
remove_column :balances, :end_balance
# Remove new columns
remove_column :balances, :start_cash_balance
remove_column :balances, :start_non_cash_balance
remove_column :balances, :cash_inflows
remove_column :balances, :cash_outflows
remove_column :balances, :non_cash_inflows
remove_column :balances, :non_cash_outflows
remove_column :balances, :net_market_flows
remove_column :balances, :cash_adjustments
remove_column :balances, :non_cash_adjustments
end
end

16
db/schema.rb generated
View file

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.2].define(version: 2025_07_18_120146) do
ActiveRecord::Schema[7.2].define(version: 2025_07_19_121103) do
# These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto"
enable_extension "plpgsql"
@ -115,6 +115,20 @@ ActiveRecord::Schema[7.2].define(version: 2025_07_18_120146) do
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.decimal "cash_balance", precision: 19, scale: 4, default: "0.0"
t.decimal "start_cash_balance", precision: 19, scale: 4, default: "0.0", null: false
t.decimal "start_non_cash_balance", precision: 19, scale: 4, default: "0.0", null: false
t.decimal "cash_inflows", precision: 19, scale: 4, default: "0.0", null: false
t.decimal "cash_outflows", precision: 19, scale: 4, default: "0.0", null: false
t.decimal "non_cash_inflows", precision: 19, scale: 4, default: "0.0", null: false
t.decimal "non_cash_outflows", precision: 19, scale: 4, default: "0.0", null: false
t.decimal "net_market_flows", precision: 19, scale: 4, default: "0.0", null: false
t.decimal "cash_adjustments", precision: 19, scale: 4, default: "0.0", null: false
t.decimal "non_cash_adjustments", precision: 19, scale: 4, default: "0.0", null: false
t.integer "flows_factor", default: 1, null: false
t.virtual "start_balance", type: :decimal, precision: 19, scale: 4, as: "(start_cash_balance + start_non_cash_balance)", stored: true
t.virtual "end_cash_balance", type: :decimal, precision: 19, scale: 4, as: "((start_cash_balance + ((cash_inflows - cash_outflows) * (flows_factor)::numeric)) + cash_adjustments)", stored: true
t.virtual "end_non_cash_balance", type: :decimal, precision: 19, scale: 4, as: "(((start_non_cash_balance + ((non_cash_inflows - non_cash_outflows) * (flows_factor)::numeric)) + net_market_flows) + non_cash_adjustments)", stored: true
t.virtual "end_balance", type: :decimal, precision: 19, scale: 4, as: "(((start_cash_balance + ((cash_inflows - cash_outflows) * (flows_factor)::numeric)) + cash_adjustments) + (((start_non_cash_balance + ((non_cash_inflows - non_cash_outflows) * (flows_factor)::numeric)) + net_market_flows) + non_cash_adjustments))", stored: true
t.index ["account_id", "date", "currency"], name: "index_account_balances_on_account_id_date_currency_unique", unique: true
t.index ["account_id", "date"], name: "index_balances_on_account_id_and_date", order: { date: :desc }
t.index ["account_id"], name: "index_balances_on_account_id"

View file

@ -154,4 +154,19 @@ namespace :data_migration do
puts " Processed: #{accounts_processed} accounts"
puts " Opening anchors set: #{opening_anchors_set}"
end
desc "Migrate balance components"
# 2025-07-20: Migrate balance components to support event-sourced ledger model.
# This task:
# 1. Sets the flows_factor for each account based on the account's classification
# 2. Sets the start_cash_balance, start_non_cash_balance, and start_balance for each balance
# 3. Sets the cash_inflows, cash_outflows, non_cash_inflows, non_cash_outflows, net_market_flows, cash_adjustments, and non_cash_adjustments for each balance
# 4. Sets the end_cash_balance, end_non_cash_balance, and end_balance for each balance
task migrate_balance_components: :environment do
puts "==> Migrating balance components..."
BalanceComponentMigrator.run
puts "✅ Balance component migration complete."
end
end

View file

@ -0,0 +1,160 @@
require "test_helper"
class BalanceComponentMigratorTest < ActiveSupport::TestCase
include EntriesTestHelper
setup do
@depository = accounts(:depository)
@investment = accounts(:investment)
@loan = accounts(:loan)
# Start fresh
Balance.delete_all
end
test "depository account with no gaps" do
create_balance_history(@depository, [
{ date: 5.days.ago, cash_balance: 1000, balance: 1000 },
{ date: 4.days.ago, cash_balance: 1100, balance: 1100 },
{ date: 3.days.ago, cash_balance: 1050, balance: 1050 },
{ date: 2.days.ago, cash_balance: 1200, balance: 1200 },
{ date: 1.day.ago, cash_balance: 1150, balance: 1150 }
])
BalanceComponentMigrator.run
assert_migrated_balances @depository, [
{ date: 5.days.ago, start_cash: 0, start_non_cash: 0, start: 0, cash_inflows: 1000, non_cash_inflows: 0, end_cash: 1000, end_non_cash: 0, end: 1000 },
{ date: 4.days.ago, start_cash: 1000, start_non_cash: 0, start: 1000, cash_inflows: 100, non_cash_inflows: 0, end_cash: 1100, end_non_cash: 0, end: 1100 },
{ date: 3.days.ago, start_cash: 1100, start_non_cash: 0, start: 1100, cash_inflows: -50, non_cash_inflows: 0, end_cash: 1050, end_non_cash: 0, end: 1050 },
{ date: 2.days.ago, start_cash: 1050, start_non_cash: 0, start: 1050, cash_inflows: 150, non_cash_inflows: 0, end_cash: 1200, end_non_cash: 0, end: 1200 },
{ date: 1.day.ago, start_cash: 1200, start_non_cash: 0, start: 1200, cash_inflows: -50, non_cash_inflows: 0, end_cash: 1150, end_non_cash: 0, end: 1150 }
]
end
test "depository account with gaps" do
create_balance_history(@depository, [
{ date: 5.days.ago, cash_balance: 1000, balance: 1000 },
{ date: 1.day.ago, cash_balance: 1150, balance: 1150 }
])
BalanceComponentMigrator.run
assert_migrated_balances @depository, [
{ date: 5.days.ago, start_cash: 0, start_non_cash: 0, start: 0, cash_inflows: 1000, non_cash_inflows: 0, end_cash: 1000, end_non_cash: 0, end: 1000 },
{ date: 1.day.ago, start_cash: 1000, start_non_cash: 0, start: 1000, cash_inflows: 150, non_cash_inflows: 0, end_cash: 1150, end_non_cash: 0, end: 1150 }
]
end
test "investment account with no gaps" do
create_balance_history(@investment, [
{ date: 3.days.ago, cash_balance: 100, balance: 200 },
{ date: 2.days.ago, cash_balance: 200, balance: 300 },
{ date: 1.day.ago, cash_balance: 0, balance: 300 }
])
BalanceComponentMigrator.run
assert_migrated_balances @investment, [
{ date: 3.days.ago, start_cash: 0, start_non_cash: 0, start: 0, cash_inflows: 100, non_cash_inflows: 100, end_cash: 100, end_non_cash: 100, end: 200 },
{ date: 2.days.ago, start_cash: 100, start_non_cash: 100, start: 200, cash_inflows: 100, non_cash_inflows: 0, end_cash: 200, end_non_cash: 100, end: 300 },
{ date: 1.day.ago, start_cash: 200, start_non_cash: 100, start: 300, cash_inflows: -200, non_cash_inflows: 200, end_cash: 0, end_non_cash: 300, end: 300 }
]
end
test "investment account with gaps" do
create_balance_history(@investment, [
{ date: 5.days.ago, cash_balance: 1000, balance: 1000 },
{ date: 1.day.ago, cash_balance: 1150, balance: 1150 }
])
BalanceComponentMigrator.run
assert_migrated_balances @investment, [
{ date: 5.days.ago, start_cash: 0, start_non_cash: 0, start: 0, cash_inflows: 1000, non_cash_inflows: 0, end_cash: 1000, end_non_cash: 0, end: 1000 },
{ date: 1.day.ago, start_cash: 1000, start_non_cash: 0, start: 1000, cash_inflows: 150, non_cash_inflows: 0, end_cash: 1150, end_non_cash: 0, end: 1150 }
]
end
# Negative flows factor test
test "loan account with no gaps" do
create_balance_history(@loan, [
{ date: 3.days.ago, cash_balance: 0, balance: 200 },
{ date: 2.days.ago, cash_balance: 0, balance: 300 },
{ date: 1.day.ago, cash_balance: 0, balance: 500 }
])
BalanceComponentMigrator.run
assert_migrated_balances @loan, [
{ date: 3.days.ago, start_cash: 0, start_non_cash: 0, start: 0, cash_inflows: 0, non_cash_inflows: -200, end_cash: 0, end_non_cash: 200, end: 200 },
{ date: 2.days.ago, start_cash: 0, start_non_cash: 200, start: 200, cash_inflows: 0, non_cash_inflows: -100, end_cash: 0, end_non_cash: 300, end: 300 },
{ date: 1.day.ago, start_cash: 0, start_non_cash: 300, start: 300, cash_inflows: 0, non_cash_inflows: -200, end_cash: 0, end_non_cash: 500, end: 500 }
]
end
test "loan account with gaps" do
create_balance_history(@loan, [
{ date: 5.days.ago, cash_balance: 0, balance: 1000 },
{ date: 1.day.ago, cash_balance: 0, balance: 2000 }
])
BalanceComponentMigrator.run
assert_migrated_balances @loan, [
{ date: 5.days.ago, start_cash: 0, start_non_cash: 0, start: 0, cash_inflows: 0, non_cash_inflows: -1000, end_cash: 0, end_non_cash: 1000, end: 1000 },
{ date: 1.day.ago, start_cash: 0, start_non_cash: 1000, start: 1000, cash_inflows: 0, non_cash_inflows: -1000, end_cash: 0, end_non_cash: 2000, end: 2000 }
]
end
private
def create_balance_history(account, balances)
balances.each do |balance|
account.balances.create!(
date: balance[:date].to_date,
balance: balance[:balance],
cash_balance: balance[:cash_balance],
currency: account.currency
)
end
end
def assert_migrated_balances(account, expected)
balances = account.balances.order(:date)
expected.each_with_index do |expected_values, index|
balance = balances.find { |b| b.date == expected_values[:date].to_date }
assert balance, "Expected balance for #{expected_values[:date].to_date} but none found"
# Assert expected values
assert_equal expected_values[:start_cash], balance.start_cash_balance,
"start_cash_balance mismatch for #{balance.date}"
assert_equal expected_values[:start_non_cash], balance.start_non_cash_balance,
"start_non_cash_balance mismatch for #{balance.date}"
assert_equal expected_values[:start], balance.start_balance,
"start_balance mismatch for #{balance.date}"
assert_equal expected_values[:cash_inflows], balance.cash_inflows,
"cash_inflows mismatch for #{balance.date}"
assert_equal expected_values[:non_cash_inflows], balance.non_cash_inflows,
"non_cash_inflows mismatch for #{balance.date}"
assert_equal expected_values[:end_cash], balance.end_cash_balance,
"end_cash_balance mismatch for #{balance.date}"
assert_equal expected_values[:end_non_cash], balance.end_non_cash_balance,
"end_non_cash_balance mismatch for #{balance.date}"
assert_equal expected_values[:end], balance.end_balance,
"end_balance mismatch for #{balance.date}"
# Assert zeros for other fields
assert_equal 0, balance.cash_outflows,
"cash_outflows should be zero for #{balance.date}"
assert_equal 0, balance.non_cash_outflows,
"non_cash_outflows should be zero for #{balance.date}"
assert_equal 0, balance.cash_adjustments,
"cash_adjustments should be zero for #{balance.date}"
assert_equal 0, balance.non_cash_adjustments,
"non_cash_adjustments should be zero for #{balance.date}"
assert_equal 0, balance.net_market_flows,
"net_market_flows should be zero for #{balance.date}"
end
end
end

View file

@ -11,7 +11,7 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
# 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" },
account: { type: Depository, currency: "USD" },
entries: []
)
@ -21,8 +21,14 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
assert_calculated_ledger_balances(
calculated_data: calculated,
expected_balances: [
[ Date.current, { balance: 0, cash_balance: 0 } ]
expected_data: [
{
date: Date.current,
legacy_balances: { balance: 0, cash_balance: 0 },
balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 0, end_non_cash: 0, end: 0 },
flows: 0,
adjustments: 0
}
]
)
end
@ -30,7 +36,7 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
# 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" },
account: { type: Depository, currency: "USD" },
entries: [
{ type: "transaction", date: 2.days.ago.to_date, amount: -1000 }
]
@ -41,16 +47,28 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
# 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 } ]
expected_data: [
{
date: 3.days.ago.to_date,
legacy_balances: { balance: 0, cash_balance: 0 },
balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 0, end_non_cash: 0, end: 0 },
flows: 0,
adjustments: 0
},
{
date: 2.days.ago.to_date,
legacy_balances: { balance: 1000, cash_balance: 1000 },
balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 1000, end_non_cash: 0, end: 1000 },
flows: { cash_inflows: 1000, cash_outflows: 0 },
adjustments: 0
}
]
)
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" },
account: { type: Depository, currency: "USD" },
entries: [
{ type: "reconciliation", date: 3.days.ago.to_date, balance: 18000 },
{ type: "transaction", date: 2.days.ago.to_date, amount: -1000 }
@ -62,9 +80,21 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
# 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 } ]
expected_data: [
{
date: 3.days.ago.to_date,
legacy_balances: { balance: 18000, cash_balance: 18000 },
balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 18000, end_non_cash: 0, end: 18000 },
flows: 0,
adjustments: { cash_adjustments: 18000, non_cash_adjustments: 0 }
},
{
date: 2.days.ago.to_date,
legacy_balances: { balance: 19000, cash_balance: 19000 },
balances: { start: 18000, start_cash: 18000, start_non_cash: 0, end_cash: 19000, end_non_cash: 0, end: 19000 },
flows: { cash_inflows: 1000, cash_outflows: 0 },
adjustments: 0
}
]
)
end
@ -72,7 +102,7 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
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" },
account: { type: account_type, currency: "USD" },
entries: [
{ type: "opening_anchor", date: 3.days.ago.to_date, balance: 17000 },
{ type: "reconciliation", date: 2.days.ago.to_date, balance: 18000 }
@ -83,9 +113,21 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
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 } ]
expected_data: [
{
date: 3.days.ago.to_date,
legacy_balances: { balance: 17000, cash_balance: 17000 },
balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 17000, end_non_cash: 0, end: 17000 },
flows: 0,
adjustments: { cash_adjustments: 17000, non_cash_adjustments: 0 }
},
{
date: 2.days.ago.to_date,
legacy_balances: { balance: 18000, cash_balance: 18000 },
balances: { start: 17000, start_cash: 17000, start_non_cash: 0, end_cash: 18000, end_non_cash: 0, end: 18000 },
flows: 0,
adjustments: { cash_adjustments: 1000, non_cash_adjustments: 0 }
}
]
)
end
@ -94,7 +136,7 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
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" },
account: { type: account_type, currency: "USD" },
entries: [
{ type: "opening_anchor", date: 3.days.ago.to_date, balance: 17000 },
{ type: "reconciliation", date: 2.days.ago.to_date, balance: 18000 }
@ -105,9 +147,21 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
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 } ]
expected_data: [
{
date: 3.days.ago.to_date,
legacy_balances: { balance: 17000, cash_balance: 0.0 },
balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 0, end_non_cash: 17000, end: 17000 },
flows: 0,
adjustments: { cash_adjustments: 0, non_cash_adjustments: 17000 }
},
{
date: 2.days.ago.to_date,
legacy_balances: { balance: 18000, cash_balance: 0.0 },
balances: { start: 17000, start_cash: 0, start_non_cash: 17000, end_cash: 0, end_non_cash: 18000, end: 18000 },
flows: 0,
adjustments: { cash_adjustments: 0, non_cash_adjustments: 1000 }
}
]
)
end
@ -115,7 +169,7 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
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" },
account: { type: Investment, currency: "USD" },
entries: [
{ type: "opening_anchor", date: 3.days.ago.to_date, balance: 17000 },
{ type: "reconciliation", date: 2.days.ago.to_date, balance: 18000 }
@ -127,9 +181,21 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
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 } ]
expected_data: [
{
date: 3.days.ago.to_date,
legacy_balances: { balance: 17000, cash_balance: 17000 },
balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 17000, end_non_cash: 0, end: 17000 },
flows: { market_flows: 0 },
adjustments: { cash_adjustments: 17000, non_cash_adjustments: 0 } # Since no holdings present, adjustment is all cash
},
{
date: 2.days.ago.to_date,
legacy_balances: { balance: 18000, cash_balance: 18000 },
balances: { start: 17000, start_cash: 17000, start_non_cash: 0, end_cash: 18000, end_non_cash: 0, end: 18000 },
flows: { market_flows: 0 },
adjustments: { cash_adjustments: 1000, non_cash_adjustments: 0 } # Since no holdings present, adjustment is all cash
}
]
)
end
@ -140,7 +206,7 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
test "transactions on depository accounts affect cash balance" do
account = create_account_with_ledger(
account: { type: Depository, balance: 20000, cash_balance: 20000, currency: "USD" },
account: { type: Depository, 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
@ -152,11 +218,35 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
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 } ]
expected_data: [
{
date: 5.days.ago.to_date,
legacy_balances: { balance: 20000, cash_balance: 20000 },
balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 },
flows: 0,
adjustments: { cash_adjustments: 20000, non_cash_adjustments: 0 }
},
{
date: 4.days.ago.to_date,
legacy_balances: { balance: 20500, cash_balance: 20500 },
balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 20500, end_non_cash: 0, end: 20500 },
flows: { cash_inflows: 500, cash_outflows: 0 },
adjustments: 0
},
{
date: 3.days.ago.to_date,
legacy_balances: { balance: 20500, cash_balance: 20500 },
balances: { start: 20500, start_cash: 20500, start_non_cash: 0, end_cash: 20500, end_non_cash: 0, end: 20500 },
flows: 0,
adjustments: 0
},
{
date: 2.days.ago.to_date,
legacy_balances: { balance: 20400, cash_balance: 20400 },
balances: { start: 20500, start_cash: 20500, start_non_cash: 0, end_cash: 20400, end_non_cash: 0, end: 20400 },
flows: { cash_inflows: 0, cash_outflows: 100 },
adjustments: 0
}
]
)
end
@ -164,7 +254,7 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
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" },
account: { type: CreditCard, 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
@ -176,26 +266,47 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
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 } ]
expected_data: [
{
date: 5.days.ago.to_date,
legacy_balances: { balance: 1000, cash_balance: 1000 },
balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 1000, end_non_cash: 0, end: 1000 },
flows: 0,
adjustments: { cash_adjustments: 1000, non_cash_adjustments: 0 }
},
{
date: 4.days.ago.to_date,
legacy_balances: { balance: 500, cash_balance: 500 },
balances: { start: 1000, start_cash: 1000, start_non_cash: 0, end_cash: 500, end_non_cash: 0, end: 500 },
flows: { cash_inflows: 500, cash_outflows: 0 },
adjustments: 0
},
{
date: 3.days.ago.to_date,
legacy_balances: { balance: 500, cash_balance: 500 },
balances: { start: 500, start_cash: 500, start_non_cash: 0, end_cash: 500, end_non_cash: 0, end: 500 },
flows: 0,
adjustments: 0
},
{
date: 2.days.ago.to_date,
legacy_balances: { balance: 600, cash_balance: 600 },
balances: { start: 500, start_cash: 500, start_non_cash: 0, end_cash: 600, end_non_cash: 0, end: 600 },
flows: { cash_inflows: 0, cash_outflows: 100 },
adjustments: 0
}
]
)
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" },
account: { type: Depository, 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 }
{ type: "opening_anchor", date: 4.days.ago.to_date, balance: 20000 },
{ type: "transaction", date: 3.days.ago.to_date, amount: -5000 },
{ type: "reconciliation", date: 2.days.ago.to_date, balance: 17000 },
{ type: "transaction", date: 1.day.ago.to_date, amount: -500 }
]
)
@ -203,24 +314,42 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
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 } ]
expected_data: [
{
date: 4.days.ago.to_date,
legacy_balances: { balance: 20000, cash_balance: 20000 },
balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 },
flows: 0,
adjustments: { cash_adjustments: 20000, non_cash_adjustments: 0 }
},
{
date: 3.days.ago.to_date,
legacy_balances: { balance: 25000, cash_balance: 25000 },
balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 25000, end_non_cash: 0, end: 25000 },
flows: { cash_inflows: 5000, cash_outflows: 0 },
adjustments: 0
},
{
date: 2.days.ago.to_date,
legacy_balances: { balance: 17000, cash_balance: 17000 },
balances: { start: 25000, start_cash: 25000, start_non_cash: 0, end_cash: 17000, end_non_cash: 0, end: 17000 },
flows: 0,
adjustments: { cash_adjustments: -8000, non_cash_adjustments: 0 }
},
{
date: 1.day.ago.to_date,
legacy_balances: { balance: 17500, cash_balance: 17500 },
balances: { start: 17000, start_cash: 17000, start_non_cash: 0, end_cash: 17500, end_non_cash: 0, end: 17500 },
flows: { cash_inflows: 500, cash_outflows: 0 },
adjustments: 0
}
]
)
end
test "accounts with transactions in multiple currencies convert to the account currency" do
test "accounts with transactions in multiple currencies convert to the account currency and flows are stored in account currency" do
account = create_account_with_ledger(
account: { type: Depository, balance: 20000, cash_balance: 20000, currency: "USD" },
account: { type: Depository, currency: "USD" },
entries: [
{ type: "opening_anchor", date: 4.days.ago.to_date, balance: 100 },
{ type: "transaction", date: 3.days.ago.to_date, amount: -100 },
@ -237,11 +366,35 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
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 } ]
expected_data: [
{
date: 4.days.ago.to_date,
legacy_balances: { balance: 100, cash_balance: 100 },
balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 100, end_non_cash: 0, end: 100 },
flows: 0,
adjustments: { cash_adjustments: 100, non_cash_adjustments: 0 }
},
{
date: 3.days.ago.to_date,
legacy_balances: { balance: 200, cash_balance: 200 },
balances: { start: 100, start_cash: 100, start_non_cash: 0, end_cash: 200, end_non_cash: 0, end: 200 },
flows: { cash_inflows: 100, cash_outflows: 0 },
adjustments: 0
},
{
date: 2.days.ago.to_date,
legacy_balances: { balance: 500, cash_balance: 500 },
balances: { start: 200, start_cash: 200, start_non_cash: 0, end_cash: 500, end_non_cash: 0, end: 500 },
flows: { cash_inflows: 300, cash_outflows: 0 },
adjustments: 0
},
{
date: 1.day.ago.to_date,
legacy_balances: { balance: 1100, cash_balance: 1100 },
balances: { start: 500, start_cash: 500, start_non_cash: 0, end_cash: 1100, end_non_cash: 0, end: 1100 },
flows: { cash_inflows: 600, cash_outflows: 0 }, # Cash inflow is the USD equivalent of €500 (converted for balances table)
adjustments: 0
}
]
)
end
@ -249,7 +402,7 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
# 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" },
account: { type: Loan, currency: "USD" },
entries: [
{ type: "opening_anchor", date: 2.days.ago.to_date, balance: 20000 },
# "Loan payment" of $2000, which reduces the principal
@ -263,9 +416,21 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
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 } ]
expected_data: [
{
date: 2.days.ago.to_date,
legacy_balances: { balance: 20000, cash_balance: 0 },
balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 0, end_non_cash: 20000, end: 20000 },
flows: 0,
adjustments: { cash_adjustments: 0, non_cash_adjustments: 20000 } # Valuations adjust non-cash balance for non-cash accounts like Loans
},
{
date: 1.day.ago.to_date,
legacy_balances: { balance: 18000, cash_balance: 0 },
balances: { start: 20000, start_cash: 0, start_non_cash: 20000, end_cash: 0, end_non_cash: 18000, end: 18000 },
flows: { non_cash_inflows: 2000, non_cash_outflows: 0, cash_inflows: 0, cash_outflows: 0 }, # Loans are "special cases" where transactions do affect non-cash balance
adjustments: 0
}
]
)
end
@ -273,7 +438,7 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
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" },
account: { type: account_type, currency: "USD" },
entries: [
{ type: "opening_anchor", date: 3.days.ago.to_date, balance: 500000 },
@ -286,9 +451,21 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
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 } ]
expected_data: [
{
date: 3.days.ago.to_date,
legacy_balances: { balance: 500000, cash_balance: 0 },
balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 0, end_non_cash: 500000, end: 500000 },
flows: 0,
adjustments: { cash_adjustments: 0, non_cash_adjustments: 500000 }
},
{
date: 2.days.ago.to_date,
legacy_balances: { balance: 500000, cash_balance: 0 },
balances: { start: 500000, start_cash: 0, start_non_cash: 500000, end_cash: 0, end_non_cash: 500000, end: 500000 },
flows: 0, # Despite having a transaction, non-cash accounts ignore it for balance calculation
adjustments: 0
}
]
)
end
@ -304,7 +481,7 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
# 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" },
account: { type: Investment, currency: "USD" },
entries: [
# Account starts with brokerage cash of $5000 and no holdings
{ type: "opening_anchor", date: 3.days.ago.to_date, balance: 5000 },
@ -314,7 +491,7 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
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 }
{ date: Date.current, ticker: "AAPL", qty: 10, price: 110, amount: 1100 } # Price increased by 10%, so holdings value goes up by $100 without a trade
]
)
@ -324,17 +501,40 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
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 } ]
expected_data: [
{
date: 3.days.ago.to_date,
legacy_balances: { balance: 5000, cash_balance: 5000 },
balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 5000, end_non_cash: 0, end: 5000 },
flows: 0,
adjustments: { cash_adjustments: 5000, non_cash_adjustments: 0 }
},
{
date: 2.days.ago.to_date,
legacy_balances: { balance: 5000, cash_balance: 5000 },
balances: { start: 5000, start_cash: 5000, start_non_cash: 0, end_cash: 5000, end_non_cash: 0, end: 5000 },
flows: 0,
adjustments: 0
},
{
date: 1.day.ago.to_date,
legacy_balances: { balance: 5000, cash_balance: 4000 },
balances: { start: 5000, start_cash: 5000, start_non_cash: 0, end_cash: 4000, end_non_cash: 1000, end: 5000 },
flows: { cash_inflows: 0, cash_outflows: 1000, non_cash_inflows: 1000, non_cash_outflows: 0, net_market_flows: 0 }, # Decrease cash by 1000, increase holdings by 1000 (i.e. "buy" of $1000 worth of AAPL)
adjustments: 0
},
{
date: Date.current,
legacy_balances: { balance: 5100, cash_balance: 4000 },
balances: { start: 5000, start_cash: 4000, start_non_cash: 1000, end_cash: 4000, end_non_cash: 1100, end: 5100 },
flows: { net_market_flows: 100 }, # Holdings value increased by 100, despite no change in portfolio quantities
adjustments: 0
}
]
)
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)

View file

@ -16,8 +16,14 @@ class Balance::ReverseCalculatorTest < ActiveSupport::TestCase
assert_calculated_ledger_balances(
calculated_data: calculated,
expected_balances: [
[ Date.current, { balance: 20000, cash_balance: 20000 } ]
expected_data: [
{
date: Date.current,
legacy_balances: { balance: 20000, cash_balance: 20000 },
balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 },
flows: 0,
adjustments: { cash_adjustments: 0, non_cash_adjustments: 0 }
}
]
)
end
@ -47,12 +53,42 @@ class Balance::ReverseCalculatorTest < ActiveSupport::TestCase
# 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
expected_data: [
{
date: Date.current,
legacy_balances: { balance: 20000, cash_balance: 20000 },
balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 },
flows: 0,
adjustments: 0
}, # Current anchor
{
date: 1.day.ago,
legacy_balances: { balance: 20000, cash_balance: 20000 },
balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 },
flows: 0,
adjustments: 0
},
{
date: 2.days.ago,
legacy_balances: { balance: 20000, cash_balance: 20000 },
balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 },
flows: 0,
adjustments: 0
},
{
date: 3.days.ago,
legacy_balances: { balance: 20000, cash_balance: 20000 },
balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 },
flows: 0,
adjustments: { cash_adjustments: 0, non_cash_adjustments: 0 }
},
{
date: 4.days.ago,
legacy_balances: { balance: 15000, cash_balance: 15000 },
balances: { start: 15000, start_cash: 15000, start_non_cash: 0, end_cash: 15000, end_non_cash: 0, end: 15000 },
flows: 0,
adjustments: 0
} # Opening anchor
]
)
end
@ -75,9 +111,21 @@ class Balance::ReverseCalculatorTest < ActiveSupport::TestCase
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
expected_data: [
{
date: Date.current,
legacy_balances: { balance: 20000, cash_balance: 10000 },
balances: { start: 20000, start_cash: 10000, start_non_cash: 10000, end_cash: 10000, end_non_cash: 10000, end: 20000 },
flows: { market_flows: 0 },
adjustments: 0
}, # Since $10,000 of holdings, cash has to be $10,000 to reach $20,000 total value
{
date: 1.day.ago,
legacy_balances: { balance: 15000, cash_balance: 5000 },
balances: { start: 15000, start_cash: 5000, start_non_cash: 10000, end_cash: 5000, end_non_cash: 10000, end: 15000 },
flows: { market_flows: 0 },
adjustments: 0
} # Since $10,000 of holdings, cash has to be $5,000 to reach $15,000 total value
]
)
end
@ -87,8 +135,8 @@ class Balance::ReverseCalculatorTest < ActiveSupport::TestCase
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
{ type: "transaction", date: 2.days.ago, amount: 100 }, # expense
{ type: "transaction", date: 4.days.ago, amount: -500 } # income
]
)
@ -96,13 +144,49 @@ class Balance::ReverseCalculatorTest < ActiveSupport::TestCase
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)
expected_data: [
{
date: Date.current,
legacy_balances: { balance: 20000, cash_balance: 20000 },
balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 },
flows: 0,
adjustments: 0
}, # Current balance
{
date: 1.day.ago,
legacy_balances: { balance: 20000, cash_balance: 20000 },
balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 },
flows: 0,
adjustments: 0
}, # No change
{
date: 2.days.ago,
legacy_balances: { balance: 20000, cash_balance: 20000 },
balances: { start: 20100, start_cash: 20100, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 },
flows: { cash_inflows: 0, cash_outflows: 100 },
adjustments: 0
}, # After expense (+100)
{
date: 3.days.ago,
legacy_balances: { balance: 20100, cash_balance: 20100 },
balances: { start: 20100, start_cash: 20100, start_non_cash: 0, end_cash: 20100, end_non_cash: 0, end: 20100 },
flows: 0,
adjustments: 0
}, # Before expense
{
date: 4.days.ago,
legacy_balances: { balance: 20100, cash_balance: 20100 },
balances: { start: 19600, start_cash: 19600, start_non_cash: 0, end_cash: 20100, end_non_cash: 0, end: 20100 },
flows: { cash_inflows: 500, cash_outflows: 0 },
adjustments: 0
}, # After income (-500)
{
date: 5.days.ago,
legacy_balances: { balance: 19600, cash_balance: 19600 },
balances: { start: 19600, start_cash: 19600, start_non_cash: 0, end_cash: 19600, end_non_cash: 0, end: 19600 },
flows: 0,
adjustments: { cash_adjustments: 0, non_cash_adjustments: 0 }
} # After income (-500)
]
)
end
@ -122,13 +206,49 @@ class Balance::ReverseCalculatorTest < ActiveSupport::TestCase
# 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 } ]
expected_data: [
{
date: Date.current,
legacy_balances: { balance: 2000, cash_balance: 2000 },
balances: { start: 2000, start_cash: 2000, start_non_cash: 0, end_cash: 2000, end_non_cash: 0, end: 2000 },
flows: 0,
adjustments: 0
}, # Current balance
{
date: 1.day.ago,
legacy_balances: { balance: 2000, cash_balance: 2000 },
balances: { start: 2000, start_cash: 2000, start_non_cash: 0, end_cash: 2000, end_non_cash: 0, end: 2000 },
flows: 0,
adjustments: 0
}, # No change
{
date: 2.days.ago,
legacy_balances: { balance: 2000, cash_balance: 2000 },
balances: { start: 1900, start_cash: 1900, start_non_cash: 0, end_cash: 2000, end_non_cash: 0, end: 2000 },
flows: { cash_inflows: 0, cash_outflows: 100 },
adjustments: 0
}, # After expense (+100)
{
date: 3.days.ago,
legacy_balances: { balance: 1900, cash_balance: 1900 },
balances: { start: 1900, start_cash: 1900, start_non_cash: 0, end_cash: 1900, end_non_cash: 0, end: 1900 },
flows: 0,
adjustments: 0
}, # Before expense
{
date: 4.days.ago,
legacy_balances: { balance: 1900, cash_balance: 1900 },
balances: { start: 2400, start_cash: 2400, start_non_cash: 0, end_cash: 1900, end_non_cash: 0, end: 1900 },
flows: { cash_inflows: 500, cash_outflows: 0 },
adjustments: 0
}, # After CC payment (-500)
{
date: 5.days.ago,
legacy_balances: { balance: 2400, cash_balance: 2400 },
balances: { start: 2400, start_cash: 2400, start_non_cash: 0, end_cash: 2400, end_non_cash: 0, end: 2400 },
flows: 0,
adjustments: { cash_adjustments: 0, non_cash_adjustments: 0 }
}
]
)
end
@ -150,10 +270,28 @@ class Balance::ReverseCalculatorTest < ActiveSupport::TestCase
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 } ]
expected_data: [
{
date: Date.current,
legacy_balances: { balance: 198000, cash_balance: 0 },
balances: { start: 198000, start_cash: 0, start_non_cash: 198000, end_cash: 0, end_non_cash: 198000, end: 198000 },
flows: 0,
adjustments: 0
},
{
date: 1.day.ago,
legacy_balances: { balance: 198000, cash_balance: 0 },
balances: { start: 200000, start_cash: 0, start_non_cash: 200000, end_cash: 0, end_non_cash: 198000, end: 198000 },
flows: { non_cash_inflows: 2000, non_cash_outflows: 0, cash_inflows: 0, cash_outflows: 0 },
adjustments: 0
},
{
date: 2.days.ago,
legacy_balances: { balance: 200000, cash_balance: 0 },
balances: { start: 200000, start_cash: 0, start_non_cash: 200000, end_cash: 0, end_non_cash: 200000, end: 200000 },
flows: 0,
adjustments: { cash_adjustments: 0, non_cash_adjustments: 0 }
}
]
)
end
@ -174,10 +312,28 @@ class Balance::ReverseCalculatorTest < ActiveSupport::TestCase
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 } ]
expected_data: [
{
date: Date.current,
legacy_balances: { balance: 1000, cash_balance: 0 },
balances: { start: 1000, start_cash: 0, start_non_cash: 1000, end_cash: 0, end_non_cash: 1000, end: 1000 },
flows: 0,
adjustments: 0
},
{
date: 1.day.ago,
legacy_balances: { balance: 1000, cash_balance: 0 },
balances: { start: 1000, start_cash: 0, start_non_cash: 1000, end_cash: 0, end_non_cash: 1000, end: 1000 },
flows: 0,
adjustments: 0
},
{
date: 2.days.ago,
legacy_balances: { balance: 1000, cash_balance: 0 },
balances: { start: 1000, start_cash: 0, start_non_cash: 1000, end_cash: 0, end_non_cash: 1000, end: 1000 },
flows: 0,
adjustments: { cash_adjustments: 0, non_cash_adjustments: 0 }
}
]
)
end
@ -206,10 +362,28 @@ class Balance::ReverseCalculatorTest < ActiveSupport::TestCase
# (the single trade doesn't affect balance; it just alters cash vs. holdings composition)
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)
expected_data: [
{
date: Date.current,
legacy_balances: { balance: 20000, cash_balance: 19000 },
balances: { start: 20000, start_cash: 19000, start_non_cash: 1000, end_cash: 19000, end_non_cash: 1000, end: 20000 },
flows: { market_flows: 0 },
adjustments: 0
}, # Current: $19k cash + $1k holdings (anchor)
{
date: 1.day.ago.to_date,
legacy_balances: { balance: 20000, cash_balance: 19000 },
balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 19000, end_non_cash: 1000, end: 20000 },
flows: { cash_inflows: 0, cash_outflows: 1000, non_cash_inflows: 1000, non_cash_outflows: 0, net_market_flows: 0 },
adjustments: 0
}, # After trade: $19k cash + $1k holdings
{
date: 2.days.ago.to_date,
legacy_balances: { balance: 20000, cash_balance: 20000 },
balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 },
flows: { market_flows: 0 },
adjustments: { cash_adjustments: 0, non_cash_adjustments: 0 }
} # At first, account is 100% cash, no holdings (no trades)
]
)
end
@ -240,10 +414,28 @@ class Balance::ReverseCalculatorTest < ActiveSupport::TestCase
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
expected_data: [
{
date: Date.current,
legacy_balances: { balance: 20000, cash_balance: 19000 },
balances: { start: 20000, start_cash: 19000, start_non_cash: 1000, end_cash: 19000, end_non_cash: 1000, end: 20000 },
flows: { market_flows: 0 },
adjustments: 0
}, # Current: $19k cash + $1k holdings ($500 MSFT, $500 AAPL)
{
date: 1.day.ago.to_date,
legacy_balances: { balance: 20000, cash_balance: 19000 },
balances: { start: 20000, start_cash: 19500, start_non_cash: 500, end_cash: 19000, end_non_cash: 1000, end: 20000 },
flows: { cash_inflows: 0, cash_outflows: 500, non_cash_inflows: 500, non_cash_outflows: 0, market_flows: 0 },
adjustments: 0
}, # After AAPL trade: $19k cash + $1k holdings
{
date: 2.days.ago.to_date,
legacy_balances: { balance: 20000, cash_balance: 19500 },
balances: { start: 19500, start_cash: 19500, start_non_cash: 0, end_cash: 19500, end_non_cash: 500, end: 20000 },
flows: { market_flows: -500 },
adjustments: 0
} # Before AAPL trade: $19.5k cash + $500 MSFT
]
)
end
@ -258,8 +450,9 @@ class Balance::ReverseCalculatorTest < ActiveSupport::TestCase
],
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 }
{ date: Date.current, ticker: "AAPL", qty: 10, price: 100, amount: 1000 },
{ date: 1.day.ago, ticker: "AAPL", qty: 10, price: 100, amount: 1000 },
{ date: 2.days.ago, ticker: "AAPL", qty: 10, price: 100, amount: 1000 }
]
)
@ -267,12 +460,30 @@ class Balance::ReverseCalculatorTest < ActiveSupport::TestCase
assert_calculated_ledger_balances(
calculated_data: calculated,
expected_balances: [
expected_data: [
# 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
{
date: Date.current,
legacy_balances: { balance: 20000, cash_balance: 19000 },
balances: { start: 20000, start_cash: 19000, start_non_cash: 1000, end_cash: 19000, end_non_cash: 1000, end: 20000 },
flows: { market_flows: 0 },
adjustments: 0
},
{
date: 1.day.ago,
legacy_balances: { balance: 20000, cash_balance: 19000 },
balances: { start: 20000, start_cash: 19000, start_non_cash: 1000, end_cash: 19000, end_non_cash: 1000, end: 20000 },
flows: { market_flows: 0 },
adjustments: 0
},
{
date: 2.days.ago,
legacy_balances: { balance: 15000, cash_balance: 14000 },
balances: { start: 15000, start_cash: 14000, start_non_cash: 1000, end_cash: 14000, end_non_cash: 1000, end: 15000 },
flows: { market_flows: 0 },
adjustments: 0
} # Opening anchor sets absolute balance
]
)
end

View file

@ -12,6 +12,8 @@ module LedgerTestingHelper
created_account = families(:empty).accounts.create!(
name: "Test Account",
accountable: account_type.new,
balance: account[:balance] || 0, # Doesn't matter, ledger derives this
cash_balance: account[:cash_balance] || 0, # Doesn't matter, ledger derives this
**account_attrs
)
@ -109,13 +111,20 @@ module LedgerTestingHelper
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 ]
def assert_calculated_ledger_balances(calculated_data:, expected_data:)
# Convert expected data to a hash for easier lookup
# Structure: [ { date:, legacy_balances: { balance:, cash_balance: }, balances: { start:, start_cash:, etc... }, flows: { ... }, adjustments: { ... } } ]
expected_hash = {}
expected_data.each do |data|
expected_hash[data[:date].to_date] = {
legacy_balances: data[:legacy_balances] || {},
balances: data[:balances] || {},
flows: data[:flows] || {},
adjustments: data[:adjustments] || {}
}
end
# Get all unique dates from both calculated and expected data
# Get all unique dates from all data sources
all_dates = (calculated_data.map(&:date) + expected_hash.keys).uniq.sort
# Check each date
@ -126,15 +135,163 @@ module LedgerTestingHelper
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
# Always assert flows_factor is correct based on account classification
expected_flows_factor = calculated_balance.account.classification == "asset" ? 1 : -1
assert_equal expected_flows_factor, calculated_balance.flows_factor,
"Flows factor mismatch for #{date}: expected #{expected_flows_factor} for #{calculated_balance.account.classification} account"
if expected[:cash_balance]
assert_equal expected[:cash_balance], calculated_balance.cash_balance.to_d,
legacy_balances = expected[:legacy_balances]
balances = expected[:balances]
flows = expected[:flows]
adjustments = expected[:adjustments]
# Legacy balance assertions
if legacy_balances.any?
assert_equal legacy_balances[:balance], calculated_balance.balance,
"Balance mismatch for #{date}"
assert_equal legacy_balances[:cash_balance], calculated_balance.cash_balance,
"Cash balance mismatch for #{date}"
end
# Balance assertions
if balances.any?
assert_equal balances[:start_cash], calculated_balance.start_cash_balance,
"Start cash balance mismatch for #{date}" if balances.key?(:start_cash)
assert_equal balances[:start_non_cash], calculated_balance.start_non_cash_balance,
"Start non-cash balance mismatch for #{date}" if balances.key?(:start_non_cash)
# Calculate end_cash_balance using the formula from the migration
if balances.key?(:end_cash)
# Determine flows_factor based on account classification
flows_factor = calculated_balance.account.classification == "asset" ? 1 : -1
expected_end_cash = calculated_balance.start_cash_balance +
((calculated_balance.cash_inflows - calculated_balance.cash_outflows) * flows_factor) +
calculated_balance.cash_adjustments
assert_equal balances[:end_cash], expected_end_cash,
"End cash balance mismatch for #{date}"
end
# Calculate end_non_cash_balance using the formula from the migration
if balances.key?(:end_non_cash)
# Determine flows_factor based on account classification
flows_factor = calculated_balance.account.classification == "asset" ? 1 : -1
expected_end_non_cash = calculated_balance.start_non_cash_balance +
((calculated_balance.non_cash_inflows - calculated_balance.non_cash_outflows) * flows_factor) +
calculated_balance.net_market_flows +
calculated_balance.non_cash_adjustments
assert_equal balances[:end_non_cash], expected_end_non_cash,
"End non-cash balance mismatch for #{date}"
end
# Calculate start_balance using the formula from the migration
if balances.key?(:start)
expected_start = calculated_balance.start_cash_balance + calculated_balance.start_non_cash_balance
assert_equal balances[:start], expected_start,
"Start balance mismatch for #{date}"
end
# Calculate end_balance using the formula from the migration since we're not persisting balances,
# and generated columns are not available until the record is persisted
if balances.key?(:end)
# Determine flows_factor based on account classification
flows_factor = calculated_balance.account.classification == "asset" ? 1 : -1
expected_end_cash_component = calculated_balance.start_cash_balance +
((calculated_balance.cash_inflows - calculated_balance.cash_outflows) * flows_factor) +
calculated_balance.cash_adjustments
expected_end_non_cash_component = calculated_balance.start_non_cash_balance +
((calculated_balance.non_cash_inflows - calculated_balance.non_cash_outflows) * flows_factor) +
calculated_balance.net_market_flows +
calculated_balance.non_cash_adjustments
expected_end = expected_end_cash_component + expected_end_non_cash_component
assert_equal balances[:end], expected_end,
"End balance mismatch for #{date}"
end
end
# Flow assertions
# If flows passed is 0, we assert all columns are 0
if flows.is_a?(Integer) && flows == 0
assert_equal 0, calculated_balance.cash_inflows,
"Cash inflows mismatch for #{date}"
assert_equal 0, calculated_balance.cash_outflows,
"Cash outflows mismatch for #{date}"
assert_equal 0, calculated_balance.non_cash_inflows,
"Non-cash inflows mismatch for #{date}"
assert_equal 0, calculated_balance.non_cash_outflows,
"Non-cash outflows mismatch for #{date}"
assert_equal 0, calculated_balance.net_market_flows,
"Net market flows mismatch for #{date}"
elsif flows.is_a?(Hash) && flows.any?
# Cash flows - must be asserted together
if flows.key?(:cash_inflows) || flows.key?(:cash_outflows)
assert flows.key?(:cash_inflows) && flows.key?(:cash_outflows),
"Cash inflows and outflows must be asserted together for #{date}"
assert_equal flows[:cash_inflows], calculated_balance.cash_inflows,
"Cash inflows mismatch for #{date}"
assert_equal flows[:cash_outflows], calculated_balance.cash_outflows,
"Cash outflows mismatch for #{date}"
end
# Non-cash flows - must be asserted together
if flows.key?(:non_cash_inflows) || flows.key?(:non_cash_outflows)
assert flows.key?(:non_cash_inflows) && flows.key?(:non_cash_outflows),
"Non-cash inflows and outflows must be asserted together for #{date}"
assert_equal flows[:non_cash_inflows], calculated_balance.non_cash_inflows,
"Non-cash inflows mismatch for #{date}"
assert_equal flows[:non_cash_outflows], calculated_balance.non_cash_outflows,
"Non-cash outflows mismatch for #{date}"
end
# Market flows - can be asserted independently
if flows.key?(:net_market_flows)
assert_equal flows[:net_market_flows], calculated_balance.net_market_flows,
"Net market flows mismatch for #{date}"
end
end
# Adjustment assertions
if adjustments.is_a?(Integer) && adjustments == 0
assert_equal 0, calculated_balance.cash_adjustments,
"Cash adjustments mismatch for #{date}"
assert_equal 0, calculated_balance.non_cash_adjustments,
"Non-cash adjustments mismatch for #{date}"
elsif adjustments.is_a?(Hash) && adjustments.any?
assert_equal adjustments[:cash_adjustments], calculated_balance.cash_adjustments,
"Cash adjustments mismatch for #{date}" if adjustments.key?(:cash_adjustments)
assert_equal adjustments[:non_cash_adjustments], calculated_balance.non_cash_adjustments,
"Non-cash adjustments mismatch for #{date}" if adjustments.key?(:non_cash_adjustments)
end
# Temporary assertions during migration (remove after migration complete)
# TODO: Remove these assertions after migration is complete
# Since we're not persisting balances, we calculate the end values
flows_factor = calculated_balance.account.classification == "asset" ? 1 : -1
expected_end_cash = calculated_balance.start_cash_balance +
((calculated_balance.cash_inflows - calculated_balance.cash_outflows) * flows_factor) +
calculated_balance.cash_adjustments
expected_end_balance = expected_end_cash +
calculated_balance.start_non_cash_balance +
((calculated_balance.non_cash_inflows - calculated_balance.non_cash_outflows) * flows_factor) +
calculated_balance.net_market_flows +
calculated_balance.non_cash_adjustments
assert_equal calculated_balance.cash_balance, expected_end_cash,
"Temporary assertion failed: end_cash_balance should equal cash_balance for #{date}"
assert_equal calculated_balance.balance, expected_end_balance,
"Temporary assertion failed: end_balance should equal balance for #{date}"
else
assert_nil calculated_balance, "Unexpected balance calculated for #{date}"
end