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
4 changes: 2 additions & 2 deletions src/subdomains/core/buy-crypto/routes/buy/buy.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -384,8 +384,8 @@ export class BuyService {
// user-level vIBAN
let virtualIban = await this.virtualIbanService.getActiveForUserAndCurrency(selector.userData, selector.currency);

// EUR/CHF: create vIBAN for KYC 50+
if (!virtualIban && ['EUR', 'CHF'].includes(selector.currency) && selector.userData.kycLevel >= KycLevel.LEVEL_50) {
// CHF: create vIBAN for KYC 50+
if (!virtualIban && VirtualIbanService.isUserEligible(selector.currency, selector.userData)) {
virtualIban = await this.virtualIbanService.createForUser(selector.userData, selector.currency).catch(() => null);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,26 @@ import { TestSharedModule } from 'src/shared/utils/test.shared.module';
import { TestUtil } from 'src/shared/utils/test.util';
import { BuyCryptoService } from 'src/subdomains/core/buy-crypto/process/services/buy-crypto.service';
import { BuyService } from 'src/subdomains/core/buy-crypto/routes/buy/buy.service';
import { createDefaultUserData } from 'src/subdomains/generic/user/models/user-data/__mocks__/user-data.entity.mock';
import {
createCustomUserData,
createDefaultUserData,
} from 'src/subdomains/generic/user/models/user-data/__mocks__/user-data.entity.mock';
import { KycLevel } from 'src/subdomains/generic/user/models/user-data/user-data.enum';
import { WalletService } from 'src/subdomains/generic/user/models/wallet/wallet.service';
import { createDefaultBankTx } from 'src/subdomains/supporting/bank-tx/bank-tx/__mocks__/bank-tx.entity.mock';
import { olkyEUR, yapealEUR } from 'src/subdomains/supporting/bank/bank/__mocks__/bank.entity.mock';
import { BankService } from 'src/subdomains/supporting/bank/bank/bank.service';
import { CardBankName, IbanBankName } from 'src/subdomains/supporting/bank/bank/dto/bank.dto';
import { createCustomVirtualIban } from 'src/subdomains/supporting/bank/virtual-iban/__mocks__/virtual-iban.entity.mock';
import { VirtualIbanService } from 'src/subdomains/supporting/bank/virtual-iban/virtual-iban.service';
import { createDefaultCheckoutTx } from 'src/subdomains/supporting/fiat-payin/__mocks__/checkout-tx.entity.mock';
import { createDefaultCryptoInput } from 'src/subdomains/supporting/payin/entities/__mocks__/crypto-input.entity.mock';
import {
createChargebackFeeInfo,
createCustomChargebackFeeInfo,
} from 'src/subdomains/supporting/payment/__mocks__/fee.dto.mock';
import { createCustomTransaction } from 'src/subdomains/supporting/payment/__mocks__/transaction.entity.mock';
import { FiatPaymentMethod } from 'src/subdomains/supporting/payment/dto/payment-method.enum';
import { TransactionSpecificationRepository } from 'src/subdomains/supporting/payment/repositories/transaction-specification.repository';
import { FeeService } from 'src/subdomains/supporting/payment/services/fee.service';
import { TransactionHelper } from 'src/subdomains/supporting/payment/services/transaction-helper';
Expand All @@ -46,6 +55,8 @@ describe('TransactionHelper', () => {
let buyService: BuyService;
let assetService: AssetService;
let countryService: CountryService;
let bankService: BankService;
let virtualIbanService: VirtualIbanService;

beforeEach(async () => {
specRepo = createMock<TransactionSpecificationRepository>();
Expand All @@ -60,6 +71,8 @@ describe('TransactionHelper', () => {
buyService = createMock<BuyService>();
assetService = createMock<AssetService>();
countryService = createMock<CountryService>();
bankService = createMock<BankService>();
virtualIbanService = createMock<VirtualIbanService>();

const module: TestingModule = await Test.createTestingModule({
imports: [TestSharedModule],
Expand All @@ -77,6 +90,8 @@ describe('TransactionHelper', () => {
{ provide: BuyService, useValue: buyService },
{ provide: AssetService, useValue: assetService },
{ provide: CountryService, useValue: countryService },
{ provide: BankService, useValue: bankService },
{ provide: VirtualIbanService, useValue: virtualIbanService },
TestUtil.provideConfig(),
],
}).compile();
Expand Down Expand Up @@ -237,4 +252,51 @@ describe('TransactionHelper', () => {
refundTarget: undefined,
});
});
describe('getBankIn', () => {
const eur = createCustomFiat({ name: 'EUR' });

it('should return the deposit bank for bank transfers', async () => {
jest.spyOn(virtualIbanService, 'getActiveForUserAndCurrency').mockResolvedValue(null);
jest.spyOn(bankService, 'getBank').mockResolvedValue(olkyEUR);

await expect(
txHelper['getBankIn'](eur, FiatPaymentMethod.BANK, createCustomUserData({ kycLevel: KycLevel.LEVEL_30 })),
).resolves.toBe(IbanBankName.OLKY);
});

it('should return the vIBAN bank for users with an active vIBAN', async () => {
jest
.spyOn(virtualIbanService, 'getActiveForUserAndCurrency')
.mockResolvedValue(createCustomVirtualIban({ bank: yapealEUR }));

await expect(
txHelper['getBankIn'](eur, FiatPaymentMethod.BANK, createCustomUserData({ kycLevel: KycLevel.LEVEL_50 })),
).resolves.toBe(IbanBankName.YAPEAL);
});

it('should return the deposit bank for users without an active vIBAN', async () => {
jest.spyOn(virtualIbanService, 'getActiveForUserAndCurrency').mockResolvedValue(null);
jest.spyOn(bankService, 'getBank').mockResolvedValue(olkyEUR);

await expect(
txHelper['getBankIn'](eur, FiatPaymentMethod.BANK, createCustomUserData({ kycLevel: KycLevel.LEVEL_50 })),
).resolves.toBe(IbanBankName.OLKY);
});

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);
});

it('should return the default bank for card payments', async () => {
await expect(txHelper['getBankIn'](eur, FiatPaymentMethod.CARD, undefined)).resolves.toBe(CardBankName.CHECKOUT);
});

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);
});
});
});
2 changes: 1 addition & 1 deletion src/subdomains/supporting/bank/bank/bank.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { BankRepository } from './bank.repository';
import { IbanBankName } from './dto/bank.dto';

