Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions app/controllers/accounts_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,34 @@ def new
end
end

def bulk_domains
@accounts = bulk_domain_accounts
render layout: false
end

def bulk_update_domains
permitted_params = bulk_domain_params
domain = Account.normalize_institution_domain(permitted_params[:institution_domain])
accounts = bulk_domain_accounts.where(id: permitted_params[:account_ids])

if permitted_params[:institution_domain].present? && domain.blank?
return redirect_to bulk_domains_accounts_path, alert: t(".invalid_domain")
end

unless accounts.any? && domain.present?
return redirect_to bulk_domains_accounts_path, alert: t(".invalid_selection")
end

Account.transaction do
accounts.each { |account| account.update!(institution_domain: domain) }
end

redirect_to accounts_path, notice: t(".success", count: accounts.count)
rescue ActiveRecord::RecordInvalid => e
error_message = e.record.errors.full_messages.to_sentence.presence || e.message
redirect_to bulk_domains_accounts_path, alert: t(".failure", error: error_message)
end

def sync_all
family.sync_later
redirect_to accounts_path, notice: t("accounts.sync_all.syncing")
Expand Down Expand Up @@ -215,6 +243,10 @@ def set_account
@account = Current.user.accessible_accounts.find(params[:id])
end

def bulk_domain_params
params.permit(:institution_domain, account_ids: [])
end

def set_manageable_account
@account = Current.user.accessible_accounts.find(params[:id])
permission = @account.permission_for(Current.user)
Expand Down Expand Up @@ -337,4 +369,8 @@ def build_sync_stats_maps
@indexa_capital_sync_stats_map[item.id] = latest_sync&.sync_stats || {}
end
end

def bulk_domain_accounts
Current.family.accounts.writable_by(Current.user).order(:name)
end
end
83 changes: 83 additions & 0 deletions app/controllers/categories_controller.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
class CategoriesController < ApplicationController
MergeTargetNotFound = Class.new(StandardError)
EmptyCategoryMerge = Class.new(StandardError)

before_action :set_category, only: %i[edit update destroy]
before_action :set_categories, only: %i[update edit]
before_action :set_transaction, only: :create
Expand All @@ -14,6 +17,12 @@ def new
set_categories
end

def merge
@categories = Current.family.categories.alphabetically

render layout: turbo_frame_request? ? false : "settings"
end

def create
@category = Current.family.categories.new(category_params)

Expand Down Expand Up @@ -67,6 +76,35 @@ def bootstrap
redirect_back_or_to categories_path, notice: t(".success")
end

def perform_merge
permitted_params = category_merge_params

if conflicting_merge_target?(permitted_params)
return redirect_to merge_categories_path, alert: t(".conflicting_target")
end

if target_selected_as_source?(permitted_params)
return redirect_to merge_categories_path, alert: t(".target_selected_as_source")
end

sources = Current.family.categories.where(id: permitted_params[:source_ids])
unless sources.any?
return redirect_to merge_categories_path, alert: t(".invalid_categories")
end

merger = merge_categories!(permitted_params, sources)

redirect_to categories_path, notice: t(".success", count: merger.merged_count)
rescue MergeTargetNotFound
redirect_to merge_categories_path, alert: t(".target_not_found")
rescue EmptyCategoryMerge
redirect_to merge_categories_path, alert: t(".no_categories_selected")
rescue Category::Merger::UnauthorizedCategoryError => e
redirect_to merge_categories_path, alert: e.message
rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotDestroyed => e
redirect_to merge_categories_path, alert: record_error_message(e)
end

private
def set_category
@category = Current.family.categories.find(params[:id])
Expand All @@ -89,4 +127,49 @@ def set_transaction
def category_params
params.require(:category).permit(:name, :color, :parent_id, :lucide_icon)
end

def category_merge_params
params.permit(:target_id, :new_target_name, :new_target_color, :new_target_icon, source_ids: [])
end

def conflicting_merge_target?(permitted_params)
permitted_params[:target_id].present? &&
permitted_params.to_h.any? { |key, value| key.to_s.start_with?("new_target_") && value.present? }
end

def target_selected_as_source?(permitted_params)
permitted_params[:target_id].present? && Array(permitted_params[:source_ids]).include?(permitted_params[:target_id])
end

