From b1ec33da01bf745fb2cc7d2a6487a781f1c57e7a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 29 Mar 2026 10:08:51 +0000 Subject: [PATCH] Treat investment contributions as transfers in budgets and reports Agent-Logs-Url: https://github.com/we-promise/sure/sessions/81bb8eeb-1c0b-4a7f-b939-187061c41f22 Co-authored-by: jjmata <187772+jjmata@users.noreply.github.com> --- app/controllers/pages_controller.rb | 50 +++++++++++++++++-- app/models/income_statement/category_stats.rb | 6 +-- app/models/income_statement/family_stats.rb | 6 +-- app/models/income_statement/totals.rb | 12 ++--- app/models/transaction.rb | 5 +- test/controllers/pages_controller_test.rb | 15 ++++++ test/models/budget_test.rb | 22 ++++++++ test/models/income_statement_test.rb | 26 ++++------ test/models/transaction_test.rb | 4 ++ 9 files changed, 113 insertions(+), 33 deletions(-) diff --git a/app/controllers/pages_controller.rb b/app/controllers/pages_controller.rb index e0802c1c5..e2ff9f22a 100644 --- a/app/controllers/pages_controller.rb +++ b/app/controllers/pages_controller.rb @@ -22,7 +22,10 @@ class PagesController < ApplicationController net_totals = income_statement.net_category_totals(period: @period) @cashflow_sankey_data = build_cashflow_sankey_data(net_totals, income_totals, expense_totals, family_currency) - @outflows_data = build_outflows_donut_data(net_totals) + @outflows_data = build_outflows_donut_data( + net_totals, + investment_contributions_total: investment_contributions_outflow_total(@period) + ) @dashboard_sections = build_dashboard_sections @@ -313,7 +316,7 @@ class PagesController < ApplicationController end end - def build_outflows_donut_data(net_totals) + def build_outflows_donut_data(net_totals, investment_contributions_total: 0) currency_symbol = Money::Currency.new(net_totals.currency).symbol total = net_totals.total_net_expense @@ -333,7 +336,48 @@ class PagesController < ApplicationController } end - { categories: categories, total: total.to_f.round(2), currency: net_totals.currency, currency_symbol: currency_symbol } + if investment_contributions_total.positive? + investment_contributions_category = Current.family.investment_contributions_category + categories << { + id: investment_contributions_category.id, + name: investment_contributions_category.name, + amount: investment_contributions_total.to_f.round(2), + currency: net_totals.currency, + percentage: 0, + color: investment_contributions_category.color.presence || Category::UNCATEGORIZED_COLOR, + icon: investment_contributions_category.lucide_icon, + clickable: true + } + end + + donut_total = total + investment_contributions_total + categories = categories + .reject { |category| category[:amount].zero? } + .sort_by { |category| -category[:amount] } + + categories.each do |category| + category[:percentage] = donut_total.zero? ? 0 : ((category[:amount] / donut_total) * 100).round(1) + end + + { categories: categories, total: donut_total.to_f.round(2), currency: net_totals.currency, currency_symbol: currency_symbol } + end + + def investment_contributions_outflow_total(period) + scope = Current.family.transactions + .visible + .excluding_pending + .in_period(period) + .where(kind: "investment_contribution") + .joins(entry: :account) + .joins(<<~SQL.squish) + LEFT JOIN exchange_rates er + ON er.date = entries.date + AND er.from_currency = entries.currency + AND er.to_currency = #{ActiveRecord::Base.connection.quote(Current.family.currency)} + SQL + .merge(Account.included_in_finances_for(Current.user)) + + scope.sum("ABS(entries.amount * COALESCE(er.rate, 1))") end def ensure_intro_guest! diff --git a/app/models/income_statement/category_stats.rb b/app/models/income_statement/category_stats.rb index f9757da16..bc4dc9e7f 100644 --- a/app/models/income_statement/category_stats.rb +++ b/app/models/income_statement/category_stats.rb @@ -62,8 +62,8 @@ class IncomeStatement::CategoryStats SELECT c.id as category_id, date_trunc(:interval, ae.date) as period, - CASE WHEN t.kind = 'investment_contribution' THEN 'expense' WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END as classification, - SUM(CASE WHEN t.kind = 'investment_contribution' THEN ABS(ae.amount * COALESCE(er.rate, 1)) ELSE ae.amount * COALESCE(er.rate, 1) END) as total + CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END as classification, + SUM(ae.amount * COALESCE(er.rate, 1)) 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 @@ -80,7 +80,7 @@ class IncomeStatement::CategoryStats AND (t.extra -> 'plaid' ->> 'pending')::boolean IS DISTINCT FROM true #{exclude_tax_advantaged_sql} #{scope_to_account_ids_sql} - GROUP BY c.id, period, CASE WHEN t.kind = 'investment_contribution' THEN 'expense' WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END + GROUP BY c.id, period, CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END ) SELECT category_id, diff --git a/app/models/income_statement/family_stats.rb b/app/models/income_statement/family_stats.rb index d172d4ebf..ffb8a7f97 100644 --- a/app/models/income_statement/family_stats.rb +++ b/app/models/income_statement/family_stats.rb @@ -60,8 +60,8 @@ class IncomeStatement::FamilyStats WITH period_totals AS ( SELECT date_trunc(:interval, ae.date) as period, - CASE WHEN t.kind = 'investment_contribution' THEN 'expense' WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END as classification, - SUM(CASE WHEN t.kind = 'investment_contribution' THEN ABS(ae.amount * COALESCE(er.rate, 1)) ELSE ae.amount * COALESCE(er.rate, 1) END) as total + CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END as classification, + SUM(ae.amount * COALESCE(er.rate, 1)) 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 @@ -77,7 +77,7 @@ class IncomeStatement::FamilyStats AND (t.extra -> 'plaid' ->> 'pending')::boolean IS DISTINCT FROM true #{exclude_tax_advantaged_sql} #{scope_to_account_ids_sql} - GROUP BY period, CASE WHEN t.kind = 'investment_contribution' THEN 'expense' WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END + GROUP BY period, CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END ) SELECT classification, diff --git a/app/models/income_statement/totals.rb b/app/models/income_statement/totals.rb index 54fb56732..3d36b31d2 100644 --- a/app/models/income_statement/totals.rb +++ b/app/models/income_statement/totals.rb @@ -60,8 +60,8 @@ class IncomeStatement::Totals SELECT c.id as category_id, c.parent_id as parent_category_id, - CASE WHEN at.kind = 'investment_contribution' THEN 'expense' WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END as classification, - ABS(SUM(CASE WHEN at.kind = 'investment_contribution' THEN ABS(ae.amount * COALESCE(er.rate, 1)) ELSE ae.amount * COALESCE(er.rate, 1) END)) as total, + CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END as classification, + ABS(SUM(ae.amount * COALESCE(er.rate, 1))) as total, COUNT(ae.id) as transactions_count, false as is_uncategorized_investment FROM (#{@transactions_scope.to_sql}) at @@ -79,7 +79,7 @@ class IncomeStatement::Totals AND a.status IN ('draft', 'active') #{exclude_tax_advantaged_sql} #{include_finance_accounts_sql} - GROUP BY c.id, c.parent_id, CASE WHEN at.kind = 'investment_contribution' THEN 'expense' WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END; + GROUP BY c.id, c.parent_id, CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END; SQL end @@ -88,8 +88,8 @@ class IncomeStatement::Totals SELECT c.id as category_id, c.parent_id as parent_category_id, - CASE WHEN at.kind = 'investment_contribution' THEN 'expense' WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END as classification, - ABS(SUM(CASE WHEN at.kind = 'investment_contribution' THEN ABS(ae.amount * COALESCE(er.rate, 1)) ELSE ae.amount * COALESCE(er.rate, 1) END)) as total, + CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END as classification, + ABS(SUM(ae.amount * COALESCE(er.rate, 1))) as total, COUNT(ae.id) as entry_count, false as is_uncategorized_investment FROM (#{@transactions_scope.to_sql}) at @@ -111,7 +111,7 @@ class IncomeStatement::Totals AND a.status IN ('draft', 'active') #{exclude_tax_advantaged_sql} #{include_finance_accounts_sql} - GROUP BY c.id, c.parent_id, CASE WHEN at.kind = 'investment_contribution' THEN 'expense' WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END + GROUP BY c.id, c.parent_id, CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END SQL end diff --git a/app/models/transaction.rb b/app/models/transaction.rb index 75e36bbdc..70f588735 100644 --- a/app/models/transaction.rb +++ b/app/models/transaction.rb @@ -42,9 +42,8 @@ class Transaction < ApplicationRecord TRANSFER_KINDS = %w[funds_movement cc_payment loan_payment investment_contribution].freeze # Kinds excluded from budget/income-statement analytics. - # loan_payment and investment_contribution are intentionally NOT here — - # they represent real cash outflow from a budgeting perspective. - BUDGET_EXCLUDED_KINDS = %w[funds_movement one_time cc_payment].freeze + # loan_payment remains included as a true expense in budgeting. + BUDGET_EXCLUDED_KINDS = %w[funds_movement one_time cc_payment investment_contribution].freeze # All valid investment activity labels (for UI dropdown) ACTIVITY_LABELS = [ diff --git a/test/controllers/pages_controller_test.rb b/test/controllers/pages_controller_test.rb index 73c39f5a1..6669367d5 100644 --- a/test/controllers/pages_controller_test.rb +++ b/test/controllers/pages_controller_test.rb @@ -43,6 +43,21 @@ class PagesControllerTest < ActionDispatch::IntegrationTest assert_select "[data-controller='sankey-chart']" end + test "dashboard outflows includes investment contributions category" do + ensure_investment_contributions_category(@family) + + create_transaction( + account: accounts(:depository), + amount: 300, + kind: "investment_contribution", + category: @family.investment_contributions_category + ) + + get root_path + assert_response :ok + assert_select "#outflows-donut-section", text: /#{Regexp.escape(Category.investment_contributions_name)}/ + end + test "changelog" do VCR.use_cassette("git_repository_provider/fetch_latest_release_notes") do get changelog_path diff --git a/test/models/budget_test.rb b/test/models/budget_test.rb index f681bebc2..15502f2ae 100644 --- a/test/models/budget_test.rb +++ b/test/models/budget_test.rb @@ -197,6 +197,28 @@ class BudgetTest < ActiveSupport::TestCase assert_equal 150, spending_without_refund - spending_with_refund end + test "actual_spending excludes investment_contribution transfers" do + family = families(:dylan_family) + budget = Budget.find_or_bootstrap(family, start_date: Date.current.beginning_of_month) + account = accounts(:depository) + + baseline_spending = budget.actual_spending + + Entry.create!( + account: account, + entryable: Transaction.create!(kind: "investment_contribution"), + date: Date.current, + name: "Transfer to investment account", + amount: 250, + currency: "USD" + ) + + budget = Budget.find(budget.id) + budget.sync_budget_categories + + assert_equal baseline_spending, budget.actual_spending + end + test "most_recent_initialized_budget returns latest initialized budget before this one" do family = families(:dylan_family) diff --git a/test/models/income_statement_test.rb b/test/models/income_statement_test.rb index f1dc44435..66b1b5f89 100644 --- a/test/models/income_statement_test.rb +++ b/test/models/income_statement_test.rb @@ -286,9 +286,8 @@ class IncomeStatementTest < ActiveSupport::TestCase assert_equal Money.new(1050, @family.currency), totals.expense_money # 900 + 150 end - test "includes investment_contribution transactions as expenses in income statement" do - # Create a transfer to investment account (marked as investment_contribution) - investment_contribution = create_transaction( + test "excludes investment_contribution transactions from income statement totals" do + create_transaction( account: @checking_account, amount: 1000, category: nil, @@ -298,16 +297,14 @@ class IncomeStatementTest < ActiveSupport::TestCase income_statement = IncomeStatement.new(@family) totals = income_statement.totals(date_range: Period.last_30_days.date_range) - # investment_contribution should be included as an expense (visible in cashflow) - assert_equal 5, totals.transactions_count # Original 4 + investment_contribution + # investment_contribution should be treated as transfer for reports/budget analytics + assert_equal 4, totals.transactions_count assert_equal Money.new(1000, @family.currency), totals.income_money - assert_equal Money.new(1900, @family.currency), totals.expense_money # 900 + 1000 investment + assert_equal Money.new(900, @family.currency), totals.expense_money end - test "includes provider-imported investment_contribution inflows as expenses" do - # Simulates a 401k contribution that was auto-deducted from payroll - # Provider imports this as an inflow to the investment account (negative amount) - # but it should still appear as an expense in cashflow + test "excludes provider-imported investment_contribution inflows from income statement" do + # Simulates a provider-imported 401k contribution transaction on investment account investment_account = @family.accounts.create!( name: "401k", @@ -317,8 +314,7 @@ class IncomeStatementTest < ActiveSupport::TestCase ) # Provider-imported contribution shows as inflow (negative amount) to the investment account - # kind is investment_contribution, which should be treated as expense regardless of sign - provider_contribution = create_transaction( + create_transaction( account: investment_account, amount: -500, # Negative = inflow to account category: nil, @@ -328,10 +324,10 @@ class IncomeStatementTest < ActiveSupport::TestCase income_statement = IncomeStatement.new(@family) totals = income_statement.totals(date_range: Period.last_30_days.date_range) - # The provider-imported contribution should appear as an expense - assert_equal 5, totals.transactions_count # Original 4 + provider contribution + # investment_contribution should be excluded from income/expense analytics + assert_equal 4, totals.transactions_count assert_equal Money.new(1000, @family.currency), totals.income_money - assert_equal Money.new(1400, @family.currency), totals.expense_money # 900 + 500 (abs of -500) + assert_equal Money.new(900, @family.currency), totals.expense_money end # Tax-Advantaged Account Exclusion Tests diff --git a/test/models/transaction_test.rb b/test/models/transaction_test.rb index 17018f778..6aef43662 100644 --- a/test/models/transaction_test.rb +++ b/test/models/transaction_test.rb @@ -52,6 +52,10 @@ class TransactionTest < ActiveSupport::TestCase end end + test "BUDGET_EXCLUDED_KINDS includes investment_contribution" do + assert_includes Transaction::BUDGET_EXCLUDED_KINDS, "investment_contribution" + end + test "ACTIVITY_LABELS contains all valid labels" do assert_includes Transaction::ACTIVITY_LABELS, "Buy" assert_includes Transaction::ACTIVITY_LABELS, "Sell"