diff --git a/app/controllers/recurring_transactions_controller.rb b/app/controllers/recurring_transactions_controller.rb
index d6b6691d1ec..0f06b52fb97 100644
--- a/app/controllers/recurring_transactions_controller.rb
+++ b/app/controllers/recurring_transactions_controller.rb
@@ -5,10 +5,22 @@ def index
@recurring_transactions = Current.family.recurring_transactions
.includes(:merchant)
.order(status: :asc, next_expected_date: :asc)
+ @family = Current.family
+ end
+
+ def update_settings
+ Current.family.update!(recurring_settings_params)
+
+ respond_to do |format|
+ format.html do
+ flash[:notice] = t("recurring_transactions.settings_updated")
+ redirect_to recurring_transactions_path
+ end
+ end
end
def identify
- count = RecurringTransaction.identify_patterns_for(Current.family)
+ count = RecurringTransaction.identify_patterns_for!(Current.family)
respond_to do |format|
format.html do
@@ -55,4 +67,10 @@ def destroy
flash[:notice] = t("recurring_transactions.deleted")
redirect_to recurring_transactions_path
end
+
+ private
+
+ def recurring_settings_params
+ { recurring_transactions_disabled: params[:recurring_transactions_disabled] == "true" }
+ end
end
diff --git a/app/controllers/transactions_controller.rb b/app/controllers/transactions_controller.rb
index aec15157b58..07e829ebdc4 100644
--- a/app/controllers/transactions_controller.rb
+++ b/app/controllers/transactions_controller.rb
@@ -159,6 +159,13 @@ def mark_as_recurring
end
end
+ def update_preferences
+ Current.user.update_transactions_preferences(preferences_params)
+ head :ok
+ rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved
+ head :unprocessable_entity
+ end
+
private
def per_page
params[:per_page].to_i.positive? ? params[:per_page].to_i : 20
@@ -236,4 +243,8 @@ def should_restore_params?
def stored_params
Current.session.prev_transaction_page_params
end
+
+ def preferences_params
+ params.require(:preferences).permit(collapsed_sections: {})
+ end
end
diff --git a/app/javascript/controllers/transactions_section_controller.js b/app/javascript/controllers/transactions_section_controller.js
new file mode 100644
index 00000000000..ef2f7391c29
--- /dev/null
+++ b/app/javascript/controllers/transactions_section_controller.js
@@ -0,0 +1,96 @@
+import { Controller } from "@hotwired/stimulus";
+
+export default class extends Controller {
+ static targets = ["content", "chevron", "button"];
+ static values = {
+ sectionKey: String,
+ collapsed: Boolean,
+ };
+
+ connect() {
+ if (this.collapsedValue) {
+ this.collapse(false);
+ }
+ }
+
+ toggle(event) {
+ event.preventDefault();
+ if (this.collapsedValue) {
+ this.expand();
+ } else {
+ this.collapse();
+ }
+ }
+
+ handleToggleKeydown(event) {
+ // Handle Enter and Space keys for keyboard accessibility
+ if (event.key === "Enter" || event.key === " ") {
+ event.preventDefault();
+ event.stopPropagation();
+ this.toggle(event);
+ }
+ }
+
+ collapse(persist = true) {
+ this.contentTarget.classList.add("hidden");
+ this.chevronTarget.classList.add("rotate-180");
+ this.collapsedValue = true;
+ if (this.hasButtonTarget) {
+ this.buttonTarget.setAttribute("aria-expanded", "false");
+ }
+ if (persist) {
+ this.savePreference(true);
+ }
+ }
+
+ expand() {
+ this.contentTarget.classList.remove("hidden");
+ this.chevronTarget.classList.remove("rotate-180");
+ this.collapsedValue = false;
+ if (this.hasButtonTarget) {
+ this.buttonTarget.setAttribute("aria-expanded", "true");
+ }
+ this.savePreference(false);
+ }
+
+ async savePreference(collapsed) {
+ const preferences = {
+ collapsed_sections: {
+ [this.sectionKeyValue]: collapsed,
+ },
+ };
+
+ const csrfToken = document.querySelector('meta[name="csrf-token"]');
+ if (!csrfToken) {
+ console.error(
+ "[Transactions Section] CSRF token not found. Cannot save preferences.",
+ );
+ return;
+ }
+
+ try {
+ const response = await fetch("/transactions/update_preferences", {
+ method: "PATCH",
+ headers: {
+ "Content-Type": "application/json",
+ "X-CSRF-Token": csrfToken.content,
+ },
+ body: JSON.stringify({ preferences }),
+ });
+
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => ({}));
+ console.error(
+ "[Transactions Section] Failed to save preferences:",
+ response.status,
+ errorData,
+ );
+ }
+ } catch (error) {
+ console.error(
+ "[Transactions Section] Network error saving preferences:",
+ error,
+ );
+ }
+ }
+}
diff --git a/app/jobs/identify_recurring_transactions_job.rb b/app/jobs/identify_recurring_transactions_job.rb
new file mode 100644
index 00000000000..92e1e9151f6
--- /dev/null
+++ b/app/jobs/identify_recurring_transactions_job.rb
@@ -0,0 +1,83 @@
+class IdentifyRecurringTransactionsJob < ApplicationJob
+ queue_as :default
+
+ # Debounce: if called multiple times within the delay window,
+ # only the last scheduled job will actually run
+ DEBOUNCE_DELAY = 30.seconds
+
+ def perform(family_id, scheduled_at)
+ family = Family.find_by(id: family_id)
+ return unless family
+ return if family.recurring_transactions_disabled?
+
+ # Check if this job is stale (a newer one was scheduled)
+ latest_scheduled = Rails.cache.read(cache_key(family_id))
+ return if latest_scheduled && latest_scheduled > scheduled_at
+
+ # Check if there are still incomplete syncs - if so, skip and let the last sync trigger it
+ return if family_has_incomplete_syncs?(family)
+
+ # Use advisory lock as final safety net against concurrent execution
+ with_advisory_lock(family_id) do
+ RecurringTransaction::Identifier.new(family).identify_recurring_patterns
+ end
+ end
+
+ def self.schedule_for(family)
+ return if family.recurring_transactions_disabled?
+
+ scheduled_at = Time.current.to_f
+ cache_key = "recurring_transaction_identify:#{family.id}"
+
+ # Store the latest scheduled time
+ Rails.cache.write(cache_key, scheduled_at, expires_in: DEBOUNCE_DELAY + 10.seconds)
+
+ # Schedule the job with delay
+ set(wait: DEBOUNCE_DELAY).perform_later(family.id, scheduled_at)
+ end
+
+ private
+
+ def cache_key(family_id)
+ "recurring_transaction_identify:#{family_id}"
+ end
+
+ def family_has_incomplete_syncs?(family)
+ # Check family's own syncs
+ return true if family.syncs.incomplete.exists?
+
+ # Check all provider items' syncs
+ return true if family.plaid_items.joins(:syncs).merge(Sync.incomplete).exists? if family.respond_to?(:plaid_items)
+ return true if family.simplefin_items.joins(:syncs).merge(Sync.incomplete).exists? if family.respond_to?(:simplefin_items)
+ return true if family.lunchflow_items.joins(:syncs).merge(Sync.incomplete).exists? if family.respond_to?(:lunchflow_items)
+ return true if family.enable_banking_items.joins(:syncs).merge(Sync.incomplete).exists? if family.respond_to?(:enable_banking_items)
+
+ # Check accounts' syncs
+ return true if family.accounts.joins(:syncs).merge(Sync.incomplete).exists?
+
+ false
+ end
+
+ def with_advisory_lock(family_id)
+ lock_key = advisory_lock_key(family_id)
+ acquired = ActiveRecord::Base.connection.select_value(
+ ActiveRecord::Base.sanitize_sql_array([ "SELECT pg_try_advisory_lock(?)", lock_key ])
+ )
+
+ return unless acquired
+
+ begin
+ yield
+ ensure
+ ActiveRecord::Base.connection.execute(
+ ActiveRecord::Base.sanitize_sql_array([ "SELECT pg_advisory_unlock(?)", lock_key ])
+ )
+ end
+ end
+
+ def advisory_lock_key(family_id)
+ # Generate a stable integer key from the family ID for PostgreSQL advisory lock
+ # Advisory locks require a bigint key
+ Digest::MD5.hexdigest("recurring_transaction_identify:#{family_id}").to_i(16) % (2**31)
+ end
+end
diff --git a/app/models/family/sync_complete_event.rb b/app/models/family/sync_complete_event.rb
index c0022607236..92538460560 100644
--- a/app/models/family/sync_complete_event.rb
+++ b/app/models/family/sync_complete_event.rb
@@ -29,7 +29,7 @@ def broadcast
Rails.logger.error("Family::SyncCompleteEvent net_worth_chart broadcast failed: #{e.message}\n#{e.backtrace&.join("\n")}")
end
- # Identify recurring transaction patterns after sync
+ # Schedule recurring transaction pattern identification (debounced to run after all syncs complete)
begin
RecurringTransaction.identify_patterns_for(family)
rescue => e
diff --git a/app/models/recurring_transaction.rb b/app/models/recurring_transaction.rb
index 069440725b6..bc8b2e8b3bc 100644
--- a/app/models/recurring_transaction.rb
+++ b/app/models/recurring_transaction.rb
@@ -37,7 +37,14 @@ def amount_variance_consistency
scope :expected_soon, -> { active.where("next_expected_date <= ?", 1.month.from_now) }
# Class methods for identification and cleanup
+ # Schedules pattern identification with debounce to run after all syncs complete
def self.identify_patterns_for(family)
+ IdentifyRecurringTransactionsJob.schedule_for(family)
+ 0 # Return immediately, actual count will be determined by the job
+ end
+
+ # Synchronous pattern identification (for manual triggers from UI)
+ def self.identify_patterns_for!(family)
Identifier.new(family).identify_recurring_patterns
end
diff --git a/app/models/recurring_transaction/identifier.rb b/app/models/recurring_transaction/identifier.rb
index 86cc6a55896..916b77483ac 100644
--- a/app/models/recurring_transaction/identifier.rb
+++ b/app/models/recurring_transaction/identifier.rb
@@ -81,35 +81,55 @@ def identify_recurring_patterns
find_conditions[:merchant_id] = nil
end
- recurring_transaction = family.recurring_transactions.find_or_initialize_by(find_conditions)
+ begin
+ recurring_transaction = family.recurring_transactions.find_or_initialize_by(find_conditions)
- # Handle manual recurring transactions specially
- if recurring_transaction.persisted? && recurring_transaction.manual?
- # Update variance for manual recurring transactions
- update_manual_recurring_variance(recurring_transaction, pattern)
- next
- end
+ # Handle manual recurring transactions specially
+ if recurring_transaction.persisted? && recurring_transaction.manual?
+ # Update variance for manual recurring transactions
+ update_manual_recurring_variance(recurring_transaction, pattern)
+ next
+ end
- # Set the name or merchant_id on new records
- if recurring_transaction.new_record?
- if pattern[:merchant_id].present?
- recurring_transaction.merchant_id = pattern[:merchant_id]
- else
- recurring_transaction.name = pattern[:name]
+ # Set the name or merchant_id on new records
+ if recurring_transaction.new_record?
+ if pattern[:merchant_id].present?
+ recurring_transaction.merchant_id = pattern[:merchant_id]
+ else
+ recurring_transaction.name = pattern[:name]
+ end
+ # New auto-detected recurring transactions are not manual
+ recurring_transaction.manual = false
end
- # New auto-detected recurring transactions are not manual
- recurring_transaction.manual = false
- end
- 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: recurring_transaction.new_record? ? "active" : recurring_transaction.status
- )
+ 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: recurring_transaction.new_record? ? "active" : recurring_transaction.status
+ )
+
+ recurring_transaction.save!
+ rescue ActiveRecord::RecordNotUnique
+ # Race condition: another process created the same record between find and save.
+ # Retry with find to get the existing record and update it.
+ recurring_transaction = family.recurring_transactions.find_by(find_conditions)
+ next unless recurring_transaction
+
+ # Skip manual recurring transactions
+ if recurring_transaction.manual?
+ update_manual_recurring_variance(recurring_transaction, pattern)
+ next
+ end
- recurring_transaction.save!
+ recurring_transaction.update!(
+ 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]
+ )
+ end
end
# Also check for manual recurring transactions that might need variance updates
diff --git a/app/models/user.rb b/app/models/user.rb
index 57e57ac0433..8f1b6e2b78c 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -234,6 +234,29 @@ def update_reports_preferences(prefs)
end
end
+ # Transactions preferences management
+ def transactions_section_collapsed?(section_key)
+ preferences&.dig("transactions_collapsed_sections", section_key) == true
+ end
+
+ def update_transactions_preferences(prefs)
+ transaction do
+ lock!
+
+ updated_prefs = (preferences || {}).deep_dup
+ prefs.each do |key, value|
+ if value.is_a?(Hash)
+ updated_prefs["transactions_#{key}"] ||= {}
+ updated_prefs["transactions_#{key}"] = updated_prefs["transactions_#{key}"].merge(value)
+ else
+ updated_prefs["transactions_#{key}"] = value
+ end
+ end
+
+ update!(preferences: updated_prefs)
+ end
+ end
+
private
def default_dashboard_section_order
%w[cashflow_sankey outflows_donut net_worth_chart balance_sheet]
diff --git a/app/views/recurring_transactions/index.html.erb b/app/views/recurring_transactions/index.html.erb
index 1917a9624bd..2fc7c7ecfe9 100644
--- a/app/views/recurring_transactions/index.html.erb
+++ b/app/views/recurring_transactions/index.html.erb
@@ -2,56 +2,77 @@
<%= t("recurring_transactions.title") %>
<%= t("recurring_transactions.info.title") %>
-<%= t("recurring_transactions.info.manual_description") %>
-<%= t("recurring_transactions.info.automatic_description") %>
-<%= t("recurring_transactions.settings.enable_label") %>
+<%= t("recurring_transactions.settings.enable_description") %>
<%= 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.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") %>
@@ -109,7 +130,7 @@