mirror of
https://github.com/we-promise/sure.git
synced 2026-04-11 16:24:51 +00:00
* feat(balance): incremental ForwardCalculator — only recalculate from changed date forward When a Sync record carries a window_start_date, ForwardCalculator now seeds its starting balances from the persisted DB balance for window_start_date - 1, then iterates only from window_start_date to calc_end_date. This avoids recomputing every daily balance on a long-lived account when a single transaction changes. Key changes: - Account::Syncer passes sync.window_start_date to Balance::Materializer - Balance::Materializer accepts window_start_date and forwards it to ForwardCalculator; purge_stale_balances uses opening_anchor_date as the lower bound in incremental mode so pre-window balances are not deleted - Balance::ForwardCalculator accepts window_start_date; resolve_starting_balances loads end_cash_balance/end_non_cash_balance from the prior DB record and falls back to full recalculation when no prior record exists - Tests added for incremental correctness, fallback behaviour, and purge safety Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> # Conflicts: # app/models/balance/materializer.rb * Enhance fallback logic on ForwardCalculator and Materializer * fix(balance): address CodeRabbit review issues on incremental ForwardCalculator - materializer.rb: handle empty sorted_balances in incremental mode by still purging stale tail balances beyond window_start_date - 1, preventing orphaned future rows when a transaction is deleted and the recalc window produces no rows - materializer_test.rb: stub incremental? alongside calculate in the incremental sync test so the guard in ForwardCalculator#incremental? doesn't raise when @fell_back is nil (never set because calculate was stubbed out) - materializer_test.rb: correct window_start_date in the fallback test from 3.days.ago to 2.days.ago so window_start_date - 1 hits a date with no persisted balance, correctly triggering full recalculation instead of accidentally seeding from the stale wrong_pre_window balance Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(balance): multi-currency fallback to full recalculation and add corresponding tests * address coderabbit comment about test * Make the foreign-currency precondition explicit in the test setup. --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
119 lines
4.0 KiB
Ruby
119 lines
4.0 KiB
Ruby
class Balance::Materializer
|
|
attr_reader :account, :strategy, :security_ids
|
|
|
|
def initialize(account, strategy:, security_ids: nil, window_start_date: nil)
|
|
@account = account
|
|
@strategy = strategy
|
|
@security_ids = security_ids
|
|
@window_start_date = window_start_date
|
|
end
|
|
|
|
def materialize_balances
|
|
Balance.transaction do
|
|
materialize_holdings
|
|
calculate_balances
|
|
|
|
Rails.logger.info("Persisting #{@balances.size} balances")
|
|
persist_balances
|
|
|
|
purge_stale_balances
|
|
|
|
if strategy == :forward
|
|
update_account_info
|
|
end
|
|
end
|
|
end
|
|
|
|
private
|
|
def materialize_holdings
|
|
@holdings = Holding::Materializer.new(account, strategy: strategy, security_ids: security_ids).materialize_holdings
|
|
end
|
|
|
|
def update_account_info
|
|
# 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}")
|
|
|
|
account.update!(
|
|
balance: calculated_balance,
|
|
cash_balance: calculated_cash_balance
|
|
)
|
|
end
|
|
|
|
def calculate_balances
|
|
@balances = calculator.calculate
|
|
end
|
|
|
|
def persist_balances
|
|
current_time = Time.now
|
|
account.balances.upsert_all(
|
|
@balances.map { |b| b.attributes
|
|
.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
|
|
sorted_balances = @balances.sort_by(&:date)
|
|
|
|
if sorted_balances.empty?
|
|
# In incremental forward-sync, even when no balances were calculated for the window
|
|
# (e.g. window_start_date is beyond the last entry), purge stale tail records that
|
|
# now fall beyond the prior-balance boundary so orphaned future rows are cleaned up.
|
|
if strategy == :forward && calculator.incremental? && account.opening_anchor_date <= @window_start_date - 1
|
|
deleted_count = account.balances.delete_by(
|
|
"date < ? OR date > ?",
|
|
account.opening_anchor_date,
|
|
@window_start_date - 1
|
|
)
|
|
Rails.logger.info("Purged #{deleted_count} stale balances") if deleted_count > 0
|
|
end
|
|
return
|
|
end
|
|
|
|
newest_calculated_balance_date = sorted_balances.last.date
|
|
|
|
# In incremental forward-sync mode the calculator only recalculates from
|
|
# window_start_date onward, so balances before that date are still valid.
|
|
# Use opening_anchor_date as the lower purge bound to preserve them.
|
|
# We ask the calculator whether it actually ran incrementally — it may have
|
|
# fallen back to a full recalculation, in which case we use the normal bound.
|
|
oldest_valid_date = if strategy == :forward && calculator.incremental?
|
|
account.opening_anchor_date
|
|
else
|
|
sorted_balances.first.date
|
|
end
|
|
|
|
deleted_count = account.balances.delete_by("date < ? OR date > ?", oldest_valid_date, newest_calculated_balance_date)
|
|
Rails.logger.info("Purged #{deleted_count} stale balances") if deleted_count > 0
|
|
end
|
|
|
|
def calculator
|
|
@calculator ||= if strategy == :reverse
|
|
Balance::ReverseCalculator.new(account)
|
|
else
|
|
Balance::ForwardCalculator.new(account, window_start_date: @window_start_date)
|
|
end
|
|
end
|
|
end
|