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

- <%= 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 - ) %> + <% unless @family.recurring_transactions_disabled? %> + <%= 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 + ) %> + <% end %>
-
-
- <%= icon "info", class: "w-5 h-5 text-link mt-0.5 flex-shrink-0" %> +
+
-

<%= t("recurring_transactions.info.title") %>

-

<%= t("recurring_transactions.info.manual_description") %>

-

<%= t("recurring_transactions.info.automatic_description") %>

-
    - <% t("recurring_transactions.info.triggers").each do |trigger| %> -
  • <%= trigger %>
  • - <% end %> -
+

<%= t("recurring_transactions.settings.enable_label") %>

+

<%= t("recurring_transactions.settings.enable_description") %>

+ <%= form_with url: update_settings_recurring_transactions_path, method: :patch, data: { turbo_frame: "_top", controller: "auto-submit-form" } do |f| %> + <%= f.hidden_field :recurring_transactions_disabled, value: @family.recurring_transactions_disabled? ? "false" : "true" %> + <%= render DS::Toggle.new( + id: "recurring_transactions_enabled", + name: "toggle_display", + checked: !@family.recurring_transactions_disabled?, + data: { auto_submit_form_target: "auto" } + ) %> + <% end %>
-
- <% if @recurring_transactions.empty? %> -
-
- <%= icon "repeat", size: "xl" %> + <% unless @family.recurring_transactions_disabled? %> +
+
+ <%= icon "info", class: "w-5 h-5 text-link mt-0.5 flex-shrink-0" %> +
+

<%= t("recurring_transactions.info.title") %>

+

<%= t("recurring_transactions.info.manual_description") %>

+

<%= t("recurring_transactions.info.automatic_description") %>

+
    + <% t("recurring_transactions.info.triggers").each do |trigger| %> +
  • <%= trigger %>
  • + <% end %> +
-

<%= 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 - ) %>
- <% else %> +
+ +
+ <% if @recurring_transactions.empty? %> +
+
+ <%= icon "repeat", size: "xl" %> +
+

<%= 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 + ) %> +
+ <% else %>

<%= t("recurring_transactions.title") %>

@@ -109,7 +130,7 @@ "> <% if recurring_transaction.manual? && recurring_transaction.has_amount_variance? %> -
+
"> ~ <%= format_money(-recurring_transaction.expected_amount_avg_money) %>
@@ -158,5 +179,6 @@
<% end %> -
+
+ <% end %>
diff --git a/app/views/transactions/index.html.erb b/app/views/transactions/index.html.erb index 47eb8ec81fd..59ab9eb18ca 100644 --- a/app/views/transactions/index.html.erb +++ b/app/views/transactions/index.html.erb @@ -74,16 +74,33 @@
<% if @projected_recurring.any? && @q.blank? %> -
-

<%= t("recurring_transactions.upcoming") %>

- <% @projected_recurring.group_by(&:next_expected_date).sort.each do |date, transactions| %> -
-
<%= l(date, format: :long) %>
- <% transactions.each do |recurring_transaction| %> - <%= render "recurring_transactions/projected_transaction", recurring_transaction: recurring_transaction %> - <% end %> -
- <% end %> +
"> +
+ +

<%= t("recurring_transactions.upcoming") %>

