Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 61 additions & 22 deletions app/models/family/auto_merchant_detector.rb
Original file line number Diff line number Diff line change
Expand Up @@ -30,32 +30,29 @@ def auto_detect
modified_count = 0
scope.each do |transaction|
auto_detection = result.data.find { |c| c.transaction_id == transaction.id }
next unless auto_detection&.business_name.present? && auto_detection&.business_url.present?

merchant_id = user_merchants_input.find { |m| m[:name] == auto_detection&.business_name }&.dig(:id)
existing_merchant = transaction.merchant

if merchant_id.nil? && auto_detection&.business_url.present? && auto_detection&.business_name.present? && Setting.brand_fetch_client_id.present?
ai_provider_merchant = ProviderMerchant.find_or_create_by!(
source: "ai",
name: auto_detection.business_name,
website_url: auto_detection.business_url,
) do |pm|
pm.logo_url = "#{default_logo_provider_url}/#{auto_detection.business_url}/icon/fallback/lettermark/w/40/h/40?c=#{Setting.brand_fetch_client_id}"
if existing_merchant.nil?
# Case 1: No merchant - create/find AI merchant and assign
merchant_id = find_matching_user_merchant(auto_detection)
merchant_id ||= find_or_create_ai_merchant(auto_detection)&.id

if merchant_id.present?
was_modified = transaction.enrich_attribute(:merchant_id, merchant_id, source: "ai")
transaction.lock_attr!(:merchant_id)
modified_count += 1 if was_modified
end
end

merchant_id = merchant_id || ai_provider_merchant&.id

if merchant_id.present?
was_modified = transaction.enrich_attribute(
:merchant_id,
merchant_id,
source: "ai"
)
# We lock the attribute so that this Rule doesn't try to run again
transaction.lock_attr!(:merchant_id)
# enrich_attribute returns true if the transaction was actually modified
modified_count += 1 if was_modified
elsif existing_merchant.is_a?(ProviderMerchant) && existing_merchant.source != "ai"
# Case 2: Has provider merchant (non-AI) - enhance it with AI data
if enhance_provider_merchant(existing_merchant, auto_detection)
transaction.lock_attr!(:merchant_id)
modified_count += 1
end
end
# Case 3: AI merchant or FamilyMerchant - skip (already good or user-set)
end

modified_count
Expand Down Expand Up @@ -95,8 +92,50 @@ def transactions_input
end

def scope
family.transactions.where(id: transaction_ids, merchant_id: nil)
family.transactions.where(id: transaction_ids)
.enrichable(:merchant_id)
.includes(:merchant, :entry)
end

def find_matching_user_merchant(auto_detection)
user_merchants_input.find { |m| m[:name] == auto_detection.business_name }&.dig(:id)
end

def find_or_create_ai_merchant(auto_detection)
# Only use (source, name) for find_or_create since that's the uniqueness constraint
ProviderMerchant.find_or_create_by!(
source: "ai",
name: auto_detection.business_name
) do |pm|
pm.website_url = auto_detection.business_url
if Setting.brand_fetch_client_id.present?
pm.logo_url = "#{default_logo_provider_url}/#{auto_detection.business_url}/icon/fallback/lettermark/w/40/h/40?c=#{Setting.brand_fetch_client_id}"
end
end
rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotUnique
# Race condition: another process created the merchant between our find and create
ProviderMerchant.find_by(source: "ai", name: auto_detection.business_name)
end
Comment on lines +100 to +118
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

User-merchant matching and AI merchant creation: robustness and error handling

find_matching_user_merchant currently does a linear, case-sensitive match on the in-memory user_merchants_input. For better UX and performance on large merchant lists, you could (optionally) normalize names (downcase/strip) and prebuild a hash once, e.g. @user_merchants_by_name ||= family.merchants.index_by { |m| m.name.downcase }, then look up by auto_detection.business_name.downcase.

More importantly, find_or_create_ai_merchant rescues both ActiveRecord::RecordInvalid and ActiveRecord::RecordNotUnique as if they were race conditions. RecordNotUnique is the race you expect, but RecordInvalid typically indicates a real validation failure (e.g., invalid URL or length constraints). Swallowing it and then doing find_by can silently hide data issues and leave you with nil and no log. I’d strongly recommend splitting the rescue and logging the validation error, for example:

-    ProviderMerchant.find_or_create_by!(
-      source: "ai",
-      name: auto_detection.business_name
-    ) do |pm|
-      pm.website_url = auto_detection.business_url
-      if Setting.brand_fetch_client_id.present?
-        pm.logo_url = "#{default_logo_provider_url}/#{auto_detection.business_url}/icon/fallback/lettermark/w/40/h/40?c=#{Setting.brand_fetch_client_id}"
-      end
-    end
-  rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotUnique
-    # Race condition: another process created the merchant between our find and create
-    ProviderMerchant.find_by(source: "ai", name: auto_detection.business_name)
+    ProviderMerchant.find_or_create_by!(
+      source: "ai",
+      name: auto_detection.business_name
+    ) do |pm|
+      pm.website_url = auto_detection.business_url
+      if Setting.brand_fetch_client_id.present?
+        pm.logo_url = "#{default_logo_provider_url}/#{auto_detection.business_url}/icon/fallback/lettermark/w/40/h/40?c=#{Setting.brand_fetch_client_id}"
+      end
+    end
+  rescue ActiveRecord::RecordNotUnique
+    # Race condition: another process created the merchant between our find and create
+    ProviderMerchant.find_by(source: "ai", name: auto_detection.business_name)
+  rescue ActiveRecord::RecordInvalid => e
+    Rails.logger.error("Failed to create AI merchant for '#{auto_detection.business_name}': #{e.message}")
+    nil

This keeps the race-condition path safe while surfacing real data problems instead of silently dropping them.

🤖 Prompt for AI Agents
In app/models/family/auto_merchant_detector.rb around lines 100 to 118, improve
user-merchant matching by normalizing names and avoiding an O(n) scan: build a
memoized hash like @user_merchants_by_name ||= user_merchants_input.index_by {
|m| m[:name].to_s.strip.downcase } and then lookup with
auto_detection.business_name.to_s.strip.downcase to return the id; and change
find_or_create_ai_merchant error handling so you only treat RecordNotUnique as
the race-condition path (rescue ActiveRecord::RecordNotUnique and fallback to
ProviderMerchant.find_by(...)), while allowing ActiveRecord::RecordInvalid to
surface (or log and re-raise) instead of silently swallowing it so real
validation failures are visible.


def enhance_provider_merchant(merchant, auto_detection)
updates = {}

# Add website_url if missing
if merchant.website_url.blank? && auto_detection.business_url.present?
updates[:website_url] = auto_detection.business_url

# Add logo if BrandFetch is configured
if Setting.brand_fetch_client_id.present?
updates[:logo_url] = "#{default_logo_provider_url}/#{auto_detection.business_url}/icon/fallback/lettermark/w/40/h/40?c=#{Setting.brand_fetch_client_id}"
end
end

return false if updates.empty?

merchant.update!(updates)
true
rescue ActiveRecord::RecordInvalid => e
Rails.logger.error("Failed to enhance merchant #{merchant.id}: #{e.message}")
false
end
end