Fix provider merchant lookup to handle case variations in names

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.
This commit is contained in:
samuelcseto
2026-01-08 23:42:01 +01:00
parent 701742e218
commit 83ff095edf

View File

@@ -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