mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-07-24 07:39:39 +02:00
perf(transactions): add kind
to Transaction
model and remove expensive Transfer joins in aggregations (#2388)
* add kind to transaction model * Basic transfer creator * Fix method naming conflict * Creator form pattern * Remove stale methods * Tweak migration * Remove BaseQuery, write entire query in each class for clarity * Query optimizations * Remove unused exchange rate query lines * Remove temporary cache-warming strategy * Fix test * Update transaction search * Decouple transactions endpoint from IncomeStatement * Clean up transactions controller * Update cursor rules * Cleanup comments, logic in search * Fix totals logic on transactions view * Fix pagination * Optimize search totals query * Default to last 30 days on transactions page if no filters * Decouple transactions list from transfer details * Revert transfer route * Migration reset * Bundle update * Fix matching logic, tests * Remove unused code
This commit is contained in:
parent
7aca5a2277
commit
1aae00f586
49 changed files with 1749 additions and 705 deletions
|
@ -12,6 +12,7 @@ class IncomeStatementTest < ActiveSupport::TestCase
|
|||
|
||||
@checking_account = @family.accounts.create! name: "Checking", currency: @family.currency, balance: 5000, accountable: Depository.new
|
||||
@credit_card_account = @family.accounts.create! name: "Credit Card", currency: @family.currency, balance: 1000, accountable: CreditCard.new
|
||||
@loan_account = @family.accounts.create! name: "Mortgage", currency: @family.currency, balance: 50000, accountable: Loan.new
|
||||
|
||||
create_transaction(account: @checking_account, amount: -1000, category: @income_category)
|
||||
create_transaction(account: @checking_account, amount: 200, category: @groceries_category)
|
||||
|
@ -56,4 +57,217 @@ class IncomeStatementTest < ActiveSupport::TestCase
|
|||
income_statement = IncomeStatement.new(@family)
|
||||
assert_equal 1000, income_statement.income_totals(period: Period.last_30_days).total
|
||||
end
|
||||
|
||||
# NEW TESTS: Statistical Methods
|
||||
test "calculates median expense correctly with known dataset" do
|
||||
# Clear existing transactions by deleting entries
|
||||
Entry.joins(:account).where(accounts: { family_id: @family.id }).destroy_all
|
||||
|
||||
# Create expenses: 100, 200, 300, 400, 500 (median should be 300)
|
||||
create_transaction(account: @checking_account, amount: 100, category: @groceries_category)
|
||||
create_transaction(account: @checking_account, amount: 200, category: @groceries_category)
|
||||
create_transaction(account: @checking_account, amount: 300, category: @groceries_category)
|
||||
create_transaction(account: @checking_account, amount: 400, category: @groceries_category)
|
||||
create_transaction(account: @checking_account, amount: 500, category: @groceries_category)
|
||||
|
||||
income_statement = IncomeStatement.new(@family)
|
||||
# CORRECT BUSINESS LOGIC: Calculates median of time-period totals for budget planning
|
||||
# All transactions in same month = monthly total of 1500, so median = 1500.0
|
||||
assert_equal 1500.0, income_statement.median_expense(interval: "month")
|
||||
end
|
||||
|
||||
test "calculates median income correctly with known dataset" do
|
||||
# Clear existing transactions by deleting entries
|
||||
Entry.joins(:account).where(accounts: { family_id: @family.id }).destroy_all
|
||||
|
||||
# Create income: -200, -300, -400, -500, -600 (median should be -400, displayed as 400)
|
||||
create_transaction(account: @checking_account, amount: -200, category: @income_category)
|
||||
create_transaction(account: @checking_account, amount: -300, category: @income_category)
|
||||
create_transaction(account: @checking_account, amount: -400, category: @income_category)
|
||||
create_transaction(account: @checking_account, amount: -500, category: @income_category)
|
||||
create_transaction(account: @checking_account, amount: -600, category: @income_category)
|
||||
|
||||
income_statement = IncomeStatement.new(@family)
|
||||
# CORRECT BUSINESS LOGIC: Calculates median of time-period totals for budget planning
|
||||
# All transactions in same month = monthly total of -2000, so median = 2000.0
|
||||
assert_equal 2000.0, income_statement.median_income(interval: "month")
|
||||
end
|
||||
|
||||
test "calculates average expense correctly with known dataset" do
|
||||
# Clear existing transactions by deleting entries
|
||||
Entry.joins(:account).where(accounts: { family_id: @family.id }).destroy_all
|
||||
|
||||
# Create expenses: 100, 200, 300 (average should be 200)
|
||||
create_transaction(account: @checking_account, amount: 100, category: @groceries_category)
|
||||
create_transaction(account: @checking_account, amount: 200, category: @groceries_category)
|
||||
create_transaction(account: @checking_account, amount: 300, category: @groceries_category)
|
||||
|
||||
income_statement = IncomeStatement.new(@family)
|
||||
# CORRECT BUSINESS LOGIC: Calculates average of time-period totals for budget planning
|
||||
# All transactions in same month = monthly total of 600, so average = 600.0
|
||||
assert_equal 600.0, income_statement.avg_expense(interval: "month")
|
||||
end
|
||||
|
||||
test "calculates category-specific median expense" do
|
||||
# Clear existing transactions by deleting entries
|
||||
Entry.joins(:account).where(accounts: { family_id: @family.id }).destroy_all
|
||||
|
||||
# Create different amounts for groceries vs other food
|
||||
other_food_category = @family.categories.create! name: "Restaurants", classification: "expense", parent: @food_category
|
||||
|
||||
# Groceries: 100, 300, 500 (median = 300)
|
||||
create_transaction(account: @checking_account, amount: 100, category: @groceries_category)
|
||||
create_transaction(account: @checking_account, amount: 300, category: @groceries_category)
|
||||
create_transaction(account: @checking_account, amount: 500, category: @groceries_category)
|
||||
|
||||
# Restaurants: 50, 150 (median = 100)
|
||||
create_transaction(account: @checking_account, amount: 50, category: other_food_category)
|
||||
create_transaction(account: @checking_account, amount: 150, category: other_food_category)
|
||||
|
||||
income_statement = IncomeStatement.new(@family)
|
||||
# CORRECT BUSINESS LOGIC: Calculates median of time-period totals for budget planning
|
||||
# All groceries in same month = monthly total of 900, so median = 900.0
|
||||
assert_equal 900.0, income_statement.median_expense(interval: "month", category: @groceries_category)
|
||||
# For restaurants: monthly total = 200, so median = 200.0
|
||||
restaurants_median = income_statement.median_expense(interval: "month", category: other_food_category)
|
||||
assert_equal 200.0, restaurants_median
|
||||
end
|
||||
|
||||
test "calculates category-specific average expense" do
|
||||
# Clear existing transactions by deleting entries
|
||||
Entry.joins(:account).where(accounts: { family_id: @family.id }).destroy_all
|
||||
|
||||
# Create different amounts for groceries
|
||||
# Groceries: 100, 200, 300 (average = 200)
|
||||
create_transaction(account: @checking_account, amount: 100, category: @groceries_category)
|
||||
create_transaction(account: @checking_account, amount: 200, category: @groceries_category)
|
||||
create_transaction(account: @checking_account, amount: 300, category: @groceries_category)
|
||||
|
||||
income_statement = IncomeStatement.new(@family)
|
||||
# CORRECT BUSINESS LOGIC: Calculates average of time-period totals for budget planning
|
||||
# All transactions in same month = monthly total of 600, so average = 600.0
|
||||
assert_equal 600.0, income_statement.avg_expense(interval: "month", category: @groceries_category)
|
||||
end
|
||||
|
||||
# NEW TESTS: Transfer and Kind Filtering
|
||||
# NOTE: These tests now pass because kind filtering is working after the refactoring!
|
||||
test "excludes regular transfers from income statement calculations" do
|
||||
# Create a regular transfer between accounts
|
||||
outflow_transaction = create_transaction(account: @checking_account, amount: 500, kind: "funds_movement")
|
||||
inflow_transaction = create_transaction(account: @credit_card_account, amount: -500, kind: "funds_movement")
|
||||
|
||||
income_statement = IncomeStatement.new(@family)
|
||||
totals = income_statement.totals
|
||||
|
||||
# NOW WORKING: Excludes transfers correctly after refactoring
|
||||
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 "includes loan payments as expenses in income statement" do
|
||||
# Create a loan payment transaction
|
||||
loan_payment = create_transaction(account: @checking_account, amount: 1000, category: nil, kind: "loan_payment")
|
||||
|
||||
income_statement = IncomeStatement.new(@family)
|
||||
totals = income_statement.totals
|
||||
|
||||
# CONTINUES TO WORK: Includes loan payments as expenses (loan_payment not in exclusion list)
|
||||
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 # 900 + 1000
|
||||
end
|
||||
|
||||
test "excludes one-time transactions from income statement calculations" do
|
||||
# Create a one-time transaction
|
||||
one_time_transaction = create_transaction(account: @checking_account, amount: 250, category: @groceries_category, kind: "one_time")
|
||||
|
||||
income_statement = IncomeStatement.new(@family)
|
||||
totals = income_statement.totals
|
||||
|
||||
# NOW WORKING: Excludes one-time transactions correctly after refactoring
|
||||
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 payment transactions from income statement calculations" do
|
||||
# Create a payment transaction (credit card payment)
|
||||
payment_transaction = create_transaction(account: @checking_account, amount: 300, category: nil, kind: "cc_payment")
|
||||
|
||||
income_statement = IncomeStatement.new(@family)
|
||||
totals = income_statement.totals
|
||||
|
||||
# NOW WORKING: Excludes payment transactions correctly after refactoring
|
||||
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
|
||||
|
||||
# NEW TESTS: Interval-Based Calculations
|
||||
test "different intervals return different statistical results with multi-period data" do
|
||||
# Clear existing transactions
|
||||
Entry.joins(:account).where(accounts: { family_id: @family.id }).destroy_all
|
||||
|
||||
# Create transactions across multiple weeks to test interval behavior
|
||||
# Week 1: 100, 200 (total: 300, median: 150)
|
||||
create_transaction(account: @checking_account, amount: 100, category: @groceries_category, date: 3.weeks.ago)
|
||||
create_transaction(account: @checking_account, amount: 200, category: @groceries_category, date: 3.weeks.ago + 1.day)
|
||||
|
||||
# Week 2: 400, 600 (total: 1000, median: 500)
|
||||
create_transaction(account: @checking_account, amount: 400, category: @groceries_category, date: 2.weeks.ago)
|
||||
create_transaction(account: @checking_account, amount: 600, category: @groceries_category, date: 2.weeks.ago + 1.day)
|
||||
|
||||
# Week 3: 800 (total: 800, median: 800)
|
||||
create_transaction(account: @checking_account, amount: 800, category: @groceries_category, date: 1.week.ago)
|
||||
|
||||
income_statement = IncomeStatement.new(@family)
|
||||
|
||||
month_median = income_statement.median_expense(interval: "month")
|
||||
week_median = income_statement.median_expense(interval: "week")
|
||||
|
||||
# CRITICAL TEST: Different intervals should return different results
|
||||
# Month interval: median of monthly totals (if all in same month) vs individual transactions
|
||||
# Week interval: median of weekly totals [300, 1000, 800] = 800 vs individual transactions [100,200,400,600,800] = 400
|
||||
refute_equal month_median, week_median, "Different intervals should return different statistical results when data spans multiple time periods"
|
||||
|
||||
# Both should still be numeric
|
||||
assert month_median.is_a?(Numeric)
|
||||
assert week_median.is_a?(Numeric)
|
||||
assert month_median > 0
|
||||
assert week_median > 0
|
||||
end
|
||||
|
||||
# NEW TESTS: Edge Cases
|
||||
test "handles empty dataset gracefully" do
|
||||
# Create a truly empty family
|
||||
empty_family = Family.create!(name: "Empty Test Family", currency: "USD")
|
||||
income_statement = IncomeStatement.new(empty_family)
|
||||
|
||||
# Should return 0 for statistical measures
|
||||
assert_equal 0, income_statement.median_expense(interval: "month")
|
||||
assert_equal 0, income_statement.median_income(interval: "month")
|
||||
assert_equal 0, income_statement.avg_expense(interval: "month")
|
||||
end
|
||||
|
||||
test "handles category not found gracefully" do
|
||||
nonexistent_category = Category.new(id: 99999, name: "Nonexistent")
|
||||
|
||||
income_statement = IncomeStatement.new(@family)
|
||||
|
||||
assert_equal 0, income_statement.median_expense(interval: "month", category: nonexistent_category)
|
||||
assert_equal 0, income_statement.avg_expense(interval: "month", category: nonexistent_category)
|
||||
end
|
||||
|
||||
test "handles transactions without categories" do
|
||||
# Create transaction without category
|
||||
create_transaction(account: @checking_account, amount: 150, category: nil)
|
||||
|
||||
income_statement = IncomeStatement.new(@family)
|
||||
totals = income_statement.totals
|
||||
|
||||
# Should still include uncategorized transaction in totals
|
||||
assert_equal 5, totals.transactions_count
|
||||
assert_equal Money.new(1050, @family.currency), totals.expense_money # 900 + 150
|
||||
end
|
||||
end
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue