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:
soky srm
2025-11-14 00:31:12 +01:00
committed by GitHub
parent 66c403438d
commit ebcd6360fd
13 changed files with 754 additions and 20 deletions

View File

@@ -116,6 +116,49 @@ class TransactionsController < ApplicationController
end
end
def mark_as_recurring
transaction = Current.family.transactions.includes(entry: :account).find(params[:id])
# Check if a recurring transaction already exists for this pattern
existing = Current.family.recurring_transactions.find_by(
merchant_id: transaction.merchant_id,
name: transaction.merchant_id.present? ? nil : transaction.entry.name,
currency: transaction.entry.currency,
manual: true
)
if existing
flash[:alert] = t("recurring_transactions.already_exists")
redirect_back_or_to transactions_path
return
end
begin
recurring_transaction = RecurringTransaction.create_from_transaction(transaction)
respond_to do |format|
format.html do
flash[:notice] = t("recurring_transactions.marked_as_recurring")
redirect_back_or_to transactions_path
end
end
rescue ActiveRecord::RecordInvalid => e
respond_to do |format|
format.html do
flash[:alert] = t("recurring_transactions.creation_failed")
redirect_back_or_to transactions_path
end
end
rescue StandardError => e
respond_to do |format|
format.html do
flash[:alert] = t("recurring_transactions.unexpected_error")
redirect_back_or_to transactions_path
end
end
end
end
private
def per_page
params[:per_page].to_i.positive? ? params[:per_page].to_i : 20

View File

