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:
soky srm
2025-11-01 09:12:42 +01:00
committed by GitHub
parent 106fcd06e4
commit e290e3d4a1
29 changed files with 1140 additions and 32 deletions

View 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

View File

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

View File

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

View File

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

View File

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

View 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

View 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

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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">&middot;</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>

View File

@@ -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" }
]
},
(

View File

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

View File

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

View File

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

View File

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

View File

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

View 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

View File

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

View File

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

View 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
View File

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

View 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

View 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

View 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