diff --git a/app/models/account.rb b/app/models/account.rb index 4421a690..fb074f5e 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -1,4 +1,6 @@ class Account < ApplicationRecord + InvalidBalanceError = Class.new(StandardError) + include Syncable, Monetizable, Chartable, Linkable, Enrichable include AASM @@ -15,7 +17,7 @@ class Account < ApplicationRecord has_many :holdings, dependent: :destroy has_many :balances, dependent: :destroy - monetize :balance, :cash_balance + monetize :balance, :cash_balance, :non_cash_balance enum :classification, { asset: "asset", liability: "liability" }, validate: { allow_nil: true } @@ -126,6 +128,87 @@ class Account < ApplicationRecord Account::BalanceUpdater.new(self, balance:, currency:, date:, notes:).update end + def update_current_balance(balance:, cash_balance:) + raise InvalidBalanceError, "Cash balance cannot exceed balance" if cash_balance > balance + + if opening_anchor_valuation.present? && valuations.where(kind: "recon").empty? + adjust_opening_balance_with_delta(balance:, cash_balance:) + else + reconcile_balance!(balance:, cash_balance:, date: Date.current) + end + end + + def reconcile_balance!(balance:, cash_balance:, date:) + raise InvalidBalanceError, "Cash balance cannot exceed balance" if cash_balance > balance + raise InvalidBalanceError, "Linked accounts cannot be reconciled" if linked? + + existing_valuation = valuations.joins(:entry).where(kind: "recon", entry: { date: Date.current }).first + + if existing_valuation.present? + existing_valuation.update!( + balance: balance, + cash_balance: cash_balance + ) + else + entries.create!( + date: date, + name: Valuation::Name.new("recon", self.accountable_type), + amount: balance, + currency: self.currency, + entryable: Valuation.new( + kind: "recon", + balance: balance, + cash_balance: cash_balance + ) + ) + end + end + + def adjust_opening_balance_with_delta(balance:, cash_balance:) + delta = self.balance - balance + cash_delta = self.cash_balance - cash_balance + + set_or_update_opening_balance!( + balance: balance - delta, + cash_balance: cash_balance - cash_delta + ) + end + + def set_or_update_opening_balance!(balance:, cash_balance:, date: nil) + # A reasonable start date for most accounts to fill up adequate history for graphs + fallback_opening_date = 2.years.ago.to_date + + raise InvalidBalanceError, "Cash balance cannot exceed balance" if cash_balance > balance + + transaction do + if opening_anchor_valuation + opening_anchor_valuation.update!( + balance: balance, + cash_balance: cash_balance + ) + + opening_anchor_valuation.entry.update!(amount: balance) + opening_anchor_valuation.entry.update!(date: date) unless date.nil? + + opening_anchor_valuation + else + entry = entries.create!( + date: date || fallback_opening_date, + name: Valuation::Name.new("opening_anchor", self.accountable_type), + amount: balance, + currency: self.currency, + entryable: Valuation.new( + kind: "opening_anchor", + balance: balance, + cash_balance: cash_balance, + ) + ) + + entry.valuation + end + end + end + def start_date first_entry_date = entries.minimum(:date) || Date.current first_entry_date - 1.day @@ -153,4 +236,20 @@ class Account < ApplicationRecord def long_subtype_label accountable_class.long_subtype_label_for(subtype) || accountable_class.display_name end + + # For depository accounts, this is 0 (total balance is liquid cash) + # For all other accounts, this represents "asset value" or "debt value" + # (i.e. Investment accounts would refer to this as "holdings value") + def non_cash_balance + balance - cash_balance + end + + private + def opening_anchor_valuation + valuations.opening_anchor.first + end + + def current_anchor_valuation + valuations.current_anchor.first + end end diff --git a/app/models/family.rb b/app/models/family.rb index 1f35488f..2a9fc553 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -116,4 +116,74 @@ class Family < ApplicationRecord def self_hoster? Rails.application.config.app_mode.self_hosted? end + + def create_property_account!(name:, current_value:, purchase_price: nil, purchase_date: nil, currency: nil) + Family.transaction do + property = accounts.create!( + name: name, + balance: current_value, + cash_balance: 0, + currency: currency.presence || self.currency, + accountable: Property.new + ) + + property.set_or_update_opening_balance!( + balance: purchase_price || 0, + cash_balance: 0, + date: purchase_date + ) + + property + end + end + + def create_vehicle_account(current_value:, purchase_price: nil, purchase_date: nil, currency: nil) + # TODO + end + + def create_depository_account(current_balance:, opening_date: nil, currency: nil) + # TODO + end + + # Investment account values are built up by adding holdings / trades, not by initializing a "balance" + def create_investment_account(currency: nil) + # TODO + end + + def create_other_asset_account(current_value:, purchase_price: nil, purchase_date: nil, currency: nil) + # TODO + end + + def create_other_liability_account(current_debt:, original_debt: nil, origination_date: nil, currency: nil) + # TODO + end + + # For now, crypto accounts are very simple; we just track overall value + def create_crypto_account(current_value:, currency: nil) + # TODO + end + + def create_credit_card_account(current_debt:, opening_date: nil, currency: nil) + # TODO + end + + def create_loan_account(current_principal:, original_principal: nil, origination_date: nil, currency: nil) + # TODO + end + + def link_depository_account + # TODO + end + + def link_investment_account + # TODO + end + + def link_credit_card_account + # TODO + end + + def link_loan_account + # TODO + end end diff --git a/test/models/account_test.rb b/test/models/account_test.rb index c8eb9749..5cb933d8 100644 --- a/test/models/account_test.rb +++ b/test/models/account_test.rb @@ -31,4 +31,195 @@ class AccountTest < ActiveSupport::TestCase assert_equal "Investments", account.short_subtype_label assert_equal "Investments", account.long_subtype_label end + + # Currency updates earn their own method because updating an account currency incurs + # side effects like recalculating balances, etc. + test "can update the account currency" do + @account.update_currency("EUR") + + assert_equal "EUR", @account.currency + end + + # If a user has an opening balance (valuation) for their manual account and has 1+ transactions, the intent of + # "updating current balance" typically means that their start balance is incorrect. We follow that user intent + # by default and find the delta required, and update the opening balance so that the timeline reflects this current balance + # + # The purpose of this is so we're not cluttering up their timeline with "balance reconciliations" that reset the balance + # on the current date. Our goal is to keep the timeline with as few "Valuations" as possible. + # + # If we ever build a UI that gives user options, this test expectation may require some updates, but for now this + # is the least surprising outcome. + test "when manual account has opening valuation and transactions, adjust opening balance directly with delta" do + account = @family.accounts.create!( + name: "Test", + balance: 900, # the balance after opening valuation + transaction have "synced" (1000 - 100 = 900) + cash_balance: 900, + currency: "USD", + accountable: Depository.new + ) + + account.entries.create!( + date: 1.year.ago.to_date, + name: "Test opening valuation", + amount: 1000, + currency: "USD", + entryable: Valuation.new( + kind: "opening_anchor", + balance: 1000, + cash_balance: 1000 + ) + ) + + account.entries.create!( + date: 10.days.ago.to_date, + name: "Test expense transaction", + amount: 100, + currency: "USD", + entryable: Transaction.new + ) + + # What we're asserting here: + # 1. User creates the account with an opening balance of 1000 + # 2. User creates a transaction of 100, which then reduces the balance to 900 (the current balance value on account above) + # 3. User requests "current balance update" back to 1000, which was their intention + # 4. We adjust the opening balance by the delta (100) to 1100, which is the new opening balance, so that the transaction + # of 100 reduces it down to 1000, which is the current balance they intended. + assert_equal 1, account.valuations.count + assert_equal 1, account.transactions.count + + # No new valuation is appended; we're just adjusting the opening valuation anchor + assert_no_difference "account.entries.count" do + account.update_current_balance(balance: 1000, cash_balance: 1000) + end + + opening_valuation = account.valuations.first + + assert_equal 1100, opening_valuation.balance + assert_equal 1100, opening_valuation.cash_balance + end + + # If the user has a "recon valuation" already (i.e. they applied a "balance override"), the most accurate thing we can do is append + # a new recon valuation to the current day (i.e. "from this day forward, the balance is X"). Any other action risks altering the user's view + # of their balance timeline and makes too many assumptions. + test "when manual account has 1+ reconciling valuations, append a new recon valuation rather than adjusting opening balance" do + account = @family.accounts.create!( + name: "Test", + balance: 1000, + cash_balance: 1000, + currency: "USD", + accountable: Depository.new + ) + + account.entries.create!( + date: 1.year.ago.to_date, + name: "Test opening valuation", + amount: 1000, + currency: "USD", + entryable: Valuation.new( + kind: "opening_anchor", + balance: 1000, + cash_balance: 1000 + ) + ) + + # User is "overriding" the balance to 1200 here + account.entries.create!( + date: 30.days.ago.to_date, + name: "First manual recon valuation", + amount: 1200, + currency: "USD", + entryable: Valuation.new( + kind: "recon", + balance: 1200, + cash_balance: 1200 + ) + ) + + assert_equal 2, account.valuations.count + + # Here, we assume user is once again "overriding" the balance to 1400 + account.update_current_balance(balance: 1400, cash_balance: 1400) + + most_recent_valuation = account.valuations.joins(:entry).order("entries.date DESC").first + + assert_equal 3, account.valuations.count + assert_equal 1400, most_recent_valuation.balance + assert_equal 1400, most_recent_valuation.cash_balance + end + + # Updating "current balance" for a linked account is a pure system operation that manages the "current anchor" valuation + test "updating current balance for linked account modifies current anchor valuation" do + # TODO + end + + # A recon valuation is an override for a user to "reset" the balance from a specific date forward. + # This means, "The balance on X date is Y", which is then used as the new starting point to apply transactions against + test "manual accounts can add recon valuations at any point in the account timeline" do + assert_equal 1, @account.valuations.count + + @account.reconcile_balance!(balance: 1000, cash_balance: 1000, date: 2.days.ago.to_date) + + assert_equal 2, @account.valuations.count + + most_recent_valuation = @account.valuations.joins(:entry).order("entries.date DESC").first + + assert_equal 1000, most_recent_valuation.balance + assert_equal 1000, most_recent_valuation.cash_balance + end + + # While technically valid and possible to calculate, "recon" valuations for a linked account rarely make sense + # and add complexity. If the user has linked to a data provider, we expect the provider to be responsible for + # delivering the correct set of transactions to construct the historical balance + test "recon valuations are invalid for linked accounts" do + linked_account = accounts(:connected) + + assert_raises Account::InvalidBalanceError do + linked_account.reconcile_balance!(balance: 1000, cash_balance: 1000, date: 2.days.ago.to_date) + end + end + + test "sets or updates opening balance" do + Entry.destroy_all + + assert_equal 0, @account.entries.valuations.count + + # Creates non-existent opening valuation + @account.set_or_update_opening_balance!( + balance: 2000, + cash_balance: 2000, + date: 2.days.ago.to_date + ) + + opening_valuation_entry = @account.entries.first + + assert_equal 2000, opening_valuation_entry.amount + assert_equal 2.days.ago.to_date, opening_valuation_entry.date + assert_equal 2000, opening_valuation_entry.valuation.balance + assert_equal 2000, opening_valuation_entry.valuation.cash_balance + + # Updates existing opening valuation + @account.set_or_update_opening_balance!( + balance: 3000, + cash_balance: 3000 + ) + + opening_valuation_entry = @account.entries.first + + assert_equal 3000, opening_valuation_entry.amount + assert_equal 2.days.ago.to_date, opening_valuation_entry.date + assert_equal 3000, opening_valuation_entry.valuation.balance + assert_equal 3000, opening_valuation_entry.valuation.cash_balance + end + + # While we don't allow "recon" valuations for a linked account, we DO allow opening balance updates. This is because + # providers rarely give 100% of the transaction history (usually cuts off at 2 years), which can misrepresent the true + # "opening date" on the account and obscure longer net worth historical graphs. This is an *optional* way for the user + # to get their linked account histories "perfect". + test "can update the opening balance and date for a linked account" do + # TODO + end + + test "can update the opening balance and date for a manual account" do + # TODO + end end diff --git a/test/models/family_test.rb b/test/models/family_test.rb index 0229aa6e..07390db5 100644 --- a/test/models/family_test.rb +++ b/test/models/family_test.rb @@ -4,6 +4,69 @@ class FamilyTest < ActiveSupport::TestCase include SyncableInterfaceTest def setup - @syncable = families(:dylan_family) + @syncable = @family = families(:dylan_family) + end + + test "creates manual property account" do + account = @family.create_property_account!( + name: "My House", + current_value: 450000, + purchase_price: 400000, + purchase_date: 1.year.ago.to_date + ) + + valuations = account.valuations + assert_equal 1, valuations.count + assert_equal "opening_anchor", valuations.first.kind + assert_equal 400000, valuations.first.balance + assert_equal 0, valuations.first.cash_balance + + assert_equal "My House", account.name + assert_equal 450000, account.balance + assert_equal 0, account.cash_balance + end + + test "creates manual vehicle account" do + # TODO + end + + test "creates manual depository account" do + # TODO + end + + test "creates manual investment account" do + # TODO + end + + test "creates manual other asset or liability account" do + # TODO + end + + test "creates manual crypto account" do + # TODO + end + + test "creates manual credit card account" do + # TODO + end + + test "creates manual loan account" do + # TODO + end + + test "creates linked depository account" do + # TODO + end + + test "creates linked investment account" do + # TODO + end + + test "creates linked credit card account" do + # TODO + end + + test "creates linked loan account" do + # TODO end end