Remove exclude_from_cashflow flag and consolidate logic into excluded toggle

- Removed `exclude_from_cashflow` attribute across models, controllers, and views.
- Updated queries to rely solely on the `excluded` flag for filtering transactions and entries.
- Simplified migration by consolidating `exclude_from_cashflow` functionality into the existing `excluded` toggle.
- Refactored related tests to remove outdated logic and ensured compatibility with the updated implementation.
This commit is contained in:
Josh Waldrep
2026-01-12 15:10:33 -05:00
parent cfda5a6d3d
commit 582eda999b
12 changed files with 13 additions and 106 deletions

View File

@@ -36,7 +36,7 @@ class AccountsController < ApplicationController
@chart_view = params[:chart_view] || "balance"
@tab = params[:tab]
@q = params.fetch(:q, {}).permit(:search, status: [])
entries = @account.entries.where(excluded: false).search(@q).reverse_chronological
entries = @account.entries.search(@q).reverse_chronological
@pagy, @entries = pagy(entries, limit: params[:per_page] || "10")

View File

@@ -338,7 +338,7 @@ class ReportsController < ApplicationController
.joins(:entry)
.joins(entry: :account)
.where(accounts: { family_id: Current.family.id, status: [ "draft", "active" ] })
.where(entries: { entryable_type: "Transaction", excluded: false, exclude_from_cashflow: false, date: @period.date_range })
.where(entries: { entryable_type: "Transaction", excluded: false, date: @period.date_range })
.where.not(kind: [ "funds_movement", "one_time", "cc_payment" ])
.includes(entry: :account, category: [])
@@ -350,7 +350,7 @@ class ReportsController < ApplicationController
.joins(:entry)
.joins(entry: :account)
.where(accounts: { family_id: Current.family.id, status: [ "draft", "active" ] })
.where(entries: { entryable_type: "Trade", excluded: false, exclude_from_cashflow: false, date: @period.date_range })
.where(entries: { entryable_type: "Trade", excluded: false, date: @period.date_range })
.includes(entry: :account, category: [])
# Get sort parameters
@@ -519,7 +519,7 @@ class ReportsController < ApplicationController
.joins(:entry)
.joins(entry: :account)
.where(accounts: { family_id: Current.family.id, status: [ "draft", "active" ] })
.where(entries: { entryable_type: "Transaction", excluded: false, exclude_from_cashflow: false, date: @period.date_range })
.where(entries: { entryable_type: "Transaction", excluded: false, date: @period.date_range })
.where.not(kind: [ "funds_movement", "one_time", "cc_payment" ])
.includes(entry: :account, category: [])
@@ -556,7 +556,7 @@ class ReportsController < ApplicationController
.joins(:entry)
.joins(entry: :account)
.where(accounts: { family_id: Current.family.id, status: [ "draft", "active" ] })
.where(entries: { entryable_type: "Transaction", excluded: false, exclude_from_cashflow: false, date: @period.date_range })
.where(entries: { entryable_type: "Transaction", excluded: false, date: @period.date_range })
.where.not(kind: [ "funds_movement", "one_time", "cc_payment" ])
.includes(entry: :account, category: [])
@@ -567,7 +567,7 @@ class ReportsController < ApplicationController
.joins(:entry)
.joins(entry: :account)
.where(accounts: { family_id: Current.family.id, status: [ "draft", "active" ] })
.where(entries: { entryable_type: "Trade", excluded: false, exclude_from_cashflow: false, date: @period.date_range })
.where(entries: { entryable_type: "Trade", excluded: false, date: @period.date_range })
.includes(entry: :account, category: [])
# Group by category, type, and month

View File

@@ -217,7 +217,7 @@ class TransactionsController < ApplicationController
def entry_params
entry_params = params.require(:entry).permit(
:name, :date, :amount, :currency, :excluded, :exclude_from_cashflow, :notes, :nature, :entryable_type,
:name, :date, :amount, :currency, :excluded, :notes, :nature, :entryable_type,
entryable_attributes: [ :id, :category_id, :merchant_id, :kind, :investment_activity_label, { tag_ids: [] } ]
)

View File

@@ -49,7 +49,6 @@ class IncomeStatement::CategoryStats
WHERE a.family_id = :family_id
AND t.kind NOT IN ('funds_movement', 'one_time', 'cc_payment', 'investment_contribution')
AND ae.excluded = false
AND ae.exclude_from_cashflow = false
AND (t.extra -> 'simplefin' ->> 'pending')::boolean IS DISTINCT FROM true
AND (t.extra -> 'plaid' ->> 'pending')::boolean IS DISTINCT FROM true
GROUP BY c.id, period, CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END

