feat: scope Mercury account uniqueness to mercury_item (#1032)

* feat: scope Mercury account uniqueness to mercury_item

* feat: extend to all other providers

* fix: add uniqueness test

* fix: lint

* fix: test

* fix: coderabbit comment

* fix: coderabbit comment

* fix: coderabbit comment

* fix: update

* fix: lint

* fix: update

* fix: update
This commit is contained in:
Clayton
2026-03-19 09:17:55 -05:00
committed by GitHub
parent b68c767b34
commit 1191d9f7d8
17 changed files with 461 additions and 117 deletions

View File

@@ -0,0 +1,29 @@
# frozen_string_literal: true
class ScopeMercuryAccountUniquenessToItem < ActiveRecord::Migration[7.2]
def up
# Allow the same Mercury account_id to be linked by different families (different mercury_items).
# Uniqueness is scoped per mercury_item, mirroring simplefin_accounts.
remove_index :mercury_accounts, name: "index_mercury_accounts_on_account_id", if_exists: true
unless index_exists?(:mercury_accounts, [ :mercury_item_id, :account_id ], unique: true, name: "index_mercury_accounts_on_item_and_account_id")
add_index :mercury_accounts,
[ :mercury_item_id, :account_id ],
unique: true,
name: "index_mercury_accounts_on_item_and_account_id"
end
end
def down
if MercuryAccount.group(:account_id).having("COUNT(*) > 1").exists?
raise ActiveRecord::IrreversibleMigration,
"Cannot restore global unique index on mercury_accounts.account_id: " \
"duplicate account_id values exist across mercury_items. " \
"Remove duplicates first before rolling back."
end
remove_index :mercury_accounts, name: "index_mercury_accounts_on_item_and_account_id", if_exists: true
unless index_exists?(:mercury_accounts, :account_id, name: "index_mercury_accounts_on_account_id")
add_index :mercury_accounts, :account_id, name: "index_mercury_accounts_on_account_id", unique: true
end
end
end

View File

@@ -0,0 +1,28 @@
# frozen_string_literal: true
# Scope plaid_accounts uniqueness to plaid_item so the same external account
# can be linked in multiple families. See: https://github.com/we-promise/sure/issues/740
# Class name avoids "Account" to prevent secret-scanner false positive (AWS Access ID pattern)
class ScopePlaidItemUniqueness < ActiveRecord::Migration[7.2]
def up
remove_index :plaid_accounts, name: "index_plaid_accounts_on_plaid_id", if_exists: true
return if index_exists?(:plaid_accounts, [ :plaid_item_id, :plaid_id ], unique: true, name: "index_plaid_accounts_on_item_and_plaid_id")
add_index :plaid_accounts,
[ :plaid_item_id, :plaid_id ],
unique: true,
name: "index_plaid_accounts_on_item_and_plaid_id"
end
def down
if execute("SELECT 1 FROM plaid_accounts WHERE plaid_id IS NOT NULL GROUP BY plaid_id HAVING COUNT(DISTINCT plaid_item_id) > 1 LIMIT 1").any?
raise ActiveRecord::IrreversibleMigration,
"Cannot rollback: cross-item duplicates exist in plaid_accounts. Remove duplicates first."
end
remove_index :plaid_accounts, name: "index_plaid_accounts_on_item_and_plaid_id", if_exists: true
return if index_exists?(:plaid_accounts, :plaid_id, name: "index_plaid_accounts_on_plaid_id")
add_index :plaid_accounts, :plaid_id, name: "index_plaid_accounts_on_plaid_id", unique: true
end
end

View File

@@ -0,0 +1,31 @@
# frozen_string_literal: true
# Scope indexa_capital_accounts uniqueness to indexa_capital_item so the same
# external account can be linked in multiple families. See: https://github.com/we-promise/sure/issues/740
class ScopeIndexaCapitalAccountUniquenessToItem < ActiveRecord::Migration[7.2]
def up
remove_index :indexa_capital_accounts, name: "index_indexa_capital_accounts_on_indexa_capital_account_id", if_exists: true
return if index_exists?(:indexa_capital_accounts, [ :indexa_capital_item_id, :indexa_capital_account_id ], unique: true, name: "index_indexa_capital_accounts_on_item_and_account_id")
add_index :indexa_capital_accounts,
[ :indexa_capital_item_id, :indexa_capital_account_id ],
unique: true,
name: "index_indexa_capital_accounts_on_item_and_account_id",
where: "indexa_capital_account_id IS NOT NULL"
end
def down
if execute("SELECT 1 FROM indexa_capital_accounts WHERE indexa_capital_account_id IS NOT NULL GROUP BY indexa_capital_account_id HAVING COUNT(DISTINCT indexa_capital_item_id) > 1 LIMIT 1").any?
raise ActiveRecord::IrreversibleMigration,
"Cannot rollback: cross-item duplicates exist in indexa_capital_accounts. Remove duplicates first."
end
remove_index :indexa_capital_accounts, name: "index_indexa_capital_accounts_on_item_and_account_id", if_exists: true
return if index_exists?(:indexa_capital_accounts, :indexa_capital_account_id, name: "index_indexa_capital_accounts_on_indexa_capital_account_id")
add_index :indexa_capital_accounts, :indexa_capital_account_id,
name: "index_indexa_capital_accounts_on_indexa_capital_account_id",
unique: true,
where: "indexa_capital_account_id IS NOT NULL"
end
end

View File

@@ -0,0 +1,48 @@
# frozen_string_literal: true
# Scope snaptrade_accounts uniqueness to snaptrade_item so the same external
# account can be linked in multiple families. See: https://github.com/we-promise/sure/issues/740
class ScopeSnaptradeAccountUniquenessToItem < ActiveRecord::Migration[7.2]
def up
remove_index :snaptrade_accounts, name: "index_snaptrade_accounts_on_account_id", if_exists: true
remove_index :snaptrade_accounts, name: "index_snaptrade_accounts_on_snaptrade_account_id", if_exists: true
unless index_exists?(:snaptrade_accounts, [ :snaptrade_item_id, :account_id ], unique: true, name: "index_snaptrade_accounts_on_item_and_account_id")
add_index :snaptrade_accounts,
[ :snaptrade_item_id, :account_id ],
unique: true,
name: "index_snaptrade_accounts_on_item_and_account_id",
where: "account_id IS NOT NULL"
end
unless index_exists?(:snaptrade_accounts, [ :snaptrade_item_id, :snaptrade_account_id ], unique: true, name: "index_snaptrade_accounts_on_item_and_snaptrade_account_id")
add_index :snaptrade_accounts,
[ :snaptrade_item_id, :snaptrade_account_id ],
unique: true,
name: "index_snaptrade_accounts_on_item_and_snaptrade_account_id",
where: "snaptrade_account_id IS NOT NULL"
end
end
def down
if execute("SELECT 1 FROM snaptrade_accounts WHERE account_id IS NOT NULL GROUP BY account_id HAVING COUNT(DISTINCT snaptrade_item_id) > 1 LIMIT 1").any? ||
execute("SELECT 1 FROM snaptrade_accounts WHERE snaptrade_account_id IS NOT NULL GROUP BY snaptrade_account_id HAVING COUNT(DISTINCT snaptrade_item_id) > 1 LIMIT 1").any?
raise ActiveRecord::IrreversibleMigration,
"Cannot rollback: cross-item duplicates exist in snaptrade_accounts. Remove duplicates first."
end
remove_index :snaptrade_accounts, name: "index_snaptrade_accounts_on_item_and_account_id", if_exists: true
remove_index :snaptrade_accounts, name: "index_snaptrade_accounts_on_item_and_snaptrade_account_id", if_exists: true
unless index_exists?(:snaptrade_accounts, :account_id, name: "index_snaptrade_accounts_on_account_id")
add_index :snaptrade_accounts, :account_id,
name: "index_snaptrade_accounts_on_account_id",
unique: true,
where: "account_id IS NOT NULL"
end
unless index_exists?(:snaptrade_accounts, :snaptrade_account_id, name: "index_snaptrade_accounts_on_snaptrade_account_id")
add_index :snaptrade_accounts, :snaptrade_account_id,
name: "index_snaptrade_accounts_on_snaptrade_account_id",
unique: true,
where: "snaptrade_account_id IS NOT NULL"
end
end
end

View File

@@ -0,0 +1,25 @@
# frozen_string_literal: true
# NEW constraint: add per-item unique index on coinbase_accounts. Unlike Plaid/Snaptrade,
# there was no prior unique index—this can fail if existing data has duplicate
# (coinbase_item_id, account_id) pairs. See: https://github.com/we-promise/sure/issues/740
class ScopeCoinbaseAccountUniquenessToItem < ActiveRecord::Migration[7.2]
def up
return if index_exists?(:coinbase_accounts, [ :coinbase_item_id, :account_id ], unique: true, name: "index_coinbase_accounts_on_item_and_account_id")
if execute("SELECT 1 FROM coinbase_accounts WHERE account_id IS NOT NULL GROUP BY coinbase_item_id, account_id HAVING COUNT(*) > 1 LIMIT 1").any?
raise ActiveRecord::Migration::IrreversibleMigration,
"Duplicate (coinbase_item_id, account_id) pairs exist in coinbase_accounts. Resolve duplicates before running this migration."
end
add_index :coinbase_accounts,
[ :coinbase_item_id, :account_id ],
unique: true,
name: "index_coinbase_accounts_on_item_and_account_id",
where: "account_id IS NOT NULL"
end
def down
remove_index :coinbase_accounts, name: "index_coinbase_accounts_on_item_and_account_id", if_exists: true
end
end

View File

@@ -0,0 +1,25 @@
# frozen_string_literal: true
# NEW constraint: add per-item unique index on lunchflow_accounts. Unlike Plaid/Snaptrade,
# there was no prior unique index—this can fail if existing data has duplicate
# (lunchflow_item_id, account_id) pairs. See: https://github.com/we-promise/sure/issues/740
class ScopeLunchflowAccountUniquenessToItem < ActiveRecord::Migration[7.2]
def up
return if index_exists?(:lunchflow_accounts, [ :lunchflow_item_id, :account_id ], unique: true, name: "index_lunchflow_accounts_on_item_and_account_id")
if execute("SELECT 1 FROM lunchflow_accounts WHERE account_id IS NOT NULL GROUP BY lunchflow_item_id, account_id HAVING COUNT(*) > 1 LIMIT 1").any?
raise ActiveRecord::Migration::IrreversibleMigration,
"Duplicate (lunchflow_item_id, account_id) pairs exist in lunchflow_accounts. Resolve duplicates before running this migration."
end
add_index :lunchflow_accounts,
[ :lunchflow_item_id, :account_id ],
unique: true,
name: "index_lunchflow_accounts_on_item_and_account_id",
where: "account_id IS NOT NULL"
end
def down
remove_index :lunchflow_accounts, name: "index_lunchflow_accounts_on_item_and_account_id", if_exists: true
end
end

12
db/schema.rb generated
View File

@@ -226,6 +226,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_03_14_131357) do
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["account_id"], name: "index_coinbase_accounts_on_account_id"
t.index ["coinbase_item_id", "account_id"], name: "index_coinbase_accounts_on_item_and_account_id", unique: true, where: "(account_id IS NOT NULL)"
t.index ["coinbase_item_id"], name: "index_coinbase_accounts_on_coinbase_item_id"
end
@@ -704,7 +705,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_03_14_131357) do
t.date "sync_start_date"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["indexa_capital_account_id"], name: "index_indexa_capital_accounts_on_indexa_capital_account_id", unique: true
t.index ["indexa_capital_item_id", "indexa_capital_account_id"], name: "index_indexa_capital_accounts_on_item_and_account_id", unique: true, where: "(indexa_capital_account_id IS NOT NULL)"
t.index ["indexa_capital_authorization_id"], name: "idx_on_indexa_capital_authorization_id_58db208d52"
t.index ["indexa_capital_item_id"], name: "index_indexa_capital_accounts_on_indexa_capital_item_id"
end
@@ -813,6 +814,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_03_14_131357) do
t.boolean "holdings_supported", default: true, null: false
t.jsonb "raw_holdings_payload"
t.index ["account_id"], name: "index_lunchflow_accounts_on_account_id"
t.index ["lunchflow_item_id", "account_id"], name: "index_lunchflow_accounts_on_item_and_account_id", unique: true, where: "(account_id IS NOT NULL)"
t.index ["lunchflow_item_id"], name: "index_lunchflow_accounts_on_lunchflow_item_id"
end
@@ -870,7 +872,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_03_14_131357) do
t.jsonb "raw_transactions_payload"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["account_id"], name: "index_mercury_accounts_on_account_id", unique: true
t.index ["mercury_item_id", "account_id"], name: "index_mercury_accounts_on_item_and_account_id", unique: true
t.index ["mercury_item_id"], name: "index_mercury_accounts_on_mercury_item_id"
end
@@ -1015,7 +1017,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_03_14_131357) do
t.jsonb "raw_transactions_payload", default: {}
t.jsonb "raw_holdings_payload", default: {}
t.jsonb "raw_liabilities_payload", default: {}
t.index ["plaid_id"], name: "index_plaid_accounts_on_plaid_id", unique: true
t.index ["plaid_item_id", "plaid_id"], name: "index_plaid_accounts_on_item_and_plaid_id", unique: true
t.index ["plaid_item_id"], name: "index_plaid_accounts_on_plaid_item_id"
end
@@ -1263,8 +1265,8 @@ ActiveRecord::Schema[7.2].define(version: 2026_03_14_131357) do
t.datetime "updated_at", null: false
t.boolean "activities_fetch_pending", default: false
t.date "sync_start_date"
t.index ["account_id"], name: "index_snaptrade_accounts_on_account_id", unique: true
t.index ["snaptrade_account_id"], name: "index_snaptrade_accounts_on_snaptrade_account_id", unique: true
t.index ["snaptrade_item_id", "account_id"], name: "index_snaptrade_accounts_on_item_and_account_id", unique: true, where: "(account_id IS NOT NULL)"
t.index ["snaptrade_item_id", "snaptrade_account_id"], name: "index_snaptrade_accounts_on_item_and_snaptrade_account_id", unique: true, where: "(snaptrade_account_id IS NOT NULL)"
t.index ["snaptrade_item_id"], name: "index_snaptrade_accounts_on_snaptrade_item_id"
end