@@ -5,6 +5,9 @@ class RecurringTransaction < ApplicationRecord
belongs_to :merchant, optional: true
monetize :amount
monetize :expected_amount_min, allow_nil: true
monetize :expected_amount_max, allow_nil: true
monetize :expected_amount_avg, allow_nil: true
enum :status, { active: "active", inactive: "inactive" }
@@ -12,6 +15,7 @@ class RecurringTransaction < ApplicationRecord
validates :currency, presence: true
validates :expected_day_of_month, presence: true, numericality: { greater_than: 0, less_than_or_equal_to: 31 }
validate :merchant_or_name_present
validate :amount_variance_consistency
def merchant_or_name_present
if merchant_id.blank? && name.blank?
@@ -19,6 +23,16 @@ class RecurringTransaction < ApplicationRecord
end
end
def amount_variance_consistency
return unless manual?
if expected_amount_min.present? && expected_amount_max.present?
if expected_amount_min > expected_amount_max
errors.add(:expected_amount_min, "cannot be greater than expected_amount_max")
end
end
end
scope :for_family, ->(family) { where(family: family) }
scope :expected_soon, -> { active.where("next_expected_date <= ?", 1.month.from_now) }
@@ -31,17 +45,147 @@ class RecurringTransaction < ApplicationRecord
Cleaner.new(family).cleanup_stale_transactions
end
# Find matching transactions for this recurring pattern
def matching_transactions
# Create a manual recurring transaction from an existing transaction
# Automatically calculates amount variance from past 6 months of matching transactions
def self.create_from_transaction(transaction, date_variance: 2)
entry = transaction.entry
family = entry.account.family
expected_day = entry.date.day
# Find matching transactions from the past 6 months
matching_amounts = find_matching_transaction_amounts(
family: family,
merchant_id: transaction.merchant_id,
name: transaction.merchant_id.present? ? nil : entry.name,
currency: entry.currency,
expected_day: expected_day,
lookback_months: 6
)
# Calculate amount variance from historical data
expected_min = expected_max = expected_avg = nil
if matching_amounts.size > 1
# Multiple transactions found - calculate variance
expected_min = matching_amounts.min
expected_max = matching_amounts.max
expected_avg = matching_amounts.sum / matching_amounts.size
elsif matching_amounts.size == 1
# Single transaction - no variance yet
amount = matching_amounts.first
expected_min = amount
expected_max = amount
expected_avg = amount
end
# Calculate next expected date relative to today, not the transaction date
next_expected = calculate_next_expected_date_from_today(expected_day)
create!(
family: family,
merchant_id: transaction.merchant_id,
name: transaction.merchant_id.present? ? nil : entry.name,
amount: entry.amount,
currency: entry.currency,
expected_day_of_month: expected_day,
last_occurrence_date: entry.date,
next_expected_date: next_expected,
status: "active",
occurrence_count: matching_amounts.size,
manual: true,
expected_amount_min: expected_min,
expected_amount_max: expected_max,
expected_amount_avg: expected_avg
)
end
# Find matching transaction entries for variance calculation
def self.find_matching_transaction_entries(family:, merchant_id:, name:, currency:, expected_day:, lookback_months: 6)
lookback_date = lookback_months.months.ago.to_date
entries = family.entries
.where(entryable_type: "Transaction")
.where(currency: currency)
.where("entries.amount = ?", amount)
.where("entries.date >= ?", lookback_date)
.where("EXTRACT(DAY FROM entries.date) BETWEEN ? AND ?",
[ expected_day_of_month - 2, 1 ].max,
[ expected_day_of_month + 2, 31 ].min)
[ expected_day - 2, 1 ].max,
[ expected_day + 2, 31 ].min)
.order(date: :desc)
# Filter by merchant or name
if merchant_id.present?
# Join with transactions table to filter by merchant_id in SQL (avoids N+1)
entries
.joins("INNER JOIN transactions ON transactions.id = entries.entryable_id")
.where(transactions: { merchant_id: merchant_id })
.to_a
else
entries.where(name: name).to_a
end
end
# Find matching transaction amounts for variance calculation
def self.find_matching_transaction_amounts(family:, merchant_id:, name:, currency:, expected_day:, lookback_months: 6)
matching_entries = find_matching_transaction_entries(
family: family,
merchant_id: merchant_id,
name: name,
currency: currency,
expected_day: expected_day,
lookback_months: lookback_months
)
matching_entries.map(&:amount)
end
# Calculate next expected date from today
def self.calculate_next_expected_date_from_today(expected_day)
today = Date.current
# Try this month first
begin
this_month_date = Date.new(today.year, today.month, expected_day)
return this_month_date if this_month_date > today
rescue ArgumentError
# Day doesn't exist in this month (e.g., 31st in February)
end
# Otherwise use next month
calculate_next_expected_date_for(today, expected_day)
end
def self.calculate_next_expected_date_for(from_date, expected_day)
next_month = from_date.next_month
begin
Date.new(next_month.year, next_month.month, expected_day)
rescue ArgumentError
next_month.end_of_month
end
end
# Find matching transactions for this recurring pattern
def matching_transactions
# For manual recurring with amount variance, match within range
# For automatic recurring, match exact amount
entries = if manual? && has_amount_variance?
family.entries
.where(entryable_type: "Transaction")
.where(currency: currency)
.where("entries.amount BETWEEN ? AND ?", expected_amount_min, expected_amount_max)
.where("EXTRACT(DAY FROM entries.date) BETWEEN ? AND ?",
[ expected_day_of_month - 2, 1 ].max,
[ expected_day_of_month + 2, 31 ].min)
.order(date: :desc)
else
family.entries
.where(entryable_type: "Transaction")
.where(currency: currency)
.where("entries.amount = ?", amount)
.where("EXTRACT(DAY FROM entries.date) BETWEEN ? AND ?",
[ expected_day_of_month - 2, 1 ].max,
[ expected_day_of_month + 2, 31 ].min)
.order(date: :desc)
end
# Filter by merchant or name
if merchant_id.present?
# Match by merchant through the entryable (Transaction)
@@ -54,10 +198,17 @@ class RecurringTransaction < ApplicationRecord
end
end
# Check if this recurring transaction has amount variance configured
def has_amount_variance?
expected_amount_min.present? && expected_amount_max.present?
end
# Check if this recurring transaction should be marked inactive
def should_be_inactive?
return false if last_occurrence_date.nil?
last_occurrence_date < 2.months.ago
# Manual recurring transactions have a longer threshold
threshold = manual? ? 6.months.ago : 2.months.ago
last_occurrence_date < threshold
end
# Mark as inactive
@@ -71,14 +222,42 @@ class RecurringTransaction < ApplicationRecord
end
# Update based on a new transaction occurrence
def record_occurrence!(transaction_date)
def record_occurrence!(transaction_date, transaction_amount = nil)
self.last_occurrence_date = transaction_date
self.next_expected_date = calculate_next_expected_date(transaction_date)
# Update amount variance for manual recurring transactions BEFORE incrementing count
if manual? && transaction_amount.present?
update_amount_variance(transaction_amount)
end
self.occurrence_count += 1
self.status = "active"
save!
end
# Update amount variance tracking based on a new transaction
def update_amount_variance(transaction_amount)
# First sample - initialize everything
if expected_amount_avg.nil?
self.expected_amount_min = transaction_amount
self.expected_amount_max = transaction_amount
self.expected_amount_avg = transaction_amount
return
end
# Update min/max
self.expected_amount_min = [ expected_amount_min, transaction_amount ].min if expected_amount_min.present?
self.expected_amount_max = [ expected_amount_max, transaction_amount ].max if expected_amount_max.present?
# Calculate new average using incremental formula
# For n samples with average A_n, adding sample x_{n+1} gives:
# A_{n+1} = A_n + (x_{n+1} - A_n)/(n+1)
# occurrence_count includes the initial occurrence, so subtract 1 to get variance samples recorded
n = occurrence_count - 1 # Number of variance samples recorded so far
self.expected_amount_avg = expected_amount_avg + ((transaction_amount - expected_amount_avg) / (n + 1))
end
# Calculate the next expected date based on the last occurrence
def calculate_next_expected_date(from_date = last_occurrence_date)
# Start with next month
@@ -98,14 +277,25 @@ class RecurringTransaction < ApplicationRecord
return nil unless active?
return nil unless next_expected_date.future?
# Use average amount for manual recurring with variance, otherwise use fixed amount
display_amount = if manual? && expected_amount_avg.present?
expected_amount_avg
else
amount
end
OpenStruct.new(
date: next_expected_date,
amount: amount,
amount: display_amount,
currency: currency,
merchant: merchant,
name: merchant.present? ? merchant.name : name,
recurring: true,
projected: true
projected: true,
amount_min: expected_amount_min,
amount_max: expected_amount_max,
amount_avg: expected_amount_avg,
has_variance: has_amount_variance?
)
end

