Add rules import/export support (#424)

* Add full import/export support for rules with versioned JSON schema

This commit implements comprehensive import/export functionality for rules,
allowing users to back up and restore their rule definitions.

Key features:
- Export rules to both CSV and NDJSON formats with versioned schema (v1)
- Import rules from CSV with full support for nested conditions and actions
- UUID to name mapping for categories and merchants for portability
- Support for compound conditions with sub-conditions
- Comprehensive test coverage for export and import functionality
- UI integration for rules import in the imports interface

Technical details:
- Extended Family::DataExporter to generate rules.csv and include rules in all.ndjson
- Created RuleImport model following the existing Import STI pattern
- Added migration for rule-specific columns in import_rows table
- Implemented serialization helpers to map UUIDs to human-readable names
- Added i18n support for the new import option
- Included versioning in NDJSON export to support future schema evolution

The implementation ensures rules can be safely exported from one family
and imported into another, even when category/merchant IDs differ,
by mapping between names and IDs during export/import.

* Fix AR migration version

* Mention support for rules export

* Rabbit suggestion

* Fix tests

* Missed schema.rb

* Fix sample CSV download for rule import

* Fix parsing in Rules import

* Fix tests

* Rule import message i18n

* Export tag names, not UUIDs

* Make sure tags are created if needed at import

* Avoid test errors when running in parallel

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Juan José Mata
2025-12-07 13:20:54 +01:00
committed by GitHub
parent a790009290
commit e5ed946959
14 changed files with 970 additions and 6 deletions

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

330
app/models/rule_import.rb Normal file
View File

@@ -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

View File

@@ -20,7 +20,7 @@
</li>
<li class="flex items-start gap-2">
<%= icon "check", class: "shrink-0 mt-0.5 text-positive" %>
<span>Categories and tags</span>
<span>Categories, tags and rules</span>
</li>
</ul>
</div>

View File

@@ -0,0 +1,15 @@
<%# locals: (import:) %>
<div class="space-y-4">
<p class="text-sm text-secondary"><%= t("import.configurations.rule_import.description") %></p>
<%= styled_form_with model: import,
url: import_configuration_path(import),
scope: :import,
method: :patch,
class: "space-y-3" do |form| %>
<p class="text-sm text-secondary"><%= t("import.configurations.rule_import.process_help") %></p>
<%= form.submit t("import.configurations.rule_import.process_button"), disabled: import.complete? %>
<% end %>
</div>

View File

@@ -105,6 +105,26 @@
</li>
<% end %>
<% if params[:type].nil? || params[:type] == "RuleImport" %>
<li>
<%= button_to imports_path(import: { type: "RuleImport" }), class: "flex items-center justify-between p-4 group cursor-pointer w-full", data: { turbo: false } do %>
<div class="flex items-center gap-2">
<div class="bg-green-500/5 rounded-md w-8 h-8 flex items-center justify-center">
<span class="text-green-500">
<%= icon("workflow", color: "current") %>
</span>
</div>
<span class="text-sm text-primary group-hover:text-secondary">
<%= t(".import_rules") %>
</span>
</div>
<%= icon("chevron-right") %>
<% end %>
<%= render "shared/ruler" %>
</li>
<% end %>
<% if Current.family.accounts.any? && (params[:type].nil? || params[:type] == "MintImport" || params[:type] == "TransactionImport") %>
<li>
<%= button_to imports_path(import: { type: "MintImport" }), class: "flex items-center justify-between p-4 group w-full", data: { turbo: false } do %>

View File

@@ -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

View File

@@ -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

5
db/schema.rb generated
View File

@@ -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

View File

@@ -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

View File

@@ -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