diff --git a/app/models/income_statement/category_stats.rb b/app/models/income_statement/category_stats.rb index 878934d44..6df45689e 100644 --- a/app/models/income_statement/category_stats.rb +++ b/app/models/income_statement/category_stats.rb @@ -1,4 +1,6 @@ class IncomeStatement::CategoryStats + include IncomeStatement::ScopedTransactionsQuery + def initialize(family, interval: "month", account_ids: nil) @family = family @interval = interval @@ -29,35 +31,7 @@ class IncomeStatement::CategoryStats end def sql_params - params = { - target_currency: @family.currency, - interval: @interval, - family_id: @family.id - } - - ids = @family.tax_advantaged_account_ids - params[:tax_advantaged_account_ids] = ids if ids.present? - - params - end - - def budget_excluded_kinds_sql - @budget_excluded_kinds_sql ||= Transaction::BUDGET_EXCLUDED_KINDS.map { |k| "'#{k}'" }.join(", ") - end - - def pending_providers_sql - Transaction.pending_providers_sql("t") - end - - def exclude_tax_advantaged_sql - ids = @family.tax_advantaged_account_ids - return "" if ids.empty? - "AND a.id NOT IN (:tax_advantaged_account_ids)" - end - - def scope_to_account_ids_sql - return "" if @account_ids.nil? - ActiveRecord::Base.sanitize_sql([ "AND a.id IN (?)", @account_ids ]) + base_sql_params(interval: @interval) end def query_sql @@ -66,17 +40,13 @@ class IncomeStatement::CategoryStats SELECT c.id as category_id, date_trunc(:interval, ae.date) as period, - CASE WHEN t.kind IN ('investment_contribution', 'loan_payment') THEN 'expense' WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END as classification, - SUM(CASE WHEN t.kind IN ('investment_contribution', 'loan_payment') THEN ABS(ae.amount * COALESCE(er.rate, 1)) ELSE ae.amount * COALESCE(er.rate, 1) END) as total + #{classification_sql("t")} as classification, + SUM(#{converted_amount_sql("t")}) as total FROM transactions t - JOIN entries ae ON ae.entryable_id = t.id AND ae.entryable_type = 'Transaction' - JOIN accounts a ON a.id = ae.account_id + #{entries_join_sql("t")} + #{accounts_join_sql} LEFT JOIN categories c ON c.id = t.category_id - LEFT JOIN exchange_rates er ON ( - er.date = ae.date AND - er.from_currency = ae.currency AND - er.to_currency = :target_currency - ) + #{exchange_rates_join_sql} WHERE a.family_id = :family_id AND t.kind NOT IN (#{budget_excluded_kinds_sql}) AND ae.excluded = false @@ -84,7 +54,7 @@ class IncomeStatement::CategoryStats #{pending_providers_sql} #{exclude_tax_advantaged_sql} #{scope_to_account_ids_sql} - GROUP BY c.id, period, CASE WHEN t.kind IN ('investment_contribution', 'loan_payment') THEN 'expense' WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END + GROUP BY c.id, period, #{classification_sql("t")} ) SELECT category_id, diff --git a/app/models/income_statement/daily_expense_totals.rb b/app/models/income_statement/daily_expense_totals.rb index 96620d1ff..cefd559f7 100644 --- a/app/models/income_statement/daily_expense_totals.rb +++ b/app/models/income_statement/daily_expense_totals.rb @@ -1,9 +1,13 @@ # Per-day expense totals (in the family's currency) for a period, used by the -# dashboard's cumulative spending chart. Follows the same scoping rules as -# IncomeStatement::Totals (visible, posted, budget-included transactions in -# report-included accounts, converted at the day's exchange rate) so the -# series always agrees with the totals shown elsewhere on the dashboard. +# dashboard's cumulative spending chart. Shares its scoping SQL with the +# other IncomeStatement query classes via +# IncomeStatement::ScopedTransactionsQuery (visible, posted, budget-included +# transactions in report-included accounts, converted at the day's exchange +# rate) so the series always agrees with the totals shown elsewhere on the +# dashboard. class IncomeStatement::DailyExpenseTotals + include IncomeStatement::ScopedTransactionsQuery + def initialize(family, transactions_scope:, date_range:, included_account_ids: nil) @family = family @transactions_scope = transactions_scope @@ -38,28 +42,21 @@ class IncomeStatement::DailyExpenseTotals SELECT day, total FROM ( SELECT ae.date as day, - CASE WHEN at.kind IN ('investment_contribution', 'loan_payment') THEN 'expense' WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END as classification, - ABS(SUM(CASE WHEN at.kind IN ('investment_contribution', 'loan_payment') THEN ABS(ae.amount * COALESCE(er.rate, 1)) ELSE ae.amount * COALESCE(er.rate, 1) END)) as total + #{classification_sql("at")} as classification, + ABS(SUM(#{converted_amount_sql("at")})) as total FROM (#{@transactions_scope.to_sql}) at - JOIN entries ae ON ae.entryable_id = at.id AND ae.entryable_type = 'Transaction' - JOIN accounts a ON a.id = ae.account_id - LEFT JOIN exchange_rates er ON ( - er.date = ae.date AND - er.from_currency = ae.currency AND - er.to_currency = :target_currency - ) + #{entries_join_sql("at")} + #{accounts_join_sql} + #{exchange_rates_join_sql} WHERE at.kind NOT IN (#{budget_excluded_kinds_sql}) - AND ( - at.investment_activity_label IS NULL - OR at.investment_activity_label NOT IN ('Transfer', 'Sweep In', 'Sweep Out', 'Exchange') - ) + #{investment_activity_label_sql("at")} AND ae.excluded = false AND a.family_id = :family_id AND a.status IN ('draft', 'active') AND a.exclude_from_reports = false #{exclude_tax_advantaged_sql} #{include_finance_accounts_sql} - GROUP BY ae.date, CASE WHEN at.kind IN ('investment_contribution', 'loan_payment') THEN 'expense' WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END + GROUP BY ae.date, #{classification_sql("at")} ) daily WHERE classification = 'expense' ORDER BY day @@ -67,43 +64,8 @@ class IncomeStatement::DailyExpenseTotals end def sql_params - params = { - target_currency: @family.currency, - family_id: @family.id, - start_date: @date_range.begin, - end_date: @date_range.end - } - - ids = @family.tax_advantaged_account_ids - params[:tax_advantaged_account_ids] = ids if ids.present? - + params = base_sql_params(start_date: @date_range.begin, end_date: @date_range.end) params[:included_account_ids] = @included_account_ids if @included_account_ids - params end - - def exclude_tax_advantaged_sql - ids = @family.tax_advantaged_account_ids - return "" if ids.empty? - "AND a.id NOT IN (:tax_advantaged_account_ids)" - end - - def include_finance_accounts_sql - return "" if @included_account_ids.nil? - "AND a.id IN (:included_account_ids)" - end - - def budget_excluded_kinds_sql - @budget_excluded_kinds_sql ||= Transaction::BUDGET_EXCLUDED_KINDS.map { |k| "'#{k}'" }.join(", ") - end - - def validate_date_range! - unless @date_range.is_a?(Range) - raise ArgumentError, "date_range must be a Range, got #{@date_range.class}" - end - - unless @date_range.begin.respond_to?(:to_date) && @date_range.end.respond_to?(:to_date) - raise ArgumentError, "date_range must contain date-like objects" - end - end end diff --git a/app/models/income_statement/family_stats.rb b/app/models/income_statement/family_stats.rb index bc4db4a9b..ebdc289ca 100644 --- a/app/models/income_statement/family_stats.rb +++ b/app/models/income_statement/family_stats.rb @@ -1,4 +1,6 @@ class IncomeStatement::FamilyStats + include IncomeStatement::ScopedTransactionsQuery + def initialize(family, interval: "month", account_ids: nil) @family = family @interval = interval @@ -28,35 +30,7 @@ class IncomeStatement::FamilyStats end def sql_params - params = { - target_currency: @family.currency, - interval: @interval, - family_id: @family.id - } - - ids = @family.tax_advantaged_account_ids - params[:tax_advantaged_account_ids] = ids if ids.present? - - params - end - - def budget_excluded_kinds_sql - @budget_excluded_kinds_sql ||= Transaction::BUDGET_EXCLUDED_KINDS.map { |k| "'#{k}'" }.join(", ") - end - - def pending_providers_sql - Transaction.pending_providers_sql("t") - end - - def exclude_tax_advantaged_sql - ids = @family.tax_advantaged_account_ids - return "" if ids.empty? - "AND a.id NOT IN (:tax_advantaged_account_ids)" - end - - def scope_to_account_ids_sql - return "" if @account_ids.nil? - ActiveRecord::Base.sanitize_sql([ "AND a.id IN (?)", @account_ids ]) + base_sql_params(interval: @interval) end def query_sql @@ -64,16 +38,12 @@ class IncomeStatement::FamilyStats WITH period_totals AS ( SELECT date_trunc(:interval, ae.date) as period, - CASE WHEN t.kind IN ('investment_contribution', 'loan_payment') THEN 'expense' WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END as classification, - SUM(CASE WHEN t.kind IN ('investment_contribution', 'loan_payment') THEN ABS(ae.amount * COALESCE(er.rate, 1)) ELSE ae.amount * COALESCE(er.rate, 1) END) as total + #{classification_sql("t")} as classification, + SUM(#{converted_amount_sql("t")}) as total FROM transactions t - JOIN entries ae ON ae.entryable_id = t.id AND ae.entryable_type = 'Transaction' - JOIN accounts a ON a.id = ae.account_id - LEFT JOIN exchange_rates er ON ( - er.date = ae.date AND - er.from_currency = ae.currency AND - er.to_currency = :target_currency - ) + #{entries_join_sql("t")} + #{accounts_join_sql} + #{exchange_rates_join_sql} WHERE a.family_id = :family_id AND t.kind NOT IN (#{budget_excluded_kinds_sql}) AND ae.excluded = false @@ -81,7 +51,7 @@ class IncomeStatement::FamilyStats #{pending_providers_sql} #{exclude_tax_advantaged_sql} #{scope_to_account_ids_sql} - GROUP BY period, CASE WHEN t.kind IN ('investment_contribution', 'loan_payment') THEN 'expense' WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END + GROUP BY period, #{classification_sql("t")} ) SELECT classification, diff --git a/app/models/income_statement/scoped_transactions_query.rb b/app/models/income_statement/scoped_transactions_query.rb new file mode 100644 index 000000000..283f916da --- /dev/null +++ b/app/models/income_statement/scoped_transactions_query.rb @@ -0,0 +1,102 @@ +# SQL building blocks shared by the IncomeStatement query classes +# (Totals, DailyExpenseTotals, FamilyStats, CategoryStats). Each class keeps +# its own SELECT/FROM/GROUP BY, but the scoping fragments - which rows count +# as reportable transactions and how amounts convert to the family +# currency - live here so every income statement number on the dashboard is +# computed the same way. +# +# Fragments are parameterized by `t`, the alias the calling query gives its +# transactions table or scope subquery, and assume the entry and account +# aliases (`ae`, `a`). The including class must set `@family`. +module IncomeStatement::ScopedTransactionsQuery + private + # Contributions and loan payments are cash outflows recorded as negative + # amounts, so they always classify as expense; other negative amounts + # classify as income. + def classification_sql(t) + "CASE WHEN #{t}.kind IN ('investment_contribution', 'loan_payment') THEN 'expense' WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END" + end + + # Entry amount converted to the family currency at the day's exchange + # rate. Contribution/loan-payment outflows are flipped positive so they + # add to expense totals. + def converted_amount_sql(t) + "CASE WHEN #{t}.kind IN ('investment_contribution', 'loan_payment') THEN ABS(ae.amount * COALESCE(er.rate, 1)) ELSE ae.amount * COALESCE(er.rate, 1) END" + end + + def entries_join_sql(t) + "JOIN entries ae ON ae.entryable_id = #{t}.id AND ae.entryable_type = 'Transaction'" + end + + def accounts_join_sql + "JOIN accounts a ON a.id = ae.account_id" + end + + def exchange_rates_join_sql + <<~SQL.chomp + LEFT JOIN exchange_rates er ON ( + er.date = ae.date AND + er.from_currency = ae.currency AND + er.to_currency = :target_currency + ) + SQL + end + + # Investment activity rows that move money within a portfolio + # (transfers, sweeps, exchanges) are neither income nor expenses. + def investment_activity_label_sql(t) + <<~SQL.chomp + AND ( + #{t}.investment_activity_label IS NULL + OR #{t}.investment_activity_label NOT IN ('Transfer', 'Sweep In', 'Sweep Out', 'Exchange') + ) + SQL + end + + def budget_excluded_kinds_sql + @budget_excluded_kinds_sql ||= Transaction::BUDGET_EXCLUDED_KINDS.map { |k| "'#{k}'" }.join(", ") + end + + def pending_providers_sql(t = "t") + Transaction.pending_providers_sql(t) + end + + # Tax-advantaged accounts (401k, IRA, HSA, etc.) are retirement savings, + # not daily expenses, so they're excluded from budget calculations. + def exclude_tax_advantaged_sql + ids = @family.tax_advantaged_account_ids + return "" if ids.empty? + "AND a.id NOT IN (:tax_advantaged_account_ids)" + end + + # Named-parameter account scoping, for queries bound with sql_params. + def include_finance_accounts_sql + return "" if @included_account_ids.nil? + "AND a.id IN (:included_account_ids)" + end + + # Inlined account scoping, for queries built with sanitize_sql. + def scope_to_account_ids_sql + return "" if @account_ids.nil? + ActiveRecord::Base.sanitize_sql([ "AND a.id IN (?)", @account_ids ]) + end + + # Bind params every income statement query needs; classes merge their + # extras (date range, interval, account ids) on top. + def base_sql_params(extra = {}) + { target_currency: @family.currency, family_id: @family.id }.merge(extra).tap do |params| + ids = @family.tax_advantaged_account_ids + params[:tax_advantaged_account_ids] = ids if ids.present? + end + end + + def validate_date_range! + unless @date_range.is_a?(Range) + raise ArgumentError, "date_range must be a Range, got #{@date_range.class}" + end + + unless @date_range.begin.respond_to?(:to_date) && @date_range.end.respond_to?(:to_date) + raise ArgumentError, "date_range must contain date-like objects" + end + end +end diff --git a/app/models/income_statement/totals.rb b/app/models/income_statement/totals.rb index c3facaae0..34fe35a56 100644 --- a/app/models/income_statement/totals.rb +++ b/app/models/income_statement/totals.rb @@ -1,4 +1,6 @@ class IncomeStatement::Totals + include IncomeStatement::ScopedTransactionsQuery + def initialize(family, transactions_scope:, date_range:, include_trades: true, included_account_ids: nil) @family = family @transactions_scope = transactions_scope @@ -60,19 +62,15 @@ class IncomeStatement::Totals SELECT c.id as category_id, c.parent_id as parent_category_id, - CASE WHEN at.kind IN ('investment_contribution', 'loan_payment') THEN 'expense' WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END as classification, - ABS(SUM(CASE WHEN at.kind IN ('investment_contribution', 'loan_payment') THEN ABS(ae.amount * COALESCE(er.rate, 1)) ELSE ae.amount * COALESCE(er.rate, 1) END)) as total, + #{classification_sql("at")} as classification, + ABS(SUM(#{converted_amount_sql("at")})) as total, COUNT(ae.id) as transactions_count, false as is_uncategorized_investment FROM (#{@transactions_scope.to_sql}) at - JOIN entries ae ON ae.entryable_id = at.id AND ae.entryable_type = 'Transaction' - JOIN accounts a ON a.id = ae.account_id + #{entries_join_sql("at")} + #{accounts_join_sql} LEFT JOIN categories c ON c.id = at.category_id - LEFT JOIN exchange_rates er ON ( - er.date = ae.date AND - er.from_currency = ae.currency AND - er.to_currency = :target_currency - ) + #{exchange_rates_join_sql} WHERE at.kind NOT IN (#{budget_excluded_kinds_sql}) AND ae.excluded = false AND a.family_id = :family_id @@ -80,7 +78,7 @@ class IncomeStatement::Totals AND a.exclude_from_reports = false #{exclude_tax_advantaged_sql} #{include_finance_accounts_sql} - GROUP BY c.id, c.parent_id, CASE WHEN at.kind IN ('investment_contribution', 'loan_payment') THEN 'expense' WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END; + GROUP BY c.id, c.parent_id, #{classification_sql("at")}; SQL end @@ -89,31 +87,24 @@ class IncomeStatement::Totals SELECT c.id as category_id, c.parent_id as parent_category_id, - CASE WHEN at.kind IN ('investment_contribution', 'loan_payment') THEN 'expense' WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END as classification, - ABS(SUM(CASE WHEN at.kind IN ('investment_contribution', 'loan_payment') THEN ABS(ae.amount * COALESCE(er.rate, 1)) ELSE ae.amount * COALESCE(er.rate, 1) END)) as total, + #{classification_sql("at")} as classification, + ABS(SUM(#{converted_amount_sql("at")})) as total, COUNT(ae.id) as entry_count, false as is_uncategorized_investment FROM (#{@transactions_scope.to_sql}) at - JOIN entries ae ON ae.entryable_id = at.id AND ae.entryable_type = 'Transaction' - JOIN accounts a ON a.id = ae.account_id + #{entries_join_sql("at")} + #{accounts_join_sql} LEFT JOIN categories c ON c.id = at.category_id - LEFT JOIN exchange_rates er ON ( - er.date = ae.date AND - er.from_currency = ae.currency AND - er.to_currency = :target_currency - ) + #{exchange_rates_join_sql} WHERE at.kind NOT IN (#{budget_excluded_kinds_sql}) - AND ( - at.investment_activity_label IS NULL - OR at.investment_activity_label NOT IN ('Transfer', 'Sweep In', 'Sweep Out', 'Exchange') - ) + #{investment_activity_label_sql("at")} AND ae.excluded = false AND a.family_id = :family_id AND a.status IN ('draft', 'active') AND a.exclude_from_reports = false #{exclude_tax_advantaged_sql} #{include_finance_accounts_sql} - GROUP BY c.id, c.parent_id, CASE WHEN at.kind IN ('investment_contribution', 'loan_payment') THEN 'expense' WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END + GROUP BY c.id, c.parent_id, #{classification_sql("at")} SQL end @@ -130,48 +121,11 @@ class IncomeStatement::Totals end def sql_params - params = { - target_currency: @family.currency, - family_id: @family.id, - start_date: @date_range.begin, - end_date: @date_range.end - } - - # Add tax-advantaged account IDs if any exist - ids = @family.tax_advantaged_account_ids - params[:tax_advantaged_account_ids] = ids if ids.present? + params = base_sql_params(start_date: @date_range.begin, end_date: @date_range.end) # Add included account IDs for per-user finance scoping params[:included_account_ids] = @included_account_ids if @included_account_ids params end - - # Returns SQL clause to exclude tax-advantaged accounts from budget calculations. - # Tax-advantaged accounts (401k, IRA, HSA, etc.) are retirement savings, not daily expenses. - def exclude_tax_advantaged_sql - ids = @family.tax_advantaged_account_ids - return "" if ids.empty? - "AND a.id NOT IN (:tax_advantaged_account_ids)" - end - - # Returns SQL clause to filter to only accounts included in the user's finances. - def include_finance_accounts_sql - return "" if @included_account_ids.nil? - "AND a.id IN (:included_account_ids)" - end - - def budget_excluded_kinds_sql - @budget_excluded_kinds_sql ||= Transaction::BUDGET_EXCLUDED_KINDS.map { |k| "'#{k}'" }.join(", ") - end - - def validate_date_range! - unless @date_range.is_a?(Range) - raise ArgumentError, "date_range must be a Range, got #{@date_range.class}" - end - - unless @date_range.begin.respond_to?(:to_date) && @date_range.end.respond_to?(:to_date) - raise ArgumentError, "date_range must contain date-like objects" - end - end end diff --git a/test/models/income_statement/scoped_transactions_query_equivalence_test.rb b/test/models/income_statement/scoped_transactions_query_equivalence_test.rb new file mode 100644 index 000000000..dd34643ca --- /dev/null +++ b/test/models/income_statement/scoped_transactions_query_equivalence_test.rb @@ -0,0 +1,122 @@ +require "test_helper" + +# Proves the IncomeStatement::ScopedTransactionsQuery refactor preserves +# behavior: each refactored query class must return exactly what its +# pre-refactor implementation returned. The legacy implementations are +# verbatim copies from main (test/support/legacy_income_statement_*.rb), and +# each test runs both over the same data across the class's full option +# matrix (account scoping, trade inclusion, stats interval). +class IncomeStatement::ScopedTransactionsQueryEquivalenceTest < ActiveSupport::TestCase + include EntriesTestHelper + + setup do + @family = families(:empty) + + @checking = @family.accounts.create! name: "Checking", currency: "USD", balance: 5000, accountable: Depository.new + @savings = @family.accounts.create! name: "Savings", currency: "USD", balance: 1000, accountable: Depository.new + @eur = @family.accounts.create! name: "EUR Checking", currency: "EUR", balance: 1000, accountable: Depository.new + @retirement = @family.accounts.create! name: "401k", currency: "USD", balance: 10000, accountable: Investment.new(subtype: "401k") + @unreported = @family.accounts.create! name: "Off the books", currency: "USD", balance: 100, accountable: Depository.new, exclude_from_reports: true + + @food = @family.categories.create! name: "Food" + @groceries = @family.categories.create! name: "Groceries", parent: @food + + # Spread across two calendar months (and weeks) so interval grouping varies. + create_transaction(account: @checking, amount: 100, date: 40.days.ago.to_date, category: @food) + create_transaction(account: @checking, amount: 50, date: 5.days.ago.to_date, category: @groceries) + create_transaction(account: @checking, amount: -500, date: 5.days.ago.to_date) # income + create_transaction(account: @checking, amount: -200, date: 5.days.ago.to_date, kind: "loan_payment") + create_transaction(account: @checking, amount: -300, date: 5.days.ago.to_date, kind: "investment_contribution") + create_transaction(account: @checking, amount: 30, date: 5.days.ago.to_date, kind: "funds_movement") # budget-excluded kind + create_transaction(account: @checking, amount: 25, date: 5.days.ago.to_date, excluded: true) + + transfer = create_transaction(account: @checking, amount: 60, date: 5.days.ago.to_date) + transfer.entryable.update!(investment_activity_label: "Transfer") + + pending = create_transaction(account: @checking, amount: 20, date: 5.days.ago.to_date) + pending.entryable.update!(extra: { "simplefin" => { "pending" => true } }) + + create_transaction(account: @savings, amount: 75, date: 10.days.ago.to_date) + + ExchangeRate.create! from_currency: "EUR", to_currency: "USD", date: 5.days.ago.to_date, rate: 2 + create_transaction(account: @eur, amount: 40, currency: "EUR", date: 5.days.ago.to_date) + create_transaction(account: @eur, amount: 10, currency: "EUR", date: 10.days.ago.to_date) # no rate row, falls back to 1 + + create_transaction(account: @retirement, amount: 300, date: 5.days.ago.to_date) # tax-advantaged, excluded + create_transaction(account: @unreported, amount: 45, date: 5.days.ago.to_date) # exclude_from_reports, excluded + + @period = Period.custom(start_date: 45.days.ago.to_date, end_date: Date.current) + end + + test "Totals matches its pre-refactor implementation across its option matrix" do + [ true, false ].each do |include_trades| + account_id_options.each do |included_account_ids| + args = { transactions_scope: transactions_scope, date_range: @period.date_range, + include_trades: include_trades, included_account_ids: included_account_ids } + + assert_equal_rows( + LegacyIncomeStatementTotals.new(@family, **args).call, + IncomeStatement::Totals.new(@family, **args).call, + "Totals (include_trades: #{include_trades}, included_account_ids: #{included_account_ids.inspect})" + ) + end + end + end + + test "DailyExpenseTotals matches its pre-refactor implementation across its option matrix" do + account_id_options.each do |included_account_ids| + args = { transactions_scope: transactions_scope, date_range: @period.date_range, + included_account_ids: included_account_ids } + + assert_equal_rows( + LegacyIncomeStatementDailyExpenseTotals.new(@family, **args).call, + IncomeStatement::DailyExpenseTotals.new(@family, **args).call, + "DailyExpenseTotals (included_account_ids: #{included_account_ids.inspect})" + ) + end + end + + test "FamilyStats matches its pre-refactor implementation across its option matrix" do + %w[month week].each do |interval| + account_id_options.each do |account_ids| + assert_equal_rows( + LegacyIncomeStatementFamilyStats.new(@family, interval: interval, account_ids: account_ids).call, + IncomeStatement::FamilyStats.new(@family, interval: interval, account_ids: account_ids).call, + "FamilyStats (interval: #{interval}, account_ids: #{account_ids.inspect})" + ) + end + end + end + + test "CategoryStats matches its pre-refactor implementation across its option matrix" do + %w[month week].each do |interval| + account_id_options.each do |account_ids| + assert_equal_rows( + LegacyIncomeStatementCategoryStats.new(@family, interval: interval, account_ids: account_ids).call, + IncomeStatement::CategoryStats.new(@family, interval: interval, account_ids: account_ids).call, + "CategoryStats (interval: #{interval}, account_ids: #{account_ids.inspect})" + ) + end + end + end + + private + # Unscoped, scoped to a subset of accounts, and scoped to nothing. + def account_id_options + [ nil, [ @checking.id, @savings.id ], [] ] + end + + # Same production scope used by the dashboard for both implementations. + def transactions_scope + @family.transactions.visible.excluding_pending.in_period(@period) + end + + def assert_equal_rows(legacy_rows, refactored_rows, label) + assert_equal normalize(legacy_rows), normalize(refactored_rows), "#{label} returned different rows" + end + + # Row order is not guaranteed by GROUP BY, so compare order-insensitively. + def normalize(rows) + rows.map(&:to_h).sort_by(&:inspect) + end +end diff --git a/test/support/legacy_income_statement_category_stats.rb b/test/support/legacy_income_statement_category_stats.rb new file mode 100644 index 000000000..c3cb785f4 --- /dev/null +++ b/test/support/legacy_income_statement_category_stats.rb @@ -0,0 +1,104 @@ +# Verbatim copy of IncomeStatement::CategoryStats as it existed on main before the +# IncomeStatement::ScopedTransactionsQuery refactor (base commit 947fe832), +# renamed so both implementations can run side by side. +# ScopedTransactionsQueryEquivalenceTest runs each refactored class and its +# legacy counterpart over the same data and option matrix and asserts +# identical results. Delete once the refactor is merged and trusted. +class LegacyIncomeStatementCategoryStats + def initialize(family, interval: "month", account_ids: nil) + @family = family + @interval = interval + @account_ids = account_ids + end + + def call + return [] if @account_ids&.empty? + + ActiveRecord::Base.connection.select_all(sanitized_query_sql).map do |row| + StatRow.new( + category_id: row["category_id"], + classification: row["classification"], + median: row["median"], + avg: row["avg"] + ) + end + end + + private + StatRow = Data.define(:category_id, :classification, :median, :avg) + + def sanitized_query_sql + ActiveRecord::Base.sanitize_sql_array([ + query_sql, + sql_params + ]) + end + + def sql_params + params = { + target_currency: @family.currency, + interval: @interval, + family_id: @family.id + } + + ids = @family.tax_advantaged_account_ids + params[:tax_advantaged_account_ids] = ids if ids.present? + + params + end + + def budget_excluded_kinds_sql + @budget_excluded_kinds_sql ||= Transaction::BUDGET_EXCLUDED_KINDS.map { |k| "'#{k}'" }.join(", ") + end + + def pending_providers_sql + Transaction.pending_providers_sql("t") + end + + def exclude_tax_advantaged_sql + ids = @family.tax_advantaged_account_ids + return "" if ids.empty? + "AND a.id NOT IN (:tax_advantaged_account_ids)" + end + + def scope_to_account_ids_sql + return "" if @account_ids.nil? + ActiveRecord::Base.sanitize_sql([ "AND a.id IN (?)", @account_ids ]) + end + + def query_sql + <<~SQL + WITH period_totals AS ( + SELECT + c.id as category_id, + date_trunc(:interval, ae.date) as period, + CASE WHEN t.kind IN ('investment_contribution', 'loan_payment') THEN 'expense' WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END as classification, + SUM(CASE WHEN t.kind IN ('investment_contribution', 'loan_payment') THEN ABS(ae.amount * COALESCE(er.rate, 1)) ELSE ae.amount * COALESCE(er.rate, 1) END) as total + FROM transactions t + JOIN entries ae ON ae.entryable_id = t.id AND ae.entryable_type = 'Transaction' + JOIN accounts a ON a.id = ae.account_id + LEFT JOIN categories c ON c.id = t.category_id + LEFT JOIN exchange_rates er ON ( + er.date = ae.date AND + er.from_currency = ae.currency AND + er.to_currency = :target_currency + ) + WHERE a.family_id = :family_id + AND t.kind NOT IN (#{budget_excluded_kinds_sql}) + AND ae.excluded = false + AND a.exclude_from_reports = false + #{pending_providers_sql} + #{exclude_tax_advantaged_sql} + #{scope_to_account_ids_sql} + GROUP BY c.id, period, CASE WHEN t.kind IN ('investment_contribution', 'loan_payment') THEN 'expense' WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END + ) + SELECT + category_id, + classification, + ABS(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY total)) as median, + ABS(AVG(total)) as avg + FROM period_totals + GROUP BY category_id, classification; + SQL + end +end diff --git a/test/support/legacy_income_statement_daily_expense_totals.rb b/test/support/legacy_income_statement_daily_expense_totals.rb new file mode 100644 index 000000000..9ed990486 --- /dev/null +++ b/test/support/legacy_income_statement_daily_expense_totals.rb @@ -0,0 +1,115 @@ +# Verbatim copy of IncomeStatement::DailyExpenseTotals as it existed on main before the +# IncomeStatement::ScopedTransactionsQuery refactor (base commit 947fe832), +# renamed so both implementations can run side by side. +# ScopedTransactionsQueryEquivalenceTest runs each refactored class and its +# legacy counterpart over the same data and option matrix and asserts +# identical results. Delete once the refactor is merged and trusted. +# Per-day expense totals (in the family's currency) for a period, used by the +# dashboard's cumulative spending chart. Follows the same scoping rules as +# IncomeStatement::Totals (visible, posted, budget-included transactions in +# report-included accounts, converted at the day's exchange rate) so the +# series always agrees with the totals shown elsewhere on the dashboard. +class LegacyIncomeStatementDailyExpenseTotals + def initialize(family, transactions_scope:, date_range:, included_account_ids: nil) + @family = family + @transactions_scope = transactions_scope + @date_range = date_range + @included_account_ids = included_account_ids + + validate_date_range! + end + + def call + # No finance accounts means no transactions to report + return [] if @included_account_ids&.empty? + + ActiveRecord::Base.connection.select_all(query_sql).map do |row| + DailyTotal.new(date: row["day"].to_date, total: row["total"]) + end + end + + private + DailyTotal = Data.define(:date, :total) + + def query_sql + ActiveRecord::Base.sanitize_sql_array([ query_sql_body, sql_params ]) + end + + # Mirrors IncomeStatement::Totals' transactions subquery, but groups by + # entry date instead of category and keeps only the expense rows. The + # classification CASE is repeated in the GROUP BY (rather than referenced + # by alias) because only some databases accept aliases there. + def query_sql_body + <<~SQL + SELECT day, total FROM ( + SELECT + ae.date as day, + CASE WHEN at.kind IN ('investment_contribution', 'loan_payment') THEN 'expense' WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END as classification, + ABS(SUM(CASE WHEN at.kind IN ('investment_contribution', 'loan_payment') THEN ABS(ae.amount * COALESCE(er.rate, 1)) ELSE ae.amount * COALESCE(er.rate, 1) END)) as total + FROM (#{@transactions_scope.to_sql}) at + JOIN entries ae ON ae.entryable_id = at.id AND ae.entryable_type = 'Transaction' + JOIN accounts a ON a.id = ae.account_id + LEFT JOIN exchange_rates er ON ( + er.date = ae.date AND + er.from_currency = ae.currency AND + er.to_currency = :target_currency + ) + WHERE at.kind NOT IN (#{budget_excluded_kinds_sql}) + AND ( + at.investment_activity_label IS NULL + OR at.investment_activity_label NOT IN ('Transfer', 'Sweep In', 'Sweep Out', 'Exchange') + ) + AND ae.excluded = false + AND a.family_id = :family_id + AND a.status IN ('draft', 'active') + AND a.exclude_from_reports = false + #{exclude_tax_advantaged_sql} + #{include_finance_accounts_sql} + GROUP BY ae.date, CASE WHEN at.kind IN ('investment_contribution', 'loan_payment') THEN 'expense' WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END + ) daily + WHERE classification = 'expense' + ORDER BY day + SQL + end + + def sql_params + params = { + target_currency: @family.currency, + family_id: @family.id, + start_date: @date_range.begin, + end_date: @date_range.end + } + + ids = @family.tax_advantaged_account_ids + params[:tax_advantaged_account_ids] = ids if ids.present? + + params[:included_account_ids] = @included_account_ids if @included_account_ids + + params + end + + def exclude_tax_advantaged_sql + ids = @family.tax_advantaged_account_ids + return "" if ids.empty? + "AND a.id NOT IN (:tax_advantaged_account_ids)" + end + + def include_finance_accounts_sql + return "" if @included_account_ids.nil? + "AND a.id IN (:included_account_ids)" + end + + def budget_excluded_kinds_sql + @budget_excluded_kinds_sql ||= Transaction::BUDGET_EXCLUDED_KINDS.map { |k| "'#{k}'" }.join(", ") + end + + def validate_date_range! + unless @date_range.is_a?(Range) + raise ArgumentError, "date_range must be a Range, got #{@date_range.class}" + end + + unless @date_range.begin.respond_to?(:to_date) && @date_range.end.respond_to?(:to_date) + raise ArgumentError, "date_range must contain date-like objects" + end + end +end diff --git a/test/support/legacy_income_statement_family_stats.rb b/test/support/legacy_income_statement_family_stats.rb new file mode 100644 index 000000000..fb92bbcac --- /dev/null +++ b/test/support/legacy_income_statement_family_stats.rb @@ -0,0 +1,100 @@ +# Verbatim copy of IncomeStatement::FamilyStats as it existed on main before the +# IncomeStatement::ScopedTransactionsQuery refactor (base commit 947fe832), +# renamed so both implementations can run side by side. +# ScopedTransactionsQueryEquivalenceTest runs each refactored class and its +# legacy counterpart over the same data and option matrix and asserts +# identical results. Delete once the refactor is merged and trusted. +class LegacyIncomeStatementFamilyStats + def initialize(family, interval: "month", account_ids: nil) + @family = family + @interval = interval + @account_ids = account_ids + end + + def call + return [] if @account_ids&.empty? + + ActiveRecord::Base.connection.select_all(sanitized_query_sql).map do |row| + StatRow.new( + classification: row["classification"], + median: row["median"], + avg: row["avg"] + ) + end + end + + private + StatRow = Data.define(:classification, :median, :avg) + + def sanitized_query_sql + ActiveRecord::Base.sanitize_sql_array([ + query_sql, + sql_params + ]) + end + + def sql_params + params = { + target_currency: @family.currency, + interval: @interval, + family_id: @family.id + } + + ids = @family.tax_advantaged_account_ids + params[:tax_advantaged_account_ids] = ids if ids.present? + + params + end + + def budget_excluded_kinds_sql + @budget_excluded_kinds_sql ||= Transaction::BUDGET_EXCLUDED_KINDS.map { |k| "'#{k}'" }.join(", ") + end + + def pending_providers_sql + Transaction.pending_providers_sql("t") + end + + def exclude_tax_advantaged_sql + ids = @family.tax_advantaged_account_ids + return "" if ids.empty? + "AND a.id NOT IN (:tax_advantaged_account_ids)" + end + + def scope_to_account_ids_sql + return "" if @account_ids.nil? + ActiveRecord::Base.sanitize_sql([ "AND a.id IN (?)", @account_ids ]) + end + + def query_sql + <<~SQL + WITH period_totals AS ( + SELECT + date_trunc(:interval, ae.date) as period, + CASE WHEN t.kind IN ('investment_contribution', 'loan_payment') THEN 'expense' WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END as classification, + SUM(CASE WHEN t.kind IN ('investment_contribution', 'loan_payment') THEN ABS(ae.amount * COALESCE(er.rate, 1)) ELSE ae.amount * COALESCE(er.rate, 1) END) as total + FROM transactions t + JOIN entries ae ON ae.entryable_id = t.id AND ae.entryable_type = 'Transaction' + JOIN accounts a ON a.id = ae.account_id + LEFT JOIN exchange_rates er ON ( + er.date = ae.date AND + er.from_currency = ae.currency AND + er.to_currency = :target_currency + ) + WHERE a.family_id = :family_id + AND t.kind NOT IN (#{budget_excluded_kinds_sql}) + AND ae.excluded = false + AND a.exclude_from_reports = false + #{pending_providers_sql} + #{exclude_tax_advantaged_sql} + #{scope_to_account_ids_sql} + GROUP BY period, CASE WHEN t.kind IN ('investment_contribution', 'loan_payment') THEN 'expense' WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END + ) + SELECT + classification, + ABS(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY total)) as median, + ABS(AVG(total)) as avg + FROM period_totals + GROUP BY classification; + SQL + end +end diff --git a/test/support/legacy_income_statement_totals.rb b/test/support/legacy_income_statement_totals.rb new file mode 100644 index 000000000..677339cd1 --- /dev/null +++ b/test/support/legacy_income_statement_totals.rb @@ -0,0 +1,183 @@ +# Verbatim copy of IncomeStatement::Totals as it existed on main before the +# IncomeStatement::ScopedTransactionsQuery refactor (base commit 947fe832), +# renamed so both implementations can run side by side. +# ScopedTransactionsQueryEquivalenceTest runs each refactored class and its +# legacy counterpart over the same data and option matrix and asserts +# identical results. Delete once the refactor is merged and trusted. +class LegacyIncomeStatementTotals + def initialize(family, transactions_scope:, date_range:, include_trades: true, included_account_ids: nil) + @family = family + @transactions_scope = transactions_scope + @date_range = date_range + @include_trades = include_trades + @included_account_ids = included_account_ids + + validate_date_range! + end + + def call + # No finance accounts means no transactions to report + return [] if @included_account_ids&.empty? + + ActiveRecord::Base.connection.select_all(query_sql).map do |row| + TotalsRow.new( + parent_category_id: row["parent_category_id"], + category_id: row["category_id"], + classification: row["classification"], + total: row["total"], + transactions_count: row["transactions_count"], + is_uncategorized_investment: row["is_uncategorized_investment"] + ) + end + end + + private + TotalsRow = Data.define(:parent_category_id, :category_id, :classification, :total, :transactions_count, :is_uncategorized_investment) + + def query_sql + ActiveRecord::Base.sanitize_sql_array([ + @include_trades ? combined_query_sql : transactions_only_query_sql, + sql_params + ]) + end + + # Combined query that includes both transactions and trades + def combined_query_sql + <<~SQL + SELECT + category_id, + parent_category_id, + classification, + is_uncategorized_investment, + SUM(total) as total, + SUM(entry_count) as transactions_count + FROM ( + #{transactions_subquery_sql} + UNION ALL + #{trades_subquery_sql} + ) combined + GROUP BY category_id, parent_category_id, classification, is_uncategorized_investment; + SQL + end + + # Original transactions-only query (for backwards compatibility) + def transactions_only_query_sql + <<~SQL + SELECT + c.id as category_id, + c.parent_id as parent_category_id, + CASE WHEN at.kind IN ('investment_contribution', 'loan_payment') THEN 'expense' WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END as classification, + ABS(SUM(CASE WHEN at.kind IN ('investment_contribution', 'loan_payment') THEN ABS(ae.amount * COALESCE(er.rate, 1)) ELSE ae.amount * COALESCE(er.rate, 1) END)) as total, + COUNT(ae.id) as transactions_count, + false as is_uncategorized_investment + FROM (#{@transactions_scope.to_sql}) at + JOIN entries ae ON ae.entryable_id = at.id AND ae.entryable_type = 'Transaction' + JOIN accounts a ON a.id = ae.account_id + LEFT JOIN categories c ON c.id = at.category_id + LEFT JOIN exchange_rates er ON ( + er.date = ae.date AND + er.from_currency = ae.currency AND + er.to_currency = :target_currency + ) + WHERE at.kind NOT IN (#{budget_excluded_kinds_sql}) + AND ae.excluded = false + AND a.family_id = :family_id + AND a.status IN ('draft', 'active') + AND a.exclude_from_reports = false + #{exclude_tax_advantaged_sql} + #{include_finance_accounts_sql} + GROUP BY c.id, c.parent_id, CASE WHEN at.kind IN ('investment_contribution', 'loan_payment') THEN 'expense' WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END; + SQL + end + + def transactions_subquery_sql + <<~SQL + SELECT + c.id as category_id, + c.parent_id as parent_category_id, + CASE WHEN at.kind IN ('investment_contribution', 'loan_payment') THEN 'expense' WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END as classification, + ABS(SUM(CASE WHEN at.kind IN ('investment_contribution', 'loan_payment') THEN ABS(ae.amount * COALESCE(er.rate, 1)) ELSE ae.amount * COALESCE(er.rate, 1) END)) as total, + COUNT(ae.id) as entry_count, + false as is_uncategorized_investment + FROM (#{@transactions_scope.to_sql}) at + JOIN entries ae ON ae.entryable_id = at.id AND ae.entryable_type = 'Transaction' + JOIN accounts a ON a.id = ae.account_id + LEFT JOIN categories c ON c.id = at.category_id + LEFT JOIN exchange_rates er ON ( + er.date = ae.date AND + er.from_currency = ae.currency AND + er.to_currency = :target_currency + ) + WHERE at.kind NOT IN (#{budget_excluded_kinds_sql}) + AND ( + at.investment_activity_label IS NULL + OR at.investment_activity_label NOT IN ('Transfer', 'Sweep In', 'Sweep Out', 'Exchange') + ) + AND ae.excluded = false + AND a.family_id = :family_id + AND a.status IN ('draft', 'active') + AND a.exclude_from_reports = false + #{exclude_tax_advantaged_sql} + #{include_finance_accounts_sql} + GROUP BY c.id, c.parent_id, CASE WHEN at.kind IN ('investment_contribution', 'loan_payment') THEN 'expense' WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END + SQL + end + + def trades_subquery_sql + # Trades are completely excluded from income/expense budgets + # Rationale: Trades represent portfolio rebalancing, not cash flow + # Example: Selling $10k AAPL to buy MSFT = no net worth change, not an expense + # Contributions/withdrawals are tracked separately as Transactions with activity labels + <<~SQL + SELECT NULL as category_id, NULL as parent_category_id, NULL as classification, + NULL as total, NULL as entry_count, NULL as is_uncategorized_investment + WHERE false + SQL + end + + def sql_params + params = { + target_currency: @family.currency, + family_id: @family.id, + start_date: @date_range.begin, + end_date: @date_range.end + } + + # Add tax-advantaged account IDs if any exist + ids = @family.tax_advantaged_account_ids + params[:tax_advantaged_account_ids] = ids if ids.present? + + # Add included account IDs for per-user finance scoping + params[:included_account_ids] = @included_account_ids if @included_account_ids + + params + end + + # Returns SQL clause to exclude tax-advantaged accounts from budget calculations. + # Tax-advantaged accounts (401k, IRA, HSA, etc.) are retirement savings, not daily expenses. + def exclude_tax_advantaged_sql + ids = @family.tax_advantaged_account_ids + return "" if ids.empty? + "AND a.id NOT IN (:tax_advantaged_account_ids)" + end + + # Returns SQL clause to filter to only accounts included in the user's finances. + def include_finance_accounts_sql + return "" if @included_account_ids.nil? + "AND a.id IN (:included_account_ids)" + end + + def budget_excluded_kinds_sql + @budget_excluded_kinds_sql ||= Transaction::BUDGET_EXCLUDED_KINDS.map { |k| "'#{k}'" }.join(", ") + end + + def validate_date_range! + unless @date_range.is_a?(Range) + raise ArgumentError, "date_range must be a Range, got #{@date_range.class}" + end + + unless @date_range.begin.respond_to?(:to_date) && @date_range.end.respond_to?(:to_date) + raise ArgumentError, "date_range must contain date-like objects" + end + end +end