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:
parent
347c0a7906
commit
da2045dbd8
13 changed files with 1159 additions and 177 deletions
59
app/data_migrations/balance_component_migrator.rb
Normal file
59
app/data_migrations/balance_component_migrator.rb
Normal 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
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
16
db/schema.rb
generated
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
|
|
160
test/data_migrations/balance_component_migrator_test.rb
Normal file
160
test/data_migrations/balance_component_migrator_test.rb
Normal 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
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue