diff --git a/app/controllers/import/configurations_controller.rb b/app/controllers/import/configurations_controller.rb index 989e44409..85e6e402d 100644 --- a/app/controllers/import/configurations_controller.rb +++ b/app/controllers/import/configurations_controller.rb @@ -12,6 +12,9 @@ class Import::ConfigurationsController < ApplicationController @import.reload.sync_mappings redirect_to import_clean_path(@import), notice: "Import configured successfully." + rescue ActiveRecord::RecordInvalid => e + message = e.record.errors.full_messages.to_sentence.presence || e.message + redirect_back_or_to import_configuration_path(@import), alert: message end private diff --git a/app/models/category_import.rb b/app/models/category_import.rb index b023849c8..a862bb77d 100644 --- a/app/models/category_import.rb +++ b/app/models/category_import.rb @@ -59,19 +59,52 @@ class CategoryImport < Import def generate_rows_from_csv rows.destroy_all + validate_required_headers! + + name_header = header_for("name") + color_header = header_for("color") + parent_header = header_for("parent_category", "parent category") + classification_header = header_for("classification") + icon_header = header_for("lucide_icon", "lucide icon", "icon") + 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, + name: row[name_header].to_s.strip, + category_color: row[color_header].to_s.strip, + category_parent: row[parent_header].to_s.strip, + category_classification: row[classification_header].to_s.strip, + category_icon: row[icon_header].to_s.strip, currency: default_currency ) end end private + def validate_required_headers! + missing_headers = required_column_keys.map(&:to_s).reject { |key| header_for(key).present? } + return if missing_headers.empty? + + errors.add(:base, "Missing required columns: #{missing_headers.join(', ')}") + raise ActiveRecord::RecordInvalid.new(self) + end + + def header_for(*candidates) + candidates.each do |candidate| + normalized = normalize_header(candidate) + header = normalized_headers[normalized] + return header if header.present? + end + + nil + end + + def normalized_headers + @normalized_headers ||= csv_headers.to_h { |header| [ normalize_header(header), header ] } + end + + def normalize_header(header) + header.to_s.strip.downcase.gsub(/\*/, "").gsub(/[\s-]+/, "_") + end def ensure_placeholder_category(name) trimmed_name = name.to_s.strip diff --git a/test/models/category_import_test.rb b/test/models/category_import_test.rb index e6b9d2045..99e645c33 100644 --- a/test/models/category_import_test.rb +++ b/test/models/category_import_test.rb @@ -69,4 +69,29 @@ class CategoryImportTest < ActiveSupport::TestCase assert_equal "#bbbbbb", snacks.color assert_equal "pizza", snacks.lucide_icon end + + test "accepts required headers with an asterisk suffix" do + csv = <<~CSV + name*,color,parent_category,classification,icon + Food & Drink,#f97316,,expense,carrot + CSV + + import = @family.imports.create!(type: "CategoryImport", raw_file_str: csv, col_sep: ",") + import.generate_rows_from_csv + + assert_equal 1, import.rows.count + assert_equal "Food & Drink", import.rows.first.name + end + + test "fails fast when required headers are missing" do + csv = <<~CSV + title,color,parent_category,classification,icon + Food & Drink,#f97316,,expense,carrot + CSV + + import = @family.imports.create!(type: "CategoryImport", raw_file_str: csv, col_sep: ",") + + error = assert_raises(ActiveRecord::RecordInvalid) { import.generate_rows_from_csv } + assert_includes error.message, "Missing required columns: name" + end end