+
+
+ <% @projected_recurring.group_by(&:next_expected_date).sort.each do |date, transactions| %> +
+
<%= l(date, format: :long) %>
+ <% transactions.each do |recurring_transaction| %> + <%= render "recurring_transactions/projected_transaction", recurring_transaction: recurring_transaction %> + <% end %> +
+ <% end %> +
<% end %> diff --git a/config/locales/views/recurring_transactions/en.yml b/config/locales/views/recurring_transactions/en.yml index 7541c1f169c..34749bc71dd 100644 --- a/config/locales/views/recurring_transactions/en.yml +++ b/config/locales/views/recurring_transactions/en.yml @@ -9,6 +9,10 @@ en: day_of_month: Day %{day} of month identify_patterns: Identify Patterns cleanup_stale: Clean Up Stale + settings: + enable_label: Enable Recurring Transactions + enable_description: Automatically detect recurring transaction patterns and show upcoming projected transactions. + settings_updated: Recurring transactions settings updated info: title: Automatic Pattern Detection manual_description: You can manually identify patterns or clean up stale recurring transactions using the buttons above. diff --git a/config/locales/views/transactions/en.yml b/config/locales/views/transactions/en.yml index c5362457c60..012ccd190d2 100644 --- a/config/locales/views/transactions/en.yml +++ b/config/locales/views/transactions/en.yml @@ -52,6 +52,8 @@ en: index: transaction: transaction transactions: transactions + import: Import + toggle_recurring_section: Toggle upcoming recurring transactions searches: filters: amount_filter: diff --git a/config/routes.rb b/config/routes.rb index 4a24e84a5b5..4e85b1c36be 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -171,6 +171,7 @@ collection do delete :clear_filter + patch :update_preferences end member do @@ -182,6 +183,7 @@ collection do match :identify, via: [ :get, :post ] match :cleanup, via: [ :get, :post ] + patch :update_settings end member do diff --git a/db/migrate/20251215100443_add_recurring_transactions_disabled_to_families.rb b/db/migrate/20251215100443_add_recurring_transactions_disabled_to_families.rb new file mode 100644 index 00000000000..62d1a5b9d1c --- /dev/null +++ b/db/migrate/20251215100443_add_recurring_transactions_disabled_to_families.rb @@ -0,0 +1,5 @@ +class AddRecurringTransactionsDisabledToFamilies < ActiveRecord::Migration[7.2] + def change + add_column :families, :recurring_transactions_disabled, :boolean, default: false, null: false + end +end diff --git a/db/schema.rb b/db/schema.rb index ae5445cdf01..6861315966c 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -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_12_06_131244) do +ActiveRecord::Schema[7.2].define(version: 2025_12_15_100443) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -408,6 +408,7 @@ t.boolean "auto_sync_on_login", default: true, null: false t.datetime "latest_sync_activity_at", default: -> { "CURRENT_TIMESTAMP" } t.datetime "latest_sync_completed_at", default: -> { "CURRENT_TIMESTAMP" } + t.boolean "recurring_transactions_disabled", default: false, null: false end create_table "family_exports", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| @@ -889,24 +890,6 @@ t.index ["family_id"], name: "index_rules_on_family_id" end - create_table "rule_runs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "rule_id", null: false - t.string "rule_name" - t.string "execution_type", null: false - t.string "status", null: false - t.integer "transactions_queued", default: 0, null: false - t.integer "transactions_processed", default: 0, null: false - t.integer "transactions_modified", default: 0, null: false - t.integer "pending_jobs_count", default: 0, null: false - t.datetime "executed_at", null: false - t.text "error_message" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.index ["executed_at"], name: "index_rule_runs_on_executed_at" - t.index ["rule_id", "executed_at"], name: "index_rule_runs_on_rule_id_and_executed_at" - t.index ["rule_id"], name: "index_rule_runs_on_rule_id" - end - create_table "securities", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.string "ticker", null: false t.string "name" diff --git a/test/models/recurring_transaction_test.rb b/test/models/recurring_transaction_test.rb index d16f147fb56..a7f638c3c09 100644 --- a/test/models/recurring_transaction_test.rb +++ b/test/models/recurring_transaction_test.rb @@ -27,7 +27,7 @@ def setup end assert_difference "@family.recurring_transactions.count", 1 do - RecurringTransaction.identify_patterns_for(@family) + RecurringTransaction.identify_patterns_for!(@family) end recurring = @family.recurring_transactions.last @@ -56,7 +56,7 @@ def setup end assert_no_difference "@family.recurring_transactions.count" do - RecurringTransaction.identify_patterns_for(@family) + RecurringTransaction.identify_patterns_for!(@family) end end @@ -159,7 +159,7 @@ def setup end assert_difference "@family.recurring_transactions.count", 1 do - RecurringTransaction.identify_patterns_for(@family) + RecurringTransaction.identify_patterns_for!(@family) end recurring = @family.recurring_transactions.last @@ -187,7 +187,7 @@ def setup end assert_difference "@family.recurring_transactions.count", 1 do - RecurringTransaction.identify_patterns_for(@family) + RecurringTransaction.identify_patterns_for!(@family) end recurring = @family.recurring_transactions.last @@ -235,7 +235,7 @@ def setup # Should create 2 patterns - one for each amount assert_difference "@family.recurring_transactions.count", 2 do - RecurringTransaction.identify_patterns_for(@family) + RecurringTransaction.identify_patterns_for!(@family) end end @@ -261,7 +261,7 @@ def setup ) end - RecurringTransaction.identify_patterns_for(@family) + RecurringTransaction.identify_patterns_for!(@family) recurring = @family.recurring_transactions.last # Verify matching transactions finds the correct entries @@ -320,7 +320,7 @@ def setup end assert_difference "@family.recurring_transactions.count", 2 do - RecurringTransaction.identify_patterns_for(@family) + RecurringTransaction.identify_patterns_for!(@family) end # Verify both types exist @@ -563,7 +563,7 @@ def setup # Run pattern identification assert_no_difference "@family.recurring_transactions.count" do - RecurringTransaction.identify_patterns_for(@family) + RecurringTransaction.identify_patterns_for!(@family) end # Manual recurring should be updated with new variance