1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-08-05 05:25:24 +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

@ -29,16 +29,16 @@ 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
def cash_adjustments_for_date(start_cash, end_cash, net_cash_flows)
return 0 unless account.balance_type != :non_cash
valuation.amount - start_cash - net_cash_flows
end_cash - 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
def non_cash_adjustments_for_date(start_non_cash, end_non_cash, non_cash_flows)
return 0 unless account.balance_type == :non_cash
valuation.amount - start_non_cash - non_cash_flows
end_non_cash - start_non_cash - non_cash_flows
end
# If holdings value goes from $100 -> $200 (change_holdings_value is $100)

View file

@ -8,21 +8,21 @@ class Balance::ChartSeriesBuilder
end
def balance_series
build_series_for(:balance)
build_series_for(:end_balance)
rescue => e
Rails.logger.error "Balance series error: #{e.message} for accounts #{@account_ids}"
raise
end
def cash_balance_series
build_series_for(:cash_balance)
build_series_for(:end_cash_balance)
rescue => e
Rails.logger.error "Cash balance series error: #{e.message} for accounts #{@account_ids}"
raise
end
def holdings_balance_series
build_series_for(:holdings_balance)
build_series_for(:end_holdings_balance)
rescue => e
Rails.logger.error "Holdings balance series error: #{e.message} for accounts #{@account_ids}"
raise
@ -37,13 +37,20 @@ class Balance::ChartSeriesBuilder
def build_series_for(column)
values = query_data.map do |datum|
# Map column names to their start equivalents
previous_column = case column
when :end_balance then :start_balance
when :end_cash_balance then :start_cash_balance
when :end_holdings_balance then :start_holdings_balance
end
Series::Value.new(
date: datum.date,
date_formatted: I18n.l(datum.date, format: :long),
value: Money.new(datum.send(column), currency),
trend: Trend.new(
current: Money.new(datum.send(column), currency),
previous: Money.new(datum.send("previous_#{column}"), currency),
previous: Money.new(datum.send(previous_column), currency),
favorable_direction: favorable_direction
)
)
@ -88,66 +95,57 @@ class Balance::ChartSeriesBuilder
WITH dates AS (
SELECT generate_series(DATE :start_date, DATE :end_date, :interval::interval)::date AS date
UNION DISTINCT
SELECT :end_date::date -- Pass in date to ensure timezone-aware "today" date
), aggregated_balances AS (
SELECT
d.date,
-- Total balance (assets positive, liabilities negative)
SUM(
CASE WHEN accounts.classification = 'asset'
THEN COALESCE(last_bal.balance, 0)
ELSE -COALESCE(last_bal.balance, 0)
END * COALESCE(er.rate, 1) * :sign_multiplier::integer
) AS balance,
-- Cash-only balance
SUM(
CASE WHEN accounts.classification = 'asset'
THEN COALESCE(last_bal.cash_balance, 0)
ELSE -COALESCE(last_bal.cash_balance, 0)
END * COALESCE(er.rate, 1) * :sign_multiplier::integer
) AS cash_balance,
-- Holdings value (balance cash)
SUM(
CASE WHEN accounts.classification = 'asset'
THEN COALESCE(last_bal.balance, 0) - COALESCE(last_bal.cash_balance, 0)
ELSE 0
END * COALESCE(er.rate, 1) * :sign_multiplier::integer
) AS holdings_balance
FROM dates d
JOIN accounts ON accounts.id = ANY(array[:account_ids]::uuid[])
-- Last observation carried forward (LOCF), use the most recent balance on or before the chart date
LEFT JOIN LATERAL (
SELECT b.balance, b.cash_balance
FROM balances b
WHERE b.account_id = accounts.id
AND b.date <= d.date
ORDER BY b.date DESC
LIMIT 1
) last_bal ON TRUE
-- Last observation carried forward (LOCF), use the most recent exchange rate on or before the chart date
LEFT JOIN LATERAL (
SELECT er.rate
FROM exchange_rates er
WHERE er.from_currency = accounts.currency
AND er.to_currency = :target_currency
AND er.date <= d.date
ORDER BY er.date DESC
LIMIT 1
) er ON TRUE
GROUP BY d.date
SELECT :end_date::date -- Ensure end date is included
)
SELECT
date,
balance,
cash_balance,
holdings_balance,
COALESCE(LAG(balance) OVER (ORDER BY date), 0) AS previous_balance,
COALESCE(LAG(cash_balance) OVER (ORDER BY date), 0) AS previous_cash_balance,
COALESCE(LAG(holdings_balance) OVER (ORDER BY date), 0) AS previous_holdings_balance
FROM aggregated_balances
ORDER BY date
d.date,
-- Use flows_factor: already handles asset (+1) vs liability (-1)
COALESCE(SUM(last_bal.end_balance * last_bal.flows_factor * COALESCE(er.rate, 1) * :sign_multiplier::integer), 0) AS end_balance,
COALESCE(SUM(last_bal.end_cash_balance * last_bal.flows_factor * COALESCE(er.rate, 1) * :sign_multiplier::integer), 0) AS end_cash_balance,
-- Holdings only for assets (flows_factor = 1)
COALESCE(SUM(
CASE WHEN last_bal.flows_factor = 1
THEN last_bal.end_non_cash_balance
ELSE 0
END * COALESCE(er.rate, 1) * :sign_multiplier::integer
), 0) AS end_holdings_balance,
-- Previous balances
COALESCE(SUM(last_bal.start_balance * last_bal.flows_factor * COALESCE(er.rate, 1) * :sign_multiplier::integer), 0) AS start_balance,
COALESCE(SUM(last_bal.start_cash_balance * last_bal.flows_factor * COALESCE(er.rate, 1) * :sign_multiplier::integer), 0) AS start_cash_balance,
COALESCE(SUM(
CASE WHEN last_bal.flows_factor = 1
THEN last_bal.start_non_cash_balance
ELSE 0
END * COALESCE(er.rate, 1) * :sign_multiplier::integer
), 0) AS start_holdings_balance
FROM dates d
CROSS JOIN accounts
LEFT JOIN LATERAL (
SELECT b.end_balance,
b.end_cash_balance,
b.end_non_cash_balance,
b.start_balance,
b.start_cash_balance,
b.start_non_cash_balance,
b.flows_factor
FROM balances b
WHERE b.account_id = accounts.id
AND b.date <= d.date
ORDER BY b.date DESC
LIMIT 1
) last_bal ON TRUE
LEFT JOIN LATERAL (
SELECT er.rate
FROM exchange_rates er
WHERE er.from_currency = accounts.currency
AND er.to_currency = :target_currency
AND er.date <= d.date
ORDER BY er.date DESC
LIMIT 1
) er ON TRUE
WHERE accounts.id = ANY(array[:account_ids]::uuid[])
GROUP BY d.date
ORDER BY d.date
SQL
end
end

