diff --git a/app/controllers/family_merchants_controller.rb b/app/controllers/family_merchants_controller.rb index e06e623ea..9a72b8659 100644 --- a/app/controllers/family_merchants_controller.rb +++ b/app/controllers/family_merchants_controller.rb @@ -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 diff --git a/app/jobs/data_cleaner_job.rb b/app/jobs/data_cleaner_job.rb new file mode 100644 index 000000000..8cb22f283 --- /dev/null +++ b/app/jobs/data_cleaner_job.rb @@ -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 diff --git a/app/models/family.rb b/app/models/family.rb index 9d61c858d..073574974 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -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 diff --git a/app/models/family/auto_merchant_detector.rb b/app/models/family/auto_merchant_detector.rb index 667b15a68..c37a4f7b7 100644 --- a/app/models/family/auto_merchant_detector.rb +++ b/app/models/family/auto_merchant_detector.rb @@ -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 = {} diff --git a/app/models/family_merchant_association.rb b/app/models/family_merchant_association.rb new file mode 100644 index 000000000..69a6f1adb --- /dev/null +++ b/app/models/family_merchant_association.rb @@ -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 diff --git a/app/models/merchant/merger.rb b/app/models/merchant/merger.rb new file mode 100644 index 000000000..7baa78e67 --- /dev/null +++ b/app/models/merchant/merger.rb @@ -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 diff --git a/app/models/provider_merchant.rb b/app/models/provider_merchant.rb index 3a97a8e0d..5bc7cea4f 100644 --- a/app/models/provider_merchant.rb +++ b/app/models/provider_merchant.rb @@ -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 diff --git a/app/models/security.rb b/app/models/security.rb index ca5b38c4b..e91d04b90 100644 --- a/app/models/security.rb +++ b/app/models/security.rb @@ -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? diff --git a/app/models/security/provided.rb b/app/models/security/provided.rb index 58b7f50e6..1fbb1f272 100644 --- a/app/models/security/provided.rb +++ b/app/models/security/provided.rb @@ -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}") diff --git a/app/models/transaction.rb b/app/models/transaction.rb index 368fa6453..dd8eb3064 100644 --- a/app/models/transaction.rb +++ b/app/models/transaction.rb @@ -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 diff --git a/app/views/family_merchants/_form.html.erb b/app/views/family_merchants/_form.html.erb index f0680ab54..49363d7ea 100644 --- a/app/views/family_merchants/_form.html.erb +++ b/app/views/family_merchants/_form.html.erb @@ -1,7 +1,7 @@ <%# locals: (family_merchant:) %>
- <%= 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| %>
<% if family_merchant.errors.any? %> <%= render "shared/form_errors", model: family_merchant %> diff --git a/app/views/family_merchants/_provider_merchant.html.erb b/app/views/family_merchants/_provider_merchant.html.erb index 1d222d5ea..c2cb0e434 100644 --- a/app/views/family_merchants/_provider_merchant.html.erb +++ b/app/views/family_merchants/_provider_merchant.html.erb @@ -21,4 +21,21 @@ <%= provider_merchant.source&.titleize %> + + <%= 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 %> + diff --git a/app/views/family_merchants/index.html.erb b/app/views/family_merchants/index.html.erb index df047726f..0550739ef 100644 --- a/app/views/family_merchants/index.html.erb +++ b/app/views/family_merchants/index.html.erb @@ -1,12 +1,20 @@
-

Merchants

+

<%= t(".title") %>

- <%= render DS::Link.new( - text: "New merchant", - variant: "primary", - href: new_family_merchant_path, - frame: :modal - ) %> +
+ <%= 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 + ) %> +
@@ -59,7 +67,7 @@
<%= icon "info", class: "w-5 h-5 text-link mt-0.5 flex-shrink-0" %> -

<%= t(".provider_read_only") %>

+

<%= t(".provider_info") %>

@@ -71,6 +79,7 @@ <%= t(".table.merchant") %> <%= t(".table.source") %> + <%= t(".table.actions") %> @@ -85,4 +94,38 @@
<% end %>
+ + <% if @unlinked_merchants.any? %> +
+
+

<%= t(".unlinked_title") %>

+ · +

<%= @unlinked_merchants.count %>

