diff --git a/app/controllers/recurring_transactions_controller.rb b/app/controllers/recurring_transactions_controller.rb new file mode 100644 index 000000000..d6b6691d1 --- /dev/null +++ b/app/controllers/recurring_transactions_controller.rb @@ -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 diff --git a/app/controllers/transactions_controller.rb b/app/controllers/transactions_controller.rb index 707fa5d4f..2036bd168 100644 --- a/app/controllers/transactions_controller.rb +++ b/app/controllers/transactions_controller.rb @@ -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 diff --git a/app/models/family.rb b/app/models/family.rb index 2380a37df..b618f3785 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -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) } diff --git a/app/models/family/sync_complete_event.rb b/app/models/family/sync_complete_event.rb index 4fd0e00f5..c00226072 100644 --- a/app/models/family/sync_complete_event.rb +++ b/app/models/family/sync_complete_event.rb @@ -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 diff --git a/app/models/merchant.rb b/app/models/merchant.rb index 4a4257bc2..f3a7bc95c 100644 --- a/app/models/merchant.rb +++ b/app/models/merchant.rb @@ -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 } diff --git a/app/models/recurring_transaction.rb b/app/models/recurring_transaction.rb new file mode 100644 index 000000000..14cb68961 --- /dev/null +++ b/app/models/recurring_transaction.rb @@ -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 diff --git a/app/models/recurring_transaction/cleaner.rb b/app/models/recurring_transaction/cleaner.rb new file mode 100644 index 000000000..ea7091051 --- /dev/null +++ b/app/models/recurring_transaction/cleaner.rb @@ -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 diff --git a/app/models/recurring_transaction/identifier.rb b/app/models/recurring_transaction/identifier.rb new file mode 100644 index 000000000..7098b39f3 --- /dev/null +++ b/app/models/recurring_transaction/identifier.rb @@ -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 diff --git a/app/views/family_exports/_list.html.erb b/app/views/family_exports/_list.html.erb index 339c0c039..e73889dfb 100644 --- a/app/views/family_exports/_list.html.erb +++ b/app/views/family_exports/_list.html.erb @@ -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 @@
<%= category[:name] %>
- <%= outflows_data[:currency_symbol] %><%= number_with_delimiter(category[:amount], delimiter: ',') %> + <%= outflows_data[:currency_symbol] %><%= number_with_delimiter(category[:amount], delimiter: ",") %>
<%= category[:percentage] %>%
@@ -75,7 +75,7 @@ <%= category[:name] %><%= t("recurring_transactions.info.title") %>
+<%= t("recurring_transactions.info.manual_description") %>
+<%= t("recurring_transactions.info.automatic_description") %>
+<%= t("recurring_transactions.empty.title") %>
+<%= t("recurring_transactions.empty.description") %>
+ <%= render DS::Link.new( + text: t("recurring_transactions.identify_patterns"), + icon: "search", + variant: "primary", + href: identify_recurring_transactions_path, + method: :post + ) %> +<%= t("recurring_transactions.title") %>
+ · +<%= @recurring_transactions.count %>
+| <%= t("recurring_transactions.table.merchant") %> | +<%= t("recurring_transactions.table.amount") %> | +<%= t("recurring_transactions.table.expected_day") %> | +<%= t("recurring_transactions.table.next_date") %> | +<%= t("recurring_transactions.table.last_occurrence") %> | +<%= t("recurring_transactions.table.status") %> | +<%= t("recurring_transactions.table.actions") %> | +
|---|---|---|---|---|---|---|
|
+
+ <% 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 %>
+ <%= recurring_transaction.merchant.name %>
+
+ |
+ "> + <%= format_money(-recurring_transaction.amount_money) %> + | ++ <%= t("recurring_transactions.day_of_month", day: recurring_transaction.expected_day_of_month) %> + | ++ <%= l(recurring_transaction.next_expected_date, format: :short) %> + | ++ <%= l(recurring_transaction.last_occurrence_date, format: :short) %> + | ++ <% if recurring_transaction.active? %> + + <%= t("recurring_transactions.status.active") %> + + <% else %> + + <%= t("recurring_transactions.status.inactive") %> + + <% end %> + | +
+
+ <%= 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 %>
+
+ |
+
<%= t(".main_system_prompt.subtitle") %>
- +<%= t(".transaction_categorizer.subtitle") %>
<%= t(".merchant_detector.subtitle") %>