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>
This commit is contained in:
copilot-swe-agent[bot]
2026-03-29 10:08:51 +00:00
parent 6554cbd44c
commit b1ec33da01
9 changed files with 113 additions and 33 deletions

View File

@@ -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!

View File

@@ -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,

View File

@@ -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,

View File

@@ -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

View File

@@ -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 = [

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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"