Files
sure/app/models/account/current_balance_manager.rb
CrossDrain ba3b20627d feat(balance): Preserve historical balances as waypoints for linked accounts (#1663)
* 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
2026-05-13 21:27:50 +02:00

176 lines
6.9 KiB
Ruby

class Account::CurrentBalanceManager
InvalidOperation = Class.new(StandardError)
Result = Struct.new(:success?, :changes_made?, :error, keyword_init: true)
def initialize(account)
@account = account
end
def has_current_anchor?
current_anchor_valuation.present?
end
# Our system should always make sure there is a current anchor, and that it is up to date.
# The fallback is provided for backwards compatibility, but should not be relied on since account.balance is a "cached/derived" value.
def current_balance
if current_anchor_valuation
current_anchor_valuation.entry.amount
else
Rails.logger.warn "No current balance anchor found for account #{account.id}. Using cached balance instead, which may be out of date."
account.balance
end
end
def current_date
if current_anchor_valuation
current_anchor_valuation.entry.date
else
Date.current
end
end
def set_current_balance(balance)
if account.linked?
result = set_current_balance_for_linked_account(balance)
else
result = set_current_balance_for_manual_account(balance)
end
# Update cache field so changes appear immediately to the user
account.update!(balance: balance)
result
rescue => e
Result.new(success?: false, changes_made?: false, error: e.message)
end
private
attr_reader :account
def opening_balance_manager
@opening_balance_manager ||= Account::OpeningBalanceManager.new(account)
end
def reconciliation_manager
@reconciliation_manager ||= Account::ReconciliationManager.new(account)
end
# Manual accounts do not manage the `current_anchor` valuation (otherwise, user would need to continually update it, which is bad UX)
# Instead, we use a combination of "auto-update strategies" to set the current balance according to the user's intent.
#
# The "auto-update strategies" are:
# 1. Value tracking - If the account has a reconciliation already, we assume they are tracking the account value primarily with reconciliations, so we append a new one
# 2. Transaction adjustment - If the account doesn't have recons, we assume user is tracking with transactions, so we adjust the opening balance with a delta until it
# gets us to the desired balance. This ensures we don't append unnecessary reconciliations to the account, which "reset" the value from that
# date forward (not user's intent).
#
# For more documentation on these auto-update strategies, see the test cases.
def set_current_balance_for_manual_account(balance)
# If we're dealing with a cash account that has no reconciliations, use "Transaction adjustment" strategy (update opening balance to "back in" to the desired current balance)
if account.balance_type == :cash && account.valuations.reconciliation.empty?
adjust_opening_balance_with_delta(new_balance: balance, old_balance: account.balance)
else
existing_reconciliation = account.entries.valuations.find_by(date: Date.current)
result = reconciliation_manager.reconcile_balance(balance: balance, date: Date.current, existing_valuation_entry: existing_reconciliation)
# Normalize to expected result format
Result.new(success?: result.success?, changes_made?: true, error: result.error_message)
end
end
def adjust_opening_balance_with_delta(new_balance:, old_balance:)
delta = new_balance - old_balance
result = opening_balance_manager.set_opening_balance(balance: account.opening_anchor_balance + delta)
# Normalize to expected result format
Result.new(success?: result.success?, changes_made?: true, error: result.error)
end
# Linked accounts manage "current balance" via the special `current_anchor` valuation.
# This is NOT a user-facing feature, and is primarily used in "processors" while syncing
# linked account data (e.g. via Plaid)
#
# Before overwriting a stale (previous-day) current_anchor, we convert it to a
# reconciliation valuation. This preserves the API-reported balance as a historical
# waypoint that the ReverseCalculator uses for more accurate balance history.
def set_current_balance_for_linked_account(balance)
changes_made = false
ActiveRecord::Base.transaction do
# If an anchor exists from a previous day, preserve it as a reconciliation
# before replacing it with today's fresh anchor.
preserve_anchor_as_reconciliation_if_stale if current_anchor_valuation
# Re-check: the memoized value was cleared if the anchor was converted
if current_anchor_valuation
changes_made = update_current_anchor(balance)
else
create_current_anchor(balance)
changes_made = true
end
end
Result.new(success?: true, changes_made?: changes_made, error: nil)
end
def current_anchor_valuation
@current_anchor_valuation ||= account.valuations.current_anchor.includes(:entry).first
end
# If the existing current_anchor is from a previous day, convert it to a
# reconciliation before overwriting. This accumulates a chain of API-reported
# balance waypoints over time without creating extra entries per sync.
#
# Same-day updates are left in place (no extra reconciliations on repeated syncs).
def preserve_anchor_as_reconciliation_if_stale
entry = current_anchor_valuation.entry
return if entry.date == Date.current # Same-day update — nothing to preserve
current_anchor_valuation.update!(kind: "reconciliation")
entry.update!(name: Valuation.build_reconciliation_name(account.accountable_type))
Rails.logger.info("[AnchorRotation] Converted current_anchor to reconciliation for account #{account.id}, date=#{entry.date}, entry_id=#{entry.id}")
# Clear memoized value so the next check creates a fresh current_anchor.
# The chained scope (.current_anchor.first) always issues a fresh SQL query,
# so we don't need to reload the full association.
@current_anchor_valuation = nil
end
def create_current_anchor(balance)
account.entries.create!(
date: Date.current,
name: Valuation.build_current_anchor_name(account.accountable_type),
amount: balance,
currency: account.currency,
entryable: Valuation.new(kind: "current_anchor")
)
# Clear memoized value so it picks up the new anchor on next access.
@current_anchor_valuation = nil
end
def update_current_anchor(balance)
changes_made = false
# Update associated entry attributes
entry = current_anchor_valuation.entry
if entry.amount != balance
entry.amount = balance
changes_made = true
end
if entry.date != Date.current
entry.date = Date.current
changes_made = true
end
entry.save! if entry.changed?
changes_made
end
end