mirror of
https://github.com/we-promise/sure.git
synced 2026-05-25 13:34:58 +00:00
* feat(balance): persist daily balance snapshots for linked accounts (SnapTrade, Plaid) When updating a linked account's balance, the previous day's current_anchor is now preserved as a reconciliation valuation before being replaced. This creates a chain of API-reported balance waypoints over time. The ReverseCalculator has been updated to treat these reconciliation valuations as reset points during reverse syncs, ensuring historical balances accurately reflect the known API-reported values even with incomplete transaction history. * fix(balance): don't treat current_anchor as reconciliation waypoint The ReverseCalculator was incorrectly treating the current_anchor valuation (on Date.current) as a reconciliation waypoint, causing it to reset the balance and ignore same-day transactions. This fix adds a check to ensure only true reconciliation entries (entryable.reconciliation?) trigger the reset behavior. Additionally, set_current_balance_for_linked_account is now wrapped in a database transaction to ensure atomicity when preserving stale anchors and creating/updating the current anchor. Logging has been improved to use debug level for amount details. A regression test was added to verify that same-day flows are correctly processed when a current_anchor exists on the current date. * test(account): ensure preserved valuations use correct historical date Add validation that valuation entries created during balance preservation are dated as of yesterday. This prevents future-dated entries and maintains temporal accuracy in financial snapshots. * refactor: remove redundant transaction block and unused method comment in current balance manager * refactor(account): remove redundant valuations reload in CurrentBalanceManager and add regression test for consecutive reconciliation waypoints * refactor: remove redundant transaction block and update anchor rotation log to include entry ID
99 lines
4.4 KiB
Ruby
99 lines
4.4 KiB
Ruby
class Balance::ReverseCalculator < Balance::BaseCalculator
|
|
def calculate
|
|
Rails.logger.tagged("Balance::ReverseCalculator") do
|
|
# Since it's a reverse sync, we're starting with the "end of day" balance components and
|
|
# calculating backwards to derive the "start of day" balance components.
|
|
end_cash_balance = derive_cash_balance_on_date_from_total(
|
|
total_balance: account.current_anchor_balance,
|
|
date: account.current_anchor_date
|
|
)
|
|
end_non_cash_balance = account.current_anchor_balance - end_cash_balance
|
|
|
|
# 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)
|
|
valuation = sync_cache.get_valuation(date)
|
|
|
|
if use_opening_anchor_for_date?(date)
|
|
end_cash_balance = derive_cash_balance_on_date_from_total(
|
|
total_balance: account.opening_anchor_balance,
|
|
date: date
|
|
)
|
|
end_non_cash_balance = account.opening_anchor_balance - end_cash_balance
|
|
|
|
start_cash_balance = end_cash_balance
|
|
start_non_cash_balance = end_non_cash_balance
|
|
market_value_change = 0
|
|
elsif valuation && valuation.entryable.reconciliation?
|
|
# Reconciliation waypoint: reset to the known API-reported balance.
|
|
# These waypoints are created by CurrentBalanceManager when it preserves
|
|
# a stale current_anchor as a reconciliation before replacing it.
|
|
# We derive both cash and non-cash from the total to ensure the split
|
|
# reflects the account's cash ratio on that date.
|
|
end_cash_balance = derive_cash_balance_on_date_from_total(
|
|
total_balance: valuation.amount,
|
|
date: date
|
|
)
|
|
end_non_cash_balance = valuation.amount - end_cash_balance
|
|
|
|
start_cash_balance = end_cash_balance
|
|
start_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)
|
|
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
|
|
|
|
private
|
|
|
|
# Negative entries amount on an "asset" account means, "account value has increased"
|
|
# Negative entries amount on a "liability" account means, "account debt has decreased"
|
|
# Positive entries amount on an "asset" account means, "account value has decreased"
|
|
# Positive entries amount on a "liability" account means, "account debt has increased"
|
|
def signed_entry_flows(entries)
|
|
entry_flows = entries.sum(&:amount)
|
|
account.asset? ? entry_flows : -entry_flows
|
|
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:)
|
|
derive_cash_balance(end_cash_balance, date)
|
|
end
|
|
|
|
# Alias method, for algorithmic clarity
|
|
# Derives non-cash balance, starting from the end-of-day, applying entries in reverse to get the start-of-day balance
|
|
def derive_start_non_cash_balance(end_non_cash_balance:, date:)
|
|
derive_non_cash_balance(end_non_cash_balance, date, direction: :reverse)
|
|
end
|
|
|
|
# Checks if this date should use the opening anchor balance instead of deriving it.
|
|
# Only the opening_anchor_date itself gets this treatment — reconciliation waypoints
|
|
# are handled separately in the calculate loop above.
|
|
def use_opening_anchor_for_date?(date)
|
|
account.has_opening_anchor? && date == account.opening_anchor_date
|
|
end
|
|
end
|