From 347c0a790693031fdd3b32792b5b6792693d1805 Mon Sep 17 00:00:00 2001 From: Akshay Birajdar Date: Tue, 22 Jul 2025 15:51:00 +0530 Subject: [PATCH 01/20] feat: Only show active accounts for transaction form (#2484) --- app/views/transactions/_form.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/transactions/_form.html.erb b/app/views/transactions/_form.html.erb index 46fd4ac1..3760f7dd 100644 --- a/app/views/transactions/_form.html.erb +++ b/app/views/transactions/_form.html.erb @@ -18,7 +18,7 @@ <% if @entry.account_id %> <%= f.hidden_field :account_id %> <% else %> - <%= f.collection_select :account_id, Current.family.accounts.manual.alphabetically, :id, :name, { prompt: t(".account_prompt"), label: t(".account") }, required: true, class: "form-field__input text-ellipsis" %> + <%= f.collection_select :account_id, Current.family.accounts.manual.active.alphabetically, :id, :name, { prompt: t(".account_prompt"), label: t(".account") }, required: true, class: "form-field__input text-ellipsis" %> <% end %> <%= f.money_field :amount, label: t(".amount"), required: true %> From da2045dbd8164edf73b0361b4a50990818e6ce33 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Wed, 23 Jul 2025 10:06:25 -0400 Subject: [PATCH 02/20] Additional cache columns on balances for activity view breakdowns (#2505) * Initial schema iteration * Add new balance components * Add existing data migrator to backfill components * Update calculator test assertions for new balance components * Update flow assertions for forward calculator * Update reverse calculator flows assumptions * Forward calculator tests passing * Get all calculator tests passing * Assert flows factor --- .../balance_component_migrator.rb | 59 +++ app/models/balance.rb | 10 +- app/models/balance/base_calculator.rb | 83 +++- app/models/balance/forward_calculator.rb | 20 +- app/models/balance/reverse_calculator.rb | 55 +-- app/models/balance/sync_cache.rb | 4 +- ...21103_add_start_end_columns_to_balances.rb | 72 ++++ db/schema.rb | 16 +- lib/tasks/data_migration.rake | 15 + .../balance_component_migrator_test.rb | 160 ++++++++ .../models/balance/forward_calculator_test.rb | 354 ++++++++++++++---- .../models/balance/reverse_calculator_test.rb | 309 ++++++++++++--- test/support/ledger_testing_helper.rb | 179 ++++++++- 13 files changed, 1159 insertions(+), 177 deletions(-) create mode 100644 app/data_migrations/balance_component_migrator.rb create mode 100644 db/migrate/20250719121103_add_start_end_columns_to_balances.rb create mode 100644 test/data_migrations/balance_component_migrator_test.rb diff --git a/app/data_migrations/balance_component_migrator.rb b/app/data_migrations/balance_component_migrator.rb new file mode 100644 index 00000000..806c2abc --- /dev/null +++ b/app/data_migrations/balance_component_migrator.rb @@ -0,0 +1,59 @@ +class BalanceComponentMigrator + def self.run + ActiveRecord::Base.transaction do + # Step 1: Update flows factor + ActiveRecord::Base.connection.execute <<~SQL + UPDATE balances SET + flows_factor = CASE WHEN a.classification = 'asset' THEN 1 ELSE -1 END + FROM accounts a + WHERE a.id = balances.account_id + SQL + + # Step 2: Set start values using LOCF (Last Observation Carried Forward) + ActiveRecord::Base.connection.execute <<~SQL + UPDATE balances b1 + SET + start_cash_balance = COALESCE(prev.cash_balance, 0), + start_non_cash_balance = COALESCE(prev.balance - prev.cash_balance, 0) + FROM balances b1_inner + LEFT JOIN LATERAL ( + SELECT + b2.cash_balance, + b2.balance + FROM balances b2 + WHERE b2.account_id = b1_inner.account_id + AND b2.currency = b1_inner.currency + AND b2.date < b1_inner.date + ORDER BY b2.date DESC + LIMIT 1 + ) prev ON true + WHERE b1.id = b1_inner.id + SQL + + # Step 3: Calculate net inflows + # A slight workaround to the fact that we can't easily derive inflows/outflows from our current data model, and + # the tradeoff not worth it since each new sync will fix it. So instead, we sum up *net* flows, and throw the signed + # amount in the "inflows" column, and zero-out the "outflows" column so our math works correctly with incomplete data. + ActiveRecord::Base.connection.execute <<~SQL + UPDATE balances SET + cash_inflows = (cash_balance - start_cash_balance) * flows_factor, + cash_outflows = 0, + non_cash_inflows = ((balance - cash_balance) - start_non_cash_balance) * flows_factor, + non_cash_outflows = 0, + net_market_flows = 0 + SQL + + # Verify data integrity + # All end_balance values should match the original balance + invalid_count = ActiveRecord::Base.connection.select_value(<<~SQL) + SELECT COUNT(*) + FROM balances b + WHERE ABS(b.balance - b.end_balance) > 0.0001 + SQL + + if invalid_count > 0 + raise "Data migration failed validation: #{invalid_count} balances have incorrect end_balance values" + end + end + end +end diff --git a/app/models/balance.rb b/app/models/balance.rb index ff28db90..dffc9f07 100644 --- a/app/models/balance.rb +++ b/app/models/balance.rb @@ -2,8 +2,16 @@ class Balance < ApplicationRecord include Monetizable belongs_to :account + validates :account, :date, :balance, presence: true - monetize :balance, :cash_balance + validates :flows_factor, inclusion: { in: [ -1, 1 ] } + + monetize :balance, :cash_balance, + :start_cash_balance, :start_non_cash_balance, :start_balance, + :cash_inflows, :cash_outflows, :non_cash_inflows, :non_cash_outflows, :net_market_flows, + :cash_adjustments, :non_cash_adjustments, + :end_cash_balance, :end_non_cash_balance, :end_balance + scope :in_period, ->(period) { period.nil? ? all : where(date: period.date_range) } scope :chronological, -> { order(:date) } end diff --git a/app/models/balance/base_calculator.rb b/app/models/balance/base_calculator.rb index 92ef5d3e..9d1d288f 100644 --- a/app/models/balance/base_calculator.rb +++ b/app/models/balance/base_calculator.rb @@ -15,8 +15,8 @@ class Balance::BaseCalculator end def holdings_value_for_date(date) - holdings = sync_cache.get_holdings(date) - holdings.sum(&:amount) + @holdings_value_for_date ||= {} + @holdings_value_for_date[date] ||= sync_cache.get_holdings(date).sum(&:amount) end def derive_cash_balance_on_date_from_total(total_balance:, date:) @@ -29,6 +29,67 @@ class Balance::BaseCalculator end end + def cash_adjustments_for_date(start_cash, net_cash_flows, valuation) + return 0 unless valuation && account.balance_type != :non_cash + + valuation.amount - start_cash - net_cash_flows + end + + def non_cash_adjustments_for_date(start_non_cash, non_cash_flows, valuation) + return 0 unless valuation && account.balance_type == :non_cash + + valuation.amount - start_non_cash - non_cash_flows + end + + # If holdings value goes from $100 -> $200 (change_holdings_value is $100) + # And non-cash flows (i.e. "buys") for day are +$50 (net_buy_sell_value is $50) + # That means value increased by $100, where $50 of that is due to the change in holdings value, and $50 is due to the buy/sell + def market_value_change_on_date(date, flows) + return 0 unless account.balance_type == :investment + + start_of_day_holdings_value = holdings_value_for_date(date.prev_day) + end_of_day_holdings_value = holdings_value_for_date(date) + + change_holdings_value = end_of_day_holdings_value - start_of_day_holdings_value + net_buy_sell_value = flows[:non_cash_inflows] - flows[:non_cash_outflows] + + change_holdings_value - net_buy_sell_value + end + + def flows_for_date(date) + entries = sync_cache.get_entries(date) + + cash_inflows = 0 + cash_outflows = 0 + non_cash_inflows = 0 + non_cash_outflows = 0 + + txn_inflow_sum = entries.select { |e| e.amount < 0 && e.transaction? }.sum(&:amount) + txn_outflow_sum = entries.select { |e| e.amount >= 0 && e.transaction? }.sum(&:amount) + + trade_cash_inflow_sum = entries.select { |e| e.amount < 0 && e.trade? }.sum(&:amount) + trade_cash_outflow_sum = entries.select { |e| e.amount >= 0 && e.trade? }.sum(&:amount) + + if account.balance_type == :non_cash && account.accountable_type == "Loan" + non_cash_inflows = txn_inflow_sum.abs + non_cash_outflows = txn_outflow_sum + elsif account.balance_type != :non_cash + cash_inflows = txn_inflow_sum.abs + trade_cash_inflow_sum.abs + cash_outflows = txn_outflow_sum + trade_cash_outflow_sum + + # Trades are inverse (a "buy" is outflow of cash, but "inflow" of non-cash, aka "holdings") + non_cash_outflows = trade_cash_inflow_sum.abs + non_cash_inflows = trade_cash_outflow_sum + end + + { + cash_inflows: cash_inflows, + cash_outflows: cash_outflows, + non_cash_inflows: non_cash_inflows, + non_cash_outflows: non_cash_outflows + } + end + def derive_cash_balance(cash_balance, date) entries = sync_cache.get_entries(date) @@ -57,13 +118,23 @@ class Balance::BaseCalculator raise NotImplementedError, "Directional calculators must implement this method" end - def build_balance(date:, cash_balance:, non_cash_balance:) + def build_balance(date:, **args) Balance.new( account_id: account.id, + currency: account.currency, date: date, - balance: non_cash_balance + cash_balance, - cash_balance: cash_balance, - currency: account.currency + balance: args[:balance], + cash_balance: args[:cash_balance], + start_cash_balance: args[:start_cash_balance] || 0, + start_non_cash_balance: args[:start_non_cash_balance] || 0, + cash_inflows: args[:cash_inflows] || 0, + cash_outflows: args[:cash_outflows] || 0, + non_cash_inflows: args[:non_cash_inflows] || 0, + non_cash_outflows: args[:non_cash_outflows] || 0, + cash_adjustments: args[:cash_adjustments] || 0, + non_cash_adjustments: args[:non_cash_adjustments] || 0, + net_market_flows: args[:net_market_flows] || 0, + flows_factor: account.classification == "asset" ? 1 : -1 ) end end diff --git a/app/models/balance/forward_calculator.rb b/app/models/balance/forward_calculator.rb index bd9272b7..42a420f7 100644 --- a/app/models/balance/forward_calculator.rb +++ b/app/models/balance/forward_calculator.rb @@ -2,13 +2,13 @@ class Balance::ForwardCalculator < Balance::BaseCalculator def calculate Rails.logger.tagged("Balance::ForwardCalculator") do start_cash_balance = derive_cash_balance_on_date_from_total( - total_balance: account.opening_anchor_balance, + total_balance: 0, date: account.opening_anchor_date ) - start_non_cash_balance = account.opening_anchor_balance - start_cash_balance + start_non_cash_balance = 0 calc_start_date.upto(calc_end_date).map do |date| - valuation = sync_cache.get_reconciliation_valuation(date) + valuation = sync_cache.get_valuation(date) if valuation end_cash_balance = derive_cash_balance_on_date_from_total( @@ -21,10 +21,22 @@ class Balance::ForwardCalculator < Balance::BaseCalculator end_non_cash_balance = derive_end_non_cash_balance(start_non_cash_balance: start_non_cash_balance, date: date) end + flows = flows_for_date(date) + market_value_change = market_value_change_on_date(date, flows) + output_balance = build_balance( date: date, + balance: end_cash_balance + end_non_cash_balance, cash_balance: end_cash_balance, - non_cash_balance: end_non_cash_balance + start_cash_balance: start_cash_balance, + start_non_cash_balance: start_non_cash_balance, + cash_inflows: flows[:cash_inflows], + cash_outflows: flows[:cash_outflows], + non_cash_inflows: flows[:non_cash_inflows], + non_cash_outflows: flows[:non_cash_outflows], + cash_adjustments: cash_adjustments_for_date(start_cash_balance, flows[:cash_inflows] - flows[:cash_outflows], valuation), + non_cash_adjustments: non_cash_adjustments_for_date(start_non_cash_balance, flows[:non_cash_inflows] - flows[:non_cash_outflows], valuation), + net_market_flows: market_value_change ) # Set values for the next iteration diff --git a/app/models/balance/reverse_calculator.rb b/app/models/balance/reverse_calculator.rb index 1e75d5e4..35a77445 100644 --- a/app/models/balance/reverse_calculator.rb +++ b/app/models/balance/reverse_calculator.rb @@ -11,6 +11,8 @@ class Balance::ReverseCalculator < Balance::BaseCalculator # Calculates in reverse-chronological order (End of day -> Start of day) account.current_anchor_date.downto(account.opening_anchor_date).map do |date| + flows = flows_for_date(date) + if use_opening_anchor_for_date?(date) end_cash_balance = derive_cash_balance_on_date_from_total( total_balance: account.opening_anchor_balance, @@ -20,29 +22,30 @@ class Balance::ReverseCalculator < Balance::BaseCalculator 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 - ) + market_value_change = 0 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 + market_value_change = market_value_change_on_date(date, flows) end + + output_balance = build_balance( + date: date, + balance: end_cash_balance + end_non_cash_balance, + cash_balance: end_cash_balance, + start_cash_balance: start_cash_balance, + start_non_cash_balance: start_non_cash_balance, + cash_inflows: flows[:cash_inflows], + cash_outflows: flows[:cash_outflows], + non_cash_inflows: flows[:non_cash_inflows], + non_cash_outflows: flows[:non_cash_outflows], + net_market_flows: market_value_change + ) + + end_cash_balance = start_cash_balance + end_non_cash_balance = start_non_cash_balance + + output_balance end end end @@ -58,13 +61,6 @@ class Balance::ReverseCalculator < Balance::BaseCalculator account.asset? ? entry_flows : -entry_flows end - # 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 - # 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:) @@ -76,4 +72,11 @@ class Balance::ReverseCalculator < Balance::BaseCalculator def derive_start_non_cash_balance(end_non_cash_balance:, date:) derive_non_cash_balance(end_non_cash_balance, date, direction: :reverse) end + + # 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 end diff --git a/app/models/balance/sync_cache.rb b/app/models/balance/sync_cache.rb index be2eaa19..aed2b64e 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_reconciliation_valuation(date) - converted_entries.find { |e| e.date == date && e.valuation? && e.valuation.reconciliation? } + def get_valuation(date) + converted_entries.find { |e| e.date == date && e.valuation? } end def get_holdings(date) diff --git a/db/migrate/20250719121103_add_start_end_columns_to_balances.rb b/db/migrate/20250719121103_add_start_end_columns_to_balances.rb new file mode 100644 index 00000000..1c864439 --- /dev/null +++ b/db/migrate/20250719121103_add_start_end_columns_to_balances.rb @@ -0,0 +1,72 @@ +class AddStartEndColumnsToBalances < ActiveRecord::Migration[7.2] + def up + # Add new columns for balance tracking + add_column :balances, :start_cash_balance, :decimal, precision: 19, scale: 4, null: false, default: 0.0 + add_column :balances, :start_non_cash_balance, :decimal, precision: 19, scale: 4, null: false, default: 0.0 + + # Flow tracking columns (absolute values) + add_column :balances, :cash_inflows, :decimal, precision: 19, scale: 4, null: false, default: 0.0 + add_column :balances, :cash_outflows, :decimal, precision: 19, scale: 4, null: false, default: 0.0 + add_column :balances, :non_cash_inflows, :decimal, precision: 19, scale: 4, null: false, default: 0.0 + add_column :balances, :non_cash_outflows, :decimal, precision: 19, scale: 4, null: false, default: 0.0 + + # Market value changes + add_column :balances, :net_market_flows, :decimal, precision: 19, scale: 4, null: false, default: 0.0 + + # Manual adjustments from valuations + add_column :balances, :cash_adjustments, :decimal, precision: 19, scale: 4, null: false, default: 0.0 + add_column :balances, :non_cash_adjustments, :decimal, precision: 19, scale: 4, null: false, default: 0.0 + + # Flows factor determines *how* the flows affect the balance. + # Inflows increase asset accounts, while inflows decrease liability accounts (reducing debt via "payment") + add_column :balances, :flows_factor, :integer, null: false, default: 1 + + # Add generated columns + change_table :balances do |t| + t.virtual :start_balance, type: :decimal, precision: 19, scale: 4, stored: true, + as: "start_cash_balance + start_non_cash_balance" + + t.virtual :end_cash_balance, type: :decimal, precision: 19, scale: 4, stored: true, + as: "start_cash_balance + ((cash_inflows - cash_outflows) * flows_factor) + cash_adjustments" + + t.virtual :end_non_cash_balance, type: :decimal, precision: 19, scale: 4, stored: true, + as: "start_non_cash_balance + ((non_cash_inflows - non_cash_outflows) * flows_factor) + net_market_flows + non_cash_adjustments" + + # Postgres doesn't support generated columns depending on other generated columns, + # but we want the integrity of the data to happen at the DB level, so this is the full formula. + # Formula: (cash components) + (non-cash components) + t.virtual :end_balance, type: :decimal, precision: 19, scale: 4, stored: true, + as: <<~SQL.squish + ( + start_cash_balance + + ((cash_inflows - cash_outflows) * flows_factor) + + cash_adjustments + ) + ( + start_non_cash_balance + + ((non_cash_inflows - non_cash_outflows) * flows_factor) + + net_market_flows + + non_cash_adjustments + ) + SQL + end + end + + def down + # Remove generated columns first (PostgreSQL requirement) + remove_column :balances, :start_balance + remove_column :balances, :end_cash_balance + remove_column :balances, :end_non_cash_balance + remove_column :balances, :end_balance + + # Remove new columns + remove_column :balances, :start_cash_balance + remove_column :balances, :start_non_cash_balance + remove_column :balances, :cash_inflows + remove_column :balances, :cash_outflows + remove_column :balances, :non_cash_inflows + remove_column :balances, :non_cash_outflows + remove_column :balances, :net_market_flows + remove_column :balances, :cash_adjustments + remove_column :balances, :non_cash_adjustments + end +end diff --git a/db/schema.rb b/db/schema.rb index 3b839c95..39f0eeb0 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_18_120146) do +ActiveRecord::Schema[7.2].define(version: 2025_07_19_121103) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -115,6 +115,20 @@ ActiveRecord::Schema[7.2].define(version: 2025_07_18_120146) do t.datetime "created_at", null: false t.datetime "updated_at", null: false t.decimal "cash_balance", precision: 19, scale: 4, default: "0.0" + t.decimal "start_cash_balance", precision: 19, scale: 4, default: "0.0", null: false + t.decimal "start_non_cash_balance", precision: 19, scale: 4, default: "0.0", null: false + t.decimal "cash_inflows", precision: 19, scale: 4, default: "0.0", null: false + t.decimal "cash_outflows", precision: 19, scale: 4, default: "0.0", null: false + t.decimal "non_cash_inflows", precision: 19, scale: 4, default: "0.0", null: false + t.decimal "non_cash_outflows", precision: 19, scale: 4, default: "0.0", null: false + t.decimal "net_market_flows", precision: 19, scale: 4, default: "0.0", null: false + t.decimal "cash_adjustments", precision: 19, scale: 4, default: "0.0", null: false + t.decimal "non_cash_adjustments", precision: 19, scale: 4, default: "0.0", null: false + t.integer "flows_factor", default: 1, null: false + t.virtual "start_balance", type: :decimal, precision: 19, scale: 4, as: "(start_cash_balance + start_non_cash_balance)", stored: true + t.virtual "end_cash_balance", type: :decimal, precision: 19, scale: 4, as: "((start_cash_balance + ((cash_inflows - cash_outflows) * (flows_factor)::numeric)) + cash_adjustments)", stored: true + t.virtual "end_non_cash_balance", type: :decimal, precision: 19, scale: 4, as: "(((start_non_cash_balance + ((non_cash_inflows - non_cash_outflows) * (flows_factor)::numeric)) + net_market_flows) + non_cash_adjustments)", stored: true + t.virtual "end_balance", type: :decimal, precision: 19, scale: 4, as: "(((start_cash_balance + ((cash_inflows - cash_outflows) * (flows_factor)::numeric)) + cash_adjustments) + (((start_non_cash_balance + ((non_cash_inflows - non_cash_outflows) * (flows_factor)::numeric)) + net_market_flows) + non_cash_adjustments))", stored: true t.index ["account_id", "date", "currency"], name: "index_account_balances_on_account_id_date_currency_unique", unique: true t.index ["account_id", "date"], name: "index_balances_on_account_id_and_date", order: { date: :desc } t.index ["account_id"], name: "index_balances_on_account_id" diff --git a/lib/tasks/data_migration.rake b/lib/tasks/data_migration.rake index 6d3a14fd..febdcb3b 100644 --- a/lib/tasks/data_migration.rake +++ b/lib/tasks/data_migration.rake @@ -154,4 +154,19 @@ namespace :data_migration do puts " Processed: #{accounts_processed} accounts" puts " Opening anchors set: #{opening_anchors_set}" end + + desc "Migrate balance components" + # 2025-07-20: Migrate balance components to support event-sourced ledger model. + # This task: + # 1. Sets the flows_factor for each account based on the account's classification + # 2. Sets the start_cash_balance, start_non_cash_balance, and start_balance for each balance + # 3. Sets the cash_inflows, cash_outflows, non_cash_inflows, non_cash_outflows, net_market_flows, cash_adjustments, and non_cash_adjustments for each balance + # 4. Sets the end_cash_balance, end_non_cash_balance, and end_balance for each balance + task migrate_balance_components: :environment do + puts "==> Migrating balance components..." + + BalanceComponentMigrator.run + + puts "✅ Balance component migration complete." + end end diff --git a/test/data_migrations/balance_component_migrator_test.rb b/test/data_migrations/balance_component_migrator_test.rb new file mode 100644 index 00000000..add8384c --- /dev/null +++ b/test/data_migrations/balance_component_migrator_test.rb @@ -0,0 +1,160 @@ +require "test_helper" + +class BalanceComponentMigratorTest < ActiveSupport::TestCase + include EntriesTestHelper + + setup do + @depository = accounts(:depository) + @investment = accounts(:investment) + @loan = accounts(:loan) + + # Start fresh + Balance.delete_all + end + + test "depository account with no gaps" do + create_balance_history(@depository, [ + { date: 5.days.ago, cash_balance: 1000, balance: 1000 }, + { date: 4.days.ago, cash_balance: 1100, balance: 1100 }, + { date: 3.days.ago, cash_balance: 1050, balance: 1050 }, + { date: 2.days.ago, cash_balance: 1200, balance: 1200 }, + { date: 1.day.ago, cash_balance: 1150, balance: 1150 } + ]) + + BalanceComponentMigrator.run + + assert_migrated_balances @depository, [ + { date: 5.days.ago, start_cash: 0, start_non_cash: 0, start: 0, cash_inflows: 1000, non_cash_inflows: 0, end_cash: 1000, end_non_cash: 0, end: 1000 }, + { date: 4.days.ago, start_cash: 1000, start_non_cash: 0, start: 1000, cash_inflows: 100, non_cash_inflows: 0, end_cash: 1100, end_non_cash: 0, end: 1100 }, + { date: 3.days.ago, start_cash: 1100, start_non_cash: 0, start: 1100, cash_inflows: -50, non_cash_inflows: 0, end_cash: 1050, end_non_cash: 0, end: 1050 }, + { date: 2.days.ago, start_cash: 1050, start_non_cash: 0, start: 1050, cash_inflows: 150, non_cash_inflows: 0, end_cash: 1200, end_non_cash: 0, end: 1200 }, + { date: 1.day.ago, start_cash: 1200, start_non_cash: 0, start: 1200, cash_inflows: -50, non_cash_inflows: 0, end_cash: 1150, end_non_cash: 0, end: 1150 } + ] + end + + test "depository account with gaps" do + create_balance_history(@depository, [ + { date: 5.days.ago, cash_balance: 1000, balance: 1000 }, + { date: 1.day.ago, cash_balance: 1150, balance: 1150 } + ]) + + BalanceComponentMigrator.run + + assert_migrated_balances @depository, [ + { date: 5.days.ago, start_cash: 0, start_non_cash: 0, start: 0, cash_inflows: 1000, non_cash_inflows: 0, end_cash: 1000, end_non_cash: 0, end: 1000 }, + { date: 1.day.ago, start_cash: 1000, start_non_cash: 0, start: 1000, cash_inflows: 150, non_cash_inflows: 0, end_cash: 1150, end_non_cash: 0, end: 1150 } + ] + end + + test "investment account with no gaps" do + create_balance_history(@investment, [ + { date: 3.days.ago, cash_balance: 100, balance: 200 }, + { date: 2.days.ago, cash_balance: 200, balance: 300 }, + { date: 1.day.ago, cash_balance: 0, balance: 300 } + ]) + + BalanceComponentMigrator.run + + assert_migrated_balances @investment, [ + { date: 3.days.ago, start_cash: 0, start_non_cash: 0, start: 0, cash_inflows: 100, non_cash_inflows: 100, end_cash: 100, end_non_cash: 100, end: 200 }, + { date: 2.days.ago, start_cash: 100, start_non_cash: 100, start: 200, cash_inflows: 100, non_cash_inflows: 0, end_cash: 200, end_non_cash: 100, end: 300 }, + { date: 1.day.ago, start_cash: 200, start_non_cash: 100, start: 300, cash_inflows: -200, non_cash_inflows: 200, end_cash: 0, end_non_cash: 300, end: 300 } + ] + end + + test "investment account with gaps" do + create_balance_history(@investment, [ + { date: 5.days.ago, cash_balance: 1000, balance: 1000 }, + { date: 1.day.ago, cash_balance: 1150, balance: 1150 } + ]) + + BalanceComponentMigrator.run + + assert_migrated_balances @investment, [ + { date: 5.days.ago, start_cash: 0, start_non_cash: 0, start: 0, cash_inflows: 1000, non_cash_inflows: 0, end_cash: 1000, end_non_cash: 0, end: 1000 }, + { date: 1.day.ago, start_cash: 1000, start_non_cash: 0, start: 1000, cash_inflows: 150, non_cash_inflows: 0, end_cash: 1150, end_non_cash: 0, end: 1150 } + ] + end + + # Negative flows factor test + test "loan account with no gaps" do + create_balance_history(@loan, [ + { date: 3.days.ago, cash_balance: 0, balance: 200 }, + { date: 2.days.ago, cash_balance: 0, balance: 300 }, + { date: 1.day.ago, cash_balance: 0, balance: 500 } + ]) + + BalanceComponentMigrator.run + + assert_migrated_balances @loan, [ + { date: 3.days.ago, start_cash: 0, start_non_cash: 0, start: 0, cash_inflows: 0, non_cash_inflows: -200, end_cash: 0, end_non_cash: 200, end: 200 }, + { date: 2.days.ago, start_cash: 0, start_non_cash: 200, start: 200, cash_inflows: 0, non_cash_inflows: -100, end_cash: 0, end_non_cash: 300, end: 300 }, + { date: 1.day.ago, start_cash: 0, start_non_cash: 300, start: 300, cash_inflows: 0, non_cash_inflows: -200, end_cash: 0, end_non_cash: 500, end: 500 } + ] + end + + test "loan account with gaps" do + create_balance_history(@loan, [ + { date: 5.days.ago, cash_balance: 0, balance: 1000 }, + { date: 1.day.ago, cash_balance: 0, balance: 2000 } + ]) + + BalanceComponentMigrator.run + + assert_migrated_balances @loan, [ + { date: 5.days.ago, start_cash: 0, start_non_cash: 0, start: 0, cash_inflows: 0, non_cash_inflows: -1000, end_cash: 0, end_non_cash: 1000, end: 1000 }, + { date: 1.day.ago, start_cash: 0, start_non_cash: 1000, start: 1000, cash_inflows: 0, non_cash_inflows: -1000, end_cash: 0, end_non_cash: 2000, end: 2000 } + ] + end + + private + def create_balance_history(account, balances) + balances.each do |balance| + account.balances.create!( + date: balance[:date].to_date, + balance: balance[:balance], + cash_balance: balance[:cash_balance], + currency: account.currency + ) + end + end + + def assert_migrated_balances(account, expected) + balances = account.balances.order(:date) + + expected.each_with_index do |expected_values, index| + balance = balances.find { |b| b.date == expected_values[:date].to_date } + assert balance, "Expected balance for #{expected_values[:date].to_date} but none found" + + # Assert expected values + assert_equal expected_values[:start_cash], balance.start_cash_balance, + "start_cash_balance mismatch for #{balance.date}" + assert_equal expected_values[:start_non_cash], balance.start_non_cash_balance, + "start_non_cash_balance mismatch for #{balance.date}" + assert_equal expected_values[:start], balance.start_balance, + "start_balance mismatch for #{balance.date}" + assert_equal expected_values[:cash_inflows], balance.cash_inflows, + "cash_inflows mismatch for #{balance.date}" + assert_equal expected_values[:non_cash_inflows], balance.non_cash_inflows, + "non_cash_inflows mismatch for #{balance.date}" + assert_equal expected_values[:end_cash], balance.end_cash_balance, + "end_cash_balance mismatch for #{balance.date}" + assert_equal expected_values[:end_non_cash], balance.end_non_cash_balance, + "end_non_cash_balance mismatch for #{balance.date}" + assert_equal expected_values[:end], balance.end_balance, + "end_balance mismatch for #{balance.date}" + + # Assert zeros for other fields + assert_equal 0, balance.cash_outflows, + "cash_outflows should be zero for #{balance.date}" + assert_equal 0, balance.non_cash_outflows, + "non_cash_outflows should be zero for #{balance.date}" + assert_equal 0, balance.cash_adjustments, + "cash_adjustments should be zero for #{balance.date}" + assert_equal 0, balance.non_cash_adjustments, + "non_cash_adjustments should be zero for #{balance.date}" + assert_equal 0, balance.net_market_flows, + "net_market_flows should be zero for #{balance.date}" + end + end +end diff --git a/test/models/balance/forward_calculator_test.rb b/test/models/balance/forward_calculator_test.rb index b6eb2d11..2a65ae19 100644 --- a/test/models/balance/forward_calculator_test.rb +++ b/test/models/balance/forward_calculator_test.rb @@ -11,7 +11,7 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase # 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 account = create_account_with_ledger( - account: { type: Depository, balance: 20000, cash_balance: 20000, currency: "USD" }, + account: { type: Depository, currency: "USD" }, entries: [] ) @@ -21,8 +21,14 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase assert_calculated_ledger_balances( calculated_data: calculated, - expected_balances: [ - [ Date.current, { balance: 0, cash_balance: 0 } ] + expected_data: [ + { + date: Date.current, + legacy_balances: { balance: 0, cash_balance: 0 }, + balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 0, end_non_cash: 0, end: 0 }, + flows: 0, + adjustments: 0 + } ] ) end @@ -30,7 +36,7 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase # 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" }, + account: { type: Depository, currency: "USD" }, entries: [ { type: "transaction", date: 2.days.ago.to_date, amount: -1000 } ] @@ -41,16 +47,28 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase # 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 } ] + expected_data: [ + { + date: 3.days.ago.to_date, + legacy_balances: { balance: 0, cash_balance: 0 }, + balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 0, end_non_cash: 0, end: 0 }, + flows: 0, + adjustments: 0 + }, + { + date: 2.days.ago.to_date, + legacy_balances: { balance: 1000, cash_balance: 1000 }, + balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 1000, end_non_cash: 0, end: 1000 }, + flows: { cash_inflows: 1000, cash_outflows: 0 }, + adjustments: 0 + } ] ) end 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" }, + account: { type: Depository, currency: "USD" }, entries: [ { type: "reconciliation", date: 3.days.ago.to_date, balance: 18000 }, { type: "transaction", date: 2.days.ago.to_date, amount: -1000 } @@ -62,9 +80,21 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase # 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 } ] + expected_data: [ + { + date: 3.days.ago.to_date, + legacy_balances: { balance: 18000, cash_balance: 18000 }, + balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 18000, end_non_cash: 0, end: 18000 }, + flows: 0, + adjustments: { cash_adjustments: 18000, non_cash_adjustments: 0 } + }, + { + date: 2.days.ago.to_date, + legacy_balances: { balance: 19000, cash_balance: 19000 }, + balances: { start: 18000, start_cash: 18000, start_non_cash: 0, end_cash: 19000, end_non_cash: 0, end: 19000 }, + flows: { cash_inflows: 1000, cash_outflows: 0 }, + adjustments: 0 + } ] ) end @@ -72,7 +102,7 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase 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" }, + account: { type: account_type, currency: "USD" }, entries: [ { type: "opening_anchor", date: 3.days.ago.to_date, balance: 17000 }, { type: "reconciliation", date: 2.days.ago.to_date, balance: 18000 } @@ -83,9 +113,21 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase 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 } ] + expected_data: [ + { + date: 3.days.ago.to_date, + legacy_balances: { balance: 17000, cash_balance: 17000 }, + balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 17000, end_non_cash: 0, end: 17000 }, + flows: 0, + adjustments: { cash_adjustments: 17000, non_cash_adjustments: 0 } + }, + { + date: 2.days.ago.to_date, + legacy_balances: { balance: 18000, cash_balance: 18000 }, + balances: { start: 17000, start_cash: 17000, start_non_cash: 0, end_cash: 18000, end_non_cash: 0, end: 18000 }, + flows: 0, + adjustments: { cash_adjustments: 1000, non_cash_adjustments: 0 } + } ] ) end @@ -94,7 +136,7 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase 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" }, + account: { type: account_type, currency: "USD" }, entries: [ { type: "opening_anchor", date: 3.days.ago.to_date, balance: 17000 }, { type: "reconciliation", date: 2.days.ago.to_date, balance: 18000 } @@ -105,9 +147,21 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase 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 } ] + expected_data: [ + { + date: 3.days.ago.to_date, + legacy_balances: { balance: 17000, cash_balance: 0.0 }, + balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 0, end_non_cash: 17000, end: 17000 }, + flows: 0, + adjustments: { cash_adjustments: 0, non_cash_adjustments: 17000 } + }, + { + date: 2.days.ago.to_date, + legacy_balances: { balance: 18000, cash_balance: 0.0 }, + balances: { start: 17000, start_cash: 0, start_non_cash: 17000, end_cash: 0, end_non_cash: 18000, end: 18000 }, + flows: 0, + adjustments: { cash_adjustments: 0, non_cash_adjustments: 1000 } + } ] ) end @@ -115,7 +169,7 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase 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" }, + account: { type: Investment, currency: "USD" }, entries: [ { type: "opening_anchor", date: 3.days.ago.to_date, balance: 17000 }, { type: "reconciliation", date: 2.days.ago.to_date, balance: 18000 } @@ -127,9 +181,21 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase 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 } ] + expected_data: [ + { + date: 3.days.ago.to_date, + legacy_balances: { balance: 17000, cash_balance: 17000 }, + balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 17000, end_non_cash: 0, end: 17000 }, + flows: { market_flows: 0 }, + adjustments: { cash_adjustments: 17000, non_cash_adjustments: 0 } # Since no holdings present, adjustment is all cash + }, + { + date: 2.days.ago.to_date, + legacy_balances: { balance: 18000, cash_balance: 18000 }, + balances: { start: 17000, start_cash: 17000, start_non_cash: 0, end_cash: 18000, end_non_cash: 0, end: 18000 }, + flows: { market_flows: 0 }, + adjustments: { cash_adjustments: 1000, non_cash_adjustments: 0 } # Since no holdings present, adjustment is all cash + } ] ) end @@ -140,7 +206,7 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase test "transactions on depository accounts affect cash balance" do account = create_account_with_ledger( - account: { type: Depository, balance: 20000, cash_balance: 20000, currency: "USD" }, + account: { type: Depository, 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 @@ -152,11 +218,35 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase 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 } ] + expected_data: [ + { + date: 5.days.ago.to_date, + legacy_balances: { balance: 20000, cash_balance: 20000 }, + balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 }, + flows: 0, + adjustments: { cash_adjustments: 20000, non_cash_adjustments: 0 } + }, + { + date: 4.days.ago.to_date, + legacy_balances: { balance: 20500, cash_balance: 20500 }, + balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 20500, end_non_cash: 0, end: 20500 }, + flows: { cash_inflows: 500, cash_outflows: 0 }, + adjustments: 0 + }, + { + date: 3.days.ago.to_date, + legacy_balances: { balance: 20500, cash_balance: 20500 }, + balances: { start: 20500, start_cash: 20500, start_non_cash: 0, end_cash: 20500, end_non_cash: 0, end: 20500 }, + flows: 0, + adjustments: 0 + }, + { + date: 2.days.ago.to_date, + legacy_balances: { balance: 20400, cash_balance: 20400 }, + balances: { start: 20500, start_cash: 20500, start_non_cash: 0, end_cash: 20400, end_non_cash: 0, end: 20400 }, + flows: { cash_inflows: 0, cash_outflows: 100 }, + adjustments: 0 + } ] ) end @@ -164,7 +254,7 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase 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" }, + account: { type: CreditCard, 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 @@ -176,26 +266,47 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase 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 } ] + expected_data: [ + { + date: 5.days.ago.to_date, + legacy_balances: { balance: 1000, cash_balance: 1000 }, + balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 1000, end_non_cash: 0, end: 1000 }, + flows: 0, + adjustments: { cash_adjustments: 1000, non_cash_adjustments: 0 } + }, + { + date: 4.days.ago.to_date, + legacy_balances: { balance: 500, cash_balance: 500 }, + balances: { start: 1000, start_cash: 1000, start_non_cash: 0, end_cash: 500, end_non_cash: 0, end: 500 }, + flows: { cash_inflows: 500, cash_outflows: 0 }, + adjustments: 0 + }, + { + date: 3.days.ago.to_date, + legacy_balances: { balance: 500, cash_balance: 500 }, + balances: { start: 500, start_cash: 500, start_non_cash: 0, end_cash: 500, end_non_cash: 0, end: 500 }, + flows: 0, + adjustments: 0 + }, + { + date: 2.days.ago.to_date, + legacy_balances: { balance: 600, cash_balance: 600 }, + balances: { start: 500, start_cash: 500, start_non_cash: 0, end_cash: 600, end_non_cash: 0, end: 600 }, + flows: { cash_inflows: 0, cash_outflows: 100 }, + adjustments: 0 + } ] ) 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" }, + account: { type: Depository, 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 } + { type: "opening_anchor", date: 4.days.ago.to_date, balance: 20000 }, + { type: "transaction", date: 3.days.ago.to_date, amount: -5000 }, + { type: "reconciliation", date: 2.days.ago.to_date, balance: 17000 }, + { type: "transaction", date: 1.day.ago.to_date, amount: -500 } ] ) @@ -203,24 +314,42 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase 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 } ] + expected_data: [ + { + date: 4.days.ago.to_date, + legacy_balances: { balance: 20000, cash_balance: 20000 }, + balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 }, + flows: 0, + adjustments: { cash_adjustments: 20000, non_cash_adjustments: 0 } + }, + { + date: 3.days.ago.to_date, + legacy_balances: { balance: 25000, cash_balance: 25000 }, + balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 25000, end_non_cash: 0, end: 25000 }, + flows: { cash_inflows: 5000, cash_outflows: 0 }, + adjustments: 0 + }, + { + date: 2.days.ago.to_date, + legacy_balances: { balance: 17000, cash_balance: 17000 }, + balances: { start: 25000, start_cash: 25000, start_non_cash: 0, end_cash: 17000, end_non_cash: 0, end: 17000 }, + flows: 0, + adjustments: { cash_adjustments: -8000, non_cash_adjustments: 0 } + }, + { + date: 1.day.ago.to_date, + legacy_balances: { balance: 17500, cash_balance: 17500 }, + balances: { start: 17000, start_cash: 17000, start_non_cash: 0, end_cash: 17500, end_non_cash: 0, end: 17500 }, + flows: { cash_inflows: 500, cash_outflows: 0 }, + adjustments: 0 + } ] ) end - test "accounts with transactions in multiple currencies convert to the account currency" do + test "accounts with transactions in multiple currencies convert to the account currency and flows are stored in account currency" do account = create_account_with_ledger( - account: { type: Depository, balance: 20000, cash_balance: 20000, currency: "USD" }, + account: { type: Depository, currency: "USD" }, entries: [ { type: "opening_anchor", date: 4.days.ago.to_date, balance: 100 }, { type: "transaction", date: 3.days.ago.to_date, amount: -100 }, @@ -237,11 +366,35 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase 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 } ] + expected_data: [ + { + date: 4.days.ago.to_date, + legacy_balances: { balance: 100, cash_balance: 100 }, + balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 100, end_non_cash: 0, end: 100 }, + flows: 0, + adjustments: { cash_adjustments: 100, non_cash_adjustments: 0 } + }, + { + date: 3.days.ago.to_date, + legacy_balances: { balance: 200, cash_balance: 200 }, + balances: { start: 100, start_cash: 100, start_non_cash: 0, end_cash: 200, end_non_cash: 0, end: 200 }, + flows: { cash_inflows: 100, cash_outflows: 0 }, + adjustments: 0 + }, + { + date: 2.days.ago.to_date, + legacy_balances: { balance: 500, cash_balance: 500 }, + balances: { start: 200, start_cash: 200, start_non_cash: 0, end_cash: 500, end_non_cash: 0, end: 500 }, + flows: { cash_inflows: 300, cash_outflows: 0 }, + adjustments: 0 + }, + { + date: 1.day.ago.to_date, + legacy_balances: { balance: 1100, cash_balance: 1100 }, + balances: { start: 500, start_cash: 500, start_non_cash: 0, end_cash: 1100, end_non_cash: 0, end: 1100 }, + flows: { cash_inflows: 600, cash_outflows: 0 }, # Cash inflow is the USD equivalent of €500 (converted for balances table) + adjustments: 0 + } ] ) end @@ -249,7 +402,7 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase # 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" }, + account: { type: Loan, currency: "USD" }, entries: [ { type: "opening_anchor", date: 2.days.ago.to_date, balance: 20000 }, # "Loan payment" of $2000, which reduces the principal @@ -263,9 +416,21 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase 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 } ] + expected_data: [ + { + date: 2.days.ago.to_date, + legacy_balances: { balance: 20000, cash_balance: 0 }, + balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 0, end_non_cash: 20000, end: 20000 }, + flows: 0, + adjustments: { cash_adjustments: 0, non_cash_adjustments: 20000 } # Valuations adjust non-cash balance for non-cash accounts like Loans + }, + { + date: 1.day.ago.to_date, + legacy_balances: { balance: 18000, cash_balance: 0 }, + balances: { start: 20000, start_cash: 0, start_non_cash: 20000, end_cash: 0, end_non_cash: 18000, end: 18000 }, + flows: { non_cash_inflows: 2000, non_cash_outflows: 0, cash_inflows: 0, cash_outflows: 0 }, # Loans are "special cases" where transactions do affect non-cash balance + adjustments: 0 + } ] ) end @@ -273,7 +438,7 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase 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" }, + account: { type: account_type, currency: "USD" }, entries: [ { type: "opening_anchor", date: 3.days.ago.to_date, balance: 500000 }, @@ -286,9 +451,21 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase 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 } ] + expected_data: [ + { + date: 3.days.ago.to_date, + legacy_balances: { balance: 500000, cash_balance: 0 }, + balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 0, end_non_cash: 500000, end: 500000 }, + flows: 0, + adjustments: { cash_adjustments: 0, non_cash_adjustments: 500000 } + }, + { + date: 2.days.ago.to_date, + legacy_balances: { balance: 500000, cash_balance: 0 }, + balances: { start: 500000, start_cash: 0, start_non_cash: 500000, end_cash: 0, end_non_cash: 500000, end: 500000 }, + flows: 0, # Despite having a transaction, non-cash accounts ignore it for balance calculation + adjustments: 0 + } ] ) end @@ -304,7 +481,7 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase # 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" }, + account: { type: Investment, currency: "USD" }, entries: [ # Account starts with brokerage cash of $5000 and no holdings { type: "opening_anchor", date: 3.days.ago.to_date, balance: 5000 }, @@ -314,7 +491,7 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase 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 } + { date: Date.current, ticker: "AAPL", qty: 10, price: 110, amount: 1100 } # Price increased by 10%, so holdings value goes up by $100 without a trade ] ) @@ -324,17 +501,40 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase 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 } ] + expected_data: [ + { + date: 3.days.ago.to_date, + legacy_balances: { balance: 5000, cash_balance: 5000 }, + balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 5000, end_non_cash: 0, end: 5000 }, + flows: 0, + adjustments: { cash_adjustments: 5000, non_cash_adjustments: 0 } + }, + { + date: 2.days.ago.to_date, + legacy_balances: { balance: 5000, cash_balance: 5000 }, + balances: { start: 5000, start_cash: 5000, start_non_cash: 0, end_cash: 5000, end_non_cash: 0, end: 5000 }, + flows: 0, + adjustments: 0 + }, + { + date: 1.day.ago.to_date, + legacy_balances: { balance: 5000, cash_balance: 4000 }, + balances: { start: 5000, start_cash: 5000, start_non_cash: 0, end_cash: 4000, end_non_cash: 1000, end: 5000 }, + flows: { cash_inflows: 0, cash_outflows: 1000, non_cash_inflows: 1000, non_cash_outflows: 0, net_market_flows: 0 }, # Decrease cash by 1000, increase holdings by 1000 (i.e. "buy" of $1000 worth of AAPL) + adjustments: 0 + }, + { + date: Date.current, + legacy_balances: { balance: 5100, cash_balance: 4000 }, + balances: { start: 5000, start_cash: 4000, start_non_cash: 1000, end_cash: 4000, end_non_cash: 1100, end: 5100 }, + flows: { net_market_flows: 100 }, # Holdings value increased by 100, despite no change in portfolio quantities + adjustments: 0 + } ] ) end private - def assert_balances(calculated_data:, expected_balances:) # Sort calculated data by date to ensure consistent ordering sorted_data = calculated_data.sort_by(&:date) diff --git a/test/models/balance/reverse_calculator_test.rb b/test/models/balance/reverse_calculator_test.rb index a9348220..c3ba12ba 100644 --- a/test/models/balance/reverse_calculator_test.rb +++ b/test/models/balance/reverse_calculator_test.rb @@ -16,8 +16,14 @@ class Balance::ReverseCalculatorTest < ActiveSupport::TestCase assert_calculated_ledger_balances( calculated_data: calculated, - expected_balances: [ - [ Date.current, { balance: 20000, cash_balance: 20000 } ] + expected_data: [ + { + date: Date.current, + legacy_balances: { balance: 20000, cash_balance: 20000 }, + balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 }, + flows: 0, + adjustments: { cash_adjustments: 0, non_cash_adjustments: 0 } + } ] ) end @@ -47,12 +53,42 @@ class Balance::ReverseCalculatorTest < ActiveSupport::TestCase # 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 + expected_data: [ + { + date: Date.current, + legacy_balances: { balance: 20000, cash_balance: 20000 }, + balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 }, + flows: 0, + adjustments: 0 + }, # Current anchor + { + date: 1.day.ago, + legacy_balances: { balance: 20000, cash_balance: 20000 }, + balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 }, + flows: 0, + adjustments: 0 + }, + { + date: 2.days.ago, + legacy_balances: { balance: 20000, cash_balance: 20000 }, + balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 }, + flows: 0, + adjustments: 0 + }, + { + date: 3.days.ago, + legacy_balances: { balance: 20000, cash_balance: 20000 }, + balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 }, + flows: 0, + adjustments: { cash_adjustments: 0, non_cash_adjustments: 0 } + }, + { + date: 4.days.ago, + legacy_balances: { balance: 15000, cash_balance: 15000 }, + balances: { start: 15000, start_cash: 15000, start_non_cash: 0, end_cash: 15000, end_non_cash: 0, end: 15000 }, + flows: 0, + adjustments: 0 + } # Opening anchor ] ) end @@ -75,9 +111,21 @@ class Balance::ReverseCalculatorTest < ActiveSupport::TestCase 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 + expected_data: [ + { + date: Date.current, + legacy_balances: { balance: 20000, cash_balance: 10000 }, + balances: { start: 20000, start_cash: 10000, start_non_cash: 10000, end_cash: 10000, end_non_cash: 10000, end: 20000 }, + flows: { market_flows: 0 }, + adjustments: 0 + }, # Since $10,000 of holdings, cash has to be $10,000 to reach $20,000 total value + { + date: 1.day.ago, + legacy_balances: { balance: 15000, cash_balance: 5000 }, + balances: { start: 15000, start_cash: 5000, start_non_cash: 10000, end_cash: 5000, end_non_cash: 10000, end: 15000 }, + flows: { market_flows: 0 }, + adjustments: 0 + } # Since $10,000 of holdings, cash has to be $5,000 to reach $15,000 total value ] ) end @@ -87,8 +135,8 @@ class Balance::ReverseCalculatorTest < ActiveSupport::TestCase 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 + { type: "transaction", date: 2.days.ago, amount: 100 }, # expense + { type: "transaction", date: 4.days.ago, amount: -500 } # income ] ) @@ -96,13 +144,49 @@ class Balance::ReverseCalculatorTest < ActiveSupport::TestCase 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) + expected_data: [ + { + date: Date.current, + legacy_balances: { balance: 20000, cash_balance: 20000 }, + balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 }, + flows: 0, + adjustments: 0 + }, # Current balance + { + date: 1.day.ago, + legacy_balances: { balance: 20000, cash_balance: 20000 }, + balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 }, + flows: 0, + adjustments: 0 + }, # No change + { + date: 2.days.ago, + legacy_balances: { balance: 20000, cash_balance: 20000 }, + balances: { start: 20100, start_cash: 20100, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 }, + flows: { cash_inflows: 0, cash_outflows: 100 }, + adjustments: 0 + }, # After expense (+100) + { + date: 3.days.ago, + legacy_balances: { balance: 20100, cash_balance: 20100 }, + balances: { start: 20100, start_cash: 20100, start_non_cash: 0, end_cash: 20100, end_non_cash: 0, end: 20100 }, + flows: 0, + adjustments: 0 + }, # Before expense + { + date: 4.days.ago, + legacy_balances: { balance: 20100, cash_balance: 20100 }, + balances: { start: 19600, start_cash: 19600, start_non_cash: 0, end_cash: 20100, end_non_cash: 0, end: 20100 }, + flows: { cash_inflows: 500, cash_outflows: 0 }, + adjustments: 0 + }, # After income (-500) + { + date: 5.days.ago, + legacy_balances: { balance: 19600, cash_balance: 19600 }, + balances: { start: 19600, start_cash: 19600, start_non_cash: 0, end_cash: 19600, end_non_cash: 0, end: 19600 }, + flows: 0, + adjustments: { cash_adjustments: 0, non_cash_adjustments: 0 } + } # After income (-500) ] ) end @@ -122,13 +206,49 @@ class Balance::ReverseCalculatorTest < ActiveSupport::TestCase # 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 } ] + expected_data: [ + { + date: Date.current, + legacy_balances: { balance: 2000, cash_balance: 2000 }, + balances: { start: 2000, start_cash: 2000, start_non_cash: 0, end_cash: 2000, end_non_cash: 0, end: 2000 }, + flows: 0, + adjustments: 0 + }, # Current balance + { + date: 1.day.ago, + legacy_balances: { balance: 2000, cash_balance: 2000 }, + balances: { start: 2000, start_cash: 2000, start_non_cash: 0, end_cash: 2000, end_non_cash: 0, end: 2000 }, + flows: 0, + adjustments: 0 + }, # No change + { + date: 2.days.ago, + legacy_balances: { balance: 2000, cash_balance: 2000 }, + balances: { start: 1900, start_cash: 1900, start_non_cash: 0, end_cash: 2000, end_non_cash: 0, end: 2000 }, + flows: { cash_inflows: 0, cash_outflows: 100 }, + adjustments: 0 + }, # After expense (+100) + { + date: 3.days.ago, + legacy_balances: { balance: 1900, cash_balance: 1900 }, + balances: { start: 1900, start_cash: 1900, start_non_cash: 0, end_cash: 1900, end_non_cash: 0, end: 1900 }, + flows: 0, + adjustments: 0 + }, # Before expense + { + date: 4.days.ago, + legacy_balances: { balance: 1900, cash_balance: 1900 }, + balances: { start: 2400, start_cash: 2400, start_non_cash: 0, end_cash: 1900, end_non_cash: 0, end: 1900 }, + flows: { cash_inflows: 500, cash_outflows: 0 }, + adjustments: 0 + }, # After CC payment (-500) + { + date: 5.days.ago, + legacy_balances: { balance: 2400, cash_balance: 2400 }, + balances: { start: 2400, start_cash: 2400, start_non_cash: 0, end_cash: 2400, end_non_cash: 0, end: 2400 }, + flows: 0, + adjustments: { cash_adjustments: 0, non_cash_adjustments: 0 } + } ] ) end @@ -150,10 +270,28 @@ class Balance::ReverseCalculatorTest < ActiveSupport::TestCase 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 } ] + expected_data: [ + { + date: Date.current, + legacy_balances: { balance: 198000, cash_balance: 0 }, + balances: { start: 198000, start_cash: 0, start_non_cash: 198000, end_cash: 0, end_non_cash: 198000, end: 198000 }, + flows: 0, + adjustments: 0 + }, + { + date: 1.day.ago, + legacy_balances: { balance: 198000, cash_balance: 0 }, + balances: { start: 200000, start_cash: 0, start_non_cash: 200000, end_cash: 0, end_non_cash: 198000, end: 198000 }, + flows: { non_cash_inflows: 2000, non_cash_outflows: 0, cash_inflows: 0, cash_outflows: 0 }, + adjustments: 0 + }, + { + date: 2.days.ago, + legacy_balances: { balance: 200000, cash_balance: 0 }, + balances: { start: 200000, start_cash: 0, start_non_cash: 200000, end_cash: 0, end_non_cash: 200000, end: 200000 }, + flows: 0, + adjustments: { cash_adjustments: 0, non_cash_adjustments: 0 } + } ] ) end @@ -174,10 +312,28 @@ class Balance::ReverseCalculatorTest < ActiveSupport::TestCase 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 } ] + expected_data: [ + { + date: Date.current, + legacy_balances: { balance: 1000, cash_balance: 0 }, + balances: { start: 1000, start_cash: 0, start_non_cash: 1000, end_cash: 0, end_non_cash: 1000, end: 1000 }, + flows: 0, + adjustments: 0 + }, + { + date: 1.day.ago, + legacy_balances: { balance: 1000, cash_balance: 0 }, + balances: { start: 1000, start_cash: 0, start_non_cash: 1000, end_cash: 0, end_non_cash: 1000, end: 1000 }, + flows: 0, + adjustments: 0 + }, + { + date: 2.days.ago, + legacy_balances: { balance: 1000, cash_balance: 0 }, + balances: { start: 1000, start_cash: 0, start_non_cash: 1000, end_cash: 0, end_non_cash: 1000, end: 1000 }, + flows: 0, + adjustments: { cash_adjustments: 0, non_cash_adjustments: 0 } + } ] ) end @@ -206,10 +362,28 @@ class Balance::ReverseCalculatorTest < ActiveSupport::TestCase # (the single trade doesn't affect balance; it just alters cash vs. holdings composition) 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) + expected_data: [ + { + date: Date.current, + legacy_balances: { balance: 20000, cash_balance: 19000 }, + balances: { start: 20000, start_cash: 19000, start_non_cash: 1000, end_cash: 19000, end_non_cash: 1000, end: 20000 }, + flows: { market_flows: 0 }, + adjustments: 0 + }, # Current: $19k cash + $1k holdings (anchor) + { + date: 1.day.ago.to_date, + legacy_balances: { balance: 20000, cash_balance: 19000 }, + balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 19000, end_non_cash: 1000, end: 20000 }, + flows: { cash_inflows: 0, cash_outflows: 1000, non_cash_inflows: 1000, non_cash_outflows: 0, net_market_flows: 0 }, + adjustments: 0 + }, # After trade: $19k cash + $1k holdings + { + date: 2.days.ago.to_date, + legacy_balances: { balance: 20000, cash_balance: 20000 }, + balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 }, + flows: { market_flows: 0 }, + adjustments: { cash_adjustments: 0, non_cash_adjustments: 0 } + } # At first, account is 100% cash, no holdings (no trades) ] ) end @@ -240,10 +414,28 @@ class Balance::ReverseCalculatorTest < ActiveSupport::TestCase 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 + expected_data: [ + { + date: Date.current, + legacy_balances: { balance: 20000, cash_balance: 19000 }, + balances: { start: 20000, start_cash: 19000, start_non_cash: 1000, end_cash: 19000, end_non_cash: 1000, end: 20000 }, + flows: { market_flows: 0 }, + adjustments: 0 + }, # Current: $19k cash + $1k holdings ($500 MSFT, $500 AAPL) + { + date: 1.day.ago.to_date, + legacy_balances: { balance: 20000, cash_balance: 19000 }, + balances: { start: 20000, start_cash: 19500, start_non_cash: 500, end_cash: 19000, end_non_cash: 1000, end: 20000 }, + flows: { cash_inflows: 0, cash_outflows: 500, non_cash_inflows: 500, non_cash_outflows: 0, market_flows: 0 }, + adjustments: 0 + }, # After AAPL trade: $19k cash + $1k holdings + { + date: 2.days.ago.to_date, + legacy_balances: { balance: 20000, cash_balance: 19500 }, + balances: { start: 19500, start_cash: 19500, start_non_cash: 0, end_cash: 19500, end_non_cash: 500, end: 20000 }, + flows: { market_flows: -500 }, + adjustments: 0 + } # Before AAPL trade: $19.5k cash + $500 MSFT ] ) end @@ -258,8 +450,9 @@ class Balance::ReverseCalculatorTest < ActiveSupport::TestCase ], 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 } + { date: Date.current, ticker: "AAPL", qty: 10, price: 100, amount: 1000 }, + { date: 1.day.ago, ticker: "AAPL", qty: 10, price: 100, amount: 1000 }, + { date: 2.days.ago, ticker: "AAPL", qty: 10, price: 100, amount: 1000 } ] ) @@ -267,12 +460,30 @@ class Balance::ReverseCalculatorTest < ActiveSupport::TestCase assert_calculated_ledger_balances( calculated_data: calculated, - expected_balances: [ + expected_data: [ # 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 + { + date: Date.current, + legacy_balances: { balance: 20000, cash_balance: 19000 }, + balances: { start: 20000, start_cash: 19000, start_non_cash: 1000, end_cash: 19000, end_non_cash: 1000, end: 20000 }, + flows: { market_flows: 0 }, + adjustments: 0 + }, + { + date: 1.day.ago, + legacy_balances: { balance: 20000, cash_balance: 19000 }, + balances: { start: 20000, start_cash: 19000, start_non_cash: 1000, end_cash: 19000, end_non_cash: 1000, end: 20000 }, + flows: { market_flows: 0 }, + adjustments: 0 + }, + { + date: 2.days.ago, + legacy_balances: { balance: 15000, cash_balance: 14000 }, + balances: { start: 15000, start_cash: 14000, start_non_cash: 1000, end_cash: 14000, end_non_cash: 1000, end: 15000 }, + flows: { market_flows: 0 }, + adjustments: 0 + } # Opening anchor sets absolute balance ] ) end diff --git a/test/support/ledger_testing_helper.rb b/test/support/ledger_testing_helper.rb index 6ae71678..d5e08aec 100644 --- a/test/support/ledger_testing_helper.rb +++ b/test/support/ledger_testing_helper.rb @@ -12,6 +12,8 @@ module LedgerTestingHelper created_account = families(:empty).accounts.create!( name: "Test Account", accountable: account_type.new, + balance: account[:balance] || 0, # Doesn't matter, ledger derives this + cash_balance: account[:cash_balance] || 0, # Doesn't matter, ledger derives this **account_attrs ) @@ -109,13 +111,20 @@ module LedgerTestingHelper 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 ] + def assert_calculated_ledger_balances(calculated_data:, expected_data:) + # Convert expected data to a hash for easier lookup + # Structure: [ { date:, legacy_balances: { balance:, cash_balance: }, balances: { start:, start_cash:, etc... }, flows: { ... }, adjustments: { ... } } ] + expected_hash = {} + expected_data.each do |data| + expected_hash[data[:date].to_date] = { + legacy_balances: data[:legacy_balances] || {}, + balances: data[:balances] || {}, + flows: data[:flows] || {}, + adjustments: data[:adjustments] || {} + } end - # Get all unique dates from both calculated and expected data + # Get all unique dates from all data sources all_dates = (calculated_data.map(&:date) + expected_hash.keys).uniq.sort # Check each date @@ -126,15 +135,163 @@ module LedgerTestingHelper 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 + # Always assert flows_factor is correct based on account classification + expected_flows_factor = calculated_balance.account.classification == "asset" ? 1 : -1 + assert_equal expected_flows_factor, calculated_balance.flows_factor, + "Flows factor mismatch for #{date}: expected #{expected_flows_factor} for #{calculated_balance.account.classification} account" - if expected[:cash_balance] - assert_equal expected[:cash_balance], calculated_balance.cash_balance.to_d, + legacy_balances = expected[:legacy_balances] + balances = expected[:balances] + flows = expected[:flows] + adjustments = expected[:adjustments] + + # Legacy balance assertions + if legacy_balances.any? + assert_equal legacy_balances[:balance], calculated_balance.balance, + "Balance mismatch for #{date}" + + assert_equal legacy_balances[:cash_balance], calculated_balance.cash_balance, "Cash balance mismatch for #{date}" end + + # Balance assertions + if balances.any? + assert_equal balances[:start_cash], calculated_balance.start_cash_balance, + "Start cash balance mismatch for #{date}" if balances.key?(:start_cash) + + assert_equal balances[:start_non_cash], calculated_balance.start_non_cash_balance, + "Start non-cash balance mismatch for #{date}" if balances.key?(:start_non_cash) + + # Calculate end_cash_balance using the formula from the migration + if balances.key?(:end_cash) + # Determine flows_factor based on account classification + flows_factor = calculated_balance.account.classification == "asset" ? 1 : -1 + expected_end_cash = calculated_balance.start_cash_balance + + ((calculated_balance.cash_inflows - calculated_balance.cash_outflows) * flows_factor) + + calculated_balance.cash_adjustments + assert_equal balances[:end_cash], expected_end_cash, + "End cash balance mismatch for #{date}" + end + + # Calculate end_non_cash_balance using the formula from the migration + if balances.key?(:end_non_cash) + # Determine flows_factor based on account classification + flows_factor = calculated_balance.account.classification == "asset" ? 1 : -1 + expected_end_non_cash = calculated_balance.start_non_cash_balance + + ((calculated_balance.non_cash_inflows - calculated_balance.non_cash_outflows) * flows_factor) + + calculated_balance.net_market_flows + + calculated_balance.non_cash_adjustments + assert_equal balances[:end_non_cash], expected_end_non_cash, + "End non-cash balance mismatch for #{date}" + end + + # Calculate start_balance using the formula from the migration + if balances.key?(:start) + expected_start = calculated_balance.start_cash_balance + calculated_balance.start_non_cash_balance + assert_equal balances[:start], expected_start, + "Start balance mismatch for #{date}" + end + + # Calculate end_balance using the formula from the migration since we're not persisting balances, + # and generated columns are not available until the record is persisted + if balances.key?(:end) + # Determine flows_factor based on account classification + flows_factor = calculated_balance.account.classification == "asset" ? 1 : -1 + expected_end_cash_component = calculated_balance.start_cash_balance + + ((calculated_balance.cash_inflows - calculated_balance.cash_outflows) * flows_factor) + + calculated_balance.cash_adjustments + expected_end_non_cash_component = calculated_balance.start_non_cash_balance + + ((calculated_balance.non_cash_inflows - calculated_balance.non_cash_outflows) * flows_factor) + + calculated_balance.net_market_flows + + calculated_balance.non_cash_adjustments + expected_end = expected_end_cash_component + expected_end_non_cash_component + assert_equal balances[:end], expected_end, + "End balance mismatch for #{date}" + end + end + + # Flow assertions + # If flows passed is 0, we assert all columns are 0 + if flows.is_a?(Integer) && flows == 0 + assert_equal 0, calculated_balance.cash_inflows, + "Cash inflows mismatch for #{date}" + + assert_equal 0, calculated_balance.cash_outflows, + "Cash outflows mismatch for #{date}" + + assert_equal 0, calculated_balance.non_cash_inflows, + "Non-cash inflows mismatch for #{date}" + + assert_equal 0, calculated_balance.non_cash_outflows, + "Non-cash outflows mismatch for #{date}" + + assert_equal 0, calculated_balance.net_market_flows, + "Net market flows mismatch for #{date}" + elsif flows.is_a?(Hash) && flows.any? + # Cash flows - must be asserted together + if flows.key?(:cash_inflows) || flows.key?(:cash_outflows) + assert flows.key?(:cash_inflows) && flows.key?(:cash_outflows), + "Cash inflows and outflows must be asserted together for #{date}" + + assert_equal flows[:cash_inflows], calculated_balance.cash_inflows, + "Cash inflows mismatch for #{date}" + + assert_equal flows[:cash_outflows], calculated_balance.cash_outflows, + "Cash outflows mismatch for #{date}" + end + + # Non-cash flows - must be asserted together + if flows.key?(:non_cash_inflows) || flows.key?(:non_cash_outflows) + assert flows.key?(:non_cash_inflows) && flows.key?(:non_cash_outflows), + "Non-cash inflows and outflows must be asserted together for #{date}" + + assert_equal flows[:non_cash_inflows], calculated_balance.non_cash_inflows, + "Non-cash inflows mismatch for #{date}" + + assert_equal flows[:non_cash_outflows], calculated_balance.non_cash_outflows, + "Non-cash outflows mismatch for #{date}" + end + + # Market flows - can be asserted independently + if flows.key?(:net_market_flows) + assert_equal flows[:net_market_flows], calculated_balance.net_market_flows, + "Net market flows mismatch for #{date}" + end + end + + # Adjustment assertions + if adjustments.is_a?(Integer) && adjustments == 0 + assert_equal 0, calculated_balance.cash_adjustments, + "Cash adjustments mismatch for #{date}" + + assert_equal 0, calculated_balance.non_cash_adjustments, + "Non-cash adjustments mismatch for #{date}" + elsif adjustments.is_a?(Hash) && adjustments.any? + assert_equal adjustments[:cash_adjustments], calculated_balance.cash_adjustments, + "Cash adjustments mismatch for #{date}" if adjustments.key?(:cash_adjustments) + + assert_equal adjustments[:non_cash_adjustments], calculated_balance.non_cash_adjustments, + "Non-cash adjustments mismatch for #{date}" if adjustments.key?(:non_cash_adjustments) + end + + # Temporary assertions during migration (remove after migration complete) + # TODO: Remove these assertions after migration is complete + # Since we're not persisting balances, we calculate the end values + flows_factor = calculated_balance.account.classification == "asset" ? 1 : -1 + expected_end_cash = calculated_balance.start_cash_balance + + ((calculated_balance.cash_inflows - calculated_balance.cash_outflows) * flows_factor) + + calculated_balance.cash_adjustments + expected_end_balance = expected_end_cash + + calculated_balance.start_non_cash_balance + + ((calculated_balance.non_cash_inflows - calculated_balance.non_cash_outflows) * flows_factor) + + calculated_balance.net_market_flows + + calculated_balance.non_cash_adjustments + + assert_equal calculated_balance.cash_balance, expected_end_cash, + "Temporary assertion failed: end_cash_balance should equal cash_balance for #{date}" + + assert_equal calculated_balance.balance, expected_end_balance, + "Temporary assertion failed: end_balance should equal balance for #{date}" else assert_nil calculated_balance, "Unexpected balance calculated for #{date}" end From 3f92fe0f6f465ddd5293638ef7c42336799f674e Mon Sep 17 00:00:00 2001 From: Juliano Julio Costa Date: Wed, 23 Jul 2025 10:10:11 -0400 Subject: [PATCH 03/20] Relax API rate limits for self-hosted deployments (#2465) - Introduced NoopApiRateLimiter to effectively disable API rate limiting for self-hosted mode. - Updated ApiRateLimiter to delegate to NoopApiRateLimiter when running self-hosted. - Increased Rack::Attack throttle limits significantly for self-hosted deployments. - Added tests for NoopApiRateLimiter to ensure correct behavior. - This allows self-hosted users to make more API requests without restriction, while keeping stricter limits for SaaS deployments. --- app/controllers/api/v1/base_controller.rb | 2 +- app/services/api_rate_limiter.rb | 12 ++++- app/services/noop_api_rate_limiter.rb | 39 ++++++++++++++ config/initializers/rack_attack.rb | 7 ++- test/services/noop_api_rate_limiter_test.rb | 58 +++++++++++++++++++++ 5 files changed, 114 insertions(+), 4 deletions(-) create mode 100644 app/services/noop_api_rate_limiter.rb create mode 100644 test/services/noop_api_rate_limiter_test.rb diff --git a/app/controllers/api/v1/base_controller.rb b/app/controllers/api/v1/base_controller.rb index a9b577d1..f176fff4 100644 --- a/app/controllers/api/v1/base_controller.rb +++ b/app/controllers/api/v1/base_controller.rb @@ -98,7 +98,7 @@ class Api::V1::BaseController < ApplicationController @current_user = @api_key.user @api_key.update_last_used! @authentication_method = :api_key - @rate_limiter = ApiRateLimiter.new(@api_key) + @rate_limiter = ApiRateLimiter.limit(@api_key) setup_current_context_for_api true end diff --git a/app/services/api_rate_limiter.rb b/app/services/api_rate_limiter.rb index 9ceb9e79..d3a771cf 100644 --- a/app/services/api_rate_limiter.rb +++ b/app/services/api_rate_limiter.rb @@ -67,7 +67,17 @@ class ApiRateLimiter # Class method to get usage for an API key without incrementing def self.usage_for(api_key) - new(api_key).usage_info + limit(api_key).usage_info + end + + def self.limit(api_key) + if Rails.application.config.app_mode.self_hosted? + # Use NoopApiRateLimiter for self-hosted mode + # This means no rate limiting is applied + NoopApiRateLimiter.new(api_key) + else + new(api_key) + end end private diff --git a/app/services/noop_api_rate_limiter.rb b/app/services/noop_api_rate_limiter.rb new file mode 100644 index 00000000..116b6537 --- /dev/null +++ b/app/services/noop_api_rate_limiter.rb @@ -0,0 +1,39 @@ +class NoopApiRateLimiter + def initialize(api_key) + @api_key = api_key + end + + def rate_limit_exceeded? + false + end + + def increment_request_count! + # No operation + end + + def current_count + 0 + end + + def rate_limit + Float::INFINITY + end + + def reset_time + 0 + end + + def usage_info + { + current_count: 0, + rate_limit: Float::INFINITY, + remaining: Float::INFINITY, + reset_time: 0, + tier: :noop + } + end + + def self.usage_for(api_key) + new(api_key).usage_info + end +end diff --git a/config/initializers/rack_attack.rb b/config/initializers/rack_attack.rb index 93f55288..3d225e58 100644 --- a/config/initializers/rack_attack.rb +++ b/config/initializers/rack_attack.rb @@ -9,8 +9,11 @@ class Rack::Attack request.ip if request.path == "/oauth/token" end + # Determine limits based on self-hosted mode + self_hosted = Rails.application.config.app_mode.self_hosted? + # Throttle API requests per access token - throttle("api/requests", limit: 100, period: 1.hour) do |request| + throttle("api/requests", limit: self_hosted ? 10_000 : 100, period: 1.hour) do |request| if request.path.start_with?("/api/") # Extract access token from Authorization header auth_header = request.get_header("HTTP_AUTHORIZATION") @@ -25,7 +28,7 @@ class Rack::Attack end # More permissive throttling for API requests by IP (for development/testing) - throttle("api/ip", limit: 200, period: 1.hour) do |request| + throttle("api/ip", limit: self_hosted ? 20_000 : 200, period: 1.hour) do |request| request.ip if request.path.start_with?("/api/") end diff --git a/test/services/noop_api_rate_limiter_test.rb b/test/services/noop_api_rate_limiter_test.rb new file mode 100644 index 00000000..9c7105b1 --- /dev/null +++ b/test/services/noop_api_rate_limiter_test.rb @@ -0,0 +1,58 @@ +require "test_helper" + +class NoopApiRateLimiterTest < ActiveSupport::TestCase + setup do + @user = users(:family_admin) + # Clean up any existing API keys for this user to ensure tests start fresh + @user.api_keys.destroy_all + + @api_key = ApiKey.create!( + user: @user, + name: "Noop Rate Limiter Test Key", + scopes: [ "read" ], + display_key: "noop_rate_limiter_test_#{SecureRandom.hex(8)}" + ) + @rate_limiter = NoopApiRateLimiter.new(@api_key) + end + + test "should never be rate limited" do + assert_not @rate_limiter.rate_limit_exceeded? + end + + test "should not increment request count" do + @rate_limiter.increment_request_count! + assert_equal 0, @rate_limiter.current_count + end + + test "should always have zero request count" do + assert_equal 0, @rate_limiter.current_count + end + + test "should have infinite rate limit" do + assert_equal Float::INFINITY, @rate_limiter.rate_limit + end + + test "should have zero reset time" do + assert_equal 0, @rate_limiter.reset_time + end + + test "should provide correct usage info" do + usage_info = @rate_limiter.usage_info + + assert_equal 0, usage_info[:current_count] + assert_equal Float::INFINITY, usage_info[:rate_limit] + assert_equal Float::INFINITY, usage_info[:remaining] + assert_equal 0, usage_info[:reset_time] + assert_equal :noop, usage_info[:tier] + end + + test "class method usage_for should work" do + usage_info = NoopApiRateLimiter.usage_for(@api_key) + + assert_equal 0, usage_info[:current_count] + assert_equal Float::INFINITY, usage_info[:rate_limit] + assert_equal Float::INFINITY, usage_info[:remaining] + assert_equal 0, usage_info[:reset_time] + assert_equal :noop, usage_info[:tier] + end +end From f7f6ebb091676256d602b57c2cc0ece879b60ec8 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Wed, 23 Jul 2025 18:15:14 -0400 Subject: [PATCH 04/20] Use new balance components in activity feed (#2511) * Balance reconcilations with new components * Fix materializer and test assumptions * Fix investment valuation calculations and recon display * Lint fixes * Balance series uses new component fields --- .../UI/account/activity_date.html.erb | 71 +----- app/components/UI/account/activity_date.rb | 24 +- .../account/balance_reconciliation.html.erb | 22 ++ .../UI/account/balance_reconciliation.rb | 155 ++++++++++++ app/models/account/activity_feed_data.rb | 206 +++------------- app/models/account/reconciliation_manager.rb | 4 +- app/models/balance.rb | 14 ++ app/models/balance/base_calculator.rb | 12 +- app/models/balance/chart_series_builder.rb | 122 +++++----- app/models/balance/forward_calculator.rb | 15 +- app/models/balance/materializer.rb | 30 ++- .../models/account/activity_feed_data_test.rb | 226 ++++++++---------- .../account/reconciliation_manager_test.rb | 24 +- .../balance/chart_series_builder_test.rb | 30 +-- .../models/balance/forward_calculator_test.rb | 87 +++++-- test/models/balance/materializer_test.rb | 148 ++++++++++-- test/support/balance_test_helper.rb | 72 ++++++ 17 files changed, 723 insertions(+), 539 deletions(-) create mode 100644 app/components/UI/account/balance_reconciliation.html.erb create mode 100644 app/components/UI/account/balance_reconciliation.rb create mode 100644 test/support/balance_test_helper.rb diff --git a/app/components/UI/account/activity_date.html.erb b/app/components/UI/account/activity_date.html.erb index 0d7f5057..6efa7e96 100644 --- a/app/components/UI/account/activity_date.html.erb +++ b/app/components/UI/account/activity_date.html.erb @@ -17,7 +17,7 @@
- <%= balance_trend.current.format %> + <%= end_balance_money.format %> <%= render DS::Tooltip.new(text: "The end of day balance, after all transactions and adjustments", placement: "left", size: "sm") %>
<%= helpers.icon "chevron-down", class: "group-open:rotate-180" %> @@ -25,73 +25,12 @@
-
-
-
- Start of day balance - <%= render DS::Tooltip.new(text: "The account balance at the beginning of this day, before any transactions or value changes", placement: "left", size: "sm") %> -
-
-
<%= start_balance_money.format %>
-
- - <% if account.balance_type == :investment %> -
-
- Δ Cash - <%= render DS::Tooltip.new(text: "Net change in cash from deposits, withdrawals, and other cash transactions during the day", placement: "left", size: "sm") %> -
-
-
<%= cash_change_money.format %>
-
- -
-
- Δ Holdings - <%= render DS::Tooltip.new(text: "Net change in investment holdings value from buying, selling, or market price movements", placement: "left", size: "sm") %> -
-
-
<%= holdings_change_money.format %>
-
+
+ <% if balance %> + <%= render UI::Account::BalanceReconciliation.new(balance: balance, account: account) %> <% else %> -
-
- Δ Cash - <%= render DS::Tooltip.new(text: "Net change in cash balance from all transactions during the day", placement: "left", size: "sm") %> -
-
-
<%= cash_change_money.format %>
-
+

No balance data available for this date

<% end %> - -
-
- End of day balance - <%= render DS::Tooltip.new(text: "The calculated balance after all transactions but before any manual adjustments or reconciliations", placement: "left", size: "sm") %> -
-
-
<%= end_balance_before_adjustments_money.format %>
-
- -
- -
-
- Δ Value adjustments - <%= render DS::Tooltip.new(text: "Adjustments are either manual reconciliations made by the user or adjustments due to market price changes throughout the day", placement: "left", size: "sm") %> -
-
-
<%= adjustments_money.format %>
-
- -
-
- Closing balance - <%= render DS::Tooltip.new(text: "The final account balance for the day, after all transactions and adjustments have been applied", placement: "left", size: "sm") %> -
-
-
<%= end_balance_money.format %>
-
diff --git a/app/components/UI/account/activity_date.rb b/app/components/UI/account/activity_date.rb index 17fa2255..9de67f8f 100644 --- a/app/components/UI/account/activity_date.rb +++ b/app/components/UI/account/activity_date.rb @@ -1,7 +1,7 @@ class UI::Account::ActivityDate < ApplicationComponent attr_reader :account, :data - delegate :date, :entries, :balance_trend, :cash_balance_trend, :holdings_value_trend, :transfers, to: :data + delegate :date, :entries, :balance, :transfers, to: :data def initialize(account:, data:) @account = account @@ -16,28 +16,8 @@ class UI::Account::ActivityDate < ApplicationComponent account end - def start_balance_money - balance_trend.previous - end - - def cash_change_money - cash_balance_trend.value - end - - def holdings_change_money - holdings_value_trend.value - end - - def end_balance_before_adjustments_money - balance_trend.previous + cash_change_money + holdings_change_money - end - - def adjustments_money - end_balance_money - end_balance_before_adjustments_money - end - def end_balance_money - balance_trend.current + balance&.end_balance_money || Money.new(0, account.currency) end def broadcast_refresh! diff --git a/app/components/UI/account/balance_reconciliation.html.erb b/app/components/UI/account/balance_reconciliation.html.erb new file mode 100644 index 00000000..9eb9e6cd --- /dev/null +++ b/app/components/UI/account/balance_reconciliation.html.erb @@ -0,0 +1,22 @@ +
+ <% reconciliation_items.each_with_index do |item, index| %> + <% if item[:style] == :subtotal %> +
+ <% end %> + +
+
+ <%= item[:label] %> + <%= render DS::Tooltip.new(text: item[:tooltip], placement: "left", size: "sm") %> +
+
"> +
"> + <%= item[:value].format %> +
+
+ + <% if item[:style] == :adjustment %> +
+ <% end %> + <% end %> +
diff --git a/app/components/UI/account/balance_reconciliation.rb b/app/components/UI/account/balance_reconciliation.rb new file mode 100644 index 00000000..980fad60 --- /dev/null +++ b/app/components/UI/account/balance_reconciliation.rb @@ -0,0 +1,155 @@ +class UI::Account::BalanceReconciliation < ApplicationComponent + attr_reader :balance, :account + + def initialize(balance:, account:) + @balance = balance + @account = account + end + + def reconciliation_items + case account.accountable_type + when "Depository", "OtherAsset", "OtherLiability" + default_items + when "CreditCard" + credit_card_items + when "Investment" + investment_items + when "Loan" + loan_items + when "Property", "Vehicle" + asset_items + when "Crypto" + crypto_items + else + default_items + end + end + + private + + def default_items + items = [ + { label: "Start balance", value: balance.start_balance_money, tooltip: "The account balance at the beginning of this day", style: :start }, + { label: "Net cash flow", value: net_cash_flow, tooltip: "Net change in balance from all transactions during the day", style: :flow } + ] + + if has_adjustments? + items << { label: "End balance", value: end_balance_before_adjustments, tooltip: "The calculated balance after all transactions", style: :subtotal } + items << { label: "Adjustments", value: total_adjustments, tooltip: "Manual reconciliations or other adjustments", style: :adjustment } + end + + items << { label: "Final balance", value: balance.end_balance_money, tooltip: "The final account balance for the day", style: :final } + items + end + + def credit_card_items + items = [ + { label: "Start balance", value: balance.start_balance_money, tooltip: "The balance owed at the beginning of this day", style: :start }, + { label: "Charges", value: balance.cash_outflows_money, tooltip: "New charges made during the day", style: :flow }, + { label: "Payments", value: balance.cash_inflows_money * -1, tooltip: "Payments made to the card during the day", style: :flow } + ] + + if has_adjustments? + items << { label: "End balance", value: end_balance_before_adjustments, tooltip: "The calculated balance after all transactions", style: :subtotal } + items << { label: "Adjustments", value: total_adjustments, tooltip: "Manual reconciliations or other adjustments", style: :adjustment } + end + + items << { label: "Final balance", value: balance.end_balance_money, tooltip: "The final balance owed for the day", style: :final } + items + end + + def investment_items + items = [ + { label: "Start balance", value: balance.start_balance_money, tooltip: "The total portfolio value at the beginning of this day", style: :start } + ] + + # Change in brokerage cash (includes deposits, withdrawals, and cash from trades) + items << { label: "Change in brokerage cash", value: net_cash_flow, tooltip: "Net change in cash from deposits, withdrawals, and trades", style: :flow } + + # Change in holdings from trading activity + items << { label: "Change in holdings (buys/sells)", value: net_non_cash_flow, tooltip: "Impact on holdings from buying and selling securities", style: :flow } + + # Market price changes + items << { label: "Change in holdings (market price activity)", value: balance.net_market_flows_money, tooltip: "Change in holdings value from market price movements", style: :flow } + + if has_adjustments? + items << { label: "End balance", value: end_balance_before_adjustments, tooltip: "The calculated balance after all activity", style: :subtotal } + items << { label: "Adjustments", value: total_adjustments, tooltip: "Manual reconciliations or other adjustments", style: :adjustment } + end + + items << { label: "Final balance", value: balance.end_balance_money, tooltip: "The final portfolio value for the day", style: :final } + items + end + + def loan_items + items = [ + { label: "Start principal", value: balance.start_balance_money, tooltip: "The principal balance at the beginning of this day", style: :start }, + { label: "Net principal change", value: net_non_cash_flow, tooltip: "Principal payments and new borrowing during the day", style: :flow } + ] + + if has_adjustments? + items << { label: "End principal", value: end_balance_before_adjustments, tooltip: "The calculated principal after all transactions", style: :subtotal } + items << { label: "Adjustments", value: balance.non_cash_adjustments_money, tooltip: "Manual reconciliations or other adjustments", style: :adjustment } + end + + items << { label: "Final principal", value: balance.end_balance_money, tooltip: "The final principal balance for the day", style: :final } + items + end + + def asset_items # Property/Vehicle + items = [ + { label: "Start value", value: balance.start_balance_money, tooltip: "The asset value at the beginning of this day", style: :start }, + { label: "Net value change", value: net_total_flow, tooltip: "All value changes including improvements and depreciation", style: :flow } + ] + + if has_adjustments? + items << { label: "End value", value: end_balance_before_adjustments, tooltip: "The calculated value after all changes", style: :subtotal } + items << { label: "Adjustments", value: total_adjustments, tooltip: "Manual value adjustments or appraisals", style: :adjustment } + end + + items << { label: "Final value", value: balance.end_balance_money, tooltip: "The final asset value for the day", style: :final } + items + end + + def crypto_items + items = [ + { label: "Start balance", value: balance.start_balance_money, tooltip: "The crypto holdings value at the beginning of this day", style: :start } + ] + + items << { label: "Buys", value: balance.cash_outflows_money * -1, tooltip: "Crypto purchases during the day", style: :flow } if balance.cash_outflows != 0 + items << { label: "Sells", value: balance.cash_inflows_money, tooltip: "Crypto sales during the day", style: :flow } if balance.cash_inflows != 0 + items << { label: "Market changes", value: balance.net_market_flows_money, tooltip: "Value changes from market price movements", style: :flow } if balance.net_market_flows != 0 + + if has_adjustments? + items << { label: "End balance", value: end_balance_before_adjustments, tooltip: "The calculated balance after all activity", style: :subtotal } + items << { label: "Adjustments", value: total_adjustments, tooltip: "Manual reconciliations or other adjustments", style: :adjustment } + end + + items << { label: "Final balance", value: balance.end_balance_money, tooltip: "The final crypto holdings value for the day", style: :final } + items + end + + def net_cash_flow + balance.cash_inflows_money - balance.cash_outflows_money + end + + def net_non_cash_flow + balance.non_cash_inflows_money - balance.non_cash_outflows_money + end + + def net_total_flow + net_cash_flow + net_non_cash_flow + balance.net_market_flows_money + end + + def total_adjustments + balance.cash_adjustments_money + balance.non_cash_adjustments_money + end + + def has_adjustments? + balance.cash_adjustments != 0 || balance.non_cash_adjustments != 0 + end + + def end_balance_before_adjustments + balance.end_balance_money - total_adjustments + end +end diff --git a/app/models/account/activity_feed_data.rb b/app/models/account/activity_feed_data.rb index 28f92a64..7d1c41bd 100644 --- a/app/models/account/activity_feed_data.rb +++ b/app/models/account/activity_feed_data.rb @@ -2,7 +2,7 @@ # This data object is useful for avoiding N+1 queries and having an easy way to pass around the required data to the # activity feed component in controllers and background jobs that refresh it. class Account::ActivityFeedData - ActivityDateData = Data.define(:date, :entries, :balance_trend, :cash_balance_trend, :holdings_value_trend, :transfers) + ActivityDateData = Data.define(:date, :entries, :balance, :transfers) attr_reader :account, :entries @@ -17,9 +17,7 @@ class Account::ActivityFeedData ActivityDateData.new( date: date, entries: date_entries, - balance_trend: balance_trend_for_date(date), - cash_balance_trend: cash_balance_trend_for_date(date), - holdings_value_trend: holdings_value_trend_for_date(date), + balance: balance_for_date(date), transfers: transfers_for_date(date) ) end @@ -27,193 +25,61 @@ class Account::ActivityFeedData end private - def balance_trend_for_date(date) - build_trend_for_date(date, :balance_money) - end - - def cash_balance_trend_for_date(date) - date_entries = grouped_entries[date] || [] - has_valuation = date_entries.any?(&:valuation?) - - if has_valuation - # When there's a valuation, calculate cash change from transaction entries only - transactions = date_entries.select { |e| e.transaction? } - cash_change = sum_entries_with_exchange_rates(transactions, date) * -1 - - start_balance = start_balance_for_date(date) - Trend.new( - current: start_balance.cash_balance_money + cash_change, - previous: start_balance.cash_balance_money - ) - else - build_trend_for_date(date, :cash_balance_money) - end - end - - def holdings_value_trend_for_date(date) - date_entries = grouped_entries[date] || [] - has_valuation = date_entries.any?(&:valuation?) - - if has_valuation - # When there's a valuation, calculate holdings change from trade entries only - trades = date_entries.select { |e| e.trade? } - holdings_change = sum_entries_with_exchange_rates(trades, date) - - start_balance = start_balance_for_date(date) - start_holdings = start_balance.balance_money - start_balance.cash_balance_money - Trend.new( - current: start_holdings + holdings_change, - previous: start_holdings - ) - else - build_trend_for_date(date) do |balance| - balance.balance_money - balance.cash_balance_money - end - end + def balance_for_date(date) + balances_by_date[date] end def transfers_for_date(date) - date_entries = grouped_entries[date] || [] - return [] if date_entries.empty? - - date_transaction_ids = date_entries.select(&:transaction?).map(&:entryable_id) - return [] if date_transaction_ids.empty? - - # Convert to Set for O(1) lookups - date_transaction_id_set = Set.new(date_transaction_ids) - - transfers.select { |txfr| - date_transaction_id_set.include?(txfr.inflow_transaction_id) || - date_transaction_id_set.include?(txfr.outflow_transaction_id) - } + transfers_by_date[date] || [] end - def build_trend_for_date(date, method = nil) - start_balance = start_balance_for_date(date) - end_balance = end_balance_for_date(date) - - if block_given? - Trend.new( - current: yield(end_balance), - previous: yield(start_balance) - ) - else - Trend.new( - current: end_balance.send(method), - previous: start_balance.send(method) - ) - end - end - - # Finds the balance on date, or the most recent balance before it ("last observation carried forward") - def start_balance_for_date(date) - @start_balance_for_date ||= {} - @start_balance_for_date[date] ||= last_observed_balance_before_date(date.prev_day) || generate_fallback_balance(date) - end - - # Finds the balance on date, or the most recent balance before it ("last observation carried forward") - def end_balance_for_date(date) - @end_balance_for_date ||= {} - @end_balance_for_date[date] ||= last_observed_balance_before_date(date) || generate_fallback_balance(date) - end - - RequiredExchangeRate = Data.define(:date, :from, :to) - def grouped_entries @grouped_entries ||= entries.group_by(&:date) end - def needs_exchange_rates? - entries.any? { |entry| entry.currency != account.currency } - end + def balances_by_date + @balances_by_date ||= begin + return {} if entries.empty? - def required_exchange_rates - multi_currency_entries = entries.select { |entry| entry.currency != account.currency } - - multi_currency_entries.map do |entry| - RequiredExchangeRate.new(date: entry.date, from: entry.currency, to: account.currency) - end.uniq - end - - # If the account has entries denominated in a different currency than the main account, we attach necessary - # exchange rates required to "roll up" the entry group balance into the normal account currency. - def exchange_rates - return [] unless needs_exchange_rates? - - @exchange_rates ||= begin - rate_requirements = required_exchange_rates - return [] if rate_requirements.empty? - - # Use ActiveRecord's or chain for better performance - conditions = rate_requirements.map do |req| - ExchangeRate.where(date: req.date, from_currency: req.from, to_currency: req.to) - end.reduce(:or) - - conditions.to_a + dates = grouped_entries.keys + account.balances + .where(date: dates, currency: account.currency) + .index_by(&:date) end end - def exchange_rate_for(date, from_currency, to_currency) - return 1.0 if from_currency == to_currency + def transfers_by_date + @transfers_by_date ||= begin + return {} if transaction_ids.empty? - rate = exchange_rates.find { |r| r.date == date && r.from_currency == from_currency && r.to_currency == to_currency } - rate&.rate || 1.0 # Fallback to 1:1 if no rate found - end + transfers = Transfer + .where(inflow_transaction_id: transaction_ids) + .or(Transfer.where(outflow_transaction_id: transaction_ids)) + .to_a - def sum_entries_with_exchange_rates(entries, date) - return Money.new(0, account.currency) if entries.empty? + # Group transfers by the date of their transaction entries + result = Hash.new { |h, k| h[k] = [] } - entries.sum do |entry| - amount = entry.amount_money - if entry.currency != account.currency - rate = exchange_rate_for(date, entry.currency, account.currency) - Money.new(amount.amount * rate, account.currency) - else - amount + entries.each do |entry| + next unless entry.transaction? && transaction_ids.include?(entry.entryable_id) + + transfers.each do |transfer| + if transfer.inflow_transaction_id == entry.entryable_id || + transfer.outflow_transaction_id == entry.entryable_id + result[entry.date] << transfer + end + end end - end - end - # We read balances so we can show "start of day" -> "end of day" balances for each entry date group in the feed - def balances - @balances ||= begin - return [] if entries.empty? - - min_date = entries.min_by(&:date).date.prev_day - max_date = entries.max_by(&:date).date - - account.balances.where(date: min_date..max_date, currency: account.currency).order(:date).to_a + # Remove duplicates + result.transform_values(&:uniq) end end def transaction_ids - entries.select { |entry| entry.transaction? }.map(&:entryable_id) - end - - def transfers - return [] if entries.select { |e| e.transaction? && e.transaction.transfer? }.empty? - return [] if transaction_ids.empty? - - @transfers ||= Transfer.where(inflow_transaction_id: transaction_ids).or(Transfer.where(outflow_transaction_id: transaction_ids)).to_a - end - - # Use binary search since balances are sorted by date - def last_observed_balance_before_date(date) - idx = balances.bsearch_index { |b| b.date > date } - - if idx - idx > 0 ? balances[idx - 1] : nil - else - balances.last - end - end - - def generate_fallback_balance(date) - Balance.new( - account: account, - date: date, - balance: 0, - currency: account.currency - ) + @transaction_ids ||= entries + .select(&:transaction?) + .map(&:entryable_id) + .compact end end diff --git a/app/models/account/reconciliation_manager.rb b/app/models/account/reconciliation_manager.rb index aac821b2..6fadcfa1 100644 --- a/app/models/account/reconciliation_manager.rb +++ b/app/models/account/reconciliation_manager.rb @@ -82,8 +82,8 @@ class Account::ReconciliationManager balance_record = account.balances.find_by(date: date, currency: account.currency) { - cash_balance: balance_record&.cash_balance, - balance: balance_record&.balance + cash_balance: balance_record&.end_cash_balance, + balance: balance_record&.end_balance } end end diff --git a/app/models/balance.rb b/app/models/balance.rb index dffc9f07..3b6f74ce 100644 --- a/app/models/balance.rb +++ b/app/models/balance.rb @@ -14,4 +14,18 @@ class Balance < ApplicationRecord scope :in_period, ->(period) { period.nil? ? all : where(date: period.date_range) } scope :chronological, -> { order(:date) } + + def balance_trend + Trend.new( + current: end_balance_money, + previous: start_balance_money, + favorable_direction: favorable_direction + ) + end + + private + + def favorable_direction + flows_factor == -1 ? "down" : "up" + end end diff --git a/app/models/balance/base_calculator.rb b/app/models/balance/base_calculator.rb index 9d1d288f..a1e43d99 100644 --- a/app/models/balance/base_calculator.rb +++ b/app/models/balance/base_calculator.rb @@ -29,16 +29,16 @@ class Balance::BaseCalculator end end - def cash_adjustments_for_date(start_cash, net_cash_flows, valuation) - return 0 unless valuation && account.balance_type != :non_cash + def cash_adjustments_for_date(start_cash, end_cash, net_cash_flows) + return 0 unless account.balance_type != :non_cash - valuation.amount - start_cash - net_cash_flows + end_cash - start_cash - net_cash_flows end - def non_cash_adjustments_for_date(start_non_cash, non_cash_flows, valuation) - return 0 unless valuation && account.balance_type == :non_cash + def non_cash_adjustments_for_date(start_non_cash, end_non_cash, non_cash_flows) + return 0 unless account.balance_type == :non_cash - valuation.amount - start_non_cash - non_cash_flows + end_non_cash - start_non_cash - non_cash_flows end # If holdings value goes from $100 -> $200 (change_holdings_value is $100) diff --git a/app/models/balance/chart_series_builder.rb b/app/models/balance/chart_series_builder.rb index 0c367685..dad10817 100644 --- a/app/models/balance/chart_series_builder.rb +++ b/app/models/balance/chart_series_builder.rb @@ -8,21 +8,21 @@ class Balance::ChartSeriesBuilder end def balance_series - build_series_for(:balance) + build_series_for(:end_balance) rescue => e Rails.logger.error "Balance series error: #{e.message} for accounts #{@account_ids}" raise end def cash_balance_series - build_series_for(:cash_balance) + build_series_for(:end_cash_balance) rescue => e Rails.logger.error "Cash balance series error: #{e.message} for accounts #{@account_ids}" raise end def holdings_balance_series - build_series_for(:holdings_balance) + build_series_for(:end_holdings_balance) rescue => e Rails.logger.error "Holdings balance series error: #{e.message} for accounts #{@account_ids}" raise @@ -37,13 +37,20 @@ class Balance::ChartSeriesBuilder def build_series_for(column) values = query_data.map do |datum| + # Map column names to their start equivalents + previous_column = case column + when :end_balance then :start_balance + when :end_cash_balance then :start_cash_balance + when :end_holdings_balance then :start_holdings_balance + end + Series::Value.new( date: datum.date, date_formatted: I18n.l(datum.date, format: :long), value: Money.new(datum.send(column), currency), trend: Trend.new( current: Money.new(datum.send(column), currency), - previous: Money.new(datum.send("previous_#{column}"), currency), + previous: Money.new(datum.send(previous_column), currency), favorable_direction: favorable_direction ) ) @@ -88,66 +95,57 @@ class Balance::ChartSeriesBuilder WITH dates AS ( SELECT generate_series(DATE :start_date, DATE :end_date, :interval::interval)::date AS date UNION DISTINCT - SELECT :end_date::date -- Pass in date to ensure timezone-aware "today" date - ), aggregated_balances AS ( - SELECT - d.date, - -- Total balance (assets positive, liabilities negative) - SUM( - CASE WHEN accounts.classification = 'asset' - THEN COALESCE(last_bal.balance, 0) - ELSE -COALESCE(last_bal.balance, 0) - END * COALESCE(er.rate, 1) * :sign_multiplier::integer - ) AS balance, - -- Cash-only balance - SUM( - CASE WHEN accounts.classification = 'asset' - THEN COALESCE(last_bal.cash_balance, 0) - ELSE -COALESCE(last_bal.cash_balance, 0) - END * COALESCE(er.rate, 1) * :sign_multiplier::integer - ) AS cash_balance, - -- Holdings value (balance ‑ cash) - SUM( - CASE WHEN accounts.classification = 'asset' - THEN COALESCE(last_bal.balance, 0) - COALESCE(last_bal.cash_balance, 0) - ELSE 0 - END * COALESCE(er.rate, 1) * :sign_multiplier::integer - ) AS holdings_balance - FROM dates d - JOIN accounts ON accounts.id = ANY(array[:account_ids]::uuid[]) - - -- Last observation carried forward (LOCF), use the most recent balance on or before the chart date - LEFT JOIN LATERAL ( - SELECT b.balance, b.cash_balance - FROM balances b - WHERE b.account_id = accounts.id - AND b.date <= d.date - ORDER BY b.date DESC - LIMIT 1 - ) last_bal ON TRUE - - -- Last observation carried forward (LOCF), use the most recent exchange rate on or before the chart date - LEFT JOIN LATERAL ( - SELECT er.rate - FROM exchange_rates er - WHERE er.from_currency = accounts.currency - AND er.to_currency = :target_currency - AND er.date <= d.date - ORDER BY er.date DESC - LIMIT 1 - ) er ON TRUE - GROUP BY d.date + SELECT :end_date::date -- Ensure end date is included ) SELECT - date, - balance, - cash_balance, - holdings_balance, - COALESCE(LAG(balance) OVER (ORDER BY date), 0) AS previous_balance, - COALESCE(LAG(cash_balance) OVER (ORDER BY date), 0) AS previous_cash_balance, - COALESCE(LAG(holdings_balance) OVER (ORDER BY date), 0) AS previous_holdings_balance - FROM aggregated_balances - ORDER BY date + d.date, + -- Use flows_factor: already handles asset (+1) vs liability (-1) + COALESCE(SUM(last_bal.end_balance * last_bal.flows_factor * COALESCE(er.rate, 1) * :sign_multiplier::integer), 0) AS end_balance, + COALESCE(SUM(last_bal.end_cash_balance * last_bal.flows_factor * COALESCE(er.rate, 1) * :sign_multiplier::integer), 0) AS end_cash_balance, + -- Holdings only for assets (flows_factor = 1) + COALESCE(SUM( + CASE WHEN last_bal.flows_factor = 1 + THEN last_bal.end_non_cash_balance + ELSE 0 + END * COALESCE(er.rate, 1) * :sign_multiplier::integer + ), 0) AS end_holdings_balance, + -- Previous balances + COALESCE(SUM(last_bal.start_balance * last_bal.flows_factor * COALESCE(er.rate, 1) * :sign_multiplier::integer), 0) AS start_balance, + COALESCE(SUM(last_bal.start_cash_balance * last_bal.flows_factor * COALESCE(er.rate, 1) * :sign_multiplier::integer), 0) AS start_cash_balance, + COALESCE(SUM( + CASE WHEN last_bal.flows_factor = 1 + THEN last_bal.start_non_cash_balance + ELSE 0 + END * COALESCE(er.rate, 1) * :sign_multiplier::integer + ), 0) AS start_holdings_balance + FROM dates d + CROSS JOIN accounts + LEFT JOIN LATERAL ( + SELECT b.end_balance, + b.end_cash_balance, + b.end_non_cash_balance, + b.start_balance, + b.start_cash_balance, + b.start_non_cash_balance, + b.flows_factor + FROM balances b + WHERE b.account_id = accounts.id + AND b.date <= d.date + ORDER BY b.date DESC + LIMIT 1 + ) last_bal ON TRUE + LEFT JOIN LATERAL ( + SELECT er.rate + FROM exchange_rates er + WHERE er.from_currency = accounts.currency + AND er.to_currency = :target_currency + AND er.date <= d.date + ORDER BY er.date DESC + LIMIT 1 + ) er ON TRUE + WHERE accounts.id = ANY(array[:account_ids]::uuid[]) + GROUP BY d.date + ORDER BY d.date SQL end end diff --git a/app/models/balance/forward_calculator.rb b/app/models/balance/forward_calculator.rb index 42a420f7..af092a2a 100644 --- a/app/models/balance/forward_calculator.rb +++ b/app/models/balance/forward_calculator.rb @@ -2,10 +2,10 @@ class Balance::ForwardCalculator < Balance::BaseCalculator def calculate Rails.logger.tagged("Balance::ForwardCalculator") do start_cash_balance = derive_cash_balance_on_date_from_total( - total_balance: 0, + total_balance: account.opening_anchor_balance, date: account.opening_anchor_date ) - start_non_cash_balance = 0 + 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_valuation(date) @@ -24,6 +24,9 @@ class Balance::ForwardCalculator < Balance::BaseCalculator flows = flows_for_date(date) market_value_change = market_value_change_on_date(date, flows) + cash_adjustments = cash_adjustments_for_date(start_cash_balance, end_cash_balance, (flows[:cash_inflows] - flows[:cash_outflows]) * flows_factor) + non_cash_adjustments = non_cash_adjustments_for_date(start_non_cash_balance, end_non_cash_balance, (flows[:non_cash_inflows] - flows[:non_cash_outflows]) * flows_factor) + output_balance = build_balance( date: date, balance: end_cash_balance + end_non_cash_balance, @@ -34,8 +37,8 @@ class Balance::ForwardCalculator < Balance::BaseCalculator cash_outflows: flows[:cash_outflows], non_cash_inflows: flows[:non_cash_inflows], non_cash_outflows: flows[:non_cash_outflows], - cash_adjustments: cash_adjustments_for_date(start_cash_balance, flows[:cash_inflows] - flows[:cash_outflows], valuation), - non_cash_adjustments: non_cash_adjustments_for_date(start_non_cash_balance, flows[:non_cash_inflows] - flows[:non_cash_outflows], valuation), + cash_adjustments: cash_adjustments, + non_cash_adjustments: non_cash_adjustments, net_market_flows: market_value_change ) @@ -75,4 +78,8 @@ class Balance::ForwardCalculator < Balance::BaseCalculator def derive_end_non_cash_balance(start_non_cash_balance:, date:) derive_non_cash_balance(start_non_cash_balance, date, direction: :forward) end + + def flows_factor + account.asset? ? 1 : -1 + end end diff --git a/app/models/balance/materializer.rb b/app/models/balance/materializer.rb index 75a98ffd..c6501ffa 100644 --- a/app/models/balance/materializer.rb +++ b/app/models/balance/materializer.rb @@ -28,9 +28,20 @@ class Balance::Materializer end def update_account_info - calculated_balance = @balances.sort_by(&:date).last&.balance || 0 - calculated_holdings_value = @holdings.select { |h| h.date == Date.current }.sum(&:amount) || 0 - calculated_cash_balance = calculated_balance - calculated_holdings_value + # Query fresh balance from DB to get generated column values + current_balance = account.balances + .where(currency: account.currency) + .order(date: :desc) + .first + + if current_balance + calculated_balance = current_balance.end_balance + calculated_cash_balance = current_balance.end_cash_balance + else + # Fallback if no balance exists + calculated_balance = 0 + calculated_cash_balance = 0 + end Rails.logger.info("Balance update: cash=#{calculated_cash_balance}, total=#{calculated_balance}") @@ -48,14 +59,23 @@ class Balance::Materializer current_time = Time.now account.balances.upsert_all( @balances.map { |b| b.attributes - .slice("date", "balance", "cash_balance", "currency") + .slice("date", "balance", "cash_balance", "currency", + "start_cash_balance", "start_non_cash_balance", + "cash_inflows", "cash_outflows", + "non_cash_inflows", "non_cash_outflows", + "net_market_flows", + "cash_adjustments", "non_cash_adjustments", + "flows_factor") .merge("updated_at" => current_time) }, unique_by: %i[account_id date currency] ) end def purge_stale_balances - deleted_count = account.balances.delete_by("date < ?", account.start_date) + sorted_balances = @balances.sort_by(&:date) + oldest_calculated_balance_date = sorted_balances.first&.date + newest_calculated_balance_date = sorted_balances.last&.date + deleted_count = account.balances.delete_by("date < ? OR date > ?", oldest_calculated_balance_date, newest_calculated_balance_date) Rails.logger.info("Purged #{deleted_count} stale balances") if deleted_count > 0 end diff --git a/test/models/account/activity_feed_data_test.rb b/test/models/account/activity_feed_data_test.rb index 2139076c..ec093791 100644 --- a/test/models/account/activity_feed_data_test.rb +++ b/test/models/account/activity_feed_data_test.rb @@ -14,7 +14,7 @@ class Account::ActivityFeedDataTest < ActiveSupport::TestCase setup_test_data end - test "calculates balance trend with complete balance history" do + test "returns balance for date with complete balance history" do entries = @checking.entries.includes(:entryable).to_a feed_data = Account::ActivityFeedData.new(@checking, entries) @@ -22,14 +22,11 @@ class Account::ActivityFeedDataTest < ActiveSupport::TestCase day2_activity = find_activity_for_date(activities, @test_period_start + 1.day) assert_not_nil day2_activity - trend = day2_activity.balance_trend - assert_equal 1100, trend.current.amount.to_i # End of day 2 - assert_equal 1000, trend.previous.amount.to_i # End of day 1 - assert_equal 100, trend.value.amount.to_i - assert_equal "up", trend.direction.to_s + assert_not_nil day2_activity.balance + assert_equal 1100, day2_activity.balance.end_balance # End of day 2 end - test "calculates balance trend for first day with zero starting balance" do + test "returns balance for first day" do entries = @checking.entries.includes(:entryable).to_a feed_data = Account::ActivityFeedData.new(@checking, entries) @@ -37,49 +34,24 @@ class Account::ActivityFeedDataTest < ActiveSupport::TestCase day1_activity = find_activity_for_date(activities, @test_period_start) assert_not_nil day1_activity - trend = day1_activity.balance_trend - assert_equal 1000, trend.current.amount.to_i # End of first day - assert_equal 0, trend.previous.amount.to_i # Fallback to 0 - assert_equal 1000, trend.value.amount.to_i + assert_not_nil day1_activity.balance + assert_equal 1000, day1_activity.balance.end_balance # End of first day end - test "uses last observed balance when intermediate balances are missing" do - @checking.balances.where(date: [ @test_period_start + 1.day, @test_period_start + 3.days ]).destroy_all - - entries = @checking.entries.includes(:entryable).to_a - feed_data = Account::ActivityFeedData.new(@checking, entries) - - activities = feed_data.entries_by_date - - # When day 2 balance is missing, both start and end use day 1 balance - day2_activity = find_activity_for_date(activities, @test_period_start + 1.day) - assert_not_nil day2_activity - trend = day2_activity.balance_trend - assert_equal 1000, trend.current.amount.to_i # LOCF from day 1 - assert_equal 1000, trend.previous.amount.to_i # LOCF from day 1 - assert_equal 0, trend.value.amount.to_i - assert_equal "flat", trend.direction.to_s - end - - test "returns zero balance when no balance history exists" do + test "returns nil balance when no balance exists for date" do @checking.balances.destroy_all entries = @checking.entries.includes(:entryable).to_a feed_data = Account::ActivityFeedData.new(@checking, entries) activities = feed_data.entries_by_date - # Use first day which has a transaction day1_activity = find_activity_for_date(activities, @test_period_start) assert_not_nil day1_activity - trend = day1_activity.balance_trend - assert_equal 0, trend.current.amount.to_i # Fallback to 0 - assert_equal 0, trend.previous.amount.to_i # Fallback to 0 - assert_equal 0, trend.value.amount.to_i - assert_equal "flat", trend.direction.to_s + assert_nil day1_activity.balance end - test "calculates cash and holdings trends for investment accounts" do + test "returns cash and holdings data for investment accounts" do entries = @investment.entries.includes(:entryable).to_a feed_data = Account::ActivityFeedData.new(@investment, entries) @@ -87,20 +59,12 @@ class Account::ActivityFeedDataTest < ActiveSupport::TestCase day3_activity = find_activity_for_date(activities, @test_period_start + 2.days) assert_not_nil day3_activity + assert_not_nil day3_activity.balance - # Cash trend for day 3 (after foreign currency transaction) - cash_trend = day3_activity.cash_balance_trend - assert_equal 400, cash_trend.current.amount.to_i # End of day 3 cash balance - assert_equal 500, cash_trend.previous.amount.to_i # End of day 2 cash balance - assert_equal(-100, cash_trend.value.amount.to_i) - assert_equal "down", cash_trend.direction.to_s - - # Holdings trend for day 3 (after trade) - holdings_trend = day3_activity.holdings_value_trend - assert_equal 1500, holdings_trend.current.amount.to_i # Total balance - cash balance - assert_equal 0, holdings_trend.previous.amount.to_i # No holdings before trade - assert_equal 1500, holdings_trend.value.amount.to_i - assert_equal "up", holdings_trend.direction.to_s + # Balance should have the new schema fields + assert_equal 400, day3_activity.balance.end_cash_balance # End of day 3 cash balance + assert_equal 1500, day3_activity.balance.end_non_cash_balance # Holdings value + assert_equal 1900, day3_activity.balance.end_balance # Total balance end test "identifies transfers for a specific date" do @@ -134,30 +98,46 @@ class Account::ActivityFeedDataTest < ActiveSupport::TestCase activities.each do |activity| assert_respond_to activity, :date assert_respond_to activity, :entries - assert_respond_to activity, :balance_trend - assert_respond_to activity, :cash_balance_trend - assert_respond_to activity, :holdings_value_trend + assert_respond_to activity, :balance assert_respond_to activity, :transfers end end - test "handles valuations correctly by summing entry changes" do + test "handles valuations correctly with new balance schema" do # Create account with known balances account = @family.accounts.create!(name: "Test Investment", accountable: Investment.new, currency: "USD", balance: 0) # Day 1: Starting balance account.balances.create!( date: @test_period_start, - balance: 7321.56, - cash_balance: 1000, + balance: 7321.56, # Keep old field for now + cash_balance: 1000, # Keep old field for now + start_cash_balance: 0, + start_non_cash_balance: 0, + cash_inflows: 1000, + cash_outflows: 0, + non_cash_inflows: 6321.56, + non_cash_outflows: 0, + net_market_flows: 0, + cash_adjustments: 0, + non_cash_adjustments: 0, currency: "USD" ) # Day 2: Add transactions, trades and a valuation account.balances.create!( date: @test_period_start + 1.day, - balance: 8500, # Valuation sets this - cash_balance: 1070, # Cash increased by transactions + balance: 8500, # Keep old field for now + cash_balance: 1070, # Keep old field for now + start_cash_balance: 1000, + start_non_cash_balance: 6321.56, + cash_inflows: 70, + cash_outflows: 0, + non_cash_inflows: 750, + non_cash_outflows: 0, + net_market_flows: 0, + cash_adjustments: 0, + non_cash_adjustments: 358.44, currency: "USD" ) @@ -198,73 +178,12 @@ class Account::ActivityFeedDataTest < ActiveSupport::TestCase day2_activity = find_activity_for_date(activities, @test_period_start + 1.day) assert_not_nil day2_activity + assert_not_nil day2_activity.balance - # Cash change should be $70 (50 + 20 from transactions only, not trades) - assert_equal 70, day2_activity.cash_balance_trend.value.amount.to_i - - # Holdings change should be 750 (from the trade) - assert_equal 750, day2_activity.holdings_value_trend.value.amount.to_i - - # Total balance change - assert_in_delta 1178.44, day2_activity.balance_trend.value.amount.to_f, 0.01 - end - - test "normalizes multi-currency entries on valuation days" do - # Create EUR account - eur_account = @family.accounts.create!(name: "EUR Investment", accountable: Investment.new, currency: "EUR", balance: 0) - - # Day 1: Starting balance - eur_account.balances.create!( - date: @test_period_start, - balance: 1000, - cash_balance: 500, - currency: "EUR" - ) - - # Day 2: Multi-currency transactions and valuation - eur_account.balances.create!( - date: @test_period_start + 1.day, - balance: 2000, - cash_balance: 600, - currency: "EUR" - ) - - # Create USD transaction (should be converted to EUR) - create_transaction( - account: eur_account, - date: @test_period_start + 1.day, - amount: -100, - currency: "USD", - name: "USD Payment" - ) - - # Create exchange rate: 1 USD = 0.9 EUR - ExchangeRate.create!( - date: @test_period_start + 1.day, - from_currency: "USD", - to_currency: "EUR", - rate: 0.9 - ) - - # Create valuation - create_valuation( - account: eur_account, - date: @test_period_start + 1.day, - amount: 2000 - ) - - entries = eur_account.entries.includes(:entryable).to_a - feed_data = Account::ActivityFeedData.new(eur_account, entries) - - activities = feed_data.entries_by_date - day2_activity = find_activity_for_date(activities, @test_period_start + 1.day) - - assert_not_nil day2_activity - - # Cash change should be 90 EUR (100 USD * 0.9) - # The transaction is -100 USD, which becomes +100 when inverted, then 100 * 0.9 = 90 EUR - assert_equal 90, day2_activity.cash_balance_trend.value.amount.to_i - assert_equal "EUR", day2_activity.cash_balance_trend.value.currency.iso_code + # Check new balance fields + assert_equal 1070, day2_activity.balance.end_cash_balance + assert_equal 7430, day2_activity.balance.end_non_cash_balance + assert_equal 8500, day2_activity.balance.end_balance end private @@ -273,12 +192,25 @@ class Account::ActivityFeedDataTest < ActiveSupport::TestCase end def setup_test_data - # Create daily balances for checking account + # Create daily balances for checking account with new schema 5.times do |i| date = @test_period_start + i.days + prev_balance = i > 0 ? 1000 + ((i - 1) * 100) : 0 + @checking.balances.create!( date: date, - balance: 1000 + (i * 100), + balance: 1000 + (i * 100), # Keep old field for now + cash_balance: 1000 + (i * 100), # Keep old field for now + start_balance: prev_balance, + start_cash_balance: prev_balance, + start_non_cash_balance: 0, + cash_inflows: i == 0 ? 1000 : 100, + cash_outflows: 0, + non_cash_inflows: 0, + non_cash_outflows: 0, + net_market_flows: 0, + cash_adjustments: 0, + non_cash_adjustments: 0, currency: "USD" ) end @@ -286,20 +218,50 @@ class Account::ActivityFeedDataTest < ActiveSupport::TestCase # Create daily balances for investment account with cash_balance @investment.balances.create!( date: @test_period_start, - balance: 500, - cash_balance: 500, + balance: 500, # Keep old field for now + cash_balance: 500, # Keep old field for now + start_balance: 0, + start_cash_balance: 0, + start_non_cash_balance: 0, + cash_inflows: 500, + cash_outflows: 0, + non_cash_inflows: 0, + non_cash_outflows: 0, + net_market_flows: 0, + cash_adjustments: 0, + non_cash_adjustments: 0, currency: "USD" ) @investment.balances.create!( date: @test_period_start + 1.day, - balance: 500, - cash_balance: 500, + balance: 500, # Keep old field for now + cash_balance: 500, # Keep old field for now + start_balance: 500, + start_cash_balance: 500, + start_non_cash_balance: 0, + cash_inflows: 0, + cash_outflows: 0, + non_cash_inflows: 0, + non_cash_outflows: 0, + net_market_flows: 0, + cash_adjustments: 0, + non_cash_adjustments: 0, currency: "USD" ) @investment.balances.create!( date: @test_period_start + 2.days, - balance: 1900, # 1500 holdings + 400 cash - cash_balance: 400, # After -100 EUR transaction + balance: 1900, # Keep old field for now + cash_balance: 400, # Keep old field for now + start_balance: 500, + start_cash_balance: 500, + start_non_cash_balance: 0, + cash_inflows: 0, + cash_outflows: 100, + non_cash_inflows: 1500, + non_cash_outflows: 0, + net_market_flows: 0, + cash_adjustments: 0, + non_cash_adjustments: 0, currency: "USD" ) diff --git a/test/models/account/reconciliation_manager_test.rb b/test/models/account/reconciliation_manager_test.rb index 794c2cc5..fe5257e9 100644 --- a/test/models/account/reconciliation_manager_test.rb +++ b/test/models/account/reconciliation_manager_test.rb @@ -1,18 +1,15 @@ require "test_helper" class Account::ReconciliationManagerTest < ActiveSupport::TestCase + include BalanceTestHelper + setup do @account = accounts(:investment) @manager = Account::ReconciliationManager.new(@account) end test "new reconciliation" do - @account.balances.create!( - date: Date.current, - balance: 1000, - cash_balance: 500, - currency: @account.currency - ) + create_balance(account: @account, date: Date.current, balance: 1000, cash_balance: 500) result = @manager.reconcile_balance(balance: 1200, date: Date.current) @@ -24,7 +21,7 @@ class Account::ReconciliationManagerTest < ActiveSupport::TestCase end test "updates existing reconciliation without date change" do - @account.balances.create!(date: Date.current, balance: 1000, cash_balance: 500, currency: @account.currency) + create_balance(account: @account, date: Date.current, balance: 1000, cash_balance: 500) # Existing reconciliation entry existing_entry = @account.entries.create!(name: "Test", amount: 1000, date: Date.current, entryable: Valuation.new(kind: "reconciliation"), currency: @account.currency) @@ -39,8 +36,8 @@ class Account::ReconciliationManagerTest < ActiveSupport::TestCase end test "updates existing reconciliation with date and amount change" do - @account.balances.create!(date: 5.days.ago, balance: 1000, cash_balance: 500, currency: @account.currency) - @account.balances.create!(date: Date.current, balance: 1200, cash_balance: 700, currency: @account.currency) + create_balance(account: @account, date: 5.days.ago, balance: 1000, cash_balance: 500) + create_balance(account: @account, date: Date.current, balance: 1200, cash_balance: 700) # Existing reconciliation entry (5 days ago) existing_entry = @account.entries.create!(name: "Test", amount: 1000, date: 5.days.ago, entryable: Valuation.new(kind: "reconciliation"), currency: @account.currency) @@ -63,12 +60,7 @@ class Account::ReconciliationManagerTest < ActiveSupport::TestCase end test "handles date conflicts" do - @account.balances.create!( - date: Date.current, - balance: 1000, - cash_balance: 1000, - currency: @account.currency - ) + create_balance(account: @account, date: Date.current, balance: 1000, cash_balance: 1000) # Existing reconciliation entry @account.entries.create!( @@ -89,7 +81,7 @@ class Account::ReconciliationManagerTest < ActiveSupport::TestCase end test "dry run does not persist account" do - @account.balances.create!(date: Date.current, balance: 1000, cash_balance: 500, currency: @account.currency) + create_balance(account: @account, date: Date.current, balance: 1000, cash_balance: 500) assert_no_difference "Valuation.count" do @manager.reconcile_balance(balance: 1200, date: Date.current, dry_run: true) diff --git a/test/models/balance/chart_series_builder_test.rb b/test/models/balance/chart_series_builder_test.rb index 80d2467f..fbf5019f 100644 --- a/test/models/balance/chart_series_builder_test.rb +++ b/test/models/balance/chart_series_builder_test.rb @@ -1,6 +1,8 @@ require "test_helper" class Balance::ChartSeriesBuilderTest < ActiveSupport::TestCase + include BalanceTestHelper + setup do end @@ -9,9 +11,9 @@ class Balance::ChartSeriesBuilderTest < ActiveSupport::TestCase account.balances.destroy_all # With gaps - account.balances.create!(date: 3.days.ago.to_date, balance: 1000, currency: "USD") - account.balances.create!(date: 1.day.ago.to_date, balance: 1100, currency: "USD") - account.balances.create!(date: Date.current, balance: 1200, currency: "USD") + create_balance(account: account, date: 3.days.ago.to_date, balance: 1000) + create_balance(account: account, date: 1.day.ago.to_date, balance: 1100) + create_balance(account: account, date: Date.current, balance: 1200) builder = Balance::ChartSeriesBuilder.new( account_ids: [ account.id ], @@ -38,9 +40,9 @@ class Balance::ChartSeriesBuilderTest < ActiveSupport::TestCase account = accounts(:depository) account.balances.destroy_all - account.balances.create!(date: 2.days.ago.to_date, balance: 1000, currency: "USD") - account.balances.create!(date: 1.day.ago.to_date, balance: 1100, currency: "USD") - account.balances.create!(date: Date.current, balance: 1200, currency: "USD") + create_balance(account: account, date: 2.days.ago.to_date, balance: 1000) + create_balance(account: account, date: 1.day.ago.to_date, balance: 1100) + create_balance(account: account, date: Date.current, balance: 1200) builder = Balance::ChartSeriesBuilder.new( account_ids: [ account.id ], @@ -68,13 +70,13 @@ class Balance::ChartSeriesBuilderTest < ActiveSupport::TestCase Balance.destroy_all - asset_account.balances.create!(date: 3.days.ago.to_date, balance: 500, currency: "USD") - asset_account.balances.create!(date: 1.day.ago.to_date, balance: 1000, currency: "USD") - asset_account.balances.create!(date: Date.current, balance: 1000, currency: "USD") + create_balance(account: asset_account, date: 3.days.ago.to_date, balance: 500) + create_balance(account: asset_account, date: 1.day.ago.to_date, balance: 1000) + create_balance(account: asset_account, date: Date.current, balance: 1000) - liability_account.balances.create!(date: 3.days.ago.to_date, balance: 200, currency: "USD") - liability_account.balances.create!(date: 2.days.ago.to_date, balance: 200, currency: "USD") - liability_account.balances.create!(date: Date.current, balance: 100, currency: "USD") + create_balance(account: liability_account, date: 3.days.ago.to_date, balance: 200) + create_balance(account: liability_account, date: 2.days.ago.to_date, balance: 200) + create_balance(account: liability_account, date: Date.current, balance: 100) builder = Balance::ChartSeriesBuilder.new( account_ids: [ asset_account.id, liability_account.id ], @@ -98,8 +100,8 @@ class Balance::ChartSeriesBuilderTest < ActiveSupport::TestCase account = accounts(:credit_card) account.balances.destroy_all - account.balances.create!(date: 1.day.ago.to_date, balance: 1000, currency: "USD") - account.balances.create!(date: Date.current, balance: 500, currency: "USD") + create_balance(account: account, date: 1.day.ago.to_date, balance: 1000) + create_balance(account: account, date: Date.current, balance: 500) builder = Balance::ChartSeriesBuilder.new( account_ids: [ account.id ], diff --git a/test/models/balance/forward_calculator_test.rb b/test/models/balance/forward_calculator_test.rb index 2a65ae19..b2462cb5 100644 --- a/test/models/balance/forward_calculator_test.rb +++ b/test/models/balance/forward_calculator_test.rb @@ -117,9 +117,9 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase { date: 3.days.ago.to_date, legacy_balances: { balance: 17000, cash_balance: 17000 }, - balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 17000, end_non_cash: 0, end: 17000 }, + balances: { start: 17000, start_cash: 17000, start_non_cash: 0, end_cash: 17000, end_non_cash: 0, end: 17000 }, flows: 0, - adjustments: { cash_adjustments: 17000, non_cash_adjustments: 0 } + adjustments: 0 }, { date: 2.days.ago.to_date, @@ -151,9 +151,9 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase { date: 3.days.ago.to_date, legacy_balances: { balance: 17000, cash_balance: 0.0 }, - balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 0, end_non_cash: 17000, end: 17000 }, + balances: { start: 17000, start_cash: 0, start_non_cash: 17000, end_cash: 0, end_non_cash: 17000, end: 17000 }, flows: 0, - adjustments: { cash_adjustments: 0, non_cash_adjustments: 17000 } + adjustments: 0 }, { date: 2.days.ago.to_date, @@ -185,9 +185,9 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase { date: 3.days.ago.to_date, legacy_balances: { balance: 17000, cash_balance: 17000 }, - balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 17000, end_non_cash: 0, end: 17000 }, + balances: { start: 17000, start_cash: 17000, start_non_cash: 0, end_cash: 17000, end_non_cash: 0, end: 17000 }, flows: { market_flows: 0 }, - adjustments: { cash_adjustments: 17000, non_cash_adjustments: 0 } # Since no holdings present, adjustment is all cash + adjustments: 0 }, { date: 2.days.ago.to_date, @@ -222,9 +222,9 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase { date: 5.days.ago.to_date, legacy_balances: { balance: 20000, cash_balance: 20000 }, - balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 }, + balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 }, flows: 0, - adjustments: { cash_adjustments: 20000, non_cash_adjustments: 0 } + adjustments: 0 }, { date: 4.days.ago.to_date, @@ -270,9 +270,9 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase { date: 5.days.ago.to_date, legacy_balances: { balance: 1000, cash_balance: 1000 }, - balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 1000, end_non_cash: 0, end: 1000 }, + balances: { start: 1000, start_cash: 1000, start_non_cash: 0, end_cash: 1000, end_non_cash: 0, end: 1000 }, flows: 0, - adjustments: { cash_adjustments: 1000, non_cash_adjustments: 0 } + adjustments: 0 }, { date: 4.days.ago.to_date, @@ -318,9 +318,9 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase { date: 4.days.ago.to_date, legacy_balances: { balance: 20000, cash_balance: 20000 }, - balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 }, + balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 }, flows: 0, - adjustments: { cash_adjustments: 20000, non_cash_adjustments: 0 } + adjustments: 0 }, { date: 3.days.ago.to_date, @@ -370,9 +370,9 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase { date: 4.days.ago.to_date, legacy_balances: { balance: 100, cash_balance: 100 }, - balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 100, end_non_cash: 0, end: 100 }, + balances: { start: 100, start_cash: 100, start_non_cash: 0, end_cash: 100, end_non_cash: 0, end: 100 }, flows: 0, - adjustments: { cash_adjustments: 100, non_cash_adjustments: 0 } + adjustments: 0 }, { date: 3.days.ago.to_date, @@ -420,9 +420,9 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase { date: 2.days.ago.to_date, legacy_balances: { balance: 20000, cash_balance: 0 }, - balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 0, end_non_cash: 20000, end: 20000 }, + balances: { start: 20000, start_cash: 0, start_non_cash: 20000, end_cash: 0, end_non_cash: 20000, end: 20000 }, flows: 0, - adjustments: { cash_adjustments: 0, non_cash_adjustments: 20000 } # Valuations adjust non-cash balance for non-cash accounts like Loans + adjustments: 0 }, { date: 1.day.ago.to_date, @@ -455,9 +455,9 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase { date: 3.days.ago.to_date, legacy_balances: { balance: 500000, cash_balance: 0 }, - balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 0, end_non_cash: 500000, end: 500000 }, + balances: { start: 500000, start_cash: 0, start_non_cash: 500000, end_cash: 0, end_non_cash: 500000, end: 500000 }, flows: 0, - adjustments: { cash_adjustments: 0, non_cash_adjustments: 500000 } + adjustments: 0 }, { date: 2.days.ago.to_date, @@ -505,9 +505,9 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase { date: 3.days.ago.to_date, legacy_balances: { balance: 5000, cash_balance: 5000 }, - balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 5000, end_non_cash: 0, end: 5000 }, + balances: { start: 5000, start_cash: 5000, start_non_cash: 0, end_cash: 5000, end_non_cash: 0, end: 5000 }, flows: 0, - adjustments: { cash_adjustments: 5000, non_cash_adjustments: 0 } + adjustments: 0 }, { date: 2.days.ago.to_date, @@ -534,6 +534,53 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase ) end + test "investment account can have valuations that override balance" do + account = create_account_with_ledger( + account: { type: Investment, currency: "USD" }, + entries: [ + { type: "opening_anchor", date: 2.days.ago.to_date, balance: 5000 }, + { type: "reconciliation", date: 1.day.ago.to_date, balance: 10000 } + ], + holdings: [ + { date: 3.days.ago.to_date, ticker: "AAPL", qty: 10, price: 100, amount: 1000 }, + { date: 2.days.ago.to_date, ticker: "AAPL", qty: 10, price: 100, amount: 1000 }, + { date: 1.day.ago.to_date, ticker: "AAPL", qty: 10, price: 110, amount: 1100 }, + { date: Date.current, ticker: "AAPL", qty: 10, price: 120, amount: 1200 } + ] + ) + + # Given constant prices, overall balance (account value) should be constant + # (the single trade doesn't affect balance; it just alters cash vs. holdings composition) + calculated = Balance::ForwardCalculator.new(account).calculate + + assert_calculated_ledger_balances( + calculated_data: calculated, + expected_data: [ + { + date: 2.days.ago.to_date, + legacy_balances: { balance: 5000, cash_balance: 4000 }, + balances: { start: 5000, start_cash: 4000, start_non_cash: 1000, end_cash: 4000, end_non_cash: 1000, end: 5000 }, + flows: 0, + adjustments: 0 + }, + { + date: 1.day.ago.to_date, + legacy_balances: { balance: 10000, cash_balance: 8900 }, + balances: { start: 5000, start_cash: 4000, start_non_cash: 1000, end_cash: 8900, end_non_cash: 1100, end: 10000 }, + flows: { net_market_flows: 100 }, + adjustments: { cash_adjustments: 4900, non_cash_adjustments: 0 } + }, + { + date: Date.current, + legacy_balances: { balance: 10100, cash_balance: 8900 }, + balances: { start: 10000, start_cash: 8900, start_non_cash: 1100, end_cash: 8900, end_non_cash: 1200, end: 10100 }, + flows: { net_market_flows: 100 }, + adjustments: 0 + } + ] + ) + end + private def assert_balances(calculated_data:, expected_balances:) # Sort calculated data by date to ensure consistent ordering diff --git a/test/models/balance/materializer_test.rb b/test/models/balance/materializer_test.rb index 4a5ac439..01d34769 100644 --- a/test/models/balance/materializer_test.rb +++ b/test/models/balance/materializer_test.rb @@ -2,6 +2,7 @@ require "test_helper" class Balance::MaterializerTest < ActiveSupport::TestCase include EntriesTestHelper + include BalanceTestHelper setup do @account = families(:empty).accounts.create!( @@ -16,36 +17,143 @@ class Balance::MaterializerTest < ActiveSupport::TestCase test "syncs balances" do Holding::Materializer.any_instance.expects(:materialize_holdings).returns([]).once - @account.expects(:start_date).returns(2.days.ago.to_date) + expected_balances = [ + Balance.new( + date: 1.day.ago.to_date, + balance: 1000, + cash_balance: 1000, + currency: "USD", + start_cash_balance: 500, + start_non_cash_balance: 0, + cash_inflows: 500, + cash_outflows: 0, + non_cash_inflows: 0, + non_cash_outflows: 0, + net_market_flows: 0, + cash_adjustments: 0, + non_cash_adjustments: 0, + flows_factor: 1 + ), + Balance.new( + date: Date.current, + balance: 1000, + cash_balance: 1000, + currency: "USD", + start_cash_balance: 1000, + start_non_cash_balance: 0, + cash_inflows: 0, + cash_outflows: 0, + non_cash_inflows: 0, + non_cash_outflows: 0, + net_market_flows: 0, + cash_adjustments: 0, + non_cash_adjustments: 0, + flows_factor: 1 + ) + ] - Balance::ForwardCalculator.any_instance.expects(:calculate).returns( - [ - Balance.new(date: 1.day.ago.to_date, balance: 1000, cash_balance: 1000, currency: "USD"), - Balance.new(date: Date.current, balance: 1000, cash_balance: 1000, currency: "USD") - ] - ) + Balance::ForwardCalculator.any_instance.expects(:calculate).returns(expected_balances) assert_difference "@account.balances.count", 2 do Balance::Materializer.new(@account, strategy: :forward).materialize_balances end + + assert_balance_fields_persisted(expected_balances) end - test "purges stale balances and holdings" do - # Balance before start date is stale - @account.expects(:start_date).returns(2.days.ago.to_date).twice - stale_balance = Balance.new(date: 3.days.ago.to_date, balance: 10000, cash_balance: 10000, currency: "USD") + test "purges stale balances outside calculated range" do + # Create existing balances that will be stale + stale_old = create_balance(account: @account, date: 5.days.ago.to_date, balance: 5000) + stale_future = create_balance(account: @account, date: 2.days.from_now.to_date, balance: 15000) - Balance::ForwardCalculator.any_instance.expects(:calculate).returns( - [ - stale_balance, - Balance.new(date: 2.days.ago.to_date, balance: 10000, cash_balance: 10000, currency: "USD"), - Balance.new(date: 1.day.ago.to_date, balance: 1000, cash_balance: 1000, currency: "USD"), - Balance.new(date: Date.current, balance: 1000, cash_balance: 1000, currency: "USD") - ] - ) + # Calculator will return balances for only these dates + expected_balances = [ + Balance.new( + date: 2.days.ago.to_date, + balance: 10000, + cash_balance: 10000, + currency: "USD", + start_cash_balance: 10000, + start_non_cash_balance: 0, + cash_inflows: 0, + cash_outflows: 0, + non_cash_inflows: 0, + non_cash_outflows: 0, + net_market_flows: 0, + cash_adjustments: 0, + non_cash_adjustments: 0, + flows_factor: 1 + ), + Balance.new( + date: 1.day.ago.to_date, + balance: 1000, + cash_balance: 1000, + currency: "USD", + start_cash_balance: 10000, + start_non_cash_balance: 0, + cash_inflows: 0, + cash_outflows: 9000, + non_cash_inflows: 0, + non_cash_outflows: 0, + net_market_flows: 0, + cash_adjustments: 0, + non_cash_adjustments: 0, + flows_factor: 1 + ), + Balance.new( + date: Date.current, + balance: 1000, + cash_balance: 1000, + currency: "USD", + start_cash_balance: 1000, + start_non_cash_balance: 0, + cash_inflows: 0, + cash_outflows: 0, + non_cash_inflows: 0, + non_cash_outflows: 0, + net_market_flows: 0, + cash_adjustments: 0, + non_cash_adjustments: 0, + flows_factor: 1 + ) + ] - assert_difference "@account.balances.count", 3 do + Balance::ForwardCalculator.any_instance.expects(:calculate).returns(expected_balances) + Holding::Materializer.any_instance.expects(:materialize_holdings).returns([]).once + + # Should end up with 3 balances (stale ones deleted, new ones created) + assert_difference "@account.balances.count", 1 do Balance::Materializer.new(@account, strategy: :forward).materialize_balances end + + # Verify stale balances were deleted + assert_nil @account.balances.find_by(id: stale_old.id) + assert_nil @account.balances.find_by(id: stale_future.id) + + # Verify expected balances were persisted + assert_balance_fields_persisted(expected_balances) end + + private + + def assert_balance_fields_persisted(expected_balances) + expected_balances.each do |expected| + persisted = @account.balances.find_by(date: expected.date) + assert_not_nil persisted, "Balance for #{expected.date} should be persisted" + + # Check all balance component fields + assert_equal expected.balance, persisted.balance + assert_equal expected.cash_balance, persisted.cash_balance + assert_equal expected.start_cash_balance, persisted.start_cash_balance + assert_equal expected.start_non_cash_balance, persisted.start_non_cash_balance + assert_equal expected.cash_inflows, persisted.cash_inflows + assert_equal expected.cash_outflows, persisted.cash_outflows + assert_equal expected.non_cash_inflows, persisted.non_cash_inflows + assert_equal expected.non_cash_outflows, persisted.non_cash_outflows + assert_equal expected.net_market_flows, persisted.net_market_flows + assert_equal expected.cash_adjustments, persisted.cash_adjustments + assert_equal expected.non_cash_adjustments, persisted.non_cash_adjustments + assert_equal expected.flows_factor, persisted.flows_factor + end + end end diff --git a/test/support/balance_test_helper.rb b/test/support/balance_test_helper.rb new file mode 100644 index 00000000..e9eed0d7 --- /dev/null +++ b/test/support/balance_test_helper.rb @@ -0,0 +1,72 @@ +module BalanceTestHelper + def create_balance(account:, date:, balance:, cash_balance: nil, **attributes) + # If cash_balance is not provided, default to entire balance being cash + cash_balance ||= balance + + # Calculate non-cash balance + non_cash_balance = balance - cash_balance + + # Set default component values that will generate the desired end_balance + # flows_factor should be 1 for assets, -1 for liabilities + flows_factor = account.classification == "liability" ? -1 : 1 + + defaults = { + date: date, + balance: balance, + cash_balance: cash_balance, + currency: account.currency, + start_cash_balance: cash_balance, + start_non_cash_balance: non_cash_balance, + cash_inflows: 0, + cash_outflows: 0, + non_cash_inflows: 0, + non_cash_outflows: 0, + net_market_flows: 0, + cash_adjustments: 0, + non_cash_adjustments: 0, + flows_factor: flows_factor + } + + account.balances.create!(defaults.merge(attributes)) + end + + def create_balance_with_flows(account:, date:, start_balance:, end_balance:, + cash_portion: 1.0, cash_flow: 0, non_cash_flow: 0, + market_flow: 0, **attributes) + # Calculate cash and non-cash portions + start_cash = start_balance * cash_portion + start_non_cash = start_balance * (1 - cash_portion) + + # Calculate adjustments needed to reach end_balance + expected_end_cash = start_cash + cash_flow + expected_end_non_cash = start_non_cash + non_cash_flow + market_flow + expected_total = expected_end_cash + expected_end_non_cash + + # Calculate adjustments if end_balance doesn't match expected + total_adjustment = end_balance - expected_total + cash_adjustment = cash_portion * total_adjustment + non_cash_adjustment = (1 - cash_portion) * total_adjustment + + # flows_factor should be 1 for assets, -1 for liabilities + flows_factor = account.classification == "liability" ? -1 : 1 + + defaults = { + date: date, + balance: end_balance, + cash_balance: expected_end_cash + cash_adjustment, + currency: account.currency, + start_cash_balance: start_cash, + start_non_cash_balance: start_non_cash, + cash_inflows: cash_flow > 0 ? cash_flow : 0, + cash_outflows: cash_flow < 0 ? -cash_flow : 0, + non_cash_inflows: non_cash_flow > 0 ? non_cash_flow : 0, + non_cash_outflows: non_cash_flow < 0 ? -non_cash_flow : 0, + net_market_flows: market_flow, + cash_adjustments: cash_adjustment, + non_cash_adjustments: non_cash_adjustment, + flows_factor: flows_factor + } + + account.balances.create!(defaults.merge(attributes)) + end +end From 32ec57146e32b773ba57d515e0dad05646958c0c Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Wed, 23 Jul 2025 18:21:37 -0400 Subject: [PATCH 05/20] Fix form submission triggers (#2512) --- .../auto_submit_form_controller.js | 50 +++++++++++++++++-- 1 file changed, 46 insertions(+), 4 deletions(-) diff --git a/app/javascript/controllers/auto_submit_form_controller.js b/app/javascript/controllers/auto_submit_form_controller.js index 18744c4d..cf4be4e8 100644 --- a/app/javascript/controllers/auto_submit_form_controller.js +++ b/app/javascript/controllers/auto_submit_form_controller.js @@ -10,16 +10,14 @@ export default class extends Controller { connect() { this.autoTargets.forEach((element) => { - const event = - element.dataset.autosubmitTriggerEvent || this.triggerEventValue; + const event = this.#getTriggerEvent(element); element.addEventListener(event, this.handleInput); }); } disconnect() { this.autoTargets.forEach((element) => { - const event = - element.dataset.autosubmitTriggerEvent || this.triggerEventValue; + const event = this.#getTriggerEvent(element); element.removeEventListener(event, this.handleInput); }); } @@ -33,6 +31,50 @@ export default class extends Controller { }, this.#debounceTimeout(target)); }; + #getTriggerEvent(element) { + // Check if element has explicit trigger event set + if (element.dataset.autosubmitTriggerEvent) { + return element.dataset.autosubmitTriggerEvent; + } + + // Check if form has explicit trigger event set + if (this.triggerEventValue !== "input") { + return this.triggerEventValue; + } + + // Otherwise, choose trigger event based on element type + const type = element.type || element.tagName; + + switch (type.toLowerCase()) { + case "text": + case "email": + case "password": + case "search": + case "tel": + case "url": + case "textarea": + return "blur"; + case "number": + case "date": + case "datetime-local": + case "month": + case "time": + case "week": + case "color": + return "change"; + case "checkbox": + case "radio": + case "select": + case "select-one": + case "select-multiple": + return "change"; + case "range": + return "input"; + default: + return "blur"; + } + } + #debounceTimeout(element) { if (element.dataset.autosubmitDebounceTimeout) { return Number.parseInt(element.dataset.autosubmitDebounceTimeout); From 527a6128b64d4a2fccfd937aaa9bcd7f733e98e3 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Wed, 23 Jul 2025 18:26:04 -0400 Subject: [PATCH 06/20] Fix budget navigation to allow selecting previous months - Allow going back 2 years minimum even without entries - Update oldest_valid_budget_date to use min of entry date or 2 years ago - Add comprehensive tests for budget date validation - Fixes issue where users couldn't select prior budget months --- app/models/budget.rb | 5 ++- test/models/budget_test.rb | 88 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 1 deletion(-) create mode 100644 test/models/budget_test.rb diff --git a/app/models/budget.rb b/app/models/budget.rb index f8cb991c..acdd128d 100644 --- a/app/models/budget.rb +++ b/app/models/budget.rb @@ -49,7 +49,10 @@ class Budget < ApplicationRecord private def oldest_valid_budget_date(family) - @oldest_valid_budget_date ||= family.oldest_entry_date.beginning_of_month + # Allow going back to either the earliest entry date OR 2 years ago, whichever is earlier + two_years_ago = 2.years.ago.beginning_of_month + oldest_entry_date = family.oldest_entry_date.beginning_of_month + [ two_years_ago, oldest_entry_date ].min end end diff --git a/test/models/budget_test.rb b/test/models/budget_test.rb new file mode 100644 index 00000000..cff5cd6c --- /dev/null +++ b/test/models/budget_test.rb @@ -0,0 +1,88 @@ +require "test_helper" + +class BudgetTest < ActiveSupport::TestCase + setup do + @family = families(:empty) + end + + test "budget_date_valid? allows going back 2 years even without entries" do + two_years_ago = 2.years.ago.beginning_of_month + assert Budget.budget_date_valid?(two_years_ago, family: @family) + end + + test "budget_date_valid? allows going back to earliest entry date if more than 2 years ago" do + # Create an entry 3 years ago + old_account = Account.create!( + family: @family, + accountable: Depository.new, + name: "Old Account", + status: "active", + currency: "USD", + balance: 1000 + ) + + old_entry = Entry.create!( + account: old_account, + entryable: Transaction.new(category: categories(:income)), + date: 3.years.ago, + name: "Old Transaction", + amount: 100, + currency: "USD" + ) + + # Should allow going back to the old entry date + assert Budget.budget_date_valid?(3.years.ago.beginning_of_month, family: @family) + end + + test "budget_date_valid? does not allow dates before earliest entry or 2 years ago" do + # Create an entry 1 year ago + account = Account.create!( + family: @family, + accountable: Depository.new, + name: "Test Account", + status: "active", + currency: "USD", + balance: 500 + ) + + Entry.create!( + account: account, + entryable: Transaction.new(category: categories(:income)), + date: 1.year.ago, + name: "Recent Transaction", + amount: 100, + currency: "USD" + ) + + # Should not allow going back more than 2 years + refute Budget.budget_date_valid?(3.years.ago.beginning_of_month, family: @family) + end + + test "budget_date_valid? does not allow future dates beyond current month" do + refute Budget.budget_date_valid?(2.months.from_now, family: @family) + end + + test "previous_budget_param returns nil when date is too old" do + # Create a budget at the oldest allowed date + two_years_ago = 2.years.ago.beginning_of_month + budget = Budget.create!( + family: @family, + start_date: two_years_ago, + end_date: two_years_ago.end_of_month, + currency: "USD" + ) + + assert_nil budget.previous_budget_param + end + + test "previous_budget_param returns param when date is valid" do + budget = Budget.create!( + family: @family, + start_date: Date.current.beginning_of_month, + end_date: Date.current.end_of_month, + currency: "USD" + ) + + assert_not_nil budget.previous_budget_param + end +end From ef49268278d17416691f73c0422b6f2ad7f1b6f9 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Wed, 23 Jul 2025 18:37:05 -0400 Subject: [PATCH 07/20] [claudesquad] update from 'totals-rounding-and-sum' on 23 Jul 25 18:30 EDT (#2514) --- app/models/transaction/search.rb | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/models/transaction/search.rb b/app/models/transaction/search.rb index 2fc17182..ce388754 100644 --- a/app/models/transaction/search.rb +++ b/app/models/transaction/search.rb @@ -47,8 +47,8 @@ class Transaction::Search Rails.cache.fetch("transaction_search_totals/#{cache_key_base}") do result = transactions_scope .select( - "COALESCE(SUM(CASE WHEN entries.amount >= 0 THEN ABS(entries.amount * COALESCE(er.rate, 1)) ELSE 0 END), 0) as expense_total", - "COALESCE(SUM(CASE WHEN entries.amount < 0 THEN ABS(entries.amount * COALESCE(er.rate, 1)) ELSE 0 END), 0) as income_total", + "COALESCE(SUM(CASE WHEN entries.amount >= 0 AND transactions.kind NOT IN ('funds_movement', 'cc_payment') THEN ABS(entries.amount * COALESCE(er.rate, 1)) ELSE 0 END), 0) as expense_total", + "COALESCE(SUM(CASE WHEN entries.amount < 0 AND transactions.kind NOT IN ('funds_movement', 'cc_payment') THEN ABS(entries.amount * COALESCE(er.rate, 1)) ELSE 0 END), 0) as income_total", "COUNT(entries.id) as transactions_count" ) .joins( @@ -61,8 +61,8 @@ class Transaction::Search Totals.new( count: result.transactions_count.to_i, - income_money: Money.new(result.income_total.to_i, family.currency), - expense_money: Money.new(result.expense_total.to_i, family.currency) + income_money: Money.new(result.income_total.round, family.currency), + expense_money: Money.new(result.expense_total.round, family.currency) ) end end From 764164cf57ddfb191814135c983e163686e171b2 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Wed, 23 Jul 2025 18:37:31 -0400 Subject: [PATCH 08/20] [claudesquad] update from 'transaction-page-filter-tweaks' on 23 Jul 25 18:27 EDT (#2513) --- app/controllers/transactions_controller.rb | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/app/controllers/transactions_controller.rb b/app/controllers/transactions_controller.rb index 9243df84..707fa5d4 100644 --- a/app/controllers/transactions_controller.rb +++ b/app/controllers/transactions_controller.rb @@ -110,7 +110,7 @@ class TransactionsController < ApplicationController private def per_page - params[:per_page].to_i.positive? ? params[:per_page].to_i : 50 + params[:per_page].to_i.positive? ? params[:per_page].to_i : 20 end def needs_rule_notification?(transaction) @@ -154,10 +154,6 @@ class TransactionsController < ApplicationController cleaned_params.delete(:amount_operator) unless cleaned_params[:amount].present? - # Only add default start_date if params are blank AND filters weren't explicitly cleared - if cleaned_params.blank? && params[:filter_cleared].blank? - cleaned_params[:start_date] = 30.days.ago.to_date - end cleaned_params end From b7c56e2fb7175f8eb9be1b7c85dd325b8b96f08f Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Wed, 23 Jul 2025 20:00:32 -0400 Subject: [PATCH 09/20] Test fixes --- test/controllers/transactions_controller_test.rb | 3 +-- test/system/transactions_test.rb | 1 + 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/test/controllers/transactions_controller_test.rb b/test/controllers/transactions_controller_test.rb index 2500615c..c2cc94b7 100644 --- a/test/controllers/transactions_controller_test.rb +++ b/test/controllers/transactions_controller_test.rb @@ -162,8 +162,7 @@ end income_money: Money.new(0, "USD") ) - expected_filters = { "start_date" => 30.days.ago.to_date } - Transaction::Search.expects(:new).with(family, filters: expected_filters).returns(search) + Transaction::Search.expects(:new).with(family, filters: {}).returns(search) search.expects(:totals).once.returns(totals) get transactions_url diff --git a/test/system/transactions_test.rb b/test/system/transactions_test.rb index be1f9b54..632f6076 100644 --- a/test/system/transactions_test.rb +++ b/test/system/transactions_test.rb @@ -34,6 +34,7 @@ class TransactionsTest < ApplicationSystemTestCase within "form#transactions-search" do fill_in "Search transactions ...", with: @transaction.name + find("#q_search").send_keys(:tab) # Trigger blur to submit form end assert_selector "#" + dom_id(@transaction), count: 1 From 0329a5f211fac5fb2b2d2383fbaa571880f5caee Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Thu, 24 Jul 2025 10:50:05 -0400 Subject: [PATCH 10/20] Data exports (#2517) * Import / export UI * Data exports * Lint fixes, brakeman update --- Gemfile | 1 + Gemfile.lock | 1 + app/controllers/family_exports_controller.rb | 47 ++++ app/jobs/family_data_export_job.rb | 22 ++ app/models/family.rb | 1 + app/models/family/data_exporter.rb | 238 ++++++++++++++++++ app/models/family_export.rb | 22 ++ app/views/family_exports/_list.html.erb | 39 +++ app/views/family_exports/index.html.erb | 1 + app/views/family_exports/new.html.erb | 42 ++++ app/views/settings/profiles/show.html.erb | 23 ++ config/brakeman.ignore | 25 +- config/routes.rb | 6 + .../20250724115507_create_family_exports.rb | 10 + db/schema.rb | 11 +- .../family_exports_controller_test.rb | 73 ++++++ test/fixtures/family_exports.yml | 3 + test/jobs/family_data_export_job_test.rb | 32 +++ test/models/family/data_exporter_test.rb | 115 +++++++++ test/models/family_export_test.rb | 7 + 20 files changed, 717 insertions(+), 2 deletions(-) create mode 100644 app/controllers/family_exports_controller.rb create mode 100644 app/jobs/family_data_export_job.rb create mode 100644 app/models/family/data_exporter.rb create mode 100644 app/models/family_export.rb create mode 100644 app/views/family_exports/_list.html.erb create mode 100644 app/views/family_exports/index.html.erb create mode 100644 app/views/family_exports/new.html.erb create mode 100644 db/migrate/20250724115507_create_family_exports.rb create mode 100644 test/controllers/family_exports_controller_test.rb create mode 100644 test/fixtures/family_exports.yml create mode 100644 test/jobs/family_data_export_job_test.rb create mode 100644 test/models/family/data_exporter_test.rb create mode 100644 test/models/family_export_test.rb diff --git a/Gemfile b/Gemfile index 4f1a83c7..33520c13 100644 --- a/Gemfile +++ b/Gemfile @@ -72,6 +72,7 @@ gem "plaid" gem "rotp", "~> 6.3" gem "rqrcode", "~> 3.0" gem "activerecord-import" +gem "rubyzip", "~> 2.3" # State machines gem "aasm" diff --git a/Gemfile.lock b/Gemfile.lock index cacdf57a..33f91f79 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -672,6 +672,7 @@ DEPENDENCIES rubocop-rails-omakase ruby-lsp-rails ruby-openai + rubyzip (~> 2.3) selenium-webdriver sentry-rails sentry-ruby diff --git a/app/controllers/family_exports_controller.rb b/app/controllers/family_exports_controller.rb new file mode 100644 index 00000000..992d68b3 --- /dev/null +++ b/app/controllers/family_exports_controller.rb @@ -0,0 +1,47 @@ +class FamilyExportsController < ApplicationController + include StreamExtensions + + before_action :require_admin + before_action :set_export, only: [ :download ] + + def new + # Modal view for initiating export + end + + def create + @export = Current.family.family_exports.create! + FamilyDataExportJob.perform_later(@export) + + respond_to do |format| + format.html { redirect_to settings_profile_path, notice: "Export started. You'll be able to download it shortly." } + format.turbo_stream { + stream_redirect_to settings_profile_path, notice: "Export started. You'll be able to download it shortly." + } + end + end + + def index + @exports = Current.family.family_exports.ordered.limit(10) + render layout: false # For turbo frame + end + + def download + if @export.downloadable? + redirect_to @export.export_file, allow_other_host: true + else + redirect_to settings_profile_path, alert: "Export not ready for download" + end + end + + private + + def set_export + @export = Current.family.family_exports.find(params[:id]) + end + + def require_admin + unless Current.user.admin? + redirect_to root_path, alert: "Access denied" + end + end +end diff --git a/app/jobs/family_data_export_job.rb b/app/jobs/family_data_export_job.rb new file mode 100644 index 00000000..1b62bd66 --- /dev/null +++ b/app/jobs/family_data_export_job.rb @@ -0,0 +1,22 @@ +class FamilyDataExportJob < ApplicationJob + queue_as :default + + def perform(family_export) + family_export.update!(status: :processing) + + exporter = Family::DataExporter.new(family_export.family) + zip_file = exporter.generate_export + + family_export.export_file.attach( + io: zip_file, + filename: family_export.filename, + content_type: "application/zip" + ) + + family_export.update!(status: :completed) + rescue => e + Rails.logger.error "Family export failed: #{e.message}" + Rails.logger.error e.backtrace.join("\n") + family_export.update!(status: :failed) + end +end diff --git a/app/models/family.rb b/app/models/family.rb index 1f35488f..7ebec1c4 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -18,6 +18,7 @@ class Family < ApplicationRecord has_many :invitations, dependent: :destroy has_many :imports, dependent: :destroy + has_many :family_exports, dependent: :destroy has_many :entries, through: :accounts has_many :transactions, through: :accounts diff --git a/app/models/family/data_exporter.rb b/app/models/family/data_exporter.rb new file mode 100644 index 00000000..93546cd6 --- /dev/null +++ b/app/models/family/data_exporter.rb @@ -0,0 +1,238 @@ +require "zip" +require "csv" + +class Family::DataExporter + def initialize(family) + @family = family + end + + def generate_export + # Create a StringIO to hold the zip data in memory + zip_data = Zip::OutputStream.write_buffer do |zipfile| + # Add accounts.csv + zipfile.put_next_entry("accounts.csv") + zipfile.write generate_accounts_csv + + # Add transactions.csv + zipfile.put_next_entry("transactions.csv") + zipfile.write generate_transactions_csv + + # Add trades.csv + zipfile.put_next_entry("trades.csv") + zipfile.write generate_trades_csv + + # Add categories.csv + zipfile.put_next_entry("categories.csv") + zipfile.write generate_categories_csv + + # Add all.ndjson + zipfile.put_next_entry("all.ndjson") + zipfile.write generate_ndjson + end + + # Rewind and return the StringIO + zip_data.rewind + zip_data + end + + private + + def generate_accounts_csv + CSV.generate do |csv| + csv << [ "id", "name", "type", "subtype", "balance", "currency", "created_at" ] + + # Only export accounts belonging to this family + @family.accounts.includes(:accountable).find_each do |account| + csv << [ + account.id, + account.name, + account.accountable_type, + account.subtype, + account.balance.to_s, + account.currency, + account.created_at.iso8601 + ] + end + end + end + + def generate_transactions_csv + CSV.generate do |csv| + csv << [ "date", "account_name", "amount", "name", "category", "tags", "notes", "currency" ] + + # Only export transactions from accounts belonging to this family + @family.transactions + .includes(:category, :tags, entry: :account) + .find_each do |transaction| + csv << [ + transaction.entry.date.iso8601, + transaction.entry.account.name, + transaction.entry.amount.to_s, + transaction.entry.name, + transaction.category&.name, + transaction.tags.pluck(:name).join(","), + transaction.entry.notes, + transaction.entry.currency + ] + end + end + end + + def generate_trades_csv + CSV.generate do |csv| + csv << [ "date", "account_name", "ticker", "quantity", "price", "amount", "currency" ] + + # Only export trades from accounts belonging to this family + @family.trades + .includes(:security, entry: :account) + .find_each do |trade| + csv << [ + trade.entry.date.iso8601, + trade.entry.account.name, + trade.security.ticker, + trade.qty.to_s, + trade.price.to_s, + trade.entry.amount.to_s, + trade.currency + ] + end + end + end + + def generate_categories_csv + CSV.generate do |csv| + csv << [ "name", "color", "parent_category", "classification" ] + + # Only export categories belonging to this family + @family.categories.includes(:parent).find_each do |category| + csv << [ + category.name, + category.color, + category.parent&.name, + category.classification + ] + end + end + end + + def generate_ndjson + lines = [] + + # Export accounts with full accountable data + @family.accounts.includes(:accountable).find_each do |account| + lines << { + type: "Account", + data: account.as_json( + include: { + accountable: {} + } + ) + }.to_json + end + + # Export categories + @family.categories.find_each do |category| + lines << { + type: "Category", + data: category.as_json + }.to_json + end + + # Export tags + @family.tags.find_each do |tag| + lines << { + type: "Tag", + data: tag.as_json + }.to_json + end + + # Export merchants (only family merchants) + @family.merchants.find_each do |merchant| + lines << { + type: "Merchant", + data: merchant.as_json + }.to_json + end + + # Export transactions with full data + @family.transactions.includes(:category, :merchant, :tags, entry: :account).find_each do |transaction| + lines << { + type: "Transaction", + data: { + id: transaction.id, + entry_id: transaction.entry.id, + account_id: transaction.entry.account_id, + date: transaction.entry.date, + amount: transaction.entry.amount, + currency: transaction.entry.currency, + name: transaction.entry.name, + notes: transaction.entry.notes, + excluded: transaction.entry.excluded, + category_id: transaction.category_id, + merchant_id: transaction.merchant_id, + tag_ids: transaction.tag_ids, + kind: transaction.kind, + created_at: transaction.created_at, + updated_at: transaction.updated_at + } + }.to_json + end + + # Export trades with full data + @family.trades.includes(:security, entry: :account).find_each do |trade| + lines << { + type: "Trade", + data: { + id: trade.id, + entry_id: trade.entry.id, + account_id: trade.entry.account_id, + security_id: trade.security_id, + ticker: trade.security.ticker, + date: trade.entry.date, + qty: trade.qty, + price: trade.price, + amount: trade.entry.amount, + currency: trade.currency, + created_at: trade.created_at, + updated_at: trade.updated_at + } + }.to_json + end + + # Export valuations + @family.entries.valuations.includes(:account, :entryable).find_each do |entry| + lines << { + type: "Valuation", + data: { + id: entry.entryable.id, + entry_id: entry.id, + account_id: entry.account_id, + date: entry.date, + amount: entry.amount, + currency: entry.currency, + name: entry.name, + created_at: entry.created_at, + updated_at: entry.updated_at + } + }.to_json + end + + # Export budgets + @family.budgets.find_each do |budget| + lines << { + type: "Budget", + data: budget.as_json + }.to_json + end + + # Export budget categories + @family.budget_categories.includes(:budget, :category).find_each do |budget_category| + lines << { + type: "BudgetCategory", + data: budget_category.as_json + }.to_json + end + + lines.join("\n") + end +end diff --git a/app/models/family_export.rb b/app/models/family_export.rb new file mode 100644 index 00000000..292ab9e0 --- /dev/null +++ b/app/models/family_export.rb @@ -0,0 +1,22 @@ +class FamilyExport < ApplicationRecord + belongs_to :family + + has_one_attached :export_file + + enum :status, { + pending: "pending", + processing: "processing", + completed: "completed", + failed: "failed" + }, default: :pending, validate: true + + scope :ordered, -> { order(created_at: :desc) } + + def filename + "maybe_export_#{created_at.strftime('%Y%m%d_%H%M%S')}.zip" + end + + def downloadable? + completed? && export_file.attached? + end +end diff --git a/app/views/family_exports/_list.html.erb b/app/views/family_exports/_list.html.erb new file mode 100644 index 00000000..f4e979d8 --- /dev/null +++ b/app/views/family_exports/_list.html.erb @@ -0,0 +1,39 @@ +<%= turbo_frame_tag "family_exports", + data: exports.any? { |e| e.pending? || e.processing? } ? { + turbo_refresh_url: family_exports_path, + turbo_refresh_interval: 3000 + } : {} do %> +
+ <% if exports.any? %> + <% exports.each do |export| %> +
+
+

Export from <%= export.created_at.strftime("%B %d, %Y at %I:%M %p") %>

+

<%= export.filename %>

+
+ + <% if export.processing? || export.pending? %> +
+
+ Exporting... +
+ <% elsif export.completed? %> + <%= link_to download_family_export_path(export), + class: "flex items-center gap-2 text-primary hover:text-primary-hover", + data: { turbo_frame: "_top" } do %> + <%= icon "download", class: "w-5 h-5" %> + Download + <% end %> + <% elsif export.failed? %> +
+ <%= icon "alert-circle", class: "w-4 h-4" %> + Failed +
+ <% end %> +
+ <% end %> + <% else %> +

No exports yet

+ <% end %> +
+<% end %> diff --git a/app/views/family_exports/index.html.erb b/app/views/family_exports/index.html.erb new file mode 100644 index 00000000..530b8151 --- /dev/null +++ b/app/views/family_exports/index.html.erb @@ -0,0 +1 @@ +<%= render "list", exports: @exports %> diff --git a/app/views/family_exports/new.html.erb b/app/views/family_exports/new.html.erb new file mode 100644 index 00000000..5bf02352 --- /dev/null +++ b/app/views/family_exports/new.html.erb @@ -0,0 +1,42 @@ +<%= render DS::Dialog.new do |dialog| %> + <% dialog.with_header(title: "Export your data", subtitle: "Download all your financial data") %> + + <% dialog.with_body do %> +
+
+

What's included:

+
    +
  • + <%= icon "check", class: "shrink-0 mt-0.5 text-positive" %> + All accounts and balances +
  • +
  • + <%= icon "check", class: "shrink-0 mt-0.5 text-positive" %> + Transaction history +
  • +
  • + <%= icon "check", class: "shrink-0 mt-0.5 text-positive" %> + Investment trades +
  • +
  • + <%= icon "check", class: "shrink-0 mt-0.5 text-positive" %> + Categories and tags +
  • +
+
+ +
+

+ Note: This export includes all of your data, but only some of the data can be imported back into Maybe via the CSV import feature. We support account, transaction (with category and tags), and trade imports. Other account data cannot be imported and is for your records only. +

+
+ + <%= form_with url: family_exports_path, method: :post, class: "space-y-4" do |form| %> +
+ <%= link_to "Cancel", "#", class: "flex-1 text-center px-4 py-2 border border-primary rounded-lg hover:bg-surface-hover", data: { action: "click->modal#close" } %> + <%= form.submit "Export data", class: "flex-1 bg-inverse fg-inverse rounded-lg px-4 py-2 cursor-pointer" %> +
+ <% end %> +
+ <% end %> +<% end %> diff --git a/app/views/settings/profiles/show.html.erb b/app/views/settings/profiles/show.html.erb index e1f0d653..1dfe2c15 100644 --- a/app/views/settings/profiles/show.html.erb +++ b/app/views/settings/profiles/show.html.erb @@ -122,6 +122,29 @@
<% end %> +<% if Current.user.admin? %> + <%= settings_section title: "Data Import/Export" do %> +
+
+ <%= render DS::Link.new( + text: "Export data", + icon: "database", + href: new_family_export_path, + variant: "secondary", + full_width: true, + data: { turbo_frame: :modal } + ) %> +
+ + <%= turbo_frame_tag "family_exports", src: family_exports_path, loading: :lazy do %> +
+
+
+ <% end %> +
+ <% end %> +<% end %> + <%= settings_section title: t(".danger_zone_title") do %>
<% if Current.user.admin? %> diff --git a/config/brakeman.ignore b/config/brakeman.ignore index 5ca90443..05172fd2 100644 --- a/config/brakeman.ignore +++ b/config/brakeman.ignore @@ -1,5 +1,28 @@ { "ignored_warnings": [ + { + "warning_type": "Redirect", + "warning_code": 18, + "fingerprint": "723b1970ca6bf16ea0c2c1afa0c00d3c54854a16568d6cb933e497947565d9ab", + "check_name": "Redirect", + "message": "Possible unprotected redirect", + "file": "app/controllers/family_exports_controller.rb", + "line": 30, + "link": "https://brakemanscanner.org/docs/warning_types/redirect/", + "code": "redirect_to(Current.family.family_exports.find(params[:id]).export_file, :allow_other_host => true)", + "render_path": null, + "location": { + "type": "method", + "class": "FamilyExportsController", + "method": "download" + }, + "user_input": "Current.family.family_exports.find(params[:id]).export_file", + "confidence": "Weak", + "cwe_id": [ + 601 + ], + "note": "" + }, { "warning_type": "Mass Assignment", "warning_code": 105, @@ -105,5 +128,5 @@ "note": "" } ], - "brakeman_version": "7.0.2" + "brakeman_version": "7.1.0" } diff --git a/config/routes.rb b/config/routes.rb index e2817432..a44554d6 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -24,6 +24,12 @@ Rails.application.routes.draw do end end + resources :family_exports, only: %i[new create index] do + member do + get :download + end + end + get "changelog", to: "pages#changelog" get "feedback", to: "pages#feedback" diff --git a/db/migrate/20250724115507_create_family_exports.rb b/db/migrate/20250724115507_create_family_exports.rb new file mode 100644 index 00000000..d432d48d --- /dev/null +++ b/db/migrate/20250724115507_create_family_exports.rb @@ -0,0 +1,10 @@ +class CreateFamilyExports < ActiveRecord::Migration[7.2] + def change + create_table :family_exports, id: :uuid do |t| + t.references :family, null: false, foreign_key: true, type: :uuid + t.string :status, default: "pending", null: false + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 39f0eeb0..5984a8f2 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_19_121103) do +ActiveRecord::Schema[7.2].define(version: 2025_07_24_115507) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -270,6 +270,14 @@ ActiveRecord::Schema[7.2].define(version: 2025_07_19_121103) do t.datetime "latest_sync_completed_at", default: -> { "CURRENT_TIMESTAMP" } end + create_table "family_exports", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "family_id", null: false + t.string "status", default: "pending", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["family_id"], name: "index_family_exports_on_family_id" + end + create_table "holdings", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.uuid "account_id", null: false t.uuid "security_id", null: false @@ -830,6 +838,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_07_19_121103) do add_foreign_key "chats", "users" add_foreign_key "entries", "accounts" add_foreign_key "entries", "imports" + add_foreign_key "family_exports", "families" add_foreign_key "holdings", "accounts" add_foreign_key "holdings", "securities" add_foreign_key "impersonation_session_logs", "impersonation_sessions" diff --git a/test/controllers/family_exports_controller_test.rb b/test/controllers/family_exports_controller_test.rb new file mode 100644 index 00000000..63adf788 --- /dev/null +++ b/test/controllers/family_exports_controller_test.rb @@ -0,0 +1,73 @@ +require "test_helper" + +class FamilyExportsControllerTest < ActionDispatch::IntegrationTest + setup do + @admin = users(:family_admin) + @non_admin = users(:family_member) + @family = @admin.family + + sign_in @admin + end + + test "non-admin cannot access exports" do + sign_in @non_admin + + get new_family_export_path + assert_redirected_to root_path + + post family_exports_path + assert_redirected_to root_path + + get family_exports_path + assert_redirected_to root_path + end + + test "admin can view export modal" do + get new_family_export_path + assert_response :success + assert_select "h2", text: "Export your data" + end + + test "admin can create export" do + assert_enqueued_with(job: FamilyDataExportJob) do + post family_exports_path + end + + assert_redirected_to settings_profile_path + assert_equal "Export started. You'll be able to download it shortly.", flash[:notice] + + export = @family.family_exports.last + assert_equal "pending", export.status + end + + test "admin can view export list" do + export1 = @family.family_exports.create!(status: "completed") + export2 = @family.family_exports.create!(status: "processing") + + get family_exports_path + assert_response :success + + assert_match export1.filename, response.body + assert_match "Exporting...", response.body + end + + test "admin can download completed export" do + export = @family.family_exports.create!(status: "completed") + export.export_file.attach( + io: StringIO.new("test zip content"), + filename: "test.zip", + content_type: "application/zip" + ) + + get download_family_export_path(export) + assert_redirected_to(/rails\/active_storage/) + end + + test "cannot download incomplete export" do + export = @family.family_exports.create!(status: "processing") + + get download_family_export_path(export) + assert_redirected_to settings_profile_path + assert_equal "Export not ready for download", flash[:alert] + end +end diff --git a/test/fixtures/family_exports.yml b/test/fixtures/family_exports.yml new file mode 100644 index 00000000..4d09645e --- /dev/null +++ b/test/fixtures/family_exports.yml @@ -0,0 +1,3 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +# Empty file - no fixtures needed, tests create them dynamically diff --git a/test/jobs/family_data_export_job_test.rb b/test/jobs/family_data_export_job_test.rb new file mode 100644 index 00000000..6aae26e8 --- /dev/null +++ b/test/jobs/family_data_export_job_test.rb @@ -0,0 +1,32 @@ +require "test_helper" + +class FamilyDataExportJobTest < ActiveJob::TestCase + setup do + @family = families(:dylan_family) + @export = @family.family_exports.create! + end + + test "marks export as processing then completed" do + assert_equal "pending", @export.status + + perform_enqueued_jobs do + FamilyDataExportJob.perform_later(@export) + end + + @export.reload + assert_equal "completed", @export.status + assert @export.export_file.attached? + end + + test "marks export as failed on error" do + # Mock the exporter to raise an error + Family::DataExporter.any_instance.stubs(:generate_export).raises(StandardError, "Export failed") + + perform_enqueued_jobs do + FamilyDataExportJob.perform_later(@export) + end + + @export.reload + assert_equal "failed", @export.status + end +end diff --git a/test/models/family/data_exporter_test.rb b/test/models/family/data_exporter_test.rb new file mode 100644 index 00000000..fe56fb08 --- /dev/null +++ b/test/models/family/data_exporter_test.rb @@ -0,0 +1,115 @@ +require "test_helper" + +class Family::DataExporterTest < ActiveSupport::TestCase + setup do + @family = families(:dylan_family) + @other_family = families(:empty) + @exporter = Family::DataExporter.new(@family) + + # Create some test data for the family + @account = @family.accounts.create!( + name: "Test Account", + accountable: Depository.new, + balance: 1000, + currency: "USD" + ) + + @category = @family.categories.create!( + name: "Test Category", + color: "#FF0000" + ) + + @tag = @family.tags.create!( + name: "Test Tag", + color: "#00FF00" + ) + end + + test "generates a zip file with all required files" do + zip_data = @exporter.generate_export + + assert zip_data.is_a?(StringIO) + + # Check that the zip contains all expected files + expected_files = [ "accounts.csv", "transactions.csv", "trades.csv", "categories.csv", "all.ndjson" ] + + Zip::File.open_buffer(zip_data) do |zip| + actual_files = zip.entries.map(&:name) + assert_equal expected_files.sort, actual_files.sort + end + end + + test "generates valid CSV files" do + zip_data = @exporter.generate_export + + Zip::File.open_buffer(zip_data) do |zip| + # Check accounts.csv + accounts_csv = zip.read("accounts.csv") + assert accounts_csv.include?("id,name,type,subtype,balance,currency,created_at") + + # Check transactions.csv + transactions_csv = zip.read("transactions.csv") + assert transactions_csv.include?("date,account_name,amount,name,category,tags,notes,currency") + + # Check trades.csv + trades_csv = zip.read("trades.csv") + assert trades_csv.include?("date,account_name,ticker,quantity,price,amount,currency") + + # Check categories.csv + categories_csv = zip.read("categories.csv") + assert categories_csv.include?("name,color,parent_category,classification") + end + end + + test "generates valid NDJSON file" do + zip_data = @exporter.generate_export + + Zip::File.open_buffer(zip_data) do |zip| + ndjson_content = zip.read("all.ndjson") + lines = ndjson_content.split("\n") + + lines.each do |line| + assert_nothing_raised { JSON.parse(line) } + end + + # Check that each line has expected structure + first_line = JSON.parse(lines.first) + assert first_line.key?("type") + assert first_line.key?("data") + end + end + + test "only exports data from the specified family" do + # Create data for another family that should NOT be exported + other_account = @other_family.accounts.create!( + name: "Other Family Account", + accountable: Depository.new, + balance: 5000, + currency: "USD" + ) + + other_category = @other_family.categories.create!( + name: "Other Family Category", + color: "#0000FF" + ) + + zip_data = @exporter.generate_export + + Zip::File.open_buffer(zip_data) do |zip| + # Check accounts.csv doesn't contain other family's data + accounts_csv = zip.read("accounts.csv") + assert accounts_csv.include?(@account.name) + refute accounts_csv.include?(other_account.name) + + # Check categories.csv doesn't contain other family's data + categories_csv = zip.read("categories.csv") + assert categories_csv.include?(@category.name) + refute categories_csv.include?(other_category.name) + + # Check NDJSON doesn't contain other family's data + ndjson_content = zip.read("all.ndjson") + refute ndjson_content.include?(other_account.id) + refute ndjson_content.include?(other_category.id) + end + end +end diff --git a/test/models/family_export_test.rb b/test/models/family_export_test.rb new file mode 100644 index 00000000..45420adf --- /dev/null +++ b/test/models/family_export_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class FamilyExportTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end From 7698ec03b9cf99cdd5e25fe2fd92f66e039241d2 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Thu, 24 Jul 2025 11:30:40 -0400 Subject: [PATCH 11/20] Fix rule toggles --- app/views/rules/_rule.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/rules/_rule.html.erb b/app/views/rules/_rule.html.erb index e562cb95..68630b70 100644 --- a/app/views/rules/_rule.html.erb +++ b/app/views/rules/_rule.html.erb @@ -57,7 +57,7 @@
- <%= styled_form_with model: rule, data: { controller: "auto-submit-form" } do |f| %> + <%= styled_form_with model: rule, namespace: "rule_#{rule.id}", data: { controller: "auto-submit-form" } do |f| %> <%= f.toggle :active, { data: { auto_submit_form_target: "auto" } } %> <% end %> <%= render DS::Menu.new do |menu| %> From bacab94a1b7ec47f52a2d278787066e3d7ee2696 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Thu, 24 Jul 2025 11:41:42 -0400 Subject: [PATCH 12/20] Fix import reverts --- app/models/import.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/import.rb b/app/models/import.rb index b0a02ea0..5d6bea64 100644 --- a/app/models/import.rb +++ b/app/models/import.rb @@ -88,7 +88,7 @@ class Import < ApplicationRecord entries.destroy_all end - family.sync + family.sync_later update! status: :pending rescue => error From 5baf258a32f933a475699dc70000febb87530883 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Thu, 24 Jul 2025 14:09:30 -0400 Subject: [PATCH 13/20] Fix transactions tool call for chat --- app/models/assistant/function/get_transactions.rb | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/models/assistant/function/get_transactions.rb b/app/models/assistant/function/get_transactions.rb index 9b0da316..7ad081fb 100644 --- a/app/models/assistant/function/get_transactions.rb +++ b/app/models/assistant/function/get_transactions.rb @@ -134,7 +134,8 @@ class Assistant::Function::GetTransactions < Assistant::Function def call(params = {}) search_params = params.except("order", "page") - transactions_query = family.transactions.visible.search(search_params) + search = Transaction::Search.new(family, filters: search_params) + transactions_query = search.transactions_scope pagy_query = params["order"] == "asc" ? transactions_query.chronological : transactions_query.reverse_chronological # By default, we give a small page size to force the AI to use filters effectively and save on tokens @@ -149,7 +150,7 @@ class Assistant::Function::GetTransactions < Assistant::Function limit: default_page_size ) - totals = family.income_statement.totals(transactions_scope: transactions_query) + totals = search.totals normalized_transactions = paginated_transactions.map do |txn| entry = txn.entry From d90d35d97b25391ce4a993ee2becc11980984f7d Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Thu, 24 Jul 2025 14:28:54 -0400 Subject: [PATCH 14/20] Hosted version notice --- app/views/pages/dashboard.html.erb | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/app/views/pages/dashboard.html.erb b/app/views/pages/dashboard.html.erb index 2444f6cf..1cf92451 100644 --- a/app/views/pages/dashboard.html.erb +++ b/app/views/pages/dashboard.html.erb @@ -1,5 +1,20 @@ <% content_for :page_header do %> + <% unless Current.family.self_hoster? %> +
+ <%= icon "triangle-alert", color: "warning" %> +
+

