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:
parent
7aca5a2277
commit
1aae00f586
49 changed files with 1749 additions and 705 deletions
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
6
test/fixtures/transactions.yml
vendored
6
test/fixtures/transactions.yml
vendored
|
@ -2,5 +2,7 @@ one:
|
|||
category: food_and_drink
|
||||
merchant: amazon
|
||||
|
||||
transfer_out: { }
|
||||
transfer_in: { }
|
||||
transfer_out:
|
||||
kind: payment
|
||||
transfer_in:
|
||||
kind: transfer
|
|
@ -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
|
||||
|
|
332
test/models/transaction/search_test.rb
Normal file
332
test/models/transaction/search_test.rb
Normal 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
|
166
test/models/transfer/creator_test.rb
Normal file
166
test/models/transfer/creator_test.rb
Normal 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
|
|
@ -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)
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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"
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue