Merchants improvements (#594)

* FIX logos

* Implement merchant mods

* FIX confirm issue

* FIX linter

* Add recently seen merchants to re-add if needed

* Update merge.html.erb

* FIX do security check

* Add error handling for update failures.
This commit is contained in:
soky srm
2026-01-09 19:38:04 +01:00
committed by GitHub
parent 140ea78b0e
commit 76dc91377c
25 changed files with 431 additions and 36 deletions

View File

@@ -8,6 +8,15 @@ class FamilyMerchantsController < ApplicationController
@family_merchants = Current.family.merchants.alphabetically
@provider_merchants = Current.family.assigned_merchants.where(type: "ProviderMerchant").alphabetically
# Show recently unlinked ProviderMerchants (within last 30 days)
# Exclude merchants that are already assigned to transactions (they appear in provider_merchants)
recently_unlinked_ids = FamilyMerchantAssociation
.where(family: Current.family)
.recently_unlinked
.pluck(:merchant_id)
assigned_ids = @provider_merchants.pluck(:id)
@unlinked_merchants = ProviderMerchant.where(id: recently_unlinked_ids - assigned_ids).alphabetically
render layout: "settings"
end
@@ -32,24 +41,91 @@ class FamilyMerchantsController < ApplicationController
end
def update
@family_merchant.update!(merchant_params)
respond_to do |format|
format.html { redirect_to family_merchants_path, notice: t(".success") }
format.turbo_stream { render turbo_stream: turbo_stream.action(:redirect, family_merchants_path) }
if @merchant.is_a?(ProviderMerchant)
# Convert ProviderMerchant to FamilyMerchant for this family only
@family_merchant = @merchant.convert_to_family_merchant_for(Current.family, merchant_params)
respond_to do |format|
format.html { redirect_to family_merchants_path, notice: t(".converted_success") }
format.turbo_stream { render turbo_stream: turbo_stream.action(:redirect, family_merchants_path) }
end
elsif @merchant.update(merchant_params)
respond_to do |format|
format.html { redirect_to family_merchants_path, notice: t(".success") }
format.turbo_stream { render turbo_stream: turbo_stream.action(:redirect, family_merchants_path) }
end
else
render :edit, status: :unprocessable_entity
end
rescue ActiveRecord::RecordInvalid => e
@family_merchant = e.record
render :edit, status: :unprocessable_entity
end
def destroy
@family_merchant.destroy!
redirect_to family_merchants_path, notice: t(".success")
if @merchant.is_a?(ProviderMerchant)
# Unlink from family's transactions only (don't delete the global merchant)
@merchant.unlink_from_family(Current.family)
redirect_to family_merchants_path, notice: t(".unlinked_success")
else
@merchant.destroy!
redirect_to family_merchants_path, notice: t(".success")
end
end
def merge
@merchants = all_family_merchants
end
def perform_merge
# Scope lookups to merchants valid for this family (FamilyMerchants + assigned ProviderMerchants)
valid_merchants = all_family_merchants
target = valid_merchants.find_by(id: params[:target_id])
unless target
return redirect_to merge_family_merchants_path, alert: t(".target_not_found")
end
sources = valid_merchants.where(id: params[:source_ids])
unless sources.any?
return redirect_to merge_family_merchants_path, alert: t(".invalid_merchants")
end
merger = Merchant::Merger.new(
family: Current.family,
target_merchant: target,
source_merchants: sources
)
if merger.merge!
redirect_to family_merchants_path, notice: t(".success", count: merger.merged_count)
else
redirect_to merge_family_merchants_path, alert: t(".no_merchants_selected")
end
rescue Merchant::Merger::UnauthorizedMerchantError => e
redirect_to merge_family_merchants_path, alert: e.message
end
private
def set_merchant
@family_merchant = Current.family.merchants.find(params[:id])
# Find merchant that either belongs to family OR is assigned to family's transactions
@merchant = Current.family.merchants.find_by(id: params[:id]) ||
Current.family.assigned_merchants.find(params[:id])
@family_merchant = @merchant # For backwards compatibility with views
end
def merchant_params
params.require(:family_merchant).permit(:name, :color)
# Handle both family_merchant and provider_merchant param keys
key = params.key?(:family_merchant) ? :family_merchant : :provider_merchant
params.require(key).permit(:name, :color)
end
def all_family_merchants
family_merchant_ids = Current.family.merchants.pluck(:id)
provider_merchant_ids = Current.family.assigned_merchants.where(type: "ProviderMerchant").pluck(:id)
combined_ids = (family_merchant_ids + provider_merchant_ids).uniq
Merchant.where(id: combined_ids)
.distinct
.order(Arel.sql("LOWER(COALESCE(name, ''))"))
end
end

View File

@@ -0,0 +1,17 @@
class DataCleanerJob < ApplicationJob
queue_as :scheduled
def perform
clean_old_merchant_associations
end
private
def clean_old_merchant_associations
# Delete FamilyMerchantAssociation records older than 30 days
deleted_count = FamilyMerchantAssociation
.where(unlinked_at: ...30.days.ago)
.delete_all
Rails.logger.info("DataCleanerJob: Deleted #{deleted_count} old merchant associations") if deleted_count > 0
end
end

View File

@@ -45,6 +45,15 @@ class Family < ApplicationRecord
Merchant.where(id: merchant_ids)
end
def available_merchants
assigned_ids = transactions.where.not(merchant_id: nil).pluck(:merchant_id).uniq
recently_unlinked_ids = FamilyMerchantAssociation
.where(family: self)
.recently_unlinked
.pluck(:merchant_id)
Merchant.where(id: (assigned_ids + recently_unlinked_ids).uniq)
end
def auto_categorize_transactions_later(transactions, rule_run_id: nil)
AutoCategorizeJob.perform_later(self, transaction_ids: transactions.pluck(:id), rule_run_id: rule_run_id)
end

View File

@@ -102,21 +102,33 @@ class Family::AutoMerchantDetector
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
# Strategy 1: Find existing merchant by website_url (most reliable for deduplication)
if auto_detection.business_url.present?
existing = ProviderMerchant.find_by(website_url: auto_detection.business_url)
return existing if existing
end
# Strategy 2: Find by exact name match
existing = ProviderMerchant.find_by(source: "ai", name: auto_detection.business_name)
return existing if existing
# Strategy 3: Create new merchant
ProviderMerchant.create!(
source: "ai",
name: auto_detection.business_name,
website_url: auto_detection.business_url,
logo_url: build_logo_url(auto_detection.business_url)
)
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
def build_logo_url(business_url)
return nil unless Setting.brand_fetch_client_id.present? && business_url.present?
"#{default_logo_provider_url}/#{business_url}/icon/fallback/lettermark/w/40/h/40?c=#{Setting.brand_fetch_client_id}"
end
def enhance_provider_merchant(merchant, auto_detection)
updates = {}

View File

@@ -0,0 +1,6 @@
class FamilyMerchantAssociation < ApplicationRecord
belongs_to :family
belongs_to :merchant
scope :recently_unlinked, -> { where(unlinked_at: 30.days.ago..).where.not(unlinked_at: nil) }
end

View File

@@ -0,0 +1,54 @@
class Merchant::Merger
class UnauthorizedMerchantError < StandardError; end
attr_reader :family, :target_merchant, :source_merchants, :merged_count
def initialize(family:, target_merchant:, source_merchants:)
@family = family
@target_merchant = target_merchant
@merged_count = 0
validate_merchant_belongs_to_family!(target_merchant, "Target merchant")
sources = Array(source_merchants)
sources.each { |m| validate_merchant_belongs_to_family!(m, "Source merchant '#{m.name}'") }
@source_merchants = sources.reject { |m| m.id == target_merchant.id }
end
private
def validate_merchant_belongs_to_family!(merchant, label)
return if family_merchant_ids.include?(merchant.id)
raise UnauthorizedMerchantError, "#{label} does not belong to this family"
end
def family_merchant_ids
@family_merchant_ids ||= begin
family_ids = family.merchants.pluck(:id)
assigned_ids = family.assigned_merchants.pluck(:id)
(family_ids + assigned_ids).uniq
end
end
public
def merge!
return false if source_merchants.empty?
Merchant.transaction do
source_merchants.each do |source|
# Reassign family's transactions to target
family.transactions.where(merchant_id: source.id).update_all(merchant_id: target_merchant.id)
# Delete FamilyMerchant, keep ProviderMerchant (it may be used by other families)
source.destroy! if source.is_a?(FamilyMerchant)
@merged_count += 1
end
end
true
end
end

View File

@@ -3,4 +3,34 @@ class ProviderMerchant < Merchant
validates :name, uniqueness: { scope: [ :source ] }
validates :source, presence: true
# Convert this ProviderMerchant to a FamilyMerchant for a specific family.
# Only affects transactions belonging to that family.
# Returns the newly created FamilyMerchant.
def convert_to_family_merchant_for(family, attributes = {})
transaction do
family_merchant = family.merchants.create!(
name: attributes[:name].presence || name,
color: attributes[:color].presence || FamilyMerchant::COLORS.sample,
logo_url: logo_url,
website_url: website_url
)
# Update only this family's transactions to point to new merchant
family.transactions.where(merchant_id: id).update_all(merchant_id: family_merchant.id)
family_merchant
end
end
# Unlink from family's transactions (set merchant_id to null).
# Does NOT delete the ProviderMerchant since it may be used by other families.
# Tracks the unlink in FamilyMerchantAssociation so it shows as "recently unlinked".
def unlink_from_family(family)
family.transactions.where(merchant_id: id).update_all(merchant_id: nil)
# Track that this merchant was unlinked from this family
association = FamilyMerchantAssociation.find_or_initialize_by(family: family, merchant: self)
association.update!(unlinked_at: Time.current)
end
end

View File

@@ -27,7 +27,25 @@ class Security < ApplicationRecord
)
end
def brandfetch_icon_url(width: 40, height: 40)
return nil unless Setting.brand_fetch_client_id.present? && website_url.present?
domain = extract_domain(website_url)
return nil unless domain.present?
"https://cdn.brandfetch.io/#{domain}/icon/fallback/lettermark/w/#{width}/h/#{height}?c=#{Setting.brand_fetch_client_id}"
end
private
def extract_domain(url)
uri = URI.parse(url)
host = uri.host || url
host.sub(/\Awww\./, "")
rescue URI::InvalidURIError
nil
end
def upcase_symbols
self.ticker = ticker.upcase
self.exchange_operating_mic = exchange_operating_mic.upcase if exchange_operating_mic.present?

