Files
sure/app/controllers/family_merchants_controller.rb
T
4930aacdb8 feat: create merchant inline from transaction detail (#3106)
* feat: create merchant inline from transaction detail

Add a searchable "create or select" merchant combobox (DS::MerchantSelect,
mirroring the existing DS::TagSelect pattern) so a new FamilyMerchant can be
created directly from the transaction detail and new-transaction forms,
instead of requiring a trip to Settings > Merchants first.

FamilyMerchantsController#create now also responds to JSON so the combobox
can create-and-select a merchant without a full page reload.

* fix: address PR review feedback on merchant inline creation

- Handle Turbo validation failures in FamilyMerchantsController#create
  (missing format.turbo_stream branch raised ActionController::UnknownFormat)
- Guard connect() so disabled merchant selectors (no menu target) don't throw
- Select the exact match (or block form submission) on Enter when the
  create row is hidden
- Catch network/parse failures in createMerchant and show an error
- Localize the fallback "could not create merchant" error message
- Move the create button/error text outside the listbox and add
  aria-controls for accessibility
- Align create-option avatar size with the selected-value display

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix: keep created merchant option inside the listbox, move logo URL logic to component

- Add a dedicated listbox target and insert newly created merchant
  options inside it (previously landed outside role="listbox" after
  the create button was moved out in the prior review-fix commit)
- Move selected-merchant logo URL transformation out of the template
  into DS::MerchantSelect#selected_merchant_logo_url

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix: address maintainer review feedback on merchant inline creation

- Drop the explicit format.turbo_stream branch in the merchant creation
  failure path: respond_to's turbo_stream matching forces the response
  Content-Type to text/vnd.turbo-stream.html before the block runs, so
  render :new, formats: [:html] only changed template lookup, not the
  Content-Type. Turbo's client then received a turbo-stream response
  with no <turbo-stream> tags and silently did nothing. Leaving
  turbo_stream undeclared lets Rails negotiate down to format.html,
  which renders :new with the correct text/html type.
- Add truncate/shrink-0 classes to the merchant option partial's name
  and avatar spans so they match the selected-value display once
  updateSelectionDisplay clones them into the trigger button.
- Add a regression test simulating a Turbo form submission (Accept:
  turbo-stream, text/html) against a duplicate merchant name.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: Gerald <248542187+gfr-free@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 22:40:46 +02:00

191 lines
7.7 KiB
Ruby

class FamilyMerchantsController < ApplicationController
before_action :set_merchant, only: %i[edit update destroy]
def index
@breadcrumbs = [ [ t("breadcrumbs.home"), root_path ], [ t("breadcrumbs.merchants"), nil ] ]
# Show all merchants for this family
@all_family_merchants = Current.family.merchants.alphabetically
@all_provider_merchants = Current.family.assigned_merchants_for(Current.user).where(type: "ProviderMerchant").alphabetically
family_scope = @all_family_merchants
if params[:family_search].is_a?(String) && params[:family_search].strip.present?
family_scope = family_scope.where("LOWER(name) LIKE ?", "%#{ActiveRecord::Base.sanitize_sql_like(params[:family_search].strip.downcase)}%")
end
provider_scope = @all_provider_merchants
if params[:provider_search].is_a?(String) && params[:provider_search].strip.present?
provider_scope = provider_scope.where("LOWER(name) LIKE ?", "%#{ActiveRecord::Base.sanitize_sql_like(params[:provider_search].strip.downcase)}%")
end
# 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 = @all_provider_merchants.pluck(:id)
@unlinked_merchants = ProviderMerchant.where(id: recently_unlinked_ids - assigned_ids).alphabetically
@enhanceable_count = @all_provider_merchants.where(website_url: [ nil, "" ]).count
@llm_available = Provider::Registry.get_provider(:openai).present?
@pagy_family_merchants, @family_merchants = pagy(family_scope, page_param: :family_page, limit: safe_per_page)
@pagy_provider_merchants, @provider_merchants = pagy(provider_scope, page_param: :provider_page, limit: safe_per_page)
render layout: "settings"
end
def new
@family_merchant = FamilyMerchant.new(family: Current.family)
end
def create
@family_merchant = FamilyMerchant.new(merchant_params.merge(family: Current.family))
if @family_merchant.save
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) }
format.json { render json: merchant_json(@family_merchant), status: :created }
end
else
respond_to do |format|
# No explicit format.turbo_stream branch: Turbo's form submissions send an
# Accept header that prefers turbo-stream, but forcing that format here would
# lock the response's Content-Type to turbo-stream while still rendering the
# plain :new HTML template — Turbo's client then sees a turbo-stream
# Content-Type with no <turbo-stream> tags in the body and does nothing.
# Leaving turbo-stream undeclared lets Rails' content negotiation fall back to
# format.html below, which renders :new with the correct text/html type.
format.html { render :new, status: :unprocessable_entity }
format.json { render json: { errors: @family_merchant.errors.full_messages }, status: :unprocessable_entity }
end
end
end
def edit
end
def update
if @merchant.is_a?(ProviderMerchant)
if merchant_params[:name].present? && merchant_params[:name] != @merchant.name
# Name changed — 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
else
# Only website changed — update the ProviderMerchant directly
@merchant.update!(merchant_params.slice(:website_url))
@merchant.generate_logo_url_from_website!
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
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
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 enhance
cache_key = "enhance_provider_merchants:#{Current.family.id}"
already_running = !Rails.cache.write(cache_key, true, expires_in: 10.minutes, unless_exist: true)
if already_running
return redirect_to family_merchants_path, alert: t(".already_running")
end
EnhanceProviderMerchantsJob.perform_later(Current.family)
redirect_to family_merchants_path, notice: t(".success")
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
# 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
# Handle both family_merchant and provider_merchant param keys
key = params.key?(:family_merchant) ? :family_merchant : :provider_merchant
params.require(key).permit(:name, :color, :website_url)
end
def merchant_json(merchant)
merchant.as_json(only: %i[id name]).merge(
html: render_to_string(
partial: "DS/merchant_select/option",
formats: [ :html ],
locals: { merchant: merchant, selected: true, view_helpers: helpers }
)
)
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)
.order(Arel.sql("LOWER(COALESCE(name, ''))"))
end
end