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

Commit 52333e3

Browse files
authored
Add reconciliation manager (#2459)
* Add reconciliation manager * Fix notes editing
1 parent 89cc644 commit 52333e3

File tree

11 files changed

+273
-64
lines changed

11 files changed

+273
-64
lines changed

app/controllers/valuations_controller.rb

Lines changed: 33 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ def confirm_create
55
@account = Current.family.accounts.find(params.dig(:entry, :account_id))
66
@entry = @account.entries.build(entry_params.merge(currency: @account.currency))
77

8+
@reconciliation_dry_run = @entry.account.create_reconciliation(
9+
balance: entry_params[:amount],
10+
date: entry_params[:date],
11+
dry_run: true
12+
)
13+
814
render :confirm_create
915
end
1016

@@ -13,19 +19,28 @@ def confirm_update
1319
@account = @entry.account
1420
@entry.assign_attributes(entry_params.merge(currency: @account.currency))
1521

22+
@reconciliation_dry_run = @entry.account.update_reconciliation(
23+
@entry,
24+
balance: entry_params[:amount],
25+
date: entry_params[:date],
26+
dry_run: true
27+
)
28+
1629
render :confirm_update
1730
end
1831

1932
def create
2033
account = Current.family.accounts.find(params.dig(:entry, :account_id))
21-
result = perform_balance_update(account, entry_params.merge(currency: account.currency))
2234

23-
if result.success?
24-
@success_message = result.updated? ? "Balance updated" : "No changes made. Account is already up to date."
35+
result = account.create_reconciliation(
36+
balance: entry_params[:amount],
37+
date: entry_params[:date],
38+
)
2539

40+
if result.success?
2641
respond_to do |format|
27-
format.html { redirect_back_or_to account_path(account), notice: @success_message }
28-
format.turbo_stream { stream_redirect_back_or_to(account_path(account), notice: @success_message) }
42+
format.html { redirect_back_or_to account_path(account), notice: "Account updated" }
43+
format.turbo_stream { stream_redirect_back_or_to(account_path(account), notice: "Account updated") }
2944
end
3045
else
3146
@error_message = result.error_message
@@ -34,13 +49,22 @@ def create
3449
end
3550

3651
def update
37-
result = perform_balance_update(@entry.account, entry_params.merge(currency: @entry.currency, existing_valuation_id: @entry.id))
52+
# Notes updating is independent of reconciliation, just a simple CRUD operation
53+
@entry.update!(notes: entry_params[:notes]) if entry_params[:notes].present?
3854

39-
if result.success?
55+
if entry_params[:date].present? && entry_params[:amount].present?
56+
result = @entry.account.update_reconciliation(
57+
@entry,
58+
balance: entry_params[:amount],
59+
date: entry_params[:date],
60+
)
61+
end
62+
63+
if result.nil? || result.success?
4064
@entry.reload
4165

4266
respond_to do |format|
43-
format.html { redirect_back_or_to account_path(@entry.account), notice: result.updated? ? "Balance updated" : "No changes made. Account is already up to date." }
67+
format.html { redirect_back_or_to account_path(@entry.account), notice: "Entry updated" }
4468
format.turbo_stream do
4569
render turbo_stream: [
4670
turbo_stream.replace(
@@ -60,17 +84,6 @@ def update
6084

6185
private
6286
def entry_params
63-
params.require(:entry)
64-
.permit(:date, :amount, :currency, :notes)
65-
end
66-
67-
def perform_balance_update(account, params)
68-
account.update_balance(
69-
balance: params[:amount],
70-
date: params[:date],
71-
currency: params[:currency],
72-
notes: params[:notes],
73-
existing_valuation_id: params[:existing_valuation_id]
74-
)
87+
params.require(:entry).permit(:date, :amount, :notes)
7588
end
7689
end

app/models/account.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
class Account < ApplicationRecord
2-
include AASM, Syncable, Monetizable, Chartable, Linkable, Enrichable, Anchorable
2+
include AASM, Syncable, Monetizable, Chartable, Linkable, Enrichable, Anchorable, Reconcileable
33

44
validates :name, :balance, :currency, presence: true
55

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
module Account::Reconcileable
2+
extend ActiveSupport::Concern
3+
4+
def create_reconciliation(balance:, date:, dry_run: false)
5+
reconciliation_manager.reconcile_balance(balance: balance, date: date, dry_run: dry_run)
6+
end
7+
8+
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)
10+
end
11+
12+
private
13+
def reconciliation_manager
14+
@reconciliation_manager ||= Account::ReconciliationManager.new(self)
15+
end
16+
end
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
class Account::ReconciliationManager
2+
attr_reader :account
3+
4+
def initialize(account)
5+
@account = account
6+
end
7+
8+
# Reconciles balance by creating a Valuation entry. If existing valuation is provided, it will be updated instead of creating a new one.
9+
def reconcile_balance(balance:, date: Date.current, dry_run: false, existing_valuation_entry: nil)
10+
old_balance_components = old_balance_components(reconciliation_date: date, existing_valuation_entry: existing_valuation_entry)
11+
prepared_valuation = prepare_reconciliation(balance, date, existing_valuation_entry)
12+
13+
unless dry_run
14+
prepared_valuation.save!
15+
account.sync_later
16+
end
17+
18+
ReconciliationResult.new(
19+
success?: true,
20+
old_cash_balance: old_balance_components[:cash_balance],
21+
old_balance: old_balance_components[:balance],
22+
new_cash_balance: derived_cash_balance(date: date, total_balance: prepared_valuation.amount),
23+
new_balance: prepared_valuation.amount,
24+
error_message: nil
25+
)
26+
rescue => e
27+
ReconciliationResult.new(
28+
success?: false,
29+
error_message: e.message
30+
)
31+
end
32+
33+
private
34+
# Returns before -> after OR error message
35+
ReconciliationResult = Struct.new(
36+
:success?,
37+
:old_cash_balance,
38+
:old_balance,
39+
:new_cash_balance,
40+
:new_balance,
41+
:error_message,
42+
keyword_init: true
43+
)
44+
45+
def prepare_reconciliation(balance, date, existing_valuation)
46+
valuation_record = existing_valuation ||
47+
account.entries.valuations.find_by(date: date) || # In case of conflict, where existing valuation is not passed as arg, but one exists
48+
account.entries.build(
49+
name: Valuation.build_reconciliation_name(account.accountable_type),
50+
entryable: Valuation.new(kind: "reconciliation")
51+
)
52+
53+
valuation_record.assign_attributes(
54+
date: date,
55+
amount: balance,
56+
currency: account.currency
57+
)
58+
59+
valuation_record
60+
end
61+
62+
def derived_cash_balance(date:, total_balance:)
63+
balance_components_for_reconciliation_date = get_balance_components_for_date(date)
64+
65+
return nil unless balance_components_for_reconciliation_date[:balance] && balance_components_for_reconciliation_date[:cash_balance]
66+
67+
# We calculate the existing non-cash balance, which for investments would represents "holdings" for the date of reconciliation
68+
# Since the user is setting "total balance", we have to subtract the existing non-cash balance from the total balance to get the new cash balance
69+
existing_non_cash_balance = balance_components_for_reconciliation_date[:balance] - balance_components_for_reconciliation_date[:cash_balance]
70+
71+
total_balance - existing_non_cash_balance
72+
end
73+
74+
def old_balance_components(reconciliation_date:, existing_valuation_entry: nil)
75+
if existing_valuation_entry
76+
get_balance_components_for_date(existing_valuation_entry.date)
77+
else
78+
get_balance_components_for_date(reconciliation_date)
79+
end
80+
end
81+
82+
def get_balance_components_for_date(date)
83+
balance_record = account.balances.find_by(date: date, currency: account.currency)
84+
85+
{
86+
cash_balance: balance_record&.cash_balance,
87+
balance: balance_record&.balance
88+
}
89+
end
90+
end

app/models/investment.rb

Lines changed: 0 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -28,19 +28,4 @@ def icon
2828
"line-chart"
2929
end
3030
end
31-
32-
def holdings_value_for_date(date)
33-
# Find the most recent holding for each security on or before the given date
34-
# Using a subquery to get the max date for each security
35-
account.holdings
36-
.where(currency: account.currency)
37-
.where("date <= ?", date)
38-
.where("(security_id, date) IN (
39-
SELECT security_id, MAX(date) as max_date
40-
FROM holdings
41-
WHERE account_id = ? AND date <= ?
42-
GROUP BY security_id
43-
)", account.id, date)
44-
.sum(:amount)
45-
end
4631
end

app/views/valuations/_confirmation_contents.html.erb

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
1+
<%# locals: (account:, entry:, reconciliation_dry_run:, is_update:, action_verb:) %>
2+
13
<div class="space-y-4 text-sm text-secondary">
24
<% if account.investment? %>
3-
<% holdings_value = account.investment.holdings_value_for_date(entry.date) %>
4-
<% brokerage_cash = entry.amount - holdings_value %>
5+
<% holdings_value = reconciliation_dry_run.new_balance - reconciliation_dry_run.new_cash_balance %>
6+
<% brokerage_cash = reconciliation_dry_run.new_cash_balance %>
57

68
<p>This will <%= action_verb %> the account value on <span class="font-medium text-primary"><%= entry.date.strftime("%B %d, %Y") %></span> to:</p>
79

810
<div class="bg-container rounded-lg p-4 space-y-2 border border-primary">
911
<div class="flex justify-between">
1012
<span>Total account value</span>
11-
<span class="font-medium text-primary"><%= entry.amount_money.format %></span>
13+
<span class="font-medium text-primary"><%= Money.new(reconciliation_dry_run.new_balance, account.currency).format %></span>
1214
</div>
1315
<div class="flex justify-between text-xs">
1416
<span>Holdings value</span>
@@ -20,7 +22,7 @@
2022
</div>
2123
</div>
2224
<% else %>
23-
<p><%= action_verb.capitalize %>
25+
<p><%= action_verb.capitalize %>
2426
<% if account.depository? %>
2527
account balance
2628
<% elsif account.credit_card? %>
@@ -40,10 +42,10 @@
4042
<% else %>
4143
balance
4244
<% end %>
43-
on <span class="font-medium text-primary"><%= entry.date.strftime("%B %d, %Y") %></span> to
45+
on <span class="font-medium text-primary"><%= entry.date.strftime("%B %d, %Y") %></span> to
4446
<span class="font-medium text-primary"><%= entry.amount_money.format %></span>.
4547
</p>
4648
<% end %>
4749

4850
<p>All future transactions and balances will be recalculated based on this <%= is_update ? "change" : "update" %>.</p>
49-
</div>
51+
</div>

app/views/valuations/confirm_create.html.erb

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,16 @@
11
<%= render DialogComponent.new do |dialog| %>
22
<% dialog.with_header(title: "Confirm new balance") %>
33
<% dialog.with_body do %>
4-
<%= styled_form_with model: @entry, url: valuations_path, class: "space-y-4", data: { turbo: false } do |form| %>
4+
<%= styled_form_with model: @entry, url: valuations_path, class: "space-y-4" do |form| %>
55
<%= form.hidden_field :account_id %>
66
<%= form.hidden_field :date %>
77
<%= form.hidden_field :amount %>
8-
<%= form.hidden_field :currency %>
9-
<%= form.hidden_field :notes %>
108

11-
<%= render "confirmation_contents",
12-
account: @account,
13-
entry: @entry,
14-
action_verb: "set",
9+
<%= render "confirmation_contents",
10+
reconciliation_dry_run: @reconciliation_dry_run,
11+
account: @account,
12+
entry: @entry,
13+
action_verb: "set",
1514
is_update: false %>
1615

1716
<%= form.submit "Confirm" %>
Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,18 @@
11
<%= render DialogComponent.new do |dialog| %>
22
<% dialog.with_header(title: "Update balance") %>
33
<% dialog.with_body do %>
4-
<%= styled_form_with model: @entry, url: valuation_path(@entry), method: :patch, class: "space-y-4", data: { turbo: false } do |form| %>
4+
<%= styled_form_with model: @entry, url: valuation_path(@entry), method: :patch, class: "space-y-4", data: { turbo_frame: :_top } do |form| %>
55
<%= form.hidden_field :date %>
66
<%= form.hidden_field :amount %>
7-
<%= form.hidden_field :currency %>
8-
<%= form.hidden_field :notes %>
97

10-
<%= render "confirmation_contents",
11-
account: @account,
12-
entry: @entry,
13-
action_verb: "update",
8+
<%= render "confirmation_contents",
9+
reconciliation_dry_run: @reconciliation_dry_run,
10+
account: @account,
11+
entry: @entry,
12+
action_verb: "update",
1413
is_update: true %>
1514

1615
<%= form.submit "Update" %>
1716
<% end %>
1817
<% end %>
19-
<% end %>
18+
<% end %>

app/views/valuations/show.html.erb

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,10 +44,7 @@
4444
url: valuation_path(entry),
4545
method: :patch,
4646
class: "space-y-2",
47-
data: { controller: "auto-submit-form" } do |f| %>
48-
<%= f.hidden_field :date, value: entry.date %>
49-
<%= f.hidden_field :amount, value: entry.amount %>
50-
<%= f.hidden_field :currency, value: entry.currency %>
47+
data: { controller: "auto-submit-form", auto_submit_form_trigger_event_value: "blur" } do |f| %>
5148
<%= f.text_area :notes,
5249
label: t(".note_label"),
5350
placeholder: t(".note_placeholder"),

test/controllers/valuations_controller_test.rb

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ class ValuationsControllerTest < ActionDispatch::IntegrationTest
88
@entry = entries(:valuation)
99
end
1010

11-
test "creates entry with basic attributes" do
11+
test "can create reconciliation" do
1212
account = accounts(:investment)
1313

1414
assert_difference [ "Entry.count", "Valuation.count" ], 1 do
@@ -35,14 +35,19 @@ class ValuationsControllerTest < ActionDispatch::IntegrationTest
3535
assert_no_difference [ "Entry.count", "Valuation.count" ] do
3636
patch valuation_url(@entry), params: {
3737
entry: {
38-
amount: 20000,
39-
date: Date.current
38+
amount: 22000,
39+
date: Date.current,
40+
notes: "Test notes"
4041
}
4142
}
4243
end
4344

4445
assert_enqueued_with job: SyncJob
4546

4647
assert_redirected_to account_url(@entry.account)
48+
49+
@entry.reload
50+
assert_equal 22000, @entry.amount
51+
assert_equal "Test notes", @entry.notes
4752
end
4853
end

0 commit comments

Comments
 (0)