View File

@@ -6,18 +6,19 @@ class RecurringTransaction
@family = family
end
# Mark recurring transactions as inactive if they haven't occurred in 2+ months
# Mark recurring transactions as inactive if they haven't occurred recently
# Uses 2 months for automatic recurring, 6 months for manual recurring
def cleanup_stale_transactions
two_months_ago = 2.months.ago.to_date
stale_transactions = family.recurring_transactions
.active
.where("last_occurrence_date < ?", two_months_ago)
stale_count = 0
stale_transactions.find_each do |recurring_transaction|
family.recurring_transactions.active.find_each do |recurring_transaction|
next unless recurring_transaction.should_be_inactive?
# Determine threshold based on manual flag
threshold = recurring_transaction.manual? ? 6.months.ago.to_date : 2.months.ago.to_date
# Double-check if there are any recent matching transactions
recent_matches = recurring_transaction.matching_transactions.select { |entry| entry.date >= two_months_ago }
recent_matches = recurring_transaction.matching_transactions.select { |entry| entry.date >= threshold }
if recent_matches.empty?
recurring_transaction.mark_inactive!
@@ -29,11 +30,13 @@ class RecurringTransaction
end
# Remove inactive recurring transactions that have been inactive for 6+ months
# Manual recurring transactions are never automatically deleted
def remove_old_inactive_transactions
six_months_ago = 6.months.ago
family.recurring_transactions
.inactive
.where(manual: false)
.where("updated_at < ?", six_months_ago)
.destroy_all
end

View File

