From 83ff095edfeca982af7dc8036c53e2c35f3f9882 Mon Sep 17 00:00:00 2001 From: samuelcseto Date: Thu, 8 Jan 2026 23:42:01 +0100 Subject: [PATCH] Fix provider merchant lookup to handle case variations in names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: Transactions were silently failing to import when the provider (e.g., Lunchflow) returned merchant names with inconsistent casing. Root cause discovered when: 1. User A connected their account, synced transactions with merchant "JOHN DOE" 2. User A disconnected their account (but ProviderMerchant records persist) 3. User B connected their account 4. User B had a transaction to the same person but provider returned it as "John Doe" (different casing) 5. Lookup by (source, name) failed to find existing "JOHN DOE" merchant 6. Tried to create new merchant with same provider_merchant_id (derived from MD5 of lowercased name) → unique constraint violation 7. Transaction import failed silently Impact: - Missing transactions caused incorrect account balances (showing negative when they shouldn't) - Balance chart displayed incorrect historical data - Users saw fewer transactions than actually existed in their bank Solution: Look up merchants by provider_merchant_id first (the stable, case-insensitive identifier derived from the normalized merchant name), then fall back to exact name match for backwards compatibility. --- app/models/account/provider_import_adapter.rb | 50 ++++++++++++------- 1 file changed, 31 insertions(+), 19 deletions(-) diff --git a/app/models/account/provider_import_adapter.rb b/app/models/account/provider_import_adapter.rb index d30e8154e..52964b165 100644 --- a/app/models/account/provider_import_adapter.rb +++ b/app/models/account/provider_import_adapter.rb @@ -93,27 +93,39 @@ class Account::ProviderImportAdapter def find_or_create_merchant(provider_merchant_id:, name:, source:, website_url: nil, logo_url: nil) return nil unless provider_merchant_id.present? && name.present? - # ProviderMerchant has a unique index on [source, name], so find by those first - # This handles cases where the provider_merchant_id format changes - merchant = begin - ProviderMerchant.find_or_create_by!(source: source, name: name) do |m| - m.provider_merchant_id = provider_merchant_id - m.website_url = website_url - m.logo_url = logo_url + # First try to find by provider_merchant_id (stable identifier derived from normalized name) + # This handles case variations in merchant names (e.g., "ACME Corp" vs "Acme Corp") + merchant = ProviderMerchant.find_by(provider_merchant_id: provider_merchant_id, source: source) + + # If not found by provider_merchant_id, try by exact name match (backwards compatibility) + merchant ||= ProviderMerchant.find_by(source: source, name: name) + + if merchant + # Update logo if provided and merchant doesn't have one (or has a different one) + # Best-effort: don't fail transaction import if logo update fails + if logo_url.present? && merchant.logo_url != logo_url + begin + merchant.update!(logo_url: logo_url) + rescue StandardError => e + Rails.logger.warn("Failed to update merchant logo: merchant_id=#{merchant.id} logo_url=#{logo_url} error=#{e.message}") + end end + return merchant + end + + # Create new merchant + begin + merchant = ProviderMerchant.create!( + source: source, + name: name, + provider_merchant_id: provider_merchant_id, + website_url: website_url, + logo_url: logo_url + ) rescue ActiveRecord::RecordNotUnique - # Handle race condition where another process created the record - ProviderMerchant.find_by(source: source, name: name) - end - - # Update provider_merchant_id if it changed (e.g., format update) - if merchant.provider_merchant_id != provider_merchant_id - merchant.update!(provider_merchant_id: provider_merchant_id) - end - - # Update logo if provided and merchant doesn't have one (or has a different one) - if logo_url.present? && merchant.logo_url != logo_url - merchant.update!(logo_url: logo_url) + # Race condition - another process created the record + merchant = ProviderMerchant.find_by(provider_merchant_id: provider_merchant_id, source: source) || + ProviderMerchant.find_by(source: source, name: name) end merchant