mirror of
https://github.com/we-promise/sure.git
synced 2026-04-19 03:54:08 +00:00
Recurring transactions (#271)
* Implement recurring transactions support * Amount fix * Hide section when any filter is applied * Add automatic identify feature Automatic identification runs after: - CSV Import completes (TransactionImport, TradeImport, AccountImport, MintImport) - Plaid sync completes - SimpleFIN sync completes - LunchFlow sync completes - Any new provider that we create. * Fix linter and tests * Fix address review * FIX proper text sizing * Fix further linter Use circular distance to handle month-boundary wrapping * normalize to a circular representation before computing the median * Better tests validation * Added some UI info Fix pattern identification, last recurrent transaction needs to happened within the last 45 days. * Fix styling * Revert text subdued look * Match structure of the other sections * Styling * Restore positive amounts styling * Shorten label for UI styling --------- Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
This commit is contained in:
248
test/models/recurring_transaction/identifier_test.rb
Normal file
248
test/models/recurring_transaction/identifier_test.rb
Normal file
@@ -0,0 +1,248 @@
|
||||
require "test_helper"
|
||||
|
||||
class RecurringTransaction::IdentifierTest < ActiveSupport::TestCase
|
||||
def setup
|
||||
@family = families(:dylan_family)
|
||||
@identifier = RecurringTransaction::Identifier.new(@family)
|
||||
@family.recurring_transactions.destroy_all
|
||||
end
|
||||
|
||||
test "identifies recurring pattern with transactions on similar days mid-month" do
|
||||
account = @family.accounts.first
|
||||
merchant = merchants(:netflix)
|
||||
|
||||
# Create 3 transactions on days 5, 6, 7 (clearly clustered)
|
||||
[ 5, 6, 7 ].each_with_index do |day, i|
|
||||
transaction = Transaction.create!(
|
||||
merchant: merchant,
|
||||
category: categories(:food_and_drink)
|
||||
)
|
||||
account.entries.create!(
|
||||
date: i.months.ago.beginning_of_month + (day - 1).days,
|
||||
amount: 15.99,
|
||||
currency: "USD",
|
||||
name: "Netflix Subscription",
|
||||
entryable: transaction
|
||||
)
|
||||
end
|
||||
|
||||
patterns_count = @identifier.identify_recurring_patterns
|
||||
|
||||
assert_equal 1, patterns_count
|
||||
assert_equal 1, @family.recurring_transactions.count
|
||||
|
||||
recurring = @family.recurring_transactions.first
|
||||
assert_equal merchant, recurring.merchant
|
||||
assert_equal 15.99, recurring.amount
|
||||
assert_in_delta 6, recurring.expected_day_of_month, 1 # Should be around day 6
|
||||
end
|
||||
|
||||
test "identifies recurring pattern with transactions wrapping month boundary" do
|
||||
account = @family.accounts.first
|
||||
merchant = merchants(:netflix)
|
||||
|
||||
# Create 3 transactions on days 30, 31, 1 (wraps around month boundary)
|
||||
dates = [
|
||||
2.months.ago.end_of_month - 1.day, # Day 30
|
||||
1.month.ago.end_of_month, # Day 31
|
||||
Date.current.beginning_of_month # Day 1
|
||||
]
|
||||
|
||||
dates.each do |date|
|
||||
transaction = Transaction.create!(
|
||||
merchant: merchant,
|
||||
category: categories(:food_and_drink)
|
||||
)
|
||||
account.entries.create!(
|
||||
date: date,
|
||||
amount: 15.99,
|
||||
currency: "USD",
|
||||
name: "Netflix Subscription",
|
||||
entryable: transaction
|
||||
)
|
||||
end
|
||||
|
||||
patterns_count = @identifier.identify_recurring_patterns
|
||||
|
||||
assert_equal 1, patterns_count, "Should identify pattern wrapping month boundary"
|
||||
assert_equal 1, @family.recurring_transactions.count
|
||||
|
||||
recurring = @family.recurring_transactions.first
|
||||
assert_equal merchant, recurring.merchant
|
||||
assert_equal 15.99, recurring.amount
|
||||
# Add validation that expected_day is near 31 or 1, not mid-month
|
||||
assert_includes [ 30, 31, 1 ], recurring.expected_day_of_month,
|
||||
"Expected day should be near month boundary (30, 31, or 1), not mid-month. Got: #{recurring.expected_day_of_month}"
|
||||
end
|
||||
|
||||
test "identifies recurring pattern with transactions spanning end and start of month" do
|
||||
account = @family.accounts.first
|
||||
merchant = merchants(:netflix)
|
||||
|
||||
# Create 3 transactions on days 28, 29, 30, 31, 1, 2 (should cluster with circular distance)
|
||||
dates = [
|
||||
3.months.ago.end_of_month - 3.days, # Day 28
|
||||
2.months.ago.end_of_month - 2.days, # Day 29
|
||||
2.months.ago.end_of_month - 1.day, # Day 30
|
||||
1.month.ago.end_of_month, # Day 31
|
||||
Date.current.beginning_of_month, # Day 1
|
||||
Date.current.beginning_of_month + 1.day # Day 2
|
||||
]
|
||||
|
||||
dates.each do |date|
|
||||
transaction = Transaction.create!(
|
||||
merchant: merchant,
|
||||
category: categories(:food_and_drink)
|
||||
)
|
||||
account.entries.create!(
|
||||
date: date,
|
||||
amount: 15.99,
|
||||
currency: "USD",
|
||||
name: "Netflix Subscription",
|
||||
entryable: transaction
|
||||
)
|
||||
end
|
||||
|
||||
patterns_count = @identifier.identify_recurring_patterns
|
||||
|
||||
assert_equal 1, patterns_count, "Should identify pattern with circular clustering at month boundary"
|
||||
assert_equal 1, @family.recurring_transactions.count
|
||||
|
||||
recurring = @family.recurring_transactions.first
|
||||
assert_equal merchant, recurring.merchant
|
||||
assert_equal 15.99, recurring.amount
|
||||
# Validate expected_day falls within the cluster range (28-31 or 1-2), not an outlier like day 15
|
||||
assert_includes (28..31).to_a + [ 1, 2 ], recurring.expected_day_of_month,
|
||||
"Expected day should be within cluster range (28-31 or 1-2), not mid-month. Got: #{recurring.expected_day_of_month}"
|
||||
end
|
||||
|
||||
test "does not identify pattern when days are not clustered" do
|
||||
account = @family.accounts.first
|
||||
merchant = merchants(:netflix)
|
||||
|
||||
# Create 3 transactions on days 1, 15, 30 (widely spread, should not cluster)
|
||||
[ 1, 15, 30 ].each_with_index do |day, i|
|
||||
transaction = Transaction.create!(
|
||||
merchant: merchant,
|
||||
category: categories(:food_and_drink)
|
||||
)
|
||||
account.entries.create!(
|
||||
date: i.months.ago.beginning_of_month + (day - 1).days,
|
||||
amount: 15.99,
|
||||
currency: "USD",
|
||||
name: "Netflix Subscription",
|
||||
entryable: transaction
|
||||
)
|
||||
end
|
||||
|
||||
patterns_count = @identifier.identify_recurring_patterns
|
||||
|
||||
assert_equal 0, patterns_count
|
||||
assert_equal 0, @family.recurring_transactions.count
|
||||
end
|
||||
|
||||
test "does not identify pattern with fewer than 3 occurrences" do
|
||||
account = @family.accounts.first
|
||||
merchant = merchants(:netflix)
|
||||
|
||||
# Create only 2 transactions
|
||||
[ 5, 6 ].each_with_index do |day, i|
|
||||
transaction = Transaction.create!(
|
||||
merchant: merchant,
|
||||
category: categories(:food_and_drink)
|
||||
)
|
||||
account.entries.create!(
|
||||
date: i.months.ago.beginning_of_month + (day - 1).days,
|
||||
amount: 15.99,
|
||||
currency: "USD",
|
||||
name: "Netflix Subscription",
|
||||
entryable: transaction
|
||||
)
|
||||
end
|
||||
|
||||
patterns_count = @identifier.identify_recurring_patterns
|
||||
|
||||
assert_equal 0, patterns_count
|
||||
assert_equal 0, @family.recurring_transactions.count
|
||||
end
|
||||
|
||||
test "updates existing recurring transaction when pattern is found again" do
|
||||
account = @family.accounts.first
|
||||
merchant = merchants(:amazon) # Use different merchant to avoid fixture conflicts
|
||||
|
||||
# Create initial recurring transaction
|
||||
existing = @family.recurring_transactions.create!(
|
||||
merchant: merchant,
|
||||
amount: 29.99,
|
||||
currency: "USD",
|
||||
expected_day_of_month: 15,
|
||||
last_occurrence_date: 2.months.ago.to_date,
|
||||
next_expected_date: 1.month.ago.to_date,
|
||||
occurrence_count: 2,
|
||||
status: "active"
|
||||
)
|
||||
|
||||
# Create 3 new transactions on similar clustered days
|
||||
[ 0, 1, 2 ].each do |months_ago|
|
||||
transaction = Transaction.create!(
|
||||
merchant: merchant,
|
||||
category: categories(:food_and_drink)
|
||||
)
|
||||
account.entries.create!(
|
||||
date: months_ago.months.ago.beginning_of_month + 14.days, # Day 15
|
||||
amount: 29.99,
|
||||
currency: "USD",
|
||||
name: "Amazon Purchase",
|
||||
entryable: transaction
|
||||
)
|
||||
end
|
||||
|
||||
assert_no_difference "@family.recurring_transactions.count" do
|
||||
@identifier.identify_recurring_patterns
|
||||
end
|
||||
|
||||
recurring = @family.recurring_transactions.first
|
||||
assert_equal existing.id, recurring.id, "Should update existing recurring transaction"
|
||||
assert_equal "active", recurring.status
|
||||
# Verify last_occurrence_date was updated
|
||||
assert recurring.last_occurrence_date >= 2.months.ago.to_date
|
||||
end
|
||||
|
||||
test "circular_distance calculates correct distance for days near month boundary" do
|
||||
# Test wrapping: day 31 to day 1 should be distance 1 (31 -> 1 is one day forward)
|
||||
distance = @identifier.send(:circular_distance, 31, 1)
|
||||
assert_equal 1, distance
|
||||
|
||||
# Test wrapping: day 1 to day 31 should also be distance 1 (wraps backward)
|
||||
distance = @identifier.send(:circular_distance, 1, 31)
|
||||
assert_equal 1, distance
|
||||
|
||||
# Test wrapping: day 30 to day 2 should be distance 3 (30->31->1->2 = 3 steps)
|
||||
distance = @identifier.send(:circular_distance, 30, 2)
|
||||
assert_equal 3, distance
|
||||
|
||||
# Test non-wrapping: day 15 to day 10 should be distance 5
|
||||
distance = @identifier.send(:circular_distance, 15, 10)
|
||||
assert_equal 5, distance
|
||||
|
||||
# Test same day: distance should be 0
|
||||
distance = @identifier.send(:circular_distance, 15, 15)
|
||||
assert_equal 0, distance
|
||||
end
|
||||
|
||||
test "days_cluster_together returns true for days wrapping month boundary" do
|
||||
# Days 29, 30, 31, 1, 2 should cluster (circular distance)
|
||||
days = [ 29, 30, 31, 1, 2 ]
|
||||
assert @identifier.send(:days_cluster_together?, days), "Should cluster with circular distance"
|
||||
end
|
||||
|
||||
test "days_cluster_together returns true for consecutive mid-month days" do
|
||||
days = [ 10, 11, 12, 13 ]
|
||||
assert @identifier.send(:days_cluster_together?, days)
|
||||
end
|
||||
|
||||
test "days_cluster_together returns false for widely spread days" do
|
||||
days = [ 1, 15, 30 ]
|
||||
assert_not @identifier.send(:days_cluster_together?, days)
|
||||
end
|
||||
end
|
||||
172
test/models/recurring_transaction_test.rb
Normal file
172
test/models/recurring_transaction_test.rb
Normal file
@@ -0,0 +1,172 @@
|
||||
require "test_helper"
|
||||
|
||||
class RecurringTransactionTest < ActiveSupport::TestCase
|
||||
def setup
|
||||
@family = families(:dylan_family)
|
||||
@merchant = merchants(:netflix)
|
||||
# Clear any existing recurring transactions
|
||||
@family.recurring_transactions.destroy_all
|
||||
end
|
||||
|
||||
test "identify_patterns_for creates recurring transactions for patterns with 3+ occurrences" do
|
||||
# Create a series of transactions with same merchant and amount on similar days
|
||||
# Use dates within the last 3 months: today, 1 month ago, 2 months ago
|
||||
account = @family.accounts.first
|
||||
[ 0, 1, 2 ].each do |months_ago|
|
||||
transaction = Transaction.create!(
|
||||
merchant: @merchant,
|
||||
category: categories(:food_and_drink)
|
||||
)
|
||||
account.entries.create!(
|
||||
date: months_ago.months.ago.beginning_of_month + 5.days,
|
||||
amount: 15.99,
|
||||
currency: "USD",
|
||||
name: "Netflix Subscription",
|
||||
entryable: transaction
|
||||
)
|
||||
end
|
||||
|
||||
assert_difference "@family.recurring_transactions.count", 1 do
|
||||
RecurringTransaction.identify_patterns_for(@family)
|
||||
end
|
||||
|
||||
recurring = @family.recurring_transactions.last
|
||||
assert_equal @merchant, recurring.merchant
|
||||
assert_equal 15.99, recurring.amount
|
||||
assert_equal "USD", recurring.currency
|
||||
assert_equal "active", recurring.status
|
||||
assert_equal 3, recurring.occurrence_count
|
||||
end
|
||||
|
||||
test "identify_patterns_for does not create recurring transaction for less than 3 occurrences" do
|
||||
# Create only 2 transactions
|
||||
account = @family.accounts.first
|
||||
2.times do |i|
|
||||
transaction = Transaction.create!(
|
||||
merchant: @merchant,
|
||||
category: categories(:food_and_drink)
|
||||
)
|
||||
account.entries.create!(
|
||||
date: (i + 1).months.ago.beginning_of_month + 5.days,
|
||||
amount: 15.99,
|
||||
currency: "USD",
|
||||
name: "Netflix Subscription",
|
||||
entryable: transaction
|
||||
)
|
||||
end
|
||||
|
||||
assert_no_difference "@family.recurring_transactions.count" do
|
||||
RecurringTransaction.identify_patterns_for(@family)
|
||||
end
|
||||
end
|
||||
|
||||
test "calculate_next_expected_date handles end of month correctly" do
|
||||
recurring = @family.recurring_transactions.create!(
|
||||
merchant: @merchant,
|
||||
amount: 29.99,
|
||||
currency: "USD",
|
||||
expected_day_of_month: 31,
|
||||
last_occurrence_date: Date.new(2025, 1, 31),
|
||||
next_expected_date: Date.new(2025, 2, 28),
|
||||
status: "active"
|
||||
)
|
||||
|
||||
# February doesn't have 31 days, should return last day of February
|
||||
next_date = recurring.calculate_next_expected_date(Date.new(2025, 1, 31))
|
||||
assert_equal Date.new(2025, 2, 28), next_date
|
||||
end
|
||||
|
||||
test "should_be_inactive? returns true when last occurrence is over 2 months ago" do
|
||||
recurring = @family.recurring_transactions.create!(
|
||||
merchant: merchants(:amazon),
|
||||
amount: 19.99,
|
||||
currency: "USD",
|
||||
expected_day_of_month: 5,
|
||||
last_occurrence_date: 3.months.ago.to_date,
|
||||
next_expected_date: 2.months.ago.to_date,
|
||||
status: "active"
|
||||
)
|
||||
|
||||
assert recurring.should_be_inactive?
|
||||
end
|
||||
|
||||
test "should_be_inactive? returns false when last occurrence is within 2 months" do
|
||||
recurring = @family.recurring_transactions.create!(
|
||||
merchant: merchants(:amazon),
|
||||
amount: 25.99,
|
||||
currency: "USD",
|
||||
expected_day_of_month: 5,
|
||||
last_occurrence_date: 1.month.ago.to_date,
|
||||
next_expected_date: Date.current,
|
||||
status: "active"
|
||||
)
|
||||
|
||||
assert_not recurring.should_be_inactive?
|
||||
end
|
||||
|
||||
test "cleanup_stale_for marks inactive when no recent occurrences" do
|
||||
recurring = @family.recurring_transactions.create!(
|
||||
merchant: merchants(:amazon),
|
||||
amount: 35.99,
|
||||
currency: "USD",
|
||||
expected_day_of_month: 5,
|
||||
last_occurrence_date: 3.months.ago.to_date,
|
||||
next_expected_date: 2.months.ago.to_date,
|
||||
status: "active"
|
||||
)
|
||||
|
||||
RecurringTransaction.cleanup_stale_for(@family)
|
||||
|
||||
assert_equal "inactive", recurring.reload.status
|
||||
end
|
||||
|
||||
test "record_occurrence! updates recurring transaction with new occurrence" do
|
||||
recurring = @family.recurring_transactions.create!(
|
||||
merchant: merchants(:amazon),
|
||||
amount: 45.99,
|
||||
currency: "USD",
|
||||
expected_day_of_month: 5,
|
||||
last_occurrence_date: 1.month.ago.to_date,
|
||||
next_expected_date: Date.current,
|
||||
status: "active",
|
||||
occurrence_count: 3
|
||||
)
|
||||
|
||||
new_date = Date.current
|
||||
recurring.record_occurrence!(new_date)
|
||||
|
||||
assert_equal new_date, recurring.last_occurrence_date
|
||||
assert_equal 4, recurring.occurrence_count
|
||||
assert_equal "active", recurring.status
|
||||
assert recurring.next_expected_date > new_date
|
||||
end
|
||||
|
||||
test "identify_patterns_for preserves sign for income transactions" do
|
||||
# Create recurring income transactions (negative amounts)
|
||||
account = @family.accounts.first
|
||||
[ 0, 1, 2 ].each do |months_ago|
|
||||
transaction = Transaction.create!(
|
||||
merchant: @merchant,
|
||||
category: categories(:income)
|
||||
)
|
||||
account.entries.create!(
|
||||
date: months_ago.months.ago.beginning_of_month + 15.days,
|
||||
amount: -1000.00,
|
||||
currency: "USD",
|
||||
name: "Monthly Salary",
|
||||
entryable: transaction
|
||||
)
|
||||
end
|
||||
|
||||
assert_difference "@family.recurring_transactions.count", 1 do
|
||||
RecurringTransaction.identify_patterns_for(@family)
|
||||
end
|
||||
|
||||
recurring = @family.recurring_transactions.last
|
||||
assert_equal @merchant, recurring.merchant
|
||||
assert_equal(-1000.00, recurring.amount)
|
||||
assert recurring.amount.negative?, "Income should have negative amount"
|
||||
assert_equal "USD", recurring.currency
|
||||
assert_equal "active", recurring.status
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user