@@ -84,6 +84,13 @@ class RecurringTransaction
recurring_transaction = family.recurring_transactions.find_or_initialize_by(find_conditions)
# Handle manual recurring transactions specially
if recurring_transaction.persisted? && recurring_transaction.manual?
# Update variance for manual recurring transactions
update_manual_recurring_variance(recurring_transaction, pattern)
next
end
# Set the name or merchant_id on new records
if recurring_transaction.new_record?
if pattern[:merchant_id].present?
@@ -91,6 +98,8 @@ class RecurringTransaction
else
recurring_transaction.name = pattern[:name]
end
# New auto-detected recurring transactions are not manual
recurring_transaction.manual = false
end
recurring_transaction.assign_attributes(
@@ -104,9 +113,74 @@ class RecurringTransaction
recurring_transaction.save!
end
# Also check for manual recurring transactions that might need variance updates
update_manual_recurring_transactions(three_months_ago)
recurring_patterns.size
end
# Update variance for existing manual recurring transactions
def update_manual_recurring_transactions(since_date)
family.recurring_transactions.where(manual: true, status: "active").find_each do |recurring|
# Find matching transactions in the recent period
matching_entries = RecurringTransaction.find_matching_transaction_entries(
family: family,
merchant_id: recurring.merchant_id,
name: recurring.name,
currency: recurring.currency,
expected_day: recurring.expected_day_of_month,
lookback_months: 6
)
next if matching_entries.empty?
# Extract amounts and dates from all matching entries
matching_amounts = matching_entries.map(&:amount)
last_entry = matching_entries.max_by(&:date)
# Recalculate variance from all occurrences (including identical amounts)
recurring.update!(
expected_amount_min: matching_amounts.min,
expected_amount_max: matching_amounts.max,
expected_amount_avg: matching_amounts.sum / matching_amounts.size,
occurrence_count: matching_amounts.size,
last_occurrence_date: last_entry.date,
next_expected_date: calculate_next_expected_date(last_entry.date, recurring.expected_day_of_month)
)
end
end
# Update variance for a manual recurring transaction when pattern is found
def update_manual_recurring_variance(recurring_transaction, pattern)
# Check if this transaction's date is more recent
if pattern[:last_occurrence_date] > recurring_transaction.last_occurrence_date
# Find all matching transactions to recalculate variance
matching_entries = RecurringTransaction.find_matching_transaction_entries(
family: family,
merchant_id: recurring_transaction.merchant_id,
name: recurring_transaction.name,
currency: recurring_transaction.currency,
expected_day: recurring_transaction.expected_day_of_month,
lookback_months: 6
)
# Update if we have any matching transactions
if matching_entries.any?
matching_amounts = matching_entries.map(&:amount)
recurring_transaction.update!(
expected_amount_min: matching_amounts.min,
expected_amount_max: matching_amounts.max,
expected_amount_avg: matching_amounts.sum / matching_amounts.size,
occurrence_count: matching_amounts.size,
last_occurrence_date: pattern[:last_occurrence_date],
next_expected_date: calculate_next_expected_date(pattern[:last_occurrence_date], recurring_transaction.expected_day_of_month),
status: "active"
)
end
end
end
private
# Check if days cluster together (within ~5 days variance)
# Uses circular distance to handle month-boundary wrapping (e.g., 28, 29, 30, 31, 1, 2)

View File

@@ -54,6 +54,7 @@
</div>
<div class="col-span-2 ml-auto text-right">
<%= content_tag :p, format_money(-recurring_transaction.amount_money), class: ["font-medium", recurring_transaction.amount.negative? ? "text-success" : "text-subdued"] %>
<% display_amount = recurring_transaction.manual? && recurring_transaction.expected_amount_avg.present? ? recurring_transaction.expected_amount_avg : recurring_transaction.amount %>
<%= content_tag :p, format_money(-Money.new(display_amount, recurring_transaction.currency)), class: ["font-medium", display_amount.negative? ? "text-success" : "text-subdued"] %>
</div>
</div>

View File

@@ -100,10 +100,22 @@
) %>
<span class="text-primary font-medium"><%= recurring_transaction.name %></span>
<% end %>
<% if recurring_transaction.manual? %>
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-tint-10 text-link">
<%= t("recurring_transactions.badges.manual") %>
</span>
<% end %>
</div>
</td>
<td class="py-3 px-2 text-sm font-medium <%= recurring_transaction.amount.negative? ? "text-success" : "text-primary" %>">
<%= format_money(-recurring_transaction.amount_money) %>
<% if recurring_transaction.manual? && recurring_transaction.has_amount_variance? %>
<div class="inline-flex items-center gap-1 cursor-help group" title="<%= t('recurring_transactions.amount_range', min: format_money(-recurring_transaction.expected_amount_min_money), max: format_money(-recurring_transaction.expected_amount_max_money)) %>">
<span class="text-xs text-secondary group-hover:text-primary transition-colors">~</span>
<span class="border-b border-dashed border-subdued group-hover:border-primary transition-colors"><%= format_money(-recurring_transaction.expected_amount_avg_money) %></span>
</div>
<% else %>
<%= format_money(-recurring_transaction.amount_money) %>
<% end %>
</td>
<td class="py-3 px-2 text-sm text-secondary">
<%= t("recurring_transactions.day_of_month", day: recurring_transaction.expected_day_of_month) %>

