Skip to content

Commit 51eb140

Browse files
fix(transactions): include one-time in reports, keep out of budget medians
Split transfer exclusions from one-time: historical totals, reports, and period expense/income use REPORT_EXCLUDED_KINDS; budget medians and budget category actuals still use BUDGET_EXCLUDED_KINDS. Fixes #1994
1 parent ca89541 commit 51eb140

7 files changed

Lines changed: 70 additions & 37 deletions

File tree

app/controllers/reports_controller.rb

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -362,7 +362,7 @@ def build_transactions_breakdown
362362
.joins(entry: :account)
363363
.where(accounts: { family_id: Current.family.id, status: [ "draft", "active" ] })
364364
.where(entries: { entryable_type: "Transaction", excluded: false, date: @period.date_range })
365-
.where.not(kind: Transaction::BUDGET_EXCLUDED_KINDS)
365+
.where.not(kind: Transaction::REPORT_EXCLUDED_KINDS)
366366
.includes(entry: :account, category: :parent)
367367

368368
# Apply filters (includes finance account scoping)
@@ -666,7 +666,7 @@ def build_transactions_breakdown_for_export
666666
.joins(entry: :account)
667667
.where(accounts: { family_id: Current.family.id, status: [ "draft", "active" ] })
668668
.where(entries: { entryable_type: "Transaction", excluded: false, date: @period.date_range })
669-
.where.not(kind: Transaction::BUDGET_EXCLUDED_KINDS)
669+
.where.not(kind: Transaction::REPORT_EXCLUDED_KINDS)
670670
.includes(entry: :account, category: [])
671671

672672
transactions = apply_transaction_filters(transactions)
@@ -703,7 +703,7 @@ def build_monthly_breakdown_for_export
703703
.joins(entry: :account)
704704
.where(accounts: { family_id: Current.family.id, status: [ "draft", "active" ] })
705705
.where(entries: { entryable_type: "Transaction", excluded: false, date: @period.date_range })
706-
.where.not(kind: Transaction::BUDGET_EXCLUDED_KINDS)
706+
.where.not(kind: Transaction::REPORT_EXCLUDED_KINDS)
707707
.includes(entry: :account, category: [])
708708

709709
transactions = apply_transaction_filters(transactions)

app/models/budget.rb

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -316,15 +316,15 @@ def income_statement
316316
end
317317

318318
def net_totals
319-
@net_totals ||= income_statement.net_category_totals(period: period)
319+
@net_totals ||= income_statement.net_category_totals(period: period, for_budget: true)
320320
end
321321

322322
def expense_totals
323-
@expense_totals ||= income_statement.expense_totals(period: period)
323+
@expense_totals ||= income_statement.expense_totals(period: period, for_budget: true)
324324
end
325325

326326
def income_totals
327-
@income_totals ||= income_statement.income_totals(period: period)
327+
@income_totals ||= income_statement.income_totals(period: period, for_budget: true)
328328
end
329329

330330
def expense_totals_by_category

app/models/income_statement.rb

Lines changed: 34 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -29,26 +29,29 @@ def totals(transactions_scope: nil, date_range:)
2929
)
3030
end
3131

32-
def expense_totals(period: Period.current_month)
32+
def expense_totals(period: Period.current_month, for_budget: false)
3333
# Memoized per instance so callers that also invoke `net_category_totals`
3434
@expense_totals_by_period ||= {}
35-
@expense_totals_by_period[period_cache_key(period)] ||=
36-
build_period_total(classification: "expense", period: period)
35+
cache_key = [ period_cache_key(period), for_budget ]
36+
@expense_totals_by_period[cache_key] ||=
37+
build_period_total(classification: "expense", period: period, for_budget: for_budget)
3738
end
3839

39-
def income_totals(period: Period.current_month)
40+
def income_totals(period: Period.current_month, for_budget: false)
4041
@income_totals_by_period ||= {}
41-
@income_totals_by_period[period_cache_key(period)] ||=
42-
build_period_total(classification: "income", period: period)
42+
cache_key = [ period_cache_key(period), for_budget ]
43+
@income_totals_by_period[cache_key] ||=
44+
build_period_total(classification: "income", period: period, for_budget: for_budget)
4345
end
4446

45-
def net_category_totals(period: Period.current_month)
47+
def net_category_totals(period: Period.current_month, for_budget: false)
4648
@net_category_totals_by_period ||= {}
47-
cached = @net_category_totals_by_period[period_cache_key(period)]
49+
cache_key = [ period_cache_key(period), for_budget ]
50+
cached = @net_category_totals_by_period[cache_key]
4851
return cached if cached
4952

50-
expense = expense_totals(period: period)
51-
income = income_totals(period: period)
53+
expense = expense_totals(period: period, for_budget: for_budget)
54+
income = income_totals(period: period, for_budget: for_budget)
5255

