Category Management#
Overview#
Categories in Sure are scoped to a Family and follow a two-level parent/child hierarchy: a top-level category can have subcategories, but subcategories cannot have children. Each category carries a name, color (hex), and a lucide_icon.
Data Model#
The categories table has these key columns:
| Column | Type | Notes |
|---|---|---|
id | UUID | PK |
family_id | UUID | Scopes all queries to the family |
name | string | Unique within a family |
color | string | Hex; defaults to #6172F3 |
lucide_icon | string | Defaults to "shapes" |
parent_id | UUID | Optional; presence makes this a subcategory |
last_used_at | datetime | Tracks recent manual category assignments; powers category picker shortcuts |
classification_unused | string | Legacy column; no longer meaningful |
Hierarchy and Validations#
The Category model defines:
has_many :subcategories(FK:parent_id) andbelongs_to :parent— self-referential associations- Depth limit: the
category_level_limitvalidation fires if a subcategory tries to gain a parent (i.e., a third level) — "can't have more than 2 levels of subcategories" - Name uniqueness scoped to
family_id - Color format: must match
/\A#[0-9A-Fa-f]{6}\z/ - Required fields:
name,color,lucide_icon,family
Color Inheritance#
A before_save callback inherit_color_from_parent overwrites the subcategory's color with its parent's color at save time. This means any color value passed to a subcategory is silently ignored — the parent's color always wins. The AI assistant's function description explicitly notes this: "color is ignored for subcategories since color is inherited" .
Default Categories (Bootstrapping)#
Category.bootstrap! calls find_or_create_by! for each of the 22 predefined defaults defined in default_categories:
| Category | Color | Icon |
|---|---|---|
| Income | #22c55e | circle-dollar-sign |
| Food & Drink | #f97316 | utensils |
| Groceries | #407706 | shopping-bag |
| Shopping | #3b82f6 | shopping-cart |
| Transportation | #0ea5e9 | bus |
| Travel | #2563eb | plane |
| Entertainment | #a855f7 | drama |
| Healthcare | #4da568 | pill |
| Personal Care | #14b8a6 | scissors |
| Home Improvement | #d97706 | hammer |
| Mortgage & Rent | #b45309 | home |
| Utilities | #eab308 | lightbulb |
| Subscriptions | #6366f1 | wifi |
| Insurance | #0284c7 | shield |
| Sports & Fitness | #10b981 | dumbbell |
| Gifts & Donations | #61c9ea | hand-helping |
| Taxes | #dc2626 | landmark |
| Loan Payments | #e11d48 | credit-card |
| Services | #7c3aed | briefcase |
| Fees | #6b7280 | receipt |
| Savings & Investments | #059669 | piggy-bank |
| Investment Contributions | #0d9488 | trending-up |
Bootstrapping is not automatic — it must be triggered explicitly. The CategoriesController#bootstrap action calls Current.family.categories.bootstrap! and is user-initiated. New families start with zero categories.
Creating Categories#
Web Form#
The categories form lets users:
- Enter a name
- Pick a color from
Category::COLORSor use a custom hex via a color picker - Pick a Lucide icon from the full
icon_codeslist - Assign an optional parent (rendered only when the category has no existing subcategories)
The color picker section is hidden for subcategories since color is inherited from the parent .
The controller's create action optionally links the new category directly to a transaction if transaction_id is present in params.
AI Assistant#
Assistant::Function::CreateCategory exposes a create_category function. Required param: name. Optional: color, icon, parent_id. If icon is omitted, Category.suggested_icon matches the name against ICON_KEYWORDS regexes. If parent_id is given, the function validates the UUID, looks up the parent, and sets it — triggering color inheritance on save.
CSV Bulk Import#
CategoryImport handles CSV-based bulk creation. Required column: name. Optional columns: color, parent_category, lucide_icon . Import logic runs two passes :
- First pass: creates/updates each category as a root (no parent set)
- Second pass: assigns parents, validating no self-parent cycles
Missing parents referenced in the CSV are auto-created as placeholder categories with UNCATEGORIZED_COLOR and the "shapes" icon .
CSV template structure :
name*,color,parent_category,lucide_icon
Food & Drink,#f97316,,carrot
Groceries,#407706,Food & Drink,shopping-basket
Salary,#22c55e,,briefcase
Merging Categories#
Category::Merger merges one or more source categories into a target, atomically:
- Reassigns all transactions to the target
- Reparents any subcategories of the source under the target
- Consolidates budget entries
- Destroys the source categories
Validations prevent merging a parent into its own subcategory, merging a category with subcategories into a subcategory, and cross-family merges. Triggered via CategoriesController#perform_merge.
Synthetic Categories#
"Uncategorized" and "Other Investments" are not database rows. They are in-memory Category objects built by Category.uncategorized and Category.other_investments, detectable by synthetic?. These are used in income statement and search filter logic. See the Transaction Categorization article for full details.
Key Source Files#
| File | Purpose |
|---|---|
app/models/category.rb | Model, validations, color inheritance, defaults, synthetic categories |
app/controllers/categories_controller.rb | CRUD, bootstrap, merge actions |
app/views/categories/_form.html.erb | Web creation/edit form |
app/models/assistant/function/create_category.rb | AI assistant function |
app/models/category_import.rb | CSV bulk import |
db/schema.rb | Table schema |