diff --git a/services/current-account/adapters/persistence/org_scoped_migration_test.go b/services/current-account/adapters/persistence/org_scoped_migration_test.go index 58d213f89..8abe0cc96 100644 --- a/services/current-account/adapters/persistence/org_scoped_migration_test.go +++ b/services/current-account/adapters/persistence/org_scoped_migration_test.go @@ -73,11 +73,9 @@ func TestOrgScopedMigrations_CockroachDB(t *testing.T) { }) t.Run("IndexesCreated", func(t *testing.T) { - // Verify the three indexes were created for _, indexName := range []string{ "idx_account_participant_syndicate", "idx_account_syndicate_participants", - "idx_account_syndicate_scope_integrity", } { var count int64 err := gormDB.Raw(` @@ -89,6 +87,20 @@ func TestOrgScopedMigrations_CockroachDB(t *testing.T) { } }) + t.Run("ScopeIntegrityIndexDropped", func(t *testing.T) { + // Migration 20260416000001 dropped idx_account_syndicate_scope_integrity to allow + // multiple accounts per (party_id, org_party_id, instrument_code) — required by + // utility billing patterns (e.g. separate GBP accounts for electricity and gas + // from the same supplier). + var count int64 + err := gormDB.Raw(` + SELECT COUNT(*) FROM pg_indexes + WHERE tablename = 'account' AND indexname = 'idx_account_syndicate_scope_integrity' + `).Scan(&count).Error + require.NoError(t, err) + assert.Equal(t, int64(0), count, "idx_account_syndicate_scope_integrity should have been dropped") + }) + t.Run("CanCreateOrgScopedAccount", func(t *testing.T) { partyID := uuid.New() orgPartyID := uuid.New() @@ -149,16 +161,18 @@ func TestOrgScopedMigrations_CockroachDB(t *testing.T) { assert.Nil(t, retrieved.OrgPartyID, "Personal account should have NULL org_party_id") }) - t.Run("UniqueConstraintOnPartyOrgInstrumentCode", func(t *testing.T) { + t.Run("MultipleAccountsPerPartyOrgInstrumentAllowed", func(t *testing.T) { + // Utility billing case: a customer has separate GBP billing accounts for electricity + // and gas with the same supplier. After migration 20260416000001, both succeed and + // uniqueness is preserved only by account_identification. partyID := uuid.New() orgPartyID := uuid.New() now := time.Now() - // First account: party + org + GBP entity1 := &CurrentAccountEntity{ ID: uuid.New(), AccountID: "ACC-UNIQ-001", - AccountIdentification: "GB82WEST33333333333333", + AccountIdentification: "PPM-ELEC-DEMO-001", AccountType: "current", InstrumentCode: "GBP", Dimension: "CURRENCY", @@ -172,13 +186,12 @@ func TestOrgScopedMigrations_CockroachDB(t *testing.T) { UpdatedBy: "system", } err := gormDB.Create(entity1).Error - require.NoError(t, err, "First org-scoped account should succeed") + require.NoError(t, err, "First org-scoped GBP account should succeed") - // Duplicate: same party + org + instrument_code should fail entity2 := &CurrentAccountEntity{ ID: uuid.New(), AccountID: "ACC-UNIQ-002", - AccountIdentification: "GB82WEST44444444444444", + AccountIdentification: "PPM-GAS-DEMO-001", AccountType: "current", InstrumentCode: "GBP", Dimension: "CURRENCY", @@ -192,13 +205,12 @@ func TestOrgScopedMigrations_CockroachDB(t *testing.T) { UpdatedBy: "system", } err = gormDB.Create(entity2).Error - assert.Error(t, err, "Duplicate (party_id, org_party_id, instrument_code) should be rejected by unique index") + assert.NoError(t, err, "Second GBP account for same (party, org) should succeed (multi-service billing)") - // Different instrument_code: same party + org + EUR should succeed entity3 := &CurrentAccountEntity{ ID: uuid.New(), AccountID: "ACC-UNIQ-003", - AccountIdentification: "GB82WEST55555555555555", + AccountIdentification: "PPM-EUR-DEMO-001", AccountType: "current", InstrumentCode: "EUR", Dimension: "CURRENCY", @@ -213,6 +225,28 @@ func TestOrgScopedMigrations_CockroachDB(t *testing.T) { } err = gormDB.Create(entity3).Error assert.NoError(t, err, "Different instrument_code for same (party_id, org_party_id) should succeed") + + // Account-identifier uniqueness is still enforced by idx_account_account_identification. + dup := &CurrentAccountEntity{ + ID: uuid.New(), + AccountID: "ACC-UNIQ-004", + AccountIdentification: "PPM-ELEC-DEMO-001", + AccountType: "current", + InstrumentCode: "GBP", + Dimension: "CURRENCY", + Status: "active", + PartyID: partyID, + OrgPartyID: &orgPartyID, + OverdraftLimit: 0, + CreatedAt: now, + UpdatedAt: now, + CreatedBy: "system", + UpdatedBy: "system", + } + err = gormDB.Create(dup).Error + require.Error(t, err, "Duplicate account_identification must still be rejected") + assert.Contains(t, err.Error(), "duplicate key", "rejection should be a uniqueness violation") + assert.Contains(t, err.Error(), "account_identification", "rejection must reference idx_account_account_identification, not some other constraint") }) t.Run("UniqueConstraintDoesNotAffectPersonalAccounts", func(t *testing.T) { diff --git a/services/current-account/migrations/20260416000001_drop_syndicate_scope_integrity_index.sql b/services/current-account/migrations/20260416000001_drop_syndicate_scope_integrity_index.sql new file mode 100644 index 000000000..334347000 --- /dev/null +++ b/services/current-account/migrations/20260416000001_drop_syndicate_scope_integrity_index.sql @@ -0,0 +1,15 @@ +-- Drop idx_account_syndicate_scope_integrity (party_id, org_party_id, instrument_code). +-- +-- The original constraint (PRD-022) assumed one account per party per organisation per +-- currency, modelled on a syndicate where Alice holds a single GBP position in Venture Alpha. +-- That assumption does not hold for utility billing or any multi-service scenario where a +-- party legitimately holds multiple instrument-equivalent accounts under the same supplier: +-- a residential customer needs separate GBP billing accounts for electricity and gas at the +-- same supplier, and the same shape extends to multi-site customers (one billing account per +-- premise) and multi-product offerings. +-- +-- Account-identifier uniqueness is preserved by idx_account_account_identification, so +-- dropping this index does not allow accidental account duplication; it only allows the +-- legitimate "Margaret has GBP-elec and GBP-gas with Utilita" pattern. + +DROP INDEX IF EXISTS "idx_account_syndicate_scope_integrity"; diff --git a/services/current-account/migrations/atlas.sum b/services/current-account/migrations/atlas.sum index a80f519e3..18050cea0 100644 --- a/services/current-account/migrations/atlas.sum +++ b/services/current-account/migrations/atlas.sum @@ -1,4 +1,4 @@ -h1:PnPRafxLtbQAhHssRdEu02xkP/eQb96IF7HwUpVmYvE= +h1:C1bgEjVoS7bhttB5ntNjdmFCKz9yaBltHLl5kwGkbXk= 20251216000001_initial.sql h1:nyRf35O3eYNJqShAvOEhvliXKLIR6e/V8B9JhOHde9w= 20251216000002_audit_system.sql h1:X/0ZHt9cGTYmFbUUvKAkBgEiGw9fYEqfnbMB8I4SCR8= 20251217000001_fix_audit_status_constraint.sql h1:yQxsHxbUp5g+ehkKFjOhZs59SbfaQIPw4/sA2xE2rwU= @@ -28,3 +28,4 @@ h1:PnPRafxLtbQAhHssRdEu02xkP/eQb96IF7HwUpVmYvE= 20260316000002_add_event_outbox_tenant_id_index.sql h1:hrEPnXoHQ1bnaMtvFFwZ9UzJVtryQrIe6zDSDqfoLbM= 20260323000001_align_audit_schema.sql h1:AmTi2tik7qBcMVH589i0pvv14DxTbPMbK0w24n0aw+g= 20260323000002_align_audit_schema_data.sql h1:5AqTImsCsnJIDldKPVAxFCwmb/8uabn7KZULF/PzQs8= +20260416000001_drop_syndicate_scope_integrity_index.sql h1:S9u8OUer3WusmW31j2zAsNGaZRwMV3v6+BxA5k0FxDk=