Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 41 additions & 9 deletions app/models/account/current_balance_manager.rb
Original file line number Diff line number Diff line change
Expand Up @@ -92,31 +92,63 @@ def adjust_opening_balance_with_delta(new_balance:, old_balance:)
# 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)
if current_anchor_valuation
changes_made = update_current_anchor(balance)
Result.new(success?: true, changes_made?: changes_made, error: nil)
else
create_current_anchor(balance)
Result.new(success?: true, changes_made?: true, error: nil)
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}, has_amount=#{entry.amount.present?}")

# 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)
entry = account.entries.create!(
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")
)

# Reload associations and clear memoized value so it gets the new anchor
account.valuations.reload
# Clear memoized value so it picks up the new anchor on next access.
@current_anchor_valuation = nil
end

Expand Down
22 changes: 19 additions & 3 deletions app/models/balance/reverse_calculator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ def calculate
# 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(
Expand All @@ -20,6 +21,21 @@ def calculate
)
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
Expand Down Expand Up @@ -73,9 +89,9 @@ def derive_start_non_cash_balance(end_non_cash_balance:, date:)
derive_non_cash_balance(end_non_cash_balance, date, direction: :reverse)
end

# Reverse syncs are a bit different than forward syncs because we do not allow "reconciliation" valuations
# to be used at all. This is primarily to keep the code and the UI easy to understand. For a more detailed
# explanation, see the test suite.
# 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
Expand Down
99 changes: 38 additions & 61 deletions test/models/account/current_balance_manager_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -208,98 +208,75 @@ class Account::CurrentBalanceManagerTest < ActiveSupport::TestCase
assert_equal 1000, @linked_account.balance
end

test "updates existing anchor for linked account" do
test "preserves previous day anchor as reconciliation when updating linked account balance" do
# First create a current anchor
manager = Account::CurrentBalanceManager.new(@linked_account)
result = manager.set_current_balance(1000)
assert result.success?

current_anchor = @linked_account.valuations.current_anchor.first
original_id = current_anchor.id
original_entry_id = current_anchor.entry.id

# Travel to tomorrow to ensure date change
travel_to Date.current + 1.day do
# Now update it
assert_no_difference -> { @linked_account.entries.count } do
assert_no_difference -> { @linked_account.valuations.count } do
result = manager.set_current_balance(2000)
assert result.success?
assert result.changes_made?
end
assert_difference -> { @linked_account.entries.count } => 1,
-> { @linked_account.valuations.count } => 1 do
result = manager.set_current_balance(2000)
assert result.success?
assert result.changes_made?
end

current_anchor.reload
assert_equal original_id, current_anchor.id # Same valuation record
assert_equal original_entry_id, current_anchor.entry.id # Same entry record
assert_equal 2000, current_anchor.entry.amount
assert_equal Date.current, current_anchor.entry.date # Should be updated to current date
# The old anchor should now be a reconciliation
preserved_valuation = Valuation.find(original_id)
assert_equal "reconciliation", preserved_valuation.kind
assert_equal 1000, preserved_valuation.entry.amount
assert_equal Valuation.build_reconciliation_name(@linked_account.accountable_type), preserved_valuation.entry.name
assert_equal Date.yesterday, preserved_valuation.entry.date

# A new current anchor should exist for today
new_anchor = @linked_account.valuations.current_anchor.first
assert_not_equal original_id, new_anchor.id
assert_equal 2000, new_anchor.entry.amount
assert_equal Date.current, new_anchor.entry.date
end

assert_equal 2000, @linked_account.balance
end

test "when no changes made, returns success with no changes made" do
# First create a current anchor
manager = Account::CurrentBalanceManager.new(@linked_account)
result = manager.set_current_balance(1000)
assert result.success?
assert result.changes_made?

# Try to set the same value on the same date
result = manager.set_current_balance(1000)

assert result.success?
assert_not result.changes_made?
assert_nil result.error

assert_equal 1000, @linked_account.balance
end

test "updates only amount when balance changes" do
manager = Account::CurrentBalanceManager.new(@linked_account)

# Create initial anchor
result = manager.set_current_balance(1000)
assert result.success?

current_anchor = @linked_account.valuations.current_anchor.first
original_date = current_anchor.entry.date

# Update only the balance
result = manager.set_current_balance(1500)
assert result.success?
assert result.changes_made?

current_anchor.reload
assert_equal 1500, current_anchor.entry.amount
assert_equal original_date, current_anchor.entry.date # Date should remain the same if on same day

assert_equal 1500, @linked_account.balance
end

test "updates date when called on different day" do
test "does not preserve same-day anchor as reconciliation" do
manager = Account::CurrentBalanceManager.new(@linked_account)

# Create initial anchor
result = manager.set_current_balance(1000)
assert result.success?

current_anchor = @linked_account.valuations.current_anchor.first
original_amount = current_anchor.entry.amount
original_id = current_anchor.id

# Travel to tomorrow and update with same balance
travel_to Date.current + 1.day do
# Try to set the same value on the same date
assert_no_difference -> { @linked_account.entries.count } do
result = manager.set_current_balance(1000)
assert result.success?
assert result.changes_made? # Should be true because date changed
assert_not result.changes_made?
end

current_anchor.reload
assert_equal original_amount, current_anchor.entry.amount
assert_equal Date.current, current_anchor.entry.date # Should be updated to new current date
# Update with different value on the same day
assert_no_difference -> { @linked_account.entries.count } do
assert_no_difference -> { @linked_account.valuations.count } do
result = manager.set_current_balance(1500)
assert result.success?
assert result.changes_made?
end
end

assert_equal 1000, @linked_account.balance
current_anchor.reload
assert_equal original_id, current_anchor.id
assert_equal 1500, current_anchor.entry.amount
assert_equal "current_anchor", current_anchor.kind
assert_equal Date.current, current_anchor.entry.date

assert_equal 1500, @linked_account.balance
end

test "current_balance returns balance from current anchor" do
Expand Down
Loading