Skip to content

Commit 7b90f45

Browse files
authored
fix: Allow multiple accounts per (party, org, instrument) - unblock nightly Demo Reset (#2174)
* fix: Allow multiple accounts per (party, org, instrument) for multi-service billing Drops idx_account_syndicate_scope_integrity, the unique partial index on (party_id, org_party_id, instrument_code) introduced in PRD-022. The original constraint modelled a syndicate where each participant holds one position per currency in a venture, but it does not match utility billing patterns where a single customer legitimately holds separate billing accounts of the same instrument with the same supplier (e.g. PPM-ELEC-CUST001 and PPM-GAS-CUST001 under the same supplier party for Margaret Thornton in the PAYG demo). This has caused the nightly Demo Reset workflow to fail since the PAYG Energy tenant was added on 2026-03-27 (#1963). With the index removed, account uniqueness is still enforced by idx_account_account_identification, so the constraint relaxation does not allow accidental duplication; it only allows the legitimate multi-service-per-supplier pattern. Updates the org-scoped migration test to assert the new behaviour (multiple GBP accounts allowed per party+org, account_identification uniqueness still enforced). * test: Tighten duplicate-key assertion to verify constraint name Per CodeRabbit review: assert.Error alone passes on any insert failure. Tighten to require the failure references account_identification so the test specifically proves uniqueness on that column survives dropping the syndicate scope integrity index. --------- Co-authored-by: Ben Coombs <bjcoombs@users.noreply.github.com>
1 parent bcf27d6 commit 7b90f45

File tree

3 files changed

+62
-12
lines changed

3 files changed

+62
-12
lines changed

services/current-account/adapters/persistence/org_scoped_migration_test.go

Lines changed: 45 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -73,11 +73,9 @@ func TestOrgScopedMigrations_CockroachDB(t *testing.T) {
7373
})
7474

7575
t.Run("IndexesCreated", func(t *testing.T) {
76-
// Verify the three indexes were created
7776
for _, indexName := range []string{
7877
"idx_account_participant_syndicate",
7978
"idx_account_syndicate_participants",
80-
"idx_account_syndicate_scope_integrity",
8179
} {
8280
var count int64
8381
err := gormDB.Raw(`
@@ -89,6 +87,20 @@ func TestOrgScopedMigrations_CockroachDB(t *testing.T) {
8987
}
9088
})
9189

90+
t.Run("ScopeIntegrityIndexDropped", func(t *testing.T) {
91+
// Migration 20260416000001 dropped idx_account_syndicate_scope_integrity to allow
92+
// multiple accounts per (party_id, org_party_id, instrument_code) — required by
93+
// utility billing patterns (e.g. separate GBP accounts for electricity and gas
94+
// from the same supplier).
95+
var count int64
96+
err := gormDB.Raw(`
97+
SELECT COUNT(*) FROM pg_indexes
98+
WHERE tablename = 'account' AND indexname = 'idx_account_syndicate_scope_integrity'
99+
`).Scan(&count).Error
100+
require.NoError(t, err)
101+
assert.Equal(t, int64(0), count, "idx_account_syndicate_scope_integrity should have been dropped")
102+
})
103+
92104
t.Run("CanCreateOrgScopedAccount", func(t *testing.T) {
93105
partyID := uuid.New()
94106
orgPartyID := uuid.New()
@@ -149,16 +161,18 @@ func TestOrgScopedMigrations_CockroachDB(t *testing.T) {
149161
assert.Nil(t, retrieved.OrgPartyID, "Personal account should have NULL org_party_id")
150162
})
151163

152-
t.Run("UniqueConstraintOnPartyOrgInstrumentCode", func(t *testing.T) {
164+
t.Run("MultipleAccountsPerPartyOrgInstrumentAllowed", func(t *testing.T) {
165+
// Utility billing case: a customer has separate GBP billing accounts for electricity
166+
// and gas with the same supplier. After migration 20260416000001, both succeed and
167+
// uniqueness is preserved only by account_identification.
153168
partyID := uuid.New()
154169
orgPartyID := uuid.New()
155170
now := time.Now()
156171

157-
// First account: party + org + GBP
158172
entity1 := &CurrentAccountEntity{
159173
ID: uuid.New(),
160174
AccountID: "ACC-UNIQ-001",
161-
AccountIdentification: "GB82WEST33333333333333",
175+
AccountIdentification: "PPM-ELEC-DEMO-001",
162176
AccountType: "current",
163177
InstrumentCode: "GBP",
164178
Dimension: "CURRENCY",
@@ -172,13 +186,12 @@ func TestOrgScopedMigrations_CockroachDB(t *testing.T) {
172186
UpdatedBy: "system",
173187
}
174188
err := gormDB.Create(entity1).Error
175-
require.NoError(t, err, "First org-scoped account should succeed")
189+
require.NoError(t, err, "First org-scoped GBP account should succeed")
176190

177-
// Duplicate: same party + org + instrument_code should fail
178191
entity2 := &CurrentAccountEntity{
179192
ID: uuid.New(),
180193
AccountID: "ACC-UNIQ-002",
181-
AccountIdentification: "GB82WEST44444444444444",
194+
AccountIdentification: "PPM-GAS-DEMO-001",
182195
AccountType: "current",
183196
InstrumentCode: "GBP",
184197
Dimension: "CURRENCY",
@@ -192,13 +205,12 @@ func TestOrgScopedMigrations_CockroachDB(t *testing.T) {
192205
UpdatedBy: "system",
193206
}
194207
err = gormDB.Create(entity2).Error
195-
assert.Error(t, err, "Duplicate (party_id, org_party_id, instrument_code) should be rejected by unique index")
208+
assert.NoError(t, err, "Second GBP account for same (party, org) should succeed (multi-service billing)")
196209

197-
// Different instrument_code: same party + org + EUR should succeed
198210
entity3 := &CurrentAccountEntity{
199211
ID: uuid.New(),
200212
AccountID: "ACC-UNIQ-003",
201-
AccountIdentification: "GB82WEST55555555555555",
213+
AccountIdentification: "PPM-EUR-DEMO-001",
202214
AccountType: "current",
203215
InstrumentCode: "EUR",
204216
Dimension: "CURRENCY",
@@ -213,6 +225,28 @@ func TestOrgScopedMigrations_CockroachDB(t *testing.T) {
213225
}
214226
err = gormDB.Create(entity3).Error
215227
assert.NoError(t, err, "Different instrument_code for same (party_id, org_party_id) should succeed")
228+
229+
// Account-identifier uniqueness is still enforced by idx_account_account_identification.
230+
dup := &CurrentAccountEntity{
231+
ID: uuid.New(),
232+
AccountID: "ACC-UNIQ-004",
233+
AccountIdentification: "PPM-ELEC-DEMO-001",
234+
AccountType: "current",
235+
InstrumentCode: "GBP",
236+
Dimension: "CURRENCY",
237+
Status: "active",
238+
PartyID: partyID,
239+
OrgPartyID: &orgPartyID,
240+
OverdraftLimit: 0,
241+
CreatedAt: now,
242+
UpdatedAt: now,
243+
CreatedBy: "system",
244+
UpdatedBy: "system",
245+
}
246+
err = gormDB.Create(dup).Error
247+
require.Error(t, err, "Duplicate account_identification must still be rejected")
248+
assert.Contains(t, err.Error(), "duplicate key", "rejection should be a uniqueness violation")
249+
assert.Contains(t, err.Error(), "account_identification", "rejection must reference idx_account_account_identification, not some other constraint")
216250
})
217251

218252
t.Run("UniqueConstraintDoesNotAffectPersonalAccounts", func(t *testing.T) {
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
-- Drop idx_account_syndicate_scope_integrity (party_id, org_party_id, instrument_code).
2+
--
3+
-- The original constraint (PRD-022) assumed one account per party per organisation per
4+
-- currency, modelled on a syndicate where Alice holds a single GBP position in Venture Alpha.
5+
-- That assumption does not hold for utility billing or any multi-service scenario where a
6+
-- party legitimately holds multiple instrument-equivalent accounts under the same supplier:
7+
-- a residential customer needs separate GBP billing accounts for electricity and gas at the
8+
-- same supplier, and the same shape extends to multi-site customers (one billing account per
9+
-- premise) and multi-product offerings.
10+
--
11+
-- Account-identifier uniqueness is preserved by idx_account_account_identification, so
12+
-- dropping this index does not allow accidental account duplication; it only allows the
13+
-- legitimate "Margaret has GBP-elec and GBP-gas with Utilita" pattern.
14+
15+
DROP INDEX IF EXISTS "idx_account_syndicate_scope_integrity";

services/current-account/migrations/atlas.sum

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
h1:PnPRafxLtbQAhHssRdEu02xkP/eQb96IF7HwUpVmYvE=
1+
h1:C1bgEjVoS7bhttB5ntNjdmFCKz9yaBltHLl5kwGkbXk=
22
20251216000001_initial.sql h1:nyRf35O3eYNJqShAvOEhvliXKLIR6e/V8B9JhOHde9w=
33
20251216000002_audit_system.sql h1:X/0ZHt9cGTYmFbUUvKAkBgEiGw9fYEqfnbMB8I4SCR8=
44
20251217000001_fix_audit_status_constraint.sql h1:yQxsHxbUp5g+ehkKFjOhZs59SbfaQIPw4/sA2xE2rwU=
@@ -28,3 +28,4 @@ h1:PnPRafxLtbQAhHssRdEu02xkP/eQb96IF7HwUpVmYvE=
2828
20260316000002_add_event_outbox_tenant_id_index.sql h1:hrEPnXoHQ1bnaMtvFFwZ9UzJVtryQrIe6zDSDqfoLbM=
2929
20260323000001_align_audit_schema.sql h1:AmTi2tik7qBcMVH589i0pvv14DxTbPMbK0w24n0aw+g=
3030
20260323000002_align_audit_schema_data.sql h1:5AqTImsCsnJIDldKPVAxFCwmb/8uabn7KZULF/PzQs8=
31+
20260416000001_drop_syndicate_scope_integrity_index.sql h1:S9u8OUer3WusmW31j2zAsNGaZRwMV3v6+BxA5k0FxDk=

0 commit comments

Comments
 (0)