From fc0581fba324ee0ccdbc9a13f975aa9798e05afb Mon Sep 17 00:00:00 2001 From: Mike Lloyd <49411532+mike-lloyd03@users.noreply.github.com> Date: Thu, 16 Jul 2026 22:22:50 -0700 Subject: [PATCH] Add per-account toggle to disable automatic transaction categorization (#2636) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add per-account toggle to disable automatic transaction categorization Adds an `enable_category_matcher` boolean (default: true) to accounts so users can opt out of Plaid's automatic category suggestions on a per-account basis. When disabled, newly synced transactions arrive uncategorized so rules or manual assignment take precedence. - New migration adds `enable_category_matcher` column (default true, null: false) - `PlaidEntry::Processor#matched_category` gates the CategoryMatcher call on the account flag - Toggle rendered in the account edit modal for linked accounts (saves via main form submit) - `AccountableResource#account_params` permits the new field - `AccountsController#toggle_category_matcher` action added for potential API use - i18n strings added for label and hint text - Unit test covers the disabled-matcher path Co-Authored-By: Claude Sonnet 4.6 * Only show category matcher toggle for Plaid-linked accounts Only PlaidEntry::Processor honors enable_category_matcher, but the toggle rendered for every linked account, silently doing nothing for other providers. Adds Account::Linkable#supports_category_matcher? (covering both the legacy plaid_account_id link and AccountProvider rows) and gates the form toggle on it. The SimpleFIN TODO now also points at this helper so the toggle appears once SimpleFIN matching lands. Co-Authored-By: Claude Fable 5 * Add controller tests for category matcher toggle persistence and rendering Covers the two paths flagged in review as untested: the flag persisting through the shared AccountableResource#update action via account_params (both disable and re-enable), and the edit form rendering the toggle only for accounts where supports_category_matcher? is true. Co-Authored-By: Claude Fable 5 --------- Signed-off-by: Mike Lloyd <49411532+mike-lloyd03@users.noreply.github.com> Signed-off-by: Juan José Mata Co-authored-by: Claude Sonnet 4.6 Co-authored-by: Juan José Mata --- .../concerns/accountable_resource.rb | 1 + app/models/account/linkable.rb | 7 ++++ app/models/plaid_entry/processor.rb | 1 + .../transactions/processor.rb | 5 +++ app/views/accounts/_form.html.erb | 10 ++++++ config/locales/views/accounts/en.yml | 2 ++ ...add_enable_category_matcher_to_accounts.rb | 5 +++ db/schema.rb | 1 + .../depositories_controller_test.rb | 27 ++++++++++++++ test/models/account/linkable_test.rb | 35 +++++++++++++++++++ test/models/plaid_entry/processor_test.rb | 31 ++++++++++++++++ 11 files changed, 125 insertions(+) create mode 100644 db/migrate/20260708000000_add_enable_category_matcher_to_accounts.rb 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