mirror of
https://github.com/we-promise/sure
synced 2026-04-25 17:15:07 +02:00
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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
330
app/models/rule_import.rb
Normal 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
|
||||
@@ -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>
|
||||
|
||||
15
app/views/import/configurations/_rule_import.html.erb
Normal file
15
app/views/import/configurations/_rule_import.html.erb
Normal 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>
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
5
db/schema.rb
generated
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
238
test/models/rule_import_test.rb
Normal file
238
test/models/rule_import_test.rb
Normal 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
|
||||
Reference in New Issue
Block a user