diff --git a/app/controllers/concerns/accountable_resource.rb b/app/controllers/concerns/accountable_resource.rb
index 69f0a2696..1b3e8c040 100644
--- a/app/controllers/concerns/accountable_resource.rb
+++ b/app/controllers/concerns/accountable_resource.rb
@@ -112,6 +112,7 @@ module AccountableResource
:name, :balance, :subtype, :currency, :accountable_type, :return_to,
:opening_balance_date,
:institution_name, :institution_domain, :notes, :exclude_from_reports,
+ :enable_category_matcher,
accountable_attributes: self.class.permitted_accountable_attributes
)
end
diff --git a/app/models/account/linkable.rb b/app/models/account/linkable.rb
index b91d4ed83..f079331e2 100644
--- a/app/models/account/linkable.rb
+++ b/app/models/account/linkable.rb
@@ -69,6 +69,13 @@ module Account::Linkable
account_providers.exists?(provider_type: provider_type)
end
+ # Whether this account's provider applies the category matcher to imported
+ # transactions. Only Plaid honors `enable_category_matcher` today; extend this
+ # when other providers (e.g. SimpleFIN) wire up category matching.
+ def supports_category_matcher?
+ plaid_account.present? || linked_to?("PlaidAccount")
+ end
+
# Check if holdings can be deleted
# If account has multiple providers, returns true only if ALL providers allow deletion
# This prevents deleting holdings that would be recreated on next sync
diff --git a/app/models/plaid_entry/processor.rb b/app/models/plaid_entry/processor.rb
index c0a890038..cf2e1ff26 100644
--- a/app/models/plaid_entry/processor.rb
+++ b/app/models/plaid_entry/processor.rb
@@ -69,6 +69,7 @@ class PlaidEntry::Processor
def matched_category
return nil unless detailed_category
+ return nil unless account&.enable_category_matcher?
@matched_category ||= category_matcher.match(detailed_category)
end
diff --git a/app/models/simplefin_account/transactions/processor.rb b/app/models/simplefin_account/transactions/processor.rb
index 12fc46fdd..87f448a35 100644
--- a/app/models/simplefin_account/transactions/processor.rb
+++ b/app/models/simplefin_account/transactions/processor.rb
@@ -52,6 +52,11 @@ class SimplefinAccount::Transactions::Processor
private
+ # TODO: When SimpleFIN category matching is wired up (SimplefinAccount::Transactions::CategoryMatcher
+ # does not exist yet and this method is currently unused), apply the same
+ # `account&.enable_category_matcher?` guard used in PlaidEntry::Processor#matched_category,
+ # and include SimpleFIN in Account::Linkable#supports_category_matcher? so the toggle
+ # appears for SimpleFIN-linked accounts.
def category_matcher
@category_matcher ||= SimplefinAccount::Transactions::CategoryMatcher.new(family_categories)
end
diff --git a/app/views/accounts/_form.html.erb b/app/views/accounts/_form.html.erb
index c0c80bf75..60be71cdc 100644
--- a/app/views/accounts/_form.html.erb
+++ b/app/views/accounts/_form.html.erb
@@ -24,6 +24,16 @@
<%= yield form %>
+ <% if account.persisted? && account.supports_category_matcher? %>
+
+
+
<%= t(".enable_category_matcher_label") %>
+
<%= t(".enable_category_matcher_hint") %>
+
+ <%= form.toggle :enable_category_matcher %>
+
+ <% end %>
+
<%= icon "chevron-right", size: "sm", class: "group-open:rotate-90 transition-transform" %>
diff --git a/config/locales/views/accounts/en.yml b/config/locales/views/accounts/en.yml
index 398bfa38a..c5433efe7 100644
--- a/config/locales/views/accounts/en.yml
+++ b/config/locales/views/accounts/en.yml
@@ -51,6 +51,8 @@ en:
notes_label: Notes
notes_placeholder: Store additional information like account numbers, sort codes, IBAN, routing numbers, etc.
exclude_from_reports: Exclude from all reports
+ enable_category_matcher_label: Enable category matcher
+ enable_category_matcher_hint: When enabled, the provider's suggested category is applied to imported transactions. Disable to leave new transactions uncategorized.
index:
accounts: Accounts
cancel_sync: Cancel sync
diff --git a/db/migrate/20260708000000_add_enable_category_matcher_to_accounts.rb b/db/migrate/20260708000000_add_enable_category_matcher_to_accounts.rb
new file mode 100644
index 000000000..07b0e15e4
--- /dev/null
+++ b/db/migrate/20260708000000_add_enable_category_matcher_to_accounts.rb
@@ -0,0 +1,5 @@
+class AddEnableCategoryMatcherToAccounts < ActiveRecord::Migration[7.2]
+ def change
+ add_column :accounts, :enable_category_matcher, :boolean, default: true, null: false
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 19a0d2907..db6128099 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -120,6 +120,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_07_14_120000) do
t.datetime "disabled_at"
t.boolean "exclude_from_reports", default: false, null: false
t.integer "account_providers_count", default: 0, null: false
+ t.boolean "enable_category_matcher", default: true, null: false
t.index ["accountable_id", "accountable_type"], name: "index_accounts_on_accountable_id_and_accountable_type"
t.index ["accountable_type"], name: "index_accounts_on_accountable_type"
t.index ["currency"], name: "index_accounts_on_currency"
diff --git a/test/controllers/depositories_controller_test.rb b/test/controllers/depositories_controller_test.rb
index 9ce0eb70b..192823a48 100644
--- a/test/controllers/depositories_controller_test.rb
+++ b/test/controllers/depositories_controller_test.rb
@@ -38,4 +38,31 @@ class DepositoriesControllerTest < ActionDispatch::IntegrationTest
created = Account.order(:created_at).last
assert_redirected_to account_path(created) # not the external URL
end
+
+ test "update persists enable_category_matcher through the shared update action" do
+ linked_account = accounts(:connected)
+ assert linked_account.enable_category_matcher?
+
+ patch depository_path(linked_account), params: {
+ account: { enable_category_matcher: "0" }
+ }
+
+ refute linked_account.reload.enable_category_matcher?
+
+ patch depository_path(linked_account), params: {
+ account: { enable_category_matcher: "1" }
+ }
+
+ assert linked_account.reload.enable_category_matcher?
+ end
+
+ test "edit form renders category matcher toggle only for accounts that support it" do
+ get edit_account_url(accounts(:connected))
+ assert_response :success
+ assert_select "input[type=checkbox][name='account[enable_category_matcher]']", 1
+
+ get edit_account_url(accounts(:depository))
+ assert_response :success
+ assert_select "input[name='account[enable_category_matcher]']", 0
+ end
end
diff --git a/test/models/account/linkable_test.rb b/test/models/account/linkable_test.rb
index 560cb5649..ec6ecfb82 100644
--- a/test/models/account/linkable_test.rb
+++ b/test/models/account/linkable_test.rb
@@ -42,6 +42,41 @@ class Account::LinkableTest < ActiveSupport::TestCase
refute @account.linked_to?("SimplefinAccount")
end
+ test "supports_category_matcher? returns true for Plaid-linked accounts" do
+ plaid_account = plaid_accounts(:one)
+ AccountProvider.create!(account: @account, provider: plaid_account)
+
+ assert @account.supports_category_matcher?
+ end
+
+ test "supports_category_matcher? returns true for legacy plaid_account_id links" do
+ plaid_account = plaid_accounts(:one)
+ @account.update!(plaid_account: plaid_account)
+
+ assert @account.supports_category_matcher?
+ end
+
+ test "supports_category_matcher? returns false for unlinked and non-Plaid accounts" do
+ refute @account.supports_category_matcher?
+
+ simplefin_item = SimplefinItem.create!(
+ family: @family,
+ name: "Test SimpleFin",
+ access_url: "https://example.com/access_token"
+ )
+ simplefin_account = SimplefinAccount.create!(
+ simplefin_item: simplefin_item,
+ name: "Test Account",
+ account_id: "test-acct",
+ currency: "USD",
+ account_type: "checking",
+ current_balance: 0
+ )
+ @account.update!(simplefin_account: simplefin_account)
+
+ refute @account.supports_category_matcher?
+ end
+
test "can_delete_holdings? returns true for unlinked accounts" do
assert @account.unlinked?
assert @account.can_delete_holdings?
diff --git a/test/models/plaid_entry/processor_test.rb b/test/models/plaid_entry/processor_test.rb
index 730be2e7f..2078e951c 100644
--- a/test/models/plaid_entry/processor_test.rb
+++ b/test/models/plaid_entry/processor_test.rb
@@ -89,4 +89,35 @@ class PlaidEntry::ProcessorTest < ActiveSupport::TestCase
assert_equal "Amazon", entry.name
assert_equal categories(:food_and_drink).id, entry.transaction.category_id
end
+
+ test "skips category matcher when account.enable_category_matcher is false" do
+ @plaid_account.current_account.update!(enable_category_matcher: false)
+
+ plaid_transaction = {
+ "transaction_id" => "456",
+ "merchant_name" => "Amazon",
+ "amount" => 100,
+ "date" => Date.current,
+ "iso_currency_code" => "USD",
+ "personal_finance_category" => {
+ "detailed" => "Food"
+ },
+ "merchant_entity_id" => "456"
+ }
+
+ @category_matcher.expects(:match).never
+
+ processor = PlaidEntry::Processor.new(
+ plaid_transaction,
+ plaid_account: @plaid_account,
+ category_matcher: @category_matcher
+ )
+
+ assert_difference [ "Entry.count", "Transaction.count" ], 1 do
+ processor.process
+ end
+
+ entry = Entry.order(created_at: :desc).first
+ assert_nil entry.transaction.category_id
+ end
end