Merchant Data Model#
The merchants table uses Single Table Inheritance (STI) to store two distinct merchant subtypes under one table, distinguished by a type column . The two concrete classes are:
FamilyMerchant— user-created merchants scoped to a specificFamilyProviderMerchant— globally shared merchants sourced from external data providers
The type column and STI structure were added in a 2025 migration that also made family_id nullable and introduced website_url, logo_url, and source columns .
Base class: Merchant#
app/models/merchant.rb defines the shared interface:
has_many :transactions, dependent: :nullify— deleting a merchant nullifies the FK on transactions, not the transactions themselveshas_many :recurring_transactions, dependent: :destroy- Validates
namepresence andtypeinclusion inTYPES = %w[FamilyMerchant ProviderMerchant] - Scope:
alphabeticallyorders by name
FamilyMerchant#
app/models/family_merchant.rb — private, per-family merchants:
belongs_to :family— name uniqueness is enforced within a family's scope- Auto-assigns a random display color from a 10-color palette on creation
- Auto-generates
logo_urlfromwebsite_urlvia the BrandFetch CDN when the URL is set or changes, gated onSetting.brand_fetch_client_id - Destroying a
FamilyMerchantdeletes the record entirely
ProviderMerchant#
app/models/provider_merchant.rb — globally shared merchants synced from financial data providers:
sourceenum covers 14 providers:plaid,simplefin,lunchflow,akahu,up,synth,ai,enable_banking,coinstats,mercury,brex,indexa_capital,sophtron,questrade- Name uniqueness is scoped to
source— the same merchant name can exist across different providers - Cannot be deleted via the UI — instead,
unlink_from_familynullifiesmerchant_idon the family's transactions and records the event inFamilyMerchantAssociation - Conversion:
convert_to_family_merchant_for(family, attributes)creates a newFamilyMerchantfor the family and re-points all that family's transactions to it. It also callsEntry.mark_user_modified_for_transactions!first, to protect the reassignment from being reverted by the next provider sync
FamilyMerchantAssociation#
A join/tracking table created in a 2026 migration . Records when a ProviderMerchant was last unlinked from a family (unlinked_at). Used by the UI to surface "recently unlinked" merchants within the past 30 days .
Rules Integration#
Merchants participate in the transaction rules engine via two classes under app/models/rule/:
| Class | Role |
|---|---|
Rule::ConditionFilter::TransactionMerchant | Filter transactions by merchant in rule conditions |
Rule::ActionExecutor::SetTransactionMerchant | Assign a merchant to matched transactions |
The condition filter uses left_joins(:merchant) and filters on merchants.id . The action executor resolves the target merchant from the family's own merchants and calls enrich_attribute(:merchant_id, ...) with source: "rule", respecting attribute locks unless overridden .
Key Entry Points#
| File | Purpose |
|---|---|
app/models/merchant.rb | Base class, shared associations & validations |
app/models/family_merchant.rb | User-created, family-scoped merchants |
app/models/provider_merchant.rb | Provider-synced global merchants |
app/controllers/family_merchants_controller.rb | CRUD, merge, convert, enhance actions |
app/models/rule/condition_filter/transaction_merchant.rb | Rule condition: filter by merchant |
app/models/rule/action_executor/set_transaction_merchant.rb | Rule action: set merchant on transactions |