diff --git a/app/models/account.rb b/app/models/account.rb index 43e02cfd..09800168 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -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 diff --git a/app/models/account/balance_updater.rb b/app/models/account/balance_updater.rb index d006df3f..1b50a9b5 100644 --- a/app/models/account/balance_updater.rb +++ b/app/models/account/balance_updater.rb @@ -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 diff --git a/app/models/entry.rb b/app/models/entry.rb index 332cbee9..f59810b6 100644 --- a/app/models/entry.rb +++ b/app/models/entry.rb @@ -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 diff --git a/app/models/valuation.rb b/app/models/valuation.rb index 6d1d2b4b..b3a823cf 100644 --- a/app/models/valuation.rb +++ b/app/models/valuation.rb @@ -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 diff --git a/app/models/valuation/name.rb b/app/models/valuation/name.rb new file mode 100644 index 00000000..e88da599 --- /dev/null +++ b/app/models/valuation/name.rb @@ -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 diff --git a/db/migrate/20250707130134_add_valuation_kind_field_for_anchors.rb b/db/migrate/20250707130134_add_valuation_kind_field_for_anchors.rb new file mode 100644 index 00000000..1152ad37 --- /dev/null +++ b/db/migrate/20250707130134_add_valuation_kind_field_for_anchors.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index f1bc9daf..af85f677 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -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| diff --git a/lib/tasks/data_migration.rake b/lib/tasks/data_migration.rake index 509e033c..15408629 100644 --- a/lib/tasks/data_migration.rake +++ b/lib/tasks/data_migration.rake @@ -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