diff --git a/app/models/account.rb b/app/models/account.rb index 95ba5520..c7cbfd53 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -1,8 +1,5 @@ class Account < ApplicationRecord - InvalidBalanceError = Class.new(StandardError) - - include Syncable, Monetizable, Chartable, Linkable, Enrichable - include AASM + include AASM, Syncable, Chartable, Linkable, Enrichable, Reconcileable validates :name, :balance, :currency, presence: true @@ -17,7 +14,7 @@ class Account < ApplicationRecord has_many :holdings, dependent: :destroy has_many :balances, dependent: :destroy - monetize :balance, :cash_balance, :non_cash_balance + enum :classification, { asset: "asset", liability: "liability" }, validate: { allow_nil: true } @@ -137,86 +134,6 @@ class Account < ApplicationRecord end 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 @@ -245,20 +162,4 @@ 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/account/reconcileable.rb b/app/models/account/reconcileable.rb new file mode 100644 index 00000000..6b6f1adc --- /dev/null +++ b/app/models/account/reconcileable.rb @@ -0,0 +1,109 @@ +# Methods for updating the historical balances of an account (opening, current, and arbitrary date reconciliations) +module Account::Reconcileable + extend ActiveSupport::Concern + + included do + include Monetizable + + monetize :balance, :cash_balance, :non_cash_balance + end + + InvalidBalanceError = Class.new(StandardError) + + # 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 + + 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 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 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 + + private + def opening_anchor_valuation + valuations.opening_anchor.first + end + + def current_anchor_valuation + valuations.current_anchor.first + end +end diff --git a/test/models/account/reconcileable_test.rb b/test/models/account/reconcileable_test.rb new file mode 100644 index 00000000..334e907d --- /dev/null +++ b/test/models/account/reconcileable_test.rb @@ -0,0 +1,200 @@ +require "test_helper" + +class Account::ReconcileableTest < ActiveSupport::TestCase + setup do + @account = @syncable = accounts(:depository) + @family = families(:dylan_family) + 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 + assert_equal "EUR", @account.entries.valuations.first.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/account_test.rb b/test/models/account_test.rb index d7d2a06c..c8eb9749 100644 --- a/test/models/account_test.rb +++ b/test/models/account_test.rb @@ -31,196 +31,4 @@ 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 - assert_equal "EUR", @account.entries.valuations.first.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