View File

@@ -68,7 +68,7 @@ module Security::Provided
return
end
if self.name.present? && self.logo_url.present? && !clear_cache
if self.name.present? && (self.logo_url.present? || self.website_url.present?) && !clear_cache
return
end
@@ -81,6 +81,7 @@ module Security::Provided
update(
name: response.data.name,
logo_url: response.data.logo_url,
website_url: response.data.links
)
else
Rails.logger.warn("Failed to fetch security info for #{ticker} from #{provider.class.name}: #{response.error.message}")

View File

@@ -9,6 +9,8 @@ class Transaction < ApplicationRecord
accepts_nested_attributes_for :taggings, allow_destroy: true
after_save :clear_merchant_unlinked_association, if: :merchant_id_previously_changed?
enum :kind, {
standard: "standard", # A regular transaction, included in budget analytics
funds_movement: "funds_movement", # Movement of funds between accounts, excluded from budget analytics
@@ -39,4 +41,14 @@ class Transaction < ApplicationRecord
rescue
false
end
private
def clear_merchant_unlinked_association
return unless merchant_id.present? && merchant.is_a?(ProviderMerchant)
family = entry&.account&.family
return unless family
FamilyMerchantAssociation.where(family: family, merchant: merchant).delete_all
end
end

View File

@@ -1,7 +1,7 @@
<%# locals: (family_merchant:) %>
<div data-controller="color-avatar">
<%= styled_form_with model: family_merchant, class: "space-y-4" do |f| %>
<%= styled_form_with model: family_merchant, url: family_merchant.persisted? ? family_merchant_path(family_merchant) : family_merchants_path, class: "space-y-4" do |f| %>
<section class="space-y-4">
<% if family_merchant.errors.any? %>
<%= render "shared/form_errors", model: family_merchant %>

View File

@@ -21,4 +21,21 @@
<td class="py-3 px-4 text-sm text-secondary align-middle">
<%= provider_merchant.source&.titleize %>
</td>
<td class="py-3 px-4 text-sm text-right align-middle">
<%= render DS::Menu.new do |menu| %>
<% menu.with_item(variant: "link", text: t(".edit"), href: edit_family_merchant_path(provider_merchant), icon: "pencil", data: { turbo_frame: "modal" }) %>
<% menu.with_item(
variant: "button",
text: t(".remove"),
href: family_merchant_path(provider_merchant),
icon: "trash-2",
method: :delete,
confirm: CustomConfirm.new(
destructive: true,
title: t(".remove_confirm_title"),
body: t(".remove_confirm_body", name: provider_merchant.name),
btn_text: t(".remove")
)) %>
<% end %>
</td>
</tr>

View File

@@ -1,12 +1,20 @@
<header class="flex items-center justify-between">
<h1 class="text-primary text-xl font-medium">Merchants</h1>
<h1 class="text-primary text-xl font-medium"><%= t(".title") %></h1>
<%= render DS::Link.new(
text: "New merchant",
variant: "primary",
href: new_family_merchant_path,
frame: :modal
) %>
<div class="flex items-center gap-2">
<%= render DS::Link.new(
text: t(".merge"),
variant: "outline",
href: merge_family_merchants_path,
frame: :modal
) %>
<%= render DS::Link.new(
text: t(".new"),
variant: "primary",
href: new_family_merchant_path,
frame: :modal
) %>
</div>
</header>
<div class="bg-container rounded-xl shadow-border-xs p-4 space-y-6">
@@ -59,7 +67,7 @@
<div class="p-4 bg-container-inset border border-secondary rounded-lg">
<div class="flex items-start gap-2">
<%= icon "info", class: "w-5 h-5 text-link mt-0.5 flex-shrink-0" %>
<p class="text-xs text-secondary leading-relaxed"><%= t(".provider_read_only") %></p>
<p class="text-xs text-secondary leading-relaxed"><%= t(".provider_info") %></p>
</div>
</div>
@@ -71,6 +79,7 @@
<tr class="text-xs uppercase font-medium text-secondary border-b border-divider">
<th scope="col" class="text-left py-3 px-4"><%= t(".table.merchant") %></th>
<th scope="col" class="text-left py-3 px-4"><%= t(".table.source") %></th>
<th scope="col" class="text-right py-3 px-4"><%= t(".table.actions") %></th>
</tr>
</thead>
<tbody>
@@ -85,4 +94,38 @@
</div>
<% end %>
</section>
<% if @unlinked_merchants.any? %>
<section class="space-y-3">
<div class="flex items-center gap-1.5 px-4 py-2 text-xs font-medium text-secondary uppercase">
<p><%= t(".unlinked_title") %></p>
<span class="text-subdued">&middot;</span>
<p><%= @unlinked_merchants.count %></p>
</div>
<div class="p-4 bg-container-inset border border-secondary rounded-lg">
<div class="flex items-start gap-2">
<%= icon "info", class: "w-5 h-5 text-subdued mt-0.5 flex-shrink-0" %>
<p class="text-xs text-secondary leading-relaxed"><%= t(".unlinked_info") %></p>
</div>
</div>
<div class="rounded-xl bg-container-inset space-y-1 p-1">
<div class="bg-container rounded-lg shadow-border-xs overflow-x-auto">
<table class="w-full">
<thead>
<tr class="text-xs uppercase font-medium text-secondary border-b border-divider">
<th scope="col" class="text-left py-3 px-4"><%= t(".table.merchant") %></th>
<th scope="col" class="text-left py-3 px-4"><%= t(".table.source") %></th>
<th scope="col" class="text-right py-3 px-4"><%= t(".table.actions") %></th>
</tr>
</thead>
<tbody>
<%= render partial: "family_merchants/provider_merchant", collection: @unlinked_merchants %>
</tbody>
</table>
</div>
</div>
</section>
<% end %>
</div>

View File

@@ -0,0 +1,33 @@
<%= render DS::Dialog.new do |dialog| %>
<% dialog.with_header(title: t(".title"), subtitle: t(".description")) %>
<% dialog.with_body do %>
<%= styled_form_with url: perform_merge_family_merchants_path, method: :post, class: "space-y-4" do |f| %>
<%= f.collection_select :target_id,
@merchants,
:id, :name,
{ prompt: t(".select_target"), label: t(".target_label") },
{ required: true } %>
<div class="space-y-2">
<p class="text-sm font-medium text-primary"><%= t(".sources_label") %></p>
<div class="max-h-64 overflow-y-auto space-y-1 border border-secondary rounded-lg p-2">
<% @merchants.each do |merchant| %>
<label class="flex items-center gap-2 p-2 hover:bg-surface-hover rounded cursor-pointer">
<%= check_box_tag "source_ids[]", merchant.id, false, class: "rounded border-gray-300" %>
<span class="text-sm text-primary"><%= merchant.name %></span>
<% if merchant.is_a?(ProviderMerchant) %>
<span class="text-xs text-subdued">(<%= merchant.source&.titleize %>)</span>
<% end %>
</label>
<% end %>
</div>
<p class="text-xs text-subdued"><%= t(".sources_hint") %></p>
</div>
<%= render DS::Button.new(
text: t(".submit"),
full_width: true
) %>
<% end %>
<% end %>
<% end %>

View File

@@ -3,8 +3,8 @@
<%= turbo_frame_tag dom_id(holding) do %>
<div class="grid grid-cols-12 items-center text-primary text-sm font-medium p-4">
<div class="col-span-4 flex items-center gap-4">
<% if Setting.brand_fetch_client_id.present? %>
<%= image_tag "https://cdn.brandfetch.io/#{holding.ticker}/icon/fallback/lettermark/w/40/h/40?c=#{Setting.brand_fetch_client_id}", class: "w-9 h-9 rounded-full", loading: "lazy" %>
<% if holding.security.brandfetch_icon_url.present? %>
<%= image_tag holding.security.brandfetch_icon_url, class: "w-9 h-9 rounded-full", loading: "lazy" %>
<% elsif holding.security.logo_url.present? %>
<%= image_tag holding.security.logo_url, class: "w-9 h-9 rounded-full", loading: "lazy" %>
<% else %>

View File

@@ -6,8 +6,8 @@
<%= tag.p @holding.ticker, class: "text-sm text-secondary" %>
</div>
<% if Setting.brand_fetch_client_id.present? %>
<%= image_tag "https://cdn.brandfetch.io/#{@holding.ticker}/icon/fallback/lettermark/w/40/h/40?c=#{Setting.brand_fetch_client_id}", loading: "lazy", class: "w-9 h-9 rounded-full" %>
<% if @holding.security.brandfetch_icon_url.present? %>
<%= image_tag @holding.security.brandfetch_icon_url, loading: "lazy", class: "w-9 h-9 rounded-full" %>
<% elsif @holding.security.logo_url.present? %>
<%= image_tag @holding.security.logo_url, loading: "lazy", class: "w-9 h-9 rounded-full" %>
<% else %>

View File

@@ -37,7 +37,7 @@
<div class="shadow-border-xs rounded-lg bg-container font-medium text-sm">
<% holdings.each_with_index do |holding, idx| %>
<div class="p-4 flex items-center <%= idx < holdings.size - 1 ? 'border-b border-primary' : '' %>">
<div class="p-4 flex items-center <%= idx < holdings.size - 1 ? "border-b border-primary" : "" %>">
<div class="flex-1 flex items-center gap-3">
<% if holding.security.logo_url.present? %>
<img src="<%= holding.security.logo_url %>" alt="<%= holding.ticker %>" class="w-8 h-8 rounded-full">

View File

@@ -13,7 +13,7 @@
<%= render DS::Disclosure.new(title: "Transactions", open: true) do %>
<div class="space-y-2">
<%= form.collection_select :category_id, Current.family.categories.alphabetically, :id, :name, { prompt: "Select a category", label: "Category", class: "text-subdued" } %>
<%= form.collection_select :merchant_id, Current.family.merchants.alphabetically, :id, :name, { prompt: "Select a merchant", label: "Merchant", class: "text-subdued" } %>
<%= form.collection_select :merchant_id, Current.family.available_merchants.alphabetically, :id, :name, { prompt: "Select a merchant", label: "Merchant", class: "text-subdued" } %>
<%= form.select :tag_ids, Current.family.tags.alphabetically.pluck(:name, :id), { include_blank: "None", multiple: true, label: "Tags", container_class: "h-40" } %>
<%= form.text_area :notes, label: "Notes", placeholder: "Enter a note that will be applied to selected transactions", rows: 5 %>
</div>

View File

@@ -69,7 +69,7 @@
<%= f.fields_for :entryable do |ef| %>
<%= ef.collection_select :merchant_id,
[@entry.transaction.merchant, *Current.family.merchants.alphabetically].compact,
Current.family.available_merchants.alphabetically,
:id, :name,
{ include_blank: t(".none"),
label: t(".merchant_label"),

View File

@@ -6,6 +6,7 @@ en:
success: New merchant created successfully
destroy:
success: Merchant deleted successfully
unlinked_success: Merchant removed from your transactions
edit:
title: Edit merchant
form:
@@ -13,12 +14,16 @@ en:
index:
empty: No merchants yet
new: New merchant
merge: Merge merchants
title: Merchants
family_title: Family merchants
family_empty: No family merchants yet
provider_title: Provider merchants
provider_empty: No provider merchants linked to this family yet
provider_read_only: Provider merchants are synced from your connected institutions. They cannot be edited here.
provider_info: These merchants were automatically detected by your bank connections or AI. You can edit them to create your own copy, or remove them to unlink from your transactions.
unlinked_title: Recently unlinked
unlinked_info: These merchants were recently removed from your transactions. They will disappear from this list after 30 days unless re-assigned to a transaction.
table:
merchant: Merchant
actions: Actions
@@ -30,7 +35,26 @@ en:
confirm_title: Delete merchant?
delete: Delete merchant
edit: Edit merchant
merge:
title: Merge merchants
description: Select a target merchant and the merchants to merge into it. All transactions from merged merchants will be reassigned to the target.
target_label: Merge into (target)
select_target: Select target merchant...
sources_label: Merchants to merge
sources_hint: Selected merchants will be merged into the target. Family merchants will be deleted, provider merchants will be unlinked.
submit: Merge selected
new:
title: New merchant
perform_merge:
success: Successfully merged %{count} merchants
no_merchants_selected: No merchants selected to merge
target_not_found: Target merchant not found
invalid_merchants: Invalid merchants selected
provider_merchant:
edit: Edit
remove: Remove
remove_confirm_title: Remove merchant?
remove_confirm_body: Are you sure you want to remove %{name}? This will unlink all associated transactions from this merchant but will not delete the merchant itself.
update:
success: Merchant updated successfully
converted_success: Merchant converted and updated successfully

View File

@@ -143,7 +143,12 @@ Rails.application.routes.draw do
resources :budget_categories, only: %i[index show update]
end
resources :family_merchants, only: %i[index new create edit update destroy]
resources :family_merchants, only: %i[index new create edit update destroy] do
collection do
get :merge
post :perform_merge
end
end
resources :transfers, only: %i[new create destroy show update]

View File

@@ -30,3 +30,9 @@ sync_hourly:
class: "SyncHourlyJob"
queue: "scheduled"
description: "Syncs provider items that opt-in to hourly syncing"
clean_data:
cron: "0 3 * * *" # daily at 3:00 AM
class: "DataCleanerJob"
queue: "scheduled"
description: "Cleans up old data (e.g., expired merchant associations)"

View File

@@ -0,0 +1,5 @@
class AddWebsiteUrlToSecurities < ActiveRecord::Migration[7.2]
def change
add_column :securities, :website_url, :string
end
end

View File

@@ -0,0 +1,13 @@
class CreateFamilyMerchantAssociations < ActiveRecord::Migration[7.2]
def change
create_table :family_merchant_associations, id: :uuid do |t|
t.references :family, null: false, foreign_key: true, type: :uuid
t.references :merchant, null: false, foreign_key: true, type: :uuid
t.datetime :unlinked_at
t.timestamps
end
add_index :family_merchant_associations, [ :family_id, :merchant_id ], unique: true
end
end

16
db/schema.rb generated
View File

@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.2].define(version: 2026_01_09_100000) do
ActiveRecord::Schema[7.2].define(version: 2026_01_09_144012) do
# These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto"
enable_extension "plpgsql"
@@ -461,6 +461,17 @@ ActiveRecord::Schema[7.2].define(version: 2026_01_09_100000) do
t.index ["family_id"], name: "index_family_exports_on_family_id"
end
create_table "family_merchant_associations", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.uuid "family_id", null: false
t.uuid "merchant_id", null: false
t.datetime "unlinked_at"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["family_id", "merchant_id"], name: "idx_on_family_id_merchant_id_23e883e08f", unique: true
t.index ["family_id"], name: "index_family_merchant_associations_on_family_id"
t.index ["merchant_id"], name: "index_family_merchant_associations_on_merchant_id"
end
create_table "holdings", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.uuid "account_id", null: false
t.uuid "security_id", null: false
@@ -951,6 +962,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_01_09_100000) do
t.datetime "failed_fetch_at"
t.integer "failed_fetch_count", default: 0, null: false
t.datetime "last_health_check_at"
t.string "website_url"
t.index "upper((ticker)::text), COALESCE(upper((exchange_operating_mic)::text), ''::text)", name: "index_securities_on_ticker_and_exchange_operating_mic_unique", unique: true
t.index ["country_code"], name: "index_securities_on_country_code"
t.index ["exchange_operating_mic"], name: "index_securities_on_exchange_operating_mic"
@@ -1224,6 +1236,8 @@ ActiveRecord::Schema[7.2].define(version: 2026_01_09_100000) do
add_foreign_key "eval_runs", "eval_datasets"
add_foreign_key "eval_samples", "eval_datasets"
add_foreign_key "family_exports", "families"
add_foreign_key "family_merchant_associations", "families"
add_foreign_key "family_merchant_associations", "merchants"
add_foreign_key "holdings", "account_providers"
add_foreign_key "holdings", "accounts", on_delete: :cascade
add_foreign_key "holdings", "securities"