diff --git a/app/controllers/rules_controller.rb b/app/controllers/rules_controller.rb index 24927b295..cd8a6eb6c 100644 --- a/app/controllers/rules_controller.rb +++ b/app/controllers/rules_controller.rb @@ -104,6 +104,30 @@ class RulesController < ApplicationController redirect_to rules_path, notice: "All rules deleted" end + def confirm_all + @rules = Current.family.rules + @total_affected_count = Rule.total_affected_resource_count(@rules) + + # Compute AI cost estimation if any rule has auto_categorize action + if @rules.any? { |r| r.actions.any? { |a| a.action_type == "auto_categorize" } } + llm_provider = Provider::Registry.get_provider(:openai) + + if llm_provider + @selected_model = Provider::Openai.effective_model + @estimated_cost = LlmUsage.estimate_auto_categorize_cost( + transaction_count: @total_affected_count, + category_count: Current.family.categories.count, + model: @selected_model + ) + end + end + end + + def apply_all + ApplyAllRulesJob.perform_later(Current.family) + redirect_back_or_to rules_path, notice: t("rules.apply_all.success") + end + private def set_rule @rule = Current.family.rules.find(params[:id]) diff --git a/app/jobs/apply_all_rules_job.rb b/app/jobs/apply_all_rules_job.rb new file mode 100644 index 000000000..f14da7091 --- /dev/null +++ b/app/jobs/apply_all_rules_job.rb @@ -0,0 +1,9 @@ +class ApplyAllRulesJob < ApplicationJob + queue_as :medium_priority + + def perform(family, execution_type: "manual") + family.rules.find_each do |rule| + RuleJob.perform_now(rule, ignore_attribute_locks: true, execution_type: execution_type) + end + end +end diff --git a/app/models/rule.rb b/app/models/rule.rb index 13087f93b..cd18deadc 100644 --- a/app/models/rule.rb +++ b/app/models/rule.rb @@ -40,6 +40,20 @@ class Rule < ApplicationRecord matching_resources_scope.count end + # Calculates total unique resources affected across multiple rules + # This handles overlapping rules by deduplicating transaction IDs + def self.total_affected_resource_count(rules) + return 0 if rules.empty? + + # Collect all unique transaction IDs matched by any rule + transaction_ids = Set.new + rules.each do |rule| + transaction_ids.merge(rule.send(:matching_resources_scope).pluck(:id)) + end + + transaction_ids.size + end + def apply(ignore_attribute_locks: false, rule_run: nil) total_modified = 0 total_async_jobs = 0 diff --git a/app/views/rules/confirm_all.html.erb b/app/views/rules/confirm_all.html.erb new file mode 100644 index 000000000..c88778767 --- /dev/null +++ b/app/views/rules/confirm_all.html.erb @@ -0,0 +1,48 @@ +<%= render DS::Dialog.new do |dialog| %> + <% dialog.with_header(title: t("rules.apply_all.confirm_title")) %> + + <% dialog.with_body do %> +

+ <%= t("rules.apply_all.confirm_message", + count: @rules.count, + transactions: @total_affected_count) %> +

+ + <% if @rules.any? { |r| r.actions.any? { |a| a.action_type == "auto_categorize" } } %> +
+
+ <%= icon "info", class: "w-4 h-4 text-blue-600 mt-0.5 flex-shrink-0" %> +
+

<%= t("rules.apply_all.ai_cost_title") %>

+ <% if @estimated_cost.present? %> +

+ <%= t("rules.apply_all.ai_cost_message", transactions: @total_affected_count) %> + <%= t("rules.apply_all.estimated_cost", cost: sprintf("%.4f", @estimated_cost)) %> +

+ <% else %> +

+ <%= t("rules.apply_all.ai_cost_message", transactions: @total_affected_count) %> + <% if @selected_model.present? %> + <%= t("rules.apply_all.cost_unavailable_model", model: @selected_model) %> + <% else %> + <%= t("rules.apply_all.cost_unavailable_no_provider") %> + <% end %> + <%= t("rules.apply_all.cost_warning") %> +

