1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-08-07 06:25:19 +02:00

Use new balance components in activity feed (#2511)

* Balance reconcilations with new components

* Fix materializer and test assumptions

* Fix investment valuation calculations and recon display

* Lint fixes

* Balance series uses new component fields
This commit is contained in:
Zach Gollwitzer 2025-07-23 18:15:14 -04:00 committed by GitHub
parent 3f92fe0f6f
commit f7f6ebb091
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 723 additions and 539 deletions

View file

@ -1,6 +1,8 @@
require "test_helper"
class Balance::ChartSeriesBuilderTest < ActiveSupport::TestCase
include BalanceTestHelper
setup do
end
@ -9,9 +11,9 @@ class Balance::ChartSeriesBuilderTest < ActiveSupport::TestCase
account.balances.destroy_all
# With gaps
account.balances.create!(date: 3.days.ago.to_date, balance: 1000, currency: "USD")
account.balances.create!(date: 1.day.ago.to_date, balance: 1100, currency: "USD")
account.balances.create!(date: Date.current, balance: 1200, currency: "USD")
create_balance(account: account, date: 3.days.ago.to_date, balance: 1000)
create_balance(account: account, date: 1.day.ago.to_date, balance: 1100)
create_balance(account: account, date: Date.current, balance: 1200)
builder = Balance::ChartSeriesBuilder.new(
account_ids: [ account.id ],
@ -38,9 +40,9 @@ class Balance::ChartSeriesBuilderTest < ActiveSupport::TestCase
account = accounts(:depository)
account.balances.destroy_all
account.balances.create!(date: 2.days.ago.to_date, balance: 1000, currency: "USD")
account.balances.create!(date: 1.day.ago.to_date, balance: 1100, currency: "USD")
account.balances.create!(date: Date.current, balance: 1200, currency: "USD")
create_balance(account: account, date: 2.days.ago.to_date, balance: 1000)
create_balance(account: account, date: 1.day.ago.to_date, balance: 1100)
create_balance(account: account, date: Date.current, balance: 1200)
builder = Balance::ChartSeriesBuilder.new(
account_ids: [ account.id ],
@ -68,13 +70,13 @@ class Balance::ChartSeriesBuilderTest < ActiveSupport::TestCase
Balance.destroy_all
asset_account.balances.create!(date: 3.days.ago.to_date, balance: 500, currency: "USD")
asset_account.balances.create!(date: 1.day.ago.to_date, balance: 1000, currency: "USD")
asset_account.balances.create!(date: Date.current, balance: 1000, currency: "USD")
create_balance(account: asset_account, date: 3.days.ago.to_date, balance: 500)
create_balance(account: asset_account, date: 1.day.ago.to_date, balance: 1000)
create_balance(account: asset_account, date: Date.current, balance: 1000)
liability_account.balances.create!(date: 3.days.ago.to_date, balance: 200, currency: "USD")
liability_account.balances.create!(date: 2.days.ago.to_date, balance: 200, currency: "USD")
liability_account.balances.create!(date: Date.current, balance: 100, currency: "USD")
create_balance(account: liability_account, date: 3.days.ago.to_date, balance: 200)
create_balance(account: liability_account, date: 2.days.ago.to_date, balance: 200)
create_balance(account: liability_account, date: Date.current, balance: 100)
builder = Balance::ChartSeriesBuilder.new(
account_ids: [ asset_account.id, liability_account.id ],
@ -98,8 +100,8 @@ class Balance::ChartSeriesBuilderTest < ActiveSupport::TestCase
account = accounts(:credit_card)
account.balances.destroy_all
account.balances.create!(date: 1.day.ago.to_date, balance: 1000, currency: "USD")
account.balances.create!(date: Date.current, balance: 500, currency: "USD")
create_balance(account: account, date: 1.day.ago.to_date, balance: 1000)
create_balance(account: account, date: Date.current, balance: 500)
builder = Balance::ChartSeriesBuilder.new(
account_ids: [ account.id ],

View file

@ -117,9 +117,9 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
{
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 },
balances: { start: 17000, start_cash: 17000, start_non_cash: 0, end_cash: 17000, end_non_cash: 0, end: 17000 },
flows: 0,
adjustments: { cash_adjustments: 17000, non_cash_adjustments: 0 }
adjustments: 0
},
{
date: 2.days.ago.to_date,
@ -151,9 +151,9 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
{
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 },
balances: { start: 17000, start_cash: 0, start_non_cash: 17000, end_cash: 0, end_non_cash: 17000, end: 17000 },
flows: 0,
adjustments: { cash_adjustments: 0, non_cash_adjustments: 17000 }
adjustments: 0
},
{
date: 2.days.ago.to_date,
@ -185,9 +185,9 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
{
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 },
balances: { start: 17000, start_cash: 17000, 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
adjustments: 0
},
{
date: 2.days.ago.to_date,
@ -222,9 +222,9 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
{
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 },
balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 },
flows: 0,
adjustments: { cash_adjustments: 20000, non_cash_adjustments: 0 }
adjustments: 0
},
{
date: 4.days.ago.to_date,
@ -270,9 +270,9 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
{
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 },
balances: { start: 1000, start_cash: 1000, start_non_cash: 0, end_cash: 1000, end_non_cash: 0, end: 1000 },
flows: 0,
adjustments: { cash_adjustments: 1000, non_cash_adjustments: 0 }
adjustments: 0
},
{
date: 4.days.ago.to_date,
@ -318,9 +318,9 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
{
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 },
balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 },
flows: 0,
adjustments: { cash_adjustments: 20000, non_cash_adjustments: 0 }
adjustments: 0
},
{
date: 3.days.ago.to_date,
@ -370,9 +370,9 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
{
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 },
balances: { start: 100, start_cash: 100, start_non_cash: 0, end_cash: 100, end_non_cash: 0, end: 100 },
flows: 0,
adjustments: { cash_adjustments: 100, non_cash_adjustments: 0 }
adjustments: 0
},
{
date: 3.days.ago.to_date,
@ -420,9 +420,9 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
{
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 },
balances: { start: 20000, start_cash: 0, start_non_cash: 20000, 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
adjustments: 0
},
{
date: 1.day.ago.to_date,
@ -455,9 +455,9 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
{
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 },
balances: { start: 500000, start_cash: 0, start_non_cash: 500000, end_cash: 0, end_non_cash: 500000, end: 500000 },
flows: 0,
adjustments: { cash_adjustments: 0, non_cash_adjustments: 500000 }
adjustments: 0
},
{
date: 2.days.ago.to_date,
@ -505,9 +505,9 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
{
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 },
balances: { start: 5000, start_cash: 5000, start_non_cash: 0, end_cash: 5000, end_non_cash: 0, end: 5000 },
flows: 0,
adjustments: { cash_adjustments: 5000, non_cash_adjustments: 0 }
adjustments: 0
},
{
date: 2.days.ago.to_date,
@ -534,6 +534,53 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
)
end
test "investment account can have valuations that override balance" do
account = create_account_with_ledger(
account: { type: Investment, currency: "USD" },
entries: [
{ type: "opening_anchor", date: 2.days.ago.to_date, balance: 5000 },
{ type: "reconciliation", date: 1.day.ago.to_date, balance: 10000 }
],
holdings: [
{ date: 3.days.ago.to_date, ticker: "AAPL", qty: 10, price: 100, amount: 1000 },
{ date: 2.days.ago.to_date, ticker: "AAPL", qty: 10, price: 100, amount: 1000 },
{ date: 1.day.ago.to_date, ticker: "AAPL", qty: 10, price: 110, amount: 1100 },
{ date: Date.current, ticker: "AAPL", qty: 10, price: 120, amount: 1200 }
]
)
# Given constant prices, overall balance (account value) should be constant
# (the single trade doesn't affect balance; it just alters cash vs. holdings composition)
calculated = Balance::ForwardCalculator.new(account).calculate
assert_calculated_ledger_balances(
calculated_data: calculated,
expected_data: [
{
date: 2.days.ago.to_date,
legacy_balances: { balance: 5000, cash_balance: 4000 },
balances: { start: 5000, start_cash: 4000, start_non_cash: 1000, end_cash: 4000, end_non_cash: 1000, end: 5000 },
flows: 0,
adjustments: 0
},
{
date: 1.day.ago.to_date,
legacy_balances: { balance: 10000, cash_balance: 8900 },
balances: { start: 5000, start_cash: 4000, start_non_cash: 1000, end_cash: 8900, end_non_cash: 1100, end: 10000 },
flows: { net_market_flows: 100 },
adjustments: { cash_adjustments: 4900, non_cash_adjustments: 0 }
},
{
date: Date.current,
legacy_balances: { balance: 10100, cash_balance: 8900 },
balances: { start: 10000, start_cash: 8900, start_non_cash: 1100, end_cash: 8900, end_non_cash: 1200, end: 10100 },
flows: { net_market_flows: 100 },
adjustments: 0
}
]
)
end
private
def assert_balances(calculated_data:, expected_balances:)
# Sort calculated data by date to ensure consistent ordering

View file

@ -2,6 +2,7 @@ require "test_helper"
class Balance::MaterializerTest < ActiveSupport::TestCase
include EntriesTestHelper
include BalanceTestHelper
setup do
@account = families(:empty).accounts.create!(
@ -16,36 +17,143 @@ class Balance::MaterializerTest < ActiveSupport::TestCase
test "syncs balances" do
Holding::Materializer.any_instance.expects(:materialize_holdings).returns([]).once
@account.expects(:start_date).returns(2.days.ago.to_date)
expected_balances = [
Balance.new(
date: 1.day.ago.to_date,
balance: 1000,
cash_balance: 1000,
currency: "USD",
start_cash_balance: 500,
start_non_cash_balance: 0,
cash_inflows: 500,
cash_outflows: 0,
non_cash_inflows: 0,
non_cash_outflows: 0,
net_market_flows: 0,
cash_adjustments: 0,
non_cash_adjustments: 0,
flows_factor: 1
),
Balance.new(
date: Date.current,
balance: 1000,
cash_balance: 1000,
currency: "USD",
start_cash_balance: 1000,
start_non_cash_balance: 0,
cash_inflows: 0,
cash_outflows: 0,
non_cash_inflows: 0,
non_cash_outflows: 0,
net_market_flows: 0,
cash_adjustments: 0,
non_cash_adjustments: 0,
flows_factor: 1
)
]
Balance::ForwardCalculator.any_instance.expects(:calculate).returns(
[
Balance.new(date: 1.day.ago.to_date, balance: 1000, cash_balance: 1000, currency: "USD"),
Balance.new(date: Date.current, balance: 1000, cash_balance: 1000, currency: "USD")
]
)
Balance::ForwardCalculator.any_instance.expects(:calculate).returns(expected_balances)
assert_difference "@account.balances.count", 2 do
Balance::Materializer.new(@account, strategy: :forward).materialize_balances
end
assert_balance_fields_persisted(expected_balances)
end
test "purges stale balances and holdings" do
# Balance before start date is stale
@account.expects(:start_date).returns(2.days.ago.to_date).twice
stale_balance = Balance.new(date: 3.days.ago.to_date, balance: 10000, cash_balance: 10000, currency: "USD")
test "purges stale balances outside calculated range" do
# Create existing balances that will be stale
stale_old = create_balance(account: @account, date: 5.days.ago.to_date, balance: 5000)
stale_future = create_balance(account: @account, date: 2.days.from_now.to_date, balance: 15000)
Balance::ForwardCalculator.any_instance.expects(:calculate).returns(
[
stale_balance,
Balance.new(date: 2.days.ago.to_date, balance: 10000, cash_balance: 10000, currency: "USD"),
Balance.new(date: 1.day.ago.to_date, balance: 1000, cash_balance: 1000, currency: "USD"),
Balance.new(date: Date.current, balance: 1000, cash_balance: 1000, currency: "USD")
]
)
# Calculator will return balances for only these dates
expected_balances = [
Balance.new(
date: 2.days.ago.to_date,
balance: 10000,
cash_balance: 10000,
currency: "USD",
start_cash_balance: 10000,
start_non_cash_balance: 0,
cash_inflows: 0,
cash_outflows: 0,
non_cash_inflows: 0,
non_cash_outflows: 0,
net_market_flows: 0,
cash_adjustments: 0,
non_cash_adjustments: 0,
flows_factor: 1
),
Balance.new(
date: 1.day.ago.to_date,
balance: 1000,
cash_balance: 1000,
currency: "USD",
start_cash_balance: 10000,
start_non_cash_balance: 0,
cash_inflows: 0,
cash_outflows: 9000,
non_cash_inflows: 0,
non_cash_outflows: 0,
net_market_flows: 0,
cash_adjustments: 0,
non_cash_adjustments: 0,
flows_factor: 1
),
Balance.new(
date: Date.current,
balance: 1000,
cash_balance: 1000,
currency: "USD",
start_cash_balance: 1000,
start_non_cash_balance: 0,
cash_inflows: 0,
cash_outflows: 0,
non_cash_inflows: 0,
non_cash_outflows: 0,
net_market_flows: 0,
cash_adjustments: 0,
non_cash_adjustments: 0,
flows_factor: 1
)
]
assert_difference "@account.balances.count", 3 do
Balance::ForwardCalculator.any_instance.expects(:calculate).returns(expected_balances)
Holding::Materializer.any_instance.expects(:materialize_holdings).returns([]).once
# Should end up with 3 balances (stale ones deleted, new ones created)
assert_difference "@account.balances.count", 1 do
Balance::Materializer.new(@account, strategy: :forward).materialize_balances
end
# Verify stale balances were deleted
assert_nil @account.balances.find_by(id: stale_old.id)
assert_nil @account.balances.find_by(id: stale_future.id)
# Verify expected balances were persisted
assert_balance_fields_persisted(expected_balances)
end
private
def assert_balance_fields_persisted(expected_balances)
expected_balances.each do |expected|
persisted = @account.balances.find_by(date: expected.date)
assert_not_nil persisted, "Balance for #{expected.date} should be persisted"
# Check all balance component fields
assert_equal expected.balance, persisted.balance
assert_equal expected.cash_balance, persisted.cash_balance
assert_equal expected.start_cash_balance, persisted.start_cash_balance
assert_equal expected.start_non_cash_balance, persisted.start_non_cash_balance
assert_equal expected.cash_inflows, persisted.cash_inflows
assert_equal expected.cash_outflows, persisted.cash_outflows
assert_equal expected.non_cash_inflows, persisted.non_cash_inflows
assert_equal expected.non_cash_outflows, persisted.non_cash_outflows
assert_equal expected.net_market_flows, persisted.net_market_flows
assert_equal expected.cash_adjustments, persisted.cash_adjustments
assert_equal expected.non_cash_adjustments, persisted.non_cash_adjustments
assert_equal expected.flows_factor, persisted.flows_factor
end
end
end