Clean up transactions controller
This commit is contained in:
@@ -3,8 +3,6 @@ class TransactionsController < ApplicationController
|
||||
|
||||
before_action :store_params!, only: :index
|
||||
|
||||
require "digest/md5"
|
||||
|
||||
def new
|
||||
super
|
||||
@income_categories = Current.family.categories.incomes.alphabetically
|
||||
@@ -13,95 +11,33 @@ class TransactionsController < ApplicationController
|
||||
|
||||
def index
|
||||
@q = search_params
|
||||
transactions_query = Transaction::Search.new(@q, family: Current.family).relation
|
||||
search = Transaction::Search.new(Current.family, filters: @q)
|
||||
@totals = Transaction::Totals.compute(search)
|
||||
transactions_query = search.relation
|
||||
|
||||
set_focused_record(transactions_query, params[:focused_record_id], default_per_page: 50)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Cache the expensive includes & pagination block so the DB work only
|
||||
# runs when either the query params change *or* any entry has been
|
||||
# updated for the current family.
|
||||
# ------------------------------------------------------------------
|
||||
items_per_page = params[:per_page].to_i.positive? ? params[:per_page].to_i : 50
|
||||
current_page = params[:page].to_i.positive? ? params[:page].to_i : 1
|
||||
|
||||
latest_update_ts = Current.family.entries.maximum(:updated_at)&.utc&.to_i || 0
|
||||
|
||||
items_per_page = (params[:per_page].presence || default_params[:per_page]).to_i
|
||||
items_per_page = 1 if items_per_page <= 0
|
||||
|
||||
current_page = (params[:page].presence || default_params[:page]).to_i
|
||||
current_page = 1 if current_page <= 0
|
||||
|
||||
# Build a compact cache digest: sanitized filters + page info + a
|
||||
# token that changes on updates *or* deletions.
|
||||
entries_changed_token = [ latest_update_ts, Current.family.entries.count ].join(":")
|
||||
|
||||
digest_source = {
|
||||
q: @q, # processed & sanitised search params
|
||||
page: current_page, # requested page number
|
||||
per: items_per_page, # page size
|
||||
tok: entries_changed_token
|
||||
}.to_json
|
||||
|
||||
cache_key = Current.family.build_cache_key(
|
||||
"transactions_idx_#{Digest::MD5.hexdigest(digest_source)}"
|
||||
)
|
||||
|
||||
cache_data = Rails.cache.fetch(cache_key, expires_in: 30.minutes) do
|
||||
current_page_i = current_page
|
||||
|
||||
# Initial query
|
||||
offset = (current_page_i - 1) * items_per_page
|
||||
ids = transactions_query
|
||||
.reverse_chronological
|
||||
.limit(items_per_page)
|
||||
.offset(offset)
|
||||
.pluck(:id)
|
||||
|
||||
total_count = transactions_query.count
|
||||
|
||||
if ids.empty? && total_count.positive? && current_page_i > 1
|
||||
current_page_i = (total_count.to_f / items_per_page).ceil
|
||||
offset = (current_page_i - 1) * items_per_page
|
||||
|
||||
ids = transactions_query
|
||||
.reverse_chronological
|
||||
.limit(items_per_page)
|
||||
.offset(offset)
|
||||
.pluck(:id)
|
||||
end
|
||||
|
||||
{ ids: ids, total_count: total_count, current_page: current_page_i }
|
||||
end
|
||||
|
||||
ids = cache_data[:ids]
|
||||
total_count = cache_data[:total_count]
|
||||
current_page = cache_data[:current_page]
|
||||
|
||||
# Build Pagy object (this part is cheap – done *after* potential
|
||||
# page fallback so the pagination UI reflects the adjusted page
|
||||
# number).
|
||||
@pagy = Pagy.new(
|
||||
count: total_count,
|
||||
page: current_page,
|
||||
items: items_per_page,
|
||||
count: transactions_query.count,
|
||||
page: current_page,
|
||||
items: items_per_page,
|
||||
params: ->(p) { p.except(:focused_record_id) }
|
||||
)
|
||||
|
||||
# Fetch the transactions in the cached order
|
||||
@transactions = Current.family.transactions
|
||||
.active
|
||||
.where(id: ids)
|
||||
.includes(
|
||||
{ entry: :account },
|
||||
:category, :merchant, :tags,
|
||||
transfer_as_outflow: { inflow_transaction: { entry: :account } },
|
||||
transfer_as_inflow: { outflow_transaction: { entry: :account } }
|
||||
)
|
||||
|
||||
# Preserve the order defined by `ids`
|
||||
@transactions = ids.map { |id| @transactions.detect { |t| t.id == id } }.compact
|
||||
|
||||
@totals = Current.family.income_statement.totals(transactions_scope: transactions_query)
|
||||
# Use Pagy's calculated page (which handles overflow) with our variables
|
||||
@transactions = transactions_query
|
||||
.reverse_chronological
|
||||
.limit(items_per_page)
|
||||
.offset((@pagy.page - 1) * items_per_page)
|
||||
.includes(
|
||||
{ entry: :account },
|
||||
:category, :merchant, :tags,
|
||||
transfer_as_outflow: { inflow_transaction: { entry: :account } },
|
||||
transfer_as_inflow: { outflow_transaction: { entry: :account } }
|
||||
)
|
||||
end
|
||||
|
||||
def clear_filter
|
||||
@@ -226,37 +162,6 @@ class TransactionsController < ApplicationController
|
||||
|
||||
cleaned_params.delete(:amount_operator) unless cleaned_params[:amount].present?
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Performance optimisation
|
||||
# -------------------------------------------------------------------
|
||||
# When a user lands on the Transactions page without an explicit date
|
||||
# filter, the previous behaviour queried *all* historical transactions
|
||||
# for the family. For large datasets this results in very expensive
|
||||
# SQL (as shown in Skylight) – particularly the aggregation queries
|
||||
# used for @totals. To keep the UI responsive while still showing a
|
||||
# sensible period of activity, we fall back to the user's preferred
|
||||
# default period (stored on User#default_period, defaulting to
|
||||
# "last_30_days") when **no** date filters have been supplied.
|
||||
#
|
||||
# This effectively changes the default view from "all-time" to a
|
||||
# rolling window, dramatically reducing the rows scanned / grouped in
|
||||
# Postgres without impacting the UX (the user can always clear the
|
||||
# filter).
|
||||
# -------------------------------------------------------------------
|
||||
if cleaned_params[:start_date].blank? && cleaned_params[:end_date].blank?
|
||||
period_key = Current.user&.default_period.presence || "last_30_days"
|
||||
|
||||
begin
|
||||
period = Period.from_key(period_key)
|
||||
cleaned_params[:start_date] = period.start_date
|
||||
cleaned_params[:end_date] = period.end_date
|
||||
rescue Period::InvalidKeyError
|
||||
# Fallback – should never happen but keeps things safe.
|
||||
cleaned_params[:start_date] = 30.days.ago.to_date
|
||||
cleaned_params[:end_date] = Date.current
|
||||
end
|
||||
end
|
||||
|
||||
cleaned_params
|
||||
end
|
||||
|
||||
@@ -264,9 +169,9 @@ class TransactionsController < ApplicationController
|
||||
if should_restore_params?
|
||||
params_to_restore = {}
|
||||
|
||||
params_to_restore[:q] = stored_params["q"].presence || default_params[:q]
|
||||
params_to_restore[:page] = stored_params["page"].presence || default_params[:page]
|
||||
params_to_restore[:per_page] = stored_params["per_page"].presence || default_params[:per_page]
|
||||
params_to_restore[:q] = stored_params["q"].presence || {}
|
||||
params_to_restore[:page] = stored_params["page"].presence || 1
|
||||
params_to_restore[:per_page] = stored_params["per_page"].presence || 50
|
||||
|
||||
redirect_to transactions_path(params_to_restore)
|
||||
else
|
||||
@@ -287,12 +192,4 @@ class TransactionsController < ApplicationController
|
||||
def stored_params
|
||||
Current.session.prev_transaction_page_params
|
||||
end
|
||||
|
||||
def default_params
|
||||
{
|
||||
q: {},
|
||||
page: 1,
|
||||
per_page: 50
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -18,9 +18,9 @@ class Transaction::Search
|
||||
|
||||
attr_reader :family
|
||||
|
||||
def initialize(attributes = {}, family:)
|
||||
def initialize(family, filters: {})
|
||||
@family = family
|
||||
super(attributes)
|
||||
super(filters)
|
||||
end
|
||||
|
||||
# Build the complete filtered relation
|
||||
|
||||
@@ -97,31 +97,97 @@ 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::Totals service 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(
|
||||
transactions_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)
|
||||
Transaction::Search.expects(:new).with(family, filters: {}).returns(search)
|
||||
Transaction::Totals.expects(:compute).once.with(search).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::Totals service 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(
|
||||
transactions_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)
|
||||
Transaction::Totals.expects(:compute).once.with(search).returns(totals)
|
||||
|
||||
get transactions_url(q: { categories: [ "Food" ], types: [ "expense" ] })
|
||||
assert_response :success
|
||||
end
|
||||
end
|
||||
|
||||
@@ -153,12 +153,8 @@ class IncomeStatementTest < ActiveSupport::TestCase
|
||||
# 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)
|
||||
inflow_transaction = create_transaction(account: @credit_card_account, amount: -500)
|
||||
|
||||
# Manually set transaction kinds to simulate transfer
|
||||
outflow_transaction.entryable.update!(kind: "transfer")
|
||||
inflow_transaction.entryable.update!(kind: "transfer")
|
||||
outflow_transaction = create_transaction(account: @checking_account, amount: 500, kind: "transfer")
|
||||
inflow_transaction = create_transaction(account: @credit_card_account, amount: -500, kind: "transfer")
|
||||
|
||||
income_statement = IncomeStatement.new(@family)
|
||||
totals = income_statement.totals
|
||||
@@ -171,8 +167,7 @@ class IncomeStatementTest < ActiveSupport::TestCase
|
||||
|
||||
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)
|
||||
loan_payment.entryable.update!(kind: "loan_payment")
|
||||
loan_payment = create_transaction(account: @checking_account, amount: 1000, category: nil, kind: "loan_payment")
|
||||
|
||||
income_statement = IncomeStatement.new(@family)
|
||||
totals = income_statement.totals
|
||||
@@ -185,8 +180,7 @@ class IncomeStatementTest < ActiveSupport::TestCase
|
||||
|
||||
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)
|
||||
one_time_transaction.entryable.update!(kind: "one_time")
|
||||
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
|
||||
@@ -199,8 +193,7 @@ class IncomeStatementTest < ActiveSupport::TestCase
|
||||
|
||||
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)
|
||||
payment_transaction.entryable.update!(kind: "payment")
|
||||
payment_transaction = create_transaction(account: @checking_account, amount: 300, category: nil, kind: "payment")
|
||||
|
||||
income_statement = IncomeStatement.new(@family)
|
||||
totals = income_statement.totals
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
require "test_helper"
|
||||
|
||||
class TransactionTest < ActiveSupport::TestCase
|
||||
class Transaction::SearchTest < ActiveSupport::TestCase
|
||||
include EntriesTestHelper
|
||||
|
||||
setup do
|
||||
@@ -15,36 +15,36 @@ class TransactionTest < ActiveSupport::TestCase
|
||||
standard_entry = create_transaction(
|
||||
account: @checking_account,
|
||||
amount: 100,
|
||||
category: categories(:food_and_drink)
|
||||
category: categories(:food_and_drink),
|
||||
kind: "standard"
|
||||
)
|
||||
standard_entry.entryable.update!(kind: "standard")
|
||||
|
||||
transfer_entry = create_transaction(
|
||||
account: @checking_account,
|
||||
amount: 200
|
||||
amount: 200,
|
||||
kind: "transfer"
|
||||
)
|
||||
transfer_entry.entryable.update!(kind: "transfer")
|
||||
|
||||
payment_entry = create_transaction(
|
||||
account: @credit_card_account,
|
||||
amount: -300
|
||||
amount: -300,
|
||||
kind: "payment"
|
||||
)
|
||||
payment_entry.entryable.update!(kind: "payment")
|
||||
|
||||
loan_payment_entry = create_transaction(
|
||||
account: @loan_account,
|
||||
amount: 400
|
||||
amount: 400,
|
||||
kind: "loan_payment"
|
||||
)
|
||||
loan_payment_entry.entryable.update!(kind: "loan_payment")
|
||||
|
||||
one_time_entry = create_transaction(
|
||||
account: @checking_account,
|
||||
amount: 500
|
||||
amount: 500,
|
||||
kind: "one_time"
|
||||
)
|
||||
one_time_entry.entryable.update!(kind: "one_time")
|
||||
|
||||
# Test transfer type filter
|
||||
transfer_results = Transaction::Search.new({ types: [ "transfer" ] }, family: @family).relation
|
||||
transfer_results = Transaction::Search.new(@family, filters: { types: [ "transfer" ] }).relation
|
||||
transfer_ids = transfer_results.pluck(:id)
|
||||
|
||||
assert_includes transfer_ids, transfer_entry.entryable.id
|
||||
@@ -54,7 +54,7 @@ class TransactionTest < ActiveSupport::TestCase
|
||||
assert_not_includes transfer_ids, loan_payment_entry.entryable.id
|
||||
|
||||
# Test expense type filter (should include loan_payment)
|
||||
expense_results = Transaction::Search.new({ types: [ "expense" ] }, family: @family).relation
|
||||
expense_results = Transaction::Search.new(@family, filters: { types: [ "expense" ] }).relation
|
||||
expense_ids = expense_results.pluck(:id)
|
||||
|
||||
assert_includes expense_ids, standard_entry.entryable.id
|
||||
@@ -66,11 +66,11 @@ class TransactionTest < ActiveSupport::TestCase
|
||||
# Test income type filter
|
||||
income_entry = create_transaction(
|
||||
account: @checking_account,
|
||||
amount: -600
|
||||
amount: -600,
|
||||
kind: "standard"
|
||||
)
|
||||
income_entry.entryable.update!(kind: "standard")
|
||||
|
||||
income_results = Transaction::Search.new({ types: [ "income" ] }, family: @family).relation
|
||||
income_results = Transaction::Search.new(@family, filters: { types: [ "income" ] }).relation
|
||||
income_ids = income_results.pluck(:id)
|
||||
|
||||
assert_includes income_ids, income_entry.entryable.id
|
||||
@@ -79,7 +79,7 @@ class TransactionTest < ActiveSupport::TestCase
|
||||
assert_not_includes income_ids, transfer_entry.entryable.id
|
||||
|
||||
# Test combined expense and income filter (excludes transfers)
|
||||
non_transfer_results = Transaction::Search.new({ types: [ "expense", "income" ] }, family: @family).relation
|
||||
non_transfer_results = Transaction::Search.new(@family, filters: { types: [ "expense", "income" ] }).relation
|
||||
non_transfer_ids = non_transfer_results.pluck(:id)
|
||||
|
||||
assert_includes non_transfer_ids, standard_entry.entryable.id
|
||||
@@ -94,24 +94,24 @@ class TransactionTest < ActiveSupport::TestCase
|
||||
# Create uncategorized transactions of different kinds
|
||||
uncategorized_standard = create_transaction(
|
||||
account: @checking_account,
|
||||
amount: 100
|
||||
amount: 100,
|
||||
kind: "standard"
|
||||
)
|
||||
uncategorized_standard.entryable.update!(kind: "standard")
|
||||
|
||||
uncategorized_transfer = create_transaction(
|
||||
account: @checking_account,
|
||||
amount: 200
|
||||
amount: 200,
|
||||
kind: "transfer"
|
||||
)
|
||||
uncategorized_transfer.entryable.update!(kind: "transfer")
|
||||
|
||||
uncategorized_loan_payment = create_transaction(
|
||||
account: @loan_account,
|
||||
amount: 300
|
||||
amount: 300,
|
||||
kind: "loan_payment"
|
||||
)
|
||||
uncategorized_loan_payment.entryable.update!(kind: "loan_payment")
|
||||
|
||||
# Search for uncategorized transactions
|
||||
uncategorized_results = Transaction::Search.new({ categories: [ "Uncategorized" ] }, family: @family).relation
|
||||
uncategorized_results = Transaction::Search.new(@family, filters: { categories: [ "Uncategorized" ] }).relation
|
||||
uncategorized_ids = uncategorized_results.pluck(:id)
|
||||
|
||||
# Should include standard and loan_payment (budget-relevant) uncategorized transactions
|
||||
@@ -127,18 +127,18 @@ class TransactionTest < ActiveSupport::TestCase
|
||||
transaction1 = create_transaction(
|
||||
account: @checking_account,
|
||||
amount: 100,
|
||||
category: categories(:food_and_drink)
|
||||
category: categories(:food_and_drink),
|
||||
kind: "standard"
|
||||
)
|
||||
transaction1.entryable.update!(kind: "standard")
|
||||
|
||||
transaction2 = create_transaction(
|
||||
account: @checking_account,
|
||||
amount: 200
|
||||
amount: 200,
|
||||
kind: "transfer"
|
||||
)
|
||||
transaction2.entryable.update!(kind: "transfer")
|
||||
|
||||
# Test new family-based API
|
||||
search = Transaction::Search.new({ types: [ "expense" ] }, family: @family)
|
||||
search = Transaction::Search.new(@family, filters: { types: [ "expense" ] })
|
||||
results = search.relation
|
||||
result_ids = results.pluck(:id)
|
||||
|
||||
@@ -154,8 +154,9 @@ class TransactionTest < ActiveSupport::TestCase
|
||||
end
|
||||
|
||||
test "family-based API requires family parameter" do
|
||||
assert_raises(ArgumentError, "missing keyword: :family") do
|
||||
Transaction::Search.new({ types: [ "expense" ] })
|
||||
assert_raises(NoMethodError) do
|
||||
search = Transaction::Search.new({ types: [ "expense" ] })
|
||||
search.relation # This will fail when trying to call .transactions on a Hash
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -11,6 +11,8 @@ class Transaction::TotalsTest < ActiveSupport::TestCase
|
||||
|
||||
# Clean up existing entries/transactions from fixtures to ensure test isolation
|
||||
@family.accounts.each { |account| account.entries.delete_all }
|
||||
|
||||
@search = Transaction::Search.new(@family)
|
||||
end
|
||||
|
||||
test "computes basic expense and income totals" do
|
||||
@@ -18,19 +20,18 @@ class Transaction::TotalsTest < ActiveSupport::TestCase
|
||||
expense_entry = create_transaction(
|
||||
account: @checking_account,
|
||||
amount: 100,
|
||||
category: categories(:food_and_drink)
|
||||
category: categories(:food_and_drink),
|
||||
kind: "standard"
|
||||
)
|
||||
expense_entry.entryable.update!(kind: "standard")
|
||||
|
||||
# Create income transaction
|
||||
income_entry = create_transaction(
|
||||
account: @checking_account,
|
||||
amount: -200
|
||||
amount: -200,
|
||||
kind: "standard"
|
||||
)
|
||||
income_entry.entryable.update!(kind: "standard")
|
||||
|
||||
search = Transaction::Search.new({}, family: @family)
|
||||
totals = Transaction::Totals.compute(search)
|
||||
totals = Transaction::Totals.compute(@search)
|
||||
|
||||
assert_equal 2, totals.transactions_count
|
||||
assert_equal Money.new(10000, "USD"), totals.expense_money # $100
|
||||
@@ -41,19 +42,18 @@ class Transaction::TotalsTest < ActiveSupport::TestCase
|
||||
# Create loan payment transaction
|
||||
loan_payment_entry = create_transaction(
|
||||
account: @loan_account,
|
||||
amount: 500
|
||||
amount: 500,
|
||||
kind: "loan_payment"
|
||||
)
|
||||
loan_payment_entry.entryable.update!(kind: "loan_payment")
|
||||
|
||||
# Create regular expense
|
||||
expense_entry = create_transaction(
|
||||
account: @checking_account,
|
||||
amount: 100
|
||||
amount: 100,
|
||||
kind: "standard"
|
||||
)
|
||||
expense_entry.entryable.update!(kind: "standard")
|
||||
|
||||
search = Transaction::Search.new({}, family: @family)
|
||||
totals = Transaction::Totals.compute(search)
|
||||
totals = Transaction::Totals.compute(@search)
|
||||
|
||||
assert_equal 2, totals.transactions_count
|
||||
assert_equal Money.new(60000, "USD"), totals.expense_money # $500 + $100
|
||||
@@ -64,31 +64,30 @@ class Transaction::TotalsTest < ActiveSupport::TestCase
|
||||
# Create transactions that should be excluded
|
||||
transfer_entry = create_transaction(
|
||||
account: @checking_account,
|
||||
amount: 100
|
||||
amount: 100,
|
||||
kind: "transfer"
|
||||
)
|
||||
transfer_entry.entryable.update!(kind: "transfer")
|
||||
|
||||
payment_entry = create_transaction(
|
||||
account: @credit_card_account,
|
||||
amount: -200
|
||||
amount: -200,
|
||||
kind: "payment"
|
||||
)
|
||||
payment_entry.entryable.update!(kind: "payment")
|
||||
|
||||
one_time_entry = create_transaction(
|
||||
account: @checking_account,
|
||||
amount: 300
|
||||
amount: 300,
|
||||
kind: "one_time"
|
||||
)
|
||||
one_time_entry.entryable.update!(kind: "one_time")
|
||||
|
||||
# Create transaction that should be included
|
||||
standard_entry = create_transaction(
|
||||
account: @checking_account,
|
||||
amount: 50
|
||||
amount: 50,
|
||||
kind: "standard"
|
||||
)
|
||||
standard_entry.entryable.update!(kind: "standard")
|
||||
|
||||
search = Transaction::Search.new({}, family: @family)
|
||||
totals = Transaction::Totals.compute(search)
|
||||
totals = Transaction::Totals.compute(@search)
|
||||
|
||||
# Only the standard transaction should be counted
|
||||
assert_equal 1, totals.transactions_count
|
||||
@@ -101,9 +100,9 @@ class Transaction::TotalsTest < ActiveSupport::TestCase
|
||||
eur_entry = create_transaction(
|
||||
account: @checking_account,
|
||||
amount: 100,
|
||||
currency: "EUR"
|
||||
currency: "EUR",
|
||||
kind: "standard"
|
||||
)
|
||||
eur_entry.entryable.update!(kind: "standard")
|
||||
|
||||
# Create exchange rate EUR -> USD
|
||||
ExchangeRate.create!(
|
||||
@@ -117,12 +116,11 @@ class Transaction::TotalsTest < ActiveSupport::TestCase
|
||||
usd_entry = create_transaction(
|
||||
account: @checking_account,
|
||||
amount: 50,
|
||||
currency: "USD"
|
||||
currency: "USD",
|
||||
kind: "standard"
|
||||
)
|
||||
usd_entry.entryable.update!(kind: "standard")
|
||||
|
||||
search = Transaction::Search.new({}, family: @family)
|
||||
totals = Transaction::Totals.compute(search)
|
||||
totals = Transaction::Totals.compute(@search)
|
||||
|
||||
assert_equal 2, totals.transactions_count
|
||||
# EUR 100 * 1.1 + USD 50 = 110 + 50 = 160
|
||||
@@ -135,12 +133,11 @@ class Transaction::TotalsTest < ActiveSupport::TestCase
|
||||
eur_entry = create_transaction(
|
||||
account: @checking_account,
|
||||
amount: 100,
|
||||
currency: "EUR"
|
||||
currency: "EUR",
|
||||
kind: "standard"
|
||||
)
|
||||
eur_entry.entryable.update!(kind: "standard")
|
||||
|
||||
search = Transaction::Search.new({}, family: @family)
|
||||
totals = Transaction::Totals.compute(search)
|
||||
totals = Transaction::Totals.compute(@search)
|
||||
|
||||
assert_equal 1, totals.transactions_count
|
||||
# Should use rate of 1 when exchange rate is missing
|
||||
@@ -153,19 +150,19 @@ class Transaction::TotalsTest < ActiveSupport::TestCase
|
||||
food_entry = create_transaction(
|
||||
account: @checking_account,
|
||||
amount: 100,
|
||||
category: categories(:food_and_drink)
|
||||
category: categories(:food_and_drink),
|
||||
kind: "standard"
|
||||
)
|
||||
food_entry.entryable.update!(kind: "standard")
|
||||
|
||||
other_entry = create_transaction(
|
||||
account: @checking_account,
|
||||
amount: 50,
|
||||
category: categories(:income)
|
||||
category: categories(:income),
|
||||
kind: "standard"
|
||||
)
|
||||
other_entry.entryable.update!(kind: "standard")
|
||||
|
||||
# Filter by food category only
|
||||
search = Transaction::Search.new({ categories: [ "Food & Drink" ] }, family: @family)
|
||||
search = Transaction::Search.new(@family, filters: { categories: [ "Food & Drink" ] })
|
||||
totals = Transaction::Totals.compute(search)
|
||||
|
||||
assert_equal 1, totals.transactions_count
|
||||
@@ -177,18 +174,18 @@ class Transaction::TotalsTest < ActiveSupport::TestCase
|
||||
# Create expense and income transactions
|
||||
expense_entry = create_transaction(
|
||||
account: @checking_account,
|
||||
amount: 100
|
||||
amount: 100,
|
||||
kind: "standard"
|
||||
)
|
||||
expense_entry.entryable.update!(kind: "standard")
|
||||
|
||||
income_entry = create_transaction(
|
||||
account: @checking_account,
|
||||
amount: -200
|
||||
amount: -200,
|
||||
kind: "standard"
|
||||
)
|
||||
income_entry.entryable.update!(kind: "standard")
|
||||
|
||||
# Filter by expense type only
|
||||
search = Transaction::Search.new({ types: [ "expense" ] }, family: @family)
|
||||
search = Transaction::Search.new(@family, filters: { types: [ "expense" ] })
|
||||
totals = Transaction::Totals.compute(search)
|
||||
|
||||
assert_equal 1, totals.transactions_count
|
||||
@@ -197,8 +194,7 @@ class Transaction::TotalsTest < ActiveSupport::TestCase
|
||||
end
|
||||
|
||||
test "handles empty results" do
|
||||
search = Transaction::Search.new({}, family: @family)
|
||||
totals = Transaction::Totals.compute(search)
|
||||
totals = Transaction::Totals.compute(@search)
|
||||
|
||||
assert_equal 0, totals.transactions_count
|
||||
assert_equal Money.new(0, "USD"), totals.expense_money
|
||||
@@ -209,27 +205,26 @@ class Transaction::TotalsTest < ActiveSupport::TestCase
|
||||
# Create an excluded transaction (should be excluded by default)
|
||||
excluded_entry = create_transaction(
|
||||
account: @checking_account,
|
||||
amount: 100
|
||||
amount: 100,
|
||||
kind: "standard"
|
||||
)
|
||||
excluded_entry.entryable.update!(kind: "standard")
|
||||
excluded_entry.update!(excluded: true) # Marks it as excluded
|
||||
|
||||
# Create a normal transaction
|
||||
normal_entry = create_transaction(
|
||||
account: @checking_account,
|
||||
amount: 50
|
||||
amount: 50,
|
||||
kind: "standard"
|
||||
)
|
||||
normal_entry.entryable.update!(kind: "standard")
|
||||
|
||||
# Default behavior should exclude excluded transactions
|
||||
search = Transaction::Search.new({}, family: @family)
|
||||
totals = Transaction::Totals.compute(search)
|
||||
totals = Transaction::Totals.compute(@search)
|
||||
|
||||
assert_equal 1, totals.transactions_count
|
||||
assert_equal Money.new(5000, "USD"), totals.expense_money # Only non-excluded transaction
|
||||
|
||||
# Explicitly include excluded transactions
|
||||
search_with_excluded = Transaction::Search.new({ excluded_transactions: true }, family: @family)
|
||||
search_with_excluded = Transaction::Search.new(@family, filters: { excluded_transactions: true })
|
||||
totals_with_excluded = Transaction::Totals.compute(search_with_excluded)
|
||||
|
||||
assert_equal 2, totals_with_excluded.transactions_count
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user