Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ import {
DataSource,
Entity,
EntityManager,
FindOneOptions,
FindOperator,
IsNull,
ManyToOne,
Expand Down Expand Up @@ -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()
Expand All @@ -97,6 +105,9 @@ class ReceivingLookupVirtualIbanTable {

@ManyToOne(() => ReceivingLookupBankTable, { nullable: false })
bank: ReceivingLookupBankTable;

@ManyToOne(() => ReceivingLookupBuyTable, { nullable: true })
buy?: ReceivingLookupBuyTable;
}

describe('VirtualIbanService', () => {
Expand Down Expand Up @@ -1460,6 +1471,7 @@ describe('VirtualIbanService', () => {
userData: { id: 7 },
currency: { name: 'CHF' },
bank: { receive: true },
buy: IsNull(),
active: true,
status: VirtualIbanStatus.ACTIVE,
},
Expand All @@ -1478,6 +1490,7 @@ describe('VirtualIbanService', () => {
userData: { id: 7 },
currency: { name: 'CHF' },
bank: { send: true },
buy: IsNull(),
active: true,
status: VirtualIbanStatus.ACTIVE,
},
Expand All @@ -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<VirtualIban>) => {
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);

Expand Down Expand Up @@ -1655,6 +1644,7 @@ describe('VirtualIbanService', () => {
ReceivingLookupUserDataTable,
ReceivingLookupFiatTable,
ReceivingLookupBankTable,
ReceivingLookupBuyTable,
ReceivingLookupVirtualIbanTable,
],
synchronize: true,
Expand Down Expand Up @@ -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' });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<VirtualIban | null> {
return this.virtualIbanRepo.findOne({
Expand All @@ -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,
},
Expand All @@ -149,14 +156,17 @@ 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<VirtualIban[]> {
return this.virtualIbanRepo.find({
where: {
userData: { id: userData.id },
currency: { name: currencyName },
bank: { send: true },
buy: IsNull(),
active: true,
status: VirtualIbanStatus.ACTIVE,
},
Expand Down