class Category < ApplicationRecord has_many :transactions, dependent: :nullify, class_name: "Transaction" has_many :import_mappings, as: :mappable, dependent: :destroy, class_name: "Import::Mapping" belongs_to :family has_many :budget_categories, dependent: :destroy has_many :subcategories, -> { order(:name) }, class_name: "Category", foreign_key: :parent_id, dependent: :nullify belongs_to :parent, class_name: "Category", optional: true validates :name, :color, :lucide_icon, :family, presence: true validates :color, format: { with: /\A#[0-9A-Fa-f]{6}\z/ } validates :name, uniqueness: { scope: :family_id } validate :category_level_limit before_save :inherit_color_from_parent scope :alphabetically, -> { order(:name) } scope :alphabetically_by_hierarchy, -> { left_joins(:parent) .order(Arel.sql("COALESCE(parents_categories.name, categories.name)")) .order(Arel.sql("parents_categories.name IS NOT NULL")) .order(:name) } scope :roots, -> { where(parent_id: nil) } # Legacy scopes - classification removed; these now return all categories scope :incomes, -> { all } scope :expenses, -> { all } COLORS = %w[#e99537 #4da568 #6471eb #db5a54 #df4e92 #c44fe9 #eb5429 #61c9ea #805dee #6ad28a] UNCATEGORIZED_COLOR = "#737373" OTHER_INVESTMENTS_COLOR = "#e99537" TRANSFER_COLOR = "#444CE7" PAYMENT_COLOR = "#db5a54" TRADE_COLOR = "#e99537" ICON_KEYWORDS = { /income|salary|paycheck|wage|earning/ => "circle-dollar-sign", /groceries|grocery|supermarket/ => "shopping-bag", /food|dining|restaurant|meal|lunch|dinner|breakfast/ => "utensils", /coffee|cafe|café/ => "coffee", /shopping|retail/ => "shopping-cart", /transport|transit|commute|subway|metro/ => "bus", /parking/ => "circle-parking", /car|auto|vehicle/ => "car", /gas|fuel|petrol/ => "fuel", /flight|airline/ => "plane", /travel|trip|vacation|holiday/ => "plane", /hotel|lodging|accommodation/ => "hotel", /movie|cinema|film|theater|theatre/ => "film", /music|concert/ => "music", /game|gaming/ => "gamepad-2", /entertainment|leisure/ => "drama", /sport|fitness|gym|workout|exercise/ => "dumbbell", /pharmacy|drug|medicine|pill|medication|dental|dentist/ => "pill", /health|medical|clinic|doctor|physician/ => "stethoscope", /personal care|beauty|salon|spa|hair/ => "scissors", /mortgage|rent/ => "home", /home|house|apartment|housing/ => "home", /improvement|renovation|remodel/ => "hammer", /repair|maintenance/ => "wrench", /electric|power|energy/ => "zap", /water|sewage/ => "waves", /internet|cable|broadband|subscription|streaming/ => "wifi", /utilities|utility/ => "lightbulb", /phone|telephone/ => "phone", /mobile|cell/ => "smartphone", /insurance/ => "shield", /gift|present/ => "gift", /donat|charity|nonprofit/ => "hand-helping", /tax|irs|revenue/ => "landmark", /loan|debt|credit card/ => "credit-card", /service|professional/ => "briefcase", /fee|charge/ => "receipt", /bank|banking/ => "landmark", /saving/ => "piggy-bank", /invest|stock|fund|portfolio/ => "trending-up", /pet|dog|cat|animal|vet/ => "paw-print", /education|school|university|college|tuition/ => "graduation-cap", /book|reading|library/ => "book", /child|kid|baby|infant|daycare/ => "baby", /cloth|apparel|fashion|wear/ => "shirt", /ticket/ => "ticket" }.freeze # Category name keys for i18n UNCATEGORIZED_NAME_KEY = "models.category.uncategorized" OTHER_INVESTMENTS_NAME_KEY = "models.category.other_investments" INVESTMENT_CONTRIBUTIONS_NAME_KEY = "models.category.investment_contributions" DEFAULT_CATEGORY_TRANSLATION_KEYS = %w[ income food_and_drink groceries shopping transportation travel entertainment healthcare personal_care home_improvement mortgage_rent utilities subscriptions insurance sports_and_fitness gifts_and_donations taxes loan_payments services fees savings_and_investments ].freeze class Group attr_reader :category, :subcategories delegate :name, :color, to: :category def self.for(categories) categories_by_parent_id = categories.to_a.group_by(&:parent_id) categories_by_parent_id[nil].to_a.map do |category| new(category, categories_by_parent_id[category.id].to_a) end end # Builds [label, id] pairs for plain HTML # options can't render icons, so we use a unicode arrow prefix (regular # leading spaces collapse in