+
+ +
+
+ <%= icon "info", class: "w-5 h-5 text-subdued mt-0.5 flex-shrink-0" %> +

<%= t(".unlinked_info") %>

+
+
+ +
+
+ + + + + + + + + + <%= render partial: "family_merchants/provider_merchant", collection: @unlinked_merchants %> + +
<%= t(".table.merchant") %><%= t(".table.source") %><%= t(".table.actions") %>
+
+
+
+ <% end %>
diff --git a/app/views/family_merchants/merge.html.erb b/app/views/family_merchants/merge.html.erb new file mode 100644 index 000000000..77d6ba84f --- /dev/null +++ b/app/views/family_merchants/merge.html.erb @@ -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 } %> + +
+

<%= t(".sources_label") %>

+
+ <% @merchants.each do |merchant| %> + + <% end %> +
+

<%= t(".sources_hint") %>

+
+ + <%= render DS::Button.new( + text: t(".submit"), + full_width: true + ) %> + <% end %> + <% end %> +<% end %> diff --git a/app/views/holdings/_holding.html.erb b/app/views/holdings/_holding.html.erb index 7daa151ff..4865af8ee 100644 --- a/app/views/holdings/_holding.html.erb +++ b/app/views/holdings/_holding.html.erb @@ -3,8 +3,8 @@ <%= turbo_frame_tag dom_id(holding) do %>
- <% 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 %> diff --git a/app/views/holdings/show.html.erb b/app/views/holdings/show.html.erb index 9e84a43f0..32918b49e 100644 --- a/app/views/holdings/show.html.erb +++ b/app/views/holdings/show.html.erb @@ -6,8 +6,8 @@ <%= tag.p @holding.ticker, class: "text-sm text-secondary" %>
- <% 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 %> diff --git a/app/views/pages/dashboard/_investment_summary.html.erb b/app/views/pages/dashboard/_investment_summary.html.erb index e0db4e926..e35a5e443 100644 --- a/app/views/pages/dashboard/_investment_summary.html.erb +++ b/app/views/pages/dashboard/_investment_summary.html.erb @@ -37,7 +37,7 @@
<% holdings.each_with_index do |holding, idx| %> -
+
">
<% if holding.security.logo_url.present? %> <%= holding.ticker %> diff --git a/app/views/transactions/bulk_updates/new.html.erb b/app/views/transactions/bulk_updates/new.html.erb index 84394a8cc..e217e4dba 100644 --- a/app/views/transactions/bulk_updates/new.html.erb +++ b/app/views/transactions/bulk_updates/new.html.erb @@ -13,7 +13,7 @@ <%= render DS::Disclosure.new(title: "Transactions", open: true) do %>
<%= 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 %>
diff --git a/app/views/transactions/show.html.erb b/app/views/transactions/show.html.erb index d923278bb..1d040c953 100644 --- a/app/views/transactions/show.html.erb +++ b/app/views/transactions/show.html.erb @@ -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"), diff --git a/config/locales/views/merchants/en.yml b/config/locales/views/merchants/en.yml index 4f48c9c21..4f2bec525 100644 --- a/config/locales/views/merchants/en.yml +++ b/config/locales/views/merchants/en.yml @@ -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 diff --git a/config/routes.rb b/config/routes.rb index 4f4f45d6a..b7b89b1ae 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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] diff --git a/config/schedule.yml b/config/schedule.yml index 67383e4be..b12d75ee7 100644 --- a/config/schedule.yml +++ b/config/schedule.yml @@ -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)" diff --git a/db/migrate/20260109135841_add_website_url_to_securities.rb b/db/migrate/20260109135841_add_website_url_to_securities.rb new file mode 100644 index 000000000..e2ed37b10 --- /dev/null +++ b/db/migrate/20260109135841_add_website_url_to_securities.rb @@ -0,0 +1,5 @@ +class AddWebsiteUrlToSecurities < ActiveRecord::Migration[7.2] + def change + add_column :securities, :website_url, :string + end +end diff --git a/db/migrate/20260109144012_create_family_merchant_associations.rb b/db/migrate/20260109144012_create_family_merchant_associations.rb new file mode 100644 index 000000000..8cbc32ced --- /dev/null +++ b/db/migrate/20260109144012_create_family_merchant_associations.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index 48fbe8ef7..1672e0fc1 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -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"