def merge_target_category(permitted_params)
if permitted_params[:new_target_name].present?
Current.family.categories.create!(
name: permitted_params[:new_target_name],
color: permitted_params[:new_target_color].presence || Category::COLORS.first,
lucide_icon: permitted_params[:new_target_icon].presence || Category.suggested_icon(permitted_params[:new_target_name])
)
else
Current.family.categories.find_by(id: permitted_params[:target_id])
end
end

def merge_categories!(permitted_params, sources)
Category.transaction do
target = merge_target_category(permitted_params) || raise(MergeTargetNotFound)
merger = Category::Merger.new(
family: Current.family,
target_category: target,
source_categories: sources
)

raise EmptyCategoryMerge unless merger.merge!

merger
end
end

def record_error_message(error)
record = error.respond_to?(:record) ? error.record : nil
record&.errors&.full_messages&.to_sentence.presence || error.message
end
end
123 changes: 107 additions & 16 deletions app/controllers/family_merchants_controller.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
class FamilyMerchantsController < ApplicationController
InvalidMerchantWebsite = Class.new(StandardError)
MergeTargetNotFound = Class.new(StandardError)
EmptyMerchantMerge = Class.new(StandardError)

before_action :set_merchant, only: %i[edit update destroy]

def index
Expand Down Expand Up @@ -100,35 +104,69 @@ def enhance

def merge
@merchants = all_family_merchants
@default_merchant_color = FamilyMerchant.default_color

render layout: turbo_frame_request? ? false : "settings"
end

def bulk_websites
@merchants = all_family_merchants
end

def bulk_update_websites
permitted_params = bulk_website_params
merchants = all_family_merchants.where(id: permitted_params[:merchant_ids])
website_url = Merchant.extract_domain(permitted_params[:website_url])

if permitted_params[:website_url].present? && website_url.blank?
return redirect_to bulk_websites_family_merchants_path, alert: t(".invalid_domain")
end

unless merchants.any? && website_url.present?
return redirect_to bulk_websites_family_merchants_path, alert: t(".invalid_selection")
end
Comment thread
coderabbitai[bot] marked this conversation as resolved.

Merchant.transaction do
merchants.each do |merchant|
merchant.update!(website_url: website_url)
merchant.generate_logo_url_from_website! if merchant.is_a?(ProviderMerchant)
end
end

redirect_to family_merchants_path, notice: t(".success", count: merchants.count)
rescue ActiveRecord::RecordInvalid => e
error_message = e.record.errors.full_messages.to_sentence.presence || e.message
redirect_to bulk_websites_family_merchants_path, alert: t(".failure", error: error_message)
end

def perform_merge
# Scope lookups to merchants valid for this family (FamilyMerchants + assigned ProviderMerchants)
valid_merchants = all_family_merchants
permitted_params = merchant_merge_params

target = valid_merchants.find_by(id: params[:target_id])
unless target
return redirect_to merge_family_merchants_path, alert: t(".target_not_found")
if conflicting_merge_target?(permitted_params)
return redirect_to merge_family_merchants_path, alert: t(".conflicting_target")
end

sources = valid_merchants.where(id: params[:source_ids])
# Scope lookups to merchants valid for this family (FamilyMerchants + assigned ProviderMerchants)
valid_merchants = all_family_merchants

