diff --git a/app/helpers/imports_helper.rb b/app/helpers/imports_helper.rb index 1ad7587b9..e7ff95e03 100644 --- a/app/helpers/imports_helper.rb +++ b/app/helpers/imports_helper.rb @@ -35,7 +35,8 @@ module ImportsHelper transactions: DryRunResource.new(label: "Transactions", icon: "credit-card", text_class: "text-cyan-500", bg_class: "bg-cyan-500/5"), accounts: DryRunResource.new(label: "Accounts", icon: "layers", text_class: "text-orange-500", bg_class: "bg-orange-500/5"), categories: DryRunResource.new(label: "Categories", icon: "shapes", text_class: "text-blue-500", bg_class: "bg-blue-500/5"), - tags: DryRunResource.new(label: "Tags", icon: "tags", text_class: "text-violet-500", bg_class: "bg-violet-500/5") + tags: DryRunResource.new(label: "Tags", icon: "tags", text_class: "text-violet-500", bg_class: "bg-violet-500/5"), + rules: DryRunResource.new(label: "Rules", icon: "workflow", text_class: "text-green-500", bg_class: "bg-green-500/5") } map[key] @@ -66,7 +67,7 @@ module ImportsHelper private def permitted_import_types - %w[transaction_import trade_import account_import mint_import category_import] + %w[transaction_import trade_import account_import mint_import category_import rule_import] end DryRunResource = Struct.new(:label, :icon, :text_class, :bg_class, keyword_init: true) diff --git a/app/models/family/data_exporter.rb b/app/models/family/data_exporter.rb index 1247097aa..6b3be40fd 100644 --- a/app/models/family/data_exporter.rb +++ b/app/models/family/data_exporter.rb @@ -25,6 +25,10 @@ class Family::DataExporter zipfile.put_next_entry("categories.csv") zipfile.write generate_categories_csv + # Add rules.csv + zipfile.put_next_entry("rules.csv") + zipfile.write generate_rules_csv + # Add all.ndjson zipfile.put_next_entry("all.ndjson") zipfile.write generate_ndjson @@ -116,6 +120,24 @@ class Family::DataExporter end end + def generate_rules_csv + CSV.generate do |csv| + csv << [ "name", "resource_type", "active", "effective_date", "conditions", "actions" ] + + # Only export rules belonging to this family + @family.rules.includes(conditions: :sub_conditions, actions: []).find_each do |rule| + csv << [ + rule.name, + rule.resource_type, + rule.active, + rule.effective_date&.iso8601, + serialize_conditions_for_csv(rule.conditions), + serialize_actions_for_csv(rule.actions) + ] + end + end + end + def generate_ndjson lines = [] @@ -234,6 +256,97 @@ class Family::DataExporter }.to_json end + # Export rules with versioned schema + @family.rules.includes(conditions: :sub_conditions, actions: []).find_each do |rule| + lines << { + type: "Rule", + version: 1, + data: serialize_rule_for_export(rule) + }.to_json + end + lines.join("\n") end + + def serialize_rule_for_export(rule) + { + name: rule.name, + resource_type: rule.resource_type, + active: rule.active, + effective_date: rule.effective_date&.iso8601, + conditions: rule.conditions.where(parent_id: nil).map { |condition| serialize_condition(condition) }, + actions: rule.actions.map { |action| serialize_action(action) } + } + end + + def serialize_condition(condition) + data = { + condition_type: condition.condition_type, + operator: condition.operator, + value: resolve_condition_value(condition) + } + + if condition.compound? && condition.sub_conditions.any? + data[:sub_conditions] = condition.sub_conditions.map { |sub| serialize_condition(sub) } + end + + data + end + + def serialize_action(action) + { + action_type: action.action_type, + value: resolve_action_value(action) + } + end + + def resolve_condition_value(condition) + return condition.value unless condition.value.present? + + # Map category UUIDs to names for portability + if condition.condition_type == "transaction_category" && condition.value.present? + category = @family.categories.find_by(id: condition.value) + return category&.name || condition.value + end + + # Map merchant UUIDs to names for portability + if condition.condition_type == "transaction_merchant" && condition.value.present? + merchant = @family.merchants.find_by(id: condition.value) + return merchant&.name || condition.value + end + + condition.value + end + + def resolve_action_value(action) + return action.value unless action.value.present? + + # Map category UUIDs to names for portability + if action.action_type == "set_transaction_category" && action.value.present? + category = @family.categories.find_by(id: action.value) || @family.categories.find_by(name: action.value) + return category&.name || action.value + end + + # Map merchant UUIDs to names for portability + if action.action_type == "set_transaction_merchant" && action.value.present? + merchant = @family.merchants.find_by(id: action.value) || @family.merchants.find_by(name: action.value) + return merchant&.name || action.value + end + + # Map tag UUIDs to names for portability + if action.action_type == "set_transaction_tags" && action.value.present? + tag = @family.tags.find_by(id: action.value) || @family.tags.find_by(name: action.value) + return tag&.name || action.value + end + + action.value + end + + def serialize_conditions_for_csv(conditions) + conditions.where(parent_id: nil).map { |c| serialize_condition(c) }.to_json + end + + def serialize_actions_for_csv(actions) + actions.map { |a| serialize_action(a) }.to_json + end end diff --git a/app/models/import.rb b/app/models/import.rb index f18c03091..639841ce1 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 CategoryImport].freeze + TYPES = %w[TransactionImport TradeImport AccountImport MintImport CategoryImport RuleImport].freeze SIGNAGE_CONVENTIONS = %w[inflows_positive inflows_negative] SEPARATORS = [ [ "Comma (,)", "," ], [ "Semicolon (;)", ";" ] ].freeze diff --git a/app/models/rule.rb b/app/models/rule.rb index b0d405c26..9632cdff2 100644 --- a/app/models/rule.rb +++ b/app/models/rule.rb @@ -79,6 +79,8 @@ class Rule < ApplicationRecord end def min_actions + return if new_record? && actions.empty? + if actions.reject(&:marked_for_destruction?).empty? errors.add(:base, "must have at least one action") end diff --git a/app/models/rule/condition.rb b/app/models/rule/condition.rb index ab5138cba..c8b804f56 100644 --- a/app/models/rule/condition.rb +++ b/app/models/rule/condition.rb @@ -2,7 +2,7 @@ class Rule::Condition < ApplicationRecord belongs_to :rule, touch: true, optional: -> { where.not(parent_id: nil) } belongs_to :parent, class_name: "Rule::Condition", optional: true, inverse_of: :sub_conditions - has_many :sub_conditions, class_name: "Rule::Condition", foreign_key: :parent_id, dependent: :destroy, inverse_of: :parent + has_many :sub_conditions, -> { order(:created_at, :id) }, class_name: "Rule::Condition", foreign_key: :parent_id, dependent: :destroy, inverse_of: :parent validates :condition_type, presence: true validates :operator, presence: true diff --git a/app/models/rule_import.rb b/app/models/rule_import.rb new file mode 100644 index 000000000..e214481aa --- /dev/null +++ b/app/models/rule_import.rb @@ -0,0 +1,330 @@ +class RuleImport < Import + def import! + transaction do + rows.each do |row| + create_or_update_rule_from_row(row) + end + end + end + + def column_keys + %i[name resource_type active effective_date conditions actions] + end + + def required_column_keys + %i[resource_type conditions actions] + end + + def mapping_steps + [] + end + + def dry_run + { rules: rows.count } + end + + def csv_template + csv_string = CSV.generate do |csv| + csv << %w[name resource_type* active effective_date conditions* actions*] + + csv << [ + "Categorize groceries", + "transaction", + "true", + "2024-01-01", + '[{"condition_type":"transaction_name","operator":"like","value":"grocery"}]', + '[{"action_type":"set_transaction_category","value":"Groceries"}]' + ] + + csv << [ + "Auto-categorize transactions", + "transaction", + "true", + "", + '[{"condition_type":"transaction_name","operator":"like","value":"amazon"}]', + '[{"action_type":"auto_categorize"}]' + ] + end + + CSV.parse(csv_string, headers: true) + end + + def generate_rows_from_csv + rows.destroy_all + + csv_rows.each do |row| + normalized_row = normalize_rule_row(row) + + rows.create!( + name: normalized_row[:name].to_s.strip, + resource_type: normalized_row[:resource_type].to_s.strip, + active: parse_boolean(normalized_row[:active]), + effective_date: normalized_row[:effective_date].to_s.strip, + conditions: normalized_row[:conditions].to_s.strip, + actions: normalized_row[:actions].to_s.strip, + currency: default_currency + ) + end + end + + def parsed_csv + @parsed_csv ||= Import.parse_csv_str(raw_file_str, col_sep: col_sep) + end + + private + + def normalize_rule_row(row) + fields = row.fields + name, resource_type, active, effective_date = fields[0..3] + conditions, actions = extract_conditions_and_actions(fields[4..]) + + { + name: row["name"].presence || name, + resource_type: row["resource_type"].presence || resource_type, + active: row["active"].presence || active, + effective_date: row["effective_date"].presence || effective_date, + conditions: conditions, + actions: actions + } + end + + def extract_conditions_and_actions(fragments) + pieces = Array(fragments).compact + return [ "", "" ] if pieces.empty? + + combined = pieces.join(col_sep) + + # If the CSV was split incorrectly because of unescaped quotes in the JSON + # payload, re-assemble the last two logical columns by splitting on the + # boundary between the two JSON arrays: ...]","[... + parts = combined.split(/(?<=\])"\s*,\s*"(?=\[)/, 2) + parts = [ pieces[0], pieces[1] ] if parts.length < 2 + + parts.map do |part| + next "" unless part + + # Remove any stray leading/trailing quotes left from CSV parsing + part.to_s.strip.gsub(/\A"+|"+\z/, "") + end + end + + def create_or_update_rule_from_row(row) + rule_name = row.name.to_s.strip.presence + resource_type = row.resource_type.to_s.strip + + # Validate resource type + unless resource_type == "transaction" + errors.add(:base, "Unsupported resource type: #{resource_type}") + raise ActiveRecord::RecordInvalid.new(self) + end + + # Parse conditions and actions from JSON + begin + conditions_data = parse_json_safely(row.conditions, "conditions") + actions_data = parse_json_safely(row.actions, "actions") + rescue JSON::ParserError => e + errors.add(:base, "Invalid JSON in conditions or actions: #{e.message}") + raise ActiveRecord::RecordInvalid.new(self) + end + + # Validate we have at least one action + if actions_data.empty? + errors.add(:base, "Rule must have at least one action") + raise ActiveRecord::RecordInvalid.new(self) + end + + # Find or create rule + rule = if rule_name.present? + family.rules.find_or_initialize_by(name: rule_name, resource_type: resource_type) + else + family.rules.build(resource_type: resource_type) + end + + rule.active = row.active || false + rule.effective_date = parse_date(row.effective_date) + + # Clear existing conditions and actions + rule.conditions.destroy_all + rule.actions.destroy_all + + # Create conditions + conditions_data.each do |condition_data| + build_condition(rule, condition_data) + end + + # Create actions + actions_data.each do |action_data| + build_action(rule, action_data) + end + + rule.save! + end + + def build_condition(rule, condition_data, parent: nil) + condition_type = condition_data["condition_type"] + operator = condition_data["operator"] + value = resolve_import_condition_value(condition_data) + + condition = if parent + parent.sub_conditions.build( + condition_type: condition_type, + operator: operator, + value: value + ) + else + rule.conditions.build( + condition_type: condition_type, + operator: operator, + value: value + ) + end + + # Handle compound conditions with sub_conditions + if condition_data["sub_conditions"].present? + condition_data["sub_conditions"].each do |sub_condition_data| + build_condition(rule, sub_condition_data, parent: condition) + end + end + + condition + end + + def build_action(rule, action_data) + action_type = action_data["action_type"] + value = resolve_import_action_value(action_data) + + rule.actions.build( + action_type: action_type, + value: value + ) + end + + def resolve_import_condition_value(condition_data) + condition_type = condition_data["condition_type"] + value = condition_data["value"] + + return value unless value.present? + + # Map category names to UUIDs + if condition_type == "transaction_category" + category = family.categories.find_by(name: value) + unless category + category = family.categories.create!( + name: value, + color: Category::UNCATEGORIZED_COLOR, + classification: "expense", + lucide_icon: "shapes" + ) + end + return category.id + end + + # Map merchant names to UUIDs + if condition_type == "transaction_merchant" + merchant = family.merchants.find_by(name: value) + unless merchant + merchant = family.merchants.create!(name: value) + end + return merchant.id + end + + value + end + + def resolve_import_action_value(action_data) + action_type = action_data["action_type"] + value = action_data["value"] + + return value unless value.present? + + # Map category names to UUIDs + if action_type == "set_transaction_category" + category = family.categories.find_by(name: value) + # Create category if it doesn't exist + unless category + category = family.categories.create!( + name: value, + color: Category::UNCATEGORIZED_COLOR, + classification: "expense", + lucide_icon: "shapes" + ) + end + return category.id + end + + # Map merchant names to UUIDs + if action_type == "set_transaction_merchant" + merchant = family.merchants.find_by(name: value) + # Create merchant if it doesn't exist + unless merchant + merchant = family.merchants.create!(name: value) + end + return merchant.id + end + + # Map tag names to UUIDs + if action_type == "set_transaction_tags" + tag = family.tags.find_by(name: value) + # Create tag if it doesn't exist + unless tag + tag = family.tags.create!(name: value) + end + return tag.id + end + + value + end + + def parse_boolean(value) + return true if value.to_s.downcase.in?(%w[true 1 yes y]) + return false if value.to_s.downcase.in?(%w[false 0 no n]) + false + end + + def parse_date(value) + return nil if value.blank? + Date.parse(value.to_s) + rescue ArgumentError + nil + end + + def parse_json_safely(json_string, field_name) + return [] if json_string.blank? + + # Clean up the JSON string - remove extra escaping that might come from CSV parsing + cleaned = json_string.to_s.strip + + # Remove surrounding quotes if present (both single and double) + cleaned = cleaned.gsub(/\A["']+|["']+\z/, "") + + # Handle multiple levels of escaping iteratively + # Keep unescaping until no more changes occur + loop do + previous = cleaned.dup + + # Unescape quotes - handle patterns like \" or \\\" or \\\\\" etc. + # Replace any number of backslashes followed by a quote with just a quote + cleaned = cleaned.gsub(/\\+"/, '"') + cleaned = cleaned.gsub(/\\+'/, "'") + + # Unescape backslashes (\\\\ becomes \) + cleaned = cleaned.gsub(/\\\\/, "\\") + + break if cleaned == previous + end + + # Handle unicode escapes like \u003e (but only if not over-escaped) + # Try to find and decode unicode escapes + cleaned = cleaned.gsub(/\\u([0-9a-fA-F]{4})/i) do |match| + code_point = $1.to_i(16) + [ code_point ].pack("U") + rescue + match # If decoding fails, keep the original + end + + # Try parsing + JSON.parse(cleaned) + rescue JSON::ParserError => e + raise JSON::ParserError.new("Invalid JSON in #{field_name}: #{e.message}. Raw value: #{json_string.inspect}") + end +end diff --git a/app/views/family_exports/new.html.erb b/app/views/family_exports/new.html.erb index ce6c53aea..a23477368 100644 --- a/app/views/family_exports/new.html.erb +++ b/app/views/family_exports/new.html.erb @@ -20,7 +20,7 @@
  • <%= icon "check", class: "shrink-0 mt-0.5 text-positive" %> - Categories and tags + Categories, tags and rules
  • diff --git a/app/views/import/configurations/_rule_import.html.erb b/app/views/import/configurations/_rule_import.html.erb new file mode 100644 index 000000000..7089a40ad --- /dev/null +++ b/app/views/import/configurations/_rule_import.html.erb @@ -0,0 +1,15 @@ +<%# locals: (import:) %> + +
    +

    <%= t("import.configurations.rule_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.rule_import.process_help") %>

    + <%= form.submit t("import.configurations.rule_import.process_button"), disabled: import.complete? %> + <% end %> +
    + diff --git a/app/views/imports/new.html.erb b/app/views/imports/new.html.erb index 8d84ef1a4..d6910c0ff 100644 --- a/app/views/imports/new.html.erb +++ b/app/views/imports/new.html.erb @@ -105,6 +105,26 @@ <% end %> + <% if params[:type].nil? || params[:type] == "RuleImport" %> +
  • + <%= button_to imports_path(import: { type: "RuleImport" }), class: "flex items-center justify-between p-4 group cursor-pointer w-full", data: { turbo: false } do %> +
    +
    + + <%= icon("workflow", color: "current") %> + +
    + + <%= t(".import_rules") %> + +
    + <%= 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 d5f4ddf76..28c5cb6f6 100644 --- a/config/locales/views/imports/en.yml +++ b/config/locales/views/imports/en.yml @@ -17,6 +17,11 @@ en: instructions: Select continue to parse your CSV and move on to the clean step. mint_import: date_format_label: Date format + rule_import: + description: Configure your rule import. Rules will be created or updated based + on the CSV data. + process_button: Process Rules + process_help: Click the button below to process your CSV and generate rule rows. show: description: Select the columns that correspond to each field in your CSV. title: Configure your import @@ -88,6 +93,7 @@ en: import_categories: Import categories import_mint: Import from Mint import_portfolio: Import investments + import_rules: Import rules import_transactions: Import transactions resume: Resume %{type} sources: Sources diff --git a/db/migrate/20251118000000_add_rule_fields_to_import_rows.rb b/db/migrate/20251118000000_add_rule_fields_to_import_rows.rb new file mode 100644 index 000000000..dae297ab9 --- /dev/null +++ b/db/migrate/20251118000000_add_rule_fields_to_import_rows.rb @@ -0,0 +1,9 @@ +class AddRuleFieldsToImportRows < ActiveRecord::Migration[7.2] + def change + add_column :import_rows, :resource_type, :string + add_column :import_rows, :active, :boolean + add_column :import_rows, :effective_date, :string + add_column :import_rows, :conditions, :text + add_column :import_rows, :actions, :text + end +end diff --git a/db/schema.rb b/db/schema.rb index bc46c3327..bd35087c4 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -422,6 +422,11 @@ ActiveRecord::Schema[7.2].define(version: 2025_11_26_094446) do t.string "category_color" t.string "category_classification" t.string "category_icon" + t.string "resource_type" + t.boolean "active" + t.string "effective_date" + t.text "conditions" + t.text "actions" t.index ["import_id"], name: "index_import_rows_on_import_id" end diff --git a/test/models/family/data_exporter_test.rb b/test/models/family/data_exporter_test.rb index eb3254502..2d5c3ad4a 100644 --- a/test/models/family/data_exporter_test.rb +++ b/test/models/family/data_exporter_test.rb @@ -23,6 +23,21 @@ class Family::DataExporterTest < ActiveSupport::TestCase name: "Test Tag", color: "#00FF00" ) + + @rule = @family.rules.create!( + name: "Test Rule", + resource_type: "transaction", + active: true + ) + @rule.conditions.create!( + condition_type: "transaction_name", + operator: "like", + value: "test" + ) + @rule.actions.create!( + action_type: "set_transaction_category", + value: @category.id + ) end test "generates a zip file with all required files" do @@ -31,7 +46,7 @@ class Family::DataExporterTest < ActiveSupport::TestCase assert zip_data.is_a?(StringIO) # Check that the zip contains all expected files - expected_files = [ "accounts.csv", "transactions.csv", "trades.csv", "categories.csv", "all.ndjson" ] + expected_files = [ "accounts.csv", "transactions.csv", "trades.csv", "categories.csv", "rules.csv", "all.ndjson" ] Zip::File.open_buffer(zip_data) do |zip| actual_files = zip.entries.map(&:name) @@ -58,6 +73,10 @@ class Family::DataExporterTest < ActiveSupport::TestCase # Check categories.csv categories_csv = zip.read("categories.csv") assert categories_csv.include?("name,color,parent_category,classification,lucide_icon") + + # Check rules.csv + rules_csv = zip.read("rules.csv") + assert rules_csv.include?("name,resource_type,active,effective_date,conditions,actions") end end @@ -112,4 +131,210 @@ class Family::DataExporterTest < ActiveSupport::TestCase refute ndjson_content.include?(other_category.id) end end + + test "exports rules in CSV format" do + zip_data = @exporter.generate_export + + Zip::File.open_buffer(zip_data) do |zip| + rules_csv = zip.read("rules.csv") + + assert rules_csv.include?("Test Rule") + assert rules_csv.include?("transaction") + assert rules_csv.include?("true") + end + end + + test "exports rules in NDJSON format with versioning" do + zip_data = @exporter.generate_export + + Zip::File.open_buffer(zip_data) do |zip| + ndjson_content = zip.read("all.ndjson") + lines = ndjson_content.split("\n") + + rule_lines = lines.select do |line| + parsed = JSON.parse(line) + parsed["type"] == "Rule" + end + + assert rule_lines.any? + + rule_data = JSON.parse(rule_lines.first) + assert_equal "Rule", rule_data["type"] + assert_equal 1, rule_data["version"] + assert rule_data["data"].key?("name") + assert rule_data["data"].key?("resource_type") + assert rule_data["data"].key?("active") + assert rule_data["data"].key?("conditions") + assert rule_data["data"].key?("actions") + end + end + + test "exports rule conditions with proper structure" do + zip_data = @exporter.generate_export + + Zip::File.open_buffer(zip_data) do |zip| + ndjson_content = zip.read("all.ndjson") + lines = ndjson_content.split("\n") + + rule_lines = lines.select do |line| + parsed = JSON.parse(line) + parsed["type"] == "Rule" && parsed["data"]["name"] == "Test Rule" + end + + assert rule_lines.any? + + rule_data = JSON.parse(rule_lines.first) + conditions = rule_data["data"]["conditions"] + + assert_equal 1, conditions.length + assert_equal "transaction_name", conditions[0]["condition_type"] + assert_equal "like", conditions[0]["operator"] + assert_equal "test", conditions[0]["value"] + end + end + + test "exports rule actions and maps category UUIDs to names" do + zip_data = @exporter.generate_export + + Zip::File.open_buffer(zip_data) do |zip| + ndjson_content = zip.read("all.ndjson") + lines = ndjson_content.split("\n") + + rule_lines = lines.select do |line| + parsed = JSON.parse(line) + parsed["type"] == "Rule" && parsed["data"]["name"] == "Test Rule" + end + + assert rule_lines.any? + + rule_data = JSON.parse(rule_lines.first) + actions = rule_data["data"]["actions"] + + assert_equal 1, actions.length + assert_equal "set_transaction_category", actions[0]["action_type"] + # Should export category name instead of UUID + assert_equal "Test Category", actions[0]["value"] + end + end + + test "exports rule actions and maps tag UUIDs to names" do + # Create a rule with a tag action + tag_rule = @family.rules.create!( + name: "Tag Rule", + resource_type: "transaction", + active: true + ) + tag_rule.conditions.create!( + condition_type: "transaction_name", + operator: "like", + value: "test" + ) + tag_rule.actions.create!( + action_type: "set_transaction_tags", + value: @tag.id + ) + + zip_data = @exporter.generate_export + + Zip::File.open_buffer(zip_data) do |zip| + ndjson_content = zip.read("all.ndjson") + lines = ndjson_content.split("\n") + + rule_lines = lines.select do |line| + parsed = JSON.parse(line) + parsed["type"] == "Rule" && parsed["data"]["name"] == "Tag Rule" + end + + assert rule_lines.any? + + rule_data = JSON.parse(rule_lines.first) + actions = rule_data["data"]["actions"] + + assert_equal 1, actions.length + assert_equal "set_transaction_tags", actions[0]["action_type"] + # Should export tag name instead of UUID + assert_equal "Test Tag", actions[0]["value"] + end + end + + test "exports compound conditions with sub-conditions" do + # Create a rule with compound conditions + compound_rule = @family.rules.create!( + name: "Compound Rule", + resource_type: "transaction", + active: true + ) + parent_condition = compound_rule.conditions.create!( + condition_type: "compound", + operator: "or" + ) + parent_condition.sub_conditions.create!( + condition_type: "transaction_name", + operator: "like", + value: "walmart" + ) + parent_condition.sub_conditions.create!( + condition_type: "transaction_name", + operator: "like", + value: "target" + ) + compound_rule.actions.create!( + action_type: "auto_categorize" + ) + + zip_data = @exporter.generate_export + + Zip::File.open_buffer(zip_data) do |zip| + ndjson_content = zip.read("all.ndjson") + lines = ndjson_content.split("\n") + + rule_lines = lines.select do |line| + parsed = JSON.parse(line) + parsed["type"] == "Rule" && parsed["data"]["name"] == "Compound Rule" + end + + assert rule_lines.any? + + rule_data = JSON.parse(rule_lines.first) + conditions = rule_data["data"]["conditions"] + + assert_equal 1, conditions.length + assert_equal "compound", conditions[0]["condition_type"] + assert_equal "or", conditions[0]["operator"] + assert_equal 2, conditions[0]["sub_conditions"].length + assert_equal "walmart", conditions[0]["sub_conditions"][0]["value"] + assert_equal "target", conditions[0]["sub_conditions"][1]["value"] + end + end + + test "only exports rules from the specified family" do + # Create a rule for another family that should NOT be exported + other_rule = @other_family.rules.create!( + name: "Other Family Rule", + resource_type: "transaction", + active: true + ) + other_rule.conditions.create!( + condition_type: "transaction_name", + operator: "like", + value: "other" + ) + other_rule.actions.create!( + action_type: "auto_categorize" + ) + + zip_data = @exporter.generate_export + + Zip::File.open_buffer(zip_data) do |zip| + # Check rules.csv doesn't contain other family's data + rules_csv = zip.read("rules.csv") + assert rules_csv.include?(@rule.name) + refute rules_csv.include?(other_rule.name) + + # Check NDJSON doesn't contain other family's rules + ndjson_content = zip.read("all.ndjson") + assert ndjson_content.include?(@rule.name) + refute ndjson_content.include?(other_rule.name) + end + end end diff --git a/test/models/rule_import_test.rb b/test/models/rule_import_test.rb new file mode 100644 index 000000000..017194887 --- /dev/null +++ b/test/models/rule_import_test.rb @@ -0,0 +1,238 @@ +require "test_helper" + +class RuleImportTest < ActiveSupport::TestCase + setup do + @family = families(:dylan_family) + @category = @family.categories.create!( + name: "Groceries", + color: "#407706", + classification: "expense", + lucide_icon: "shopping-basket" + ) + @csv = <<~CSV + name,resource_type,active,effective_date,conditions,actions + "Categorize groceries","transaction",true,2024-01-01,"[{\"condition_type\":\"transaction_name\",\"operator\":\"like\",\"value\":\"grocery\"}]","[{\"action_type\":\"set_transaction_category\",\"value\":\"Groceries\"}]" + "Auto-categorize transactions","transaction",false,,"[{\"condition_type\":\"transaction_amount\",\"operator\":\">\",\"value\":\"100\"}]","[{\"action_type\":\"auto_categorize\"}]" + CSV + end + + test "imports rules from CSV" do + import = @family.imports.create!(type: "RuleImport", raw_file_str: @csv, col_sep: ",") + import.generate_rows_from_csv + assert_equal 2, import.rows.count + + assert_difference -> { Rule.where(family: @family).count }, 2 do + import.send(:import!) + end + + grocery_rule = Rule.find_by!(family: @family, name: "Categorize groceries") + auto_rule = Rule.find_by!(family: @family, name: "Auto-categorize transactions") + + assert_equal "transaction", grocery_rule.resource_type + assert grocery_rule.active + assert_equal Date.parse("2024-01-01"), grocery_rule.effective_date + assert_equal 1, grocery_rule.conditions.count + assert_equal 1, grocery_rule.actions.count + + assert_equal "transaction", auto_rule.resource_type + assert_not auto_rule.active + assert_nil auto_rule.effective_date + assert_equal 1, auto_rule.conditions.count + assert_equal 1, auto_rule.actions.count + end + + test "imports rule conditions correctly" do + import = @family.imports.create!(type: "RuleImport", raw_file_str: @csv, col_sep: ",") + import.generate_rows_from_csv + import.send(:import!) + + grocery_rule = Rule.find_by!(family: @family, name: "Categorize groceries") + condition = grocery_rule.conditions.first + + assert_equal "transaction_name", condition.condition_type + assert_equal "like", condition.operator + assert_equal "grocery", condition.value + end + + test "imports rule actions correctly and maps category names to IDs" do + import = @family.imports.create!(type: "RuleImport", raw_file_str: @csv, col_sep: ",") + import.generate_rows_from_csv + import.send(:import!) + + grocery_rule = Rule.find_by!(family: @family, name: "Categorize groceries") + action = grocery_rule.actions.first + + assert_equal "set_transaction_category", action.action_type + assert_equal @category.id, action.value + end + + test "imports compound conditions with sub-conditions" do + csv = <<~CSV + name,resource_type,active,effective_date,conditions,actions + "Complex rule","transaction",true,,"[{\"condition_type\":\"compound\",\"operator\":\"or\",\"sub_conditions\":[{\"condition_type\":\"transaction_name\",\"operator\":\"like\",\"value\":\"walmart\"},{\"condition_type\":\"transaction_name\",\"operator\":\"like\",\"value\":\"target\"}]}]","[{\"action_type\":\"set_transaction_category\",\"value\":\"Groceries\"}]" + CSV + + import = @family.imports.create!(type: "RuleImport", raw_file_str: csv, col_sep: ",") + import.generate_rows_from_csv + import.send(:import!) + + rule = Rule.find_by!(family: @family, name: "Complex rule") + assert_equal 1, rule.conditions.count + + compound_condition = rule.conditions.first + assert compound_condition.compound? + assert_equal "or", compound_condition.operator + assert_equal 2, compound_condition.sub_conditions.count + + sub_condition_1 = compound_condition.sub_conditions.first + assert_equal "transaction_name", sub_condition_1.condition_type + assert_equal "like", sub_condition_1.operator + assert_equal "walmart", sub_condition_1.value + + sub_condition_2 = compound_condition.sub_conditions.last + assert_equal "transaction_name", sub_condition_2.condition_type + assert_equal "like", sub_condition_2.operator + assert_equal "target", sub_condition_2.value + end + + test "creates missing categories when importing actions" do + csv = <<~CSV + name,resource_type,active,effective_date,conditions,actions + "New category rule","transaction",true,,"[{\"condition_type\":\"transaction_name\",\"operator\":\"like\",\"value\":\"coffee\"}]","[{\"action_type\":\"set_transaction_category\",\"value\":\"Coffee Shops\"}]" + CSV + + import = @family.imports.create!(type: "RuleImport", raw_file_str: csv, col_sep: ",") + import.generate_rows_from_csv + + assert_difference -> { Category.where(family: @family).count }, 1 do + import.send(:import!) + end + + new_category = Category.find_by!(family: @family, name: "Coffee Shops") + assert_equal Category::UNCATEGORIZED_COLOR, new_category.color + assert_equal "expense", new_category.classification + + rule = Rule.find_by!(family: @family, name: "New category rule") + action = rule.actions.first + assert_equal new_category.id, action.value + end + + test "creates missing tags when importing actions" do + csv = <<~CSV + name,resource_type,active,effective_date,conditions,actions + "New tag rule","transaction",true,,"[{\"condition_type\":\"transaction_name\",\"operator\":\"like\",\"value\":\"coffee\"}]","[{\"action_type\":\"set_transaction_tags\",\"value\":\"Coffee Tag\"}]" + CSV + + import = @family.imports.create!(type: "RuleImport", raw_file_str: csv, col_sep: ",") + import.generate_rows_from_csv + + assert_difference -> { Tag.where(family: @family).count }, 1 do + import.send(:import!) + end + + new_tag = Tag.find_by!(family: @family, name: "Coffee Tag") + + rule = Rule.find_by!(family: @family, name: "New tag rule") + action = rule.actions.first + assert_equal "set_transaction_tags", action.action_type + assert_equal new_tag.id, action.value + end + + test "reuses existing tags when importing actions" do + existing_tag = @family.tags.create!(name: "Existing Tag") + + csv = <<~CSV + name,resource_type,active,effective_date,conditions,actions + "Tag rule","transaction",true,,"[{\"condition_type\":\"transaction_name\",\"operator\":\"like\",\"value\":\"test\"}]","[{\"action_type\":\"set_transaction_tags\",\"value\":\"Existing Tag\"}]" + CSV + + import = @family.imports.create!(type: "RuleImport", raw_file_str: csv, col_sep: ",") + import.generate_rows_from_csv + + assert_no_difference -> { Tag.where(family: @family).count } do + import.send(:import!) + end + + rule = Rule.find_by!(family: @family, name: "Tag rule") + action = rule.actions.first + assert_equal "set_transaction_tags", action.action_type + assert_equal existing_tag.id, action.value + end + + test "updates existing rule when re-importing with same name" do + # First import + import1 = @family.imports.create!(type: "RuleImport", raw_file_str: @csv, col_sep: ",") + import1.generate_rows_from_csv + import1.send(:import!) + + original_rule = Rule.find_by!(family: @family, name: "Categorize groceries") + assert original_rule.active + + # Second import with updated rule + csv2 = <<~CSV + name,resource_type,active,effective_date,conditions,actions + "Categorize groceries","transaction",false,2024-02-01,"[{\"condition_type\":\"transaction_name\",\"operator\":\"like\",\"value\":\"market\"}]","[{\"action_type\":\"auto_categorize\"}]" + CSV + + import2 = @family.imports.create!(type: "RuleImport", raw_file_str: csv2, col_sep: ",") + import2.generate_rows_from_csv + + assert_no_difference -> { Rule.where(family: @family).count } do + import2.send(:import!) + end + + updated_rule = Rule.find_by!(family: @family, name: "Categorize groceries") + assert_equal original_rule.id, updated_rule.id + assert_not updated_rule.active + assert_equal Date.parse("2024-02-01"), updated_rule.effective_date + + # Verify old conditions/actions are replaced + condition = updated_rule.conditions.first + assert_equal "market", condition.value + + action = updated_rule.actions.first + assert_equal "auto_categorize", action.action_type + end + + test "validates resource_type" do + csv = <<~CSV + name,resource_type,active,effective_date,conditions,actions + "Invalid rule","invalid_type",true,,"[{\"condition_type\":\"transaction_name\",\"operator\":\"like\",\"value\":\"test\"}]","[{\"action_type\":\"auto_categorize\"}]" + CSV + + import = @family.imports.create!(type: "RuleImport", raw_file_str: csv, col_sep: ",") + import.generate_rows_from_csv + + assert_raises ActiveRecord::RecordInvalid do + import.send(:import!) + end + end + + test "validates at least one action exists" do + csv = <<~CSV + name,resource_type,active,effective_date,conditions,actions + "No actions rule","transaction",true,,"[{\"condition_type\":\"transaction_name\",\"operator\":\"like\",\"value\":\"test\"}]","[]" + CSV + + import = @family.imports.create!(type: "RuleImport", raw_file_str: csv, col_sep: ",") + import.generate_rows_from_csv + + assert_raises ActiveRecord::RecordInvalid do + import.send(:import!) + end + end + + test "handles invalid JSON in conditions or actions" do + csv = <<~CSV + name,resource_type,active,effective_date,conditions,actions + "Bad JSON rule","transaction",true,,"invalid json","[{\"action_type\":\"auto_categorize\"}]" + CSV + + import = @family.imports.create!(type: "RuleImport", raw_file_str: csv, col_sep: ",") + import.generate_rows_from_csv + + assert_raises ActiveRecord::RecordInvalid do + import.send(:import!) + end + end +end