diff --git a/app/controllers/import/configurations_controller.rb b/app/controllers/import/configurations_controller.rb index f723c63ef..989e44409 100644 --- a/app/controllers/import/configurations_controller.rb +++ b/app/controllers/import/configurations_controller.rb @@ -20,7 +20,7 @@ class Import::ConfigurationsController < ApplicationController end def import_params - params.require(:import).permit( + params.fetch(:import, {}).permit( :date_col_label, :amount_col_label, :name_col_label, diff --git a/app/controllers/import/rows_controller.rb b/app/controllers/import/rows_controller.rb index a3905b148..ecab770bc 100644 --- a/app/controllers/import/rows_controller.rb +++ b/app/controllers/import/rows_controller.rb @@ -12,7 +12,7 @@ class Import::RowsController < ApplicationController private def row_params - params.require(:import_row).permit(:type, :account, :date, :qty, :ticker, :price, :amount, :currency, :name, :category, :tags, :entity_type, :notes) + params.require(:import_row).permit(:type, :account, :date, :qty, :ticker, :price, :amount, :currency, :name, :category, :tags, :entity_type, :notes, :category_color, :category_classification, :category_parent, :category_icon) end def set_import_row diff --git a/app/helpers/imports_helper.rb b/app/helpers/imports_helper.rb index cabc31acb..1ad7587b9 100644 --- a/app/helpers/imports_helper.rb +++ b/app/helpers/imports_helper.rb @@ -22,7 +22,11 @@ module ImportsHelper ticker: "Ticker", exchange: "Exchange", price: "Price", - entity_type: "Type" + entity_type: "Type", + category_parent: "Parent category", + category_color: "Color", + category_classification: "Classification", + category_icon: "Lucide icon" }[key] end @@ -62,7 +66,7 @@ module ImportsHelper private def permitted_import_types - %w[transaction_import trade_import account_import mint_import] + %w[transaction_import trade_import account_import mint_import category_import] end DryRunResource = Struct.new(:label, :icon, :text_class, :bg_class, keyword_init: true) diff --git a/app/models/category_import.rb b/app/models/category_import.rb new file mode 100644 index 000000000..fadab4520 --- /dev/null +++ b/app/models/category_import.rb @@ -0,0 +1,86 @@ +class CategoryImport < Import + def import! + transaction do + rows.each do |row| + category_name = row.name.to_s.strip + category = family.categories.find_or_initialize_by(name: category_name) + category.color = row.category_color.presence || category.color || Category::UNCATEGORIZED_COLOR + category.classification = row.category_classification.presence || category.classification || "expense" + category.lucide_icon = row.category_icon.presence || category.lucide_icon || "shapes" + category.parent = nil + category.save! + + ensure_placeholder_category(row.category_parent) + end + + rows.each do |row| + category = family.categories.find_by!(name: row.name.to_s.strip) + parent = ensure_placeholder_category(row.category_parent) + + if parent && parent == category + errors.add(:base, "Category '#{category.name}' cannot be its own parent") + raise ActiveRecord::RecordInvalid.new(self) + end + + next if category.parent == parent + + category.update!(parent: parent) + end + end + end + + def column_keys + %i[name category_color category_parent category_classification category_icon] + end + + def required_column_keys + %i[name] + end + + def mapping_steps + [] + end + + def dry_run + { categories: rows.count } + end + + def csv_template + template = <<-CSV + name*,color,parent_category,classification,lucide-icon + Food & Drink,#f97316,,expense,carrot + Groceries,#407706,Food & Drink,expense,shopping-basket + Salary,#22c55e,,income,briefcase + CSV + + CSV.parse(template, headers: true) + end + + def generate_rows_from_csv + rows.destroy_all + + csv_rows.each do |row| + rows.create!( + name: row["name"].to_s.strip, + category_color: row["color"].to_s.strip, + category_parent: row["parent_category"].to_s.strip, + category_classification: row["classification"].to_s.strip, + category_icon: (row["lucide-icon"].presence || row["icon"]).to_s.strip, + currency: default_currency + ) + end + end + + private + + def ensure_placeholder_category(name) + trimmed_name = name.to_s.strip + return if trimmed_name.blank? + + family.categories.find_or_create_by!(name: trimmed_name) do |placeholder| + placeholder.color = Category::UNCATEGORIZED_COLOR + placeholder.classification = "expense" + placeholder.lucide_icon = "shapes" + end + end +end diff --git a/app/models/family/data_exporter.rb b/app/models/family/data_exporter.rb index 93546cd6d..1247097aa 100644 --- a/app/models/family/data_exporter.rb +++ b/app/models/family/data_exporter.rb @@ -101,7 +101,7 @@ class Family::DataExporter def generate_categories_csv CSV.generate do |csv| - csv << [ "name", "color", "parent_category", "classification" ] + csv << [ "name", "color", "parent_category", "classification", "lucide_icon" ] # Only export categories belonging to this family @family.categories.includes(:parent).find_each do |category| @@ -109,7 +109,8 @@ class Family::DataExporter category.name, category.color, category.parent&.name, - category.classification + category.classification, + category.lucide_icon ] end end diff --git a/app/models/import.rb b/app/models/import.rb index b6e25e416..f18c03091 100644 --- a/app/models/import.rb +++ b/app/models/import.rb @@ -2,7 +2,7 @@ class Import < ApplicationRecord MaxRowCountExceededError = Class.new(StandardError) MappingError = Class.new(StandardError) - TYPES = %w[TransactionImport TradeImport AccountImport MintImport].freeze + TYPES = %w[TransactionImport TradeImport AccountImport MintImport CategoryImport].freeze SIGNAGE_CONVENTIONS = %w[inflows_positive inflows_negative] SEPARATORS = [ [ "Comma (,)", "," ], [ "Semicolon (;)", ";" ] ].freeze diff --git a/app/views/import/configurations/_category_import.html.erb b/app/views/import/configurations/_category_import.html.erb new file mode 100644 index 000000000..4f0cf2279 --- /dev/null +++ b/app/views/import/configurations/_category_import.html.erb @@ -0,0 +1,14 @@ +<%# locals: (import:) %> + +
+

<%= t("import.configurations.category_import.description") %>

+ + <%= styled_form_with model: import, + url: import_configuration_path(import), + scope: :import, + method: :patch, + class: "space-y-3" do |form| %> +

<%= t("import.configurations.category_import.instructions") %>

+ <%= form.submit t("import.configurations.category_import.button_label"), disabled: import.complete? %> + <% end %> +
diff --git a/app/views/imports/new.html.erb b/app/views/imports/new.html.erb index c436cb256..8d84ef1a4 100644 --- a/app/views/imports/new.html.erb +++ b/app/views/imports/new.html.erb @@ -85,6 +85,26 @@ <% end %> + <% if params[:type].nil? || params[:type] == "CategoryImport" %> +
  • + <%= button_to imports_path(import: { type: "CategoryImport" }), class: "flex items-center justify-between p-4 group cursor-pointer w-full", data: { turbo: false } do %> +
    +
    + + <%= icon("shapes", color: "current") %> + +
    + + <%= t(".import_categories") %> + +
    + <%= icon("chevron-right") %> + <% end %> + + <%= render "shared/ruler" %> +
  • + <% end %> + <% if Current.family.accounts.any? && (params[:type].nil? || params[:type] == "MintImport" || params[:type] == "TransactionImport") %>
  • <%= button_to imports_path(import: { type: "MintImport" }), class: "flex items-center justify-between p-4 group w-full", data: { turbo: false } do %> diff --git a/config/locales/views/imports/en.yml b/config/locales/views/imports/en.yml index c2958d267..d5f4ddf76 100644 --- a/config/locales/views/imports/en.yml +++ b/config/locales/views/imports/en.yml @@ -10,6 +10,11 @@ en: details. title: Clean your data configurations: + category_import: + button_label: Continue + description: Upload a simple CSV file (like the one we generate when you + export your data). We'll automatically map the columns for you. + instructions: Select continue to parse your CSV and move on to the clean step. mint_import: date_format_label: Date format show: @@ -80,6 +85,7 @@ en: description: You can manually import various types of data via CSV or use one of our import templates like Mint. import_accounts: Import accounts + import_categories: Import categories import_mint: Import from Mint import_portfolio: Import investments import_transactions: Import transactions diff --git a/db/migrate/20240701000000_add_category_fields_to_import_rows.rb b/db/migrate/20240701000000_add_category_fields_to_import_rows.rb new file mode 100644 index 000000000..8a4210223 --- /dev/null +++ b/db/migrate/20240701000000_add_category_fields_to_import_rows.rb @@ -0,0 +1,7 @@ +class AddCategoryFieldsToImportRows < ActiveRecord::Migration[7.1] + def change + add_column :import_rows, :category_parent, :string + add_column :import_rows, :category_color, :string + add_column :import_rows, :category_classification, :string + end +end diff --git a/db/migrate/20240701000001_add_category_icon_to_import_rows.rb b/db/migrate/20240701000001_add_category_icon_to_import_rows.rb new file mode 100644 index 000000000..66f389233 --- /dev/null +++ b/db/migrate/20240701000001_add_category_icon_to_import_rows.rb @@ -0,0 +1,5 @@ +class AddCategoryIconToImportRows < ActiveRecord::Migration[7.1] + def change + add_column :import_rows, :category_icon, :string + end +end diff --git a/db/schema.rb b/db/schema.rb index 4743b4f02..75490c240 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -370,6 +370,10 @@ ActiveRecord::Schema[7.2].define(version: 2025_11_11_094448) do t.datetime "created_at", null: false t.datetime "updated_at", null: false t.string "exchange_operating_mic" + t.string "category_parent" + t.string "category_color" + t.string "category_classification" + t.string "category_icon" t.index ["import_id"], name: "index_import_rows_on_import_id" end diff --git a/test/models/category_import_test.rb b/test/models/category_import_test.rb new file mode 100644 index 000000000..e6b9d2045 --- /dev/null +++ b/test/models/category_import_test.rb @@ -0,0 +1,72 @@ +require "test_helper" + +class CategoryImportTest < ActiveSupport::TestCase + setup do + @family = families(:dylan_family) + @csv = <<~CSV + name,color,parent_category,classification,icon + Food & Drink,#f97316,,expense,carrot + Groceries,#407706,Food & Drink,expense,shopping-basket + Salary,#22c55e,,income,briefcase + CSV + end + + test "imports categories from Sure export" do + import = @family.imports.create!(type: "CategoryImport", raw_file_str: @csv, col_sep: ",") + import.generate_rows_from_csv + assert_equal 3, import.rows.count + + tracked_categories = Category.where(family: @family, name: [ "Food & Drink", "Groceries", "Salary" ]) + + assert_difference -> { tracked_categories.count }, 2 do + import.send(:import!) + end + + food = Category.find_by!(family: @family, name: "Food & Drink") + groceries = Category.find_by!(family: @family, name: "Groceries") + salary = Category.find_by!(family: @family, name: "Salary") + + assert_equal "expense", food.classification + assert_equal "carrot", food.lucide_icon + assert_equal food, groceries.parent + assert_equal "shopping-basket", groceries.lucide_icon + assert_equal "income", salary.classification + assert_equal "briefcase", salary.lucide_icon + end + + test "imports subcategories even when parent row comes later" do + csv = <<~CSV + name,color,parent_category,classification,icon + Utilities,#407706,Household,expense,plug + Household,#f97316,,expense,house + CSV + + import = @family.imports.create!(type: "CategoryImport", raw_file_str: csv, col_sep: ",") + import.generate_rows_from_csv + + import.send(:import!) + + household = Category.find_by!(family: @family, name: "Household") + utilities = Category.find_by!(family: @family, name: "Utilities") + + assert_equal household, utilities.parent + assert_equal "#f97316", household.color + end + + test "updates categories when duplicate rows are provided" do + csv = <<~CSV + name,color,parent_category,classification,icon + Snacks,#aaaaaa,,expense,cookie + Snacks,#bbbbbb,,expense,pizza + CSV + + import = @family.imports.create!(type: "CategoryImport", raw_file_str: csv, col_sep: ",") + import.generate_rows_from_csv + + import.send(:import!) + + snacks = Category.find_by!(family: @family, name: "Snacks") + assert_equal "#bbbbbb", snacks.color + assert_equal "pizza", snacks.lucide_icon + end +end diff --git a/test/models/family/data_exporter_test.rb b/test/models/family/data_exporter_test.rb index fe56fb08a..eb3254502 100644 --- a/test/models/family/data_exporter_test.rb +++ b/test/models/family/data_exporter_test.rb @@ -57,7 +57,7 @@ class Family::DataExporterTest < ActiveSupport::TestCase # Check categories.csv categories_csv = zip.read("categories.csv") - assert categories_csv.include?("name,color,parent_category,classification") + assert categories_csv.include?("name,color,parent_category,classification,lucide_icon") end end