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

Commit 15f8d82

Browse files
committed
Account balance anchors
1 parent 662f2c0 commit 15f8d82

File tree

8 files changed

+227
-22
lines changed

8 files changed

+227
-22
lines changed

app/models/account.rb

Lines changed: 14 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -59,28 +59,22 @@ class << self
5959
def create_and_sync(attributes)
6060
attributes[:accountable_attributes] ||= {} # Ensure accountable is created, even if empty
6161
account = new(attributes.merge(cash_balance: attributes[:balance]))
62-
initial_balance = attributes.dig(:accountable_attributes, :initial_balance)&.to_d || 0
63-
64-
transaction do
65-
# Create 2 valuations for new accounts to establish a value history for users to see
66-
account.entries.build(
67-
name: "Current Balance",
68-
date: Date.current,
69-
amount: account.balance,
70-
currency: account.currency,
71-
entryable: Valuation.new
62+
initial_balance = attributes.dig(:accountable_attributes, :initial_balance)&.to_d || account.balance
63+
64+
account.entries.build(
65+
name: Valuation::Name.new("opening_anchor", account.accountable_type).to_s,
66+
date: 2.years.ago.to_date,
67+
amount: initial_balance,
68+
currency: account.currency,
69+
entryable: Valuation.new(
70+
kind: "opening_anchor",
71+
balance: initial_balance,
72+
cash_balance: initial_balance,
73+
currency: account.currency
7274
)
73-
account.entries.build(
74-
name: "Initial Balance",
75-
date: 1.day.ago.to_date,
76-
amount: initial_balance,
77-
currency: account.currency,
78-
entryable: Valuation.new
79-
)
80-
81-
account.save!
82-
end
75+
)
8376

77+
account.save!
8478
account.sync_later
8579
account
8680
end

app/models/account/balance_updater.rb

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ def update
2323

2424
valuation_entry.amount = balance
2525
valuation_entry.currency = currency if currency.present?
26-
valuation_entry.name = "Manual #{account.accountable.balance_display_name} update"
26+
valuation_entry.name = valuation_name(valuation_entry.entryable, account)
2727
valuation_entry.notes = notes if notes.present?
2828
valuation_entry.save!
2929
end
@@ -44,4 +44,8 @@ def update
4444
def requires_update?
4545
date != Date.current || account.balance != balance || account.currency != currency
4646
end
47+
48+
def valuation_name(valuation_entry, account)
49+
Valuation::Name.new(valuation_entry.entryable.kind, account.accountable_type).to_s
50+
end
4751
end

app/models/entry.rb

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@ class Entry < ApplicationRecord
1414
validates :date, uniqueness: { scope: [ :account_id, :entryable_type ] }, if: -> { valuation? }
1515
validates :date, comparison: { greater_than: -> { min_supported_date } }
1616

17+
# To ensure we can recreate balance history solely from Entries, all entries must post on or before the current anchor (i.e. "Current balance"),
18+
# and after the opening anchor (i.e. "Opening balance"). This domain invariant should be enforced by the Account model when adding/modifying entries.
19+
validate :date_after_opening_anchor
20+
validate :date_on_or_before_current_anchor
21+
1722
scope :visible, -> {
1823
joins(:account).where(accounts: { status: [ "draft", "active" ] })
1924
}
@@ -96,4 +101,39 @@ def bulk_update!(bulk_update_params)
96101
all.size
97102
end
98103
end
104+
105+
private
106+
def date_after_opening_anchor
107+
return unless account && date
108+
109+
# Skip validation for anchor valuations themselves
110+
return if valuation? && entryable.kind.in?(%w[opening_anchor current_anchor])
111+
112+
opening_anchor_date = account.valuations
113+
.joins(:entry)
114+
.where(kind: "opening_anchor")
115+
.pluck(Arel.sql("entries.date"))
116+
.first
117+
118+
if opening_anchor_date && date <= opening_anchor_date
119+
errors.add(:date, "must be after the opening balance date (#{opening_anchor_date})")
120+
end
121+
end
122+
123+
def date_on_or_before_current_anchor
124+
return unless account && date
125+
126+
# Skip validation for anchor valuations themselves
127+
return if valuation? && entryable.kind.in?(%w[opening_anchor current_anchor])
128+
129+
current_anchor_date = account.valuations
130+
.joins(:entry)
131+
.where(kind: "current_anchor")
132+
.pluck(Arel.sql("entries.date"))
133+
.first
134+
135+
if current_anchor_date && date > current_anchor_date
136+
errors.add(:date, "must be on or before the current balance date (#{current_anchor_date})")
137+
end
138+
end
99139
end

app/models/valuation.rb

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,29 @@
11
class Valuation < ApplicationRecord
22
include Entryable
3+
4+
enum :kind, {
5+
recon: "recon", # A balance reconciliation that sets the Account balance from this point forward (often defined by user)
6+
snapshot: "snapshot", # An "event-sourcing snapshot", which is purely for performance so less history is required to derive the balance
7+
opening_anchor: "opening_anchor", # Each account has a single opening anchor, which defines the opening balance on the account
8+
current_anchor: "current_anchor" # Each account has a single current anchor, which defines the current balance on the account
9+
}, validate: true
10+
11+
# Each account can have at most 1 opening anchor and 1 current anchor. All valuations between these anchors should
12+
# be either "recon" or "snapshot". This ensures we can reliably construct the account balance history solely from Entries.
13+
validate :unique_anchor_per_account, if: -> { opening_anchor? || current_anchor? }
14+
15+
private
16+
def unique_anchor_per_account
17+
return unless entry&.account
18+
19+
existing_anchor = entry.account.valuations
20+
.joins(:entry)
21+
.where(kind: kind)
22+
.where.not(id: id)
23+
.exists?
24+
25+
if existing_anchor
26+
errors.add(:kind, "#{kind.humanize} already exists for this account")
27+
end
28+
end
329
end

app/models/valuation/name.rb

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
# While typically a view concern, we store the `name` in the DB as a denormalized value to keep our search classes simpler.
2+
# This is a simple class to handle the logic for generating the name.
3+
class Valuation::Name
4+
def initialize(valuation_kind, accountable_type)
5+
@valuation_kind = valuation_kind
6+
@accountable_type = accountable_type
7+
end
8+
9+
def to_s
10+
case valuation_kind
11+
when "opening_anchor"
12+
opening_anchor_name
13+
when "current_anchor"
14+
current_anchor_name
15+
else
16+
recon_name
17+
end
18+
end
19+
20+
private
21+
attr_reader :valuation_kind, :accountable_type
22+
23+
# The start value on the account
24+
def opening_anchor_name
25+
case accountable_type
26+
when "Property"
27+
"Original purchase price"
28+
when "Loan"
29+
"Original principal"
30+
when "Investment"
31+
"Opening account value"
32+
else
33+
"Opening balance"
34+
end
35+
end
36+
37+
# The current value on the account
38+
def current_anchor_name
39+
case accountable_type
40+
when "Property"
41+
"Current market value"
42+
when "Loan"
43+
"Current loan balance"
44+
when "Investment"
45+
"Current account value"
46+
else
47+
"Current balance"
48+
end
49+
end
50+
51+
# Any "reconciliation" in the middle of the timeline, typically an "override" by the user to account
52+
# for missing entries that cause the balance to be incorrect.
53+
def recon_name
54+
case accountable_type
55+
when "Property", "Investment"
56+
"Manual value update"
57+
when "Loan"
58+
"Manual principal update"
59+
else
60+
"Manual balance update"
61+
end
62+
end
63+
end
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
class AddValuationKindFieldForAnchors < ActiveRecord::Migration[7.2]
2+
def up
3+
add_column :valuations, :kind, :string, default: "recon"
4+
add_column :valuations, :balance, :decimal, precision: 19, scale: 4
5+
add_column :valuations, :cash_balance, :decimal, precision: 19, scale: 4
6+
add_column :valuations, :currency, :string
7+
8+
# Copy `amount` from Entry, set both `balance` and `cash_balance` to the same value on all Valuation records, and `currency` from Entry to Valuation
9+
execute <<-SQL
10+
UPDATE valuations
11+
SET
12+
balance = entries.amount,
13+
cash_balance = entries.amount,
14+
currency = entries.currency
15+
FROM entries
16+
WHERE entries.entryable_type = 'Valuation' AND entries.entryable_id = valuations.id
17+
SQL
18+
19+
change_column_null :valuations, :kind, false
20+
change_column_null :valuations, :currency, false
21+
change_column_null :valuations, :balance, false
22+
change_column_null :valuations, :cash_balance, false
23+
end
24+
25+
def down
26+
remove_column :valuations, :kind
27+
remove_column :valuations, :balance
28+
remove_column :valuations, :cash_balance
29+
remove_column :valuations, :currency
30+
end
31+
end

db/schema.rb

Lines changed: 5 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

lib/tasks/data_migration.rake

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,4 +111,47 @@ namespace :data_migration do
111111

112112
puts "✅ Duplicate security migration complete."
113113
end
114+
115+
desc "Migrate account valuation anchors"
116+
# 2025-01-07: Set opening_anchor kinds for valuations to support event-sourced ledger model.
117+
# Manual accounts get their oldest valuation marked as opening_anchor, which acts as the
118+
# starting balance for the account. Current anchors are only used for Plaid accounts.
119+
task migrate_account_valuation_anchors: :environment do
120+
puts "==> Migrating account valuation anchors..."
121+
122+
manual_accounts = Account.manual.includes(valuations: :entry)
123+
total_accounts = manual_accounts.count
124+
accounts_processed = 0
125+
opening_anchors_set = 0
126+
127+
manual_accounts.find_each do |account|
128+
accounts_processed += 1
129+
130+
# Find oldest valuation for opening anchor
131+
oldest_valuation = account.valuations
132+
.joins(:entry)
133+
.order("entries.date ASC, entries.created_at ASC")
134+
.first
135+
136+
if oldest_valuation && !oldest_valuation.opening_anchor?
137+
derived_valuation_name = "#{account.name} Opening Balance"
138+
139+
Account.transaction do
140+
oldest_valuation.update!(kind: "opening_anchor")
141+
oldest_valuation.entry.update!(name: derived_valuation_name)
142+
end
143+
opening_anchors_set += 1
144+
end
145+
146+
if accounts_processed % 100 == 0
147+
puts "[#{accounts_processed}/#{total_accounts}] Processed #{accounts_processed} accounts..."
148+
end
149+
rescue => e
150+
puts "ERROR processing account #{account.id}: #{e.message}"
151+
end
152+
153+
puts "✅ Account valuation anchor migration complete."
154+
puts " Processed: #{accounts_processed} accounts"
155+
puts " Opening anchors set: #{opening_anchors_set}"
156+
end
114157
end

0 commit comments

Comments
 (0)