we-promise/sure · error · Category::Merger::UnauthorizedCategoryError

A parent category cannot be merged into its own subcategory

Error message

A parent category cannot be merged into its own subcategory

What it means

Category::Merger#validate_hierarchy! blocks merges where the target category is a descendant of any source category. ancestor_ids_for(target) walks the parent_id chain upward; if any ancestor id matches a source id, merging would fold a parent into its own subcategory and create a cycle in the category tree.

Source

Thrown at app/models/category/merger.rb:49

        family.transactions.where(category_id: source.id).update_all(category_id: target_category.id)
        merge_budget_categories(source)
        family.categories.where(parent_id: source.id).where.not(id: target_category.id).update_all(parent_id: target_category.id)
        family.categories.find(source.id).destroy!
        @merged_count += 1
      end
    end

    def validate_category_belongs_to_family!(category, label)
      return if category&.family_id == family.id

      raise UnauthorizedCategoryError, "#{label} does not belong to this family"
    end

    def validate_hierarchy!
      target_ancestor_ids = ancestor_ids_for(target_category)
      return unless source_categories.any? { |source| target_ancestor_ids.include?(source.id) }

      raise UnauthorizedCategoryError, "A parent category cannot be merged into its own subcategory"
    end

    def validate_reparenting!
      return if target_category.parent_id.blank?
      return unless source_categories.any? { |source| family.categories.exists?(parent_id: source.id) }

      raise UnauthorizedCategoryError, "Cannot merge a category with subcategories into a subcategory"
    end

    def ancestor_ids_for(category)
      ids = []
      seen_ids = Set.new
      current = category

      while current&.parent_id.present? && seen_ids.exclude?(current.parent_id)
        ids << current.parent_id
        seen_ids << current.parent_id
        current = family.categories.find_by(id: current.parent_id)

View on GitHub (pinned to e69894adb9)

Solutions

  1. Pick a target outside every source's subtree (a sibling or an unrelated top-level category)
  2. Invert the merge direction: merge the subcategory into the parent instead of the parent into the subcategory
  3. Exclude descendants of any selected source from target candidates in the picker or a pre-merge validation layer
Defensive patterns

Strategy: validation

Validate before calling

source_ids = source_categories.map(&:id).to_set
# walk target's ancestors; reject any target inside a source subtree
ancestor = target_category
while (pid = ancestor.parent_id)
  break if source_ids.include?(pid)
  ancestor = family.categories.find_by(id: pid) or break
end
# if the loop matched, the merger will raise

Prevention

When it happens

Trigger: Selecting a subcategory as the merge target while one of its ancestors is among source_categories — for example target 'Food > Groceries' with source 'Food'. Also hit when the target picker lists all family categories without excluding descendants of the selected sources.

Common situations: Category cleanup UIs that allow any category as target; bulk merges built from name matching that pair parents with their children; imports that auto-merge similarly named categories.

Related errors


AI-assisted analysis of we-promise/sure@e69894adb9 (2026-08-21). Data as JSON: /api/errors/453c0964418f1be3. Report an issue: GitHub.