5356
# Use a stable key for each category: id for persisted, invariant token for synthetic
5457
cat_key = ->(ct) {
@@ -96,7 +99,7 @@ def net_category_totals(period: Period.current_month)
9699
CategoryTotal.new(category: r[:category], total: r[:total], currency: family.currency, weight: weight)
97100
end
98101

99-
@net_category_totals_by_period[period_cache_key(period)] = NetCategoryTotals.new(
102+
@net_category_totals_by_period[cache_key] = NetCategoryTotals.new(
100103
net_expense_categories: net_expense_categories,
101104
net_income_categories: net_income_categories,
102105
total_net_expense: total_net_expense,
@@ -139,9 +142,14 @@ def period_cache_key(period)
139142
[ period.start_date, period.end_date ]
140143
end
141144

142-
def build_period_total(classification:, period:)
145+
def build_period_total(classification:, period:, for_budget: false)
146+
excluded_kinds = for_budget ? Transaction::BUDGET_EXCLUDED_KINDS : Transaction::REPORT_EXCLUDED_KINDS
143147
# Exclude pending transactions from budget calculations
144-
totals = totals_query(transactions_scope: family.transactions.visible.excluding_pending.in_period(period), date_range: period.date_range).select { |t| t.classification == classification }
148+
totals = totals_query(
149+
transactions_scope: family.transactions.visible.excluding_pending.in_period(period),
150+
date_range: period.date_range,
151+
excluded_kinds: excluded_kinds
152+
).select { |t| t.classification == classification }
145153
classification_total = totals.sum(&:total)
146154

147155
uncategorized_category = family.categories.uncategorized
@@ -208,12 +216,22 @@ def included_account_ids_hash
208216
@included_account_ids_hash ||= included_account_ids ? Digest::MD5.hexdigest(included_account_ids.sort.join(",")) : nil
209217
end
210218

211-
def totals_query(transactions_scope:, date_range:)
219+
def totals_query(transactions_scope:, date_range:, excluded_kinds: Transaction::REPORT_EXCLUDED_KINDS)
212220
sql_hash = Digest::MD5.hexdigest(transactions_scope.to_sql)
221+
kinds_key = excluded_kinds.join(",")
213222

214223
Rails.cache.fetch([
215-
"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
216-
]) { Totals.new(family, transactions_scope: transactions_scope, date_range: date_range, included_account_ids: included_account_ids).call }
224+
"income_statement", "totals_query", "v3", family.id, user&.id, included_account_ids_hash, sql_hash,
225+
date_range.begin, date_range.end, kinds_key, family.entries_cache_version
226+
]) {
227+
Totals.new(
228+
family,
229+
transactions_scope: transactions_scope,
230+
date_range: date_range,
231+
included_account_ids: included_account_ids,
232+
excluded_kinds: excluded_kinds
233+
).call
234+
}
217235
end
218236

219237
def monetizable_currency

app/models/income_statement/totals.rb

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
class IncomeStatement::Totals
2-
def initialize(family, transactions_scope:, date_range:, include_trades: true, included_account_ids: nil)
2+
def initialize(family, transactions_scope:, date_range:, include_trades: true, included_account_ids: nil,
3+
excluded_kinds: Transaction::REPORT_EXCLUDED_KINDS)
34
@family = family
45
@transactions_scope = transactions_scope
56
@date_range = date_range
67
@include_trades = include_trades
78
@included_account_ids = included_account_ids
9+
@excluded_kinds = excluded_kinds
810

911
validate_date_range!
1012
end
@@ -73,7 +75,7 @@ def transactions_only_query_sql
7375
er.from_currency = ae.currency AND
7476
er.to_currency = :target_currency
7577
)
76-
WHERE at.kind NOT IN (#{budget_excluded_kinds_sql})
78+
WHERE at.kind NOT IN (#{excluded_kinds_sql})
7779
AND ae.excluded = false
7880
AND a.family_id = :family_id
7981
AND a.status IN ('draft', 'active')
@@ -101,7 +103,7 @@ def transactions_subquery_sql
101103
er.from_currency = ae.currency AND
102104
er.to_currency = :target_currency
103105
)
104-
WHERE at.kind NOT IN (#{budget_excluded_kinds_sql})
106+
WHERE at.kind NOT IN (#{excluded_kinds_sql})
105107
AND (
106108
at.investment_activity_label IS NULL
107109
OR at.investment_activity_label NOT IN ('Transfer', 'Sweep In', 'Sweep Out', 'Exchange')
@@ -159,8 +161,8 @@ def include_finance_accounts_sql
159161
"AND a.id IN (:included_account_ids)"
160162
end
161163

162-
def budget_excluded_kinds_sql
163-
@budget_excluded_kinds_sql ||= Transaction::BUDGET_EXCLUDED_KINDS.map { |k| "'#{k}'" }.join(", ")
164+
def excluded_kinds_sql
165+
@excluded_kinds_sql ||= @excluded_kinds.map { |k| "'#{k}'" }.join(", ")
164166
end
165167

166168
def validate_date_range!

app/models/transaction.rb

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,18 +71,21 @@ def exchange_rate_must_be_valid
7171
funds_movement: "funds_movement", # Movement of funds between accounts, excluded from budget analytics
7272
cc_payment: "cc_payment", # A CC payment, excluded from budget analytics (CC payments offset the sum of expense transactions)
7373
loan_payment: "loan_payment", # A payment to a Loan account, treated as an expense in budgets
74-
one_time: "one_time", # A one-time expense/income, excluded from budget analytics
74+
one_time: "one_time", # A one-time expense/income; included in historical reports, excluded from budget medians
7575
investment_contribution: "investment_contribution" # Transfer to investment/crypto account, treated as an expense in budgets
7676
}
7777

7878
# All kinds where money moves between accounts (transfer? returns true).
7979
# Used for search filters, rule conditions, and UI display.
8080
TRANSFER_KINDS = %w[funds_movement cc_payment loan_payment investment_contribution].freeze
8181

82-
# Kinds excluded from budget/income-statement analytics.
82+
# Kinds excluded from historical reports and period totals (transfers / CC payments only).
83+
REPORT_EXCLUDED_KINDS = %w[funds_movement cc_payment].freeze
84+
85+
# Kinds excluded from budget medians, burn-rate stats, and budget category actuals.
8386
# loan_payment and investment_contribution are intentionally NOT here —
8487
# they represent real cash outflow from a budgeting perspective.
85-
BUDGET_EXCLUDED_KINDS = %w[funds_movement one_time cc_payment].freeze
88+
BUDGET_EXCLUDED_KINDS = (REPORT_EXCLUDED_KINDS + %w[one_time]).freeze
8689

8790
# All valid investment activity labels (for UI dropdown)
8891
ACTIVITY_LABELS = [

config/locales/views/transactions/en.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ en:
7575
activity_type: Activity Type
7676
activity_type_description: Type of investment activity (Buy, Sell, Dividend, etc.). Auto-detected or set manually.
7777
one_time_title: One-time %{type}
78-
one_time_description: One-time transactions will be excluded from certain budgeting calculations and reports to help you see what's really important.
78+
one_time_description: One-time transactions appear in historical reports and totals but are excluded from budget medians and recurring spending estimates.
7979
convert_to_trade_title: Convert to Security Trade
8080
convert_to_trade_description: Convert this transaction into a Buy or Sell trade with security details for portfolio tracking.
8181
convert_to_trade_button: Convert to Trade

test/models/income_statement_test.rb

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -179,17 +179,27 @@ class IncomeStatementTest < ActiveSupport::TestCase
179179
assert_equal Money.new(1900, @family.currency), totals.expense_money # 900 + 1000
180180
end
181181

182-
test "excludes one-time transactions from income statement calculations" do
183-
# Create a one-time transaction
184-
one_time_transaction = create_transaction(account: @checking_account, amount: 250, category: @groceries_category, kind: "one_time")
182+
test "includes one-time transactions in income statement totals and reports" do
183+
create_transaction(account: @checking_account, amount: 250, category: @groceries_category, kind: "one_time")
185184

186185
income_statement = IncomeStatement.new(@family)
187186
totals = income_statement.totals(date_range: Period.last_30_days.date_range)
188187

189-
# NOW WORKING: Excludes one-time transactions correctly after refactoring
190-
assert_equal 4, totals.transactions_count # Only original 4 transactions
188+
assert_equal 5, totals.transactions_count
191189
assert_equal Money.new(1000, @family.currency), totals.income_money
192-
assert_equal Money.new(900, @family.currency), totals.expense_money
190+
assert_equal Money.new(1150, @family.currency), totals.expense_money
191+
192+
expense_totals = income_statement.expense_totals(period: Period.last_30_days)
193+
assert_equal 200 + 300 + 400 + 250, expense_totals.total
194+
end
195+
196+
test "excludes one-time transactions from budget-scoped period totals" do
197+
create_transaction(account: @checking_account, amount: 250, category: @groceries_category, kind: "one_time")
198+
199+
income_statement = IncomeStatement.new(@family)
200+
expense_totals = income_statement.expense_totals(period: Period.last_30_days, for_budget: true)
201+
202+
assert_equal 200 + 300 + 400, expense_totals.total
193203
end
194204

195205
test "excludes payment transactions from income statement calculations" do

0 commit comments

Comments
 (0)