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
176 lines
6.9 KiB
Ruby
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
|