Skip to content
Open
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
d206669
feat(settings): add reviewed bulk cleanup flows
JSONbored May 1, 2026
e3ae39e
fix(settings): harden bulk cleanup review paths
JSONbored May 2, 2026
9c9e6c6
fix(settings): address bulk cleanup review gaps
JSONbored May 2, 2026
ff3e3ea
fix(settings): sanitize account institution domains
JSONbored May 2, 2026
9e98ed9
fix(settings): address bulk cleanup review
JSONbored May 3, 2026
4b4cac0
fix(settings): complete bulk cleanup review
JSONbored May 3, 2026
e0087ed
fix(settings): address bulk cleanup review
JSONbored May 3, 2026
ad7456f
fix(settings): handle category merge teardown errors
JSONbored May 3, 2026
6287683
Merge branch 'main' into codex/feat-settings-bulk-cleanup
JSONbored May 4, 2026
27a2783
fix(settings): polish bulk cleanup review feedback
JSONbored May 4, 2026
d0d5148
fix(settings): reject invalid merchant merge websites
JSONbored May 4, 2026
2feb858
Merge branch 'main' into codex/feat-settings-bulk-cleanup
JSONbored May 5, 2026
6db4812
Merge branch 'main' into codex/feat-settings-bulk-cleanup
jjmata May 5, 2026
f2d3c51
Merge remote-tracking branch 'upstream/main' into codex/feat-settings…
JSONbored May 6, 2026
4db76cc
fix(settings): make merchant merges atomic
JSONbored May 6, 2026
9c81230
Merge branch 'main' into codex/feat-settings-bulk-cleanup
JSONbored May 6, 2026
402dd20
fix(settings): harden cleanup review paths
JSONbored May 6, 2026
4f83204
Merge remote-tracking branch 'origin/codex/feat-settings-bulk-cleanup…
JSONbored May 6, 2026
140dc82
fix(settings): harden category merge totals
JSONbored May 6, 2026
716bb34
fix(settings): reuse merchant logo helpers
JSONbored May 6, 2026
49a606a
Merge branch 'main' into codex/feat-settings-bulk-cleanup
JSONbored May 7, 2026
4c0e431
test(settings): cover provider merchant merge isolation
JSONbored May 7, 2026
fdbb039
Merge branch 'main' into codex/feat-settings-bulk-cleanup
JSONbored May 8, 2026
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
32 changes: 32 additions & 0 deletions app/controllers/accounts_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,30 @@ 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])

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)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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 @@ -214,6 +238,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 @@ -336,4 +364,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
82 changes: 82 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: "settings"
end
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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
Comment on lines +92 to +102
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Controller perform_merge is harder to follow than necessary.

Initializing target, merger, and merge_succeeded as nil/false outside the transaction block, then checking them again after the block, is a pattern that relies on Ruby variable mutation across a raise ActiveRecord::Rollback — which is non-obvious and could surprise maintainers who don't know that Rollback silently absorbs the exception rather than propagating it.

A cleaner approach: move the redirect logic so that all branching happens inside the transaction for the happy path, and only rescue/redirect outside. For example:

def perform_merge
  permitted_params = category_merge_params
  return redirect_to merge_categories_path, alert: t(".conflicting_target") if conflicting_merge_target?(permitted_params)

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

  ActiveRecord::Base.transaction do
    target = merge_target_category(permitted_params) or raise ActiveRecord::Rollback
    merger = Category::Merger.new(family: Current.family, target_category: target, source_categories: sources)
    merger.merge! or raise ActiveRecord::Rollback
    # if we reach here, commit and redirect
    return redirect_to categories_path, notice: t(".success", count: merger.merged_count)
  end

  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 => e
  redirect_to merge_categories_path, alert: e.record.errors.full_messages.to_sentence
end

This makes the control flow explicit: return from inside the transaction on success, fall through to an alert redirect if the transaction was rolled back.


Generated by Claude Code

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
Comment thread
coderabbitai[bot] marked this conversation as resolved.

private
def set_category
@category = Current.family.categories.find(params[:id])
Expand All @@ -89,4 +127,48 @@ 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[:new_target_name].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],
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

COLORS.sample fallback picks a random color non-deterministically.

The form pre-populates the color picker with Category::COLORS.first, so the user always sees a chosen color. But if the field arrives empty server-side (e.g. a crafted request), the saved color would be random, producing a mismatch from what was shown.

A safer fallback is the same default the form uses:

