mirror of
https://github.com/we-promise/sure
synced 2026-04-25 17:15:07 +02:00
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:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
12
db/schema.rb
generated
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user