mirror of
https://github.com/we-promise/sure.git
synced 2026-04-19 20:14:08 +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
|
@family_merchants = Current.family.merchants.alphabetically
|
||||||
@provider_merchants = Current.family.assigned_merchants.where(type: "ProviderMerchant").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"
|
render layout: "settings"
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -32,24 +41,91 @@ class FamilyMerchantsController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def update
|
def update
|
||||||
@family_merchant.update!(merchant_params)
|
if @merchant.is_a?(ProviderMerchant)
|
||||||
respond_to do |format|
|
# Convert ProviderMerchant to FamilyMerchant for this family only
|
||||||
format.html { redirect_to family_merchants_path, notice: t(".success") }
|
@family_merchant = @merchant.convert_to_family_merchant_for(Current.family, merchant_params)
|
||||||
format.turbo_stream { render turbo_stream: turbo_stream.action(:redirect, family_merchants_path) }
|
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
|
end
|
||||||
|
rescue ActiveRecord::RecordInvalid => e
|
||||||
|
@family_merchant = e.record
|
||||||
|
render :edit, status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
@family_merchant.destroy!
|
if @merchant.is_a?(ProviderMerchant)
|
||||||
redirect_to family_merchants_path, notice: t(".success")
|
# 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
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
def set_merchant
|
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
|
end
|
||||||
|
|
||||||
def merchant_params
|
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
|
||||||
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)
|
Merchant.where(id: merchant_ids)
|
||||||
end
|
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)
|
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)
|
AutoCategorizeJob.perform_later(self, transaction_ids: transactions.pluck(:id), rule_run_id: rule_run_id)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -102,21 +102,33 @@ class Family::AutoMerchantDetector
|
|||||||
end
|
end
|
||||||
|
|
||||||
def find_or_create_ai_merchant(auto_detection)
|
def find_or_create_ai_merchant(auto_detection)
|
||||||
# Only use (source, name) for find_or_create since that's the uniqueness constraint
|
# Strategy 1: Find existing merchant by website_url (most reliable for deduplication)
|
||||||
ProviderMerchant.find_or_create_by!(
|
if auto_detection.business_url.present?
|
||||||
source: "ai",
|
existing = ProviderMerchant.find_by(website_url: auto_detection.business_url)
|
||||||
name: auto_detection.business_name
|
return existing if existing
|
||||||
) 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
|
|
||||||
end
|
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
|
rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotUnique
|
||||||
# Race condition: another process created the merchant between our find and create
|
# Race condition: another process created the merchant between our find and create
|
||||||
ProviderMerchant.find_by(source: "ai", name: auto_detection.business_name)
|
ProviderMerchant.find_by(source: "ai", name: auto_detection.business_name)
|
||||||
end
|
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)
|
def enhance_provider_merchant(merchant, auto_detection)
|
||||||
updates = {}
|
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 :name, uniqueness: { scope: [ :source ] }
|
||||||
validates :source, presence: true
|
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
|
end
|
||||||
|
|||||||
@@ -27,7 +27,25 @@ class Security < ApplicationRecord
|
|||||||
)
|
)
|
||||||
end
|
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
|
private
|
||||||
|
|
||||||
|
def extract_domain(url)
|
||||||
|
uri = URI.parse(url)
|
||||||
|
host = uri.host || url
|
||||||
|
host.sub(/\Awww\./, "")
|
||||||
|
rescue URI::InvalidURIError
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
def upcase_symbols
|
def upcase_symbols
|
||||||
self.ticker = ticker.upcase
|
self.ticker = ticker.upcase
|
||||||
self.exchange_operating_mic = exchange_operating_mic.upcase if exchange_operating_mic.present?
|
self.exchange_operating_mic = exchange_operating_mic.upcase if exchange_operating_mic.present?
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ module Security::Provided
|
|||||||
return
|
return
|
||||||
end
|
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
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -81,6 +81,7 @@ module Security::Provided
|
|||||||
update(
|
update(
|
||||||
name: response.data.name,
|
name: response.data.name,
|
||||||
logo_url: response.data.logo_url,
|
logo_url: response.data.logo_url,
|
||||||
|
website_url: response.data.links
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
Rails.logger.warn("Failed to fetch security info for #{ticker} from #{provider.class.name}: #{response.error.message}")
|
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
|
accepts_nested_attributes_for :taggings, allow_destroy: true
|
||||||
|
|
||||||
|
after_save :clear_merchant_unlinked_association, if: :merchant_id_previously_changed?
|
||||||
|
|
||||||
enum :kind, {
|
enum :kind, {
|
||||||
standard: "standard", # A regular transaction, included in budget analytics
|
standard: "standard", # A regular transaction, included in budget analytics
|
||||||
funds_movement: "funds_movement", # Movement of funds between accounts, excluded from budget analytics
|
funds_movement: "funds_movement", # Movement of funds between accounts, excluded from budget analytics
|
||||||
@@ -39,4 +41,14 @@ class Transaction < ApplicationRecord
|
|||||||
rescue
|
rescue
|
||||||
false
|
false
|
||||||
end
|
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
|
end
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<%# locals: (family_merchant:) %>
|
<%# locals: (family_merchant:) %>
|
||||||
|
|
||||||
<div data-controller="color-avatar">
|
<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">
|
<section class="space-y-4">
|
||||||
<% if family_merchant.errors.any? %>
|
<% if family_merchant.errors.any? %>
|
||||||
<%= render "shared/form_errors", model: family_merchant %>
|
<%= render "shared/form_errors", model: family_merchant %>
|
||||||
|
|||||||
@@ -21,4 +21,21 @@
|
|||||||
<td class="py-3 px-4 text-sm text-secondary align-middle">
|
<td class="py-3 px-4 text-sm text-secondary align-middle">
|
||||||
<%= provider_merchant.source&.titleize %>
|
<%= provider_merchant.source&.titleize %>
|
||||||
</td>
|
</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>
|
</tr>
|
||||||
|
|||||||
@@ -1,12 +1,20 @@
|
|||||||
<header class="flex items-center justify-between">
|
<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(
|
<div class="flex items-center gap-2">
|
||||||
text: "New merchant",
|
<%= render DS::Link.new(
|
||||||
variant: "primary",
|
text: t(".merge"),
|
||||||
href: new_family_merchant_path,
|
variant: "outline",
|
||||||
frame: :modal
|
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>
|
</header>
|
||||||
|
|
||||||
<div class="bg-container rounded-xl shadow-border-xs p-4 space-y-6">
|
<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="p-4 bg-container-inset border border-secondary rounded-lg">
|
||||||
<div class="flex items-start gap-2">
|
<div class="flex items-start gap-2">
|
||||||
<%= icon "info", class: "w-5 h-5 text-link mt-0.5 flex-shrink-0" %>
|
<%= 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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -71,6 +79,7 @@
|
|||||||
<tr class="text-xs uppercase font-medium text-secondary border-b border-divider">
|
<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.merchant") %></th>
|
||||||
<th scope="col" class="text-left py-3 px-4"><%= t(".table.source") %></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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -85,4 +94,38 @@
|
|||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
</section>
|
</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>
|
</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 %>
|
<%= 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="grid grid-cols-12 items-center text-primary text-sm font-medium p-4">
|
||||||
<div class="col-span-4 flex items-center gap-4">
|
<div class="col-span-4 flex items-center gap-4">
|
||||||
<% if Setting.brand_fetch_client_id.present? %>
|
<% if holding.security.brandfetch_icon_url.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" %>
|
<%= image_tag holding.security.brandfetch_icon_url, class: "w-9 h-9 rounded-full", loading: "lazy" %>
|
||||||
<% elsif holding.security.logo_url.present? %>
|
<% elsif holding.security.logo_url.present? %>
|
||||||
<%= image_tag holding.security.logo_url, class: "w-9 h-9 rounded-full", loading: "lazy" %>
|
<%= image_tag holding.security.logo_url, class: "w-9 h-9 rounded-full", loading: "lazy" %>
|
||||||
<% else %>
|
<% else %>
|
||||||
|
|||||||
@@ -6,8 +6,8 @@
|
|||||||
<%= tag.p @holding.ticker, class: "text-sm text-secondary" %>
|
<%= tag.p @holding.ticker, class: "text-sm text-secondary" %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<% if Setting.brand_fetch_client_id.present? %>
|
<% if @holding.security.brandfetch_icon_url.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" %>
|
<%= image_tag @holding.security.brandfetch_icon_url, loading: "lazy", class: "w-9 h-9 rounded-full" %>
|
||||||
<% elsif @holding.security.logo_url.present? %>
|
<% elsif @holding.security.logo_url.present? %>
|
||||||
<%= image_tag @holding.security.logo_url, loading: "lazy", class: "w-9 h-9 rounded-full" %>
|
<%= image_tag @holding.security.logo_url, loading: "lazy", class: "w-9 h-9 rounded-full" %>
|
||||||
<% else %>
|
<% else %>
|
||||||
|
|||||||
@@ -37,7 +37,7 @@
|
|||||||
|
|
||||||
<div class="shadow-border-xs rounded-lg bg-container font-medium text-sm">
|
<div class="shadow-border-xs rounded-lg bg-container font-medium text-sm">
|
||||||
<% holdings.each_with_index do |holding, idx| %>
|
<% 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">
|
<div class="flex-1 flex items-center gap-3">
|
||||||
<% if holding.security.logo_url.present? %>
|
<% if holding.security.logo_url.present? %>
|
||||||
<img src="<%= holding.security.logo_url %>" alt="<%= holding.ticker %>" class="w-8 h-8 rounded-full">
|
<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 %>
|
<%= render DS::Disclosure.new(title: "Transactions", open: true) do %>
|
||||||
<div class="space-y-2">
|
<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 :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.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 %>
|
<%= form.text_area :notes, label: "Notes", placeholder: "Enter a note that will be applied to selected transactions", rows: 5 %>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -69,7 +69,7 @@
|
|||||||
<%= f.fields_for :entryable do |ef| %>
|
<%= f.fields_for :entryable do |ef| %>
|
||||||
|
|
||||||
<%= ef.collection_select :merchant_id,
|
<%= ef.collection_select :merchant_id,
|
||||||
[@entry.transaction.merchant, *Current.family.merchants.alphabetically].compact,
|
Current.family.available_merchants.alphabetically,
|
||||||
:id, :name,
|
:id, :name,
|
||||||
{ include_blank: t(".none"),
|
{ include_blank: t(".none"),
|
||||||
label: t(".merchant_label"),
|
label: t(".merchant_label"),
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ en:
|
|||||||
success: New merchant created successfully
|
success: New merchant created successfully
|
||||||
destroy:
|
destroy:
|
||||||
success: Merchant deleted successfully
|
success: Merchant deleted successfully
|
||||||
|
unlinked_success: Merchant removed from your transactions
|
||||||
edit:
|
edit:
|
||||||
title: Edit merchant
|
title: Edit merchant
|
||||||
form:
|
form:
|
||||||
@@ -13,12 +14,16 @@ en:
|
|||||||
index:
|
index:
|
||||||
empty: No merchants yet
|
empty: No merchants yet
|
||||||
new: New merchant
|
new: New merchant
|
||||||
|
merge: Merge merchants
|
||||||
title: Merchants
|
title: Merchants
|
||||||
family_title: Family merchants
|
family_title: Family merchants
|
||||||
family_empty: No family merchants yet
|
family_empty: No family merchants yet
|
||||||
provider_title: Provider merchants
|
provider_title: Provider merchants
|
||||||
provider_empty: No provider merchants linked to this family yet
|
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_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:
|
table:
|
||||||
merchant: Merchant
|
merchant: Merchant
|
||||||
actions: Actions
|
actions: Actions
|
||||||
@@ -30,7 +35,26 @@ en:
|
|||||||
confirm_title: Delete merchant?
|
confirm_title: Delete merchant?
|
||||||
delete: Delete merchant
|
delete: Delete merchant
|
||||||
edit: Edit 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:
|
new:
|
||||||
title: New merchant
|
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:
|
update:
|
||||||
success: Merchant updated successfully
|
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]
|
resources :budget_categories, only: %i[index show update]
|
||||||
end
|
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]
|
resources :transfers, only: %i[new create destroy show update]
|
||||||
|
|
||||||
|
|||||||
@@ -30,3 +30,9 @@ sync_hourly:
|
|||||||
class: "SyncHourlyJob"
|
class: "SyncHourlyJob"
|
||||||
queue: "scheduled"
|
queue: "scheduled"
|
||||||
description: "Syncs provider items that opt-in to hourly syncing"
|
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.
|
# 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
|
# These are extensions that must be enabled in order to support this database
|
||||||
enable_extension "pgcrypto"
|
enable_extension "pgcrypto"
|
||||||
enable_extension "plpgsql"
|
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"
|
t.index ["family_id"], name: "index_family_exports_on_family_id"
|
||||||
end
|
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|
|
create_table "holdings", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||||
t.uuid "account_id", null: false
|
t.uuid "account_id", null: false
|
||||||
t.uuid "security_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.datetime "failed_fetch_at"
|
||||||
t.integer "failed_fetch_count", default: 0, null: false
|
t.integer "failed_fetch_count", default: 0, null: false
|
||||||
t.datetime "last_health_check_at"
|
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 "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 ["country_code"], name: "index_securities_on_country_code"
|
||||||
t.index ["exchange_operating_mic"], name: "index_securities_on_exchange_operating_mic"
|
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_runs", "eval_datasets"
|
||||||
add_foreign_key "eval_samples", "eval_datasets"
|
add_foreign_key "eval_samples", "eval_datasets"
|
||||||
add_foreign_key "family_exports", "families"
|
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", "account_providers"
|
||||||
add_foreign_key "holdings", "accounts", on_delete: :cascade
|
add_foreign_key "holdings", "accounts", on_delete: :cascade
|
||||||
add_foreign_key "holdings", "securities"
|
add_foreign_key "holdings", "securities"
|
||||||
|
|||||||
Reference in New Issue
Block a user