We've made a tough decision to shut down the hosted version of Maybe. Here's what's happening next:

+
    +
  • <%= link_to "Read why we're doing this here", "https://x.com/Shpigford/status/1947725345244709240", class: "underline" %>
  • +
  • You will be refunded in full.
  • +
  • You have until July 31, 2025 to export your data. You can do that <%= link_to "here", settings_profile_path, class: "underline" %>.
  • +
+
+
+ <% end %> +
+

Welcome back, <%= Current.user.first_name %>

Here's what's happening with your finances

From 3fb379d14079ca65c59b52fae7bae470a53786bb Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Thu, 24 Jul 2025 17:34:00 -0400 Subject: [PATCH 15/20] Sync family icon button --- app/controllers/accounts_controller.rb | 5 +++++ app/views/accounts/index.html.erb | 8 ++++++++ config/routes.rb | 4 ++++ 3 files changed, 17 insertions(+) diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index 0b2252d7..b78c54ad 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -9,6 +9,11 @@ class AccountsController < ApplicationController render layout: "settings" end + def sync_all + family.sync_later + redirect_to accounts_path, notice: "Syncing accounts..." + end + def show @chart_view = params[:chart_view] || "balance" @tab = params[:tab] diff --git a/app/views/accounts/index.html.erb b/app/views/accounts/index.html.erb index ff45d33f..3557ceb8 100644 --- a/app/views/accounts/index.html.erb +++ b/app/views/accounts/index.html.erb @@ -2,6 +2,14 @@

