require "zip" require "csv" class Family::DataExporter def initialize(family) @family = family end def generate_export # Create a StringIO to hold the zip data in memory zip_data = Zip::OutputStream.write_buffer do |zipfile| # Add accounts.csv zipfile.put_next_entry("accounts.csv") zipfile.write generate_accounts_csv # Add transactions.csv zipfile.put_next_entry("transactions.csv") zipfile.write generate_transactions_csv # Add trades.csv zipfile.put_next_entry("trades.csv") zipfile.write generate_trades_csv # Add categories.csv 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 end # Rewind and return the StringIO zip_data.rewind zip_data end private def generate_accounts_csv CSV.generate do |csv| csv << [ "id", "name", "type", "subtype", "balance", "currency", "created_at" ] # Only export accounts belonging to this family @family.accounts.includes(:accountable).find_each do |account| csv << [ account.id, account.name, account.accountable_type, account.subtype, account.balance.to_s, account.currency, account.created_at.iso8601 ] end end end def generate_transactions_csv CSV.generate do |csv| csv << [ "date", "account_name", "amount", "name", "category", "tags", "notes", "currency" ] # Only export transactions from accounts belonging to this family # Exclude split parents (export children instead) exportable_transactions .includes(:category, :tags, entry: :account) .find_each do |transaction| csv << [ transaction.entry.date.iso8601, transaction.entry.account.name, transaction.entry.amount.to_s, transaction.entry.name, transaction.category&.name, transaction.tags.pluck(:name).join(","), transaction.entry.notes, transaction.entry.currency ] end end end def generate_trades_csv CSV.generate do |csv| csv << [ "date", "account_name", "ticker", "quantity", "price", "amount", "currency" ] # Only export trades from accounts belonging to this family @family.trades .includes(:security, entry: :account) .find_each do |trade| csv << [ trade.entry.date.iso8601, trade.entry.account.name, trade.security.ticker, trade.qty.to_s, trade.price.to_s, trade.entry.amount.to_s, trade.currency ] end end end def generate_categories_csv CSV.generate do |csv| csv << [ "name", "color", "parent_category", "lucide_icon" ] # Only export categories belonging to this family @family.categories.includes(:parent).find_each do |category| csv << [ category.name, category.color, category.parent&.name, category.lucide_icon ] end 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 = [] # Export accounts with full accountable data @family.accounts.includes(:accountable).find_each do |account| lines << { type: "Account", data: account.as_json( include: { accountable: {} } ) }.to_json end Balance.joins(:account) .where(accounts: { family_id: @family.id }) .chronological .each do |balance| lines << { type: "Balance", data: { id: balance.id, account_id: balance.account_id, date: balance.date, balance: balance.balance, currency: balance.currency, cash_balance: balance.cash_balance, start_cash_balance: balance.start_cash_balance, start_non_cash_balance: balance.start_non_cash_balance, cash_inflows: balance.cash_inflows, cash_outflows: balance.cash_outflows, non_cash_inflows: balance.non_cash_inflows, non_cash_outflows: balance.non_cash_outflows, net_market_flows: balance.net_market_flows, cash_adjustments: balance.cash_adjustments, non_cash_adjustments: balance.non_cash_adjustments, flows_factor: balance.flows_factor, start_balance: balance.start_balance, end_cash_balance: balance.end_cash_balance, end_non_cash_balance: balance.end_non_cash_balance, end_balance: balance.end_balance, created_at: balance.created_at, updated_at: balance.updated_at } }.to_json end # Export categories @family.categories.find_each do |category| lines << { type: "Category", data: category.as_json }.to_json end # Export tags @family.tags.find_each do |tag| lines << { type: "Tag", data: tag.as_json }.to_json end # Export merchants (only family merchants) @family.merchants.find_each do |merchant| lines << { type: "Merchant", data: merchant.as_json }.to_json end # Export recurring transactions after accounts and merchants so import can remap dependencies. @family.recurring_transactions.includes(:account, :merchant).find_each do |recurring_transaction| lines << { type: "RecurringTransaction", data: serialize_recurring_transaction_for_export(recurring_transaction) }.to_json end # Export transactions with full data (exclude split parents, export children instead) exportable_transactions.includes(:category, :merchant, :tags, entry: :account).find_each do |transaction| lines << { type: "Transaction", data: { id: transaction.id, entry_id: transaction.entry.id, account_id: transaction.entry.account_id, date: transaction.entry.date, amount: transaction.entry.amount, currency: transaction.entry.currency, name: transaction.entry.name, notes: transaction.entry.notes, excluded: transaction.entry.excluded, category_id: transaction.category_id, merchant_id: transaction.merchant_id, tag_ids: transaction.tag_ids, kind: transaction.kind, created_at: transaction.created_at, updated_at: transaction.updated_at } }.to_json end # Export transfer decisions after transactions so import can remap both sides. family_transfers.find_each do |transfer| lines << { type: "Transfer", data: { id: transfer.id, inflow_transaction_id: transfer.inflow_transaction_id, outflow_transaction_id: transfer.outflow_transaction_id, status: transfer.status, notes: transfer.notes, created_at: transfer.created_at, updated_at: transfer.updated_at } }.to_json end family_rejected_transfers.find_each do |rejected_transfer| lines << { type: "RejectedTransfer", data: { id: rejected_transfer.id, inflow_transaction_id: rejected_transfer.inflow_transaction_id, outflow_transaction_id: rejected_transfer.outflow_transaction_id, created_at: rejected_transfer.created_at, updated_at: rejected_transfer.updated_at } }.to_json end # Export trades with full data @family.trades.includes(:security, entry: :account).find_each do |trade| lines << { type: "Trade", data: { id: trade.id, entry_id: trade.entry.id, account_id: trade.entry.account_id, security_id: trade.security_id, ticker: trade.security.ticker, security_name: trade.security.name, exchange_operating_mic: trade.security.exchange_operating_mic, date: trade.entry.date, qty: trade.qty, price: trade.price, amount: trade.entry.amount, currency: trade.currency, created_at: trade.created_at, updated_at: trade.updated_at } }.to_json end # Export holding snapshots for backup and portfolio verification. @family.holdings.includes(:account, :security).find_each do |holding| lines << { type: "Holding", data: { id: holding.id, account_id: holding.account_id, security_id: holding.security_id, ticker: holding.security.ticker, security_name: holding.security.name, exchange_operating_mic: holding.security.exchange_operating_mic, exchange_mic: holding.security.exchange_mic, exchange_acronym: holding.security.exchange_acronym, country_code: holding.security.country_code, kind: holding.security.kind, website_url: holding.security.website_url, date: holding.date, qty: holding.qty, price: holding.price, amount: holding.amount, currency: holding.currency, cost_basis: holding.cost_basis, cost_basis_source: holding.cost_basis_source, cost_basis_locked: holding.cost_basis_locked, security_locked: holding.security_locked } }.to_json end # Export valuations @family.entries.valuations.includes(:account, :entryable).find_each do |entry| lines << { type: "Valuation", data: { id: entry.entryable.id, entry_id: entry.id, account_id: entry.account_id, date: entry.date, amount: entry.amount, currency: entry.currency, name: entry.name, kind: entry.entryable.kind, created_at: entry.created_at, updated_at: entry.updated_at } }.to_json end # Export budgets @family.budgets.find_each do |budget| lines << { type: "Budget", data: budget.as_json }.to_json end # Export budget categories @family.budget_categories.includes(:budget, :category).find_each do |budget_category| lines << { type: "BudgetCategory", data: budget_category.as_json }.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 exportable_transactions @family.transactions.merge(Entry.excluding_split_parents) end def family_transaction_ids @family_transaction_ids ||= exportable_transactions.select(:id) end def family_transfers Transfer.where( inflow_transaction_id: family_transaction_ids, outflow_transaction_id: family_transaction_ids ) end def family_rejected_transfers RejectedTransfer.where( inflow_transaction_id: family_transaction_ids, outflow_transaction_id: family_transaction_ids ) end def serialize_recurring_transaction_for_export(recurring_transaction) { id: recurring_transaction.id, account_id: recurring_transaction.account_id, merchant_id: recurring_transaction.merchant_id, amount: recurring_transaction.amount, currency: recurring_transaction.currency, expected_day_of_month: recurring_transaction.expected_day_of_month, last_occurrence_date: recurring_transaction.last_occurrence_date, next_expected_date: recurring_transaction.next_expected_date, status: recurring_transaction.status, occurrence_count: recurring_transaction.occurrence_count, name: recurring_transaction.name, manual: recurring_transaction.manual, expected_amount_min: recurring_transaction.expected_amount_min, expected_amount_max: recurring_transaction.expected_amount_max, expected_amount_avg: recurring_transaction.expected_amount_avg, created_at: recurring_transaction.created_at, updated_at: recurring_transaction.updated_at } 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