Treat investment_contribution as a transfer in Reports/Budget (#1313)

Investment contributions are transfers between accounts (checking ->
investment), not expenses. Counting them as expenses inflated budget
totals and made "spending" look larger than it really was.

- Add investment_contribution to BUDGET_EXCLUDED_KINDS so it's filtered
  out of Reports, Budget, and the income statement by default.
- Remove the SQL CASE branch that force-classified
  investment_contribution rows as expense regardless of entry sign.
- Add an `include_kinds:` opt-in parameter on IncomeStatement
  (totals/expense_totals/income_totals/net_category_totals) so callers
  can re-include otherwise-excluded kinds. Used by the dashboard
  cashflow Sankey and outflows donut so contributions still appear as
  outflows where users expect to see where their cash went.
- Bump totals_query cache key to v3 and include the kinds key so cached
  results aren't reused across different include_kinds calls.

loan_payment stays in-budget (it's real cash outflow); withdrawals and
tax handling were already correct and are unchanged.

https://claude.ai/code/session_01EAD3jkLeHSsSTnJ32Po9em
This commit is contained in:
Claude
2026-04-16 16:29:59 +00:00
parent 3eedf5137d
commit 968c661b06
7 changed files with 182 additions and 54 deletions

View File

@@ -15,11 +15,15 @@ class PagesController < ApplicationController
family_currency = Current.family.currency
# Use IncomeStatement for all cashflow data (now includes categorized trades)
# Use IncomeStatement for all cashflow data (now includes categorized trades).
# On the dashboard we opt investment_contribution back in via include_kinds
# so contributions to investment/crypto accounts still appear as outflows in
# the cashflow Sankey and outflows donut. Reports/Budget keep them excluded.
income_statement = Current.family.income_statement
income_totals = income_statement.income_totals(period: @period)
expense_totals = income_statement.expense_totals(period: @period)
net_totals = income_statement.net_category_totals(period: @period)
cashflow_kinds = [ "investment_contribution" ]
income_totals = income_statement.income_totals(period: @period, include_kinds: cashflow_kinds)
expense_totals = income_statement.expense_totals(period: @period, include_kinds: cashflow_kinds)
net_totals = income_statement.net_category_totals(period: @period, include_kinds: cashflow_kinds)
@cashflow_sankey_data = build_cashflow_sankey_data(net_totals, income_totals, expense_totals, family_currency)
@outflows_data = build_outflows_donut_data(net_totals)

View File

@@ -12,12 +12,12 @@ class IncomeStatement
@user = user || Current.user
end
def totals(transactions_scope: nil, date_range:)
def totals(transactions_scope: nil, date_range:, include_kinds: [])
# Default to excluding pending transactions from budget/analytics calculations
# Pending transactions shouldn't affect budget totals until they post
transactions_scope ||= family.transactions.visible.excluding_pending
result = totals_query(transactions_scope: transactions_scope, date_range: date_range)
result = totals_query(transactions_scope: transactions_scope, date_range: date_range, include_kinds: include_kinds)
total_income = result.select { |t| t.classification == "income" }.sum(&:total)
total_expense = result.select { |t| t.classification == "expense" }.sum(&:total)
@@ -29,17 +29,17 @@ class IncomeStatement
)
end
def expense_totals(period: Period.current_month)
build_period_total(classification: "expense", period: period)
def expense_totals(period: Period.current_month, include_kinds: [])
build_period_total(classification: "expense", period: period, include_kinds: include_kinds)
end
def income_totals(period: Period.current_month)
build_period_total(classification: "income", period: period)
def income_totals(period: Period.current_month, include_kinds: [])
build_period_total(classification: "income", period: period, include_kinds: include_kinds)
end
def net_category_totals(period: Period.current_month)
expense = expense_totals(period: period)
income = income_totals(period: period)
def net_category_totals(period: Period.current_month, include_kinds: [])
expense = expense_totals(period: period, include_kinds: include_kinds)
income = income_totals(period: period, include_kinds: include_kinds)
# Use a stable key for each category: id for persisted, invariant token for synthetic
cat_key = ->(ct) {
@@ -126,9 +126,9 @@ class IncomeStatement
@categories ||= family.categories.all.to_a
end
def build_period_total(classification:, period:)
def build_period_total(classification:, period:, include_kinds: [])
# Exclude pending transactions from budget calculations
totals = totals_query(transactions_scope: family.transactions.visible.excluding_pending.in_period(period), date_range: period.date_range).select { |t| t.classification == classification }
totals = totals_query(transactions_scope: family.transactions.visible.excluding_pending.in_period(period), date_range: period.date_range, include_kinds: include_kinds).select { |t| t.classification == classification }
classification_total = totals.sum(&:total)
uncategorized_category = family.categories.uncategorized
@@ -195,12 +195,13 @@ class IncomeStatement
@included_account_ids_hash ||= included_account_ids ? Digest::MD5.hexdigest(included_account_ids.sort.join(",")) : nil
end
def totals_query(transactions_scope:, date_range:)
def totals_query(transactions_scope:, date_range:, include_kinds: [])
sql_hash = Digest::MD5.hexdigest(transactions_scope.to_sql)
kinds_key = Array(include_kinds).map(&:to_s).sort.join(",")
Rails.cache.fetch([
"income_statement", "totals_query", "v2", family.id, user&.id, included_account_ids_hash, sql_hash, date_range.begin, date_range.end, family.entries_cache_version
]) { Totals.new(family, transactions_scope: transactions_scope, date_range: date_range, included_account_ids: included_account_ids).call }
"income_statement", "totals_query", "v3", family.id, user&.id, included_account_ids_hash, sql_hash, date_range.begin, date_range.end, kinds_key, family.entries_cache_version
]) { Totals.new(family, transactions_scope: transactions_scope, date_range: date_range, included_account_ids: included_account_ids, include_kinds: include_kinds).call }
end
def monetizable_currency

View File

@@ -1,8 +1,9 @@
class IncomeStatement::CategoryStats
def initialize(family, interval: "month", account_ids: nil)
def initialize(family, interval: "month", account_ids: nil, include_kinds: [])
@family = family
@interval = interval
@account_ids = account_ids
@include_kinds = Array(include_kinds).map(&:to_s)
end
def call
@@ -42,7 +43,28 @@ class IncomeStatement::CategoryStats
end
def budget_excluded_kinds_sql
@budget_excluded_kinds_sql ||= Transaction::BUDGET_EXCLUDED_KINDS.map { |k| "'#{k}'" }.join(", ")
@budget_excluded_kinds_sql ||= begin
kinds = Transaction::BUDGET_EXCLUDED_KINDS - @include_kinds
kinds.map { |k| "'#{k}'" }.join(", ")
end
end
def force_expense_kinds
kinds = [ "loan_payment" ]
kinds << "investment_contribution" if @include_kinds.include?("investment_contribution")
kinds
end
def force_expense_kinds_sql
force_expense_kinds.map { |k| "'#{k}'" }.join(", ")
end
def classification_case_sql
"CASE WHEN t.kind IN (#{force_expense_kinds_sql}) THEN 'expense' WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END"
end
def amount_case_sql
"CASE WHEN t.kind IN (#{force_expense_kinds_sql}) THEN ABS(ae.amount * COALESCE(er.rate, 1)) ELSE ae.amount * COALESCE(er.rate, 1) END"
end
def pending_providers_sql
@@ -66,8 +88,8 @@ 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_case_sql} as classification,
SUM(#{amount_case_sql}) 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
@@ -83,7 +105,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_case_sql}
)
SELECT
category_id,

View File

@@ -1,8 +1,9 @@
class IncomeStatement::FamilyStats
def initialize(family, interval: "month", account_ids: nil)
def initialize(family, interval: "month", account_ids: nil, include_kinds: [])
@family = family
@interval = interval
@account_ids = account_ids
@include_kinds = Array(include_kinds).map(&:to_s)
end
def call
@@ -41,7 +42,28 @@ class IncomeStatement::FamilyStats
end
def budget_excluded_kinds_sql
@budget_excluded_kinds_sql ||= Transaction::BUDGET_EXCLUDED_KINDS.map { |k| "'#{k}'" }.join(", ")
@budget_excluded_kinds_sql ||= begin
kinds = Transaction::BUDGET_EXCLUDED_KINDS - @include_kinds
kinds.map { |k| "'#{k}'" }.join(", ")
end
end
def force_expense_kinds
kinds = [ "loan_payment" ]
kinds << "investment_contribution" if @include_kinds.include?("investment_contribution")
kinds
end
def force_expense_kinds_sql
force_expense_kinds.map { |k| "'#{k}'" }.join(", ")
end
def classification_case_sql
"CASE WHEN t.kind IN (#{force_expense_kinds_sql}) THEN 'expense' WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END"
end
def amount_case_sql
"CASE WHEN t.kind IN (#{force_expense_kinds_sql}) THEN ABS(ae.amount * COALESCE(er.rate, 1)) ELSE ae.amount * COALESCE(er.rate, 1) END"
end
def pending_providers_sql
@@ -64,8 +86,8 @@ 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_case_sql} as classification,
SUM(#{amount_case_sql}) 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 +102,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_case_sql}
)
SELECT
classification,

View File