View File

@@ -150,6 +150,23 @@
) %>
</div>
<!-- Mark as Recurring Form -->
<div class="flex items-center justify-between gap-2 p-3">
<div class="text-sm space-y-1">
<h4 class="text-primary"><%= t(".mark_recurring_title") %></h4>
<p class="text-secondary"><%= t(".mark_recurring_subtitle") %></p>
</div>
<%= render DS::Button.new(
text: t(".mark_recurring"),
variant: "outline",
icon: "repeat",
href: mark_as_recurring_transaction_path(@entry.transaction),
method: :post,
frame: "_top"
) %>
</div>
<!-- Delete Transaction Form -->
<div class="flex items-center justify-between gap-2 p-3">
<div class="text-sm space-y-1">

View File

@@ -22,6 +22,11 @@ en:
marked_active: Recurring transaction marked as active
deleted: Recurring transaction deleted
confirm_delete: Are you sure you want to delete this recurring transaction?
marked_as_recurring: Transaction marked as recurring
already_exists: A manual recurring transaction already exists for this pattern
creation_failed: Failed to create recurring transaction. Please check the transaction details and try again.
unexpected_error: An unexpected error occurred while creating the recurring transaction
amount_range: "Range: %{min} to %{max}"
empty:
title: No recurring transactions found
description: Click "Identify Patterns" to automatically detect recurring transactions from your transaction history.
@@ -36,3 +41,5 @@ en:
status:
active: Active
inactive: Inactive
badges:
manual: Manual

View File

@@ -30,6 +30,9 @@ en:
balances, and cannot be undone.
delete_title: Delete transaction
details: Details
mark_recurring: Mark as Recurring
mark_recurring_subtitle: Track this as a recurring transaction. Amount variance is automatically calculated from past 6 months of similar transactions.
mark_recurring_title: Recurring Transaction
merchant_label: Merchant
name_label: Name
nature: Type

View File

@@ -153,6 +153,10 @@ Rails.application.routes.draw do
collection do
delete :clear_filter
end
member do
post :mark_as_recurring
end
end
resources :recurring_transactions, only: %i[index destroy] do

View File

@@ -0,0 +1,8 @@
class AddManualAndAmountVarianceToRecurringTransactions < ActiveRecord::Migration[7.2]
def change
add_column :recurring_transactions, :manual, :boolean, default: false, null: false
add_column :recurring_transactions, :expected_amount_min, :decimal, precision: 19, scale: 4
add_column :recurring_transactions, :expected_amount_max, :decimal, precision: 19, scale: 4
add_column :recurring_transactions, :expected_amount_avg, :decimal, precision: 19, scale: 4
end
end

View File

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

View File

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