mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-08-02 20:15:22 +02:00
Account balance anchors
This commit is contained in:
parent
662f2c04ce
commit
15f8d827b5
8 changed files with 226 additions and 21 deletions
|
@ -59,28 +59,22 @@ class Account < ApplicationRecord
|
|||
def create_and_sync(attributes)
|
||||
attributes[:accountable_attributes] ||= {} # Ensure accountable is created, even if empty
|
||||
account = new(attributes.merge(cash_balance: attributes[:balance]))
|
||||
initial_balance = attributes.dig(:accountable_attributes, :initial_balance)&.to_d || 0
|
||||
initial_balance = attributes.dig(:accountable_attributes, :initial_balance)&.to_d || account.balance
|
||||
|
||||
transaction do
|
||||
# Create 2 valuations for new accounts to establish a value history for users to see
|
||||
account.entries.build(
|
||||
name: "Current Balance",
|
||||
date: Date.current,
|
||||
amount: account.balance,
|
||||
currency: account.currency,
|
||||
entryable: Valuation.new
|
||||
)
|
||||
account.entries.build(
|
||||
name: "Initial Balance",
|
||||
date: 1.day.ago.to_date,
|
||||
amount: initial_balance,
|
||||
currency: account.currency,
|
||||
entryable: Valuation.new
|
||||
account.entries.build(
|
||||
name: Valuation::Name.new("opening_anchor", account.accountable_type).to_s,
|
||||
date: 2.years.ago.to_date,
|
||||
amount: initial_balance,
|
||||
currency: account.currency,
|
||||
entryable: Valuation.new(
|
||||
kind: "opening_anchor",
|
||||
balance: initial_balance,
|
||||
cash_balance: initial_balance,
|
||||
currency: account.currency
|
||||
)
|
||||
)
|
||||
|
||||
account.save!
|
||||
end
|
||||
|
||||
account.save!
|
||||
account.sync_later
|
||||
account
|
||||
end
|
||||
|
|
|
@ -23,7 +23,7 @@ class Account::BalanceUpdater
|
|||
|
||||
valuation_entry.amount = balance
|
||||
valuation_entry.currency = currency if currency.present?
|
||||
valuation_entry.name = "Manual #{account.accountable.balance_display_name} update"
|
||||
valuation_entry.name = valuation_name(valuation_entry.entryable, account)
|
||||
valuation_entry.notes = notes if notes.present?
|
||||
valuation_entry.save!
|
||||
end
|
||||
|
@ -44,4 +44,8 @@ class Account::BalanceUpdater
|
|||
def requires_update?
|
||||
date != Date.current || account.balance != balance || account.currency != currency
|
||||
end
|
||||
|
||||
def valuation_name(valuation_entry, account)
|
||||
Valuation::Name.new(valuation_entry.entryable.kind, account.accountable_type).to_s
|
||||
end
|
||||
end
|
||||
|
|
|
@ -14,6 +14,11 @@ class Entry < ApplicationRecord
|
|||
validates :date, uniqueness: { scope: [ :account_id, :entryable_type ] }, if: -> { valuation? }
|
||||
validates :date, comparison: { greater_than: -> { min_supported_date } }
|
||||
|
||||
# To ensure we can recreate balance history solely from Entries, all entries must post on or before the current anchor (i.e. "Current balance"),
|
||||
# and after the opening anchor (i.e. "Opening balance"). This domain invariant should be enforced by the Account model when adding/modifying entries.
|
||||
validate :date_after_opening_anchor
|
||||
validate :date_on_or_before_current_anchor
|
||||
|
||||
scope :visible, -> {
|
||||
joins(:account).where(accounts: { status: [ "draft", "active" ] })
|
||||
}
|
||||
|
@ -96,4 +101,39 @@ class Entry < ApplicationRecord
|
|||
all.size
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def date_after_opening_anchor
|
||||
return unless account && date
|
||||
|
||||
# Skip validation for anchor valuations themselves
|
||||
return if valuation? && entryable.kind.in?(%w[opening_anchor current_anchor])
|
||||
|
||||
opening_anchor_date = account.valuations
|
||||
.joins(:entry)
|
||||
.where(kind: "opening_anchor")
|
||||
.pluck(Arel.sql("entries.date"))
|
||||
.first
|
||||
|
||||
if opening_anchor_date && date <= opening_anchor_date
|
||||
errors.add(:date, "must be after the opening balance date (#{opening_anchor_date})")
|
||||
end
|
||||
end
|
||||
|
||||
def date_on_or_before_current_anchor
|
||||
return unless account && date
|
||||
|
||||
# Skip validation for anchor valuations themselves
|
||||
return if valuation? && entryable.kind.in?(%w[opening_anchor current_anchor])
|
||||
|
||||
current_anchor_date = account.valuations
|
||||
.joins(:entry)
|
||||
.where(kind: "current_anchor")
|
||||
.pluck(Arel.sql("entries.date"))
|
||||
.first
|
||||
|
||||
if current_anchor_date && date > current_anchor_date
|
||||
errors.add(:date, "must be on or before the current balance date (#{current_anchor_date})")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,3 +1,29 @@
|
|||
class Valuation < ApplicationRecord
|
||||
include Entryable
|
||||
|
||||
enum :kind, {
|
||||
recon: "recon", # A balance reconciliation that sets the Account balance from this point forward (often defined by user)
|
||||
snapshot: "snapshot", # An "event-sourcing snapshot", which is purely for performance so less history is required to derive the balance
|
||||
opening_anchor: "opening_anchor", # Each account has a single opening anchor, which defines the opening balance on the account
|
||||
current_anchor: "current_anchor" # Each account has a single current anchor, which defines the current balance on the account
|
||||
}, validate: true
|
||||
|
||||
# Each account can have at most 1 opening anchor and 1 current anchor. All valuations between these anchors should
|
||||
# be either "recon" or "snapshot". This ensures we can reliably construct the account balance history solely from Entries.
|
||||
validate :unique_anchor_per_account, if: -> { opening_anchor? || current_anchor? }
|
||||
|
||||
private
|
||||
def unique_anchor_per_account
|
||||
return unless entry&.account
|
||||
|
||||
existing_anchor = entry.account.valuations
|
||||
.joins(:entry)
|
||||
.where(kind: kind)
|
||||
.where.not(id: id)
|
||||
.exists?
|
||||
|
||||
if existing_anchor
|
||||
errors.add(:kind, "#{kind.humanize} already exists for this account")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
63
app/models/valuation/name.rb
Normal file
63
app/models/valuation/name.rb
Normal file
|
@ -0,0 +1,63 @@
|
|||
# While typically a view concern, we store the `name` in the DB as a denormalized value to keep our search classes simpler.
|
||||
# This is a simple class to handle the logic for generating the name.
|
||||
class Valuation::Name
|
||||
def initialize(valuation_kind, accountable_type)
|
||||
@valuation_kind = valuation_kind
|
||||
@accountable_type = accountable_type
|
||||
end
|
||||
|
||||
def to_s
|
||||
case valuation_kind
|
||||
when "opening_anchor"
|
||||
opening_anchor_name
|
||||
when "current_anchor"
|
||||
current_anchor_name
|
||||
else
|
||||
recon_name
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
attr_reader :valuation_kind, :accountable_type
|
||||
|
||||
# The start value on the account
|
||||
def opening_anchor_name
|
||||
case accountable_type
|
||||
when "Property"
|
||||
"Original purchase price"
|
||||
when "Loan"
|
||||
"Original principal"
|
||||
when "Investment"
|
||||
"Opening account value"
|
||||
else
|
||||
"Opening balance"
|
||||
end
|
||||
end
|
||||
|
||||
# The current value on the account
|
||||
def current_anchor_name
|
||||
case accountable_type
|
||||
when "Property"
|
||||
"Current market value"
|
||||
when "Loan"
|
||||
"Current loan balance"
|
||||
when "Investment"
|
||||
"Current account value"
|
||||
else
|
||||
"Current balance"
|
||||
end
|
||||
end
|
||||
|
||||
# Any "reconciliation" in the middle of the timeline, typically an "override" by the user to account
|
||||
# for missing entries that cause the balance to be incorrect.
|
||||
def recon_name
|
||||
case accountable_type
|
||||
when "Property", "Investment"
|
||||
"Manual value update"
|
||||
when "Loan"
|
||||
"Manual principal update"
|
||||
else
|
||||
"Manual balance update"
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,31 @@
|
|||
class AddValuationKindFieldForAnchors < ActiveRecord::Migration[7.2]
|
||||
def up
|
||||
add_column :valuations, :kind, :string, default: "recon"
|
||||
add_column :valuations, :balance, :decimal, precision: 19, scale: 4
|
||||
add_column :valuations, :cash_balance, :decimal, precision: 19, scale: 4
|
||||
add_column :valuations, :currency, :string
|
||||
|
||||
# Copy `amount` from Entry, set both `balance` and `cash_balance` to the same value on all Valuation records, and `currency` from Entry to Valuation
|
||||
execute <<-SQL
|
||||
UPDATE valuations
|
||||
SET
|
||||
balance = entries.amount,
|
||||
cash_balance = entries.amount,
|
||||
currency = entries.currency
|
||||
FROM entries
|
||||
WHERE entries.entryable_type = 'Valuation' AND entries.entryable_id = valuations.id
|
||||
SQL
|
||||
|
||||
change_column_null :valuations, :kind, false
|
||||
change_column_null :valuations, :currency, false
|
||||
change_column_null :valuations, :balance, false
|
||||
change_column_null :valuations, :cash_balance, false
|
||||
end
|
||||
|
||||
def down
|
||||
remove_column :valuations, :kind
|
||||
remove_column :valuations, :balance
|
||||
remove_column :valuations, :cash_balance
|
||||
remove_column :valuations, :currency
|
||||
end
|
||||
end
|
6
db/schema.rb
generated
6
db/schema.rb
generated
|
@ -10,7 +10,7 @@
|
|||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.2].define(version: 2025_07_01_161640) do
|
||||
ActiveRecord::Schema[7.2].define(version: 2025_07_07_130134) do
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "pgcrypto"
|
||||
enable_extension "plpgsql"
|
||||
|
@ -780,6 +780,10 @@ ActiveRecord::Schema[7.2].define(version: 2025_07_01_161640) do
|
|||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.jsonb "locked_attributes", default: {}
|
||||
t.string "kind", default: "recon", null: false
|
||||
t.decimal "balance", precision: 19, scale: 4, null: false
|
||||
t.decimal "cash_balance", precision: 19, scale: 4, null: false
|
||||
t.string "currency", null: false
|
||||
end
|
||||
|
||||
create_table "vehicles", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
|
|
|
@ -111,4 +111,47 @@ namespace :data_migration do
|
|||
|
||||
puts "✅ Duplicate security migration complete."
|
||||
end
|
||||
|
||||
desc "Migrate account valuation anchors"
|
||||
# 2025-01-07: Set opening_anchor kinds for valuations to support event-sourced ledger model.
|
||||
# Manual accounts get their oldest valuation marked as opening_anchor, which acts as the
|
||||
# starting balance for the account. Current anchors are only used for Plaid accounts.
|
||||
task migrate_account_valuation_anchors: :environment do
|
||||
puts "==> Migrating account valuation anchors..."
|
||||
|
||||
manual_accounts = Account.manual.includes(valuations: :entry)
|
||||
total_accounts = manual_accounts.count
|
||||
accounts_processed = 0
|
||||
opening_anchors_set = 0
|
||||
|
||||
manual_accounts.find_each do |account|
|
||||
accounts_processed += 1
|
||||
|
||||
# Find oldest valuation for opening anchor
|
||||
oldest_valuation = account.valuations
|
||||
.joins(:entry)
|
||||
.order("entries.date ASC, entries.created_at ASC")
|
||||
.first
|
||||
|
||||
if oldest_valuation && !oldest_valuation.opening_anchor?
|
||||
derived_valuation_name = "#{account.name} Opening Balance"
|
||||
|
||||
Account.transaction do
|
||||
oldest_valuation.update!(kind: "opening_anchor")
|
||||
oldest_valuation.entry.update!(name: derived_valuation_name)
|
||||
end
|
||||
opening_anchors_set += 1
|
||||
end
|
||||
|
||||
if accounts_processed % 100 == 0
|
||||
puts "[#{accounts_processed}/#{total_accounts}] Processed #{accounts_processed} accounts..."
|
||||
end
|
||||
rescue => e
|
||||
puts "ERROR processing account #{account.id}: #{e.message}"
|
||||
end
|
||||
|
||||
puts "✅ Account valuation anchor migration complete."
|
||||
puts " Processed: #{accounts_processed} accounts"
|
||||
puts " Opening anchors set: #{opening_anchors_set}"
|
||||
end
|
||||
end
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue