mirror of
https://github.com/we-promise/sure.git
synced 2026-04-16 10:34:09 +00:00
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:
@@ -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!
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user