mirror of
https://github.com/we-promise/sure.git
synced 2026-04-07 14:31:25 +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:
58
app/controllers/recurring_transactions_controller.rb
Normal file
58
app/controllers/recurring_transactions_controller.rb
Normal file
@@ -0,0 +1,58 @@
|
||||
class RecurringTransactionsController < ApplicationController
|
||||
layout "settings"
|
||||
|
||||
def index
|
||||
@recurring_transactions = Current.family.recurring_transactions
|
||||
.includes(:merchant)
|
||||
.order(status: :asc, next_expected_date: :asc)
|
||||
end
|
||||
|
||||
def identify
|
||||
count = RecurringTransaction.identify_patterns_for(Current.family)
|
||||
|
||||
respond_to do |format|
|
||||
format.html do
|
||||
flash[:notice] = t("recurring_transactions.identified", count: count)
|
||||
redirect_to recurring_transactions_path
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def cleanup
|
||||
count = RecurringTransaction.cleanup_stale_for(Current.family)
|
||||
|
||||
respond_to do |format|
|
||||
format.html do
|
||||
flash[:notice] = t("recurring_transactions.cleaned_up", count: count)
|
||||
redirect_to recurring_transactions_path
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def toggle_status
|
||||
@recurring_transaction = Current.family.recurring_transactions.find(params[:id])
|
||||
|
||||
if @recurring_transaction.active?
|
||||
@recurring_transaction.mark_inactive!
|
||||
message = t("recurring_transactions.marked_inactive")
|
||||
else
|
||||
@recurring_transaction.mark_active!
|
||||
message = t("recurring_transactions.marked_active")
|
||||
end
|
||||
|
||||
respond_to do |format|
|
||||
format.html do
|
||||
flash[:notice] = message
|
||||
redirect_to recurring_transactions_path
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
@recurring_transaction = Current.family.recurring_transactions.find(params[:id])
|
||||
@recurring_transaction.destroy!
|
||||
|
||||
flash[:notice] = t("recurring_transactions.deleted")
|
||||
redirect_to recurring_transactions_path
|
||||
end
|
||||
end
|
||||
@@ -22,6 +22,14 @@ class TransactionsController < ApplicationController
|
||||
)
|
||||
|
||||
@pagy, @transactions = pagy(base_scope, limit: per_page)
|
||||
|
||||
# Load projected recurring transactions for next month
|
||||
@projected_recurring = Current.family.recurring_transactions
|
||||
.active
|
||||
.where("next_expected_date <= ? AND next_expected_date >= ?",
|
||||
1.month.from_now.to_date,
|
||||
Date.current)
|
||||
.includes(:merchant)
|
||||
end
|
||||
|
||||
def clear_filter
|
||||
|
||||
@@ -35,6 +35,7 @@ class Family < ApplicationRecord
|
||||
has_many :budget_categories, through: :budgets
|
||||
|
||||
has_many :llm_usages, dependent: :destroy
|
||||
has_many :recurring_transactions, dependent: :destroy
|
||||
|
||||
validates :locale, inclusion: { in: I18n.available_locales.map(&:to_s) }
|
||||
validates :date_format, inclusion: { in: DATE_FORMATS.map(&:last) }
|
||||
|
||||
@@ -28,5 +28,12 @@ class Family::SyncCompleteEvent
|
||||
rescue => e
|
||||
Rails.logger.error("Family::SyncCompleteEvent net_worth_chart broadcast failed: #{e.message}\n#{e.backtrace&.join("\n")}")
|
||||
end
|
||||
|
||||
# Identify recurring transaction patterns after sync
|
||||
begin
|
||||
RecurringTransaction.identify_patterns_for(family)
|
||||
rescue => e
|
||||
Rails.logger.error("Family::SyncCompleteEvent recurring transaction identification failed: #{e.message}\n#{e.backtrace&.join("\n")}")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2,6 +2,7 @@ class Merchant < ApplicationRecord
|
||||
TYPES = %w[FamilyMerchant ProviderMerchant].freeze
|
||||
|
||||
has_many :transactions, dependent: :nullify
|
||||
has_many :recurring_transactions, dependent: :destroy
|
||||
|
||||
validates :name, presence: true
|
||||
validates :type, inclusion: { in: TYPES }
|
||||
|
||||
102
app/models/recurring_transaction.rb
Normal file
102
app/models/recurring_transaction.rb
Normal file
@@ -0,0 +1,102 @@
|
||||
class RecurringTransaction < ApplicationRecord
|
||||
include Monetizable
|
||||
|
||||
belongs_to :family
|
||||
belongs_to :merchant
|
||||
|
||||
monetize :amount
|
||||
|
||||
enum :status, { active: "active", inactive: "inactive" }
|
||||
|
||||
validates :amount, presence: true
|
||||
validates :currency, presence: true
|
||||
validates :expected_day_of_month, presence: true, numericality: { greater_than: 0, less_than_or_equal_to: 31 }
|
||||
|
||||
scope :for_family, ->(family) { where(family: family) }
|
||||
scope :expected_soon, -> { active.where("next_expected_date <= ?", 1.month.from_now) }
|
||||
|
||||
# Class methods for identification and cleanup
|
||||
def self.identify_patterns_for(family)
|
||||
Identifier.new(family).identify_recurring_patterns
|
||||
end
|
||||
|
||||
def self.cleanup_stale_for(family)
|
||||
Cleaner.new(family).cleanup_stale_transactions
|
||||
end
|
||||
|
||||
# Find matching transactions for this recurring pattern
|
||||
def matching_transactions
|
||||
entries = 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)
|
||||
|
||||
# Filter by merchant through the entryable (Transaction)
|
||||
entries.select do |entry|
|
||||
entry.entryable.is_a?(Transaction) && entry.entryable.merchant_id == merchant_id
|
||||
end
|
||||
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
|
||||
end
|
||||
|
||||
# Mark as inactive
|
||||
def mark_inactive!
|
||||
update!(status: "inactive")
|
||||
end
|
||||
|
||||
# Mark as active
|
||||
def mark_active!
|
||||
update!(status: "active")
|
||||
end
|
||||
|
||||
# Update based on a new transaction occurrence
|
||||
def record_occurrence!(transaction_date)
|
||||
self.last_occurrence_date = transaction_date
|
||||
self.next_expected_date = calculate_next_expected_date(transaction_date)
|
||||
self.occurrence_count += 1
|
||||
self.status = "active"
|
||||
save!
|
||||
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
|
||||
next_month = from_date.next_month
|
||||
|
||||
# Try to use the expected day of month
|
||||
begin
|
||||
Date.new(next_month.year, next_month.month, expected_day_of_month)
|
||||
rescue ArgumentError
|
||||
# If day doesn't exist in month (e.g., 31st in February), use last day of month
|
||||
next_month.end_of_month
|
||||
end
|
||||
end
|
||||
|
||||
# Get the projected transaction for display
|
||||
def projected_entry
|
||||
return nil unless active?
|
||||
return nil unless next_expected_date.future?
|
||||
|
||||
OpenStruct.new(
|
||||
date: next_expected_date,
|
||||
amount: amount,
|
||||
currency: currency,
|
||||
merchant: merchant,
|
||||
recurring: true,
|
||||
projected: true
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
def monetizable_currency
|
||||
currency
|
||||
end
|
||||
end
|
||||
41
app/models/recurring_transaction/cleaner.rb
Normal file
41
app/models/recurring_transaction/cleaner.rb
Normal file
@@ -0,0 +1,41 @@
|
||||
class RecurringTransaction
|
||||
class Cleaner
|
||||
attr_reader :family
|
||||
|
||||
def initialize(family)
|
||||
@family = family
|
||||
end
|
||||
|
||||
# Mark recurring transactions as inactive if they haven't occurred in 2+ months
|
||||
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|
|
||||
# Double-check if there are any recent matching transactions
|
||||
recent_matches = recurring_transaction.matching_transactions.select { |entry| entry.date >= two_months_ago }
|
||||
|
||||
if recent_matches.empty?
|
||||
recurring_transaction.mark_inactive!
|
||||
stale_count += 1
|
||||
end
|
||||
end
|
||||
|
||||
stale_count
|
||||
end
|
||||
|
||||
# Remove inactive recurring transactions that have been inactive for 6+ months
|
||||
def remove_old_inactive_transactions
|
||||
six_months_ago = 6.months.ago
|
||||
|
||||
family.recurring_transactions
|
||||
.inactive
|
||||
.where("updated_at < ?", six_months_ago)
|
||||
.destroy_all
|
||||
end
|
||||
end
|
||||
end
|
||||
159
app/models/recurring_transaction/identifier.rb
Normal file
159
app/models/recurring_transaction/identifier.rb
Normal file
@@ -0,0 +1,159 @@
|
||||
class RecurringTransaction
|
||||
class Identifier
|
||||
attr_reader :family
|
||||
|
||||
def initialize(family)
|
||||
@family = family
|
||||
end
|
||||
|
||||
# Identify and create/update recurring transactions for the family
|
||||
def identify_recurring_patterns
|
||||
three_months_ago = 3.months.ago.to_date
|
||||
|
||||
# Get all transactions from the last 3 months
|
||||
entries_with_transactions = family.entries
|
||||
.where(entryable_type: "Transaction")
|
||||
.where("entries.date >= ?", three_months_ago)
|
||||
.includes(:entryable)
|
||||
.to_a
|
||||
|
||||
# Filter to only those with merchants and group by merchant and amount (preserve sign)
|
||||
grouped_transactions = entries_with_transactions
|
||||
.select { |entry| entry.entryable.is_a?(Transaction) && entry.entryable.merchant_id.present? }
|
||||
.group_by { |entry| [ entry.entryable.merchant_id, entry.amount.round(2), entry.currency ] }
|
||||
|
||||
recurring_patterns = []
|
||||
|
||||
grouped_transactions.each do |(merchant_id, amount, currency), entries|
|
||||
next if entries.size < 3 # Must have at least 3 occurrences
|
||||
|
||||
# Check if the last occurrence was within the last 45 days
|
||||
last_occurrence = entries.max_by(&:date)
|
||||
next if last_occurrence.date < 45.days.ago.to_date
|
||||
|
||||
# Check if transactions occur on similar days (within 5 days of each other)
|
||||
days_of_month = entries.map { |e| e.date.day }.sort
|
||||
|
||||
# Calculate if days cluster together (standard deviation check)
|
||||
if days_cluster_together?(days_of_month)
|
||||
expected_day = calculate_expected_day(days_of_month)
|
||||
|
||||
recurring_patterns << {
|
||||
merchant_id: merchant_id,
|
||||
amount: amount,
|
||||
currency: currency,
|
||||
expected_day_of_month: expected_day,
|
||||
last_occurrence_date: last_occurrence.date,
|
||||
occurrence_count: entries.size,
|
||||
entries: entries
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
# Create or update RecurringTransaction records
|
||||
recurring_patterns.each do |pattern|
|
||||
recurring_transaction = family.recurring_transactions.find_or_initialize_by(
|
||||
merchant_id: pattern[:merchant_id],
|
||||
amount: pattern[:amount],
|
||||
currency: pattern[:currency]
|
||||
)
|
||||
|
||||
recurring_transaction.assign_attributes(
|
||||
expected_day_of_month: pattern[:expected_day_of_month],
|
||||
last_occurrence_date: pattern[:last_occurrence_date],
|
||||
next_expected_date: calculate_next_expected_date(pattern[:last_occurrence_date], pattern[:expected_day_of_month]),
|
||||
occurrence_count: pattern[:occurrence_count],
|
||||
status: "active"
|
||||
)
|
||||
|
||||
recurring_transaction.save!
|
||||
end
|
||||
|
||||
recurring_patterns.size
|
||||
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)
|
||||
def days_cluster_together?(days)
|
||||
return false if days.empty?
|
||||
|
||||
# Calculate median as reference point
|
||||
median = calculate_expected_day(days)
|
||||
|
||||
# Calculate circular distances from median
|
||||
circular_distances = days.map { |day| circular_distance(day, median) }
|
||||
|
||||
# Calculate standard deviation of circular distances
|
||||
mean_distance = circular_distances.sum.to_f / circular_distances.size
|
||||
variance = circular_distances.map { |dist| (dist - mean_distance)**2 }.sum / circular_distances.size
|
||||
std_dev = Math.sqrt(variance)
|
||||
|
||||
# Allow up to 5 days standard deviation
|
||||
std_dev <= 5
|
||||
end
|
||||
|
||||
# Calculate circular distance between two days on a 31-day circle
|
||||
# Examples:
|
||||
# circular_distance(1, 31) = 2 (wraps around: 31 -> 1 is 1 day forward)
|
||||
# circular_distance(28, 2) = 5 (wraps: 28, 29, 30, 31, 1, 2)
|
||||
def circular_distance(day1, day2)
|
||||
linear_distance = (day1 - day2).abs
|
||||
wrap_distance = 31 - linear_distance
|
||||
[ linear_distance, wrap_distance ].min
|
||||
end
|
||||
|
||||
# Calculate the expected day based on the most common day
|
||||
# Uses circular rotation to handle month-wrapping sequences (e.g., [29, 30, 31, 1, 2])
|
||||
def calculate_expected_day(days)
|
||||
return days.first if days.size == 1
|
||||
|
||||
# Convert to 0-indexed (0-30 instead of 1-31) for modular arithmetic
|
||||
days_0 = days.map { |d| d - 1 }
|
||||
|
||||
# Find the rotation (pivot) that minimizes span, making the cluster contiguous
|
||||
# This handles month-wrapping sequences like [29, 30, 31, 1, 2]
|
||||
best_pivot = 0
|
||||
min_span = Float::INFINITY
|
||||
|
||||
(0..30).each do |pivot|
|
||||
rotated = days_0.map { |d| (d - pivot) % 31 }
|
||||
span = rotated.max - rotated.min
|
||||
|
||||
if span < min_span
|
||||
min_span = span
|
||||
best_pivot = pivot
|
||||
end
|
||||
end
|
||||
|
||||
# Rotate days using best pivot to create contiguous array
|
||||
rotated_days = days_0.map { |d| (d - best_pivot) % 31 }.sort
|
||||
|
||||
# Calculate median on rotated, contiguous array
|
||||
mid = rotated_days.size / 2
|
||||
rotated_median = if rotated_days.size.odd?
|
||||
rotated_days[mid]
|
||||
else
|
||||
# For even count, average and round
|
||||
((rotated_days[mid - 1] + rotated_days[mid]) / 2.0).round
|
||||
end
|
||||
|
||||
# Map median back to original day space (unrotate) and convert to 1-indexed
|
||||
original_day = (rotated_median + best_pivot) % 31 + 1
|
||||
|
||||
original_day
|
||||
end
|
||||
|
||||
# Calculate next expected date
|
||||
def calculate_next_expected_date(last_date, expected_day)
|
||||
next_month = last_date.next_month
|
||||
|
||||
begin
|
||||
Date.new(next_month.year, next_month.month, expected_day)
|
||||
rescue ArgumentError
|
||||
# If day doesn't exist in month, use last day of month
|
||||
next_month.end_of_month
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -38,13 +38,13 @@
|
||||
<%= button_to family_export_path(export),
|
||||
method: :delete,
|
||||
class: "flex items-center gap-2 text-destructive hover:text-destructive-hover",
|
||||
data: {
|
||||
data: {
|
||||
turbo_confirm: "Are you sure you want to delete this export? This action cannot be undone.",
|
||||
turbo_frame: "_top"
|
||||
} do %>
|
||||
<%= icon "trash-2", class: "w-5 h-5 text-destructive" %>
|
||||
<% end %>
|
||||
|
||||
|
||||
<%= link_to download_family_export_path(export),
|
||||
class: "flex items-center gap-2 text-primary hover:text-primary-hover",
|
||||
data: { turbo_frame: "_top" } do %>
|
||||
@@ -56,11 +56,11 @@
|
||||
<div class="flex items-center gap-2 text-destructive">
|
||||
<%= icon "alert-circle", class: "w-4 h-4" %>
|
||||
</div>
|
||||
|
||||
|
||||
<%= button_to family_export_path(export),
|
||||
method: :delete,
|
||||
class: "flex items-center gap-2 text-destructive hover:text-destructive-hover",
|
||||
data: {
|
||||
data: {
|
||||
turbo_confirm: "Are you sure you want to delete this failed export?",
|
||||
turbo_frame: "_top"
|
||||
} do %>
|
||||
|
||||
@@ -38,25 +38,21 @@
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
|
||||
|
||||
<% if import.complete? || import.revert_failed? %>
|
||||
<%= button_to revert_import_path(import),
|
||||
method: :put,
|
||||
class: "flex items-center gap-2 text-orange-500 hover:text-orange-600",
|
||||
data: {
|
||||
data: {
|
||||
turbo_confirm: "This will delete transactions that were imported, but you will still be able to review and re-import your data at any time."
|
||||
} do %>
|
||||
<%= icon "rotate-ccw", class: "w-5 h-5 text-destructive" %>
|
||||
<% end %>
|
||||
|
||||
|
||||
|
||||
|
||||
<% else %>
|
||||
<%= button_to import_path(import),
|
||||
method: :delete,
|
||||
class: "flex items-center gap-2 text-destructive hover:text-destructive-hover",
|
||||
data: {
|
||||
data: {
|
||||
turbo_confirm: CustomConfirm.for_resource_deletion("import")
|
||||
} do %>
|
||||
<%= icon "trash-2", class: "w-5 h-5 text-destructive" %>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<%= render partial: "imports/import", collection: @imports.ordered %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
|
||||
<%= link_to new_import_path,
|
||||
class: "bg-container-inset flex items-center justify-center gap-2 text-secondary mt-1 hover:bg-container-inset-hover rounded-lg px-4 py-2 w-full text-center",
|
||||
data: { turbo_frame: :modal } do %>
|
||||
@@ -27,7 +27,7 @@
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
|
||||
<%= link_to new_family_export_path,
|
||||
class: "bg-container-inset flex items-center justify-center gap-2 text-secondary mt-1 hover:bg-container-inset-hover rounded-lg px-4 py-2 w-full text-center",
|
||||
data: { turbo_frame: :modal } do %>
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
if (localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
document.documentElement.setAttribute("data-theme", "dark");
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
</div>
|
||||
|
||||
<div class="text-3xl font-medium text-primary">
|
||||
<%= outflows_data[:currency_symbol] %><%= number_with_delimiter(outflows_data[:total], delimiter: ',') %>
|
||||
<%= outflows_data[:currency_symbol] %><%= number_with_delimiter(outflows_data[:total], delimiter: ",") %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
<p class="text-sm text-secondary"><%= category[:name] %></p>
|
||||
|
||||
<p class="text-3xl font-medium text-primary">
|
||||
<%= outflows_data[:currency_symbol] %><%= number_with_delimiter(category[:amount], delimiter: ',') %>
|
||||
<%= outflows_data[:currency_symbol] %><%= number_with_delimiter(category[:amount], delimiter: ",") %>
|
||||
</p>
|
||||
|
||||
<p class="text-sm text-secondary"><%= category[:percentage] %>%</p>
|
||||
@@ -75,7 +75,7 @@
|
||||
<span class="text-sm font-medium text-primary truncate"><%= category[:name] %></span>
|
||||
</div>
|
||||
<div class="flex items-center gap-4 flex-shrink-0">
|
||||
<span class="text-sm font-medium text-primary whitespace-nowrap"><%= outflows_data[:currency_symbol] %><%= number_with_delimiter(category[:amount], delimiter: ',') %></span>
|
||||
<span class="text-sm font-medium text-primary whitespace-nowrap"><%= outflows_data[:currency_symbol] %><%= number_with_delimiter(category[:amount], delimiter: ",") %></span>
|
||||
<span class="text-sm text-secondary whitespace-nowrap"><%= category[:percentage] %>%</span>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<%# locals: (recurring_transaction:) %>
|
||||
|
||||
<div class="grid grid-cols-12 items-center text-subdued text-sm font-medium p-4 lg:p-4 bg-container-inset rounded">
|
||||
<div class="pr-4 lg:pr-10 flex items-center gap-3 lg:gap-4 col-span-8">
|
||||
<div class="max-w-full">
|
||||
<%= content_tag :div, class: ["flex items-center gap-2"] do %>
|
||||
<% if recurring_transaction.merchant&.logo_url.present? %>
|
||||
<%= image_tag recurring_transaction.merchant.logo_url,
|
||||
class: "w-6 h-6 rounded-full",
|
||||
loading: "lazy" %>
|
||||
<% else %>
|
||||
<%= render DS::FilledIcon.new(
|
||||
variant: :text,
|
||||
text: recurring_transaction.merchant.name,
|
||||
size: "sm",
|
||||
rounded: true
|
||||
) %>
|
||||
<% end %>
|
||||
|
||||
<div class="truncate">
|
||||
<div class="space-y-0.5">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<div class="truncate flex-shrink">
|
||||
<%= recurring_transaction.merchant.name %>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1 flex-shrink-0">
|
||||
<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.projected") %>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-secondary text-xs font-normal">
|
||||
<%= t("recurring_transactions.expected_on", date: l(recurring_transaction.next_expected_date, format: :short)) %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hidden lg:flex items-center gap-1 col-span-2">
|
||||
<span class="text-xs text-secondary"><%= t("recurring_transactions.recurring") %></span>
|
||||
</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"] %>
|
||||
</div>
|
||||
</div>
|
||||
140
app/views/recurring_transactions/index.html.erb
Normal file
140
app/views/recurring_transactions/index.html.erb
Normal file
@@ -0,0 +1,140 @@
|
||||
<div class="space-y-4 pb-20 flex flex-col">
|
||||
<header class="flex justify-between items-center text-primary font-medium">
|
||||
<h1 class="text-xl"><%= t("recurring_transactions.title") %></h1>
|
||||
<div class="flex items-center gap-2">
|
||||
<%= render DS::Link.new(
|
||||
text: t("recurring_transactions.identify_patterns"),
|
||||
icon: "search",
|
||||
variant: "outline",
|
||||
href: identify_recurring_transactions_path,
|
||||
method: :post
|
||||
) %>
|
||||
<%= render DS::Link.new(
|
||||
text: t("recurring_transactions.cleanup_stale"),
|
||||
icon: "trash-2",
|
||||
variant: "outline",
|
||||
href: cleanup_recurring_transactions_path,
|
||||
method: :post
|
||||
) %>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="p-4 bg-container-inset border border-secondary rounded-lg">
|
||||
<div class="flex items-start gap-2">
|
||||
<%= icon "info", class: "w-5 h-5 text-link mt-0.5 flex-shrink-0" %>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-primary mb-2"><%= t("recurring_transactions.info.title") %></p>
|
||||
<p class="text-xs text-secondary mb-2"><%= t("recurring_transactions.info.manual_description") %></p>
|
||||
<p class="text-xs text-secondary mb-1"><%= t("recurring_transactions.info.automatic_description") %></p>
|
||||
<ul class="list-disc list-inside text-xs text-secondary space-y-0.5 ml-2">
|
||||
<% t("recurring_transactions.info.triggers").each do |trigger| %>
|
||||
<li><%= trigger %></li>
|
||||
<% end %>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-container rounded-xl shadow-border-xs p-4">
|
||||
<% if @recurring_transactions.empty? %>
|
||||
<div class="text-center py-12">
|
||||
<div class="text-secondary mb-4">
|
||||
<%= icon "repeat", size: "xl" %>
|
||||
</div>
|
||||
<p class="text-primary font-medium mb-2"><%= t("recurring_transactions.empty.title") %></p>
|
||||
<p class="text-secondary text-sm mb-4"><%= t("recurring_transactions.empty.description") %></p>
|
||||
<%= render DS::Link.new(
|
||||
text: t("recurring_transactions.identify_patterns"),
|
||||
icon: "search",
|
||||
variant: "primary",
|
||||
href: identify_recurring_transactions_path,
|
||||
method: :post
|
||||
) %>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="rounded-xl bg-container-inset space-y-1 p-1">
|
||||
<div class="flex items-center gap-1.5 px-4 py-2 text-xs font-medium text-secondary uppercase">
|
||||
<p><%= t("recurring_transactions.title") %></p>
|
||||
<span class="text-subdued">·</span>
|
||||
<p><%= @recurring_transactions.count %></p>
|
||||
</div>
|
||||
|
||||
<div class="bg-container rounded-lg shadow-border-xs overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr class="text-xs uppercase font-medium text-secondary border-b border-divider">
|
||||
<th class="text-left py-3 px-2"><%= t("recurring_transactions.table.merchant") %></th>
|
||||
<th class="text-left py-3 px-2"><%= t("recurring_transactions.table.amount") %></th>
|
||||
<th class="text-left py-3 px-2"><%= t("recurring_transactions.table.expected_day") %></th>
|
||||
<th class="text-left py-3 px-2"><%= t("recurring_transactions.table.next_date") %></th>
|
||||
<th class="text-left py-3 px-2"><%= t("recurring_transactions.table.last_occurrence") %></th>
|
||||
<th class="text-left py-3 px-2"><%= t("recurring_transactions.table.status") %></th>
|
||||
<th class="text-right py-3 px-2"><%= t("recurring_transactions.table.actions") %></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% @recurring_transactions.each do |recurring_transaction| %>
|
||||
<tr class="border-b border-subdued hover:bg-surface-hover <%= "opacity-60" unless recurring_transaction.active? %>">
|
||||
<td class="py-3 px-2 text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<% if recurring_transaction.merchant&.logo_url.present? %>
|
||||
<%= image_tag recurring_transaction.merchant.logo_url,
|
||||
class: "w-6 h-6 rounded-full",
|
||||
loading: "lazy" %>
|
||||
<% else %>
|
||||
<%= render DS::FilledIcon.new(
|
||||
variant: :text,
|
||||
text: recurring_transaction.merchant.name,
|
||||
size: "sm",
|
||||
rounded: true
|
||||
) %>
|
||||
<% end %>
|
||||
<span class="text-primary font-medium"><%= recurring_transaction.merchant.name %></span>
|
||||
</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) %>
|
||||
</td>
|
||||
<td class="py-3 px-2 text-sm text-secondary">
|
||||
<%= t("recurring_transactions.day_of_month", day: recurring_transaction.expected_day_of_month) %>
|
||||
</td>
|
||||
<td class="py-3 px-2 text-sm text-secondary">
|
||||
<%= l(recurring_transaction.next_expected_date, format: :short) %>
|
||||
</td>
|
||||
<td class="py-3 px-2 text-sm text-secondary">
|
||||
<%= l(recurring_transaction.last_occurrence_date, format: :short) %>
|
||||
</td>
|
||||
<td class="py-3 px-2 text-sm">
|
||||
<% if recurring_transaction.active? %>
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-50 text-success">
|
||||
<%= t("recurring_transactions.status.active") %>
|
||||
</span>
|
||||
<% else %>
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-surface-inset text-primary">
|
||||
<%= t("recurring_transactions.status.inactive") %>
|
||||
</span>
|
||||
<% end %>
|
||||
</td>
|
||||
<td class="py-3 px-2 text-sm text-right">
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<%= link_to toggle_status_recurring_transaction_path(recurring_transaction),
|
||||
data: { turbo_method: :post },
|
||||
class: "text-secondary hover:text-primary" do %>
|
||||
<%= icon recurring_transaction.active? ? "pause" : "play", size: "sm" %>
|
||||
<% end %>
|
||||
<%= link_to recurring_transaction_path(recurring_transaction),
|
||||
data: { turbo_method: :delete, turbo_confirm: t("recurring_transactions.confirm_delete") },
|
||||
class: "text-secondary hover:text-destructive" do %>
|
||||
<%= icon "trash-2", size: "sm" %>
|
||||
<% end %>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
@@ -17,7 +17,8 @@ nav_sections = [
|
||||
{ label: t(".categories_label"), path: categories_path, icon: "shapes" },
|
||||
{ label: t(".tags_label"), path: tags_path, icon: "tags" },
|
||||
{ label: t(".rules_label"), path: rules_path, icon: "git-branch" },
|
||||
{ label: t(".merchants_label"), path: family_merchants_path, icon: "store" }
|
||||
{ label: t(".merchants_label"), path: family_merchants_path, icon: "store" },
|
||||
{ label: t(".recurring_transactions_label"), path: recurring_transactions_path, icon: "repeat" }
|
||||
]
|
||||
},
|
||||
(
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
<p class="text-xs text-secondary"><%= t(".main_system_prompt.subtitle") %></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="pl-12 space-y-2">
|
||||
<details class="group">
|
||||
<summary class="flex items-center gap-2 cursor-pointer">
|
||||
@@ -60,7 +60,7 @@
|
||||
<p class="text-xs text-secondary"><%= t(".transaction_categorizer.subtitle") %></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="pl-12 space-y-2">
|
||||
<details class="group">
|
||||
<summary class="flex items-center gap-2 cursor-pointer">
|
||||
@@ -88,7 +88,7 @@
|
||||
<p class="text-xs text-secondary"><%= t(".merchant_detector.subtitle") %></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="pl-12 space-y-2">
|
||||
<details class="group">
|
||||
<summary class="flex items-center gap-2 cursor-pointer">
|
||||
@@ -105,4 +105,4 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
<%# Assign distinct colors to each provider %>
|
||||
<% provider_colors = {
|
||||
"Lunch Flow" => "#6471eb",
|
||||
"Plaid" => "#4da568",
|
||||
"Plaid" => "#4da568",
|
||||
"SimpleFin" => "#e99537"
|
||||
} %>
|
||||
<% provider_color = provider_colors[provider_link[:name]] || "#6B7280" %>
|
||||
|
||||
<%= link_to provider_link[:path],
|
||||
target: provider_link[:target],
|
||||
<%= link_to provider_link[:path],
|
||||
target: provider_link[:target],
|
||||
rel: provider_link[:rel],
|
||||
class: "flex justify-between items-center p-4 bg-container hover:bg-container-hover transition-colors" do %>
|
||||
<div class="flex w-full items-center gap-2.5">
|
||||
@@ -28,5 +28,3 @@
|
||||
<%= icon("arrow-right", size: "sm", class: "text-secondary") %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<%= content_for :page_title, "Bank Sync" %>
|
||||
|
||||
|
||||
<div class="bg-container rounded-xl shadow-border-xs p-4">
|
||||
<% if @providers.any? %>
|
||||
<div class="rounded-xl bg-container-inset space-y-1 p-1">
|
||||
@@ -25,5 +24,3 @@
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
|
||||
<% if %w[buy sell].include?(type) %>
|
||||
<%= form.number_field :qty, label: t(".qty"), placeholder: "10", min: 0.000000000000000001, step: "any", required: true %>
|
||||
<%= form.money_field :price, label: t(".price"), step: 'any', precision: 10, required: true %>
|
||||
<%= form.money_field :price, label: t(".price"), step: "any", precision: 10, required: true %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
<%= render "transactions/selection_bar" %>
|
||||
</div>
|
||||
|
||||
<% if @pagy.count > 0 %>
|
||||
<% if @pagy.count > 0 || (@projected_recurring.any? && @q.blank?) %>
|
||||
<div class="grow overflow-y-auto">
|
||||
<div class="grid-cols-12 bg-container-inset rounded-xl px-5 py-3 text-xs uppercase font-medium text-secondary items-center mb-4 hidden md:grid">
|
||||
<div class="pl-0.5 col-span-8 flex items-center gap-4">
|
||||
@@ -80,6 +80,20 @@
|
||||
<% end %>
|
||||
|
||||
<div class="space-y-6">
|
||||
<% if @projected_recurring.any? && @q.blank? %>
|
||||
<div class="space-y-2">
|
||||
<h3 class="text-xs uppercase font-medium text-secondary px-2"><%= t("recurring_transactions.upcoming") %></h3>
|
||||
<% @projected_recurring.group_by(&:next_expected_date).sort.each do |date, transactions| %>
|
||||
<div class="space-y-2">
|
||||
<div class="text-xs text-secondary px-2 pt-2"><%= l(date, format: :long) %></div>
|
||||
<% transactions.each do |recurring_transaction| %>
|
||||
<%= render "recurring_transactions/projected_transaction", recurring_transaction: recurring_transaction %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= entries_by_date(@transactions.map(&:entry), totals: true) do |entries| %>
|
||||
<%= render entries %>
|
||||
<% end %>
|
||||
|
||||
38
config/locales/views/recurring_transactions/en.yml
Normal file
38
config/locales/views/recurring_transactions/en.yml
Normal file
@@ -0,0 +1,38 @@
|
||||
---
|
||||
en:
|
||||
recurring_transactions:
|
||||
title: Recurring Transactions
|
||||
upcoming: Upcoming Recurring Transactions
|
||||
projected: Projected
|
||||
recurring: Recurring
|
||||
expected_on: Expected on %{date}
|
||||
day_of_month: Day %{day} of month
|
||||
identify_patterns: Identify Patterns
|
||||
cleanup_stale: Clean Up Stale
|
||||
info:
|
||||
title: Automatic Pattern Detection
|
||||
manual_description: You can manually identify patterns or clean up stale recurring transactions using the buttons above.
|
||||
automatic_description: "Automatic identification also runs after:"
|
||||
triggers:
|
||||
- CSV imports complete (transactions, trades, accounts, etc. )
|
||||
- Any provider sync completes ( Plaid, SimpleFIN, etc. )
|
||||
identified: Identified %{count} recurring transaction patterns
|
||||
cleaned_up: Cleaned up %{count} stale recurring transactions
|
||||
marked_inactive: Recurring transaction marked as inactive
|
||||
marked_active: Recurring transaction marked as active
|
||||
deleted: Recurring transaction deleted
|
||||
confirm_delete: Are you sure you want to delete this recurring transaction?
|
||||
empty:
|
||||
title: No recurring transactions found
|
||||
description: Click "Identify Patterns" to automatically detect recurring transactions from your transaction history.
|
||||
table:
|
||||
merchant: Merchant
|
||||
amount: Amount
|
||||
expected_day: Expected Day
|
||||
next_date: Next Date
|
||||
last_occurrence: Last Occurrence
|
||||
status: Status
|
||||
actions: Actions
|
||||
status:
|
||||
active: Active
|
||||
inactive: Inactive
|
||||
@@ -105,6 +105,7 @@ en:
|
||||
other_section_title: More
|
||||
preferences_label: Preferences
|
||||
profile_label: Profile Info
|
||||
recurring_transactions_label: Recurring
|
||||
rules_label: Rules
|
||||
security_label: Security
|
||||
self_hosting_label: Self-Hosting
|
||||
|
||||
@@ -149,6 +149,17 @@ Rails.application.routes.draw do
|
||||
end
|
||||
end
|
||||
|
||||
resources :recurring_transactions, only: %i[index destroy] do
|
||||
collection do
|
||||
match :identify, via: [ :get, :post ]
|
||||
match :cleanup, via: [ :get, :post ]
|
||||
end
|
||||
|
||||
member do
|
||||
match :toggle_status, via: [ :get, :post ]
|
||||
end
|
||||
end
|
||||
|
||||
resources :accountable_sparklines, only: :show, param: :accountable_type
|
||||
|
||||
direct :entry do |entry, options|
|
||||
|
||||
23
db/migrate/20251031132654_create_recurring_transactions.rb
Normal file
23
db/migrate/20251031132654_create_recurring_transactions.rb
Normal file
@@ -0,0 +1,23 @@
|
||||
class CreateRecurringTransactions < ActiveRecord::Migration[7.2]
|
||||
def change
|
||||
create_table :recurring_transactions, id: :uuid do |t|
|
||||
t.references :family, null: false, foreign_key: true, type: :uuid
|
||||
t.references :merchant, null: false, foreign_key: true, type: :uuid
|
||||
t.decimal :amount, precision: 19, scale: 4, null: false
|
||||
t.string :currency, null: false
|
||||
t.integer :expected_day_of_month, null: false
|
||||
t.date :last_occurrence_date, null: false
|
||||
t.date :next_expected_date, null: false
|
||||
t.string :status, default: "active", null: false
|
||||
t.integer :occurrence_count, default: 0, null: false
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :recurring_transactions, [ :family_id, :merchant_id, :amount, :currency ],
|
||||
unique: true,
|
||||
name: "idx_recurring_txns_on_family_merchant_amount_currency"
|
||||
add_index :recurring_transactions, [ :family_id, :status ]
|
||||
add_index :recurring_transactions, :next_expected_date
|
||||
end
|
||||
end
|
||||
23
db/schema.rb
generated
23
db/schema.rb
generated
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.2].define(version: 2025_10_29_204447) do
|
||||
ActiveRecord::Schema[7.2].define(version: 2025_10_31_132654) do
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "pgcrypto"
|
||||
enable_extension "plpgsql"
|
||||
@@ -674,6 +674,25 @@ ActiveRecord::Schema[7.2].define(version: 2025_10_29_204447) do
|
||||
t.string "subtype"
|
||||
end
|
||||
|
||||
create_table "recurring_transactions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
t.uuid "family_id", null: false
|
||||
t.uuid "merchant_id", null: false
|
||||
t.decimal "amount", precision: 19, scale: 4, null: false
|
||||
t.string "currency", null: false
|
||||
t.integer "expected_day_of_month", null: false
|
||||
t.date "last_occurrence_date", null: false
|
||||
t.date "next_expected_date", null: false
|
||||
t.string "status", default: "active", null: false
|
||||
t.integer "occurrence_count", default: 0, null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["family_id", "merchant_id", "amount", "currency"], name: "idx_recurring_txns_on_family_merchant_amount_currency", unique: true
|
||||
t.index ["family_id", "status"], name: "index_recurring_transactions_on_family_id_and_status"
|
||||
t.index ["family_id"], name: "index_recurring_transactions_on_family_id"
|
||||
t.index ["merchant_id"], name: "index_recurring_transactions_on_merchant_id"
|
||||
t.index ["next_expected_date"], name: "index_recurring_transactions_on_next_expected_date"
|
||||
end
|
||||
|
||||
create_table "rejected_transfers", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
t.uuid "inflow_transaction_id", null: false
|
||||
t.uuid "outflow_transaction_id", null: false
|
||||
@@ -1008,6 +1027,8 @@ ActiveRecord::Schema[7.2].define(version: 2025_10_29_204447) do
|
||||
add_foreign_key "oidc_identities", "users"
|
||||
add_foreign_key "plaid_accounts", "plaid_items"
|
||||
add_foreign_key "plaid_items", "families"
|
||||
add_foreign_key "recurring_transactions", "families"
|
||||
add_foreign_key "recurring_transactions", "merchants"
|
||||
add_foreign_key "rejected_transfers", "transactions", column: "inflow_transaction_id"
|
||||
add_foreign_key "rejected_transfers", "transactions", column: "outflow_transaction_id"
|
||||
add_foreign_key "rule_actions", "rules"
|
||||
|
||||
21
test/fixtures/recurring_transactions.yml
vendored
Normal file
21
test/fixtures/recurring_transactions.yml
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
netflix_subscription:
|
||||
family: dylan_family
|
||||
merchant: netflix
|
||||
amount: 15.99
|
||||
currency: USD
|
||||
expected_day_of_month: 5
|
||||
last_occurrence_date: <%= 1.month.ago.to_date %>
|
||||
next_expected_date: <%= 5.days.from_now.to_date %>
|
||||
status: active
|
||||
occurrence_count: 3
|
||||
|
||||
inactive_subscription:
|
||||
family: dylan_family
|
||||
merchant: amazon
|
||||
amount: 9.99
|
||||
currency: USD
|
||||
expected_day_of_month: 15
|
||||
last_occurrence_date: <%= 3.months.ago.to_date %>
|
||||
next_expected_date: <%= 2.months.ago.to_date %>
|
||||
status: inactive
|
||||
occurrence_count: 2
|
||||
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