1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-07-18 20:59: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:
Zach Gollwitzer 2025-06-20 13:31:58 -04:00 committed by GitHub
parent 7aca5a2277
commit 1aae00f586
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
49 changed files with 1749 additions and 705 deletions

View file

@ -313,13 +313,13 @@ end
accountable: Depository.new
)
transfer = Transfer.from_accounts(
from_account: from_account,
to_account: to_account,
transfer = Transfer::Creator.new(
family: @family,
source_account_id: from_account.id,
destination_account_id: to_account.id,
date: Date.current,
amount: 100
)
transfer.save!
).create
get api_v1_transaction_url(transfer.inflow_transaction), headers: api_headers(@api_key)
assert_response :success

View file

@ -39,9 +39,8 @@ class TradesControllerTest < ActionDispatch::IntegrationTest
assert_difference -> { Entry.count } => 2,
-> { Transaction.count } => 2,
-> { Transfer.count } => 1 do
post trades_url, params: {
entry: {
account_id: @entry.account_id,
post trades_url(account_id: @entry.account_id), params: {
model: {
type: "deposit",
date: Date.current,
amount: 10,
@ -60,9 +59,8 @@ class TradesControllerTest < ActionDispatch::IntegrationTest
assert_difference -> { Entry.count } => 2,
-> { Transaction.count } => 2,
-> { Transfer.count } => 1 do
post trades_url, params: {
entry: {
account_id: @entry.account_id,
post trades_url(account_id: @entry.account_id), params: {
model: {
type: "withdrawal",
date: Date.current,
amount: 10,
@ -79,9 +77,8 @@ class TradesControllerTest < ActionDispatch::IntegrationTest
assert_difference -> { Entry.count } => 1,
-> { Transaction.count } => 1,
-> { Transfer.count } => 0 do
post trades_url, params: {
entry: {
account_id: @entry.account_id,
post trades_url(account_id: @entry.account_id), params: {
model: {
type: "withdrawal",
date: Date.current,
amount: 10,
@ -98,9 +95,8 @@ class TradesControllerTest < ActionDispatch::IntegrationTest
test "creates interest entry" do
assert_difference [ "Entry.count", "Transaction.count" ], 1 do
post trades_url, params: {
entry: {
account_id: @entry.account_id,
post trades_url(account_id: @entry.account_id), params: {
model: {
type: "interest",
date: Date.current,
amount: 10,
@ -117,9 +113,8 @@ class TradesControllerTest < ActionDispatch::IntegrationTest
test "creates trade buy entry" do
assert_difference [ "Entry.count", "Trade.count", "Security.count" ], 1 do
post trades_url, params: {
entry: {
account_id: @entry.account_id,
post trades_url(account_id: @entry.account_id), params: {
model: {
type: "buy",
date: Date.current,
ticker: "NVDA (NASDAQ)",
@ -141,9 +136,8 @@ class TradesControllerTest < ActionDispatch::IntegrationTest
test "creates trade sell entry" do
assert_difference [ "Entry.count", "Trade.count" ], 1 do
post trades_url, params: {
entry: {
account_id: @entry.account_id,
post trades_url(account_id: @entry.account_id), params: {
model: {
type: "sell",
ticker: "AAPL (NYSE)",
date: Date.current,

View file

@ -97,31 +97,98 @@ class TransactionsControllerTest < ActionDispatch::IntegrationTest
end
test "can paginate" do
family = families(:empty)
sign_in users(:empty)
# Clean up any existing entries to ensure clean test
family.accounts.each { |account| account.entries.delete_all }
account = family.accounts.create! name: "Test", balance: 0, currency: "USD", accountable: Depository.new
# Create multiple transactions for pagination
25.times do |i|
create_transaction(
account: account,
name: "Transaction #{i + 1}",
amount: 100 + i, # Different amounts to prevent transfer matching
date: Date.current - i.days # Different dates
)
end
total_transactions = family.entries.transactions.count
assert_operator total_transactions, :>=, 20, "Should have at least 20 transactions for testing"
# Test page 1 - should show limited transactions
get transactions_url(page: 1, per_page: 10)
assert_response :success
page_1_count = css_select("turbo-frame[id^='entry_']").count
assert_equal 10, page_1_count, "Page 1 should respect per_page limit"
# Test page 2 - should show different transactions
get transactions_url(page: 2, per_page: 10)
assert_response :success
page_2_count = css_select("turbo-frame[id^='entry_']").count
assert_operator page_2_count, :>, 0, "Page 2 should show some transactions"
assert_operator page_2_count, :<=, 10, "Page 2 should not exceed per_page limit"
# Test Pagy overflow handling - should redirect or handle gracefully
get transactions_url(page: 9999999, per_page: 10)
# Either success (if Pagy shows last page) or redirect (if Pagy redirects)
assert_includes [ 200, 302 ], response.status, "Pagy should handle overflow gracefully"
if response.status == 302
follow_redirect!
assert_response :success
end
overflow_count = css_select("turbo-frame[id^='entry_']").count
assert_operator overflow_count, :>, 0, "Overflow should show some transactions"
end
test "calls Transaction::Search totals method with correct search parameters" do
family = families(:empty)
sign_in users(:empty)
account = family.accounts.create! name: "Test", balance: 0, currency: "USD", accountable: Depository.new
11.times do
create_transaction(account: account)
end
create_transaction(account: account, amount: 100)
sorted_transactions = family.entries.transactions.reverse_chronological.to_a
search = Transaction::Search.new(family)
totals = OpenStruct.new(
count: 1,
expense_money: Money.new(10000, "USD"),
income_money: Money.new(0, "USD")
)
assert_equal 11, sorted_transactions.count
get transactions_url(page: 1, per_page: 10)
expected_filters = { "start_date" => 30.days.ago.to_date }
Transaction::Search.expects(:new).with(family, filters: expected_filters).returns(search)
search.expects(:totals).once.returns(totals)
get transactions_url
assert_response :success
sorted_transactions.first(10).each do |transaction|
assert_dom "#" + dom_id(transaction), count: 1
end
end
get transactions_url(page: 2, per_page: 10)
test "calls Transaction::Search totals method with filtered search parameters" do
family = families(:empty)
sign_in users(:empty)
account = family.accounts.create! name: "Test", balance: 0, currency: "USD", accountable: Depository.new
category = family.categories.create! name: "Food", color: "#ff0000"
assert_dom "#" + dom_id(sorted_transactions.last), count: 1
create_transaction(account: account, amount: 100, category: category)
get transactions_url(page: 9999999, per_page: 10) # out of range loads last page
search = Transaction::Search.new(family, filters: { "categories" => [ "Food" ], "types" => [ "expense" ] })
totals = OpenStruct.new(
count: 1,
expense_money: Money.new(10000, "USD"),
income_money: Money.new(0, "USD")
)
assert_dom "#" + dom_id(sorted_transactions.last), count: 1
Transaction::Search.expects(:new).with(family, filters: { "categories" => [ "Food" ], "types" => [ "expense" ] }).returns(search)
search.expects(:totals).once.returns(totals)
get transactions_url(q: { categories: [ "Food" ], types: [ "expense" ] })
assert_response :success
end
end

View file

@ -2,5 +2,7 @@ one:
category: food_and_drink
merchant: amazon
transfer_out: { }
transfer_in: { }
transfer_out:
kind: payment
transfer_in:
kind: transfer

View file

@ -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

View file

@ -0,0 +1,332 @@
require "test_helper"
class Transaction::SearchTest < ActiveSupport::TestCase
include EntriesTestHelper
setup do
@family = families(:dylan_family)
@checking_account = accounts(:depository)
@credit_card_account = accounts(:credit_card)
@loan_account = accounts(:loan)
# Clean up existing entries/transactions from fixtures to ensure test isolation
@family.accounts.each { |account| account.entries.delete_all }
end
test "search filters by transaction types using kind enum" do
# Create different types of transactions using the helper method
standard_entry = create_transaction(
account: @checking_account,
amount: 100,
category: categories(:food_and_drink),
kind: "standard"
)
transfer_entry = create_transaction(
account: @checking_account,
amount: 200,
kind: "funds_movement"
)
payment_entry = create_transaction(
account: @credit_card_account,
amount: -300,
kind: "cc_payment"
)
loan_payment_entry = create_transaction(
account: @loan_account,
amount: 400,
kind: "loan_payment"
)
one_time_entry = create_transaction(
account: @checking_account,
amount: 500,
kind: "one_time"
)
# Test transfer type filter (includes loan_payment)
transfer_results = Transaction::Search.new(@family, filters: { types: [ "transfer" ] }).transactions_scope
transfer_ids = transfer_results.pluck(:id)
assert_includes transfer_ids, transfer_entry.entryable.id
assert_includes transfer_ids, payment_entry.entryable.id
assert_includes transfer_ids, loan_payment_entry.entryable.id
assert_not_includes transfer_ids, one_time_entry.entryable.id
assert_not_includes transfer_ids, standard_entry.entryable.id
# Test expense type filter (excludes transfer kinds but includes one_time)
expense_results = Transaction::Search.new(@family, filters: { types: [ "expense" ] }).transactions_scope
expense_ids = expense_results.pluck(:id)
assert_includes expense_ids, standard_entry.entryable.id
assert_includes expense_ids, one_time_entry.entryable.id
assert_not_includes expense_ids, loan_payment_entry.entryable.id
assert_not_includes expense_ids, transfer_entry.entryable.id
assert_not_includes expense_ids, payment_entry.entryable.id
# Test income type filter
income_entry = create_transaction(
account: @checking_account,
amount: -600,
kind: "standard"
)
income_results = Transaction::Search.new(@family, filters: { types: [ "income" ] }).transactions_scope
income_ids = income_results.pluck(:id)
assert_includes income_ids, income_entry.entryable.id
assert_not_includes income_ids, standard_entry.entryable.id
assert_not_includes income_ids, loan_payment_entry.entryable.id
assert_not_includes income_ids, transfer_entry.entryable.id
# Test combined expense and income filter (excludes transfer kinds but includes one_time)
non_transfer_results = Transaction::Search.new(@family, filters: { types: [ "expense", "income" ] }).transactions_scope
non_transfer_ids = non_transfer_results.pluck(:id)
assert_includes non_transfer_ids, standard_entry.entryable.id
assert_includes non_transfer_ids, income_entry.entryable.id
assert_includes non_transfer_ids, one_time_entry.entryable.id
assert_not_includes non_transfer_ids, loan_payment_entry.entryable.id
assert_not_includes non_transfer_ids, transfer_entry.entryable.id
assert_not_includes non_transfer_ids, payment_entry.entryable.id
end
test "search category filter handles uncategorized transactions correctly with kind filtering" do
# Create uncategorized transactions of different kinds
uncategorized_standard = create_transaction(
account: @checking_account,
amount: 100,
kind: "standard"
)
uncategorized_transfer = create_transaction(
account: @checking_account,
amount: 200,
kind: "funds_movement"
)
uncategorized_loan_payment = create_transaction(
account: @loan_account,
amount: 300,
kind: "loan_payment"
)
# Search for uncategorized transactions
uncategorized_results = Transaction::Search.new(@family, filters: { categories: [ "Uncategorized" ] }).transactions_scope
uncategorized_ids = uncategorized_results.pluck(:id)
# Should include standard uncategorized transactions
assert_includes uncategorized_ids, uncategorized_standard.entryable.id
# Should include loan_payment since it's treated specially in category logic
assert_includes uncategorized_ids, uncategorized_loan_payment.entryable.id
# Should exclude transfer transactions even if uncategorized
assert_not_includes uncategorized_ids, uncategorized_transfer.entryable.id
end
test "new family-based API works correctly" do
# Create transactions for testing
transaction1 = create_transaction(
account: @checking_account,
amount: 100,
category: categories(:food_and_drink),
kind: "standard"
)
transaction2 = create_transaction(
account: @checking_account,
amount: 200,
kind: "funds_movement"
)
# Test new family-based API
search = Transaction::Search.new(@family, filters: { types: [ "expense" ] })
results = search.transactions_scope
result_ids = results.pluck(:id)
# Should include expense transactions
assert_includes result_ids, transaction1.entryable.id
# Should exclude transfer transactions
assert_not_includes result_ids, transaction2.entryable.id
# Test that the relation builds from family.transactions correctly
assert_equal @family.transactions.joins(entry: :account).where(
"entries.amount >= 0 AND NOT (transactions.kind IN ('funds_movement', 'cc_payment', 'loan_payment'))"
).count, results.count
end
test "family-based API requires family parameter" do
assert_raises(NoMethodError) do
search = Transaction::Search.new({ types: [ "expense" ] })
search.transactions_scope # This will fail when trying to call .transactions on a Hash
end
end
# Totals method tests (lifted from Transaction::TotalsTest)
test "totals computes basic expense and income totals" do
# Create expense transaction
expense_entry = create_transaction(
account: @checking_account,
amount: 100,
category: categories(:food_and_drink),
kind: "standard"
)
# Create income transaction
income_entry = create_transaction(
account: @checking_account,
amount: -200,
kind: "standard"
)
search = Transaction::Search.new(@family)
totals = search.totals
assert_equal 2, totals.count
assert_equal Money.new(100, "USD"), totals.expense_money # $100
assert_equal Money.new(200, "USD"), totals.income_money # $200
end
test "totals handles multi-currency transactions with exchange rates" do
# Create EUR transaction
eur_entry = create_transaction(
account: @checking_account,
amount: 100,
currency: "EUR",
kind: "standard"
)
# Create exchange rate EUR -> USD
ExchangeRate.create!(
from_currency: "EUR",
to_currency: "USD",
rate: 1.1,
date: eur_entry.date
)
# Create USD transaction
usd_entry = create_transaction(
account: @checking_account,
amount: 50,
currency: "USD",
kind: "standard"
)
search = Transaction::Search.new(@family)
totals = search.totals
assert_equal 2, totals.count
# EUR 100 * 1.1 + USD 50 = 110 + 50 = 160
assert_equal Money.new(160, "USD"), totals.expense_money
assert_equal Money.new(0, "USD"), totals.income_money
end
test "totals handles missing exchange rates gracefully" do
# Create EUR transaction without exchange rate
eur_entry = create_transaction(
account: @checking_account,
amount: 100,
currency: "EUR",
kind: "standard"
)
search = Transaction::Search.new(@family)
totals = search.totals
assert_equal 1, totals.count
# Should use rate of 1 when exchange rate is missing
assert_equal Money.new(100, "USD"), totals.expense_money # EUR 100 * 1
assert_equal Money.new(0, "USD"), totals.income_money
end
test "totals respects category filters" do
# Create transactions in different categories
food_entry = create_transaction(
account: @checking_account,
amount: 100,
category: categories(:food_and_drink),
kind: "standard"
)
other_entry = create_transaction(
account: @checking_account,
amount: 50,
category: categories(:income),
kind: "standard"
)
# Filter by food category only
search = Transaction::Search.new(@family, filters: { categories: [ "Food & Drink" ] })
totals = search.totals
assert_equal 1, totals.count
assert_equal Money.new(100, "USD"), totals.expense_money # Only food transaction
assert_equal Money.new(0, "USD"), totals.income_money
end
test "totals respects type filters" do
# Create expense and income transactions
expense_entry = create_transaction(
account: @checking_account,
amount: 100,
kind: "standard"
)
income_entry = create_transaction(
account: @checking_account,
amount: -200,
kind: "standard"
)
# Filter by expense type only
search = Transaction::Search.new(@family, filters: { types: [ "expense" ] })
totals = search.totals
assert_equal 1, totals.count
assert_equal Money.new(100, "USD"), totals.expense_money
assert_equal Money.new(0, "USD"), totals.income_money
end
test "totals handles empty results" do
search = Transaction::Search.new(@family)
totals = search.totals
assert_equal 0, totals.count
assert_equal Money.new(0, "USD"), totals.expense_money
assert_equal Money.new(0, "USD"), totals.income_money
end
test "totals respects excluded transactions filter from search" do
# Create an excluded transaction (should be excluded by default)
excluded_entry = create_transaction(
account: @checking_account,
amount: 100,
kind: "standard"
)
excluded_entry.update!(excluded: true) # Marks it as excluded
# Create a normal transaction
normal_entry = create_transaction(
account: @checking_account,
amount: 50,
kind: "standard"
)
# Default behavior should exclude excluded transactions
search = Transaction::Search.new(@family)
totals = search.totals
assert_equal 1, totals.count
assert_equal Money.new(50, "USD"), totals.expense_money # Only non-excluded transaction
# Explicitly include excluded transactions
search_with_excluded = Transaction::Search.new(@family, filters: { excluded_transactions: true })
totals_with_excluded = search_with_excluded.totals
assert_equal 2, totals_with_excluded.count
assert_equal Money.new(150, "USD"), totals_with_excluded.expense_money # Both transactions
end
end

View file

@ -0,0 +1,166 @@
require "test_helper"
class Transfer::CreatorTest < ActiveSupport::TestCase
setup do
@family = families(:dylan_family)
@source_account = accounts(:depository)
@destination_account = accounts(:investment)
@date = Date.current
@amount = 100
end
test "creates basic transfer" do
creator = Transfer::Creator.new(
family: @family,
source_account_id: @source_account.id,
destination_account_id: @destination_account.id,
date: @date,
amount: @amount
)
transfer = creator.create
assert transfer.persisted?
assert_equal "confirmed", transfer.status
assert transfer.regular_transfer?
assert_equal "transfer", transfer.transfer_type
# Verify outflow transaction (from source account)
outflow = transfer.outflow_transaction
assert_equal "funds_movement", outflow.kind
assert_equal @amount, outflow.entry.amount
assert_equal @source_account.currency, outflow.entry.currency
assert_equal "Transfer to #{@destination_account.name}", outflow.entry.name
# Verify inflow transaction (to destination account)
inflow = transfer.inflow_transaction
assert_equal "funds_movement", inflow.kind
assert_equal(@amount * -1, inflow.entry.amount)
assert_equal @destination_account.currency, inflow.entry.currency
assert_equal "Transfer from #{@source_account.name}", inflow.entry.name
end
test "creates multi-currency transfer" do
# Use crypto account which has USD currency but different from source
crypto_account = accounts(:crypto)
creator = Transfer::Creator.new(
family: @family,
source_account_id: @source_account.id,
destination_account_id: crypto_account.id,
date: @date,
amount: @amount
)
transfer = creator.create
assert transfer.persisted?
assert transfer.regular_transfer?
assert_equal "transfer", transfer.transfer_type
# Verify outflow transaction
outflow = transfer.outflow_transaction
assert_equal "funds_movement", outflow.kind
assert_equal "Transfer to #{crypto_account.name}", outflow.entry.name
# Verify inflow transaction with currency handling
inflow = transfer.inflow_transaction
assert_equal "funds_movement", inflow.kind
assert_equal "Transfer from #{@source_account.name}", inflow.entry.name
assert_equal crypto_account.currency, inflow.entry.currency
end
test "creates loan payment" do
loan_account = accounts(:loan)
creator = Transfer::Creator.new(
family: @family,
source_account_id: @source_account.id,
destination_account_id: loan_account.id,
date: @date,
amount: @amount
)
transfer = creator.create
assert transfer.persisted?
assert transfer.loan_payment?
assert_equal "loan_payment", transfer.transfer_type
# Verify outflow transaction is marked as loan payment
outflow = transfer.outflow_transaction
assert_equal "loan_payment", outflow.kind
assert_equal "Payment to #{loan_account.name}", outflow.entry.name
# Verify inflow transaction
inflow = transfer.inflow_transaction
assert_equal "funds_movement", inflow.kind
assert_equal "Payment from #{@source_account.name}", inflow.entry.name
end
test "creates credit card payment" do
credit_card_account = accounts(:credit_card)
creator = Transfer::Creator.new(
family: @family,
source_account_id: @source_account.id,
destination_account_id: credit_card_account.id,
date: @date,
amount: @amount
)
transfer = creator.create
assert transfer.persisted?
assert transfer.liability_payment?
assert_equal "liability_payment", transfer.transfer_type
# Verify outflow transaction is marked as payment for liability
outflow = transfer.outflow_transaction
assert_equal "cc_payment", outflow.kind
assert_equal "Payment to #{credit_card_account.name}", outflow.entry.name
# Verify inflow transaction
inflow = transfer.inflow_transaction
assert_equal "funds_movement", inflow.kind
assert_equal "Payment from #{@source_account.name}", inflow.entry.name
end
test "raises error when source account ID is invalid" do
assert_raises(ActiveRecord::RecordNotFound) do
Transfer::Creator.new(
family: @family,
source_account_id: 99999,
destination_account_id: @destination_account.id,
date: @date,
amount: @amount
)
end
end
test "raises error when destination account ID is invalid" do
assert_raises(ActiveRecord::RecordNotFound) do
Transfer::Creator.new(
family: @family,
source_account_id: @source_account.id,
destination_account_id: 99999,
date: @date,
amount: @amount
)
end
end
test "raises error when source account belongs to different family" do
other_family = families(:empty)
assert_raises(ActiveRecord::RecordNotFound) do
Transfer::Creator.new(
family: other_family,
source_account_id: @source_account.id,
destination_account_id: @destination_account.id,
date: @date,
amount: @amount
)
end
end
end

View file

@ -93,36 +93,6 @@ class TransferTest < ActiveSupport::TestCase
assert_equal "Must be from same family", transfer.errors.full_messages.first
end
test "from_accounts converts amounts to the to_account's currency" do
accounts(:depository).update!(currency: "EUR")
eur_account = accounts(:depository).reload
usd_account = accounts(:credit_card)
ExchangeRate.create!(
from_currency: "EUR",
to_currency: "USD",
rate: 1.1,
date: Date.current,
)
transfer = Transfer.from_accounts(
from_account: eur_account,
to_account: usd_account,
date: Date.current,
amount: 500,
)
assert_equal 500, transfer.outflow_transaction.entry.amount
assert_equal "EUR", transfer.outflow_transaction.entry.currency
assert_equal -550, transfer.inflow_transaction.entry.amount
assert_equal "USD", transfer.inflow_transaction.entry.currency
assert_difference -> { Transfer.count } => 1 do
transfer.save!
end
end
test "transaction can only belong to one transfer" do
outflow_entry = create_transaction(date: Date.current, account: accounts(:depository), amount: 500)
inflow_entry1 = create_transaction(date: Date.current, account: accounts(:credit_card), amount: -500)

View file

@ -1,7 +1,7 @@
module EntriesTestHelper
def create_transaction(attributes = {})
entry_attributes = attributes.except(:category, :tags, :merchant)
transaction_attributes = attributes.slice(:category, :tags, :merchant)
entry_attributes = attributes.except(:category, :tags, :merchant, :kind)
transaction_attributes = attributes.slice(:category, :tags, :merchant, :kind)
entry_defaults = {
account: accounts(:depository),

View file

@ -24,7 +24,7 @@ class TradesTest < ApplicationSystemTestCase
fill_in "Ticker symbol", with: "AAPL"
fill_in "Date", with: Date.current
fill_in "Quantity", with: shares_qty
fill_in "entry[price]", with: 214.23
fill_in "model[price]", with: 214.23
click_button "Add transaction"
@ -45,7 +45,7 @@ class TradesTest < ApplicationSystemTestCase
fill_in "Ticker symbol", with: "AAPL"
fill_in "Date", with: Date.current
fill_in "Quantity", with: qty
fill_in "entry[price]", with: 215.33
fill_in "model[price]", with: 215.33
click_button "Add transaction"

View file

@ -189,7 +189,7 @@ class TransactionsTest < ApplicationSystemTestCase
end
select "Deposit", from: "Type"
fill_in "Date", with: transfer_date
fill_in "entry[amount]", with: 175.25
fill_in "model[amount]", with: 175.25
click_button "Add transaction"
within "#entry-group-" + transfer_date.to_s do
assert_text "175.25"
@ -203,6 +203,7 @@ class TransactionsTest < ApplicationSystemTestCase
inflow_entry = create_transaction("inflow", 1.day.ago.to_date, -500, account: investment_account)
@user.family.auto_match_transfers!
visit transactions_url
within "#entry-group-" + Date.current.to_s + "-totals" do
assert_text "-$100.00" # transaction eleven from setup
end