From facd74f73378f5fd7b7503e799011b4e5da88781 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Mon, 4 Mar 2024 08:31:22 -0500 Subject: [PATCH] Net worth calculation (#508) * Add classification generated column to account * Add basic net worth calculation * Add net worth tests * Fix lint errors --- app/helpers/application_helper.rb | 4 +- app/models/account.rb | 16 ----- app/models/account/balance_calculator.rb | 4 +- app/models/family.rb | 62 +++++++++++++++++ app/models/money_series.rb | 2 +- app/models/trend.rb | 4 +- ...02145715_add_classification_to_accounts.rb | 18 +++++ db/schema.rb | 3 +- test/controllers/accounts_controller_test.rb | 2 +- .../controllers/valuations_controller_test.rb | 2 +- test/fixtures/accounts.yml | 10 ++- test/models/family_test.rb | 69 ++++++++++++++++--- 12 files changed, 156 insertions(+), 40 deletions(-) create mode 100644 db/migrate/20240302145715_add_classification_to_accounts.rb diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index cba97434..0ef198be 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -52,9 +52,9 @@ module ApplicationHelper def trend_styles(trend) bg_class, text_class, symbol, icon = case trend.direction when "up" - trend.type == :liability ? [ "bg-red-500/5", "text-red-500", "+", "arrow-up" ] : [ "bg-green-500/5", "text-green-500", "+", "arrow-up" ] + trend.type == "liability" ? [ "bg-red-500/5", "text-red-500", "+", "arrow-up" ] : [ "bg-green-500/5", "text-green-500", "+", "arrow-up" ] when "down" - trend.type == :liability ? [ "bg-green-500/5", "text-green-500", "-", "arrow-down" ] : [ "bg-red-500/5", "text-red-500", "-", "arrow-down" ] + trend.type == "liability" ? [ "bg-green-500/5", "text-green-500", "-", "arrow-down" ] : [ "bg-red-500/5", "text-red-500", "-", "arrow-down" ] when "flat" [ "bg-gray-500/5", "text-gray-500", "", "minus" ] else diff --git a/app/models/account.rb b/app/models/account.rb index eb421166..ceabced7 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -9,24 +9,8 @@ class Account < ApplicationRecord delegated_type :accountable, types: Accountable::TYPES, dependent: :destroy - delegate :type_name, to: :accountable before_create :check_currency - def classification - classifications = { - "Account::Depository" => :asset, - "Account::Investment" => :asset, - "Account::Property" => :asset, - "Account::Vehicle" => :asset, - "Account::OtherAsset" => :asset, - "Account::Loan" => :liability, - "Account::Credit" => :liability, - "Account::OtherLiability" => :liability - } - - classifications[accountable_type] - end - def balance_series(period) MoneySeries.new( balances.in_period(period).order(:date), diff --git a/app/models/account/balance_calculator.rb b/app/models/account/balance_calculator.rb index 435f4407..640ec640 100644 --- a/app/models/account/balance_calculator.rb +++ b/app/models/account/balance_calculator.rb @@ -11,7 +11,7 @@ class Account::BalanceCalculator oldest_entry = [ valuations.first, transactions.first ].compact.min_by(&:date) net_transaction_flows = transactions.sum(&:amount) - net_transaction_flows *= -1 if @account.classification == :liability + net_transaction_flows *= -1 if @account.classification == "liability" implied_start_balance = oldest_entry.is_a?(Valuation) ? oldest_entry.value : @account.balance + net_transaction_flows prior_balance = implied_start_balance @@ -22,7 +22,7 @@ class Account::BalanceCalculator current_balance = valuation.value else current_day_net_transaction_flows = transactions.select { |t| t.date == date }.sum(&:amount) - current_day_net_transaction_flows *= -1 if @account.classification == :liability + current_day_net_transaction_flows *= -1 if @account.classification == "liability" current_balance = prior_balance - current_day_net_transaction_flows end diff --git a/app/models/family.rb b/app/models/family.rb index fca65cb0..32261214 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -2,4 +2,66 @@ class Family < ApplicationRecord has_many :users, dependent: :destroy has_many :accounts, dependent: :destroy has_many :transactions, through: :accounts + + def net_worth + accounts.sum("CASE WHEN classification = 'asset' THEN balance ELSE -balance END") + end + + def assets + accounts.where(classification: "asset").sum(:balance) + end + + def liabilities + accounts.where(classification: "liability").sum(:balance) + end + + def net_worth_series(period = nil) + query = accounts.joins(:balances) + .select("account_balances.date, SUM(CASE WHEN accounts.classification = 'asset' THEN account_balances.balance ELSE -account_balances.balance END) AS balance, 'USD' as currency") + .group("account_balances.date") + .order("account_balances.date ASC") + + if period && period.date_range + query = query.where("account_balances.date BETWEEN ? AND ?", period.date_range.begin, period.date_range.end) + end + + MoneySeries.new( + query, + { trend_type: "asset" } + ) + end + + def asset_series(period = nil) + query = accounts.joins(:balances) + .select("account_balances.date, SUM(account_balances.balance) AS balance, 'asset' AS classification, 'USD' AS currency") + .group("account_balances.date") + .order("account_balances.date ASC") + .where(classification: "asset") + + if period && period.date_range + query = query.where("account_balances.date BETWEEN ? AND ?", period.date_range.begin, period.date_range.end) + end + + MoneySeries.new( + query, + { trend_type: "asset" } + ) + end + + def liability_series(period = nil) + query = accounts.joins(:balances) + .select("account_balances.date, SUM(account_balances.balance) AS balance, 'liability' AS classification, 'USD' AS currency") + .group("account_balances.date") + .order("account_balances.date ASC") + .where(classification: "liability") + + if period && period.date_range + query = query.where("account_balances.date BETWEEN ? AND ?", period.date_range.begin, period.date_range.end) + end + + MoneySeries.new( + query, + { trend_type: "liability" } + ) + end end diff --git a/app/models/money_series.rb b/app/models/money_series.rb index df493343..f9cea5d9 100644 --- a/app/models/money_series.rb +++ b/app/models/money_series.rb @@ -1,6 +1,6 @@ class MoneySeries def initialize(series, options = {}) - @trend_type = options[:trend_type] || :asset # Defines whether a positive trend is good or bad + @trend_type = options[:trend_type] || "asset" # Defines whether a positive trend is good or bad @accessor = options[:amount_accessor] || :balance @series = series end diff --git a/app/models/trend.rb b/app/models/trend.rb index d7a5c64f..abb89036 100644 --- a/app/models/trend.rb +++ b/app/models/trend.rb @@ -1,10 +1,10 @@ class Trend attr_reader :current, :previous, :type - def initialize(current:, previous: nil, type: :asset) + def initialize(current:, previous: nil, type: "asset") @current = current @previous = previous - @type = type # :asset means positive trend is good, :liability means negative trend is good + @type = type # asset means positive trend is good, liability means negative trend is good end def direction diff --git a/db/migrate/20240302145715_add_classification_to_accounts.rb b/db/migrate/20240302145715_add_classification_to_accounts.rb new file mode 100644 index 00000000..6a3da5ec --- /dev/null +++ b/db/migrate/20240302145715_add_classification_to_accounts.rb @@ -0,0 +1,18 @@ +class AddClassificationToAccounts < ActiveRecord::Migration[7.2] + def change + change_table :accounts do |t| + t.virtual( + :classification, + type: :string, + stored: true, + as: <<-SQL + CASE + WHEN accountable_type IN ('Account::Loan', 'Account::Credit', 'Account::OtherLiability') + THEN 'liability' + ELSE 'asset' + END + SQL + ) + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 01b212e0..564b9cf3 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: 2024_02_27_142457) do +ActiveRecord::Schema[7.2].define(version: 2024_03_02_145715) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -79,6 +79,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_02_27_142457) do t.decimal "converted_balance", precision: 19, scale: 4, default: "0.0" t.string "converted_currency", default: "USD" t.string "status", default: "OK" + t.virtual "classification", type: :string, as: "\nCASE\n WHEN ((accountable_type)::text = ANY ((ARRAY['Account::Loan'::character varying, 'Account::Credit'::character varying, 'Account::OtherLiability'::character varying])::text[])) THEN 'liability'::text\n ELSE 'asset'::text\nEND", stored: true t.index ["accountable_type"], name: "index_accounts_on_accountable_type" t.index ["family_id"], name: "index_accounts_on_family_id" end diff --git a/test/controllers/accounts_controller_test.rb b/test/controllers/accounts_controller_test.rb index bb493fee..8ef6db20 100644 --- a/test/controllers/accounts_controller_test.rb +++ b/test/controllers/accounts_controller_test.rb @@ -3,7 +3,7 @@ require "test_helper" class AccountsControllerTest < ActionDispatch::IntegrationTest setup do sign_in @user = users(:family_admin) - @account = accounts(:generic) + @account = accounts(:checking) end test "new" do diff --git a/test/controllers/valuations_controller_test.rb b/test/controllers/valuations_controller_test.rb index 478aaf75..c0f367ac 100644 --- a/test/controllers/valuations_controller_test.rb +++ b/test/controllers/valuations_controller_test.rb @@ -3,7 +3,7 @@ require "test_helper" class ValuationsControllerTest < ActionDispatch::IntegrationTest setup do sign_in @user = users(:family_admin) - @account = accounts(:generic) + @account = accounts(:checking) end test "new" do diff --git a/test/fixtures/accounts.yml b/test/fixtures/accounts.yml index dffb3151..358358c2 100644 --- a/test/fixtures/accounts.yml +++ b/test/fixtures/accounts.yml @@ -1,29 +1,27 @@ -# No transactions, no valuations, just a generic account -generic: - family: dylan_family - name: No history, generic account - balance: 1200 - # Account with only valuations collectable: family: dylan_family name: Collectable Account balance: 550 + accountable_type: Account::OtherAsset # Account with only transactions checking: family: dylan_family name: Checking Account balance: 5000 + accountable_type: Account::Depository # Account with both transactions and valuations savings_with_valuation_overrides: family: dylan_family name: Savings account with valuation overrides balance: 20000 + accountable_type: Account::Depository # Liability account credit_card: family: dylan_family name: Credit Card balance: 1000 + accountable_type: Account::Credit diff --git a/test/models/family_test.rb b/test/models/family_test.rb index d3e37007..83219a37 100644 --- a/test/models/family_test.rb +++ b/test/models/family_test.rb @@ -2,27 +2,80 @@ require "test_helper" class FamilyTest < ActiveSupport::TestCase def setup - @dylan_family = families(:dylan_family) + @family = families(:dylan_family) + + @family.accounts.each do |account| + account.accountable = account.classification == "asset" ? account_other_assets(:one) : account_other_liabilities(:one) + account.sync + end end test "should have many users" do - assert @dylan_family.users.size > 0 - assert @dylan_family.users.include?(users(:family_admin)) + assert @family.users.size > 0 + assert @family.users.include?(users(:family_admin)) end test "should have many accounts" do - assert @dylan_family.accounts.size > 0 + assert @family.accounts.size > 0 end test "should destroy dependent users" do - assert_difference("User.count", -@dylan_family.users.count) do - @dylan_family.destroy + assert_difference("User.count", -@family.users.count) do + @family.destroy end end test "should destroy dependent accounts" do - assert_difference("Account.count", -@dylan_family.accounts.count) do - @dylan_family.destroy + assert_difference("Account.count", -@family.accounts.count) do + @family.destroy end end + + test "should calculate total assets" do + assert_equal BigDecimal("25550"), @family.assets + end + + test "should calculate total liabilities" do + assert_equal BigDecimal("1000"), @family.liabilities + end + + test "should calculate net worth" do + assert_equal BigDecimal("24550"), @family.net_worth + end + + test "calculates asset series" do + # Sum of expected balances for all asset accounts in balance_calculator_test.rb + expected_balances = [ + 25650, 26135, 26135, 26135, 26135, 25385, 25385, 25385, 26460, 26460, + 26460, 26460, 24460, 24460, 24460, 24440, 24440, 24440, 25210, 25210, + 25210, 25210, 25210, 25210, 25210, 25400, 25250, 26050, 26050, 26050, + 25550 + ].map(&:to_d) + + assert_equal expected_balances, @family.asset_series.data.map { |b| b[:value].amount } + end + + test "calculates liability series" do + # Sum of expected balances for all liability accounts in balance_calculator_test.rb + expected_balances = [ + 1040, 940, 940, 940, 940, 940, 940, 940, 940, 940, + 940, 940, 940, 940, 940, 960, 960, 960, 990, 990, + 990, 990, 990, 990, 990, 1000, 1000, 1000, 1000, 1000, + 1000 + ].map(&:to_d) + + assert_equal expected_balances, @family.liability_series.data.map { |b| b[:value].amount } + end + + test "calculates net worth" do + # Net difference between asset and liability series above + expected_balances = [ + 24610, 25195, 25195, 25195, 25195, 24445, 24445, 24445, 25520, 25520, + 25520, 25520, 23520, 23520, 23520, 23480, 23480, 23480, 24220, 24220, + 24220, 24220, 24220, 24220, 24220, 24400, 24250, 25050, 25050, 25050, + 24550 + ].map(&:to_d) + + assert_equal expected_balances, @family.net_worth_series.data.map { |b| b[:value].amount } + end end