View file

@ -2,10 +2,10 @@ 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: 0,
total_balance: account.opening_anchor_balance,
date: account.opening_anchor_date
)
start_non_cash_balance = 0
start_non_cash_balance = account.opening_anchor_balance - start_cash_balance
calc_start_date.upto(calc_end_date).map do |date|
valuation = sync_cache.get_valuation(date)
@ -24,6 +24,9 @@ class Balance::ForwardCalculator < Balance::BaseCalculator
flows = flows_for_date(date)
market_value_change = market_value_change_on_date(date, flows)
cash_adjustments = cash_adjustments_for_date(start_cash_balance, end_cash_balance, (flows[:cash_inflows] - flows[:cash_outflows]) * flows_factor)
non_cash_adjustments = non_cash_adjustments_for_date(start_non_cash_balance, end_non_cash_balance, (flows[:non_cash_inflows] - flows[:non_cash_outflows]) * flows_factor)
output_balance = build_balance(
date: date,
balance: end_cash_balance + end_non_cash_balance,
@ -34,8 +37,8 @@ class Balance::ForwardCalculator < Balance::BaseCalculator
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),
cash_adjustments: cash_adjustments,
non_cash_adjustments: non_cash_adjustments,
net_market_flows: market_value_change
)
@ -75,4 +78,8 @@ class Balance::ForwardCalculator < Balance::BaseCalculator
def derive_end_non_cash_balance(start_non_cash_balance:, date:)
derive_non_cash_balance(start_non_cash_balance, date, direction: :forward)
end
def flows_factor
account.asset? ? 1 : -1
end
end

View file

@ -28,9 +28,20 @@ class Balance::Materializer
end
def update_account_info
calculated_balance = @balances.sort_by(&:date).last&.balance || 0
calculated_holdings_value = @holdings.select { |h| h.date == Date.current }.sum(&:amount) || 0
calculated_cash_balance = calculated_balance - calculated_holdings_value
# Query fresh balance from DB to get generated column values
current_balance = account.balances
.where(currency: account.currency)
.order(date: :desc)
.first
if current_balance
calculated_balance = current_balance.end_balance
calculated_cash_balance = current_balance.end_cash_balance
else
# Fallback if no balance exists
calculated_balance = 0
calculated_cash_balance = 0
end
Rails.logger.info("Balance update: cash=#{calculated_cash_balance}, total=#{calculated_balance}")
@ -48,14 +59,23 @@ class Balance::Materializer
current_time = Time.now
account.balances.upsert_all(
@balances.map { |b| b.attributes
.slice("date", "balance", "cash_balance", "currency")
.slice("date", "balance", "cash_balance", "currency",
"start_cash_balance", "start_non_cash_balance",
"cash_inflows", "cash_outflows",
"non_cash_inflows", "non_cash_outflows",
"net_market_flows",
"cash_adjustments", "non_cash_adjustments",
"flows_factor")
.merge("updated_at" => current_time) },
unique_by: %i[account_id date currency]
)
end
def purge_stale_balances
deleted_count = account.balances.delete_by("date < ?", account.start_date)
sorted_balances = @balances.sort_by(&:date)
oldest_calculated_balance_date = sorted_balances.first&.date
newest_calculated_balance_date = sorted_balances.last&.date
deleted_count = account.balances.delete_by("date < ? OR date > ?", oldest_calculated_balance_date, newest_calculated_balance_date)
Rails.logger.info("Purged #{deleted_count} stale balances") if deleted_count > 0
end