export interface BankSelectorInput {
amount: number;
amount?: number;
currency: string;
paymentMethod: FiatPaymentMethod;
userData?: UserData;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,21 @@ import { YapealService } from 'src/integration/bank/services/yapeal.service';
import { FiatService } from 'src/shared/models/fiat/fiat.service';
import { Buy } from 'src/subdomains/core/buy-crypto/routes/buy/buy.entity';
import { UserData } from 'src/subdomains/generic/user/models/user-data/user-data.entity';
import { KycLevel } from 'src/subdomains/generic/user/models/user-data/user-data.enum';

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

currencies includes EUR but EUR vIBANs no longer exist (Yapeal EUR has receive=false). This causes isUserEligible to return true for EUR users, triggering vIBAN creation attempts in getBankInfo that silently fail and an incorrect Yapeal fee quote in getBankIn (see other comment). Should be ['CHF'] only.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 32b6581currencies is now ['CHF'], so getBankInfo no longer attempts EUR vIBAN creation.

import { BankService } from '../bank/bank.service';
import { IbanBankName } from '../bank/dto/bank.dto';
import { VirtualIban, VirtualIbanStatus } from './virtual-iban.entity';
import { VirtualIbanRepository } from './virtual-iban.repository';

@Injectable()
export class VirtualIbanService {
static readonly bankName = IbanBankName.YAPEAL;
static readonly currencies = ['CHF'];

static isUserEligible(currencyName: string, userData: UserData): boolean {
return VirtualIbanService.currencies.includes(currencyName) && userData.kycLevel >= KycLevel.LEVEL_50;
}

constructor(
private readonly virtualIbanRepo: VirtualIbanRepository,
private readonly bankService: BankService,
Expand Down Expand Up @@ -44,7 +52,7 @@ export class VirtualIbanService {
const currency = await this.fiatService.getFiatByName(currencyName);
if (!currency) throw new BadRequestException('Currency not found');

const bank = await this.bankService.getBankInternal(IbanBankName.YAPEAL, currencyName);
const bank = await this.bankService.getBankInternal(VirtualIbanService.bankName, currencyName);
if (!bank?.receive) throw new BadRequestException('No bank available for this currency');

const { iban, bban, accountUid } = await this.reserveVibanFromYapeal(bank.iban);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,9 @@ import { FeeService, UserFeeRequest } from 'src/subdomains/supporting/payment/se
import { Price } from 'src/subdomains/supporting/pricing/domain/entities/price';
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 { 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';
import { FeeAmountsDto, FeeInfo, FeeSpec, InternalFeeDto, toFeeDto } from '../dto/fee.dto';
Expand Down Expand Up @@ -77,6 +79,8 @@ export class TransactionHelper implements OnModuleInit {
private readonly buyService: BuyService,
private readonly assetService: AssetService,
private readonly countryService: CountryService,
private readonly bankService: BankService,
private readonly virtualIbanService: VirtualIbanService,
) {}

onModuleInit() {
Expand Down Expand Up @@ -269,7 +273,7 @@ export class TransactionHelper implements OnModuleInit {
const chfPrice = await this.pricingService.getPrice(txAsset, PriceCurrency.CHF, PriceValidity.ANY);
const txAmountChf = chfPrice.convert(txAmount);

const bankIn = TransactionHelper.getDefaultBankByPaymentMethod(paymentMethodIn);
const bankIn = await this.getBankIn(from, paymentMethodIn, user?.userData);
const bankOut = TransactionHelper.getDefaultBankByPaymentMethod(paymentMethodOut);

const wallet = walletName ? await this.walletService.getByIdOrName(undefined, walletName) : undefined;
Expand Down Expand Up @@ -851,6 +855,31 @@ export class TransactionHelper implements OnModuleInit {

// --- HELPER METHODS --- //

private async getBankIn(
from: Active,
paymentMethodIn: PaymentMethod,
userData?: UserData,
): Promise<CardBankName | IbanBankName | undefined> {
const isBankTransfer =
isFiat(from) &&
[FiatPaymentMethod.BANK, FiatPaymentMethod.INSTANT].includes(paymentMethodIn as FiatPaymentMethod);
if (!isBankTransfer) return TransactionHelper.getDefaultBankByPaymentMethod(paymentMethodIn);

// vIBAN deposits are received at the vIBAN bank
if (userData) {
const virtualIban = await this.virtualIbanService.getActiveForUserAndCurrency(userData, from.name);
if (virtualIban?.bank.receive) return virtualIban.bank.name;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This early return is wrong for EUR (see other comment on currencies). For CHF it's redundant — bankService.getBank('CHF') already returns Yapeal CHF and there's no CHF bank fee anyway. The active vIBAN check above already handles existing vIBANs. This line should be removed; let bankService.getBank resolve the bank for everyone without an active vIBAN.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 32b6581 — eligibility prediction removed from getBankIn; users without an active vIBAN now resolve via bankService.getBank. Test updated accordingly (EUR KYC 50+ without vIBAN → Olky).


const bank = await this.bankService.getBank({
currency: from.name,
paymentMethod: paymentMethodIn as FiatPaymentMethod,
userData,
});

return bank?.name ?? TransactionHelper.getDefaultBankByPaymentMethod(paymentMethodIn);
}

static getDefaultBankByPaymentMethod(paymentMethod: PaymentMethod): CardBankName | IbanBankName {
switch (paymentMethod) {
case FiatPaymentMethod.BANK:
Expand Down