View File

@@ -46,7 +46,6 @@ class IncomeStatement::FamilyStats
WHERE a.family_id = :family_id
AND t.kind NOT IN ('funds_movement', 'one_time', 'cc_payment', 'investment_contribution')
AND ae.excluded = false
AND ae.exclude_from_cashflow = false
AND (t.extra -> 'simplefin' ->> 'pending')::boolean IS DISTINCT FROM true
AND (t.extra -> 'plaid' ->> 'pending')::boolean IS DISTINCT FROM true
GROUP BY period, CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END

View File

@@ -71,8 +71,7 @@ class IncomeStatement::Totals
)
WHERE at.kind NOT IN ('funds_movement', 'one_time', 'cc_payment', 'investment_contribution')
AND ae.excluded = false
AND ae.exclude_from_cashflow = false
AND a.family_id = :family_id
AND a.family_id = :family_id
AND a.status IN ('draft', 'active')
GROUP BY c.id, c.parent_id, CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END;
SQL
@@ -98,8 +97,7 @@ class IncomeStatement::Totals
)
WHERE at.kind NOT IN ('funds_movement', 'one_time', 'cc_payment', 'investment_contribution')
AND ae.excluded = false
AND ae.exclude_from_cashflow = false
AND a.family_id = :family_id
AND a.family_id = :family_id
AND a.status IN ('draft', 'active')
GROUP BY c.id, c.parent_id, CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END
SQL
@@ -128,8 +126,7 @@ class IncomeStatement::Totals
WHERE a.family_id = :family_id
AND a.status IN ('draft', 'active')
AND ae.excluded = false
AND ae.exclude_from_cashflow = false
AND ae.date BETWEEN :start_date AND :end_date
AND ae.date BETWEEN :start_date AND :end_date
GROUP BY c.id, c.parent_id, CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END, CASE WHEN t.category_id IS NULL THEN true ELSE false END
SQL
end

View File

