From be9a27300e53852c155a48f5ea53c594c618f57e Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sat, 1 Aug 2026 17:32:36 +0200 Subject: [PATCH] Fall back to the collection account when a personal IBAN cannot be issued (#4580) * Fall back to the referenced collection account when personal IBAN issuance fails When a personal IBAN cannot be issued for an eligible bank-transfer customer (e.g. the provider is temporarily unavailable), show the shared collection account with the per-buy reference instead of failing the request. The reference keeps the incoming transfer attributable, so the collection account is shown only when a reference is present; without one the request still fails. The failure is logged at ERROR regardless, so a provider outage stays visible even though the customer no longer sees an error. A customer below KYC 50 keeps getting KYC_REQUIRED, and unsupported currencies keep getting the currency error - the personal-IBAN/KYC coupling is unchanged. Both the explicit-provider path and the user-level path share one collectionAccountOrThrow helper. * Stub the missing collection account in the no-fallback IBAN tests The two negative tests (no collection account resolves) did not stub bankService.getBank, so the default mock returned a truthy bank and the new fallback resolved instead of throwing. Stub getBank to undefined so both pin the no-fallback path: a transient issuance error surfaces as PersonalIbanIssuanceFailed rather than degrading to the transfer fallback. --- .../routes/buy/__tests__/buy.service.spec.ts | 99 +++++++++++++++++-- .../core/buy-crypto/routes/buy/buy.service.ts | 87 ++++++++++------ 2 files changed, 152 insertions(+), 34 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..cd44fc4136 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 @@ -341,8 +341,9 @@ describe('BuyService', () => { }, ); - it('delegates the KYC decision to the locked issuance read and preserves its KycRequired error', async () => { + it('reports KycRequired for an explicit Frick request below KYC 50 after issuance fails', async () => { jest.spyOn(virtualIbanService, 'getOrCreateFrickForUser').mockRejectedValue(new Error(QuoteError.KYC_REQUIRED)); + jest.spyOn(virtualIbanService, 'hasProviderSupportingCurrency').mockReturnValue(true); await expect( service['resolveBankInfo']( @@ -363,6 +364,37 @@ describe('BuyService', () => { ); }); + it('falls back to the referenced collection account when an explicit Frick request fails for an eligible customer', async () => { + jest + .spyOn(virtualIbanService, 'getOrCreateFrickForUser') + .mockRejectedValue(new Error('transient issuance error')); + jest.spyOn(virtualIbanService, 'hasProviderSupportingCurrency').mockReturnValue(true); + const collectionBank = { + id: 16, + name: IbanBankName.OLKY, + iban: 'FR7616798060015010806550926', + receive: true, + } as any; + jest.spyOn(bankService, 'getBank').mockResolvedValue(collectionBank); + const errorLog = jest.spyOn(service['logger'], 'error').mockImplementation(() => undefined); + + const resolved = await service['resolveBankInfo']( + { currency: 'EUR', paymentMethod: FiatPaymentMethod.BANK, userData }, + buy, + asset, + undefined, + PersonalIbanProvider.FRICK, + ); + + expect(resolved).toMatchObject({ bankId: 16, bankName: IbanBankName.OLKY }); + expect(resolved.bankInfo).toMatchObject({ + iban: collectionBank.iban, + isPersonalIban: false, + reference: buy.bankUsage, + }); + expect(errorLog).toHaveBeenCalledTimes(1); + }); + it('uses the standard bank for CARD when implicit providers are ineligible for EUR', async () => { const standardBank = { id: 16, @@ -740,6 +772,9 @@ describe('BuyService', () => { jest.spyOn(virtualIbanService, 'getActiveReceivingForUserAndCurrency').mockResolvedValue(null); jest.spyOn(virtualIbanService, 'isUserEligible').mockReturnValue(true); jest.spyOn(virtualIbanService, 'getOrCreateFrickForUser').mockRejectedValue(transientError); + // No collection account resolves, so the transfer fallback cannot apply and a BadRequest surfaces + // instead of the raw transient error. + jest.spyOn(bankService, 'getBank').mockResolvedValue(undefined); const resolution = service.getBankInfo({ currency: 'EUR', paymentMethod: FiatPaymentMethod.BANK, userData }, buy); @@ -750,23 +785,70 @@ describe('BuyService', () => { // because isUserEligible also folds in provider availability - asking it there would report a // missing KYC level to a customer who holds it whenever the provider is down. expect(virtualIbanService.isUserEligible).toHaveBeenCalledTimes(1); - expect(bankService.getBank).not.toHaveBeenCalled(); + // The transfer fallback is attempted (getBank resolves the collection account); with none mocked + // it resolves to undefined, so a BadRequest still surfaces rather than the raw transient error. + expect(bankService.getBank).toHaveBeenCalled(); }); - it('reports PersonalIbanIssuanceFailed for an eligible EUR transfer after issuance fails', async () => { + it('reports PersonalIbanIssuanceFailed for an eligible EUR transfer when no collection account resolves', async () => { jest.spyOn(virtualIbanService, 'getActiveReceivingForUserAndCurrency').mockResolvedValue(null); jest.spyOn(virtualIbanService, 'isUserEligible').mockReturnValue(true); jest.spyOn(virtualIbanService, 'hasProviderSupportingCurrency').mockReturnValue(true); jest .spyOn(virtualIbanService, 'getOrCreateFrickForUser') .mockRejectedValue(new Error('transient issuance error')); + // No collection bank resolves, so the transfer cannot fall back and still fails. + jest.spyOn(bankService, 'getBank').mockResolvedValue(undefined); await expect( service.getBankInfo({ currency: 'EUR', paymentMethod: FiatPaymentMethod.BANK, userData }, buy), ).rejects.toThrow(QuoteError.PERSONAL_IBAN_ISSUANCE_FAILED); expect(virtualIbanService.getOrCreateFrickForUser).toHaveBeenCalledWith(userData, 'EUR'); - expect(bankService.getBank).not.toHaveBeenCalled(); + expect(bankService.getBank).toHaveBeenCalled(); + }); + + it('falls back to the referenced collection account for an eligible EUR transfer after issuance fails', async () => { + jest.spyOn(virtualIbanService, 'getActiveReceivingForUserAndCurrency').mockResolvedValue(null); + jest.spyOn(virtualIbanService, 'isUserEligible').mockReturnValue(true); + jest.spyOn(virtualIbanService, 'hasProviderSupportingCurrency').mockReturnValue(true); + jest + .spyOn(virtualIbanService, 'getOrCreateFrickForUser') + .mockRejectedValue(new Error('transient issuance error')); + jest.spyOn(bankService, 'getBank').mockResolvedValue(collectionBank); + const errorLog = jest.spyOn(service['logger'], 'error').mockImplementation(() => undefined); + + const bankInfo = await service.getBankInfo( + { currency: 'EUR', paymentMethod: FiatPaymentMethod.BANK, userData }, + buy, + ); + + expect(bankInfo).toMatchObject({ + bank: IbanBankName.OLKY, + iban: collectionBank.iban, + isPersonalIban: false, + reference: buy.bankUsage, + }); + // The outage must stay visible even though the customer no longer sees an error. + expect(errorLog).toHaveBeenCalledTimes(1); + }); + + it('does not show a collection account without a reference, even for an eligible EUR transfer', async () => { + jest.spyOn(virtualIbanService, 'getActiveReceivingForUserAndCurrency').mockResolvedValue(null); + jest.spyOn(virtualIbanService, 'isUserEligible').mockReturnValue(true); + jest.spyOn(virtualIbanService, 'hasProviderSupportingCurrency').mockReturnValue(true); + jest + .spyOn(virtualIbanService, 'getOrCreateFrickForUser') + .mockRejectedValue(new Error('transient issuance error')); + jest.spyOn(bankService, 'getBank').mockResolvedValue(collectionBank); + + // A collection transfer without a reference cannot be attributed - it must never be shown. + await expect( + service.getBankInfo({ currency: 'EUR', paymentMethod: FiatPaymentMethod.BANK, userData }, { + ...buy, + bankUsage: undefined, + } as Buy), + ).rejects.toThrow(QuoteError.PERSONAL_IBAN_ISSUANCE_FAILED); }); it('reports KycRequired for an ineligible EUR transfer without a personal IBAN', async () => { @@ -902,18 +984,20 @@ describe('BuyService', () => { expect(bankService.getBank).not.toHaveBeenCalled(); }); - it('reports PersonalIbanIssuanceFailed for an eligible CHF transfer after issuance fails', async () => { + it('reports PersonalIbanIssuanceFailed for an eligible CHF transfer when no collection account resolves', async () => { jest.spyOn(virtualIbanService, 'getActiveReceivingForUserAndCurrency').mockResolvedValue(null); jest.spyOn(virtualIbanService, 'isUserEligible').mockReturnValue(true); jest.spyOn(virtualIbanService, 'hasProviderSupportingCurrency').mockReturnValue(true); jest.spyOn(virtualIbanService, 'createForUser').mockRejectedValue(new Error('transient issuance error')); + // No collection bank resolves, so the transfer cannot fall back and still fails. + jest.spyOn(bankService, 'getBank').mockResolvedValue(undefined); await expect( service.getBankInfo({ currency: 'CHF', paymentMethod: FiatPaymentMethod.BANK, userData }, buy), ).rejects.toThrow(QuoteError.PERSONAL_IBAN_ISSUANCE_FAILED); expect(virtualIbanService.createForUser).toHaveBeenCalledWith(userData, 'CHF'); - expect(bankService.getBank).not.toHaveBeenCalled(); + expect(bankService.getBank).toHaveBeenCalled(); }); it('continues to the standard bank for CHF CARD below KYC LEVEL_50', async () => { @@ -969,6 +1053,9 @@ describe('BuyService', () => { jest .spyOn(virtualIbanService, 'getOrCreateFrickForUser') .mockRejectedValue(new Error('transient issuance error')); + // No collection account resolves, so the outage surfaces as PersonalIbanIssuanceFailed rather than + // degrading to the transfer fallback — this pins the error discrimination, not the fallback. + jest.spyOn(bankService, 'getBank').mockResolvedValue(undefined); const resolution = service.getBankInfo({ currency: 'EUR', paymentMethod: FiatPaymentMethod.BANK, userData }, buy); 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..b38355a860 100644 --- a/src/subdomains/core/buy-crypto/routes/buy/buy.service.ts +++ b/src/subdomains/core/buy-crypto/routes/buy/buy.service.ts @@ -13,6 +13,7 @@ import { JwtPayload } from 'src/shared/auth/jwt-payload.interface'; import { Asset } from 'src/shared/models/asset/asset.entity'; import { AssetDtoMapper } from 'src/shared/models/asset/dto/asset-dto.mapper'; import { FiatDtoMapper } from 'src/shared/models/fiat/dto/fiat-dto.mapper'; +import { DfxLogger } from 'src/shared/services/dfx-logger'; import { PaymentInfoService } from 'src/shared/services/payment-info.service'; import { DfxCron } from 'src/shared/utils/cron'; import { PdfUtil } from 'src/shared/utils/pdf.util'; @@ -45,6 +46,8 @@ import { UpdateBuyDto } from './dto/update-buy.dto'; @Injectable() export class BuyService { + private readonly logger = new DfxLogger(BuyService); + private cache: { id: number; bankUsage: string }[] = undefined; constructor( @@ -487,16 +490,21 @@ export class BuyService { if (selector.paymentMethod !== FiatPaymentMethod.BANK) throw new BadRequestException(QuoteError.PAYMENT_METHOD_NOT_ALLOWED); - const virtualIban = await this.virtualIbanService.getOrCreateFrickForUser(selector.userData, selector.currency); - if (!virtualIban.bank.receive || virtualIban.bank.name !== IbanBankName.FRICK) - throw new BadRequestException(QuoteError.PERSONAL_IBAN_ISSUANCE_FAILED); + const virtualIban = await this.virtualIbanService + .getOrCreateFrickForUser(selector.userData, selector.currency) + .catch(() => null); + if (virtualIban?.bank.receive && virtualIban.bank.name === IbanBankName.FRICK) { + return { + bankInfo: this.buildVirtualIbanResponse(virtualIban, selector.userData, buy?.bankUsage), + bankId: virtualIban.bank.id, + virtualIbanId: virtualIban.id, + bankName: virtualIban.bank.name, + }; + } - return { - bankInfo: this.buildVirtualIbanResponse(virtualIban, selector.userData, buy?.bankUsage), - bankId: virtualIban.bank.id, - virtualIbanId: virtualIban.id, - bankName: virtualIban.bank.name, - }; + // Personal Frick vIBAN could not be issued - degrade to the referenced collection account, the + // same rule the user-level path uses below (KYC-cleared customer, reference present, logged at ERROR). + return this.collectionAccountOrThrow(selector, buy); } // CARD keeps the same active-vIBAN lookups as BANK so an existing personal IBAN remains visible, @@ -568,25 +576,10 @@ export class BuyService { }; } - // No personal IBAN could be resolved, and a collection account must never be shown - so a transfer - // fails here instead of falling back to one. This applies to EVERY currency, not just EUR: it is - // the deliberate policy that a bank transfer requires a personal IBAN, and therefore KYC 50. - // Card payments use no deposit IBAN at all (the response carries a payment link), so they keep - // resolving a bank rather than breaking. - // - // Three reasons are told apart, because sending a customer after the wrong one wastes their time: - // no provider covers the currency at all; the customer has not reached KYC 50; or issuance failed - // for someone who has. KYC is read directly rather than through isUserEligible, which also folds - // in whether the provider is reachable right now - during an outage that would tell a fully - // verified customer to complete a level they already hold. - if (selector.paymentMethod !== FiatPaymentMethod.CARD) - throw new BadRequestException( - !this.virtualIbanService.hasProviderSupportingCurrency(selector.currency) - ? QuoteError.PERSONAL_IBAN_CURRENCY_NOT_SUPPORTED - : selector.userData.kycLevel >= KycLevel.LEVEL_50 - ? QuoteError.PERSONAL_IBAN_ISSUANCE_FAILED - : QuoteError.KYC_REQUIRED, - ); + // No personal IBAN could be resolved. Bank transfers degrade through collectionAccountOrThrow (see + // there); card payments carry a payment link rather than a deposit IBAN, so they keep resolving a + // bank instead of degrading. + if (selector.paymentMethod !== FiatPaymentMethod.CARD) return this.collectionAccountOrThrow(selector, buy); const bank = await this.bankService.getBank(selector); @@ -599,6 +592,44 @@ export class BuyService { }; } + // Fallback when a personal IBAN could not be issued for a bank transfer (e.g. the provider is down). + // Three reasons are told apart, because sending a customer after the wrong one wastes their time: no + // provider covers the currency at all; the customer has not reached KYC 50; or issuance failed for + // someone who has. Only the last case degrades to the shared collection account, and only WITH a + // per-buy reference so the incoming transfer stays attributable - an unreferenced collection transfer + // cannot be matched to a customer and must never be shown, so without a reference the request still + // fails. The failure is logged at ERROR regardless, so a provider outage stays visible even though the + // customer no longer sees an error. A customer below KYC 50 keeps getting KYC_REQUIRED, so the + // personal-IBAN/KYC coupling holds. KYC is read directly rather than through isUserEligible, which also + // folds in whether the provider is reachable right now - during an outage that would tell a fully + // verified customer to complete a level they already hold. + private async collectionAccountOrThrow( + selector: BankSelectorInput, + buy?: Buy, + ): Promise<{ + bankInfo: BankInfoDto & { isPersonalIban: boolean; reference?: string }; + bankId: number; + bankName: IbanBankName; + }> { + if (!this.virtualIbanService.hasProviderSupportingCurrency(selector.currency)) + throw new BadRequestException(QuoteError.PERSONAL_IBAN_CURRENCY_NOT_SUPPORTED); + if (selector.userData.kycLevel < KycLevel.LEVEL_50) throw new BadRequestException(QuoteError.KYC_REQUIRED); + + const bank = await this.bankService.getBank(selector); + const reference = buy?.bankUsage; + if (!bank || !reference) throw new BadRequestException(QuoteError.PERSONAL_IBAN_ISSUANCE_FAILED); + + this.logger.error( + `Personal IBAN issuance failed for a KYC-cleared customer (userData ${selector.userData.id}, ${selector.currency}); showing the collection account instead`, + ); + + return { + bankInfo: this.buildBankResponse(bank, reference), + bankId: bank.id, + bankName: bank.name, + }; + } + // getBank() can return undefined, but this builder dereferences the bank unconditionally - callers // must resolve that before calling in, so the parameter states the precondition instead of widening. private buildBankResponse(