sources = valid_merchants.where(id: permitted_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
)
merger = merge_merchants!(valid_merchants, permitted_params, 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
redirect_to family_merchants_path, notice: t(".success", count: merger.merged_count)
rescue MergeTargetNotFound
redirect_to merge_family_merchants_path, alert: t(".target_not_found")
rescue EmptyMerchantMerge
redirect_to merge_family_merchants_path, alert: t(".no_merchants_selected")
rescue Merchant::Merger::UnauthorizedMerchantError => e
redirect_to merge_family_merchants_path, alert: e.message
rescue InvalidMerchantWebsite
redirect_to merge_family_merchants_path, alert: t(".invalid_website")
rescue ActiveRecord::RecordInvalid => e
redirect_to merge_family_merchants_path, alert: record_error_message(e)
end

private
Expand All @@ -145,6 +183,19 @@ def merchant_params
params.require(key).permit(:name, :color, :website_url)
end

def bulk_website_params
params.permit(:website_url, merchant_ids: [])
end

def merchant_merge_params
params.permit(:target_id, :new_target_name, :new_target_color, :new_target_website_url, source_ids: [])
end

def conflicting_merge_target?(permitted_params)
permitted_params[:target_id].present? &&
permitted_params.to_h.any? { |key, value| key.to_s.start_with?("new_target_") && value.present? }
end
Comment thread
coderabbitai[bot] marked this conversation as resolved.

def all_family_merchants
family_merchant_ids = Current.family.merchants.pluck(:id)
provider_merchant_ids = Current.family.assigned_merchants.where(type: "ProviderMerchant").pluck(:id)
Expand All @@ -153,4 +204,44 @@ def all_family_merchants
Merchant.where(id: combined_ids)
.order(Arel.sql("LOWER(COALESCE(name, ''))"))
end

def merge_target_merchant(valid_merchants, permitted_params)
if permitted_params[:new_target_name].present?
website_url = normalized_new_target_website_url(permitted_params)

Current.family.merchants.create!(
name: permitted_params[:new_target_name],
color: permitted_params[:new_target_color].presence || FamilyMerchant.default_color,
website_url: website_url
)
else
valid_merchants.find_by(id: permitted_params[:target_id])
end
end

def normalized_new_target_website_url(permitted_params)
return if permitted_params[:new_target_website_url].blank?

Merchant.extract_domain(permitted_params[:new_target_website_url]).presence || raise(InvalidMerchantWebsite)
end

def merge_merchants!(valid_merchants, permitted_params, sources)
Merchant.transaction do
target = merge_target_merchant(valid_merchants, permitted_params) || raise(MergeTargetNotFound)
merger = Merchant::Merger.new(
family: Current.family,
target_merchant: target,
source_merchants: sources
)

raise EmptyMerchantMerge unless merger.merge!

merger
end
end

def record_error_message(error)
record = error.respond_to?(:record) ? error.record : nil
record&.errors&.full_messages&.to_sentence.presence || error.message
end
end
73 changes: 73 additions & 0 deletions app/javascript/controllers/merchant_merge_target_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { Controller } from "@hotwired/stimulus"

const DISABLED_CLASSES = ["opacity-50", "pointer-events-none"]

export default class extends Controller {
static targets = [
"existingSection",
"existingTarget",
"newSection",
"newTextInput",
"newColorInput"
]
static values = { defaultColor: String }

connect() {
this.sync()
}

sync() {
const existingTargetSelected = this.existingTargetSelected()
const newTargetStarted = this.newTargetStarted()

if (existingTargetSelected) {
this.clearNewTargetFields()
} else if (newTargetStarted) {
this.clearExistingTarget()
}

this.setSectionDisabled(this.existingSectionTarget, !existingTargetSelected && newTargetStarted)
this.setSectionDisabled(this.newSectionTarget, existingTargetSelected)
}

beforeSubmit() {
this.sync()
}

newTargetStarted() {
const textEntered = this.newTextInputTargets.some((input) => input.value.trim() !== "")
const colorChanged = this.hasNewColorInputTarget &&
this.defaultColorValue &&
this.newColorInputTarget.value.toLowerCase() !== this.defaultColorValue.toLowerCase()

return textEntered || colorChanged
}

existingTargetSelected() {
return this.hasExistingTargetTarget && this.existingTargetTarget.value !== ""
}

clearExistingTarget() {
if (this.hasExistingTargetTarget) this.existingTargetTarget.value = ""
}

clearNewTargetFields() {
this.newTextInputTargets.forEach((input) => {
input.value = ""
})

if (this.hasNewColorInputTarget && this.defaultColorValue) {
this.newColorInputTarget.value = this.defaultColorValue
}
}

setSectionDisabled(section, disabled) {
section.setAttribute("aria-disabled", disabled.toString())
section.classList.toggle("cursor-not-allowed", disabled)
DISABLED_CLASSES.forEach((className) => section.classList.toggle(className, disabled))

section.querySelectorAll("input, button, select, textarea").forEach((input) => {
input.disabled = disabled
})
}
}
Loading
Loading