@@ -1,10 +1,11 @@
class IncomeStatement::Totals
def initialize(family, transactions_scope:, date_range:, include_trades: true, included_account_ids: nil)
def initialize(family, transactions_scope:, date_range:, include_trades: true, included_account_ids: nil, include_kinds: [])
@family = family
@transactions_scope = transactions_scope
@date_range = date_range
@include_trades = include_trades
@included_account_ids = included_account_ids
@include_kinds = Array(include_kinds).map(&:to_s)
validate_date_range!
end
@@ -60,8 +61,8 @@ 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_case_sql} as classification,
ABS(SUM(#{amount_case_sql})) as total,
COUNT(ae.id) as transactions_count,
false as is_uncategorized_investment
FROM (#{@transactions_scope.to_sql}) at
@@ -79,7 +80,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 IN ('investment_contribution', 'loan_payment') THEN 'expense' WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END;
GROUP BY c.id, c.parent_id, #{classification_case_sql};
SQL
end
@@ -88,8 +89,8 @@ 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_case_sql} as classification,
ABS(SUM(#{amount_case_sql})) as total,
COUNT(ae.id) as entry_count,
false as is_uncategorized_investment
FROM (#{@transactions_scope.to_sql}) at
@@ -111,10 +112,33 @@ 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 IN ('investment_contribution', 'loan_payment') THEN 'expense' WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END
GROUP BY c.id, c.parent_id, #{classification_case_sql}
SQL
end
# Kinds that are normally treated as transfers (sign-driven income/expense)
# but should be force-classified as expense. loan_payment is always such a
# kind. investment_contribution joins the list when callers explicitly
# opt it back in via `include_kinds:` (e.g., dashboard outflows) so the
# outflow side and any provider-imported inflow side both render as outflow.
def force_expense_kinds
kinds = [ "loan_payment" ]
kinds << "investment_contribution" if @include_kinds.include?("investment_contribution")
kinds
end
def force_expense_kinds_sql
force_expense_kinds.map { |k| "'#{k}'" }.join(", ")
end
def classification_case_sql
"CASE WHEN at.kind IN (#{force_expense_kinds_sql}) THEN 'expense' WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END"
end
def amount_case_sql
"CASE WHEN at.kind IN (#{force_expense_kinds_sql}) THEN ABS(ae.amount * COALESCE(er.rate, 1)) ELSE ae.amount * COALESCE(er.rate, 1) END"
end
def trades_subquery_sql
# Trades are completely excluded from income/expense budgets
# Rationale: Trades represent portfolio rebalancing, not cash flow
@@ -160,7 +184,10 @@ class IncomeStatement::Totals
end
def budget_excluded_kinds_sql
@budget_excluded_kinds_sql ||= Transaction::BUDGET_EXCLUDED_KINDS.map { |k| "'#{k}'" }.join(", ")
@budget_excluded_kinds_sql ||= begin
kinds = Transaction::BUDGET_EXCLUDED_KINDS - @include_kinds
kinds.map { |k| "'#{k}'" }.join(", ")
end
end
def validate_date_range!

View File

@@ -71,7 +71,7 @@ class Transaction < ApplicationRecord
cc_payment: "cc_payment", # A CC payment, excluded from budget analytics (CC payments offset the sum of expense transactions)
loan_payment: "loan_payment", # A payment to a Loan account, treated as an expense in budgets
one_time: "one_time", # A one-time expense/income, excluded from budget analytics
investment_contribution: "investment_contribution" # Transfer to investment/crypto account, treated as an expense in budgets
investment_contribution: "investment_contribution" # Transfer to investment/crypto account, excluded from budget analytics (treated as a transfer)
}
# All kinds where money moves between accounts (transfer? returns true).
@@ -79,9 +79,12 @@ 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
# investment_contribution is excluded so contributions to investment/crypto
# accounts don't show up as expenses in Reports/Budget — they're transfers,
# not spending. Dashboard outflows can opt them back in via `include_kinds:`.
# loan_payment is intentionally NOT here — it represents real cash outflow
# from a budgeting perspective.
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

@@ -286,8 +286,9 @@ 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)
test "excludes investment_contribution transactions from income statement by default" do
# Investment contributions are transfers to investment/crypto accounts and
# should NOT show up as expenses in Reports/Budget — see issue #1313.
investment_contribution = create_transaction(
account: @checking_account,
amount: 1000,
@@ -298,16 +299,17 @@ 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 excluded from expense totals
assert_equal 4, totals.transactions_count # Only the original 4 transactions
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 by default" do
# Simulates a 401k contribution that was auto-deducted from payroll.
# Provider imports this as an inflow to the investment account (negative amount).
# It must NOT appear as income — investment_contribution kinds are excluded
# from the income statement entirely (issue #1313).
investment_account = @family.accounts.create!(
name: "401k",
@@ -316,8 +318,6 @@ class IncomeStatementTest < ActiveSupport::TestCase
accountable: Investment.new
)
# 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(
account: investment_account,
amount: -500, # Negative = inflow to account
@@ -328,10 +328,59 @@ 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
# Excluded from both income and expense totals
assert_equal 4, totals.transactions_count # Only original 4 transactions
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
test "includes investment_contribution as expense when include_kinds is set" do
# Dashboard outflows opt investment_contribution back in so users can still
# see where their cash went (issue #1313).
create_transaction(
account: @checking_account,
amount: 1000,
category: nil,
kind: "investment_contribution"
)
income_statement = IncomeStatement.new(@family)
totals = income_statement.totals(
date_range: Period.last_30_days.date_range,
include_kinds: [ "investment_contribution" ]
)
assert_equal 5, totals.transactions_count
assert_equal Money.new(1000, @family.currency), totals.income_money
assert_equal Money.new(1900, @family.currency), totals.expense_money
end
test "includes provider-imported investment_contribution inflows as expense when include_kinds is set" do
investment_account = @family.accounts.create!(
name: "401k",
currency: @family.currency,
balance: 10000,
accountable: Investment.new
)
create_transaction(
account: investment_account,
amount: -500, # Negative = inflow to account
category: nil,
kind: "investment_contribution"
)
income_statement = IncomeStatement.new(@family)
totals = income_statement.totals(
date_range: Period.last_30_days.date_range,
include_kinds: [ "investment_contribution" ]
)
# The CASE force-classifies investment_contribution rows as expense even
# when amount < 0 (the inflow side of a contribution).
assert_equal 5, totals.transactions_count
assert_equal Money.new(1000, @family.currency), totals.income_money
assert_equal Money.new(1400, @family.currency), totals.expense_money # 900 + abs(-500)
end
# Tax-Advantaged Account Exclusion Tests