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