From 04549d80bf652f422a5911959b85ea46add10b9c Mon Sep 17 00:00:00 2001
From: Himank Dave <93311724+steadyfall@users.noreply.github.com>
Date: Thu, 14 May 2026 15:56:49 -0400
Subject: [PATCH] fix(rules): count blocked rule transactions (#1782)
* Add blocked count to rule run summary
* test(rules): cover rule run blocked counts
* fix(rules): derive blocked count from modified rows
Blocked rule transactions are the processed rows that were not modified. This keeps the displayed queued / processed / modified / blocked summary aligned when a run has already processed all matching rows but some were skipped by enrichment locks.
* fix(rules): count processed rows for rule jobs
Synchronous rule actions return the number of rows they modified, but rule-run processed counts should represent the number of matched transactions the job attempted to process. Using queued matches for processed preserves the distinction between processed and modified rows, which lets locked manual edits appear as blocked instead of making processed collapse to modified.
This changes RuleJob counter semantics, so it was committed separately from the derived blocked-count display change.
---
app/jobs/rule_job.rb | 2 +-
app/models/rule_run.rb | 4 ++
app/views/rules/index.html.erb | 3 +-
config/locales/views/rules/en.yml | 1 +
test/controllers/rules_controller_test.rb | 20 +++++++++
test/jobs/rule_job_test.rb | 54 +++++++++++++++++++++++
test/system/rules_test.rb | 37 ++++++++++++++++
7 files changed, 119 insertions(+), 2 deletions(-)
create mode 100644 test/jobs/rule_job_test.rb
create mode 100644 test/system/rules_test.rb
diff --git a/app/jobs/rule_job.rb b/app/jobs/rule_job.rb
index 92a23c086..c70c79fa9 100644
--- a/app/jobs/rule_job.rb
+++ b/app/jobs/rule_job.rb
@@ -40,7 +40,7 @@ class RuleJob < ApplicationJob
status = "pending"
elsif result.is_a?(Integer)
# Only synchronous actions were executed
- transactions_processed = result
+ transactions_processed = transactions_queued
transactions_modified = result
status = "success"
else
diff --git a/app/models/rule_run.rb b/app/models/rule_run.rb
index 8f0ac759f..3028de44a 100644
--- a/app/models/rule_run.rb
+++ b/app/models/rule_run.rb
@@ -27,6 +27,10 @@ class RuleRun < ApplicationRecord
status == "failed"
end
+ def transactions_blocked
+ [ transactions_processed - transactions_modified, 0 ].max
+ end
+
# Thread-safe method to complete a job and update the run
def complete_job!(modified_count: 0)
with_lock do
diff --git a/app/views/rules/index.html.erb b/app/views/rules/index.html.erb
index 49282af42..00d0dfb94 100644
--- a/app/views/rules/index.html.erb
+++ b/app/views/rules/index.html.erb
@@ -128,6 +128,7 @@
<%= t("rules.recent_runs.columns.transactions_counts.queued") %>
<%= t("rules.recent_runs.columns.transactions_counts.processed") %>
<%= t("rules.recent_runs.columns.transactions_counts.modified") %>
+ <%= t("rules.recent_runs.columns.transactions_counts.blocked", default: "Blocked") %>
@@ -169,7 +170,7 @@
<%= run.rule_name.presence || run.rule&.name.presence || t("rules.recent_runs.unnamed_rule") %>
- <%= "#{number_with_delimiter(run.transactions_queued)} / #{number_with_delimiter(run.transactions_processed)} / #{number_with_delimiter(run.transactions_modified)}" %>
+ <%= "#{number_with_delimiter(run.transactions_queued)} / #{number_with_delimiter(run.transactions_processed)} / #{number_with_delimiter(run.transactions_modified)} / #{number_with_delimiter(run.transactions_blocked)}" %>
|
<% end %>
diff --git a/config/locales/views/rules/en.yml b/config/locales/views/rules/en.yml
index 81eccc7e8..5c6fac3bf 100644
--- a/config/locales/views/rules/en.yml
+++ b/config/locales/views/rules/en.yml
@@ -31,6 +31,7 @@ en:
queued: Queued
processed: Processed
modified: Modified
+ blocked: Blocked
execution_types:
manual: Manual
scheduled: Scheduled
diff --git a/test/controllers/rules_controller_test.rb b/test/controllers/rules_controller_test.rb
index baa30a1f3..cdbc49134 100644
--- a/test/controllers/rules_controller_test.rb
+++ b/test/controllers/rules_controller_test.rb
@@ -210,6 +210,26 @@ class RulesControllerTest < ActionDispatch::IntegrationTest
end
end
+ test "index shows blocked count in recent runs summary" do
+ rule = rules(:one)
+ RuleRun.create!(
+ rule: rule,
+ execution_type: "manual",
+ status: "success",
+ transactions_queued: 10,
+ transactions_processed: 7,
+ transactions_modified: 4,
+ pending_jobs_count: 0,
+ executed_at: Time.current
+ )
+
+ get rules_url
+
+ assert_response :success
+ assert_select "th", text: /Queued\s+Processed\s+Modified\s+Blocked/
+ assert_select "td", text: "10 / 7 / 4 / 3"
+ end
+
test "should get confirm_all" do
get confirm_all_rules_url
assert_response :success
diff --git a/test/jobs/rule_job_test.rb b/test/jobs/rule_job_test.rb
new file mode 100644
index 000000000..c7d1c462a
--- /dev/null
+++ b/test/jobs/rule_job_test.rb
@@ -0,0 +1,54 @@
+require "test_helper"
+
+class RuleJobTest < ActiveJob::TestCase
+ include EntriesTestHelper
+
+ setup do
+ @family = families(:empty)
+ @account = @family.accounts.create!(name: "Rule job test", balance: 1000, currency: "USD", accountable: Depository.new)
+ @food_and_dining = @family.categories.create!(name: "Food & Dining")
+ @groceries = @family.categories.create!(name: "Groceries")
+ end
+
+ test "records manually locked matching transactions as blocked" do
+ 20.times do |index|
+ create_transaction(
+ account: @account,
+ name: "Whole Foods #{index}",
+ date: Date.current - index.days
+ )
+ end
+
+ manually_locked_transactions = @family.transactions
+ .joins(:entry)
+ .where("entries.name LIKE ?", "Whole Foods%")
+ .order("entries.date DESC")
+ .limit(10)
+
+ manually_locked_transactions.each do |transaction|
+ transaction.update!(category: @groceries)
+ transaction.lock_attr!(:category_id)
+ transaction.entry.mark_user_modified!
+ end
+
+ rule = @family.rules.create!(
+ name: "Whole Foods Testing",
+ resource_type: "transaction",
+ effective_date: 1.year.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: @food_and_dining.id)
+ ]
+ )
+
+ RuleJob.perform_now(rule)
+
+ rule_run = rule.rule_runs.order(:created_at).last
+ assert_equal 20, rule_run.transactions_queued
+ assert_equal 20, rule_run.transactions_processed
+ assert_equal 10, rule_run.transactions_modified
+ assert_equal 10, rule_run.transactions_blocked
+ end
+end
diff --git a/test/system/rules_test.rb b/test/system/rules_test.rb
new file mode 100644
index 000000000..49f4601bc
--- /dev/null
+++ b/test/system/rules_test.rb
@@ -0,0 +1,37 @@
+require "application_system_test_case"
+
+class RulesTest < ApplicationSystemTestCase
+ setup do
+ sign_in @user = users(:family_admin)
+ end
+
+ test "shows queued processed modified and blocked counts for recent rule runs" do
+ rule = @user.family.rules.create!(
+ name: "Whole Foods Testing",
+ resource_type: "transaction",
+ effective_date: 1.year.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: categories(:food_and_drink).id)
+ ]
+ )
+
+ rule.rule_runs.create!(
+ rule_name: rule.name,
+ execution_type: "manual",
+ status: "success",
+ transactions_queued: 20,
+ transactions_processed: 20,
+ transactions_modified: 10,
+ pending_jobs_count: 0,
+ executed_at: Time.current
+ )
+
+ visit rules_path
+
+ assert_selector "th", text: /queued\s+processed\s+modified\s+blocked/i
+ assert_selector "td", text: "20 / 20 / 10 / 10"
+ end
+end