diff --git a/app/models/account.rb b/app/models/account.rb index 222062b4..1217c5fe 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -1,6 +1,5 @@ class Account < ApplicationRecord - include Syncable, Monetizable, Chartable, Linkable, Enrichable - include AASM + include AASM, Syncable, Monetizable, Chartable, Linkable, Enrichable, Anchorable validates :name, :balance, :currency, presence: true @@ -59,26 +58,14 @@ 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 transaction do - # Create 2 valuations for new accounts to establish a value history for users to see - account.entries.build( - name: Valuation.build_current_anchor_name(account.accountable_type), - date: Date.current, - amount: account.balance, - currency: account.currency, - entryable: Valuation.new - ) - account.entries.build( - name: Valuation.build_opening_anchor_name(account.accountable_type), - date: 1.day.ago.to_date, - amount: initial_balance, - currency: account.currency, - entryable: Valuation.new - ) - account.save! + + manager = Account::OpeningBalanceManager.new(account) + result = manager.set_opening_balance(balance: initial_balance || account.balance) + raise result.error if result.error end account.sync_later diff --git a/app/models/account/anchorable.rb b/app/models/account/anchorable.rb new file mode 100644 index 00000000..750fb067 --- /dev/null +++ b/app/models/account/anchorable.rb @@ -0,0 +1,52 @@ +# All accounts are "anchored" with start/end valuation records, with transactions, +# trades, and reconciliations between them. +module Account::Anchorable + extend ActiveSupport::Concern + + included do + include Monetizable + + monetize :opening_balance + end + + def set_opening_anchor_balance(**opts) + opening_balance_manager.set_opening_balance(**opts) + end + + def opening_anchor_date + opening_balance_manager.opening_date + end + + def opening_anchor_balance + opening_balance_manager.opening_balance + end + + def has_opening_anchor? + opening_balance_manager.has_opening_anchor? + end + + def set_current_anchor_balance(balance) + current_balance_manager.set_current_balance(balance) + end + + def current_anchor_balance + current_balance_manager.current_balance + end + + def current_anchor_date + current_balance_manager.current_date + end + + def has_current_anchor? + current_balance_manager.has_current_anchor? + end + + private + def opening_balance_manager + @opening_balance_manager ||= Account::OpeningBalanceManager.new(self) + end + + def current_balance_manager + @current_balance_manager ||= Account::CurrentBalanceManager.new(self) + end +end diff --git a/app/models/account/balance_updater.rb b/app/models/account/balance_updater.rb index a8100d9b..0eb9a789 100644 --- a/app/models/account/balance_updater.rb +++ b/app/models/account/balance_updater.rb @@ -18,7 +18,7 @@ class Account::BalanceUpdater end valuation_entry = account.entries.valuations.find_or_initialize_by(date: date) do |entry| - entry.entryable = Valuation.new + entry.entryable = Valuation.new(kind: "reconciliation") end valuation_entry.amount = balance diff --git a/app/models/account/current_balance_manager.rb b/app/models/account/current_balance_manager.rb new file mode 100644 index 00000000..24303213 --- /dev/null +++ b/app/models/account/current_balance_manager.rb @@ -0,0 +1,86 @@ +class Account::CurrentBalanceManager + InvalidOperation = Class.new(StandardError) + + Result = Struct.new(:success?, :changes_made?, :error, keyword_init: true) + + def initialize(account) + @account = account + end + + def has_current_anchor? + current_anchor_valuation.present? + end + + # Our system should always make sure there is a current anchor, and that it is up to date. + # The fallback is provided for backwards compatibility, but should not be relied on since account.balance is a "cached/derived" value. + def current_balance + if current_anchor_valuation + current_anchor_valuation.entry.amount + else + Rails.logger.warn "No current balance anchor found for account #{account.id}. Using cached balance instead, which may be out of date." + account.balance + end + end + + def current_date + if current_anchor_valuation + current_anchor_valuation.entry.date + else + Date.current + end + end + + def set_current_balance(balance) + # A current balance anchor implies there is an external data source that will keep it updated. Since manual accounts + # are tracked by the user, a current balance anchor is not appropriate. + raise InvalidOperation, "Manual accounts cannot set current balance anchor. Set opening balance or use a reconciliation instead." if account.manual? + + if current_anchor_valuation + changes_made = update_current_anchor(balance) + Result.new(success?: true, changes_made?: changes_made, error: nil) + else + create_current_anchor(balance) + Result.new(success?: true, changes_made?: true, error: nil) + end + end + + private + attr_reader :account + + def current_anchor_valuation + @current_anchor_valuation ||= account.valuations.current_anchor.includes(:entry).first + end + + def create_current_anchor(balance) + account.entries.create!( + date: Date.current, + name: Valuation.build_current_anchor_name(account.accountable_type), + amount: balance, + currency: account.currency, + entryable: Valuation.new(kind: "current_anchor") + ) + end + + def update_current_anchor(balance) + changes_made = false + + ActiveRecord::Base.transaction do + # Update associated entry attributes + entry = current_anchor_valuation.entry + + if entry.amount != balance + entry.amount = balance + changes_made = true + end + + if entry.date != Date.current + entry.date = Date.current + changes_made = true + end + + entry.save! if entry.changed? + end + + changes_made + end +end diff --git a/app/models/account/linkable.rb b/app/models/account/linkable.rb index ee0871bd..76b41bb1 100644 --- a/app/models/account/linkable.rb +++ b/app/models/account/linkable.rb @@ -15,4 +15,5 @@ module Account::Linkable def unlinked? !linked? end + alias_method :manual?, :unlinked? end diff --git a/app/models/account/opening_balance_manager.rb b/app/models/account/opening_balance_manager.rb new file mode 100644 index 00000000..95597cda --- /dev/null +++ b/app/models/account/opening_balance_manager.rb @@ -0,0 +1,99 @@ +class Account::OpeningBalanceManager + Result = Struct.new(:success?, :changes_made?, :error, keyword_init: true) + + def initialize(account) + @account = account + end + + def has_opening_anchor? + opening_anchor_valuation.present? + end + + # Most accounts should have an opening anchor. If not, we derive the opening date from the oldest entry date + def opening_date + return opening_anchor_valuation.entry.date if opening_anchor_valuation.present? + + [ + account.entries.valuations.order(:date).first&.date, + account.entries.where.not(entryable_type: "Valuation").order(:date).first&.date&.prev_day + ].compact.min || Date.current + end + + def opening_balance + opening_anchor_valuation&.entry&.amount || 0 + end + + def set_opening_balance(balance:, date: nil) + resolved_date = date || default_date + + # Validate date is before oldest entry + if date && oldest_entry_date && resolved_date >= oldest_entry_date + return Result.new(success?: false, changes_made?: false, error: "Opening balance date must be before the oldest entry date") + end + + if opening_anchor_valuation.nil? + create_opening_anchor( + balance: balance, + date: resolved_date + ) + Result.new(success?: true, changes_made?: true, error: nil) + else + changes_made = update_opening_anchor(balance: balance, date: date) + Result.new(success?: true, changes_made?: changes_made, error: nil) + end + end + + private + attr_reader :account + + def opening_anchor_valuation + @opening_anchor_valuation ||= account.valuations.opening_anchor.includes(:entry).first + end + + def oldest_entry_date + @oldest_entry_date ||= account.entries.minimum(:date) + end + + def default_date + if oldest_entry_date + [ oldest_entry_date - 1.day, 2.years.ago.to_date ].min + else + 2.years.ago.to_date + end + end + + def create_opening_anchor(balance:, date:) + account.entries.create!( + date: date, + name: Valuation.build_opening_anchor_name(account.accountable_type), + amount: balance, + currency: account.currency, + entryable: Valuation.new( + kind: "opening_anchor" + ) + ) + end + + def update_opening_anchor(balance:, date: nil) + changes_made = false + + ActiveRecord::Base.transaction do + # Update associated entry attributes + entry = opening_anchor_valuation.entry + + if entry.amount != balance + entry.amount = balance + changes_made = true + end + + if date.present? && entry.date != date + entry.date = date + changes_made = true + end + + entry.save! if entry.changed? + end + + changes_made + end +end diff --git a/app/models/account_import.rb b/app/models/account_import.rb index 4836ce55..0ffdcec4 100644 --- a/app/models/account_import.rb +++ b/app/models/account_import.rb @@ -1,4 +1,6 @@ class AccountImport < Import + OpeningBalanceError = Class.new(StandardError) + def import! transaction do rows.each do |row| @@ -15,13 +17,13 @@ class AccountImport < Import account.save! - account.entries.create!( - amount: row.amount, - currency: row.currency, - date: 2.years.ago.to_date, - name: Valuation.build_opening_anchor_name(account.accountable_type), - entryable: Valuation.new - ) + manager = Account::OpeningBalanceManager.new(account) + result = manager.set_opening_balance(balance: row.amount.to_d) + + # Re-raise since we should never have an error here + if result.error + raise OpeningBalanceError, result.error + end end end end diff --git a/app/models/balance/base_calculator.rb b/app/models/balance/base_calculator.rb new file mode 100644 index 00000000..3360bcec --- /dev/null +++ b/app/models/balance/base_calculator.rb @@ -0,0 +1,82 @@ +class Balance::BaseCalculator + attr_reader :account + + def initialize(account) + @account = account + end + + def calculate + raise NotImplementedError, "Subclasses must implement this method" + end + + private + def sync_cache + @sync_cache ||= Balance::SyncCache.new(account) + end + + def holdings_value_for_date(date) + holdings = sync_cache.get_holdings(date) + holdings.sum(&:amount) + end + + def derive_cash_balance_on_date_from_total(total_balance:, date:) + if balance_type == :investment + total_balance - holdings_value_for_date(date) + elsif balance_type == :cash + total_balance + else + 0 + end + end + + def derive_cash_balance(cash_balance, date) + entries = sync_cache.get_entries(date) + + if balance_type == :non_cash + 0 + else + cash_balance + signed_entry_flows(entries) + end + end + + def derive_non_cash_balance(non_cash_balance, date, direction: :forward) + entries = sync_cache.get_entries(date) + # Loans are a special case (loan payment reducing principal, which is non-cash) + if balance_type == :non_cash && account.accountable_type == "Loan" + non_cash_balance + signed_entry_flows(entries) + elsif balance_type == :investment + # For reverse calculations, we need the previous day's holdings + target_date = direction == :forward ? date : date.prev_day + holdings_value_for_date(target_date) + else + non_cash_balance + end + end + + def signed_entry_flows(entries) + raise NotImplementedError, "Directional calculators must implement this method" + end + + def balance_type + case account.accountable_type + when "Depository", "CreditCard" + :cash + when "Property", "Vehicle", "OtherAsset", "Loan", "OtherLiability" + :non_cash + when "Investment", "Crypto" + :investment + else + raise "Unknown account type: #{account.accountable_type}" + end + end + + def build_balance(date:, cash_balance:, non_cash_balance:) + Balance.new( + account_id: account.id, + date: date, + balance: non_cash_balance + cash_balance, + cash_balance: cash_balance, + currency: account.currency + ) + end +end diff --git a/app/models/balance/forward_calculator.rb b/app/models/balance/forward_calculator.rb index 4e6f2d5c..bd9272b7 100644 --- a/app/models/balance/forward_calculator.rb +++ b/app/models/balance/forward_calculator.rb @@ -1,61 +1,66 @@ -class Balance::ForwardCalculator - attr_reader :account - - def initialize(account) - @account = account - end - +class Balance::ForwardCalculator < Balance::BaseCalculator def calculate Rails.logger.tagged("Balance::ForwardCalculator") do - calculate_balances + start_cash_balance = derive_cash_balance_on_date_from_total( + total_balance: account.opening_anchor_balance, + date: account.opening_anchor_date + ) + start_non_cash_balance = account.opening_anchor_balance - start_cash_balance + + calc_start_date.upto(calc_end_date).map do |date| + valuation = sync_cache.get_reconciliation_valuation(date) + + if valuation + end_cash_balance = derive_cash_balance_on_date_from_total( + total_balance: valuation.amount, + date: date + ) + end_non_cash_balance = valuation.amount - end_cash_balance + else + end_cash_balance = derive_end_cash_balance(start_cash_balance: start_cash_balance, date: date) + end_non_cash_balance = derive_end_non_cash_balance(start_non_cash_balance: start_non_cash_balance, date: date) + end + + output_balance = build_balance( + date: date, + cash_balance: end_cash_balance, + non_cash_balance: end_non_cash_balance + ) + + # Set values for the next iteration + start_cash_balance = end_cash_balance + start_non_cash_balance = end_non_cash_balance + + output_balance + end end end private - def calculate_balances - current_cash_balance = 0 - next_cash_balance = nil - - @balances = [] - - account.start_date.upto(Date.current).each do |date| - entries = sync_cache.get_entries(date) - holdings = sync_cache.get_holdings(date) - holdings_value = holdings.sum(&:amount) - valuation = sync_cache.get_valuation(date) - - next_cash_balance = if valuation - valuation.amount - holdings_value - else - calculate_next_balance(current_cash_balance, entries, direction: :forward) - end - - @balances << build_balance(date, next_cash_balance, holdings_value) - - current_cash_balance = next_cash_balance - end - - @balances + def calc_start_date + account.opening_anchor_date end - def sync_cache - @sync_cache ||= Balance::SyncCache.new(account) + def calc_end_date + [ account.entries.order(:date).last&.date, account.holdings.order(:date).last&.date ].compact.max || Date.current end - def build_balance(date, cash_balance, holdings_value) - Balance.new( - account_id: account.id, - date: date, - balance: holdings_value + cash_balance, - cash_balance: cash_balance, - currency: account.currency - ) + # Negative entries amount on an "asset" account means, "account value has increased" + # Negative entries amount on a "liability" account means, "account debt has decreased" + # Positive entries amount on an "asset" account means, "account value has decreased" + # Positive entries amount on a "liability" account means, "account debt has increased" + def signed_entry_flows(entries) + entry_flows = entries.sum(&:amount) + account.asset? ? -entry_flows : entry_flows end - def calculate_next_balance(prior_balance, transactions, direction: :forward) - flows = transactions.sum(&:amount) - negated = direction == :forward ? account.asset? : account.liability? - flows *= -1 if negated - prior_balance + flows + # Derives cash balance, starting from the start-of-day, applying entries in forward to get the end-of-day balance + def derive_end_cash_balance(start_cash_balance:, date:) + derive_cash_balance(start_cash_balance, date) + end + + # Derives non-cash balance, starting from the start-of-day, applying entries in forward to get the end-of-day balance + def derive_end_non_cash_balance(start_non_cash_balance:, date:) + derive_non_cash_balance(start_non_cash_balance, date, direction: :forward) end end diff --git a/app/models/balance/reverse_calculator.rb b/app/models/balance/reverse_calculator.rb index 52a05608..1e75d5e4 100644 --- a/app/models/balance/reverse_calculator.rb +++ b/app/models/balance/reverse_calculator.rb @@ -1,71 +1,79 @@ -class Balance::ReverseCalculator - attr_reader :account - - def initialize(account) - @account = account - end - +class Balance::ReverseCalculator < Balance::BaseCalculator def calculate Rails.logger.tagged("Balance::ReverseCalculator") do - calculate_balances + # Since it's a reverse sync, we're starting with the "end of day" balance components and + # calculating backwards to derive the "start of day" balance components. + end_cash_balance = derive_cash_balance_on_date_from_total( + total_balance: account.current_anchor_balance, + date: account.current_anchor_date + ) + end_non_cash_balance = account.current_anchor_balance - end_cash_balance + + # Calculates in reverse-chronological order (End of day -> Start of day) + account.current_anchor_date.downto(account.opening_anchor_date).map do |date| + if use_opening_anchor_for_date?(date) + end_cash_balance = derive_cash_balance_on_date_from_total( + total_balance: account.opening_anchor_balance, + date: date + ) + end_non_cash_balance = account.opening_anchor_balance - end_cash_balance + + start_cash_balance = end_cash_balance + start_non_cash_balance = end_non_cash_balance + + build_balance( + date: date, + cash_balance: end_cash_balance, + non_cash_balance: end_non_cash_balance + ) + else + start_cash_balance = derive_start_cash_balance(end_cash_balance: end_cash_balance, date: date) + start_non_cash_balance = derive_start_non_cash_balance(end_non_cash_balance: end_non_cash_balance, date: date) + + # Even though we've just calculated "start" balances, we set today equal to end of day, then use those + # in our next iteration (slightly confusing, but just the nature of a "reverse" sync) + output_balance = build_balance( + date: date, + cash_balance: end_cash_balance, + non_cash_balance: end_non_cash_balance + ) + + end_cash_balance = start_cash_balance + end_non_cash_balance = start_non_cash_balance + + output_balance + end + end end end private - def calculate_balances - current_cash_balance = account.cash_balance - previous_cash_balance = nil - @balances = [] - - Date.current.downto(account.start_date).map do |date| - entries = sync_cache.get_entries(date) - holdings = sync_cache.get_holdings(date) - holdings_value = holdings.sum(&:amount) - valuation = sync_cache.get_valuation(date) - - previous_cash_balance = if valuation - valuation.amount - holdings_value - else - calculate_next_balance(current_cash_balance, entries, direction: :reverse) - end - - if valuation.present? - @balances << build_balance(date, previous_cash_balance, holdings_value) - else - # If date is today, we don't distinguish cash vs. total since provider's are inconsistent with treatment - # of the cash component. Instead, just set the balance equal to the "total value" reported by the provider - if date == Date.current - @balances << build_balance(date, account.cash_balance, account.balance - account.cash_balance) - else - @balances << build_balance(date, current_cash_balance, holdings_value) - end - end - - current_cash_balance = previous_cash_balance - end - - @balances + # Negative entries amount on an "asset" account means, "account value has increased" + # Negative entries amount on a "liability" account means, "account debt has decreased" + # Positive entries amount on an "asset" account means, "account value has decreased" + # Positive entries amount on a "liability" account means, "account debt has increased" + def signed_entry_flows(entries) + entry_flows = entries.sum(&:amount) + account.asset? ? entry_flows : -entry_flows end - def sync_cache - @sync_cache ||= Balance::SyncCache.new(account) + # Reverse syncs are a bit different than forward syncs because we do not allow "reconciliation" valuations + # to be used at all. This is primarily to keep the code and the UI easy to understand. For a more detailed + # explanation, see the test suite. + def use_opening_anchor_for_date?(date) + account.has_opening_anchor? && date == account.opening_anchor_date end - def build_balance(date, cash_balance, holdings_value) - Balance.new( - account_id: account.id, - date: date, - balance: holdings_value + cash_balance, - cash_balance: cash_balance, - currency: account.currency - ) + # Alias method, for algorithmic clarity + # Derives cash balance, starting from the end-of-day, applying entries in reverse to get the start-of-day balance + def derive_start_cash_balance(end_cash_balance:, date:) + derive_cash_balance(end_cash_balance, date) end - def calculate_next_balance(prior_balance, transactions, direction: :forward) - flows = transactions.sum(&:amount) - negated = direction == :forward ? account.asset? : account.liability? - flows *= -1 if negated - prior_balance + flows + # Alias method, for algorithmic clarity + # Derives non-cash balance, starting from the end-of-day, applying entries in reverse to get the start-of-day balance + def derive_start_non_cash_balance(end_non_cash_balance:, date:) + derive_non_cash_balance(end_non_cash_balance, date, direction: :reverse) end end diff --git a/app/models/balance/sync_cache.rb b/app/models/balance/sync_cache.rb index aed2b64e..be2eaa19 100644 --- a/app/models/balance/sync_cache.rb +++ b/app/models/balance/sync_cache.rb @@ -3,8 +3,8 @@ class Balance::SyncCache @account = account end - def get_valuation(date) - converted_entries.find { |e| e.date == date && e.valuation? } + def get_reconciliation_valuation(date) + converted_entries.find { |e| e.date == date && e.valuation? && e.valuation.reconciliation? } end def get_holdings(date) diff --git a/app/models/balance/trend_calculator.rb b/app/models/balance/trend_calculator.rb index b088d022..990a8339 100644 --- a/app/models/balance/trend_calculator.rb +++ b/app/models/balance/trend_calculator.rb @@ -18,7 +18,7 @@ class Balance::TrendCalculator BalanceTrend.new( trend: Trend.new( current: Money.new(balance.balance, balance.currency), - previous: Money.new(prior_balance.balance, balance.currency), + previous: prior_balance.present? ? Money.new(prior_balance.balance, balance.currency) : nil, favorable_direction: balance.account.favorable_direction ), cash: Money.new(balance.cash_balance, balance.currency), diff --git a/app/models/concerns/syncable.rb b/app/models/concerns/syncable.rb index 739d5381..298aa620 100644 --- a/app/models/concerns/syncable.rb +++ b/app/models/concerns/syncable.rb @@ -47,7 +47,7 @@ module Syncable end def sync_error - latest_sync&.error + latest_sync&.error || latest_sync&.children&.map(&:error)&.compact&.first end def last_synced_at diff --git a/app/models/demo/generator.rb b/app/models/demo/generator.rb index e12e5b85..b530b273 100644 --- a/app/models/demo/generator.rb +++ b/app/models/demo/generator.rb @@ -1174,7 +1174,7 @@ class Demo::Generator # Property valuations (these accounts are valued, not transaction-driven) @home.entries.create!( - entryable: Valuation.new, + entryable: Valuation.new(kind: "current_anchor"), amount: 350_000, name: Valuation.build_current_anchor_name(@home.accountable_type), currency: "USD", @@ -1183,7 +1183,7 @@ class Demo::Generator # Vehicle valuations (these depreciate over time) @honda_accord.entries.create!( - entryable: Valuation.new, + entryable: Valuation.new(kind: "current_anchor"), amount: 18_000, name: Valuation.build_current_anchor_name(@honda_accord.accountable_type), currency: "USD", @@ -1191,7 +1191,7 @@ class Demo::Generator ) @tesla_model3.entries.create!( - entryable: Valuation.new, + entryable: Valuation.new(kind: "current_anchor"), amount: 4_500, name: Valuation.build_current_anchor_name(@tesla_model3.accountable_type), currency: "USD", @@ -1199,7 +1199,7 @@ class Demo::Generator ) @jewelry.entries.create!( - entryable: Valuation.new, + entryable: Valuation.new(kind: "reconciliation"), amount: 2000, name: Valuation.build_reconciliation_name(@jewelry.accountable_type), currency: "USD", @@ -1207,7 +1207,7 @@ class Demo::Generator ) @personal_loc.entries.create!( - entryable: Valuation.new, + entryable: Valuation.new(kind: "reconciliation"), amount: 800, name: Valuation.build_reconciliation_name(@personal_loc.accountable_type), currency: "USD", diff --git a/app/models/plaid_account/processor.rb b/app/models/plaid_account/processor.rb index 6c999911..5b16f90d 100644 --- a/app/models/plaid_account/processor.rb +++ b/app/models/plaid_account/processor.rb @@ -51,6 +51,13 @@ class PlaidAccount::Processor ) account.save! + + # Create or update the current balance anchor valuation for event-sourced ledger + # Note: This is a partial implementation. In the future, we'll introduce HoldingValuation + # to properly track the holdings vs. cash breakdown, but for now we're only tracking + # the total balance in the current anchor. The cash_balance field on the account model + # is still being used for the breakdown. + account.set_current_anchor_balance(balance_calculator.balance) end end diff --git a/app/models/valuation.rb b/app/models/valuation.rb index fe3febcc..f80226be 100644 --- a/app/models/valuation.rb +++ b/app/models/valuation.rb @@ -1,6 +1,12 @@ class Valuation < ApplicationRecord include Entryable + enum :kind, { + reconciliation: "reconciliation", + opening_anchor: "opening_anchor", + current_anchor: "current_anchor" + }, validate: true, default: "reconciliation" + class << self def build_reconciliation_name(accountable_type) Valuation::Name.new("reconciliation", accountable_type).to_s @@ -14,10 +20,4 @@ class Valuation < ApplicationRecord Valuation::Name.new("current_anchor", accountable_type).to_s end end - - # TODO: Remove this method when `kind` column is added to valuations table - # This is a temporary implementation until the database migration is complete - def kind - "reconciliation" - end end diff --git a/app/models/valuation/name.rb b/app/models/valuation/name.rb index 6b442876..79398cde 100644 --- a/app/models/valuation/name.rb +++ b/app/models/valuation/name.rb @@ -20,11 +20,11 @@ class Valuation::Name def opening_anchor_name case accountable_type - when "Property" + when "Property", "Vehicle" "Original purchase price" when "Loan" "Original principal" - when "Investment" + when "Investment", "Crypto", "OtherAsset" "Opening account value" else "Opening balance" @@ -33,11 +33,11 @@ class Valuation::Name def current_anchor_name case accountable_type - when "Property" + when "Property", "Vehicle" "Current market value" when "Loan" "Current loan balance" - when "Investment" + when "Investment", "Crypto", "OtherAsset" "Current account value" else "Current balance" @@ -46,7 +46,7 @@ class Valuation::Name def recon_name case accountable_type - when "Property", "Investment" + when "Property", "Investment", "Vehicle", "Crypto", "OtherAsset" "Manual value update" when "Loan" "Manual principal update" diff --git a/db/migrate/20250710225721_add_valuation_kind.rb b/db/migrate/20250710225721_add_valuation_kind.rb new file mode 100644 index 00000000..e6b80702 --- /dev/null +++ b/db/migrate/20250710225721_add_valuation_kind.rb @@ -0,0 +1,5 @@ +class AddValuationKind < ActiveRecord::Migration[7.2] + def change + add_column :valuations, :kind, :string, default: "reconciliation", null: false + end +end diff --git a/db/schema.rb b/db/schema.rb index 8484bac8..56d7ba09 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_02_173231) do +ActiveRecord::Schema[7.2].define(version: 2025_07_10_225721) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -779,6 +779,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_07_02_173231) do t.datetime "created_at", null: false t.datetime "updated_at", null: false t.jsonb "locked_attributes", default: {} + t.string "kind", default: "reconciliation", 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..6d3a14fd 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-07-10: 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 account entry + oldest_entry = account.entries + .order("date ASC, created_at ASC") + .first + + # Check if it's a valuation that isn't already an anchor + if oldest_entry && oldest_entry.valuation? + derived_valuation_name = Valuation.build_opening_anchor_name(account.accountable_type) + + Account.transaction do + oldest_entry.valuation.update!(kind: "opening_anchor") + oldest_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 diff --git a/test/controllers/credit_cards_controller_test.rb b/test/controllers/credit_cards_controller_test.rb index 6a270156..5fb0ec52 100644 --- a/test/controllers/credit_cards_controller_test.rb +++ b/test/controllers/credit_cards_controller_test.rb @@ -11,8 +11,8 @@ class CreditCardsControllerTest < ActionDispatch::IntegrationTest test "creates with credit card details" do assert_difference -> { Account.count } => 1, -> { CreditCard.count } => 1, - -> { Valuation.count } => 2, - -> { Entry.count } => 2 do + -> { Valuation.count } => 1, + -> { Entry.count } => 1 do post credit_cards_path, params: { account: { name: "New Credit Card", diff --git a/test/controllers/loans_controller_test.rb b/test/controllers/loans_controller_test.rb index ec590363..e12a2705 100644 --- a/test/controllers/loans_controller_test.rb +++ b/test/controllers/loans_controller_test.rb @@ -11,8 +11,8 @@ class LoansControllerTest < ActionDispatch::IntegrationTest test "creates with loan details" do assert_difference -> { Account.count } => 1, -> { Loan.count } => 1, - -> { Valuation.count } => 2, - -> { Entry.count } => 2 do + -> { Valuation.count } => 1, + -> { Entry.count } => 1 do post loans_path, params: { account: { name: "New Loan", diff --git a/test/controllers/vehicles_controller_test.rb b/test/controllers/vehicles_controller_test.rb index bb7df9c6..37cea18d 100644 --- a/test/controllers/vehicles_controller_test.rb +++ b/test/controllers/vehicles_controller_test.rb @@ -11,8 +11,8 @@ class VehiclesControllerTest < ActionDispatch::IntegrationTest test "creates with vehicle details" do assert_difference -> { Account.count } => 1, -> { Vehicle.count } => 1, - -> { Valuation.count } => 2, - -> { Entry.count } => 2 do + -> { Valuation.count } => 1, + -> { Entry.count } => 1 do post vehicles_path, params: { account: { name: "Vehicle", diff --git a/test/fixtures/imports.yml b/test/fixtures/imports.yml index 366bb6d9..b0172532 100644 --- a/test/fixtures/imports.yml +++ b/test/fixtures/imports.yml @@ -7,3 +7,8 @@ trade: family: dylan_family type: TradeImport status: pending + +account: + family: dylan_family + type: AccountImport + status: pending diff --git a/test/fixtures/valuations.yml b/test/fixtures/valuations.yml index 21aeae24..27891bd4 100644 --- a/test/fixtures/valuations.yml +++ b/test/fixtures/valuations.yml @@ -1,2 +1,2 @@ -one: { } -two: { } \ No newline at end of file +one: + kind: reconciliation diff --git a/test/models/account/current_balance_manager_test.rb b/test/models/account/current_balance_manager_test.rb new file mode 100644 index 00000000..d48eb927 --- /dev/null +++ b/test/models/account/current_balance_manager_test.rb @@ -0,0 +1,153 @@ +require "test_helper" + +class Account::CurrentBalanceManagerTest < ActiveSupport::TestCase + setup do + @connected_account = accounts(:connected) # Connected account - can update current balance + @manual_account = accounts(:depository) # Manual account - cannot update current balance + end + + test "when no existing anchor, creates new anchor" do + manager = Account::CurrentBalanceManager.new(@connected_account) + + assert_difference -> { @connected_account.entries.count } => 1, + -> { @connected_account.valuations.count } => 1 do + result = manager.set_current_balance(1000) + + assert result.success? + assert result.changes_made? + assert_nil result.error + end + + current_anchor = @connected_account.valuations.current_anchor.first + assert_not_nil current_anchor + assert_equal 1000, current_anchor.entry.amount + assert_equal "current_anchor", current_anchor.kind + + entry = current_anchor.entry + assert_equal 1000, entry.amount + assert_equal Date.current, entry.date + assert_equal "Current balance", entry.name # Depository type returns "Current balance" + end + + test "updates existing anchor" do + # First create a current anchor + manager = Account::CurrentBalanceManager.new(@connected_account) + result = manager.set_current_balance(1000) + assert result.success? + + current_anchor = @connected_account.valuations.current_anchor.first + original_id = current_anchor.id + original_entry_id = current_anchor.entry.id + + # Travel to tomorrow to ensure date change + travel_to Date.current + 1.day do + # Now update it + assert_no_difference -> { @connected_account.entries.count } do + assert_no_difference -> { @connected_account.valuations.count } do + result = manager.set_current_balance(2000) + assert result.success? + assert result.changes_made? + end + end + + current_anchor.reload + assert_equal original_id, current_anchor.id # Same valuation record + assert_equal original_entry_id, current_anchor.entry.id # Same entry record + assert_equal 2000, current_anchor.entry.amount + assert_equal Date.current, current_anchor.entry.date # Should be updated to current date + end + end + + test "when manual account, raises InvalidOperation error" do + manager = Account::CurrentBalanceManager.new(@manual_account) + + error = assert_raises(Account::CurrentBalanceManager::InvalidOperation) do + manager.set_current_balance(1000) + end + + assert_equal "Manual accounts cannot set current balance anchor. Set opening balance or use a reconciliation instead.", error.message + + # Verify no current anchor was created + assert_nil @manual_account.valuations.current_anchor.first + end + + test "when no changes made, returns success with no changes made" do + # First create a current anchor + manager = Account::CurrentBalanceManager.new(@connected_account) + result = manager.set_current_balance(1000) + assert result.success? + assert result.changes_made? + + # Try to set the same value on the same date + result = manager.set_current_balance(1000) + + assert result.success? + assert_not result.changes_made? + assert_nil result.error + end + + test "updates only amount when balance changes" do + manager = Account::CurrentBalanceManager.new(@connected_account) + + # Create initial anchor + result = manager.set_current_balance(1000) + assert result.success? + + current_anchor = @connected_account.valuations.current_anchor.first + original_date = current_anchor.entry.date + + # Update only the balance + result = manager.set_current_balance(1500) + assert result.success? + assert result.changes_made? + + current_anchor.reload + assert_equal 1500, current_anchor.entry.amount + assert_equal original_date, current_anchor.entry.date # Date should remain the same if on same day + end + + test "updates date when called on different day" do + manager = Account::CurrentBalanceManager.new(@connected_account) + + # Create initial anchor + result = manager.set_current_balance(1000) + assert result.success? + + current_anchor = @connected_account.valuations.current_anchor.first + original_amount = current_anchor.entry.amount + + # Travel to tomorrow and update with same balance + travel_to Date.current + 1.day do + result = manager.set_current_balance(1000) + assert result.success? + assert result.changes_made? # Should be true because date changed + + current_anchor.reload + assert_equal original_amount, current_anchor.entry.amount + assert_equal Date.current, current_anchor.entry.date # Should be updated to new current date + end + end + + test "current_balance returns balance from current anchor" do + manager = Account::CurrentBalanceManager.new(@connected_account) + + # Create a current anchor + manager.set_current_balance(1500) + + # Should return the anchor's balance + assert_equal 1500, manager.current_balance + + # Update the anchor + manager.set_current_balance(2500) + + # Should return the updated balance + assert_equal 2500, manager.current_balance + end + + test "current_balance falls back to account balance when no anchor exists" do + manager = Account::CurrentBalanceManager.new(@connected_account) + + # When no current anchor exists, should fall back to account.balance + assert_equal @connected_account.balance, manager.current_balance + end +end diff --git a/test/models/account/entry_test.rb b/test/models/account/entry_test.rb index 1cc6b478..dba43ba9 100644 --- a/test/models/account/entry_test.rb +++ b/test/models/account/entry_test.rb @@ -17,7 +17,7 @@ class EntryTest < ActiveSupport::TestCase existing_valuation = entries :valuation new_valuation = Entry.new \ - entryable: Valuation.new, + entryable: Valuation.new(kind: "reconciliation"), account: existing_valuation.account, date: existing_valuation.date, # invalid currency: existing_valuation.currency, diff --git a/test/models/account/opening_balance_manager_test.rb b/test/models/account/opening_balance_manager_test.rb new file mode 100644 index 00000000..67becb60 --- /dev/null +++ b/test/models/account/opening_balance_manager_test.rb @@ -0,0 +1,252 @@ +require "test_helper" + +class Account::OpeningBalanceManagerTest < ActiveSupport::TestCase + setup do + @depository_account = accounts(:depository) + @investment_account = accounts(:investment) + end + + test "when no existing anchor, creates new anchor" do + manager = Account::OpeningBalanceManager.new(@depository_account) + + assert_difference -> { @depository_account.entries.count } => 1, + -> { @depository_account.valuations.count } => 1 do + result = manager.set_opening_balance( + balance: 1000, + date: 1.year.ago.to_date + ) + + assert result.success? + assert result.changes_made? + assert_nil result.error + end + + opening_anchor = @depository_account.valuations.opening_anchor.first + assert_not_nil opening_anchor + assert_equal 1000, opening_anchor.entry.amount + assert_equal "opening_anchor", opening_anchor.kind + + entry = opening_anchor.entry + assert_equal 1000, entry.amount + assert_equal 1.year.ago.to_date, entry.date + assert_equal "Opening balance", entry.name + end + + test "when no existing anchor, creates with provided balance" do + # Test with Depository account (should default to balance) + depository_manager = Account::OpeningBalanceManager.new(@depository_account) + + assert_difference -> { @depository_account.valuations.count } => 1 do + result = depository_manager.set_opening_balance(balance: 2000) + assert result.success? + assert result.changes_made? + end + + depository_anchor = @depository_account.valuations.opening_anchor.first + assert_equal 2000, depository_anchor.entry.amount + + # Test with Investment account (should default to 0) + investment_manager = Account::OpeningBalanceManager.new(@investment_account) + + assert_difference -> { @investment_account.valuations.count } => 1 do + result = investment_manager.set_opening_balance(balance: 5000) + assert result.success? + assert result.changes_made? + end + + investment_anchor = @investment_account.valuations.opening_anchor.first + assert_equal 5000, investment_anchor.entry.amount + end + + test "when no existing anchor and no date provided, provides default based on account type" do + # Test with recent entry (less than 2 years ago) + @depository_account.entries.create!( + date: 30.days.ago.to_date, + name: "Test transaction", + amount: 100, + currency: "USD", + entryable: Transaction.new + ) + + manager = Account::OpeningBalanceManager.new(@depository_account) + + assert_difference -> { @depository_account.valuations.count } => 1 do + result = manager.set_opening_balance(balance: 1500) + assert result.success? + assert result.changes_made? + end + + opening_anchor = @depository_account.valuations.opening_anchor.first + # Default should be MIN(1 day before oldest entry, 2 years ago) = 2 years ago + assert_equal 2.years.ago.to_date, opening_anchor.entry.date + + # Test with old entry (more than 2 years ago) + loan_account = accounts(:loan) + loan_account.entries.create!( + date: 3.years.ago.to_date, + name: "Old transaction", + amount: 100, + currency: "USD", + entryable: Transaction.new + ) + + loan_manager = Account::OpeningBalanceManager.new(loan_account) + + assert_difference -> { loan_account.valuations.count } => 1 do + result = loan_manager.set_opening_balance(balance: 5000) + assert result.success? + assert result.changes_made? + end + + loan_anchor = loan_account.valuations.opening_anchor.first + # Default should be MIN(3 years ago - 1 day, 2 years ago) = 3 years ago - 1 day + assert_equal (3.years.ago.to_date - 1.day), loan_anchor.entry.date + + # Test with account that has no entries + property_account = accounts(:property) + manager_no_entries = Account::OpeningBalanceManager.new(property_account) + + assert_difference -> { property_account.valuations.count } => 1 do + result = manager_no_entries.set_opening_balance(balance: 3000) + assert result.success? + assert result.changes_made? + end + + opening_anchor_no_entries = property_account.valuations.opening_anchor.first + # Default should be 2 years ago when no entries exist + assert_equal 2.years.ago.to_date, opening_anchor_no_entries.entry.date + end + + test "updates existing anchor" do + # First create an opening anchor + manager = Account::OpeningBalanceManager.new(@depository_account) + result = manager.set_opening_balance( + balance: 1000, + date: 6.months.ago.to_date + ) + assert result.success? + + opening_anchor = @depository_account.valuations.opening_anchor.first + original_id = opening_anchor.id + original_entry_id = opening_anchor.entry.id + + # Now update it + assert_no_difference -> { @depository_account.entries.count } do + assert_no_difference -> { @depository_account.valuations.count } do + result = manager.set_opening_balance( + balance: 2000, + date: 8.months.ago.to_date + ) + assert result.success? + assert result.changes_made? + end + end + + opening_anchor.reload + assert_equal original_id, opening_anchor.id # Same valuation record + assert_equal original_entry_id, opening_anchor.entry.id # Same entry record + assert_equal 2000, opening_anchor.entry.amount + assert_equal 2000, opening_anchor.entry.amount + assert_equal 8.months.ago.to_date, opening_anchor.entry.date + end + + test "when existing anchor and no date provided, only update balance" do + # First create an opening anchor + manager = Account::OpeningBalanceManager.new(@depository_account) + result = manager.set_opening_balance( + balance: 1000, + date: 3.months.ago.to_date + ) + assert result.success? + + opening_anchor = @depository_account.valuations.opening_anchor.first + + # Update without providing date + result = manager.set_opening_balance(balance: 1500) + assert result.success? + assert result.changes_made? + + opening_anchor.reload + assert_equal 1500, opening_anchor.entry.amount + end + + test "when existing anchor and updating balance only, preserves original date" do + # First create an opening anchor with specific date + manager = Account::OpeningBalanceManager.new(@depository_account) + original_date = 4.months.ago.to_date + result = manager.set_opening_balance( + balance: 1000, + date: original_date + ) + assert result.success? + + opening_anchor = @depository_account.valuations.opening_anchor.first + + # Update without providing date + result = manager.set_opening_balance(balance: 2500) + assert result.success? + assert result.changes_made? + + opening_anchor.reload + assert_equal 2500, opening_anchor.entry.amount + assert_equal original_date, opening_anchor.entry.date # Should remain unchanged + end + + test "when date is equal to or greater than account's oldest entry, returns error result" do + # Create an entry with a specific date + oldest_date = 60.days.ago.to_date + @depository_account.entries.create!( + date: oldest_date, + name: "Test transaction", + amount: 100, + currency: "USD", + entryable: Transaction.new + ) + + manager = Account::OpeningBalanceManager.new(@depository_account) + + # Try to set opening balance on the same date as oldest entry + result = manager.set_opening_balance( + balance: 1000, + date: oldest_date + ) + + assert_not result.success? + assert_not result.changes_made? + assert_equal "Opening balance date must be before the oldest entry date", result.error + + # Try to set opening balance after the oldest entry + result = manager.set_opening_balance( + balance: 1000, + date: oldest_date + 1.day + ) + + assert_not result.success? + assert_not result.changes_made? + assert_equal "Opening balance date must be before the oldest entry date", result.error + + # Verify no opening anchor was created + assert_nil @depository_account.valuations.opening_anchor.first + end + + test "when no changes made, returns success with no changes made" do + # First create an opening anchor + manager = Account::OpeningBalanceManager.new(@depository_account) + result = manager.set_opening_balance( + balance: 1000, + date: 2.months.ago.to_date + ) + assert result.success? + assert result.changes_made? + + # Try to set the same values + result = manager.set_opening_balance( + balance: 1000, + date: 2.months.ago.to_date + ) + + assert result.success? + assert_not result.changes_made? + assert_nil result.error + end +end diff --git a/test/models/account_import_test.rb b/test/models/account_import_test.rb new file mode 100644 index 00000000..29204c0f --- /dev/null +++ b/test/models/account_import_test.rb @@ -0,0 +1,92 @@ +require "test_helper" + +class AccountImportTest < ActiveSupport::TestCase + include ActiveJob::TestHelper, ImportInterfaceTest + + setup do + @subject = @import = imports(:account) + end + + test "import creates accounts with valuations" do + import_csv = <<~CSV + type,name,amount,currency + depository,Main Checking,1000.00,USD + depository,Savings Account,5000.00,USD + CSV + + @import.update!( + raw_file_str: import_csv, + entity_type_col_label: "type", + name_col_label: "name", + amount_col_label: "amount", + currency_col_label: "currency" + ) + + @import.generate_rows_from_csv + + # Create mappings for account types + @import.mappings.create! key: "depository", value: "Depository", type: "Import::AccountTypeMapping" + + @import.reload + + # Store initial counts + initial_account_count = Account.count + initial_entry_count = Entry.count + initial_valuation_count = Valuation.count + + # Perform the import + @import.publish + + # Check if import succeeded + if @import.failed? + fail "Import failed with error: #{@import.error}" + end + + assert_equal "complete", @import.status + + # Check the differences + assert_equal initial_account_count + 2, Account.count, "Expected 2 new accounts" + assert_equal initial_entry_count + 2, Entry.count, "Expected 2 new entries" + assert_equal initial_valuation_count + 2, Valuation.count, "Expected 2 new valuations" + + # Verify accounts were created correctly + accounts = @import.accounts.order(:name) + assert_equal [ "Main Checking", "Savings Account" ], accounts.pluck(:name) + assert_equal [ 1000.00, 5000.00 ], accounts.map { |a| a.balance.to_f } + + # Verify valuations were created with correct fields + accounts.each do |account| + valuation = account.valuations.last + assert_not_nil valuation + assert_equal "opening_anchor", valuation.kind + assert_equal account.balance, valuation.entry.amount + end + end + + test "column_keys returns expected keys" do + assert_equal %i[entity_type name amount currency], @import.column_keys + end + + test "required_column_keys returns expected keys" do + assert_equal %i[name amount], @import.required_column_keys + end + + test "mapping_steps returns account type mapping" do + assert_equal [ Import::AccountTypeMapping ], @import.mapping_steps + end + + test "dry_run returns expected counts" do + @import.rows.create!( + entity_type: "depository", + name: "Test Account", + amount: "1000.00", + currency: "USD" + ) + + assert_equal({ accounts: 1 }, @import.dry_run) + end + + test "max_row_count is limited to 50" do + assert_equal 50, @import.max_row_count + end +end diff --git a/test/models/balance/forward_calculator_test.rb b/test/models/balance/forward_calculator_test.rb index 05215c25..b6eb2d11 100644 --- a/test/models/balance/forward_calculator_test.rb +++ b/test/models/balance/forward_calculator_test.rb @@ -1,129 +1,349 @@ require "test_helper" +# The "forward calculator" is used for all **manual** accounts where balance tracking is done through entries and NOT from an external data provider. class Balance::ForwardCalculatorTest < ActiveSupport::TestCase - include EntriesTestHelper + include LedgerTestingHelper - setup do - @account = families(:empty).accounts.create!( - name: "Test", - balance: 20000, - cash_balance: 20000, - currency: "USD", - accountable: Investment.new - ) - end - - test "balance generation respects user timezone and last generated date is current user date" do - # Simulate user in EST timezone - Time.use_zone("America/New_York") do - # Set current time to 1am UTC on Jan 5, 2025 - # This would be 8pm EST on Jan 4, 2025 (user's time, and the last date we should generate balances for) - travel_to Time.utc(2025, 01, 05, 1, 0, 0) - - # Create a valuation for Jan 3, 2025 - create_valuation(account: @account, date: "2025-01-03", amount: 17000) - - expected = [ [ "2025-01-02", 0 ], [ "2025-01-03", 17000 ], [ "2025-01-04", 17000 ] ] - calculated = Balance::ForwardCalculator.new(@account).calculate - - assert_equal expected, calculated.map { |b| [ b.date.to_s, b.balance ] } - end - end + # ------------------------------------------------------------------------------------------------ + # General tests for all account types + # ------------------------------------------------------------------------------------------------ # When syncing forwards, we don't care about the account balance. We generate everything based on entries, starting from 0. test "no entries sync" do - assert_equal 0, @account.balances.count + account = create_account_with_ledger( + account: { type: Depository, balance: 20000, cash_balance: 20000, currency: "USD" }, + entries: [] + ) - expected = [ 0, 0 ] - calculated = Balance::ForwardCalculator.new(@account).calculate + assert_equal 0, account.balances.count - assert_equal expected, calculated.map(&:balance) + calculated = Balance::ForwardCalculator.new(account).calculate + + assert_calculated_ledger_balances( + calculated_data: calculated, + expected_balances: [ + [ Date.current, { balance: 0, cash_balance: 0 } ] + ] + ) end - test "valuations sync" do - create_valuation(account: @account, date: 4.days.ago.to_date, amount: 17000) - create_valuation(account: @account, date: 2.days.ago.to_date, amount: 19000) + # Our system ensures all manual accounts have an opening anchor (for UX), but we should be able to handle a missing anchor by starting at 0 (i.e. "fresh account with no history") + test "account without opening anchor starts at zero balance" do + account = create_account_with_ledger( + account: { type: Depository, balance: 20000, cash_balance: 20000, currency: "USD" }, + entries: [ + { type: "transaction", date: 2.days.ago.to_date, amount: -1000 } + ] + ) - expected = [ 0, 17000, 17000, 19000, 19000, 19000 ] - calculated = Balance::ForwardCalculator.new(@account).calculate.sort_by(&:date).map(&:balance) + calculated = Balance::ForwardCalculator.new(account).calculate - assert_equal expected, calculated + # Since we start at 0, this transaction (inflow) simply increases balance from 0 -> 1000 + assert_calculated_ledger_balances( + calculated_data: calculated, + expected_balances: [ + [ 3.days.ago.to_date, { balance: 0, cash_balance: 0 } ], + [ 2.days.ago.to_date, { balance: 1000, cash_balance: 1000 } ] + ] + ) end - test "transactions sync" do - create_transaction(account: @account, date: 4.days.ago.to_date, amount: -500) # income - create_transaction(account: @account, date: 2.days.ago.to_date, amount: 100) # expense + test "reconciliation valuation sets absolute balance before applying subsequent transactions" do + account = create_account_with_ledger( + account: { type: Depository, balance: 20000, cash_balance: 20000, currency: "USD" }, + entries: [ + { type: "reconciliation", date: 3.days.ago.to_date, balance: 18000 }, + { type: "transaction", date: 2.days.ago.to_date, amount: -1000 } + ] + ) - expected = [ 0, 500, 500, 400, 400, 400 ] - calculated = Balance::ForwardCalculator.new(@account).calculate.sort_by(&:date).map(&:balance) + calculated = Balance::ForwardCalculator.new(account).calculate - assert_equal expected, calculated + # First valuation sets balance to 18000, then transaction increases balance to 19000 + assert_calculated_ledger_balances( + calculated_data: calculated, + expected_balances: [ + [ 3.days.ago.to_date, { balance: 18000, cash_balance: 18000 } ], + [ 2.days.ago.to_date, { balance: 19000, cash_balance: 19000 } ] + ] + ) end - test "multi-entry sync" do - create_transaction(account: @account, date: 8.days.ago.to_date, amount: -5000) - create_valuation(account: @account, date: 6.days.ago.to_date, amount: 17000) - create_transaction(account: @account, date: 6.days.ago.to_date, amount: -500) - create_transaction(account: @account, date: 4.days.ago.to_date, amount: -500) - create_valuation(account: @account, date: 3.days.ago.to_date, amount: 17000) - create_transaction(account: @account, date: 1.day.ago.to_date, amount: 100) + test "cash-only accounts (depository, credit card) use valuations where cash balance equals total balance" do + [ Depository, CreditCard ].each do |account_type| + account = create_account_with_ledger( + account: { type: account_type, balance: 10000, cash_balance: 10000, currency: "USD" }, + entries: [ + { type: "opening_anchor", date: 3.days.ago.to_date, balance: 17000 }, + { type: "reconciliation", date: 2.days.ago.to_date, balance: 18000 } + ] + ) - expected = [ 0, 5000, 5000, 17000, 17000, 17500, 17000, 17000, 16900, 16900 ] - calculated = Balance::ForwardCalculator.new(@account).calculate.sort_by(&:date).map(&:balance) + calculated = Balance::ForwardCalculator.new(account).calculate - assert_equal expected, calculated + assert_calculated_ledger_balances( + calculated_data: calculated, + expected_balances: [ + [ 3.days.ago.to_date, { balance: 17000, cash_balance: 17000 } ], + [ 2.days.ago.to_date, { balance: 18000, cash_balance: 18000 } ] + ] + ) + end end - test "multi-currency sync" do - ExchangeRate.create! date: 1.day.ago.to_date, from_currency: "EUR", to_currency: "USD", rate: 1.2 + test "non-cash accounts (property, loan) use valuations where cash balance is always zero" do + [ Property, Loan ].each do |account_type| + account = create_account_with_ledger( + account: { type: account_type, balance: 10000, cash_balance: 10000, currency: "USD" }, + entries: [ + { type: "opening_anchor", date: 3.days.ago.to_date, balance: 17000 }, + { type: "reconciliation", date: 2.days.ago.to_date, balance: 18000 } + ] + ) - create_transaction(account: @account, date: 3.days.ago.to_date, amount: -100, currency: "USD") - create_transaction(account: @account, date: 2.days.ago.to_date, amount: -300, currency: "USD") + calculated = Balance::ForwardCalculator.new(account).calculate - # Transaction in different currency than the account's main currency - create_transaction(account: @account, date: 1.day.ago.to_date, amount: -500, currency: "EUR") # €500 * 1.2 = $600 - - expected = [ 0, 100, 400, 1000, 1000 ] - calculated = Balance::ForwardCalculator.new(@account).calculate.sort_by(&:date).map(&:balance) - - assert_equal expected, calculated + assert_calculated_ledger_balances( + calculated_data: calculated, + expected_balances: [ + [ 3.days.ago.to_date, { balance: 17000, cash_balance: 0.0 } ], + [ 2.days.ago.to_date, { balance: 18000, cash_balance: 0.0 } ] + ] + ) + end end - test "holdings and trades sync" do - aapl = securities(:aapl) + test "mixed accounts (investment) use valuations where cash balance is total minus holdings" do + account = create_account_with_ledger( + account: { type: Investment, balance: 10000, cash_balance: 10000, currency: "USD" }, + entries: [ + { type: "opening_anchor", date: 3.days.ago.to_date, balance: 17000 }, + { type: "reconciliation", date: 2.days.ago.to_date, balance: 18000 } + ] + ) - # Account starts at a value of $5000 - create_valuation(account: @account, date: 2.days.ago.to_date, amount: 5000) + # Without holdings, cash balance equals total balance + calculated = Balance::ForwardCalculator.new(account).calculate - # Share purchase reduces cash balance by $1000, but keeps overall balance same - create_trade(aapl, account: @account, qty: 10, date: 1.day.ago.to_date, price: 100) + assert_calculated_ledger_balances( + calculated_data: calculated, + expected_balances: [ + [ 3.days.ago.to_date, { balance: 17000, cash_balance: 17000 } ], + [ 2.days.ago.to_date, { balance: 18000, cash_balance: 18000 } ] + ] + ) + end - Holding.create!(date: 1.day.ago.to_date, account: @account, security: aapl, qty: 10, price: 100, amount: 1000, currency: "USD") - Holding.create!(date: Date.current, account: @account, security: aapl, qty: 10, price: 100, amount: 1000, currency: "USD") + # ------------------------------------------------------------------------------------------------ + # All Cash accounts (Depository, CreditCard) + # ------------------------------------------------------------------------------------------------ + + test "transactions on depository accounts affect cash balance" do + account = create_account_with_ledger( + account: { type: Depository, balance: 20000, cash_balance: 20000, currency: "USD" }, + entries: [ + { type: "opening_anchor", date: 5.days.ago.to_date, balance: 20000 }, + { type: "transaction", date: 4.days.ago.to_date, amount: -500 }, # income + { type: "transaction", date: 2.days.ago.to_date, amount: 100 } # expense + ] + ) + + calculated = Balance::ForwardCalculator.new(account).calculate + + assert_calculated_ledger_balances( + calculated_data: calculated, + expected_balances: [ + [ 5.days.ago.to_date, { balance: 20000, cash_balance: 20000 } ], + [ 4.days.ago.to_date, { balance: 20500, cash_balance: 20500 } ], + [ 3.days.ago.to_date, { balance: 20500, cash_balance: 20500 } ], + [ 2.days.ago.to_date, { balance: 20400, cash_balance: 20400 } ] + ] + ) + end + + + test "transactions on credit card accounts affect cash balance inversely" do + account = create_account_with_ledger( + account: { type: CreditCard, balance: 10000, cash_balance: 10000, currency: "USD" }, + entries: [ + { type: "opening_anchor", date: 5.days.ago.to_date, balance: 1000 }, + { type: "transaction", date: 4.days.ago.to_date, amount: -500 }, # CC payment + { type: "transaction", date: 2.days.ago.to_date, amount: 100 } # expense + ] + ) + + calculated = Balance::ForwardCalculator.new(account).calculate + + assert_calculated_ledger_balances( + calculated_data: calculated, + expected_balances: [ + [ 5.days.ago.to_date, { balance: 1000, cash_balance: 1000 } ], + [ 4.days.ago.to_date, { balance: 500, cash_balance: 500 } ], + [ 3.days.ago.to_date, { balance: 500, cash_balance: 500 } ], + [ 2.days.ago.to_date, { balance: 600, cash_balance: 600 } ] + ] + ) + end + + test "depository account with transactions and balance reconciliations" do + account = create_account_with_ledger( + account: { type: Depository, balance: 20000, cash_balance: 20000, currency: "USD" }, + entries: [ + { type: "opening_anchor", date: 10.days.ago.to_date, balance: 20000 }, + { type: "transaction", date: 8.days.ago.to_date, amount: -5000 }, + { type: "reconciliation", date: 6.days.ago.to_date, balance: 17000 }, + { type: "transaction", date: 6.days.ago.to_date, amount: -500 }, + { type: "transaction", date: 4.days.ago.to_date, amount: -500 }, + { type: "reconciliation", date: 3.days.ago.to_date, balance: 17000 }, + { type: "transaction", date: 1.day.ago.to_date, amount: 100 } + ] + ) + + calculated = Balance::ForwardCalculator.new(account).calculate + + assert_calculated_ledger_balances( + calculated_data: calculated, + expected_balances: [ + [ 10.days.ago.to_date, { balance: 20000, cash_balance: 20000 } ], + [ 9.days.ago.to_date, { balance: 20000, cash_balance: 20000 } ], + [ 8.days.ago.to_date, { balance: 25000, cash_balance: 25000 } ], + [ 7.days.ago.to_date, { balance: 25000, cash_balance: 25000 } ], + [ 6.days.ago.to_date, { balance: 17000, cash_balance: 17000 } ], + [ 5.days.ago.to_date, { balance: 17000, cash_balance: 17000 } ], + [ 4.days.ago.to_date, { balance: 17500, cash_balance: 17500 } ], + [ 3.days.ago.to_date, { balance: 17000, cash_balance: 17000 } ], + [ 2.days.ago.to_date, { balance: 17000, cash_balance: 17000 } ], + [ 1.day.ago.to_date, { balance: 16900, cash_balance: 16900 } ] + ] + ) + end + + test "accounts with transactions in multiple currencies convert to the account currency" do + account = create_account_with_ledger( + account: { type: Depository, balance: 20000, cash_balance: 20000, currency: "USD" }, + entries: [ + { type: "opening_anchor", date: 4.days.ago.to_date, balance: 100 }, + { type: "transaction", date: 3.days.ago.to_date, amount: -100 }, + { type: "transaction", date: 2.days.ago.to_date, amount: -300 }, + # Transaction in different currency than the account's main currency + { type: "transaction", date: 1.day.ago.to_date, amount: -500, currency: "EUR" } # €500 * 1.2 = $600 + ], + exchange_rates: [ + { date: 1.day.ago.to_date, from: "EUR", to: "USD", rate: 1.2 } + ] + ) + + calculated = Balance::ForwardCalculator.new(account).calculate + + assert_calculated_ledger_balances( + calculated_data: calculated, + expected_balances: [ + [ 4.days.ago.to_date, { balance: 100, cash_balance: 100 } ], + [ 3.days.ago.to_date, { balance: 200, cash_balance: 200 } ], + [ 2.days.ago.to_date, { balance: 500, cash_balance: 500 } ], + [ 1.day.ago.to_date, { balance: 1100, cash_balance: 1100 } ] + ] + ) + end + + # A loan is a special case where despite being a "non-cash" account, it is typical to have "payment" transactions that reduce the loan principal (non cash balance) + test "loan payment transactions affect non cash balance" do + account = create_account_with_ledger( + account: { type: Loan, balance: 10000, cash_balance: 0, currency: "USD" }, + entries: [ + { type: "opening_anchor", date: 2.days.ago.to_date, balance: 20000 }, + # "Loan payment" of $2000, which reduces the principal + # TODO: We'll eventually need to calculate which portion of the txn was "interest" vs. "principal", but for now we'll just assume it's all principal + # since we don't have a first-class way to track interest payments yet. + { type: "transaction", date: 1.day.ago.to_date, amount: -2000 } + ] + ) + + calculated = Balance::ForwardCalculator.new(account).calculate + + assert_calculated_ledger_balances( + calculated_data: calculated, + expected_balances: [ + [ 2.days.ago.to_date, { balance: 20000, cash_balance: 0 } ], + [ 1.day.ago.to_date, { balance: 18000, cash_balance: 0 } ] + ] + ) + end + + test "non cash accounts can only use valuations and transactions will be recorded but ignored for balance calculation" do + [ Property, Vehicle, OtherAsset, OtherLiability ].each do |account_type| + account = create_account_with_ledger( + account: { type: account_type, balance: 10000, cash_balance: 10000, currency: "USD" }, + entries: [ + { type: "opening_anchor", date: 3.days.ago.to_date, balance: 500000 }, + + # Will be ignored for balance calculation due to account type of non-cash + { type: "transaction", date: 2.days.ago.to_date, amount: -50000 } + ] + ) + + calculated = Balance::ForwardCalculator.new(account).calculate + + assert_calculated_ledger_balances( + calculated_data: calculated, + expected_balances: [ + [ 3.days.ago.to_date, { balance: 500000, cash_balance: 0 } ], + [ 2.days.ago.to_date, { balance: 500000, cash_balance: 0 } ] + ] + ) + end + end + + # ------------------------------------------------------------------------------------------------ + # Hybrid accounts (Investment, Crypto) - these have both cash and non-cash balance components + # ------------------------------------------------------------------------------------------------ + + # A transaction increases/decreases cash balance (i.e. "deposits" and "withdrawals") + # A trade increases/decreases cash balance (i.e. "buys" and "sells", which consume/add "brokerage cash" and create/destroy "holdings") + # A valuation can set both cash and non-cash balances to "override" investment account value. + # Holdings are calculated separately and fed into the balance calculator; treated as "non-cash" + test "investment account calculates balance from transactions and trades and treats holdings as non-cash, additive to balance" do + account = create_account_with_ledger( + account: { type: Investment, balance: 10000, cash_balance: 10000, currency: "USD" }, + entries: [ + # Account starts with brokerage cash of $5000 and no holdings + { type: "opening_anchor", date: 3.days.ago.to_date, balance: 5000 }, + # Share purchase reduces cash balance by $1000, but keeps overall balance same + { type: "trade", date: 1.day.ago.to_date, ticker: "AAPL", qty: 10, price: 100 } + ], + holdings: [ + # Holdings calculator will calculate $1000 worth of holdings + { date: 1.day.ago.to_date, ticker: "AAPL", qty: 10, price: 100, amount: 1000 }, + { date: Date.current, ticker: "AAPL", qty: 10, price: 100, amount: 1000 } + ] + ) # Given constant prices, overall balance (account value) should be constant # (the single trade doesn't affect balance; it just alters cash vs. holdings composition) - expected = [ 0, 5000, 5000, 5000 ] - calculated = Balance::ForwardCalculator.new(@account).calculate.sort_by(&:date).map(&:balance) + calculated = Balance::ForwardCalculator.new(account).calculate - assert_equal expected, calculated + assert_calculated_ledger_balances( + calculated_data: calculated, + expected_balances: [ + [ 3.days.ago.to_date, { balance: 5000, cash_balance: 5000 } ], + [ 2.days.ago.to_date, { balance: 5000, cash_balance: 5000 } ], + [ 1.day.ago.to_date, { balance: 5000, cash_balance: 4000 } ], + [ Date.current, { balance: 5000, cash_balance: 4000 } ] + ] + ) end - # Balance calculator is entirely reliant on HoldingCalculator and respects whatever holding records it creates. - test "holdings are additive to total balance" do - aapl = securities(:aapl) + private - # Account starts at a value of $5000 - create_valuation(account: @account, date: 2.days.ago.to_date, amount: 5000) + def assert_balances(calculated_data:, expected_balances:) + # Sort calculated data by date to ensure consistent ordering + sorted_data = calculated_data.sort_by(&:date) - # Even though there are no trades in the history, the calculator will still add the holdings to the total balance - Holding.create!(date: 1.day.ago.to_date, account: @account, security: aapl, qty: 10, price: 100, amount: 1000, currency: "USD") - Holding.create!(date: Date.current, account: @account, security: aapl, qty: 10, price: 100, amount: 1000, currency: "USD") + # Extract actual values as [date, { balance:, cash_balance: }] + actual_balances = sorted_data.map do |b| + [ b.date, { balance: b.balance, cash_balance: b.cash_balance } ] + end - # Start at zero, then valuation of $5000, then tack on $1000 of holdings for remaining 2 days - expected = [ 0, 5000, 6000, 6000 ] - calculated = Balance::ForwardCalculator.new(@account).calculate.sort_by(&:date).map(&:balance) - - assert_equal expected, calculated - end + assert_equal expected_balances, actual_balances + end end diff --git a/test/models/balance/reverse_calculator_test.rb b/test/models/balance/reverse_calculator_test.rb index 6d73aea8..a9348220 100644 --- a/test/models/balance/reverse_calculator_test.rb +++ b/test/models/balance/reverse_calculator_test.rb @@ -1,142 +1,279 @@ require "test_helper" class Balance::ReverseCalculatorTest < ActiveSupport::TestCase - include EntriesTestHelper + include LedgerTestingHelper - setup do - @account = families(:empty).accounts.create!( - name: "Test", - balance: 20000, - cash_balance: 20000, - currency: "USD", - accountable: Investment.new + # When syncing backwards, we start with the account balance and generate everything from there. + test "when missing anchor and no entries, falls back to cached account balance" do + account = create_account_with_ledger( + account: { type: Depository, balance: 20000, cash_balance: 20000, currency: "USD" }, + entries: [] + ) + + assert_equal 20000, account.balance + + calculated = Balance::ReverseCalculator.new(account).calculate + + assert_calculated_ledger_balances( + calculated_data: calculated, + expected_balances: [ + [ Date.current, { balance: 20000, cash_balance: 20000 } ] + ] ) end - # When syncing backwards, we start with the account balance and generate everything from there. - test "no entries sync" do - assert_equal 0, @account.balances.count + # An artificial constraint we put on the reverse sync because it's confusing in both the code and the UI + # to think about how an absolute "Valuation" affects balances when syncing backwards. Furthermore, since + # this is typically a Plaid sync, we expect Plaid to provide us the history. + # Note: while "reconciliation" valuations don't affect balance, `current_anchor` and `opening_anchor` do. + test "reconciliation valuations do not affect balance for reverse syncs" do + account = create_account_with_ledger( + account: { type: Depository, balance: 20000, cash_balance: 20000, currency: "USD" }, + entries: [ + { type: "current_anchor", date: Date.current, balance: 20000 }, + { type: "reconciliation", date: 1.day.ago, balance: 17000 }, # Ignored + { type: "reconciliation", date: 2.days.ago, balance: 17000 }, # Ignored + { type: "opening_anchor", date: 4.days.ago, balance: 15000 } + ] + ) - expected = [ @account.balance, @account.balance ] - calculated = Balance::ReverseCalculator.new(@account).calculate + calculated = Balance::ReverseCalculator.new(account).calculate - assert_equal expected, calculated.map(&:balance) + # The "opening anchor" works slightly differently than most would expect. Since it's an artificial + # value provided by the user to set the date/balance of the start of the account, we must assume + # that there are "missing" entries following it. Because of this, we cannot "carry forward" this value + # like we do for a "forward sync". We simply sync backwards normally, then set the balance on opening + # date equal to this anchor. This is not "ideal", but is a constraint put on us since we cannot guarantee + # a 100% full entries history. + assert_calculated_ledger_balances( + calculated_data: calculated, + expected_balances: [ + [ Date.current, { balance: 20000, cash_balance: 20000 } ], # Current anchor + [ 1.day.ago, { balance: 20000, cash_balance: 20000 } ], + [ 2.days.ago, { balance: 20000, cash_balance: 20000 } ], + [ 3.days.ago, { balance: 20000, cash_balance: 20000 } ], + [ 4.days.ago, { balance: 15000, cash_balance: 15000 } ] # Opening anchor + ] + ) end - test "balance generation respects user timezone and last generated date is current user date" do - # Simulate user in EST timezone - Time.use_zone("America/New_York") do - # Set current time to 1am UTC on Jan 5, 2025 - # This would be 8pm EST on Jan 4, 2025 (user's time, and the last date we should generate balances for) - travel_to Time.utc(2025, 01, 05, 1, 0, 0) + # Investment account balances are made of two components: cash and holdings. + test "anchors on investment accounts calculate cash balance dynamically based on holdings value" do + account = create_account_with_ledger( + account: { type: Investment, balance: 20000, cash_balance: 10000, currency: "USD" }, + entries: [ + { type: "current_anchor", date: Date.current, balance: 20000 }, # "Total account value is $20,000 today" + { type: "opening_anchor", date: 1.day.ago, balance: 15000 } # "Total account value was $15,000 at the start of the account" + ], + holdings: [ + { date: Date.current, ticker: "AAPL", qty: 100, price: 100, amount: 10000 }, + { date: 1.day.ago, ticker: "AAPL", qty: 100, price: 100, amount: 10000 } + ] + ) - create_valuation(account: @account, date: "2025-01-03", amount: 17000) + calculated = Balance::ReverseCalculator.new(account).calculate - expected = [ [ "2025-01-02", 17000 ], [ "2025-01-03", 17000 ], [ "2025-01-04", @account.balance ] ] - calculated = Balance::ReverseCalculator.new(@account).calculate + assert_calculated_ledger_balances( + calculated_data: calculated, + expected_balances: [ + [ Date.current, { balance: 20000, cash_balance: 10000 } ], # Since $10,000 of holdings, cash has to be $10,000 to reach $20,000 total value + [ 1.day.ago, { balance: 15000, cash_balance: 5000 } ] # Since $10,000 of holdings, cash has to be $5,000 to reach $15,000 total value + ] + ) + end - assert_equal expected, calculated.sort_by(&:date).map { |b| [ b.date.to_s, b.balance ] } + test "transactions on depository accounts affect cash balance" do + account = create_account_with_ledger( + account: { type: Depository, balance: 20000, cash_balance: 20000, currency: "USD" }, + entries: [ + { type: "current_anchor", date: Date.current, balance: 20000 }, + { type: "transaction", date: 4.days.ago, amount: -500 }, # income + { type: "transaction", date: 2.days.ago, amount: 100 } # expense + ] + ) + + calculated = Balance::ReverseCalculator.new(account).calculate + + assert_calculated_ledger_balances( + calculated_data: calculated, + expected_balances: [ + [ Date.current, { balance: 20000, cash_balance: 20000 } ], # Current balance + [ 1.day.ago, { balance: 20000, cash_balance: 20000 } ], # No change + [ 2.days.ago, { balance: 20000, cash_balance: 20000 } ], # After expense (+100) + [ 3.days.ago, { balance: 20100, cash_balance: 20100 } ], # Before expense + [ 4.days.ago, { balance: 20100, cash_balance: 20100 } ], # After income (-500) + [ 5.days.ago, { balance: 19600, cash_balance: 19600 } ] # After income (-500) + ] + ) + end + + test "transactions on credit card accounts affect cash balance inversely" do + account = create_account_with_ledger( + account: { type: CreditCard, balance: 2000, cash_balance: 2000, currency: "USD" }, + entries: [ + { type: "current_anchor", date: Date.current, balance: 2000 }, + { type: "transaction", date: 2.days.ago, amount: 100 }, # expense (increases cash balance) + { type: "transaction", date: 4.days.ago, amount: -500 } # CC payment (reduces cash balance) + ] + ) + + calculated = Balance::ReverseCalculator.new(account).calculate + + # Reversed order: showing how we work backwards + assert_calculated_ledger_balances( + calculated_data: calculated, + expected_balances: [ + [ Date.current, { balance: 2000, cash_balance: 2000 } ], # Current balance + [ 1.day.ago, { balance: 2000, cash_balance: 2000 } ], # No change + [ 2.days.ago, { balance: 2000, cash_balance: 2000 } ], # After expense (+100) + [ 3.days.ago, { balance: 1900, cash_balance: 1900 } ], # Before expense + [ 4.days.ago, { balance: 1900, cash_balance: 1900 } ], # After CC payment (-500) + [ 5.days.ago, { balance: 2400, cash_balance: 2400 } ] + ] + ) + end + + # A loan is a special case where despite being a "non-cash" account, it is typical to have "payment" transactions that reduce the loan principal (non cash balance) + test "loan payment transactions affect non cash balance" do + account = create_account_with_ledger( + account: { type: Loan, balance: 198000, cash_balance: 0, currency: "USD" }, + entries: [ + { type: "current_anchor", date: Date.current, balance: 198000 }, + # "Loan payment" of $2000, which reduces the principal + # TODO: We'll eventually need to calculate which portion of the txn was "interest" vs. "principal", but for now we'll just assume it's all principal + # since we don't have a first-class way to track interest payments yet. + { type: "transaction", date: 1.day.ago.to_date, amount: -2000 } + ] + ) + + calculated = Balance::ReverseCalculator.new(account).calculate + + assert_calculated_ledger_balances( + calculated_data: calculated, + expected_balances: [ + [ Date.current, { balance: 198000, cash_balance: 0 } ], + [ 1.day.ago, { balance: 198000, cash_balance: 0 } ], + [ 2.days.ago, { balance: 200000, cash_balance: 0 } ] + ] + ) + end + + test "non cash accounts can only use valuations and transactions will be recorded but ignored for balance calculation" do + [ Property, Vehicle, OtherAsset, OtherLiability ].each do |account_type| + account = create_account_with_ledger( + account: { type: account_type, balance: 1000, cash_balance: 0, currency: "USD" }, + entries: [ + { type: "current_anchor", date: Date.current, balance: 1000 }, + + # Will be ignored for balance calculation due to account type of non-cash + { type: "transaction", date: 1.day.ago, amount: -100 } + ] + ) + + calculated = Balance::ReverseCalculator.new(account).calculate + + assert_calculated_ledger_balances( + calculated_data: calculated, + expected_balances: [ + [ Date.current, { balance: 1000, cash_balance: 0 } ], + [ 1.day.ago, { balance: 1000, cash_balance: 0 } ], + [ 2.days.ago, { balance: 1000, cash_balance: 0 } ] + ] + ) end end - test "valuations sync" do - create_valuation(account: @account, date: 4.days.ago.to_date, amount: 17000) - create_valuation(account: @account, date: 2.days.ago.to_date, amount: 19000) - - expected = [ 17000, 17000, 19000, 19000, 20000, 20000 ] - calculated = Balance::ReverseCalculator.new(@account).calculate.sort_by(&:date).map(&:balance) - - assert_equal expected, calculated - end - - test "transactions sync" do - create_transaction(account: @account, date: 4.days.ago.to_date, amount: -500) # income - create_transaction(account: @account, date: 2.days.ago.to_date, amount: 100) # expense - - expected = [ 19600, 20100, 20100, 20000, 20000, 20000 ] - calculated = Balance::ReverseCalculator.new(@account).calculate.sort_by(&:date).map(&:balance) - - assert_equal expected, calculated - end - - test "multi-entry sync" do - create_transaction(account: @account, date: 8.days.ago.to_date, amount: -5000) - create_valuation(account: @account, date: 6.days.ago.to_date, amount: 17000) - create_transaction(account: @account, date: 6.days.ago.to_date, amount: -500) - create_transaction(account: @account, date: 4.days.ago.to_date, amount: -500) - create_valuation(account: @account, date: 3.days.ago.to_date, amount: 17000) - create_transaction(account: @account, date: 1.day.ago.to_date, amount: 100) - - expected = [ 12000, 17000, 17000, 17000, 16500, 17000, 17000, 20100, 20000, 20000 ] - calculated = Balance::ReverseCalculator.new(@account).calculate.sort_by(&:date).map(&:balance) - - assert_equal expected, calculated - end - # When syncing backwards, trades from the past should NOT affect the current balance or previous balances. # They should only affect the *cash* component of the historical balances test "holdings and trades sync" do - aapl = securities(:aapl) - # Account starts with $20,000 total value, $19,000 cash, $1,000 in holdings - @account.update!(cash_balance: 19000, balance: 20000) + account = create_account_with_ledger( + account: { type: Investment, balance: 20000, cash_balance: 19000, currency: "USD" }, + entries: [ + { type: "current_anchor", date: Date.current, balance: 20000 }, + # Bought 10 AAPL shares 1 day ago, so cash is $19,000, $1,000 in holdings, total value is $20,000 + { type: "trade", date: 1.day.ago.to_date, ticker: "AAPL", qty: 10, price: 100 } + ], + holdings: [ + { date: Date.current, ticker: "AAPL", qty: 10, price: 100, amount: 1000 }, + { date: 1.day.ago.to_date, ticker: "AAPL", qty: 10, price: 100, amount: 1000 } + ] + ) - # Bought 10 AAPL shares 1 day ago, so cash is $19,000, $1,000 in holdings, total value is $20,000 - create_trade(aapl, account: @account, qty: 10, date: 1.day.ago.to_date, price: 100) - - Holding.create!(date: Date.current, account: @account, security: aapl, qty: 10, price: 100, amount: 1000, currency: "USD") - Holding.create!(date: 1.day.ago.to_date, account: @account, security: aapl, qty: 10, price: 100, amount: 1000, currency: "USD") + calculated = Balance::ReverseCalculator.new(account).calculate # Given constant prices, overall balance (account value) should be constant # (the single trade doesn't affect balance; it just alters cash vs. holdings composition) - expected = [ 20000, 20000, 20000 ] - calculated = Balance::ReverseCalculator.new(@account).calculate.sort_by(&:date).map(&:balance) - - assert_equal expected, calculated + assert_calculated_ledger_balances( + calculated_data: calculated, + expected_balances: [ + [ Date.current, { balance: 20000, cash_balance: 19000 } ], # Current: $19k cash + $1k holdings (anchor) + [ 1.day.ago.to_date, { balance: 20000, cash_balance: 19000 } ], # After trade: $19k cash + $1k holdings + [ 2.days.ago.to_date, { balance: 20000, cash_balance: 20000 } ] # At first, account is 100% cash, no holdings (no trades) + ] + ) end # A common scenario with Plaid is they'll give us holding records for today, but no trade history for some of them. # This is because they only supply 2 years worth of historical data. Our system must properly handle this. test "properly calculates balances when a holding has no trade history" do - aapl = securities(:aapl) - msft = securities(:msft) - # Account starts with $20,000 total value, $19,000 cash, $1,000 in holdings ($500 AAPL, $500 MSFT) - @account.update!(cash_balance: 19000, balance: 20000) + account = create_account_with_ledger( + account: { type: Investment, balance: 20000, cash_balance: 19000, currency: "USD" }, + entries: [ + { type: "current_anchor", date: Date.current, balance: 20000 }, + # A holding *with* trade history (5 shares of AAPL, purchased 1 day ago) + { type: "trade", date: 1.day.ago.to_date, ticker: "AAPL", qty: 5, price: 100 } + ], + holdings: [ + # AAPL holdings + { date: Date.current, ticker: "AAPL", qty: 5, price: 100, amount: 500 }, + { date: 1.day.ago.to_date, ticker: "AAPL", qty: 5, price: 100, amount: 500 }, + # MSFT holdings without trade history - Balance calculator doesn't care how the holdings were created. It just reads them and assumes they are accurate. + { date: Date.current, ticker: "MSFT", qty: 5, price: 100, amount: 500 }, + { date: 1.day.ago.to_date, ticker: "MSFT", qty: 5, price: 100, amount: 500 }, + { date: 2.days.ago.to_date, ticker: "MSFT", qty: 5, price: 100, amount: 500 } + ] + ) - # A holding *with* trade history (5 shares of AAPL, purchased 1 day ago, results in 2 holdings) - Holding.create!(date: Date.current, account: @account, security: aapl, qty: 5, price: 100, amount: 500, currency: "USD") - Holding.create!(date: 1.day.ago.to_date, account: @account, security: aapl, qty: 5, price: 100, amount: 500, currency: "USD") - create_trade(aapl, account: @account, qty: 5, date: 1.day.ago.to_date, price: 100) + calculated = Balance::ReverseCalculator.new(account).calculate - # A holding *without* trade history (5 shares of MSFT, no trade history, results in 1 holding) - # We assume if no history is provided, this holding has existed since beginning of account - Holding.create!(date: Date.current, account: @account, security: msft, qty: 5, price: 100, amount: 500, currency: "USD") - Holding.create!(date: 1.day.ago.to_date, account: @account, security: msft, qty: 5, price: 100, amount: 500, currency: "USD") - Holding.create!(date: 2.days.ago.to_date, account: @account, security: msft, qty: 5, price: 100, amount: 500, currency: "USD") - - expected = [ 20000, 20000, 20000 ] - calculated = Balance::ReverseCalculator.new(@account).calculate.sort_by(&:date).map(&:balance) - - assert_equal expected, calculated + assert_calculated_ledger_balances( + calculated_data: calculated, + expected_balances: [ + [ Date.current, { balance: 20000, cash_balance: 19000 } ], # Current: $19k cash + $1k holdings ($500 MSFT, $500 AAPL) + [ 1.day.ago.to_date, { balance: 20000, cash_balance: 19000 } ], # After AAPL trade: $19k cash + $1k holdings + [ 2.days.ago.to_date, { balance: 20000, cash_balance: 19500 } ] # Before AAPL trade: $19.5k cash + $500 MSFT + ] + ) end test "uses provider reported holdings and cash value on current day" do - aapl = securities(:aapl) - # Implied holdings value of $1,000 from provider - @account.update!(cash_balance: 19000, balance: 20000) + account = create_account_with_ledger( + account: { type: Investment, balance: 20000, cash_balance: 19000, currency: "USD" }, + entries: [ + { type: "current_anchor", date: Date.current, balance: 20000 }, + { type: "opening_anchor", date: 2.days.ago, balance: 15000 } + ], + holdings: [ + # Create holdings that differ in value from provider ($2,000 vs. the $1,000 reported by provider) + { date: Date.current, ticker: "AAPL", qty: 10, price: 100, amount: 2000 }, + { date: 1.day.ago, ticker: "AAPL", qty: 10, price: 100, amount: 2000 } + ] + ) - # Create a holding that differs in value from provider ($2,000 vs. the $1,000 reported by provider) - Holding.create!(date: Date.current, account: @account, security: aapl, qty: 10, price: 100, amount: 2000, currency: "USD") - Holding.create!(date: 1.day.ago.to_date, account: @account, security: aapl, qty: 10, price: 100, amount: 2000, currency: "USD") + calculated = Balance::ReverseCalculator.new(account).calculate - # Today reports the provider value. Yesterday, provider won't give us any data, so we MUST look at the generated holdings value - # to calculate the end balance ($19,000 cash + $2,000 holdings = $21,000 total value) - expected = [ 21000, 20000 ] - - calculated = Balance::ReverseCalculator.new(@account).calculate.sort_by(&:date).map(&:balance) - - assert_equal expected, calculated + assert_calculated_ledger_balances( + calculated_data: calculated, + expected_balances: [ + # No matter what, we force current day equal to the "anchor" balance (what provider gave us), and let "cash" float based on holdings value + # This ensures the user sees the same top-line number reported by the provider (even if it creates a discrepancy in the cash balance) + [ Date.current, { balance: 20000, cash_balance: 18000 } ], + [ 1.day.ago, { balance: 20000, cash_balance: 18000 } ], + [ 2.days.ago, { balance: 15000, cash_balance: 15000 } ] # Opening anchor sets absolute balance + ] + ) end end diff --git a/test/models/plaid_account/processor_test.rb b/test/models/plaid_account/processor_test.rb index ec75296d..ba6a002f 100644 --- a/test/models/plaid_account/processor_test.rb +++ b/test/models/plaid_account/processor_test.rb @@ -94,10 +94,21 @@ class PlaidAccount::ProcessorTest < ActiveSupport::TestCase test "calculates balance using BalanceCalculator for investment accounts" do @plaid_account.update!(plaid_type: "investment") - PlaidAccount::Investments::BalanceCalculator.any_instance.expects(:balance).returns(1000).once + # Balance is called twice: once for account.balance and once for set_current_balance + PlaidAccount::Investments::BalanceCalculator.any_instance.expects(:balance).returns(1000).twice PlaidAccount::Investments::BalanceCalculator.any_instance.expects(:cash_balance).returns(1000).once PlaidAccount::Processor.new(@plaid_account).process + + # Verify that the balance was set correctly + account = @plaid_account.account + assert_equal 1000, account.balance + assert_equal 1000, account.cash_balance + + # Verify current balance anchor was created with correct value + current_anchor = account.valuations.current_anchor.first + assert_not_nil current_anchor + assert_equal 1000, current_anchor.entry.amount end test "processes credit liability data" do @@ -142,6 +153,76 @@ class PlaidAccount::ProcessorTest < ActiveSupport::TestCase PlaidAccount::Processor.new(@plaid_account).process end + test "creates current balance anchor when processing account" do + expect_default_subprocessor_calls + + # Clear out accounts to start fresh + Account.destroy_all + + @plaid_account.update!( + plaid_id: "test_plaid_id", + plaid_type: "depository", + plaid_subtype: "checking", + current_balance: 1500, + available_balance: 1500, + currency: "USD", + name: "Test Account with Anchor", + mask: "1234" + ) + + assert_difference "Account.count", 1 do + assert_difference "Entry.count", 1 do + assert_difference "Valuation.count", 1 do + PlaidAccount::Processor.new(@plaid_account).process + end + end + end + + account = Account.order(created_at: :desc).first + assert_equal 1500, account.balance + + # Verify current balance anchor was created + current_anchor = account.valuations.current_anchor.first + assert_not_nil current_anchor + assert_equal "current_anchor", current_anchor.kind + assert_equal 1500, current_anchor.entry.amount + assert_equal Date.current, current_anchor.entry.date + assert_equal "Current balance", current_anchor.entry.name + end + + test "updates existing current balance anchor when reprocessing" do + # First process creates the account and anchor + expect_default_subprocessor_calls + PlaidAccount::Processor.new(@plaid_account).process + + account = @plaid_account.account + original_anchor = account.valuations.current_anchor.first + assert_not_nil original_anchor + original_anchor_id = original_anchor.id + original_entry_id = original_anchor.entry.id + original_balance = original_anchor.entry.amount + + # Update the plaid account balance + @plaid_account.update!(current_balance: 2500) + + # Expect subprocessor calls again for the second processing + expect_default_subprocessor_calls + + # Reprocess should update the existing anchor + assert_no_difference "Valuation.count" do + assert_no_difference "Entry.count" do + PlaidAccount::Processor.new(@plaid_account).process + end + end + + # Verify the anchor was updated + original_anchor.reload + assert_equal original_anchor_id, original_anchor.id + assert_equal original_entry_id, original_anchor.entry.id + assert_equal 2500, original_anchor.entry.amount + assert_not_equal original_balance, original_anchor.entry.amount + end + private def expect_investment_product_processor_calls PlaidAccount::Investments::TransactionsProcessor.any_instance.expects(:process).once diff --git a/test/models/valuation/name_test.rb b/test/models/valuation/name_test.rb index 7fa41cca..feed97ea 100644 --- a/test/models/valuation/name_test.rb +++ b/test/models/valuation/name_test.rb @@ -17,6 +17,21 @@ class Valuation::NameTest < ActiveSupport::TestCase assert_equal "Opening account value", name.to_s end + test "generates opening anchor name for Vehicle" do + name = Valuation::Name.new("opening_anchor", "Vehicle") + assert_equal "Original purchase price", name.to_s + end + + test "generates opening anchor name for Crypto" do + name = Valuation::Name.new("opening_anchor", "Crypto") + assert_equal "Opening account value", name.to_s + end + + test "generates opening anchor name for OtherAsset" do + name = Valuation::Name.new("opening_anchor", "OtherAsset") + assert_equal "Opening account value", name.to_s + end + test "generates opening anchor name for other account types" do name = Valuation::Name.new("opening_anchor", "Depository") assert_equal "Opening balance", name.to_s @@ -38,6 +53,21 @@ class Valuation::NameTest < ActiveSupport::TestCase assert_equal "Current account value", name.to_s end + test "generates current anchor name for Vehicle" do + name = Valuation::Name.new("current_anchor", "Vehicle") + assert_equal "Current market value", name.to_s + end + + test "generates current anchor name for Crypto" do + name = Valuation::Name.new("current_anchor", "Crypto") + assert_equal "Current account value", name.to_s + end + + test "generates current anchor name for OtherAsset" do + name = Valuation::Name.new("current_anchor", "OtherAsset") + assert_equal "Current account value", name.to_s + end + test "generates current anchor name for other account types" do name = Valuation::Name.new("current_anchor", "Depository") assert_equal "Current balance", name.to_s @@ -54,6 +84,21 @@ class Valuation::NameTest < ActiveSupport::TestCase assert_equal "Manual value update", name.to_s end + test "generates recon name for Vehicle" do + name = Valuation::Name.new("reconciliation", "Vehicle") + assert_equal "Manual value update", name.to_s + end + + test "generates recon name for Crypto" do + name = Valuation::Name.new("reconciliation", "Crypto") + assert_equal "Manual value update", name.to_s + end + + test "generates recon name for OtherAsset" do + name = Valuation::Name.new("reconciliation", "OtherAsset") + assert_equal "Manual value update", name.to_s + end + test "generates recon name for Loan" do name = Valuation::Name.new("reconciliation", "Loan") assert_equal "Manual principal update", name.to_s diff --git a/test/support/entries_test_helper.rb b/test/support/entries_test_helper.rb index a4f2013f..35e5450f 100644 --- a/test/support/entries_test_helper.rb +++ b/test/support/entries_test_helper.rb @@ -15,17 +15,50 @@ module EntriesTestHelper Entry.create! entry_defaults.merge(entry_attributes) end + def create_opening_anchor_valuation(account:, balance:, date:) + create_valuation( + account: account, + kind: "opening_anchor", + amount: balance, + date: date + ) + end + + def create_reconciliation_valuation(account:, balance:, date:) + create_valuation( + account: account, + kind: "reconciliation", + amount: balance, + date: date + ) + end + + def create_current_anchor_valuation(account:, balance:, date: Date.current) + create_valuation( + account: account, + kind: "current_anchor", + amount: balance, + date: date + ) + end + def create_valuation(attributes = {}) + entry_attributes = attributes.except(:kind) + valuation_attributes = attributes.slice(:kind) + + account = attributes[:account] || accounts(:depository) + amount = attributes[:amount] || 5000 + entry_defaults = { - account: accounts(:depository), + account: account, name: "Valuation", date: 1.day.ago.to_date, currency: "USD", - amount: 5000, - entryable: Valuation.new + amount: amount, + entryable: Valuation.new({ kind: "reconciliation" }.merge(valuation_attributes)) } - Entry.create! entry_defaults.merge(attributes) + Entry.create! entry_defaults.merge(entry_attributes) end def create_trade(security, account:, qty:, date:, price: nil, currency: "USD") diff --git a/test/support/ledger_testing_helper.rb b/test/support/ledger_testing_helper.rb new file mode 100644 index 00000000..6ae71678 --- /dev/null +++ b/test/support/ledger_testing_helper.rb @@ -0,0 +1,152 @@ +module LedgerTestingHelper + def create_account_with_ledger(account:, entries: [], exchange_rates: [], security_prices: [], holdings: []) + # Clear all exchange rates and security prices to ensure clean test environment + ExchangeRate.destroy_all + Security::Price.destroy_all + + # Create account with specified attributes + account_attrs = account.except(:type) + account_type = account[:type] + + # Create the account + created_account = families(:empty).accounts.create!( + name: "Test Account", + accountable: account_type.new, + **account_attrs + ) + + # Set up exchange rates if provided + exchange_rates.each do |rate_data| + ExchangeRate.create!( + date: rate_data[:date], + from_currency: rate_data[:from], + to_currency: rate_data[:to], + rate: rate_data[:rate] + ) + end + + # Set up security prices if provided + security_prices.each do |price_data| + security = Security.find_or_create_by!(ticker: price_data[:ticker]) do |s| + s.name = price_data[:ticker] + end + + Security::Price.create!( + security: security, + date: price_data[:date], + price: price_data[:price], + currency: created_account.currency + ) + end + + # Create entries in the order they were specified + entries.each do |entry_data| + case entry_data[:type] + when "current_anchor", "opening_anchor", "reconciliation" + # Create valuation entry + created_account.entries.create!( + name: "Valuation", + date: entry_data[:date], + amount: entry_data[:balance], + currency: entry_data[:currency] || created_account.currency, + entryable: Valuation.new(kind: entry_data[:type]) + ) + when "transaction" + # Use account currency if not specified + currency = entry_data[:currency] || created_account.currency + + created_account.entries.create!( + name: "Transaction", + date: entry_data[:date], + amount: entry_data[:amount], + currency: currency, + entryable: Transaction.new + ) + when "trade" + # Find or create security + security = Security.find_or_create_by!(ticker: entry_data[:ticker]) do |s| + s.name = entry_data[:ticker] + end + + # Use account currency if not specified + currency = entry_data[:currency] || created_account.currency + + trade = Trade.new( + qty: entry_data[:qty], + security: security, + price: entry_data[:price], + currency: currency + ) + + created_account.entries.create!( + name: "Trade", + date: entry_data[:date], + amount: entry_data[:qty] * entry_data[:price], + currency: currency, + entryable: trade + ) + end + end + + # Create holdings if provided + holdings.each do |holding_data| + # Find or create security + security = Security.find_or_create_by!(ticker: holding_data[:ticker]) do |s| + s.name = holding_data[:ticker] + end + + Holding.create!( + account: created_account, + security: security, + date: holding_data[:date], + qty: holding_data[:qty], + price: holding_data[:price], + amount: holding_data[:amount], + currency: holding_data[:currency] || created_account.currency + ) + end + + created_account + end + + def assert_calculated_ledger_balances(calculated_data:, expected_balances:) + # Convert expected balances to a hash for easier lookup + expected_hash = expected_balances.to_h do |date, balance_data| + [ date.to_date, balance_data ] + end + + # Get all unique dates from both calculated and expected data + all_dates = (calculated_data.map(&:date) + expected_hash.keys).uniq.sort + + # Check each date + all_dates.each do |date| + calculated_balance = calculated_data.find { |b| b.date == date } + expected = expected_hash[date] + + if expected + assert calculated_balance, "Expected balance for #{date} but none was calculated" + + if expected[:balance] + assert_equal expected[:balance], calculated_balance.balance.to_d, + "Balance mismatch for #{date}" + end + + if expected[:cash_balance] + assert_equal expected[:cash_balance], calculated_balance.cash_balance.to_d, + "Cash balance mismatch for #{date}" + end + else + assert_nil calculated_balance, "Unexpected balance calculated for #{date}" + end + end + + # Verify we got all expected dates + expected_dates = expected_hash.keys.sort + calculated_dates = calculated_data.map(&:date).sort + + expected_dates.each do |date| + assert_includes calculated_dates, date, + "Expected balance for #{date} was not in calculated data" + end + end +end