Skip to content
This repository was archived by the owner on Jul 27, 2025. It is now read-only.

Commit 3eea5a9

Browse files
authored
Add auto-update strategies for current balance on manual accounts (#2460)
* Add auto-update strategies for current balance on manual accounts * Remove deprecated BalanceUpdater, replace with new methods
1 parent 52333e3 commit 3eea5a9

File tree

13 files changed

+311
-136
lines changed

13 files changed

+311
-136
lines changed

app/controllers/concerns/accountable_resource.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,12 +45,13 @@ def create
4545
def update
4646
# Handle balance update if provided
4747
if account_params[:balance].present?
48-
result = @account.update_balance(balance: account_params[:balance], currency: account_params[:currency])
48+
result = @account.set_current_balance(account_params[:balance].to_d)
4949
unless result.success?
5050
@error_message = result.error_message
5151
render :edit, status: :unprocessable_entity
5252
return
5353
end
54+
@account.sync_later
5455
end
5556

5657
# Update remaining account attributes

app/controllers/properties_controller.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,10 @@ def balances
3737
end
3838

3939
def update_balances
40-
result = @account.update_balance(balance: balance_params[:balance], currency: balance_params[:currency])
40+
result = @account.set_current_balance(balance_params[:balance].to_d)
4141

4242
if result.success?
43-
@success_message = result.updated? ? "Balance updated successfully." : "No changes made. Account is already up to date."
43+
@success_message = "Balance updated successfully."
4444

4545
if @account.active?
4646
render :balances

app/models/account.rb

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -114,11 +114,6 @@ def current_holdings
114114
.order(amount: :desc)
115115
end
116116

117-
118-
def update_balance(balance:, date: Date.current, currency: nil, notes: nil, existing_valuation_id: nil)
119-
Account::BalanceUpdater.new(self, balance:, currency:, date:, notes:, existing_valuation_id:).update
120-
end
121-
122117
def start_date
123118
first_entry_date = entries.minimum(:date) || Date.current
124119
first_entry_date - 1.day
@@ -146,4 +141,23 @@ def short_subtype_label
146141
def long_subtype_label
147142
accountable_class.long_subtype_label_for(subtype) || accountable_class.display_name
148143
end
144+
145+
# The balance type determines which "component" of balance is being tracked.
146+
# This is primarily used for balance related calculations and updates.
147+
#
148+
# "Cash" = "Liquid"
149+
# "Non-cash" = "Illiquid"
150+
# "Investment" = A mix of both, including brokerage cash (liquid) and holdings (illiquid)
151+
def balance_type
152+
case accountable_type
153+
when "Depository", "CreditCard"
154+
:cash
155+
when "Property", "Vehicle", "OtherAsset", "Loan", "OtherLiability"
156+
:non_cash
157+
when "Investment", "Crypto"
158+
:investment
159+
else
160+
raise "Unknown account type: #{accountable_type}"
161+
end
162+
end
149163
end

app/models/account/anchorable.rb

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@ module Account::Anchorable
1010
end
1111

1212
def set_opening_anchor_balance(**opts)
13-
opening_balance_manager.set_opening_balance(**opts)
13+
result = opening_balance_manager.set_opening_balance(**opts)
14+
sync_later if result.success?
15+
result
1416
end
1517

1618
def opening_anchor_date
@@ -25,8 +27,10 @@ def has_opening_anchor?
2527
opening_balance_manager.has_opening_anchor?
2628
end
2729

28-
def set_current_anchor_balance(balance)
29-
current_balance_manager.set_current_balance(balance)
30+
def set_current_balance(balance)
31+
result = current_balance_manager.set_current_balance(balance)
32+
sync_later if result.success?
33+
result
3034
end
3135

3236
def current_anchor_balance

app/models/account/balance_updater.rb

Lines changed: 0 additions & 53 deletions
This file was deleted.

app/models/account/current_balance_manager.rb

Lines changed: 64 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -31,22 +31,77 @@ def current_date
3131
end
3232

3333
def set_current_balance(balance)
34-
# A current balance anchor implies there is an external data source that will keep it updated. Since manual accounts
35-
# are tracked by the user, a current balance anchor is not appropriate.
36-
raise InvalidOperation, "Manual accounts cannot set current balance anchor. Set opening balance or use a reconciliation instead." if account.manual?
37-
38-
if current_anchor_valuation
39-
changes_made = update_current_anchor(balance)
40-
Result.new(success?: true, changes_made?: changes_made, error: nil)
34+
if account.linked?
35+
result = set_current_balance_for_linked_account(balance)
4136
else
42-
create_current_anchor(balance)
43-
Result.new(success?: true, changes_made?: true, error: nil)
37+
result = set_current_balance_for_manual_account(balance)
4438
end
39+
40+
# Update cache field so changes appear immediately to the user
41+
account.update!(balance: balance)
42+
43+
result
44+
rescue => e
45+
Result.new(success?: false, changes_made?: false, error: e.message)
4546
end
4647

4748
private
4849
attr_reader :account
4950

51+
def opening_balance_manager
52+
@opening_balance_manager ||= Account::OpeningBalanceManager.new(account)
53+
end
54+
55+
def reconciliation_manager
56+
@reconciliation_manager ||= Account::ReconciliationManager.new(account)
57+
end
58+
59+
# Manual accounts do not manage the `current_anchor` valuation (otherwise, user would need to continually update it, which is bad UX)
60+
# Instead, we use a combination of "auto-update strategies" to set the current balance according to the user's intent.
61+
#
62+
# The "auto-update strategies" are:
63+
# 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
64+
# 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
65+
# gets us to the desired balance. This ensures we don't append unnecessary reconciliations to the account, which "reset" the value from that
66+
# date forward (not user's intent).
67+
#
68+
# For more documentation on these auto-update strategies, see the test cases.
69+
def set_current_balance_for_manual_account(balance)
70+
# 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)
71+
if account.balance_type == :cash && account.valuations.reconciliation.empty?
72+
adjust_opening_balance_with_delta(new_balance: balance, old_balance: account.balance)
73+
else
74+
existing_reconciliation = account.entries.valuations.find_by(date: Date.current)
75+
76+
result = reconciliation_manager.reconcile_balance(balance: balance, date: Date.current, existing_valuation_entry: existing_reconciliation)
77+
78+
# Normalize to expected result format
79+
Result.new(success?: result.success?, changes_made?: true, error: result.error_message)
80+
end
81+
end
82+
83+
def adjust_opening_balance_with_delta(new_balance:, old_balance:)
84+
delta = new_balance - old_balance
85+
86+
result = opening_balance_manager.set_opening_balance(balance: account.opening_anchor_balance + delta)
87+
88+
# Normalize to expected result format
89+
Result.new(success?: result.success?, changes_made?: true, error: result.error)
90+
end
91+
92+
# Linked accounts manage "current balance" via the special `current_anchor` valuation.
93+
# This is NOT a user-facing feature, and is primarily used in "processors" while syncing
94+
# linked account data (e.g. via Plaid)
95+
def set_current_balance_for_linked_account(balance)
96+
if current_anchor_valuation
97+
changes_made = update_current_anchor(balance)
98+
Result.new(success?: true, changes_made?: changes_made, error: nil)
99+
else
100+
create_current_anchor(balance)
101+
Result.new(success?: true, changes_made?: true, error: nil)
102+
end
103+
end
104+
50105
def current_anchor_valuation
51106
@current_anchor_valuation ||= account.valuations.current_anchor.includes(:entry).first
52107
end

app/models/account/reconcileable.rb

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,15 @@ module Account::Reconcileable
22
extend ActiveSupport::Concern
33

44
def create_reconciliation(balance:, date:, dry_run: false)
5-
reconciliation_manager.reconcile_balance(balance: balance, date: date, dry_run: dry_run)
5+
result = reconciliation_manager.reconcile_balance(balance: balance, date: date, dry_run: dry_run)
6+
sync_later if result.success?
7+
result
68
end
79

810
def update_reconciliation(existing_valuation_entry, balance:, date:, dry_run: false)
9-
reconciliation_manager.reconcile_balance(balance: balance, date: date, existing_valuation_entry: existing_valuation_entry, dry_run: dry_run)
11+
result = reconciliation_manager.reconcile_balance(balance: balance, date: date, existing_valuation_entry: existing_valuation_entry, dry_run: dry_run)
12+
sync_later if result.success?
13+
result
1014
end
1115

1216
private

app/models/account/reconciliation_manager.rb

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ def reconcile_balance(balance:, date: Date.current, dry_run: false, existing_val
1212

1313
unless dry_run
1414
prepared_valuation.save!
15-
account.sync_later
1615
end
1716

1817
ReconciliationResult.new(

app/models/balance/base_calculator.rb

Lines changed: 5 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,9 @@ def holdings_value_for_date(date)
2020
end
2121

2222
def derive_cash_balance_on_date_from_total(total_balance:, date:)
23-
if balance_type == :investment
23+
if account.balance_type == :investment
2424
total_balance - holdings_value_for_date(date)
25-
elsif balance_type == :cash
25+
elsif account.balance_type == :cash
2626
total_balance
2727
else
2828
0
@@ -32,7 +32,7 @@ def derive_cash_balance_on_date_from_total(total_balance:, date:)
3232
def derive_cash_balance(cash_balance, date)
3333
entries = sync_cache.get_entries(date)
3434

35-
if balance_type == :non_cash
35+
if account.balance_type == :non_cash
3636
0
3737
else
3838
cash_balance + signed_entry_flows(entries)
@@ -42,9 +42,9 @@ def derive_cash_balance(cash_balance, date)
4242
def derive_non_cash_balance(non_cash_balance, date, direction: :forward)
4343
entries = sync_cache.get_entries(date)
4444
# Loans are a special case (loan payment reducing principal, which is non-cash)
45-
if balance_type == :non_cash && account.accountable_type == "Loan"
45+
if account.balance_type == :non_cash && account.accountable_type == "Loan"
4646
non_cash_balance + signed_entry_flows(entries)
47-
elsif balance_type == :investment
47+
elsif account.balance_type == :investment
4848
# For reverse calculations, we need the previous day's holdings
4949
target_date = direction == :forward ? date : date.prev_day
5050
holdings_value_for_date(target_date)
@@ -57,19 +57,6 @@ def signed_entry_flows(entries)
5757
raise NotImplementedError, "Directional calculators must implement this method"
5858
end
5959

60-
def balance_type
61-
case account.accountable_type
62-
when "Depository", "CreditCard"
63-
:cash
64-
when "Property", "Vehicle", "OtherAsset", "Loan", "OtherLiability"
65-
:non_cash
66-
when "Investment", "Crypto"
67-
:investment
68-
else
69-
raise "Unknown account type: #{account.accountable_type}"
70-
end
71-
end
72-
7360
def build_balance(date:, cash_balance:, non_cash_balance:)
7461
Balance.new(
7562
account_id: account.id,

app/models/plaid_account/processor.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ def process_account!
5757
# to properly track the holdings vs. cash breakdown, but for now we're only tracking
5858
# the total balance in the current anchor. The cash_balance field on the account model
5959
# is still being used for the breakdown.
60-
account.set_current_anchor_balance(balance_calculator.balance)
60+
account.set_current_balance(balance_calculator.balance)
6161
end
6262
end
6363

0 commit comments

Comments
 (0)