mirror of
https://github.com/we-promise/sure.git
synced 2026-04-07 14:31:25 +00:00
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:
@@ -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
|
||||
|
||||
17
app/jobs/data_cleaner_job.rb
Normal file
17
app/jobs/data_cleaner_job.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
|
||||
6
app/models/family_merchant_association.rb
Normal file
6
app/models/family_merchant_association.rb
Normal 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
|
||||
54
app/models/merchant/merger.rb
Normal file
54
app/models/merchant/merger.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">·</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>
|
||||
|
||||
33
app/views/family_merchants/merge.html.erb
Normal file
33
app/views/family_merchants/merge.html.erb
Normal 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 %>
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
class AddWebsiteUrlToSecurities < ActiveRecord::Migration[7.2]
|
||||
def change
|
||||
add_column :securities, :website_url, :string
|
||||
end
|
||||
end
|
||||
@@ -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
16
db/schema.rb
generated
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user