Files
sure/db/migrate/20260108000000_cleanup_orphaned_currency_balances.rb
2026-01-09 11:54:38 +01:00

94 lines
3.5 KiB
Ruby

# frozen_string_literal: true
# This migration cleans up orphaned balance records that have a currency different
# from their account's current currency. This can happen when linked accounts
# (SimpleFIN, Lunchflow, Enable Banking, Plaid) were created with an initial sync
# before the correct currency was known from the provider.
#
# The fix in Account.create_and_sync with skip_initial_sync: true prevents this
# going forward, but existing data needs to be cleaned up.
class CleanupOrphanedCurrencyBalances < ActiveRecord::Migration[7.2]
def up
# Skip in test environment with empty database (CI)
return say "Skipping in test environment - no data to clean" if Rails.env.test? && account_count.zero?
# First, identify affected accounts for logging
affected_accounts = execute(<<~SQL).to_a
SELECT DISTINCT
a.id,
a.name,
a.currency as account_currency,
b.currency as orphaned_currency,
COUNT(b.id) as orphaned_balance_count
FROM accounts a
JOIN balances b ON a.id = b.account_id
WHERE b.currency != a.currency
AND (
a.simplefin_account_id IS NOT NULL
OR a.plaid_account_id IS NOT NULL
OR EXISTS (SELECT 1 FROM account_providers WHERE account_id = a.id)
)
GROUP BY a.id, a.name, a.currency, b.currency
ORDER BY a.name
SQL
if affected_accounts.any?
say "Found #{affected_accounts.size} account-currency combinations with orphaned balances:"
affected_accounts.each do |row|
say " - #{row['name']}: #{row['orphaned_balance_count']} balances in #{row['orphaned_currency']} (account is #{row['account_currency']})"
end
# Delete orphaned balances where currency doesn't match account currency
# Only for linked accounts (provider-connected accounts)
execute(<<~SQL)
DELETE FROM balances
WHERE id IN (
SELECT b.id
FROM balances b
JOIN accounts a ON b.account_id = a.id
WHERE b.currency != a.currency
AND (
a.simplefin_account_id IS NOT NULL
OR a.plaid_account_id IS NOT NULL
OR EXISTS (SELECT 1 FROM account_providers WHERE account_id = a.id)
)
)
SQL
say "Deleted orphaned balances from linked accounts"
# Get unique account IDs that need re-sync
account_ids = affected_accounts.map { |row| row["id"] }.uniq
# Schedule re-sync for affected accounts to regenerate correct balances
# Only if Account model is available and responds to sync_later
if defined?(Account) && Account.respond_to?(:where)
say "Scheduling re-sync for #{account_ids.size} affected accounts..."
Account.where(id: account_ids).find_each do |account|
account.sync_later if account.respond_to?(:sync_later)
end
say "Scheduled re-sync for #{account_ids.size} affected accounts"
else
say "Skipping re-sync scheduling (Account model not available)"
say "Please manually sync affected accounts: #{account_ids.join(', ')}"
end
else
say "No orphaned currency balances found - database is clean"
end
end
def down
say "This migration cannot be fully reversed."
say "The deleted balances will be regenerated by the scheduled syncs."
say "If syncs haven't run yet, you may need to manually trigger them."
end
private
def account_count
execute("SELECT COUNT(*) FROM accounts").first["count"].to_i
rescue
0
end
end