<%= t(".accounts") %>

+ <%= icon( + "refresh-cw", + as_button: true, + size: "sm", + href: sync_all_accounts_path, + disabled: Current.family.syncing?, + frame: :_top + ) %> <%= render DS::Link.new( text: "New account", href: new_account_path(return_to: accounts_path), diff --git a/config/routes.rb b/config/routes.rb index a44554d6..d6c2bc7a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -162,6 +162,10 @@ Rails.application.routes.draw do get :sparkline patch :toggle_active end + + collection do + post :sync_all + end end # Convenience routes for polymorphic paths From 224f21354a916dfbec29e9435db1045b3d61a94b Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Thu, 24 Jul 2025 17:34:28 -0400 Subject: [PATCH 16/20] Bump to v0.6.0 --- config/initializers/version.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/initializers/version.rb b/config/initializers/version.rb index 134b959a..3aefc6ba 100644 --- a/config/initializers/version.rb +++ b/config/initializers/version.rb @@ -14,7 +14,7 @@ module Maybe private def semver - "0.5.0" + "0.6.0" end end end From a2cfa0356fa797df77d9146750f092a723190cd1 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Thu, 24 Jul 2025 18:11:29 -0400 Subject: [PATCH 17/20] Add final release note to README Signed-off-by: Zach Gollwitzer --- README.md | 56 ++++--------------------------------------------------- 1 file changed, 4 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index 18602345..9eb97cfd 100644 --- a/README.md +++ b/README.md @@ -1,50 +1,14 @@ -maybe_hero +maybe_hero # Maybe: The personal finance app for everyone -Get -involved: [Discord](https://link.maybe.co/discord) • [Website](https://maybefinance.com) • [Issues](https://github.com/maybe-finance/maybe/issues) - -## Backstory - -We spent the better part of 2021/2022 building a personal finance + wealth -management app called, Maybe. Very full-featured, including an "Ask an Advisor" -feature which connected users with an actual CFP/CFA to help them with their -finances (all included in your subscription). - -The business end of things didn't work out, and so we shut things down mid-2023. - -We spent the better part of $1,000,000 building the app (employees + -contractors, data providers/services, infrastructure, etc.). - -We're now reviving the product as a fully open-source project. The goal is to -let you run the app yourself, for free, and use it to manage your own finances -and eventually offer a hosted version of the app for a small monthly fee. +> [!IMPORTANT] +> This repository is no longer actively maintained. You can read more about this in our [final release](https://github.com/user-attachments/assets/5ed08763-a9ee-42b2-a436-e05038fcf573). ## Maybe Hosting -There are 2 primary ways to use the Maybe app: - -1. Managed (easiest) - we're in alpha and release invites in our Discord -2. [Self-host with Docker](docs/hosting/docker.md) - -## Contributing - -Before contributing, you'll likely find it helpful -to [understand context and general vision/direction](https://github.com/maybe-finance/maybe/wiki). - -Once you've done that, please visit -our [contributing guide](https://github.com/maybe-finance/maybe/blob/main/CONTRIBUTING.md) -to get started! - -### Performance Issues - -With data-heavy apps, inevitably, there are performance issues. We've set up a public dashboard showing the problematic requests, along with the stacktraces to help debug them. - -Any contributions that help improve performance are very much welcome. - -https://oss.skylight.io/app/applications/XDpPIXEX52oi/recent/6h/endpoints +Maybe is a fully working personal finance app that can be [self hosted with Docker](docs/hosting/docker.md). ## Local Development Setup @@ -78,14 +42,6 @@ credentials to log in (generated by DB seed): For further instructions, see guides below. -### Multi-currency support - -If you'd like multi-currency support, there are a few extra steps to follow. - -1. Sign up for an API key at [Synth](https://synthfinance.com). It's a Maybe - product and the free plan is sufficient for basic multi-currency support. -2. Add your API key to your `.env` file. - ### Setup Guides - [Mac dev setup guide](https://github.com/maybe-finance/maybe/wiki/Mac-Dev-Setup-Guide) @@ -93,10 +49,6 @@ If you'd like multi-currency support, there are a few extra steps to follow. - [Windows dev setup guide](https://github.com/maybe-finance/maybe/wiki/Windows-Dev-Setup-Guide) - Dev containers - visit [this guide](https://code.visualstudio.com/docs/devcontainers/containers) to learn more -## Repo Activity - -![Repo Activity](https://repobeats.axiom.co/api/embed/7866c9790deba0baf63ca1688b209130b306ea4e.svg "Repobeats analytics image") - ## Copyright & license Maybe is distributed under From fd9ba8c1b9ffe767904b25741f348e903c10ca91 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Thu, 24 Jul 2025 18:12:07 -0400 Subject: [PATCH 18/20] Link update Signed-off-by: Zach Gollwitzer --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 9eb97cfd..e3fcafa8 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ # Maybe: The personal finance app for everyone > [!IMPORTANT] -> This repository is no longer actively maintained. You can read more about this in our [final release](https://github.com/user-attachments/assets/5ed08763-a9ee-42b2-a436-e05038fcf573). +> This repository is no longer actively maintained. You can read more about this in our [final release]([https://github.com/user-attachments/assets/5ed08763-a9ee-42b2-a436-e05038fcf573](https://github.com/maybe-finance/maybe/releases/tag/v0.6.0)). ## Maybe Hosting From a90899668ff25bf24d5feb8100809642ce6192ee Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Thu, 24 Jul 2025 18:13:56 -0400 Subject: [PATCH 19/20] Fix pasting issue for markdown link Signed-off-by: Zach Gollwitzer --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e3fcafa8..2c9192f1 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ # Maybe: The personal finance app for everyone > [!IMPORTANT] -> This repository is no longer actively maintained. You can read more about this in our [final release]([https://github.com/user-attachments/assets/5ed08763-a9ee-42b2-a436-e05038fcf573](https://github.com/maybe-finance/maybe/releases/tag/v0.6.0)). +> This repository is no longer actively maintained. You can read more about this in our [final release](https://github.com/maybe-finance/maybe/releases/tag/v0.6.0). ## Maybe Hosting From 77b5469832758d1cbee1a940f3012a1ae1c74cd3 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Thu, 24 Jul 2025 18:20:44 -0400 Subject: [PATCH 20/20] Add attribution note Signed-off-by: Zach Gollwitzer --- README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.md b/README.md index 2c9192f1..4e5ccf86 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,13 @@ Maybe is a fully working personal finance app that can be [self hosted with Docker](docs/hosting/docker.md). +## Forking and Attribution + +This repo is no longer maintained. You’re free to fork it under the AGPLv3. To stay compliant and avoid trademark issues: + +- Be sure to include the original [AGPLv3 license](https://github.com/maybe-finance/maybe/blob/main/LICENSE) and clearly state in your README that your fork is based on Maybe Finance but is **not affiliated with or endorsed by** Maybe Finance Inc. +- "Maybe" is a trademark of Maybe Finance Inc. and therefore, use of it is NOT allowed in forked repositories (or the logo) + ## Local Development Setup **If you are trying to _self-host_ the Maybe app, stop here. You