color: permitted_params[:new_target_color].presence || Category::COLORS.first,

The same issue exists in merge_target_merchant for FamilyMerchant::COLORS.sample.


Generated by Claude Code

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
71 changes: 65 additions & 6 deletions app/controllers/family_merchants_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -100,22 +100,55 @@ def enhance

def merge
@merchants = all_family_merchants
@default_merchant_color = FamilyMerchant.default_color
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])

unless merchants.any? && website_url.present?
return redirect_to bulk_websites_family_merchants_path, alert: t(".invalid_selection")
end

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)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing error handling — potential 500.

bulk_update_websites has no rescue ActiveRecord::RecordInvalid block. If a merchant.update! call fails validation, the exception will bubble up and produce a 500. The two other bulk operations (bulk_update_domains, perform_merge) both rescue this; this one should too:

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

Generated by Claude Code


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

target = merge_target_merchant(valid_merchants, permitted_params)
unless target
return redirect_to merge_family_merchants_path, alert: t(".target_not_found")
end

merger = Merchant::Merger.new(
family: Current.family,
target_merchant: target,
Expand All @@ -129,6 +162,8 @@ def perform_merge
end
rescue Merchant::Merger::UnauthorizedMerchantError => e
redirect_to merge_family_merchants_path, alert: e.message
rescue ActiveRecord::RecordInvalid => e
redirect_to merge_family_merchants_path, alert: e.record.errors.full_messages.to_sentence
end

private
Expand All @@ -145,6 +180,18 @@ 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[:new_target_name].present?
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)
Expand All @@ -153,4 +200,16 @@ 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?
Current.family.merchants.create!(
name: permitted_params[:new_target_name],
color: permitted_params[:new_target_color].presence || FamilyMerchant.default_color,
website_url: Merchant.extract_domain(permitted_params[:new_target_website_url])
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
else
valid_merchants.find_by(id: permitted_params[:target_id])
end
end
end
49 changes: 49 additions & 0 deletions app/javascript/controllers/merchant_merge_target_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
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.hasExistingTargetTarget && this.existingTargetTarget.value !== ""
const newTargetStarted = this.newTargetStarted()

this.setSectionDisabled(this.existingSectionTarget, 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
}

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
})
}
}
14 changes: 12 additions & 2 deletions app/models/account.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ class Account < ApplicationRecord
include AASM, Syncable, Monetizable, Chartable, Linkable, Enrichable, Anchorable, Reconcileable, TaxTreatable

before_validation :assign_default_owner, if: -> { owner_id.blank? }
before_validation :normalize_institution_domain_value, if: -> { will_save_change_to_institution_domain? }

validates :name, :balance, :currency, presence: true
validate :owner_belongs_to_family, if: -> { owner_id.present? && family_id.present? }
Expand Down Expand Up @@ -138,6 +139,9 @@ def create_and_sync(attributes, skip_initial_sync: false, opening_balance_date:
account
end

def normalize_institution_domain(value)
Merchant.extract_domain(value)
end
Comment thread
coderabbitai[bot] marked this conversation as resolved.

def create_from_simplefin_account(simplefin_account, account_type, subtype = nil)
# Respect user choice when provided; otherwise infer a sensible default
Expand Down Expand Up @@ -299,10 +303,12 @@ def institution_domain
end

def logo_url
if institution_domain.present? && Setting.brand_fetch_client_id.present?
normalized_domain = self.class.normalize_institution_domain(institution_domain)

if normalized_domain.present? && Setting.brand_fetch_client_id.present?
logo_size = Setting.brand_fetch_logo_size

"https://cdn.brandfetch.io/#{institution_domain}/icon/fallback/lettermark/w/#{logo_size}/h/#{logo_size}?c=#{Setting.brand_fetch_client_id}"
"https://cdn.brandfetch.io/#{normalized_domain}/icon/fallback/lettermark/w/#{logo_size}/h/#{logo_size}?c=#{Setting.brand_fetch_client_id}"
elsif provider&.logo_url.present?
provider.logo_url
elsif logo.attached?
Expand Down Expand Up @@ -470,4 +476,8 @@ def owner_belongs_to_family
return if User.where(id: owner_id, family_id: family_id).exists?
errors.add(:owner, :invalid, message: "must belong to the same family as the account")
end

def normalize_institution_domain_value
self[:institution_domain] = self.class.normalize_institution_domain(read_attribute(:institution_domain))
end
end
Loading
Loading