Skip to content
Merged
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 @@ -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'](
Expand All @@ -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,
Expand Down Expand Up @@ -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);

Expand All @@ -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 () => {
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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);

Expand Down
87 changes: 59 additions & 28 deletions src/subdomains/core/buy-crypto/routes/buy/buy.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);

Expand All @@ -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(
Expand Down
Loading