From c636c0e75a9c3765ec55a00aa8ceec95a09cb01e Mon Sep 17 00:00:00 2001 From: Daniel Padrino Date: Fri, 31 Jul 2026 11:01:59 -0300 Subject: [PATCH 1/4] perf(buy): resolve the user and the active vIBAN once per payment-info request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PUT /v1/buy/paymentInfos issued the same two lookups twice. Measured against production traces of typical requests (~106 ms, 13 DB queries, 74% of the request spent in the database): the user accounted for 4 of those queries and the vIBAN for 3.5. - The user was loaded in createBuyPaymentInfo and again in toPaymentInfoDto. Each load costs two queries, because TypeORM emits its DISTINCT-id pattern for findOne with relations. toPaymentInfoDto now accepts the already-loaded user; the standalone callers keep their previous behaviour through the optional parameter. - getTxDetails already resolves the user's active vIBAN to pick the receiving bank for the fee. It now returns that result so the deposit-destination step reuses it instead of repeating the query. null distinguishes "looked up, none found" from undefined ("no lookup ran"), so a caller that never triggered one still resolves it itself. The lookup still happens inside getTxDetails, so the ordering that keeps a failed quote from creating an external account is unchanged. Sharing one relation set between both loads also fixes a latent defect: createBuyPaymentInfo requested userData.wallet, while buyCheck reads user.wallet — which was therefore always undefined there. Both escape hatches guarded by it were inert: SKIP_AML_CHECK, and autoTradeApproval, which is meant to let a wallet without tradeApprovalDate through rather than failing it with RecommendationRequired. The equivalent check in getTxDetails does have the wallet loaded and only emits a soft QuoteError, so the two disagreed. The resulting behaviour change is gated by DisabledProcess(TRADE_APPROVAL_DATE). userData.users is dropped: nothing reachable from this endpoint reads it, and it fanned the user query out by the number of users on the account. userData.organization is kept, because UserData.address reads organization.country and TypeORM joins the eager relations of a requested relation one level only. --- .../routes/buy/__tests__/buy.service.spec.ts | 71 +++++++++++++++++++ .../core/buy-crypto/routes/buy/buy.service.ts | 45 ++++++++---- .../__tests__/transaction-helper.spec.ts | 29 +++++--- .../transaction-details.dto.ts | 8 +++ .../payment/services/transaction-helper.ts | 27 +++++-- 5 files changed, 152 insertions(+), 28 deletions(-) diff --git a/src/subdomains/core/buy-crypto/routes/buy/__tests__/buy.service.spec.ts b/src/subdomains/core/buy-crypto/routes/buy/__tests__/buy.service.spec.ts index 2d6cf3d2a9..2bd27d700c 100644 --- a/src/subdomains/core/buy-crypto/routes/buy/__tests__/buy.service.spec.ts +++ b/src/subdomains/core/buy-crypto/routes/buy/__tests__/buy.service.spec.ts @@ -1293,6 +1293,77 @@ describe('BuyService', () => { expect(virtualIbanService.isUserEligible).not.toHaveBeenCalled(); expect(bankService.getBank).not.toHaveBeenCalled(); }); + + // getTxDetails already resolves the user's active vIBAN to pick the receiving bank for the fee. It + // hands that result back so the deposit-destination step reuses it instead of repeating the query. + describe('active vIBAN reuse between fee calculation and deposit destination', () => { + const userLevelVirtualIban = { + id: 778, + iban: 'CH4431999123000889013', + bank: defaultRouteBank, + currency, + userData, + active: true, + status: VirtualIbanStatus.ACTIVE, + } as VirtualIban; + + beforeEach(() => { + jest.spyOn(userService, 'getUser').mockResolvedValue({ id: 1, userData, wallet } as any); + jest.spyOn(bankService, 'getBank').mockResolvedValue(defaultRouteBank); + }); + + it('does not repeat the lookup when getTxDetails resolved an active vIBAN', async () => { + jest + .spyOn(transactionHelper, 'getTxDetails') + .mockResolvedValue({ ...feeResult(), activeVirtualIban: userLevelVirtualIban } as any); + + const response = await service.toPaymentInfoDto(1, buy, { + amount: 100, + currency, + asset, + paymentMethod: FiatPaymentMethod.BANK, + exactPrice: false, + } as GetBuyPaymentInfoDto); + + expect(response.iban).toBe(userLevelVirtualIban.iban); + expect(virtualIbanService.getActiveReceivingForUserAndCurrency).not.toHaveBeenCalled(); + }); + + // CARD on purpose: a BANK transfer with no personal IBAN fails closed by design, so the "none + // found" outcome can only be observed on the payment method that still resolves a bank. + it('does not repeat the lookup when getTxDetails resolved that there is none', async () => { + jest + .spyOn(transactionHelper, 'getTxDetails') + .mockResolvedValue({ ...feeResult(), activeVirtualIban: null } as any); + + await service.toPaymentInfoDto(1, buy, { + amount: 100, + currency, + asset, + paymentMethod: FiatPaymentMethod.CARD, + exactPrice: false, + } as GetBuyPaymentInfoDto); + + expect(virtualIbanService.getActiveReceivingForUserAndCurrency).not.toHaveBeenCalled(); + }); + + it('resolves it itself when getTxDetails ran no lookup', async () => { + jest + .spyOn(transactionHelper, 'getTxDetails') + .mockResolvedValue({ ...feeResult(), activeVirtualIban: undefined } as any); + jest.spyOn(virtualIbanService, 'getActiveReceivingForUserAndCurrency').mockResolvedValue(null); + + await service.toPaymentInfoDto(1, buy, { + amount: 100, + currency, + asset, + paymentMethod: FiatPaymentMethod.CARD, + exactPrice: false, + } as GetBuyPaymentInfoDto); + + expect(virtualIbanService.getActiveReceivingForUserAndCurrency).toHaveBeenCalledTimes(1); + }); + }); }); describe('createBuy route persistence', () => { diff --git a/src/subdomains/core/buy-crypto/routes/buy/buy.service.ts b/src/subdomains/core/buy-crypto/routes/buy/buy.service.ts index e2c92bfe9a..c4825f08c3 100644 --- a/src/subdomains/core/buy-crypto/routes/buy/buy.service.ts +++ b/src/subdomains/core/buy-crypto/routes/buy/buy.service.ts @@ -35,7 +35,7 @@ import { TransactionRequestType } from 'src/subdomains/supporting/payment/entiti import { SwissQRService } from 'src/subdomains/supporting/payment/services/swiss-qr.service'; import { TransactionHelper } from 'src/subdomains/supporting/payment/services/transaction-helper'; import { TransactionRequestService } from 'src/subdomains/supporting/payment/services/transaction-request.service'; -import { In, IsNull, Not, Repository } from 'typeorm'; +import { FindOptionsRelations, In, IsNull, Not, Repository } from 'typeorm'; import { Buy } from './buy.entity'; import { BuyRepository } from './buy.repository'; import { BankInfoDto, BuyPaymentInfoDto } from './dto/buy-payment-info.dto'; @@ -43,6 +43,18 @@ import { CreateBuyDto } from './dto/create-buy.dto'; import { GetBuyPaymentInfoDto, PersonalIbanProvider } from './dto/get-buy-payment-info.dto'; import { UpdateBuyDto } from './dto/update-buy.dto'; +// Single relation set for the whole payment-info request, loaded once and passed down. Both halves of +// the request used to load the user separately with different relations, which is how `user.wallet` +// went missing in createBuyPaymentInfo (buyCheck reads it) while toPaymentInfoDto had it. +// - userData.organization: `UserData.address` reads organization.street/country for ORGANIZATION and +// SOLE_PROPRIETORSHIP accounts. TypeORM joins the eager relations of a requested relation one level +// only, so organization.country is joined only when organization is requested explicitly. +// - wallet: read as user.wallet (not userData.wallet) by buyCheck and getTxDetails. +const PAYMENT_INFO_USER_RELATIONS: FindOptionsRelations = { + userData: { organization: true }, + wallet: true, +}; + @Injectable() export class BuyService { private cache: { id: number; bankUsage: string }[] = undefined; @@ -141,7 +153,7 @@ export class BuyService { } async createBuyPaymentInfo(jwt: JwtPayload, dto: GetBuyPaymentInfoDto): Promise { - const user = await this.userService.getUser(jwt.user, { userData: { wallet: true } }); + const user = await this.userService.getUser(jwt.user, PAYMENT_INFO_USER_RELATIONS); if (dto.personalIbanProvider === PersonalIbanProvider.FRICK && dto.paymentMethod !== FiatPaymentMethod.BANK) { throw new BadRequestException(QuoteError.PAYMENT_METHOD_NOT_ALLOWED); } @@ -154,7 +166,7 @@ export class BuyService { (e) => e.message?.includes('duplicate key'), ); - return this.toPaymentInfoDto(jwt.user, buy, dto); + return this.toPaymentInfoDto(jwt.user, buy, dto, user); } async createBuy(user: User, userAddress: string, dto: CreateBuyDto, ignoreExisting = false): Promise { @@ -267,11 +279,13 @@ export class BuyService { return this.buyRepo; } - async toPaymentInfoDto(userId: number, buy: Buy, dto: GetBuyPaymentInfoDto): Promise { - const user = await this.userService.getUser(userId, { - userData: { users: true, organization: true }, - wallet: true, - }); + async toPaymentInfoDto( + userId: number, + buy: Buy, + dto: GetBuyPaymentInfoDto, + preloadedUser?: User, + ): Promise { + const user = preloadedUser ?? (await this.userService.getUser(userId, PAYMENT_INFO_USER_RELATIONS)); // Explicit personal-IBAN selector dispatch is exhaustive and fail-closed. Frick resolves the // deposit destination before fee calculation so bankInOverride can pass the Frick bank name @@ -327,6 +341,7 @@ export class BuyService { feeSource, feeTarget, priceSteps, + activeVirtualIban, } = await this.transactionHelper.getTxDetails( dto.amount, dto.targetAmount, @@ -354,6 +369,8 @@ export class BuyService { buy, dto.asset, user.wallet, + undefined, + activeVirtualIban, ); } @@ -476,6 +493,7 @@ export class BuyService { asset?: Asset, wallet?: Wallet, personalIbanProvider?: PersonalIbanProvider, + activeVirtualIban?: VirtualIban | null, ): Promise<{ bankInfo: BankInfoDto & { isPersonalIban: boolean; reference?: string }; bankId: number; @@ -536,11 +554,12 @@ export class BuyService { } } - // user-level vIBAN - let virtualIban = await this.virtualIbanService.getActiveReceivingForUserAndCurrency( - selector.userData, - selector.currency, - ); + // user-level vIBAN — reuse the caller's lookup when it already ran one for this (userData, currency); + // null means "resolved, none found", undefined means "not resolved". + let virtualIban = + activeVirtualIban !== undefined + ? activeVirtualIban + : await this.virtualIbanService.getActiveReceivingForUserAndCurrency(selector.userData, selector.currency); // create a personal IBAN for an eligible KYC 50+ user if ( diff --git a/src/subdomains/core/history/__tests__/transaction-helper.spec.ts b/src/subdomains/core/history/__tests__/transaction-helper.spec.ts index 0ed3d5dc16..7cf02ee437 100644 --- a/src/subdomains/core/history/__tests__/transaction-helper.spec.ts +++ b/src/subdomains/core/history/__tests__/transaction-helper.spec.ts @@ -259,23 +259,25 @@ describe('TransactionHelper', () => { describe('getBankIn', () => { const eur = createCustomFiat({ name: 'EUR' }); + // getBankIn returns the resolved bank together with the vIBAN it looked up, so getTxDetails can hand + // that vIBAN to the caller instead of having it repeat the lookup. `activeVirtualIban` is null when the + // lookup ran and found none, and undefined when no lookup ran at all. it('should return the deposit bank for bank transfers', async () => { jest.spyOn(virtualIbanService, 'getActiveReceivingForUserAndCurrency').mockResolvedValue(null); jest.spyOn(bankService, 'getBank').mockResolvedValue(olkyEUR); await expect( txHelper['getBankIn'](eur, FiatPaymentMethod.BANK, createCustomUserData({ kycLevel: KycLevel.LEVEL_30 })), - ).resolves.toBe(IbanBankName.OLKY); + ).resolves.toEqual({ bankName: IbanBankName.OLKY, activeVirtualIban: null }); }); it('should return the vIBAN bank for users with an active vIBAN', async () => { - jest - .spyOn(virtualIbanService, 'getActiveReceivingForUserAndCurrency') - .mockResolvedValue(createCustomVirtualIban({ bank: yapealEUR })); + const activeVirtualIban = createCustomVirtualIban({ bank: yapealEUR }); + jest.spyOn(virtualIbanService, 'getActiveReceivingForUserAndCurrency').mockResolvedValue(activeVirtualIban); await expect( txHelper['getBankIn'](eur, FiatPaymentMethod.BANK, createCustomUserData({ kycLevel: KycLevel.LEVEL_50 })), - ).resolves.toBe(IbanBankName.YAPEAL); + ).resolves.toEqual({ bankName: IbanBankName.YAPEAL, activeVirtualIban }); }); it('should return the deposit bank for users without an active vIBAN', async () => { @@ -284,23 +286,32 @@ describe('TransactionHelper', () => { await expect( txHelper['getBankIn'](eur, FiatPaymentMethod.BANK, createCustomUserData({ kycLevel: KycLevel.LEVEL_50 })), - ).resolves.toBe(IbanBankName.OLKY); + ).resolves.toEqual({ bankName: IbanBankName.OLKY, activeVirtualIban: null }); }); it('should return the instant bank for instant transfers', async () => { jest.spyOn(bankService, 'getBank').mockResolvedValue(olkyEUR); - await expect(txHelper['getBankIn'](eur, FiatPaymentMethod.INSTANT, undefined)).resolves.toBe(IbanBankName.OLKY); + await expect(txHelper['getBankIn'](eur, FiatPaymentMethod.INSTANT, undefined)).resolves.toEqual({ + bankName: IbanBankName.OLKY, + activeVirtualIban: undefined, + }); }); it('should return the default bank for card payments', async () => { - await expect(txHelper['getBankIn'](eur, FiatPaymentMethod.CARD, undefined)).resolves.toBe(CardBankName.CHECKOUT); + await expect(txHelper['getBankIn'](eur, FiatPaymentMethod.CARD, undefined)).resolves.toEqual({ + bankName: CardBankName.CHECKOUT, + activeVirtualIban: undefined, + }); }); it('should fall back to the default bank if no deposit bank is found', async () => { jest.spyOn(bankService, 'getBank').mockResolvedValue(undefined); - await expect(txHelper['getBankIn'](eur, FiatPaymentMethod.BANK, undefined)).resolves.toBe(IbanBankName.YAPEAL); + await expect(txHelper['getBankIn'](eur, FiatPaymentMethod.BANK, undefined)).resolves.toEqual({ + bankName: IbanBankName.YAPEAL, + activeVirtualIban: undefined, + }); }); }); diff --git a/src/subdomains/supporting/payment/dto/transaction-helper/transaction-details.dto.ts b/src/subdomains/supporting/payment/dto/transaction-helper/transaction-details.dto.ts index 3a5313db87..a0d6d442cc 100644 --- a/src/subdomains/supporting/payment/dto/transaction-helper/transaction-details.dto.ts +++ b/src/subdomains/supporting/payment/dto/transaction-helper/transaction-details.dto.ts @@ -1,3 +1,4 @@ +import { VirtualIban } from 'src/subdomains/supporting/bank/virtual-iban/virtual-iban.entity'; import { PriceStep } from 'src/subdomains/supporting/pricing/domain/entities/price'; import { FeeDto } from '../fee.dto'; import { QuoteError } from './quote-error.enum'; @@ -23,4 +24,11 @@ export interface TransactionDetails extends TargetEstimation { /** @deprecated Use `errors` instead */ error?: QuoteError; errors: QuoteError[]; + /** + * The user's active receiving vIBAN as resolved while picking the receiving bank, so a caller that also + * needs it for the deposit destination can reuse it instead of repeating the lookup. `null` means the + * lookup ran and found none; `undefined` means no lookup ran (non-bank transfer, no userData, or an + * overridden bank) and the caller must resolve it itself. + */ + activeVirtualIban?: VirtualIban | null; } diff --git a/src/subdomains/supporting/payment/services/transaction-helper.ts b/src/subdomains/supporting/payment/services/transaction-helper.ts index 711cb2a708..715bee9ae0 100644 --- a/src/subdomains/supporting/payment/services/transaction-helper.ts +++ b/src/subdomains/supporting/payment/services/transaction-helper.ts @@ -39,6 +39,7 @@ import { BankTxReturn } from '../../bank-tx/bank-tx-return/bank-tx-return.entity import { BankTx } from '../../bank-tx/bank-tx/entities/bank-tx.entity'; import { BankService } from '../../bank/bank/bank.service'; import { CardBankName, IbanBankName } from '../../bank/bank/dto/bank.dto'; +import { VirtualIban } from '../../bank/virtual-iban/virtual-iban.entity'; import { VirtualIbanService } from '../../bank/virtual-iban/virtual-iban.service'; import { CryptoInput, PayInConfirmationType } from '../../payin/entities/crypto-input.entity'; import { PriceCurrency, PriceValidity, PricingService } from '../../pricing/services/pricing.service'; @@ -274,7 +275,12 @@ export class TransactionHelper implements OnModuleInit { const chfPrice = await this.pricingService.getPrice(txAsset, PriceCurrency.CHF, PriceValidity.ANY); const txAmountChf = chfPrice.convert(txAmount); - const bankIn = bankInOverride ?? (await this.getBankIn(from, paymentMethodIn, user?.userData)); + // getBankIn resolves the user's active vIBAN to pick the receiving bank. It is surfaced on the result + // so the caller can reuse it for the deposit destination instead of repeating the same lookup. + const resolvedBankIn = bankInOverride + ? { bankName: bankInOverride, activeVirtualIban: undefined } + : await this.getBankIn(from, paymentMethodIn, user?.userData); + const bankIn = resolvedBankIn.bankName; const bankOut = TransactionHelper.getDefaultBankByPaymentMethod(paymentMethodOut); const wallet = walletName ? await this.walletService.getByIdOrName(undefined, walletName) : undefined; @@ -355,6 +361,7 @@ export class TransactionHelper implements OnModuleInit { isValid: !errors.length, error: errors[0], errors, + activeVirtualIban: resolvedBankIn.activeVirtualIban, }; } @@ -876,16 +883,21 @@ export class TransactionHelper implements OnModuleInit { from: Active, paymentMethodIn: PaymentMethod, userData?: UserData, - ): Promise { + ): Promise<{ bankName: CardBankName | IbanBankName | undefined; activeVirtualIban?: VirtualIban | null }> { const isBankTransfer = isFiat(from) && [FiatPaymentMethod.BANK, FiatPaymentMethod.INSTANT].includes(paymentMethodIn as FiatPaymentMethod); - if (!isBankTransfer) return TransactionHelper.getDefaultBankByPaymentMethod(paymentMethodIn); + if (!isBankTransfer) + return { + bankName: TransactionHelper.getDefaultBankByPaymentMethod(paymentMethodIn), + activeVirtualIban: undefined, + }; // vIBAN deposits are received at the vIBAN bank + let activeVirtualIban: VirtualIban | null | undefined; if (userData) { - const virtualIban = await this.virtualIbanService.getActiveReceivingForUserAndCurrency(userData, from.name); - if (virtualIban?.bank.receive) return virtualIban.bank.name; + activeVirtualIban = await this.virtualIbanService.getActiveReceivingForUserAndCurrency(userData, from.name); + if (activeVirtualIban?.bank.receive) return { bankName: activeVirtualIban.bank.name, activeVirtualIban }; } const bank = await this.bankService.getBank({ @@ -894,7 +906,10 @@ export class TransactionHelper implements OnModuleInit { userData, }); - return bank?.name ?? TransactionHelper.getDefaultBankByPaymentMethod(paymentMethodIn); + return { + bankName: bank?.name ?? TransactionHelper.getDefaultBankByPaymentMethod(paymentMethodIn), + activeVirtualIban, + }; } static getDefaultBankByPaymentMethod(paymentMethod: PaymentMethod): CardBankName | IbanBankName { From 44e4d6a12fa9f587489f1d50a2ad83d1e5b578f4 Mon Sep 17 00:00:00 2001 From: Daniel Padrino Date: Fri, 31 Jul 2026 11:40:55 -0300 Subject: [PATCH 2/4] fix(buy): re-read a negative vIBAN lookup, and guard the preloaded user MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up on the payment-info deduplication. Reusing the vIBAN lookup was wrong for the negative case. A "none found" answer is a whole getTxDetails (pricing, fees, limits) old by the time resolveBankInfo sees it, and the branch that follows issues an IBAN. If a concurrent request issued one in that window, the stale negative caused createForUser to hit a duplicate, swallow the ConflictException and fall into the fail-closed throw — a 400 PersonalIbanIssuanceFailed where the previous fresh read returned the IBAN. Bank 15 issued 460 user-level vIBANs in the last 90 days and several accounts hold multiple active ones, so concurrent requests demonstrably reach this path. Only a positive result is reused now; the negative path re-reads, which costs one SELECT before an external issuance call anyway. toPaymentInfoDto now rejects a preloadedUser whose id does not match userId — the transaction request is attributed to userId, so a mismatch would book it against another account — and documents that the user must carry PAYMENT_INFO_USER_RELATIONS, since getTxErrors dereferences user.wallet without optional chaining. Test gaps that let the change revert unnoticed are closed: getTxDetails actually returning the resolved vIBAN, createBuyPaymentInfo handing its user down, and the relation set itself (getUser is mocked everywhere, so deleting wallet: true was invisible). The trade-approval gate that the shared relation set restores now has direct coverage in a new payment-info.service spec. --- .../__tests__/payment-info.service.spec.ts | 60 +++++++++++++++++++ .../routes/buy/__tests__/buy.service.spec.ts | 56 ++++++++++++++++- .../core/buy-crypto/routes/buy/buy.service.ts | 22 +++++-- .../__tests__/transaction-helper.spec.ts | 41 ++++++++++++- 4 files changed, 171 insertions(+), 8 deletions(-) create mode 100644 src/shared/services/__tests__/payment-info.service.spec.ts diff --git a/src/shared/services/__tests__/payment-info.service.spec.ts b/src/shared/services/__tests__/payment-info.service.spec.ts new file mode 100644 index 0000000000..1af2f888f5 --- /dev/null +++ b/src/shared/services/__tests__/payment-info.service.spec.ts @@ -0,0 +1,60 @@ +import { createMock } from '@golevelup/ts-jest'; +import { BadRequestException } from '@nestjs/common'; +import { Asset } from 'src/shared/models/asset/asset.entity'; +import { AssetService } from 'src/shared/models/asset/asset.service'; +import { Fiat } from 'src/shared/models/fiat/fiat.entity'; +import { FiatService } from 'src/shared/models/fiat/fiat.service'; +import { FiatPaymentMethod } from 'src/subdomains/supporting/payment/dto/payment-method.enum'; +import { GetBuyPaymentInfoDto } from 'src/subdomains/core/buy-crypto/routes/buy/dto/get-buy-payment-info.dto'; +import { PaymentInfoService } from '../payment-info.service'; +import * as processServiceModule from '../process.service'; + +// The trade-approval gate reads user.wallet. Callers that load the user without the wallet relation see +// an undefined wallet, which silently disables both escape hatches below - so these assert the gate +// against a user that actually carries one. +describe('PaymentInfoService buyCheck trade-approval gate', () => { + let service: PaymentInfoService; + let fiatService: FiatService; + let assetService: AssetService; + + const currency = { id: 2, name: 'EUR', sellable: true } as Fiat; + const asset = { id: 10, name: 'BTC', buyable: true } as Asset; + + function dto(): GetBuyPaymentInfoDto { + return { amount: 100, currency, asset, paymentMethod: FiatPaymentMethod.BANK } as GetBuyPaymentInfoDto; + } + + function user(wallet: Record, tradeApprovalDate?: Date) { + return { id: 1, userData: { kycLevel: 50, tradeApprovalDate }, wallet } as any; + } + + beforeEach(() => { + fiatService = createMock(); + assetService = createMock(); + jest.spyOn(fiatService, 'getFiat').mockResolvedValue(currency); + jest.spyOn(assetService, 'getAssetById').mockResolvedValue(asset); + jest.spyOn(processServiceModule, 'DisabledProcess').mockReturnValue(false); + + service = new PaymentInfoService(fiatService, assetService); + }); + + afterEach(() => jest.restoreAllMocks()); + + it('lets an autoTradeApproval wallet through without a tradeApprovalDate', async () => { + await expect( + service.buyCheck(dto(), undefined, user({ autoTradeApproval: true, amlRuleList: [] })), + ).resolves.toBeDefined(); + }); + + it('still rejects a wallet without autoTradeApproval and without a tradeApprovalDate', async () => { + await expect( + service.buyCheck(dto(), undefined, user({ autoTradeApproval: false, amlRuleList: [] })), + ).rejects.toThrow(BadRequestException); + }); + + it('accepts a wallet without autoTradeApproval once a tradeApprovalDate is set', async () => { + await expect( + service.buyCheck(dto(), undefined, user({ autoTradeApproval: false, amlRuleList: [] }, new Date())), + ).resolves.toBeDefined(); + }); +}); diff --git a/src/subdomains/core/buy-crypto/routes/buy/__tests__/buy.service.spec.ts b/src/subdomains/core/buy-crypto/routes/buy/__tests__/buy.service.spec.ts index 2bd27d700c..70ecda2c43 100644 --- a/src/subdomains/core/buy-crypto/routes/buy/__tests__/buy.service.spec.ts +++ b/src/subdomains/core/buy-crypto/routes/buy/__tests__/buy.service.spec.ts @@ -167,6 +167,54 @@ describe('BuyService', () => { expect(virtualIbanService.getOrCreateFrickForUser).not.toHaveBeenCalled(); }); + // The user is loaded once and handed to toPaymentInfoDto. Loading it a second time there costs two + // extra queries, and letting the two loads drift apart is what left user.wallet unloaded for buyCheck. + it('loads the user once and reuses it for the payment-info DTO', async () => { + const loadedUser = { id: 1, userData, wallet: {} } as any; + const getUser = jest.spyOn(userService, 'getUser').mockResolvedValue(loadedUser); + jest.spyOn(paymentInfoService, 'buyCheck').mockImplementation(async (d) => d as any); + jest.spyOn(service, 'createBuy').mockResolvedValue(buy); + jest.spyOn(virtualIbanService, 'getOrCreateFrickForUser').mockResolvedValue(virtualIban); + const fees = { min: 0, rate: 0.01, fixed: 0, dfx: 1, network: 0, platform: 0, bank: 0, total: 1 }; + jest.spyOn(transactionHelper, 'getTxDetails').mockResolvedValue({ + timestamp: new Date('2026-07-24T00:00:00Z'), + minVolume: 10, + minVolumeTarget: 0.001, + maxVolume: 10000, + maxVolumeTarget: 1, + exchangeRate: 100000, + rate: 101000, + estimatedAmount: 0.00099, + sourceAmount: 100, + isValid: false, + exactPrice: false, + feeSource: fees, + feeTarget: fees, + priceSteps: [], + } as any); + + await service.createBuyPaymentInfo({ user: 1, address: '0x123' } as any, dto()); + + expect(getUser).toHaveBeenCalledTimes(1); + // wallet is the relation buyCheck reads (as user.wallet, not userData.wallet) and organization is + // what UserData.address needs - asserted explicitly, because getUser is mocked and would otherwise + // hand back a fully-populated user no matter which relations were requested. + expect(getUser).toHaveBeenCalledWith(1, { userData: { organization: true }, wallet: true }); + // and the fee calculation sees the very same instance + expect((transactionHelper.getTxDetails as jest.Mock).mock.calls[0][7]).toBe(loadedUser); + }); + + // the transaction request is attributed to userId, so a preloaded user for someone else would book + // the request against the wrong account instead of failing + it('refuses a preloaded user that does not match the requested user', async () => { + await expect(service.toPaymentInfoDto(1, buy, dto(), { id: 2 } as any)).rejects.toThrow( + 'preloadedUser does not match userId', + ); + + expect(transactionHelper.getTxDetails).not.toHaveBeenCalled(); + expect(transactionRequestService.create).not.toHaveBeenCalled(); + }); + it('selects Frick once before fee calculation, persists exact IDs, and does not leak IDs publicly', async () => { const events: string[] = []; jest.spyOn(userService, 'getUser').mockResolvedValue({ id: 1, userData, wallet: {} } as any); @@ -1331,10 +1379,14 @@ describe('BuyService', () => { // CARD on purpose: a BANK transfer with no personal IBAN fails closed by design, so the "none // found" outcome can only be observed on the payment method that still resolves a bank. - it('does not repeat the lookup when getTxDetails resolved that there is none', async () => { + // A negative result is deliberately re-read: it is a whole getTxDetails old, and the issuance branch + // follows. Reusing it would miss a vIBAN issued concurrently in that window and turn the lost race + // into a fail-closed PersonalIbanIssuanceFailed. + it('re-resolves when getTxDetails found none, so a concurrently issued vIBAN is not missed', async () => { jest .spyOn(transactionHelper, 'getTxDetails') .mockResolvedValue({ ...feeResult(), activeVirtualIban: null } as any); + jest.spyOn(virtualIbanService, 'getActiveReceivingForUserAndCurrency').mockResolvedValue(null); await service.toPaymentInfoDto(1, buy, { amount: 100, @@ -1344,7 +1396,7 @@ describe('BuyService', () => { exactPrice: false, } as GetBuyPaymentInfoDto); - expect(virtualIbanService.getActiveReceivingForUserAndCurrency).not.toHaveBeenCalled(); + expect(virtualIbanService.getActiveReceivingForUserAndCurrency).toHaveBeenCalledTimes(1); }); it('resolves it itself when getTxDetails ran no lookup', async () => { diff --git a/src/subdomains/core/buy-crypto/routes/buy/buy.service.ts b/src/subdomains/core/buy-crypto/routes/buy/buy.service.ts index c4825f08c3..a7969ff38d 100644 --- a/src/subdomains/core/buy-crypto/routes/buy/buy.service.ts +++ b/src/subdomains/core/buy-crypto/routes/buy/buy.service.ts @@ -279,12 +279,21 @@ export class BuyService { return this.buyRepo; } + /** + * @param preloadedUser the user for `userId`, saving a second load. Must be loaded with + * {@link PAYMENT_INFO_USER_RELATIONS} — `getTxErrors` dereferences `user.wallet` without optional + * chaining, so a differently-loaded user fails at runtime rather than degrading. + */ async toPaymentInfoDto( userId: number, buy: Buy, dto: GetBuyPaymentInfoDto, preloadedUser?: User, ): Promise { + // the request is attributed to userId further down, so a mismatch would book it against another account + if (preloadedUser && preloadedUser.id !== userId) + throw new Error('toPaymentInfoDto: preloadedUser does not match userId'); + const user = preloadedUser ?? (await this.userService.getUser(userId, PAYMENT_INFO_USER_RELATIONS)); // Explicit personal-IBAN selector dispatch is exhaustive and fail-closed. Frick resolves the @@ -554,12 +563,15 @@ export class BuyService { } } - // user-level vIBAN — reuse the caller's lookup when it already ran one for this (userData, currency); - // null means "resolved, none found", undefined means "not resolved". + // user-level vIBAN — reuse the caller's lookup only when it actually found one. A negative result is + // deliberately NOT reused: it is up to a full getTxDetails (pricing, fees, limits) old by now, and the + // branch below issues an IBAN. A concurrent request that issued one in that window would otherwise be + // missed here, createForUser would hit a duplicate and swallow it, and the customer would get a + // fail-closed PersonalIbanIssuanceFailed where a fresh read returns the IBAN. Re-reading costs one + // SELECT on a path that is about to make an external issuance call anyway. let virtualIban = - activeVirtualIban !== undefined - ? activeVirtualIban - : await this.virtualIbanService.getActiveReceivingForUserAndCurrency(selector.userData, selector.currency); + activeVirtualIban ?? + (await this.virtualIbanService.getActiveReceivingForUserAndCurrency(selector.userData, selector.currency)); // create a personal IBAN for an eligible KYC 50+ user if ( diff --git a/src/subdomains/core/history/__tests__/transaction-helper.spec.ts b/src/subdomains/core/history/__tests__/transaction-helper.spec.ts index 7cf02ee437..dec471d848 100644 --- a/src/subdomains/core/history/__tests__/transaction-helper.spec.ts +++ b/src/subdomains/core/history/__tests__/transaction-helper.spec.ts @@ -333,7 +333,7 @@ describe('TransactionHelper', () => { jest.spyOn(txHelper as any, 'getTargetSpecs').mockResolvedValue({ volume: { min: 0, max: 1000 } }); jest.spyOn(txHelper as any, 'getTargetEstimation').mockResolvedValue({ sourceAmount: 100 }); - await txHelper.getTxDetails( + const details = await txHelper.getTxDetails( 100, undefined, from, @@ -351,6 +351,45 @@ describe('TransactionHelper', () => { expect(getBankIn).not.toHaveBeenCalled(); expect(getAllFees.mock.calls[0][4]).toBe(IbanBankName.FRICK); + // no lookup ran, so the caller must resolve the vIBAN itself rather than treat this as "none found" + expect(details.activeVirtualIban).toBeUndefined(); + }); + + it('hands the resolved active vIBAN to the caller so the deposit destination is not looked up again', async () => { + const from = createCustomFiat({ name: 'EUR' }); + const to = createCustomFiat({ name: 'CHF' }); + const activeVirtualIban = createCustomVirtualIban({ bank: yapealEUR }); + const user = { userData: createCustomUserData({ kycLevel: KycLevel.LEVEL_50 }) } as any; + jest.spyOn(pricingService, 'getPrice').mockResolvedValue({ convert: () => 100 } as any); + jest.spyOn(virtualIbanService, 'getActiveReceivingForUserAndCurrency').mockResolvedValue(activeVirtualIban); + jest + .spyOn(txHelper as any, 'getAllFees') + .mockResolvedValue([ + { network: 0, dfx: { rate: 0, fixed: 0 }, bank: { rate: 0, fixed: 0 }, partner: { rate: 0, fixed: 0 } }, + 0, + ]); + jest.spyOn(txHelper as any, 'getMinSpecs').mockReturnValue({ minFee: 0, minVolume: 0 }); + jest.spyOn(txHelper as any, 'getLimits').mockResolvedValue({ kycLimit: 1000, defaultLimit: 1000 }); + jest.spyOn(txHelper as any, 'getTxErrors').mockReturnValue([]); + jest.spyOn(txHelper as any, 'getSourceSpecs').mockResolvedValue({ volume: { min: 0, max: 1000 } }); + jest.spyOn(txHelper as any, 'getTargetSpecs').mockResolvedValue({ volume: { min: 0, max: 1000 } }); + jest.spyOn(txHelper as any, 'getTargetEstimation').mockResolvedValue({ sourceAmount: 100 }); + + const details = await txHelper.getTxDetails( + 100, + undefined, + from, + to, + FiatPaymentMethod.BANK, + FiatPaymentMethod.BANK, + false, + user, + undefined, + [], + ); + + expect(virtualIbanService.getActiveReceivingForUserAndCurrency).toHaveBeenCalledTimes(1); + expect(details.activeVirtualIban).toBe(activeVirtualIban); }); it('uses the persisted bank selection when regenerating a completed buy invoice', async () => { From 7a553d576179a3f156f023f1d7e776bfd0438ab6 Mon Sep 17 00:00:00 2001 From: Daniel Padrino Date: Fri, 31 Jul 2026 12:01:44 -0300 Subject: [PATCH 3/4] refactor(buy): make a negative vIBAN result unrepresentable instead of documented MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up. The previous commit fixed the stale-negative race with a runtime check, but kept a null/undefined tri-state that nothing consumed and whose doc still told callers a negative result was reusable — the exact reasoning that produces the fail-closed PersonalIbanIssuanceFailed. getBankIn now normalises "none found" to undefined and the type is narrowed to VirtualIban | undefined, so a negative cannot be represented at all and reuse is only ever a positive hit. The doc says why, rather than describing a distinction that no longer exists. Also from review: the negative-path test asserted only a call count, so a mutation that re-read and then discarded the result still passed. Its fresh read now returns a vIBAN — standing in for one issued concurrently in that window — and the test asserts that IBAN reaches the response. Smaller conformance fixes: sorted and absolutised the new spec's imports, added the missing return type on its user() helper and used the KycLevel enum rather than a bare 50, and gave the preloaded-user guard a capitalised message without the methodName prefix, which appears nowhere else in src. --- .../__tests__/payment-info.service.spec.ts | 10 ++++++---- .../routes/buy/__tests__/buy.service.spec.ts | 17 ++++++++++------- .../core/buy-crypto/routes/buy/buy.service.ts | 5 ++--- .../__tests__/transaction-helper.spec.ts | 8 ++++---- .../transaction-details.dto.ts | 12 ++++++++---- .../payment/services/transaction-helper.ts | 7 ++++--- 6 files changed, 34 insertions(+), 25 deletions(-) diff --git a/src/shared/services/__tests__/payment-info.service.spec.ts b/src/shared/services/__tests__/payment-info.service.spec.ts index 1af2f888f5..35ab2e7cca 100644 --- a/src/shared/services/__tests__/payment-info.service.spec.ts +++ b/src/shared/services/__tests__/payment-info.service.spec.ts @@ -4,10 +4,12 @@ import { Asset } from 'src/shared/models/asset/asset.entity'; import { AssetService } from 'src/shared/models/asset/asset.service'; import { Fiat } from 'src/shared/models/fiat/fiat.entity'; import { FiatService } from 'src/shared/models/fiat/fiat.service'; -import { FiatPaymentMethod } from 'src/subdomains/supporting/payment/dto/payment-method.enum'; import { GetBuyPaymentInfoDto } from 'src/subdomains/core/buy-crypto/routes/buy/dto/get-buy-payment-info.dto'; +import { KycLevel } from 'src/subdomains/generic/user/models/user-data/user-data.enum'; +import { User } from 'src/subdomains/generic/user/models/user/user.entity'; +import { FiatPaymentMethod } from 'src/subdomains/supporting/payment/dto/payment-method.enum'; +import * as processServiceModule from 'src/shared/services/process.service'; import { PaymentInfoService } from '../payment-info.service'; -import * as processServiceModule from '../process.service'; // The trade-approval gate reads user.wallet. Callers that load the user without the wallet relation see // an undefined wallet, which silently disables both escape hatches below - so these assert the gate @@ -24,8 +26,8 @@ describe('PaymentInfoService buyCheck trade-approval gate', () => { return { amount: 100, currency, asset, paymentMethod: FiatPaymentMethod.BANK } as GetBuyPaymentInfoDto; } - function user(wallet: Record, tradeApprovalDate?: Date) { - return { id: 1, userData: { kycLevel: 50, tradeApprovalDate }, wallet } as any; + function user(wallet: Record, tradeApprovalDate?: Date): User { + return { id: 1, userData: { kycLevel: KycLevel.LEVEL_50, tradeApprovalDate }, wallet } as unknown as User; } beforeEach(() => { diff --git a/src/subdomains/core/buy-crypto/routes/buy/__tests__/buy.service.spec.ts b/src/subdomains/core/buy-crypto/routes/buy/__tests__/buy.service.spec.ts index 70ecda2c43..1b55a1f603 100644 --- a/src/subdomains/core/buy-crypto/routes/buy/__tests__/buy.service.spec.ts +++ b/src/subdomains/core/buy-crypto/routes/buy/__tests__/buy.service.spec.ts @@ -208,7 +208,7 @@ describe('BuyService', () => { // the request against the wrong account instead of failing it('refuses a preloaded user that does not match the requested user', async () => { await expect(service.toPaymentInfoDto(1, buy, dto(), { id: 2 } as any)).rejects.toThrow( - 'preloadedUser does not match userId', + 'Preloaded user does not match userId', ); expect(transactionHelper.getTxDetails).not.toHaveBeenCalled(); @@ -1380,23 +1380,26 @@ describe('BuyService', () => { // CARD on purpose: a BANK transfer with no personal IBAN fails closed by design, so the "none // found" outcome can only be observed on the payment method that still resolves a bank. // A negative result is deliberately re-read: it is a whole getTxDetails old, and the issuance branch - // follows. Reusing it would miss a vIBAN issued concurrently in that window and turn the lost race - // into a fail-closed PersonalIbanIssuanceFailed. + // follows. The fresh read here returns a vIBAN, standing in for one issued concurrently in that + // window — reusing the stale negative would lose it and fail closed with PersonalIbanIssuanceFailed. it('re-resolves when getTxDetails found none, so a concurrently issued vIBAN is not missed', async () => { jest .spyOn(transactionHelper, 'getTxDetails') - .mockResolvedValue({ ...feeResult(), activeVirtualIban: null } as any); - jest.spyOn(virtualIbanService, 'getActiveReceivingForUserAndCurrency').mockResolvedValue(null); + .mockResolvedValue({ ...feeResult(), activeVirtualIban: undefined } as any); + jest.spyOn(virtualIbanService, 'getActiveReceivingForUserAndCurrency').mockResolvedValue(userLevelVirtualIban); - await service.toPaymentInfoDto(1, buy, { + const response = await service.toPaymentInfoDto(1, buy, { amount: 100, currency, asset, - paymentMethod: FiatPaymentMethod.CARD, + paymentMethod: FiatPaymentMethod.BANK, exactPrice: false, } as GetBuyPaymentInfoDto); expect(virtualIbanService.getActiveReceivingForUserAndCurrency).toHaveBeenCalledTimes(1); + // the freshly-read IBAN must reach the response, not the stale "none" + expect(response.iban).toBe(userLevelVirtualIban.iban); + expect(virtualIbanService.createForUser).not.toHaveBeenCalled(); }); it('resolves it itself when getTxDetails ran no lookup', async () => { diff --git a/src/subdomains/core/buy-crypto/routes/buy/buy.service.ts b/src/subdomains/core/buy-crypto/routes/buy/buy.service.ts index a7969ff38d..b1c5fd5484 100644 --- a/src/subdomains/core/buy-crypto/routes/buy/buy.service.ts +++ b/src/subdomains/core/buy-crypto/routes/buy/buy.service.ts @@ -291,8 +291,7 @@ export class BuyService { preloadedUser?: User, ): Promise { // the request is attributed to userId further down, so a mismatch would book it against another account - if (preloadedUser && preloadedUser.id !== userId) - throw new Error('toPaymentInfoDto: preloadedUser does not match userId'); + if (preloadedUser && preloadedUser.id !== userId) throw new Error('Preloaded user does not match userId'); const user = preloadedUser ?? (await this.userService.getUser(userId, PAYMENT_INFO_USER_RELATIONS)); @@ -502,7 +501,7 @@ export class BuyService { asset?: Asset, wallet?: Wallet, personalIbanProvider?: PersonalIbanProvider, - activeVirtualIban?: VirtualIban | null, + activeVirtualIban?: VirtualIban, ): Promise<{ bankInfo: BankInfoDto & { isPersonalIban: boolean; reference?: string }; bankId: number; diff --git a/src/subdomains/core/history/__tests__/transaction-helper.spec.ts b/src/subdomains/core/history/__tests__/transaction-helper.spec.ts index dec471d848..50ace5a7ff 100644 --- a/src/subdomains/core/history/__tests__/transaction-helper.spec.ts +++ b/src/subdomains/core/history/__tests__/transaction-helper.spec.ts @@ -260,15 +260,15 @@ describe('TransactionHelper', () => { const eur = createCustomFiat({ name: 'EUR' }); // getBankIn returns the resolved bank together with the vIBAN it looked up, so getTxDetails can hand - // that vIBAN to the caller instead of having it repeat the lookup. `activeVirtualIban` is null when the - // lookup ran and found none, and undefined when no lookup ran at all. + // that vIBAN to the caller instead of having it repeat the lookup. Only a positive result is reusable, + // so "none found" and "no lookup ran" are both undefined. it('should return the deposit bank for bank transfers', async () => { jest.spyOn(virtualIbanService, 'getActiveReceivingForUserAndCurrency').mockResolvedValue(null); jest.spyOn(bankService, 'getBank').mockResolvedValue(olkyEUR); await expect( txHelper['getBankIn'](eur, FiatPaymentMethod.BANK, createCustomUserData({ kycLevel: KycLevel.LEVEL_30 })), - ).resolves.toEqual({ bankName: IbanBankName.OLKY, activeVirtualIban: null }); + ).resolves.toEqual({ bankName: IbanBankName.OLKY, activeVirtualIban: undefined }); }); it('should return the vIBAN bank for users with an active vIBAN', async () => { @@ -286,7 +286,7 @@ describe('TransactionHelper', () => { await expect( txHelper['getBankIn'](eur, FiatPaymentMethod.BANK, createCustomUserData({ kycLevel: KycLevel.LEVEL_50 })), - ).resolves.toEqual({ bankName: IbanBankName.OLKY, activeVirtualIban: null }); + ).resolves.toEqual({ bankName: IbanBankName.OLKY, activeVirtualIban: undefined }); }); it('should return the instant bank for instant transfers', async () => { diff --git a/src/subdomains/supporting/payment/dto/transaction-helper/transaction-details.dto.ts b/src/subdomains/supporting/payment/dto/transaction-helper/transaction-details.dto.ts index a0d6d442cc..f3a7811b4f 100644 --- a/src/subdomains/supporting/payment/dto/transaction-helper/transaction-details.dto.ts +++ b/src/subdomains/supporting/payment/dto/transaction-helper/transaction-details.dto.ts @@ -26,9 +26,13 @@ export interface TransactionDetails extends TargetEstimation { errors: QuoteError[]; /** * The user's active receiving vIBAN as resolved while picking the receiving bank, so a caller that also - * needs it for the deposit destination can reuse it instead of repeating the lookup. `null` means the - * lookup ran and found none; `undefined` means no lookup ran (non-bank transfer, no userData, or an - * overridden bank) and the caller must resolve it itself. + * needs it for the deposit destination can reuse it instead of repeating the lookup. Undefined when none + * was found, or when no lookup ran at all (non-bank transfer, no userData, or an overridden bank). + * + * Only a positive result is reusable. A caller must NOT treat undefined as a settled "there is none" + * and skip its own read before issuing an IBAN: by then this value is a whole getTxDetails old, and a + * vIBAN issued concurrently in that window would be missed — which surfaces as a fail-closed + * PersonalIbanIssuanceFailed once the duplicate issuance is swallowed. */ - activeVirtualIban?: VirtualIban | null; + activeVirtualIban?: VirtualIban; } diff --git a/src/subdomains/supporting/payment/services/transaction-helper.ts b/src/subdomains/supporting/payment/services/transaction-helper.ts index 715bee9ae0..5e437c25fc 100644 --- a/src/subdomains/supporting/payment/services/transaction-helper.ts +++ b/src/subdomains/supporting/payment/services/transaction-helper.ts @@ -883,7 +883,7 @@ export class TransactionHelper implements OnModuleInit { from: Active, paymentMethodIn: PaymentMethod, userData?: UserData, - ): Promise<{ bankName: CardBankName | IbanBankName | undefined; activeVirtualIban?: VirtualIban | null }> { + ): Promise<{ bankName: CardBankName | IbanBankName | undefined; activeVirtualIban?: VirtualIban }> { const isBankTransfer = isFiat(from) && [FiatPaymentMethod.BANK, FiatPaymentMethod.INSTANT].includes(paymentMethodIn as FiatPaymentMethod); @@ -894,9 +894,10 @@ export class TransactionHelper implements OnModuleInit { }; // vIBAN deposits are received at the vIBAN bank - let activeVirtualIban: VirtualIban | null | undefined; + let activeVirtualIban: VirtualIban | undefined; if (userData) { - activeVirtualIban = await this.virtualIbanService.getActiveReceivingForUserAndCurrency(userData, from.name); + activeVirtualIban = + (await this.virtualIbanService.getActiveReceivingForUserAndCurrency(userData, from.name)) ?? undefined; if (activeVirtualIban?.bank.receive) return { bankName: activeVirtualIban.bank.name, activeVirtualIban }; } From cbc1b7ac1f9e7b20b4c94e5a3d6d8ade2cd0cccb Mon Sep 17 00:00:00 2001 From: Daniel Padrino Date: Fri, 31 Jul 2026 12:14:51 -0300 Subject: [PATCH 4/4] docs(buy): correct a stale test comment and the preloaded-user contract Review follow-up, no behaviour change. The negative-path test moved from CARD to BANK when its fresh read started returning a vIBAN, but kept the comment explaining why CARD was necessary. That rationale belongs to the following test, which is still CARD, and was actively wrong where it stood. The preloaded-user JSDoc pointed at a module-private const via {@link}, so a cross-module caller could not reach the contract it was told to satisfy. The relation set is spelled out instead, which keeps the const private and the guidance usable. Also sorts the new spec's process.service import into its alphabetical position, which the previous commit claimed to have done. --- src/shared/services/__tests__/payment-info.service.spec.ts | 2 +- .../buy-crypto/routes/buy/__tests__/buy.service.spec.ts | 5 +++-- src/subdomains/core/buy-crypto/routes/buy/buy.service.ts | 6 +++--- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/shared/services/__tests__/payment-info.service.spec.ts b/src/shared/services/__tests__/payment-info.service.spec.ts index 35ab2e7cca..c63c873ef6 100644 --- a/src/shared/services/__tests__/payment-info.service.spec.ts +++ b/src/shared/services/__tests__/payment-info.service.spec.ts @@ -4,11 +4,11 @@ import { Asset } from 'src/shared/models/asset/asset.entity'; import { AssetService } from 'src/shared/models/asset/asset.service'; import { Fiat } from 'src/shared/models/fiat/fiat.entity'; import { FiatService } from 'src/shared/models/fiat/fiat.service'; +import * as processServiceModule from 'src/shared/services/process.service'; import { GetBuyPaymentInfoDto } from 'src/subdomains/core/buy-crypto/routes/buy/dto/get-buy-payment-info.dto'; import { KycLevel } from 'src/subdomains/generic/user/models/user-data/user-data.enum'; import { User } from 'src/subdomains/generic/user/models/user/user.entity'; import { FiatPaymentMethod } from 'src/subdomains/supporting/payment/dto/payment-method.enum'; -import * as processServiceModule from 'src/shared/services/process.service'; import { PaymentInfoService } from '../payment-info.service'; // The trade-approval gate reads user.wallet. Callers that load the user without the wallet relation see diff --git a/src/subdomains/core/buy-crypto/routes/buy/__tests__/buy.service.spec.ts b/src/subdomains/core/buy-crypto/routes/buy/__tests__/buy.service.spec.ts index 1b55a1f603..1542e3da03 100644 --- a/src/subdomains/core/buy-crypto/routes/buy/__tests__/buy.service.spec.ts +++ b/src/subdomains/core/buy-crypto/routes/buy/__tests__/buy.service.spec.ts @@ -1377,8 +1377,6 @@ describe('BuyService', () => { expect(virtualIbanService.getActiveReceivingForUserAndCurrency).not.toHaveBeenCalled(); }); - // CARD on purpose: a BANK transfer with no personal IBAN fails closed by design, so the "none - // found" outcome can only be observed on the payment method that still resolves a bank. // A negative result is deliberately re-read: it is a whole getTxDetails old, and the issuance branch // follows. The fresh read here returns a vIBAN, standing in for one issued concurrently in that // window — reusing the stale negative would lose it and fail closed with PersonalIbanIssuanceFailed. @@ -1402,6 +1400,9 @@ describe('BuyService', () => { expect(virtualIbanService.createForUser).not.toHaveBeenCalled(); }); + // CARD on purpose: a BANK transfer that resolves no personal IBAN fails closed by design, so the + // "nothing found either way" outcome can only be observed on the payment method that still + // resolves a bank. it('resolves it itself when getTxDetails ran no lookup', async () => { jest .spyOn(transactionHelper, 'getTxDetails') diff --git a/src/subdomains/core/buy-crypto/routes/buy/buy.service.ts b/src/subdomains/core/buy-crypto/routes/buy/buy.service.ts index b1c5fd5484..2bf6ac85e9 100644 --- a/src/subdomains/core/buy-crypto/routes/buy/buy.service.ts +++ b/src/subdomains/core/buy-crypto/routes/buy/buy.service.ts @@ -280,9 +280,9 @@ export class BuyService { } /** - * @param preloadedUser the user for `userId`, saving a second load. Must be loaded with - * {@link PAYMENT_INFO_USER_RELATIONS} — `getTxErrors` dereferences `user.wallet` without optional - * chaining, so a differently-loaded user fails at runtime rather than degrading. + * @param preloadedUser the user for `userId`, saving a second load. Must be loaded with the relations + * `{ userData: { organization: true }, wallet: true }` — `getTxErrors` dereferences `user.wallet` + * without optional chaining, so a differently-loaded user fails at runtime rather than degrading. */ async toPaymentInfoDto( userId: number,