require "test_helper" class Family::DataImporterTest < ActiveSupport::TestCase setup do @family = families(:empty) end test "imports accounts with accountable data" do ndjson = build_ndjson([ { type: "Account", data: { id: "old-account-1", name: "Test Checking", balance: "1500.00", currency: "USD", accountable_type: "Depository", accountable: { subtype: "checking" } } } ]) importer = Family::DataImporter.new(@family, ndjson) result = importer.import! assert_equal 1, result[:accounts].count account = result[:accounts].first assert_equal "Test Checking", account.name assert_equal 1500.0, account.balance.to_f assert_equal "USD", account.currency assert_equal "Depository", account.accountable_type end test "imports non-destructive account status from ndjson" do ndjson = build_ndjson([ { type: "Account", data: { id: "disabled-account", name: "Closed Checking", balance: "0.00", currency: "USD", accountable_type: "Depository", status: "disabled" } } ]) importer = Family::DataImporter.new(@family, ndjson) result = importer.import! account = result[:accounts].first assert_equal "Closed Checking", account.name assert_equal "disabled", account.status end test "does not import pending deletion account status" do ndjson = build_ndjson([ { type: "Account", data: { id: "pending-delete-account", name: "Pending Delete Checking", balance: "0.00", currency: "USD", accountable_type: "Depository", status: "pending_deletion" } } ]) importer = Family::DataImporter.new(@family, ndjson) result = importer.import! account = result[:accounts].first assert_equal "Pending Delete Checking", account.name assert_equal "active", account.status end test "dates synthesized account opening balance before oldest imported activity" do ndjson = build_ndjson([ { type: "Account", data: { id: "acct-1", name: "Main Account", balance: "5000", currency: "USD", accountable_type: "Depository" } }, { type: "Transaction", data: { id: "txn-1", account_id: "acct-1", date: "2020-04-02", amount: "-50.00", name: "Grocery Store", currency: "USD" } } ]) Family::DataImporter.new(@family, ndjson).import! account = @family.accounts.find_by!(name: "Main Account") opening_anchor = account.valuations.opening_anchor.first assert_not_nil opening_anchor assert_equal Date.parse("2020-04-01"), opening_anchor.entry.date end test "clamps explicit account opening balance dates before imported activity" do ndjson = build_ndjson([ { type: "Account", data: { id: "acct-1", name: "Main Account", balance: "5000", currency: "USD", accountable_type: "Depository", opening_balance_date: "2020-04-02" } }, { type: "Transaction", data: { id: "txn-1", account_id: "acct-1", date: "2020-04-02", amount: "-50.00", name: "Grocery Store", currency: "USD" } } ]) Family::DataImporter.new(@family, ndjson).import! account = @family.accounts.find_by!(name: "Main Account") opening_anchor = account.valuations.opening_anchor.first assert_not_nil opening_anchor assert_equal Date.parse("2020-04-01"), opening_anchor.entry.date end test "imports explicit opening anchor valuations without synthesizing duplicates" do ndjson = build_ndjson([ { type: "Account", data: { id: "acct-1", name: "Main Account", balance: "5000", currency: "USD", accountable_type: "Depository" } }, { type: "Valuation", data: { id: "val-opening", account_id: "acct-1", date: "2020-04-01", amount: "5000", name: "Opening balance", currency: "USD", kind: "opening_anchor" } } ]) Family::DataImporter.new(@family, ndjson).import! account = @family.accounts.find_by!(name: "Main Account") opening_anchors = account.valuations.opening_anchor.to_a assert_equal 1, opening_anchors.count assert_equal Date.parse("2020-04-01"), opening_anchors.first.entry.date assert_equal 5000.0, opening_anchors.first.entry.amount.to_f end test "imports categories with parent relationships" do ndjson = build_ndjson([ { type: "Category", data: { id: "cat-parent", name: "Shopping", color: "#FF5733", classification: "expense" } }, { type: "Category", data: { id: "cat-child", name: "Groceries", color: "#33FF57", classification: "expense", parent_id: "cat-parent" } } ]) importer = Family::DataImporter.new(@family, ndjson) importer.import! parent = @family.categories.find_by(name: "Shopping") child = @family.categories.find_by(name: "Groceries") assert_not_nil parent assert_not_nil child assert_equal parent.id, child.parent_id end test "imports tags" do ndjson = build_ndjson([ { type: "Tag", data: { id: "tag-1", name: "Important", color: "#FF0000" } } ]) importer = Family::DataImporter.new(@family, ndjson) importer.import! tag = @family.tags.find_by(name: "Important") assert_not_nil tag assert_equal "#FF0000", tag.color end test "imports merchants" do ndjson = build_ndjson([ { type: "Merchant", data: { id: "merchant-1", name: "Amazon" } } ]) importer = Family::DataImporter.new(@family, ndjson) importer.import! merchant = @family.merchants.find_by(name: "Amazon") assert_not_nil merchant end test "imports recurring transactions with remapped account and merchant references" do ndjson = build_ndjson([ { type: "Account", data: { id: "acct-1", name: "Main Checking", balance: "5000", currency: "USD", accountable_type: "Depository" } }, { type: "Merchant", data: { id: "merchant-1", name: "Internet Provider" } }, { type: "RecurringTransaction", data: { id: "recurring-1", account_id: "acct-1", merchant_id: "merchant-1", amount: "-89.99", currency: "USD", expected_day_of_month: 14, last_occurrence_date: "2024-01-14", next_expected_date: "2024-02-14", status: "active", occurrence_count: 6, manual: true, expected_amount_min: "-95.00", expected_amount_max: "-85.00", expected_amount_avg: "-89.99" } } ]) Family::DataImporter.new(@family, ndjson).import! recurring_transaction = @family.recurring_transactions.first assert_not_nil recurring_transaction assert_equal "Main Checking", recurring_transaction.account.name assert_equal "Internet Provider", recurring_transaction.merchant.name assert_equal(-89.99, recurring_transaction.amount.to_f) assert_equal "USD", recurring_transaction.currency assert_equal 14, recurring_transaction.expected_day_of_month assert_equal Date.parse("2024-01-14"), recurring_transaction.last_occurrence_date assert_equal Date.parse("2024-02-14"), recurring_transaction.next_expected_date assert_equal "active", recurring_transaction.status assert_equal 6, recurring_transaction.occurrence_count assert_equal true, recurring_transaction.manual assert_equal(-95.0, recurring_transaction.expected_amount_min.to_f) assert_equal(-85.0, recurring_transaction.expected_amount_max.to_f) assert_equal(-89.99, recurring_transaction.expected_amount_avg.to_f) end test "imports recurring transactions with unknown status fallback" do ndjson = build_ndjson([ { type: "Account", data: { id: "acct-1", name: "Main Checking", balance: "5000", currency: "USD", accountable_type: "Depository" } }, { type: "Merchant", data: { id: "merchant-1", name: "Streaming Service" } }, { type: "RecurringTransaction", data: { id: "recurring-1", account_id: "acct-1", merchant_id: "merchant-1", amount: "-15.99", currency: "USD", expected_day_of_month: "8", last_occurrence_date: "2024-01-08", next_expected_date: "2024-02-08", status: "paused", occurrence_count: 2 } } ]) Family::DataImporter.new(@family, ndjson).import! recurring_transaction = @family.recurring_transactions.first assert_not_nil recurring_transaction assert_equal 8, recurring_transaction.expected_day_of_month assert_equal Date.parse("2024-01-08"), recurring_transaction.last_occurrence_date assert_equal Date.parse("2024-02-08"), recurring_transaction.next_expected_date assert_equal "active", recurring_transaction.status end test "skips recurring transactions with missing recurrence dates" do ndjson = build_ndjson([ { type: "RecurringTransaction", data: { id: "recurring-1", amount: "-15.99", currency: "USD", expected_day_of_month: "8", last_occurrence_date: nil, status: "active", occurrence_count: 2, name: "Streaming Service" } } ]) Family::DataImporter.new(@family, ndjson).import! assert_equal 0, @family.recurring_transactions.count end test "skips recurring transactions when referenced account is missing" do ndjson = build_ndjson([ { type: "RecurringTransaction", data: { id: "recurring-1", account_id: "missing-account", amount: "-89.99", currency: "USD", expected_day_of_month: 14, last_occurrence_date: "2024-01-14", next_expected_date: "2024-02-14", status: "active", name: "Internet Provider" } } ]) Family::DataImporter.new(@family, ndjson).import! assert_equal 0, @family.recurring_transactions.count end test "skips recurring transactions with blank expected day" do ndjson = build_ndjson([ { type: "RecurringTransaction", data: { id: "recurring-1", amount: "-89.99", currency: "USD", expected_day_of_month: "", status: "active", name: "Internet Provider" } } ]) Family::DataImporter.new(@family, ndjson).import! assert_equal 0, @family.recurring_transactions.count end test "imports transactions with references" do ndjson = build_ndjson([ { type: "Account", data: { id: "acct-1", name: "Main Account", balance: "5000", currency: "USD", accountable_type: "Depository" } }, { type: "Category", data: { id: "cat-1", name: "Food", color: "#FF0000", classification: "expense" } }, { type: "Tag", data: { id: "tag-1", name: "Essential" } }, { type: "Transaction", data: { id: "txn-1", account_id: "acct-1", date: "2024-01-15", amount: "-50.00", name: "Grocery Store", currency: "USD", category_id: "cat-1", tag_ids: [ "tag-1" ], notes: "Weekly groceries" } } ]) importer = Family::DataImporter.new(@family, ndjson) result = importer.import! assert_equal 1, result[:entries].count transaction = @family.transactions.first assert_not_nil transaction assert_equal "Grocery Store", transaction.entry.name assert_equal -50.0, transaction.entry.amount.to_f assert_equal "Food", transaction.category.name assert_equal 1, transaction.tags.count assert_equal "Essential", transaction.tags.first.name assert_equal "Weekly groceries", transaction.entry.notes end test "imports trades with securities" do ndjson = build_ndjson([ { type: "Account", data: { id: "inv-acct-1", name: "Investment Account", balance: "10000", currency: "USD", accountable_type: "Investment" } }, { type: "Trade", data: { id: "trade-1", account_id: "inv-acct-1", date: "2024-01-15", ticker: "AAPL", qty: "10", price: "150.00", amount: "-1500.00", currency: "USD" } } ]) importer = Family::DataImporter.new(@family, ndjson) result = importer.import! # Account + Opening balance + Trade entry assert_equal 1, result[:entries].count trade = @family.trades.first assert_not_nil trade assert_equal "AAPL", trade.security.ticker assert_equal 10.0, trade.qty.to_f assert_equal 150.0, trade.price.to_f end test "imports holding snapshots with security identity" do ndjson = build_ndjson([ { type: "Account", data: { id: "inv-acct-1", name: "Investment Account", balance: "10000", currency: "USD", accountable_type: "Investment" } }, { type: "Holding", data: { id: "holding-1", account_id: "inv-acct-1", security_id: "security-1", ticker: "VTI", security_name: "Vanguard Total Stock Market ETF", exchange_operating_mic: "ARCX", country_code: "US", date: "2024-01-15", qty: "100", price: "250.25", amount: "25025.00", currency: "USD", cost_basis: "200.00", cost_basis_source: "manual", cost_basis_locked: true, security_locked: true } } ]) Family::DataImporter.new(@family, ndjson).import! account = @family.accounts.find_by!(name: "Investment Account") holding = account.holdings.first assert_not_nil holding assert_equal Date.parse("2024-01-15"), holding.date assert_equal "VTI", holding.security.ticker assert_equal "Vanguard Total Stock Market ETF", holding.security.name assert_equal "ARCX", holding.security.exchange_operating_mic assert_equal 100.0, holding.qty.to_f assert_equal 250.25, holding.price.to_f assert_equal 25_025.0, holding.amount.to_f assert_equal 200.0, holding.cost_basis.to_f assert_equal "manual", holding.cost_basis_source assert holding.cost_basis_locked assert holding.security_locked opening_anchor = account.valuations.opening_anchor.first assert_equal Date.parse("2024-01-14"), opening_anchor.entry.date end test "imports duplicate holding snapshots idempotently by account security date and currency" do holding_record = { type: "Holding", data: { id: "holding-1", account_id: "inv-acct-1", security_id: "security-1", ticker: "VTI", security_name: "Vanguard Total Stock Market ETF", exchange_operating_mic: "ARCX", kind: "unsupported", date: "2024-01-15", qty: "100", price: "250.25", amount: "25025.00", currency: "USD" } } ndjson = build_ndjson([ { type: "Account", data: { id: "inv-acct-1", name: "Investment Account", balance: "10000", currency: "USD", accountable_type: "Investment" } }, holding_record, holding_record.deep_merge(data: { id: "holding-1-duplicate", qty: "101", amount: "25275.25" }) ]) Family::DataImporter.new(@family, ndjson).import! account = @family.accounts.find_by!(name: "Investment Account") assert_equal 1, account.holdings.count holding = account.holdings.first assert_equal 101.0, holding.qty.to_f assert_equal 25_275.25, holding.amount.to_f assert_equal "standard", holding.security.kind end test "imports same holding date in different currencies separately" do holding_record = { type: "Holding", data: { id: "holding-1", account_id: "inv-acct-1", security_id: "security-1", ticker: "VTI", security_name: "Vanguard Total Stock Market ETF", exchange_operating_mic: "ARCX", date: "2024-01-15", qty: "100", price: "250.25", amount: "25025.00", currency: "USD" } } ndjson = build_ndjson([ { type: "Account", data: { id: "inv-acct-1", name: "Investment Account", balance: "10000", currency: "USD", accountable_type: "Investment" } }, holding_record, holding_record.deep_merge(data: { id: "holding-2", currency: "CAD", amount: "34034.00" }) ]) Family::DataImporter.new(@family, ndjson).import! account = @family.accounts.find_by!(name: "Investment Account") assert_equal 2, account.holdings.count assert_equal %w[CAD USD], account.holdings.order(:currency).pluck(:currency) end test "round trips holding snapshots through full export" do source_family = Family.create!( name: "Source Family", currency: "USD", locale: "en", date_format: "%Y-%m-%d" ) source_account = source_family.accounts.create!( name: "Round Trip Investment", accountable: Investment.new, balance: 25_000, currency: "USD" ) source_security = Security.create!( ticker: "VTI#{SecureRandom.hex(4).upcase}", name: "Vanguard Total Stock Market ETF", country_code: "US", exchange_operating_mic: "ARCX" ) source_account.holdings.create!( security: source_security, date: Date.parse("2024-01-15"), qty: 100, price: 250.25, amount: 25_025, currency: "USD", cost_basis: 200, cost_basis_source: "manual", cost_basis_locked: true, security_locked: true ) zip_data = Family::DataExporter.new(source_family).generate_export ndjson = nil Zip::File.open_buffer(zip_data) do |zip| ndjson = zip.read("all.ndjson") end Family::DataImporter.new(@family, ndjson).import! imported_account = @family.accounts.find_by!(name: "Round Trip Investment") imported_holding = imported_account.holdings.find_by!(date: Date.parse("2024-01-15")) assert_equal source_security.ticker, imported_holding.security.ticker assert_equal "ARCX", imported_holding.security.exchange_operating_mic assert_equal 100.0, imported_holding.qty.to_f assert_equal 250.25, imported_holding.price.to_f assert_equal 25_025.0, imported_holding.amount.to_f assert_equal 200.0, imported_holding.cost_basis.to_f assert_equal "manual", imported_holding.cost_basis_source assert imported_holding.cost_basis_locked assert imported_holding.security_locked end test "imports holding snapshots with ticker fallback when exchange mic is missing" do existing_security = Security.create!( ticker: "VTI", name: "Existing VTI", exchange_operating_mic: "ARCX" ) ndjson = build_ndjson([ { type: "Account", data: { id: "inv-acct-1", name: "Investment Account", balance: "10000", currency: "USD", accountable_type: "Investment" } }, { type: "Holding", data: { id: "holding-1", account_id: "inv-acct-1", ticker: "VTI", security_name: "Imported VTI", date: "2024-01-15", qty: "100", price: "250.25", amount: "25025.00", currency: "USD", cost_basis_locked: false, security_locked: false } } ]) Family::DataImporter.new(@family, ndjson).import! holding = @family.accounts.find_by!(name: "Investment Account").holdings.first assert_equal existing_security, holding.security assert_equal 1, Security.where(ticker: "VTI").count end test "updates cached security with safe holding metadata" do ndjson = build_ndjson([ { type: "Account", data: { id: "inv-acct-1", name: "Investment Account", balance: "10000", currency: "USD", accountable_type: "Investment" } }, { type: "Trade", data: { id: "trade-1", account_id: "inv-acct-1", security_id: "security-1", ticker: "VTI", date: "2024-01-10", qty: "10", price: "250.00", amount: "-2500.00", currency: "USD" } }, { type: "Holding", data: { id: "holding-1", account_id: "inv-acct-1", security_id: "security-1", ticker: "VTI", security_name: "Vanguard Total Stock Market ETF", exchange_operating_mic: "ARCX", country_code: "US", website_url: "https://investor.vanguard.com", date: "2024-01-15", qty: "100", price: "250.25", amount: "25025.00", currency: "USD", cost_basis_locked: false, security_locked: false } } ]) Family::DataImporter.new(@family, ndjson).import! security = @family.holdings.first.security assert_equal "Vanguard Total Stock Market ETF", security.name assert_equal "ARCX", security.exchange_operating_mic assert_equal "US", security.country_code assert_equal "https://investor.vanguard.com", security.website_url end test "imports valuations" do ndjson = build_ndjson([ { type: "Account", data: { id: "prop-acct-1", name: "Property", balance: "500000", currency: "USD", accountable_type: "Property" } }, { type: "Valuation", data: { id: "val-1", account_id: "prop-acct-1", date: "2024-06-15", amount: "520000", name: "Updated valuation", currency: "USD" } } ]) importer = Family::DataImporter.new(@family, ndjson) result = importer.import! assert_equal 1, result[:entries].count account = @family.accounts.find_by(name: "Property") valuation = account.valuations.joins(:entry).find_by(entries: { name: "Updated valuation" }) assert_not_nil valuation assert_equal 520000.0, valuation.entry.amount.to_f end test "imports unknown valuation kinds as reconciliations" do ndjson = build_ndjson([ { type: "Account", data: { id: "prop-acct-1", name: "Property", balance: "500000", currency: "USD", accountable_type: "Property" } }, { type: "Valuation", data: { id: "val-1", account_id: "prop-acct-1", date: "2024-06-15", amount: "520000", name: "Updated valuation", currency: "USD", kind: "legacy_unknown_kind" } } ]) Family::DataImporter.new(@family, ndjson).import! account = @family.accounts.find_by!(name: "Property") valuation = account.valuations.joins(:entry).find_by!(entries: { name: "Updated valuation" }) assert_equal "reconciliation", valuation.kind end test "imports transfer decisions and rejected transfers with remapped transactions" do ndjson = build_ndjson([ { type: "Account", data: { id: "checking", name: "Checking", balance: "1000", currency: "USD", accountable_type: "Depository" } }, { type: "Account", data: { id: "savings", name: "Savings", balance: "2500", currency: "USD", accountable_type: "Depository" } }, { type: "Transaction", data: { id: "transfer-outflow", account_id: "checking", date: "2024-01-15", amount: "100.00", name: "Transfer to savings", currency: "USD", kind: "funds_movement" } }, { type: "Transaction", data: { id: "transfer-inflow", account_id: "savings", date: "2024-01-15", amount: "-100.00", name: "Transfer from checking", currency: "USD", kind: "funds_movement" } }, { type: "Transfer", data: { id: "transfer-1", inflow_transaction_id: "transfer-inflow", outflow_transaction_id: "transfer-outflow", status: "confirmed", notes: "Confirmed by user" } }, { type: "Transaction", data: { id: "rejected-outflow", account_id: "checking", date: "2024-01-20", amount: "25.00", name: "Candidate outflow", currency: "USD", kind: "standard" } }, { type: "Transaction", data: { id: "rejected-inflow", account_id: "savings", date: "2024-01-20", amount: "-25.00", name: "Candidate inflow", currency: "USD", kind: "standard" } }, { type: "RejectedTransfer", data: { id: "rejected-transfer-1", inflow_transaction_id: "rejected-inflow", outflow_transaction_id: "rejected-outflow" } } ]) Family::DataImporter.new(@family, ndjson).import! transfer = Transfer.find_by!(notes: "Confirmed by user") assert_not_nil transfer assert_equal "confirmed", transfer.status assert_equal "Confirmed by user", transfer.notes assert_equal "Transfer from checking", transfer.inflow_transaction.entry.name assert_equal "Transfer to savings", transfer.outflow_transaction.entry.name rejected_transfer = RejectedTransfer .joins(inflow_transaction: :entry) .find_by!(entries: { name: "Candidate inflow" }) assert_not_nil rejected_transfer assert_equal "Candidate inflow", rejected_transfer.inflow_transaction.entry.name assert_equal "Candidate outflow", rejected_transfer.outflow_transaction.entry.name end test "imports duplicate transfer decisions idempotently with unknown status fallback" do ndjson = build_ndjson([ { type: "Account", data: { id: "checking", name: "Checking", balance: "1000", currency: "USD", accountable_type: "Depository" } }, { type: "Account", data: { id: "savings", name: "Savings", balance: "2500", currency: "USD", accountable_type: "Depository" } }, { type: "Transaction", data: { id: "transfer-outflow", account_id: "checking", date: "2024-01-15", amount: "100.00", name: "Transfer to savings", currency: "USD", kind: "funds_movement" } }, { type: "Transaction", data: { id: "transfer-inflow", account_id: "savings", date: "2024-01-15", amount: "-100.00", name: "Transfer from checking", currency: "USD", kind: "funds_movement" } }, { type: "Transfer", data: { id: "transfer-1", inflow_transaction_id: "transfer-inflow", outflow_transaction_id: "transfer-outflow", status: "settled" } }, { type: "Transfer", data: { id: "transfer-1-duplicate", inflow_transaction_id: "transfer-inflow", outflow_transaction_id: "transfer-outflow", status: "settled" } } ]) fallback_logs = [] Rails.logger.stubs(:debug).with do |*args| message = args.first fallback_logs << message if message.to_s.include?("Unknown transfer status") true end assert_difference("Transfer.count", 1) do Family::DataImporter.new(@family, ndjson).import! end assert_equal [ 'Unknown transfer status "settled"; defaulting to pending' ], fallback_logs imported_transfer = Transfer .joins(inflow_transaction: :entry) .find_by!(entries: { name: "Transfer from checking" }) assert_equal "pending", imported_transfer.status end test "imports budgets" do ndjson = build_ndjson([ { type: "Budget", data: { id: "budget-1", start_date: "2024-01-01", end_date: "2024-01-31", budgeted_spending: "3000.00", expected_income: "5000.00", currency: "USD" } } ]) importer = Family::DataImporter.new(@family, ndjson) importer.import! budget = @family.budgets.first assert_not_nil budget assert_equal Date.parse("2024-01-01"), budget.start_date assert_equal Date.parse("2024-01-31"), budget.end_date assert_equal 3000.0, budget.budgeted_spending.to_f assert_equal 5000.0, budget.expected_income.to_f end test "imports budget_categories" do ndjson = build_ndjson([ { type: "Category", data: { id: "cat-groceries", name: "Groceries", color: "#00FF00", classification: "expense" } }, { type: "Budget", data: { id: "budget-1", start_date: "2024-01-01", end_date: "2024-01-31", budgeted_spending: "3000.00", expected_income: "5000.00", currency: "USD" } }, { type: "BudgetCategory", data: { id: "bc-1", budget_id: "budget-1", category_id: "cat-groceries", budgeted_spending: "500.00", currency: "USD" } } ]) importer = Family::DataImporter.new(@family, ndjson) importer.import! budget = @family.budgets.first budget_category = budget.budget_categories.first assert_not_nil budget_category assert_equal "Groceries", budget_category.category.name assert_equal 500.0, budget_category.budgeted_spending.to_f end test "imports rules with conditions and actions" do ndjson = build_ndjson([ { type: "Rule", version: 1, data: { name: "Categorize Coffee", resource_type: "transaction", active: true, conditions: [ { condition_type: "transaction_name", operator: "like", value: "starbucks" } ], actions: [ { action_type: "set_transaction_category", value: "Coffee" } ] } } ]) importer = Family::DataImporter.new(@family, ndjson) importer.import! rule = @family.rules.find_by(name: "Categorize Coffee") assert_not_nil rule assert rule.active assert_equal "transaction", rule.resource_type assert_equal 1, rule.conditions.count condition = rule.conditions.first assert_equal "transaction_name", condition.condition_type assert_equal "like", condition.operator assert_equal "starbucks", condition.value assert_equal 1, rule.actions.count action = rule.actions.first assert_equal "set_transaction_category", action.action_type # Category should be created category = @family.categories.find_by(name: "Coffee") assert_not_nil category assert_equal category.id, action.value end test "imports rules with compound conditions" do ndjson = build_ndjson([ { type: "Rule", version: 1, data: { name: "Compound Rule", resource_type: "transaction", active: true, conditions: [ { condition_type: "compound", operator: "or", sub_conditions: [ { condition_type: "transaction_name", operator: "like", value: "walmart" }, { condition_type: "transaction_name", operator: "like", value: "target" } ] } ], actions: [ { action_type: "auto_categorize" } ] } } ]) importer = Family::DataImporter.new(@family, ndjson) importer.import! rule = @family.rules.find_by(name: "Compound Rule") assert_not_nil rule parent_condition = rule.conditions.first assert_equal "compound", parent_condition.condition_type assert_equal "or", parent_condition.operator assert_equal 2, parent_condition.sub_conditions.count end test "skips invalid records gracefully" do ndjson = "not valid json\n" + build_ndjson([ { type: "Account", data: { id: "valid-acct", name: "Valid Account", balance: "1000", currency: "USD", accountable_type: "Depository" } } ]) importer = Family::DataImporter.new(@family, ndjson) result = importer.import! assert_equal 1, result[:accounts].count assert_equal "Valid Account", result[:accounts].first.name end test "skips unsupported record types" do ndjson = build_ndjson([ { type: "UnsupportedType", data: { id: "unknown" } }, { type: "Account", data: { id: "valid-acct", name: "Known Account", balance: "1000", currency: "USD", accountable_type: "Depository" } } ]) importer = Family::DataImporter.new(@family, ndjson) result = importer.import! assert_equal 1, result[:accounts].count end test "full import scenario with all entity types" do ndjson = build_ndjson([ # Account { type: "Account", data: { id: "acct-main", name: "Main Checking", balance: "5000", currency: "USD", accountable_type: "Depository" } }, # Category { type: "Category", data: { id: "cat-food", name: "Food", color: "#FF5733", classification: "expense" } }, # Tag { type: "Tag", data: { id: "tag-weekly", name: "Weekly" } }, # Merchant { type: "Merchant", data: { id: "merchant-1", name: "Local Grocery" } }, # Transaction { type: "RecurringTransaction", data: { id: "recurring-grocery", account_id: "acct-main", merchant_id: "merchant-1", amount: "-75.50", currency: "USD", expected_day_of_month: 15, last_occurrence_date: "2024-01-15", next_expected_date: "2024-02-15", status: "active", occurrence_count: 3, manual: false } }, # Transaction { type: "Transaction", data: { id: "txn-1", account_id: "acct-main", date: "2024-01-15", amount: "-75.50", name: "Weekly groceries", currency: "USD", category_id: "cat-food", merchant_id: "merchant-1", tag_ids: [ "tag-weekly" ] } }, # Budget { type: "Budget", data: { id: "budget-jan", start_date: "2024-01-01", end_date: "2024-01-31", budgeted_spending: "2000", expected_income: "4000", currency: "USD" } }, # BudgetCategory { type: "BudgetCategory", data: { id: "bc-food", budget_id: "budget-jan", category_id: "cat-food", budgeted_spending: "500", currency: "USD" } }, # Rule { type: "Rule", version: 1, data: { name: "Auto-tag groceries", resource_type: "transaction", active: true, conditions: [ { condition_type: "transaction_name", operator: "like", value: "grocery" } ], actions: [ { action_type: "set_transaction_tags", value: "Weekly" } ] } } ]) importer = Family::DataImporter.new(@family, ndjson) result = importer.import! # Verify all entities were created assert_equal 1, result[:accounts].count assert_equal 1, @family.categories.count assert_equal 1, @family.tags.count assert_equal 1, @family.merchants.count assert_equal 1, @family.recurring_transactions.count assert_equal 1, @family.transactions.count assert_equal 1, @family.budgets.count assert_equal 1, @family.budget_categories.count assert_equal 1, @family.rules.count # Verify relationships transaction = @family.transactions.first assert_equal "Food", transaction.category.name assert_equal "Local Grocery", transaction.merchant.name assert_equal "Weekly", transaction.tags.first.name recurring_transaction = @family.recurring_transactions.first assert_equal "Main Checking", recurring_transaction.account.name assert_equal "Local Grocery", recurring_transaction.merchant.name end private def build_ndjson(records) records.map(&:to_json).join("\n") end end