diff --git a/src/subdomains/supporting/bank/virtual-iban/__tests__/virtual-iban.service.spec.ts b/src/subdomains/supporting/bank/virtual-iban/__tests__/virtual-iban.service.spec.ts index d28ad60c1d..9c8512d345 100644 --- a/src/subdomains/supporting/bank/virtual-iban/__tests__/virtual-iban.service.spec.ts +++ b/src/subdomains/supporting/bank/virtual-iban/__tests__/virtual-iban.service.spec.ts @@ -15,7 +15,6 @@ import { DataSource, Entity, EntityManager, - FindOneOptions, FindOperator, IsNull, ManyToOne, @@ -75,6 +74,15 @@ class ReceivingLookupBankTable { send: boolean; } +@Entity({ name: 'receiving_lookup_buy' }) +class ReceivingLookupBuyTable { + @PrimaryGeneratedColumn() + id: number; + + @Column({ type: 'varchar', length: 256 }) + reference: string; +} + @Entity({ name: 'receiving_lookup_virtual_iban' }) class ReceivingLookupVirtualIbanTable { @PrimaryGeneratedColumn() @@ -97,6 +105,9 @@ class ReceivingLookupVirtualIbanTable { @ManyToOne(() => ReceivingLookupBankTable, { nullable: false }) bank: ReceivingLookupBankTable; + + @ManyToOne(() => ReceivingLookupBuyTable, { nullable: true }) + buy?: ReceivingLookupBuyTable; } describe('VirtualIbanService', () => { @@ -1460,6 +1471,7 @@ describe('VirtualIbanService', () => { userData: { id: 7 }, currency: { name: 'CHF' }, bank: { receive: true }, + buy: IsNull(), active: true, status: VirtualIbanStatus.ACTIVE, }, @@ -1478,6 +1490,7 @@ describe('VirtualIbanService', () => { userData: { id: 7 }, currency: { name: 'CHF' }, bank: { send: true }, + buy: IsNull(), active: true, status: VirtualIbanStatus.ACTIVE, }, @@ -1486,30 +1499,6 @@ describe('VirtualIbanService', () => { }); }); - it('retains merge-base behavior by reusing a buy-bound Yapeal IBAN in the generic lookup', async () => { - const buyBound = { - id: 88, - iban: 'CH4400762011623852959', - buy: { id: 55 }, - userData: { id: 7 }, - currency: { name: 'CHF' }, - bank: { name: IbanBankName.YAPEAL }, - active: true, - status: VirtualIbanStatus.ACTIVE, - } as VirtualIban; - - jest.spyOn(virtualIbanRepo, 'findOne').mockImplementation(async (options: FindOneOptions) => { - if (!options.where || Array.isArray(options.where)) throw new Error('Expected one vIBAN where clause'); - const buyWhere = options.where.buy; - // The regressed filter excluded this row. With merge-base semantics, no buy predicate exists. - if (buyWhere instanceof FindOperator && buyWhere.type === 'isNull') return null; - return buyBound; - }); - - await expect(service.getActiveReceivingForUserAndCurrency(userData, 'CHF')).resolves.toBe(buyBound); - expect(virtualIbanRepo.findOne).toHaveBeenCalled(); - }); - it('getActiveForBuyAndCurrency reads through to the database for issuance correctness', async () => { jest.spyOn(virtualIbanRepo, 'findOne').mockResolvedValue(null); @@ -1655,6 +1644,7 @@ describe('VirtualIbanService', () => { ReceivingLookupUserDataTable, ReceivingLookupFiatTable, ReceivingLookupBankTable, + ReceivingLookupBuyTable, ReceivingLookupVirtualIbanTable, ], synchronize: true, @@ -1718,6 +1708,43 @@ describe('VirtualIbanService', () => { }); }); + it('excludes a buy-bound IBAN from the generic user lookup', async () => { + const lookupUser = await pgDataSource.getRepository(ReceivingLookupUserDataTable).save({ label: 'buy-filter' }); + const lookupCurrency = await pgDataSource.getRepository(ReceivingLookupFiatTable).save({ name: 'USD' }); + const receivingBank = await pgDataSource + .getRepository(ReceivingLookupBankTable) + .save({ name: IbanBankName.YAPEAL, receive: true, send: true }); + const lookupBuy = await pgDataSource + .getRepository(ReceivingLookupBuyTable) + .save({ reference: 'buy-bound-lookup' }); + + // Store the generally assigned row first so it has the lower id. The newer buy-bound row would win by + // ordering alone, which means only the buy IS NULL predicate can make this test return the free row. + const free = await pgDataSource.getRepository(ReceivingLookupVirtualIbanTable).save({ + iban: 'CH0000000000000000005', + userData: lookupUser, + currency: lookupCurrency, + bank: receivingBank, + active: true, + status: VirtualIbanStatus.ACTIVE, + }); + await pgDataSource.getRepository(ReceivingLookupVirtualIbanTable).save({ + iban: 'CH0000000000000000006', + userData: lookupUser, + currency: lookupCurrency, + bank: receivingBank, + buy: lookupBuy, + active: true, + status: VirtualIbanStatus.ACTIVE, + }); + + // The merge-base behavior was deliberately left unchanged in #4384 to keep that PR scoped. This + // standalone reversal prevents a buy-bound IBAN in the generic path from misassigning incoming funds. + await expect( + lookupService.getActiveReceivingForUserAndCurrency({ id: lookupUser.id } as UserData, 'USD'), + ).resolves.toMatchObject({ id: free.id, iban: free.iban }); + }); + it('selects the most recently created row when two active rows can receive', async () => { const lookupUser = await pgDataSource.getRepository(ReceivingLookupUserDataTable).save({ label: 'lookup' }); const lookupCurrency = await pgDataSource.getRepository(ReceivingLookupFiatTable).save({ name: 'CHF' }); diff --git a/src/subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts b/src/subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts index 63683390d4..3b94448a46 100644 --- a/src/subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts +++ b/src/subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts @@ -125,6 +125,12 @@ export class VirtualIbanService { * eligibility can diverge, so one shared lookup cannot serve both directions correctly. The directional * bank predicate excludes rows that can no longer receive, but cannot break ties between qualifying rows; * the explicit newest-first order makes the most recently created operational replacement win deterministically. + * + * A buy-bound row belongs to exactly one purchase, and BankTxService assigns incoming funds on that IBAN + * to that stored purchase. Returning it in the generic deposit path could therefore show an IBAN for purchase + * B while the customer is paying for purchase A, causing the funds to be assigned to B. Buy-specific callers + * use {@link getActiveForBuyAndCurrency}; excluding buy-bound rows here also makes all three user-related + * lookups consistent, because findActiveForUserCurrencyAndBank already uses `buy: IsNull()`. */ async getActiveReceivingForUserAndCurrency(userData: UserData, currencyName: string): Promise { return this.virtualIbanRepo.findOne({ @@ -137,6 +143,7 @@ export class VirtualIbanService { // receive", and with no collection-account fallback left the request fails outright. That is // what surfaced as PersonalIbanIssuanceFailed for every holder of a retired Yapeal EUR IBAN. bank: { receive: true }, + buy: IsNull(), active: true, status: VirtualIbanStatus.ACTIVE, }, @@ -149,7 +156,9 @@ export class VirtualIbanService { * Finds all active personal-IBAN candidates whose banks can send customer payouts. Sending and receiving * eligibility can diverge, so the directional bank predicate is required but cannot break ties on its own. * The caller applies additional entity-level eligibility rules; newest-first ordering gives it a deterministic - * choice among candidates that remain equally qualified after those rules. + * choice among candidates that remain equally qualified after those rules. Both user-level lookups must treat + * the same rows as generally assigned to the user; an asymmetric buy-bound condition between deposits and + * payouts would create the next class of inconsistent behavior. */ async getActiveSendingCandidatesForUserAndCurrency(userData: UserData, currencyName: string): Promise { return this.virtualIbanRepo.find({ @@ -157,6 +166,7 @@ export class VirtualIbanService { userData: { id: userData.id }, currency: { name: currencyName }, bank: { send: true }, + buy: IsNull(), active: true, status: VirtualIbanStatus.ACTIVE, },