+ <% end %> +

+ <%= link_to t("rules.apply_all.view_usage"), settings_llm_usage_path, class: "underline hover:text-blue-800" %> +

+
+
+
+ <% end %> + + <%= render DS::Button.new( + text: t("rules.apply_all.confirm_button"), + href: apply_all_rules_path, + method: :post, + full_width: true, + data: { turbo_frame: "_top" }) %> + <% end %> +<% end %> diff --git a/app/views/rules/index.html.erb b/app/views/rules/index.html.erb index f40020abe..0acacb0fc 100644 --- a/app/views/rules/index.html.erb +++ b/app/views/rules/index.html.erb @@ -11,6 +11,13 @@ method: :delete, confirm: CustomConfirm.for_resource_deletion("all rules", high_severity: true)) %> <% end %> + <%= render DS::Link.new( + text: t("rules.apply_all.button"), + variant: "secondary", + href: confirm_all_rules_path, + icon: "play", + frame: :modal + ) %> <% end %> <%= render DS::Link.new( text: "New rule", diff --git a/config/locales/views/rules/en.yml b/config/locales/views/rules/en.yml index 2d258c411..5cbd252d5 100644 --- a/config/locales/views/rules/en.yml +++ b/config/locales/views/rules/en.yml @@ -4,6 +4,19 @@ en: no_action: No Action actions: value_placeholder: Enter a value + apply_all: + button: Apply All + confirm_title: Apply All Rules + confirm_message: You are about to apply %{count} rules affecting %{transactions} unique transactions. Please confirm if you wish to proceed. + confirm_button: Confirm and Apply All + success: All rules have been queued for execution + ai_cost_title: AI Cost Estimation + ai_cost_message: This will use AI to categorize up to %{transactions} transactions. + estimated_cost: "Estimated cost: ~$%{cost}" + cost_unavailable_model: Cost estimation unavailable for model "%{model}". + cost_unavailable_no_provider: Cost estimation unavailable (no LLM provider configured). + cost_warning: You may incur costs, please check with the model provider for the most up-to-date prices. + view_usage: View usage history recent_runs: title: Recent Runs description: View the execution history of your rules including success/failure status and transaction counts. diff --git a/config/routes.rb b/config/routes.rb index b425b5824..4f4f45d6a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -219,6 +219,8 @@ Rails.application.routes.draw do collection do delete :destroy_all + get :confirm_all + post :apply_all end end diff --git a/lib/tasks/rules.rake b/lib/tasks/rules.rake new file mode 100644 index 000000000..0a5a5c79a --- /dev/null +++ b/lib/tasks/rules.rake @@ -0,0 +1,33 @@ +namespace :rules do + desc "Apply all rules for a family" + task :apply_all, [ :family_id ] => :environment do |_t, args| + family_id = args[:family_id] + + if family_id.blank? + puts "Usage: bin/rails rules:apply_all[family_id]" + exit 1 + end + + family = Family.find(family_id) + rules = family.rules + + if rules.empty? + puts "No rules found for family #{family_id}" + exit 0 + end + + puts "Applying #{rules.count} rules for family #{family_id}..." + + rules.find_each do |rule| + print " Applying rule '#{rule.name || rule.id}'... " + begin + RuleJob.perform_now(rule, ignore_attribute_locks: true, execution_type: "manual") + puts "done" + rescue => e + puts "failed: #{e.message}" + end + end + + puts "Finished applying all rules" + end +end diff --git a/test/controllers/rules_controller_test.rb b/test/controllers/rules_controller_test.rb index a948f399d..681bac37b 100644 --- a/test/controllers/rules_controller_test.rb +++ b/test/controllers/rules_controller_test.rb @@ -179,4 +179,17 @@ class RulesControllerTest < ActionDispatch::IntegrationTest assert_redirected_to rules_url end + + test "should get confirm_all" do + get confirm_all_rules_url + assert_response :success + end + + test "apply_all enqueues job and redirects" do + assert_enqueued_with(job: ApplyAllRulesJob) do + post apply_all_rules_url + end + + assert_redirected_to rules_url + end end diff --git a/test/jobs/apply_all_rules_job_test.rb b/test/jobs/apply_all_rules_job_test.rb new file mode 100644 index 000000000..68591c29d --- /dev/null +++ b/test/jobs/apply_all_rules_job_test.rb @@ -0,0 +1,41 @@ +require "test_helper" + +class ApplyAllRulesJobTest < ActiveJob::TestCase + include EntriesTestHelper + + setup do + @family = families(:empty) + @account = @family.accounts.create!(name: "Test Account", balance: 1000, currency: "USD", accountable: Depository.new) + @groceries_category = @family.categories.create!(name: "Groceries") + end + + test "applies all rules for a family" do + # Create a rule + rule = Rule.create!( + family: @family, + resource_type: "transaction", + effective_date: 1.day.ago.to_date, + conditions: [ Rule::Condition.new(condition_type: "transaction_name", operator: "like", value: "Whole Foods") ], + actions: [ Rule::Action.new(action_type: "set_transaction_category", value: @groceries_category.id) ] + ) + + # Mock RuleJob to verify it gets called for each rule + RuleJob.expects(:perform_now).with(rule, ignore_attribute_locks: true, execution_type: "manual").once + + ApplyAllRulesJob.perform_now(@family) + end + + test "applies all rules with custom execution type" do + rule = Rule.create!( + family: @family, + resource_type: "transaction", + effective_date: 1.day.ago.to_date, + conditions: [ Rule::Condition.new(condition_type: "transaction_name", operator: "like", value: "Test") ], + actions: [ Rule::Action.new(action_type: "set_transaction_category", value: @groceries_category.id) ] + ) + + RuleJob.expects(:perform_now).with(rule, ignore_attribute_locks: true, execution_type: "scheduled").once + + ApplyAllRulesJob.perform_now(@family, execution_type: "scheduled") + end +end diff --git a/test/models/rule_test.rb b/test/models/rule_test.rb index bf082ec73..731199fab 100644 --- a/test/models/rule_test.rb +++ b/test/models/rule_test.rb @@ -201,4 +201,39 @@ class RuleTest < ActiveSupport::TestCase assert_equal business_category, transaction_entry.transaction.category, "Transaction with 'business' in notes should be categorized" assert_nil transaction_entry2.transaction.category, "Transaction without 'business' in notes should not be categorized" end + + test "total_affected_resource_count deduplicates overlapping rules" do + # Create transactions + transaction_entry1 = create_transaction(date: Date.current, account: @account, name: "Whole Foods", amount: 50) + transaction_entry2 = create_transaction(date: Date.current, account: @account, name: "Whole Foods", amount: 100) + transaction_entry3 = create_transaction(date: Date.current, account: @account, name: "Target", amount: 75) + + # Rule 1: Match transactions with name "Whole Foods" (matches txn 1 and 2) + rule1 = Rule.create!( + family: @family, + resource_type: "transaction", + effective_date: 1.day.ago.to_date, + conditions: [ Rule::Condition.new(condition_type: "transaction_name", operator: "like", value: "Whole Foods") ], + actions: [ Rule::Action.new(action_type: "set_transaction_category", value: @groceries_category.id) ] + ) + + # Rule 2: Match transactions with amount > 60 (matches txn 2 and 3) + rule2 = Rule.create!( + family: @family, + resource_type: "transaction", + effective_date: 1.day.ago.to_date, + conditions: [ Rule::Condition.new(condition_type: "transaction_amount", operator: ">", value: 60) ], + actions: [ Rule::Action.new(action_type: "set_transaction_category", value: @groceries_category.id) ] + ) + + # Rule 1 affects 2 transactions, Rule 2 affects 2 transactions + # But transaction_entry2 is matched by both, so total unique should be 3 + assert_equal 2, rule1.affected_resource_count + assert_equal 2, rule2.affected_resource_count + assert_equal 3, Rule.total_affected_resource_count([ rule1, rule2 ]) + end + + test "total_affected_resource_count returns zero for empty rules" do + assert_equal 0, Rule.total_affected_resource_count([]) + end end