@@ -49,8 +49,8 @@ class Transaction::Search
Rails.cache.fetch("transaction_search_totals/#{cache_key_base}") do
result = transactions_scope
.select(
"COALESCE(SUM(CASE WHEN entries.amount >= 0 AND transactions.kind NOT IN ('funds_movement', 'cc_payment', 'investment_contribution') AND entries.exclude_from_cashflow = false THEN ABS(entries.amount * COALESCE(er.rate, 1)) ELSE 0 END), 0) as expense_total",
"COALESCE(SUM(CASE WHEN entries.amount < 0 AND transactions.kind NOT IN ('funds_movement', 'cc_payment', 'investment_contribution') AND entries.exclude_from_cashflow = false THEN ABS(entries.amount * COALESCE(er.rate, 1)) ELSE 0 END), 0) as income_total",
"COALESCE(SUM(CASE WHEN entries.amount >= 0 AND transactions.kind NOT IN ('funds_movement', 'cc_payment', 'investment_contribution') THEN ABS(entries.amount * COALESCE(er.rate, 1)) ELSE 0 END), 0) as expense_total",
"COALESCE(SUM(CASE WHEN entries.amount < 0 AND transactions.kind NOT IN ('funds_movement', 'cc_payment', 'investment_contribution') THEN ABS(entries.amount * COALESCE(er.rate, 1)) ELSE 0 END), 0) as income_total",
"COUNT(entries.id) as transactions_count"
)
.joins(

View File

@@ -78,12 +78,6 @@
</span>
<% end %>
<% if entry.exclude_from_cashflow? %>
<span class="text-secondary" title="<%= t('transactions.transaction.excluded_from_cashflow_tooltip') %>">
<%= icon "eye-off", size: "sm", color: "current" %>
</span>
<% end %>
<%# Investment activity label badge %>
<% if transaction.investment_activity_label.present? %>
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-alpha-black-50 text-secondary" title="<%= t("transactions.transaction.activity_type_tooltip") %>">

View File

@@ -203,28 +203,6 @@
<% end %>
</div>
<div class="pb-4">
<%= styled_form_with model: @entry,
url: transaction_path(@entry),
class: "p-3",
data: { controller: "auto-submit-form" } do |f| %>
<div class="flex cursor-pointer items-center gap-4 justify-between">
<div class="text-sm space-y-1">
<h4 class="text-primary"><%= t(".exclude_from_cashflow") %></h4>
<p class="text-secondary">
<% if @entry.account.investment? || @entry.account.crypto? %>
<%= t(".exclude_from_cashflow_description_investment") %>
<% else %>
<%= t(".exclude_from_cashflow_description") %>
<% end %>
</p>
</div>
<%= f.toggle :exclude_from_cashflow, { data: { auto_submit_form_target: "auto" } } %>
</div>
<% end %>
</div>
<% if @entry.account.investment? || @entry.account.crypto? %>
<div class="pb-4">
<%= styled_form_with model: @entry,

View File

@@ -33,9 +33,6 @@ en:
details: Details
exclude: Exclude
exclude_description: Excluded transactions will be removed from budgeting calculations and reports.
exclude_from_cashflow: Exclude from Cashflow
exclude_from_cashflow_description: Hide from income/expense reports and Sankey chart. Useful for transactions you don't want in cashflow analysis.
exclude_from_cashflow_description_investment: Hide from income/expense reports and Sankey chart. Use for internal investment activity like fund swaps, reinvestments, or money market sweeps.
activity_type: Activity Type
activity_type_description: Type of investment activity (Buy, Sell, Dividend, etc.). Auto-detected or set manually.
one_time_title: One-time %{type}
@@ -76,7 +73,6 @@ en:
transaction:
pending: Pending
pending_tooltip: Pending transaction — may change when posted
excluded_from_cashflow_tooltip: Excluded from cashflow reports
activity_type_tooltip: Investment activity type
possible_duplicate: Duplicate?
potential_duplicate_tooltip: This may be a duplicate of another transaction

View File

@@ -1,13 +1,5 @@
class AddInvestmentCashflowSupport < ActiveRecord::Migration[7.2]
# No-op: exclude_from_cashflow was consolidated into the existing 'excluded' toggle
def change
# Flag for excluding from cashflow (user-controllable)
# Used for internal investment activity like fund swaps
add_column :entries, :exclude_from_cashflow, :boolean, default: false, null: false
add_index :entries, :exclude_from_cashflow
# Holdings snapshot for comparison (provider-agnostic)
# Used to detect internal investment activity by comparing holdings between syncs
add_column :accounts, :holdings_snapshot_data, :jsonb
add_column :accounts, :holdings_snapshot_at, :datetime
end
end

View File

@@ -286,35 +286,6 @@ class IncomeStatementTest < ActiveSupport::TestCase
assert_equal Money.new(1050, @family.currency), totals.expense_money # 900 + 150
end
# NEW TESTS: exclude_from_cashflow Feature
test "excludes transactions with exclude_from_cashflow flag from totals" do
# Create an expense transaction and mark it as excluded from cashflow
excluded_entry = create_transaction(account: @checking_account, amount: 250, category: @groceries_category)
excluded_entry.update!(exclude_from_cashflow: true)
income_statement = IncomeStatement.new(@family)
totals = income_statement.totals(date_range: Period.last_30_days.date_range)
# Should NOT include the excluded transaction
assert_equal 4, totals.transactions_count # Only original 4 transactions
assert_equal Money.new(1000, @family.currency), totals.income_money
assert_equal Money.new(900, @family.currency), totals.expense_money
end
test "excludes income transactions with exclude_from_cashflow flag" do
# Create income and mark as excluded from cashflow
excluded_income = create_transaction(account: @checking_account, amount: -500, category: @income_category)
excluded_income.update!(exclude_from_cashflow: true)
income_statement = IncomeStatement.new(@family)
totals = income_statement.totals(date_range: Period.last_30_days.date_range)
# Should NOT include the excluded income
assert_equal 4, totals.transactions_count
assert_equal Money.new(1000, @family.currency), totals.income_money # Original income only
assert_equal Money.new(900, @family.currency), totals.expense_money
end
test "excludes investment_contribution transactions from income statement" do
# Create a transfer to investment account (marked as investment_contribution)
investment_contribution = create_transaction(
@@ -332,23 +303,4 @@ class IncomeStatementTest < ActiveSupport::TestCase
assert_equal Money.new(1000, @family.currency), totals.income_money
assert_equal Money.new(900, @family.currency), totals.expense_money
end
test "exclude_from_cashflow works with median calculations" do
# Clear existing transactions
Entry.joins(:account).where(accounts: { family_id: @family.id }).destroy_all
# Create expenses: 100, 200, 300
create_transaction(account: @checking_account, amount: 100, category: @groceries_category)
create_transaction(account: @checking_account, amount: 200, category: @groceries_category)
excluded_entry = create_transaction(account: @checking_account, amount: 300, category: @groceries_category)
# Exclude the 300 transaction from cashflow
excluded_entry.update!(exclude_from_cashflow: true)
income_statement = IncomeStatement.new(@family)
# Median should only consider non-excluded transactions (100, 200)
# Monthly total = 300, so median = 300.0
assert_equal 300.0, income_statement.median_expense(interval: "month")
end
end