mirror of
https://github.com/we-promise/sure.git
synced 2026-04-07 14:31:25 +00:00
Add support for manual recurring transaction creation (#311)
* Support manual recurring * Automatic variance calc * Automatic variance update * Tooltip for manual * Review * Fix variance calculations Manual recurring updates collapse occurrence tracking when amounts repeat * Proper Bigdecimal calcs * Fix n+1 query * Nicer UI errors. * Style --------- Signed-off-by: Juan José Mata <juanjo.mata@gmail.com> Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
This commit is contained in:
@@ -190,4 +190,97 @@ end
|
||||
get transactions_url(q: { categories: [ "Food" ], types: [ "expense" ] })
|
||||
assert_response :success
|
||||
end
|
||||
|
||||
test "mark_as_recurring creates a manual recurring transaction" do
|
||||
family = families(:empty)
|
||||
sign_in users(:empty)
|
||||
account = family.accounts.create! name: "Test", balance: 0, currency: "USD", accountable: Depository.new
|
||||
merchant = family.merchants.create! name: "Test Merchant"
|
||||
entry = create_transaction(account: account, amount: 100, merchant: merchant)
|
||||
transaction = entry.entryable
|
||||
|
||||
assert_difference "family.recurring_transactions.count", 1 do
|
||||
post mark_as_recurring_transaction_path(transaction)
|
||||
end
|
||||
|
||||
assert_redirected_to transactions_path
|
||||
assert_equal "Transaction marked as recurring", flash[:notice]
|
||||
|
||||
recurring = family.recurring_transactions.last
|
||||
assert_equal true, recurring.manual, "Expected recurring transaction to be manual"
|
||||
assert_equal merchant.id, recurring.merchant_id
|
||||
assert_equal entry.currency, recurring.currency
|
||||
assert_equal entry.date.day, recurring.expected_day_of_month
|
||||
end
|
||||
|
||||
test "mark_as_recurring shows alert if recurring transaction already exists" do
|
||||
family = families(:empty)
|
||||
sign_in users(:empty)
|
||||
account = family.accounts.create! name: "Test", balance: 0, currency: "USD", accountable: Depository.new
|
||||
merchant = family.merchants.create! name: "Test Merchant"
|
||||
entry = create_transaction(account: account, amount: 100, merchant: merchant)
|
||||
transaction = entry.entryable
|
||||
|
||||
# Create existing recurring transaction
|
||||
family.recurring_transactions.create!(
|
||||
merchant: merchant,
|
||||
amount: entry.amount,
|
||||
currency: entry.currency,
|
||||
expected_day_of_month: entry.date.day,
|
||||
last_occurrence_date: entry.date,
|
||||
next_expected_date: 1.month.from_now,
|
||||
status: "active",
|
||||
manual: true,
|
||||
occurrence_count: 1
|
||||
)
|
||||
|
||||
assert_no_difference "RecurringTransaction.count" do
|
||||
post mark_as_recurring_transaction_path(transaction)
|
||||
end
|
||||
|
||||
assert_redirected_to transactions_path
|
||||
assert_equal "A manual recurring transaction already exists for this pattern", flash[:alert]
|
||||
end
|
||||
|
||||
test "mark_as_recurring handles validation errors gracefully" do
|
||||
family = families(:empty)
|
||||
sign_in users(:empty)
|
||||
account = family.accounts.create! name: "Test", balance: 0, currency: "USD", accountable: Depository.new
|
||||
merchant = family.merchants.create! name: "Test Merchant"
|
||||
entry = create_transaction(account: account, amount: 100, merchant: merchant)
|
||||
transaction = entry.entryable
|
||||
|
||||
# Stub create_from_transaction to raise a validation error
|
||||
RecurringTransaction.expects(:create_from_transaction).raises(
|
||||
ActiveRecord::RecordInvalid.new(
|
||||
RecurringTransaction.new.tap { |rt| rt.errors.add(:base, "Test validation error") }
|
||||
)
|
||||
)
|
||||
|
||||
assert_no_difference "RecurringTransaction.count" do
|
||||
post mark_as_recurring_transaction_path(transaction)
|
||||
end
|
||||
|
||||
assert_redirected_to transactions_path
|
||||
assert_equal "Failed to create recurring transaction. Please check the transaction details and try again.", flash[:alert]
|
||||
end
|
||||
|
||||
test "mark_as_recurring handles unexpected errors gracefully" do
|
||||
family = families(:empty)
|
||||
sign_in users(:empty)
|
||||
account = family.accounts.create! name: "Test", balance: 0, currency: "USD", accountable: Depository.new
|
||||
merchant = family.merchants.create! name: "Test Merchant"
|
||||
entry = create_transaction(account: account, amount: 100, merchant: merchant)
|
||||
transaction = entry.entryable
|
||||
|
||||
# Stub create_from_transaction to raise an unexpected error
|
||||
RecurringTransaction.expects(:create_from_transaction).raises(StandardError.new("Unexpected error"))
|
||||
|
||||
assert_no_difference "RecurringTransaction.count" do
|
||||
post mark_as_recurring_transaction_path(transaction)
|
||||
end
|
||||
|
||||
assert_redirected_to transactions_path
|
||||
assert_equal "An unexpected error occurred while creating the recurring transaction", flash[:alert]
|
||||
end
|
||||
end
|
||||
|
||||
@@ -324,4 +324,283 @@ class RecurringTransactionTest < ActiveSupport::TestCase
|
||||
assert name_based.present?
|
||||
assert_equal "Monthly Rent", name_based.name
|
||||
end
|
||||
|
||||
# Manual recurring transaction tests
|
||||
test "create_from_transaction creates a manual recurring transaction" do
|
||||
account = @family.accounts.first
|
||||
transaction = Transaction.create!(
|
||||
merchant: @merchant,
|
||||
category: categories(:food_and_drink)
|
||||
)
|
||||
entry = account.entries.create!(
|
||||
date: 2.months.ago,
|
||||
amount: 50.00,
|
||||
currency: "USD",
|
||||
name: "Test Transaction",
|
||||
entryable: transaction
|
||||
)
|
||||
|
||||
recurring = nil
|
||||
assert_difference "@family.recurring_transactions.count", 1 do
|
||||
recurring = RecurringTransaction.create_from_transaction(transaction)
|
||||
end
|
||||
|
||||
assert recurring.present?
|
||||
assert recurring.manual?
|
||||
assert_equal @merchant, recurring.merchant
|
||||
assert_equal 50.00, recurring.amount
|
||||
assert_equal "USD", recurring.currency
|
||||
assert_equal 2.months.ago.day, recurring.expected_day_of_month
|
||||
assert_equal "active", recurring.status
|
||||
assert_equal 1, recurring.occurrence_count
|
||||
# Next expected date should be in the future (either this month or next month)
|
||||
assert recurring.next_expected_date >= Date.current
|
||||
end
|
||||
|
||||
test "create_from_transaction automatically calculates amount variance from history" do
|
||||
account = @family.accounts.first
|
||||
|
||||
# Create multiple historical transactions with varying amounts on the same day of month
|
||||
amounts = [ 90.00, 100.00, 110.00, 120.00 ]
|
||||
amounts.each_with_index do |amount, i|
|
||||
transaction = Transaction.create!(
|
||||
merchant: @merchant,
|
||||
category: categories(:food_and_drink)
|
||||
)
|
||||
account.entries.create!(
|
||||
date: (amounts.size - i).months.ago.beginning_of_month + 14.days, # Day 15
|
||||
amount: amount,
|
||||
currency: "USD",
|
||||
name: "Test Transaction",
|
||||
entryable: transaction
|
||||
)
|
||||
end
|
||||
|
||||
# Mark the most recent one as recurring
|
||||
most_recent_entry = account.entries.order(date: :desc).first
|
||||
recurring = RecurringTransaction.create_from_transaction(most_recent_entry.transaction)
|
||||
|
||||
assert recurring.manual?
|
||||
assert_equal 90.00, recurring.expected_amount_min
|
||||
assert_equal 120.00, recurring.expected_amount_max
|
||||
assert_equal 105.00, recurring.expected_amount_avg # (90 + 100 + 110 + 120) / 4
|
||||
assert_equal 4, recurring.occurrence_count
|
||||
# Next expected date should be in the future
|
||||
assert recurring.next_expected_date >= Date.current
|
||||
end
|
||||
|
||||
test "create_from_transaction with single transaction sets fixed amount" do
|
||||
account = @family.accounts.first
|
||||
transaction = Transaction.create!(
|
||||
merchant: @merchant,
|
||||
category: categories(:food_and_drink)
|
||||
)
|
||||
entry = account.entries.create!(
|
||||
date: 1.month.ago,
|
||||
amount: 50.00,
|
||||
currency: "USD",
|
||||
name: "Test Transaction",
|
||||
entryable: transaction
|
||||
)
|
||||
|
||||
recurring = RecurringTransaction.create_from_transaction(transaction)
|
||||
|
||||
assert recurring.manual?
|
||||
assert_equal 50.00, recurring.expected_amount_min
|
||||
assert_equal 50.00, recurring.expected_amount_max
|
||||
assert_equal 50.00, recurring.expected_amount_avg
|
||||
assert_equal 1, recurring.occurrence_count
|
||||
# Next expected date should be in the future
|
||||
assert recurring.next_expected_date >= Date.current
|
||||
end
|
||||
|
||||
test "matching_transactions with amount variance matches within range" do
|
||||
account = @family.accounts.first
|
||||
|
||||
# Create manual recurring with variance for day 15 of the month
|
||||
recurring = @family.recurring_transactions.create!(
|
||||
merchant: @merchant,
|
||||
amount: 100.00,
|
||||
currency: "USD",
|
||||
expected_day_of_month: 15,
|
||||
last_occurrence_date: 1.month.ago,
|
||||
next_expected_date: Date.current.next_month.beginning_of_month + 14.days,
|
||||
status: "active",
|
||||
manual: true,
|
||||
expected_amount_min: 80.00,
|
||||
expected_amount_max: 120.00,
|
||||
expected_amount_avg: 100.00
|
||||
)
|
||||
|
||||
# Create transactions with varying amounts on day 14 (within ±2 days of day 15)
|
||||
transaction_within_range = Transaction.create!(merchant: @merchant, category: categories(:food_and_drink))
|
||||
entry_within = account.entries.create!(
|
||||
date: Date.current.next_month.beginning_of_month + 13.days, # Day 14
|
||||
amount: 90.00,
|
||||
currency: "USD",
|
||||
name: "Test Transaction",
|
||||
entryable: transaction_within_range
|
||||
)
|
||||
|
||||
transaction_outside_range = Transaction.create!(merchant: @merchant, category: categories(:food_and_drink))
|
||||
entry_outside = account.entries.create!(
|
||||
date: Date.current.next_month.beginning_of_month + 14.days, # Day 15
|
||||
amount: 150.00,
|
||||
currency: "USD",
|
||||
name: "Test Transaction",
|
||||
entryable: transaction_outside_range
|
||||
)
|
||||
|
||||
matches = recurring.matching_transactions
|
||||
assert_includes matches, entry_within
|
||||
assert_not_includes matches, entry_outside
|
||||
end
|
||||
|
||||
test "should_be_inactive? has longer threshold for manual recurring" do
|
||||
# Manual recurring - 6 months threshold
|
||||
manual_recurring = @family.recurring_transactions.create!(
|
||||
merchant: @merchant,
|
||||
amount: 50.00,
|
||||
currency: "USD",
|
||||
expected_day_of_month: 15,
|
||||
last_occurrence_date: 5.months.ago,
|
||||
next_expected_date: 15.days.from_now,
|
||||
status: "active",
|
||||
manual: true
|
||||
)
|
||||
|
||||
# Auto recurring - 2 months threshold with different amount to avoid unique constraint
|
||||
auto_recurring = @family.recurring_transactions.create!(
|
||||
merchant: @merchant,
|
||||
amount: 60.00,
|
||||
currency: "USD",
|
||||
expected_day_of_month: 15,
|
||||
last_occurrence_date: 3.months.ago,
|
||||
next_expected_date: 15.days.from_now,
|
||||
status: "active",
|
||||
manual: false
|
||||
)
|
||||
|
||||
assert_not manual_recurring.should_be_inactive?
|
||||
assert auto_recurring.should_be_inactive?
|
||||
end
|
||||
|
||||
test "update_amount_variance updates min/max/avg correctly" do
|
||||
recurring = @family.recurring_transactions.create!(
|
||||
merchant: @merchant,
|
||||
amount: 100.00,
|
||||
currency: "USD",
|
||||
expected_day_of_month: 15,
|
||||
last_occurrence_date: Date.current,
|
||||
next_expected_date: 1.month.from_now,
|
||||
status: "active",
|
||||
manual: true,
|
||||
occurrence_count: 1
|
||||
)
|
||||
|
||||
# Record first occurrence with amount variance
|
||||
recurring.record_occurrence!(Date.current, 100.00)
|
||||
assert_equal 100.00, recurring.expected_amount_min.to_f
|
||||
assert_equal 100.00, recurring.expected_amount_max.to_f
|
||||
assert_equal 100.00, recurring.expected_amount_avg.to_f
|
||||
|
||||
# Record second occurrence with different amount
|
||||
recurring.record_occurrence!(1.month.from_now, 120.00)
|
||||
assert_equal 100.00, recurring.expected_amount_min.to_f
|
||||
assert_equal 120.00, recurring.expected_amount_max.to_f
|
||||
assert_in_delta 110.00, recurring.expected_amount_avg.to_f, 0.01
|
||||
|
||||
# Record third occurrence with lower amount
|
||||
recurring.record_occurrence!(2.months.from_now, 90.00)
|
||||
assert_equal 90.00, recurring.expected_amount_min.to_f
|
||||
assert_equal 120.00, recurring.expected_amount_max.to_f
|
||||
assert_in_delta 103.33, recurring.expected_amount_avg.to_f, 0.01
|
||||
end
|
||||
|
||||
test "identify_patterns_for updates variance for manual recurring transactions" do
|
||||
account = @family.accounts.first
|
||||
|
||||
# Create a manual recurring transaction with initial variance
|
||||
manual_recurring = @family.recurring_transactions.create!(
|
||||
merchant: @merchant,
|
||||
amount: 50.00,
|
||||
currency: "USD",
|
||||
expected_day_of_month: 15,
|
||||
last_occurrence_date: 3.months.ago,
|
||||
next_expected_date: 1.month.from_now,
|
||||
status: "active",
|
||||
manual: true,
|
||||
occurrence_count: 1,
|
||||
expected_amount_min: 50.00,
|
||||
expected_amount_max: 50.00,
|
||||
expected_amount_avg: 50.00
|
||||
)
|
||||
|
||||
# Create new transactions with varying amounts that would match the pattern
|
||||
amounts = [ 45.00, 55.00, 60.00 ]
|
||||
amounts.each_with_index do |amount, i|
|
||||
transaction = Transaction.create!(
|
||||
merchant: @merchant,
|
||||
category: categories(:food_and_drink)
|
||||
)
|
||||
account.entries.create!(
|
||||
date: (amounts.size - i).months.ago.beginning_of_month + 14.days,
|
||||
amount: amount,
|
||||
currency: "USD",
|
||||
name: "Test Transaction",
|
||||
entryable: transaction
|
||||
)
|
||||
end
|
||||
|
||||
# Run pattern identification
|
||||
assert_no_difference "@family.recurring_transactions.count" do
|
||||
RecurringTransaction.identify_patterns_for(@family)
|
||||
end
|
||||
|
||||
# Manual recurring should be updated with new variance
|
||||
manual_recurring.reload
|
||||
assert manual_recurring.manual?
|
||||
assert_equal 45.00, manual_recurring.expected_amount_min
|
||||
assert_equal 60.00, manual_recurring.expected_amount_max
|
||||
assert_in_delta 53.33, manual_recurring.expected_amount_avg.to_f, 0.01 # (45 + 55 + 60) / 3
|
||||
assert manual_recurring.occurrence_count > 1
|
||||
end
|
||||
|
||||
test "cleaner does not delete manual recurring transactions" do
|
||||
# Create inactive manual recurring
|
||||
manual_recurring = @family.recurring_transactions.create!(
|
||||
merchant: @merchant,
|
||||
amount: 50.00,
|
||||
currency: "USD",
|
||||
expected_day_of_month: 15,
|
||||
last_occurrence_date: 1.year.ago,
|
||||
next_expected_date: 1.year.ago + 1.month,
|
||||
status: "inactive",
|
||||
manual: true,
|
||||
occurrence_count: 1
|
||||
)
|
||||
# Set updated_at to be old enough for cleanup
|
||||
manual_recurring.update_column(:updated_at, 1.year.ago)
|
||||
|
||||
# Create inactive auto recurring with different merchant
|
||||
auto_recurring = @family.recurring_transactions.create!(
|
||||
merchant: merchants(:amazon),
|
||||
amount: 30.00,
|
||||
currency: "USD",
|
||||
expected_day_of_month: 10,
|
||||
last_occurrence_date: 1.year.ago,
|
||||
next_expected_date: 1.year.ago + 1.month,
|
||||
status: "inactive",
|
||||
manual: false,
|
||||
occurrence_count: 1
|
||||
)
|
||||
# Set updated_at to be old enough for cleanup
|
||||
auto_recurring.update_column(:updated_at, 1.year.ago)
|
||||
|
||||
cleaner = RecurringTransaction::Cleaner.new(@family)
|
||||
cleaner.remove_old_inactive_transactions
|
||||
|
||||
assert RecurringTransaction.exists?(manual_recurring.id)
|
||||
assert_not RecurringTransaction.exists?(auto_recurring.id)
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user