diff --git a/migration/1767952500437-MaerkiRemoved.js b/migration/1767952500437-MaerkiRemoved.js new file mode 100644 index 0000000000..2a96ef6e5e --- /dev/null +++ b/migration/1767952500437-MaerkiRemoved.js @@ -0,0 +1,28 @@ +/** + * @typedef {import('typeorm').MigrationInterface} MigrationInterface + * @typedef {import('typeorm').QueryRunner} QueryRunner + */ + +/** + * @class + * @implements {MigrationInterface} + */ +module.exports = class MaerkiRemoved1767952500437 { + name = 'MaerkiRemoved1767952500437' + + /** + * @param {QueryRunner} queryRunner + */ + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "country" DROP CONSTRAINT "DF_687dc858f7aff3f03ffbb214f2c"`); + await queryRunner.query(`ALTER TABLE "country" DROP COLUMN "maerkiBaumannEnable"`); + } + + /** + * @param {QueryRunner} queryRunner + */ + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "country" ADD "maerkiBaumannEnable" bit NOT NULL`); + await queryRunner.query(`ALTER TABLE "country" ADD CONSTRAINT "DF_687dc858f7aff3f03ffbb214f2c" DEFAULT 0 FOR "maerkiBaumannEnable"`); + } +} diff --git a/src/config/config.ts b/src/config/config.ts index eb2b248aed..460c034c0f 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -56,6 +56,10 @@ export class Configuration { priceSourceManual = 'DFX'; // source name for priceStep if price is set manually in buy-crypto priceSourcePayment = 'Payment'; // source name for priceStep if price is defined by payment quote + isDomesticIban(iban: string): boolean { + return ['CH', 'LI'].includes(iban?.substring(0, 2)); + } + defaults = { currency: 'EUR', language: 'EN', diff --git a/src/integration/exchange/services/binance.service.ts b/src/integration/exchange/services/binance.service.ts index b1b69bbb63..73ca59ccae 100644 --- a/src/integration/exchange/services/binance.service.ts +++ b/src/integration/exchange/services/binance.service.ts @@ -13,7 +13,7 @@ export class BinanceService extends ExchangeService { Arbitrum: 'ARBITRUM', BinanceSmartChain: 'BSC', Bitcoin: 'BTC', - Lightning: undefined, + Lightning: 'LIGHTNING', Spark: undefined, Monero: 'XMR', Zano: undefined, diff --git a/src/integration/lightning/dto/lnbits.dto.ts b/src/integration/lightning/dto/lnbits.dto.ts index 99b0c6acd6..036453885b 100644 --- a/src/integration/lightning/dto/lnbits.dto.ts +++ b/src/integration/lightning/dto/lnbits.dto.ts @@ -15,8 +15,8 @@ export interface LnBitsWalletPaymentParamsDto { amount: number; memo: string; expirySec: number; - webhook: string; - extra: { + webhook?: string; + extra?: { link: string; signature: string; }; diff --git a/src/integration/lightning/dto/lnd.dto.ts b/src/integration/lightning/dto/lnd.dto.ts index 7e3046c93c..42b884a512 100644 --- a/src/integration/lightning/dto/lnd.dto.ts +++ b/src/integration/lightning/dto/lnd.dto.ts @@ -27,6 +27,24 @@ export enum LndPaymentStatus { FAILED = 'FAILED', } +export enum LndInvoiceState { + OPEN = 'OPEN', + SETTLED = 'SETTLED', + CANCELED = 'CANCELED', + ACCEPTED = 'ACCEPTED', +} + +export interface LndInvoiceDto { + memo: string; + r_hash: string; + payment_request: string; + value_sat: string; + state: LndInvoiceState; + settled: boolean; + settle_date: string; + amt_paid_sat: string; +} + export interface LndPaymentDto { payment_hash: string; value_sat: number; diff --git a/src/integration/lightning/lightning-client.ts b/src/integration/lightning/lightning-client.ts index 94353796a0..0649f33d91 100644 --- a/src/integration/lightning/lightning-client.ts +++ b/src/integration/lightning/lightning-client.ts @@ -8,6 +8,7 @@ import { LndChannelBalanceDto, LndChannelDto, LndInfoDto, + LndInvoiceDto, LndPaymentDto, LndRouteDto, LndSendPaymentResponseDto, @@ -115,6 +116,13 @@ export class LightningClient { .then((p) => p.payments); } + async lookupInvoice(paymentHashHex: string): Promise { + return this.http.get( + `${Config.blockchain.lightning.lnd.apiUrl}/invoice/${paymentHashHex}`, + this.httpLndConfig(), + ); + } + async sendPaymentByInvoice(invoice: string): Promise { return this.http.post( `${Config.blockchain.lightning.lnd.apiUrl}/channels/transactions`, diff --git a/src/shared/models/country/__mocks__/country.entity.mock.ts b/src/shared/models/country/__mocks__/country.entity.mock.ts index 95f3553063..5f4f298c50 100644 --- a/src/shared/models/country/__mocks__/country.entity.mock.ts +++ b/src/shared/models/country/__mocks__/country.entity.mock.ts @@ -7,7 +7,6 @@ const defaultCountry: Partial = { dfxEnable: true, lockEnable: true, ipEnable: true, - maerkiBaumannEnable: true, yapealEnable: true, updated: undefined, created: undefined, diff --git a/src/shared/models/country/country.entity.ts b/src/shared/models/country/country.entity.ts index 77fe3d061b..2d9943b0e9 100644 --- a/src/shared/models/country/country.entity.ts +++ b/src/shared/models/country/country.entity.ts @@ -30,9 +30,6 @@ export class Country extends IEntity { @Column({ default: true }) ipEnable: boolean; - @Column({ default: false }) - maerkiBaumannEnable: boolean; - @Column({ default: false }) yapealEnable: boolean; diff --git a/src/shared/services/payment-info.service.ts b/src/shared/services/payment-info.service.ts index 472a7e955c..cd7f3e57c8 100644 --- a/src/shared/services/payment-info.service.ts +++ b/src/shared/services/payment-info.service.ts @@ -90,7 +90,7 @@ export class PaymentInfoService { if (!dto.currency) throw new NotFoundException('Currency not found'); if (!dto.currency.buyable) throw new BadRequestException('Currency not buyable'); - if ('iban' in dto && dto.currency?.name === 'CHF' && !dto.iban.startsWith('CH') && !dto.iban.startsWith('LI')) + if ('iban' in dto && dto.currency?.name === 'CHF' && !Config.isDomesticIban(dto.iban)) throw new BadRequestException( 'CHF transactions are only permitted to Liechtenstein or Switzerland. Use EUR for other countries.', ); diff --git a/src/shared/utils/test.util.ts b/src/shared/utils/test.util.ts index e0227a5394..5f18e7ecf6 100644 --- a/src/shared/utils/test.util.ts +++ b/src/shared/utils/test.util.ts @@ -4,7 +4,7 @@ import { DeepPartial } from 'typeorm'; export class TestUtil { static provideConfig(config: DeepPartial = {}): Provider { - const conf = { ...new Configuration(), ...config } as Configuration; + const conf = Object.assign(new Configuration(), config); return { provide: ConfigService, useValue: new ConfigService(conf) }; } } diff --git a/src/shared/utils/util.ts b/src/shared/utils/util.ts index 46b766e4e1..b935bfcbe1 100644 --- a/src/shared/utils/util.ts +++ b/src/shared/utils/util.ts @@ -67,7 +67,7 @@ export class Util { } static roundByPrecision(amount: number, precision: number): number { - return new BigNumber(amount).precision(precision).toNumber(); + return new BigNumber(amount).precision(precision, BigNumber.ROUND_HALF_UP).toNumber(); } static floorByPrecision(amount: number, precision: number): number { diff --git a/src/subdomains/core/aml/services/aml-helper.service.ts b/src/subdomains/core/aml/services/aml-helper.service.ts index 243f560f58..e8cd420bc0 100644 --- a/src/subdomains/core/aml/services/aml-helper.service.ts +++ b/src/subdomains/core/aml/services/aml-helper.service.ts @@ -305,7 +305,7 @@ export class AmlHelperService { if (entity.inputAmount > entity.cryptoInput.asset.liquidityCapacity) errors.push(AmlError.LIQUIDITY_LIMIT_EXCEEDED); if (nationality && !nationality.cryptoEnable) errors.push(AmlError.TX_COUNTRY_NOT_ALLOWED); - if (entity.sell.fiat.name === 'CHF' && !entity.sell.iban.startsWith('CH') && !entity.sell.iban.startsWith('LI')) + if (entity.sell.fiat.name === 'CHF' && !Config.isDomesticIban(entity.sell.iban)) errors.push(AmlError.ABROAD_CHF_NOT_ALLOWED); if ( blacklist.some((b) => diff --git a/src/subdomains/core/aml/services/aml.service.ts b/src/subdomains/core/aml/services/aml.service.ts index 9b73b52b53..76f8a7ec49 100644 --- a/src/subdomains/core/aml/services/aml.service.ts +++ b/src/subdomains/core/aml/services/aml.service.ts @@ -60,7 +60,7 @@ export class AmlService { if ( !entity.userData.bankTransactionVerification && entity instanceof BuyFiat && - (entity.sell.iban.startsWith('LI') || entity.sell.iban.startsWith('CH')) + Config.isDomesticIban(entity.sell.iban) ) entity.userData = await this.userDataService.updateUserDataInternal(entity.userData, { bankTransactionVerification: CheckStatus.GSHEET, diff --git a/src/subdomains/core/buy-crypto/process/services/buy-crypto.service.ts b/src/subdomains/core/buy-crypto/process/services/buy-crypto.service.ts index 040f8a53fc..02a4b4d545 100644 --- a/src/subdomains/core/buy-crypto/process/services/buy-crypto.service.ts +++ b/src/subdomains/core/buy-crypto/process/services/buy-crypto.service.ts @@ -558,7 +558,7 @@ export class BuyCryptoService { { iban: chargebackIban, amount: chargebackAmount, - currency: buyCrypto.bankTx?.currency, + currency: dto.chargebackCurrency ?? buyCrypto.bankTx?.currency, ...creditorData, }, ); diff --git a/src/subdomains/core/history/__tests__/transaction-helper.spec.ts b/src/subdomains/core/history/__tests__/transaction-helper.spec.ts index ea582f0784..1d209f5459 100644 --- a/src/subdomains/core/history/__tests__/transaction-helper.spec.ts +++ b/src/subdomains/core/history/__tests__/transaction-helper.spec.ts @@ -106,7 +106,7 @@ describe('TransactionHelper', () => { txHelper.getRefundData( transaction.refundTargetEntity, defaultUserData, - IbanBankName.MAERKI, + IbanBankName.YAPEAL, 'DE12500105170648489890', !transaction.cryptoInput, ), @@ -130,18 +130,21 @@ describe('TransactionHelper', () => { jest.spyOn(fiatService, 'getFiatByName').mockResolvedValue(createCustomFiat({ name: 'CHF' })); jest.spyOn(feeService, 'getChargebackFee').mockResolvedValue(createInternalChargebackFeeDto()); + jest + .spyOn(pricingService, 'getPrice') + .mockResolvedValue(createCustomPrice({ source: 'CHF', target: 'CHF', price: 1 })); await expect( txHelper.getRefundData( transaction.refundTargetEntity, defaultUserData, - IbanBankName.MAERKI, + IbanBankName.YAPEAL, 'DE12500105170648489890', !transaction.cryptoInput, ), ).resolves.toMatchObject({ fee: { network: 0, bank: 1.13 }, - refundAmount: 99.87, + refundAmount: 99.88, refundTarget: 'DE12500105170648489890', }); }); diff --git a/src/subdomains/core/history/controllers/transaction.controller.ts b/src/subdomains/core/history/controllers/transaction.controller.ts index 99c36c058f..9b8abb4298 100644 --- a/src/subdomains/core/history/controllers/transaction.controller.ts +++ b/src/subdomains/core/history/controllers/transaction.controller.ts @@ -454,11 +454,14 @@ export class TransactionController { } : undefined; + const chargebackCurrency = refundData.refundAsset.name; + if (transaction.targetEntity instanceof BankTxReturn) { if (!dto.creditorData) throw new BadRequestException('Creditor data is required for bank refunds'); return this.bankTxReturnService.refundBankTx(transaction.targetEntity, { refundIban: refundData.refundTarget ?? dto.refundTarget, + chargebackCurrency, creditorData, ...refundDto, }); @@ -487,6 +490,7 @@ export class TransactionController { return this.buyCryptoService.refundBankTx(transaction.targetEntity, { refundIban: refundData.refundTarget ?? dto.refundTarget, + chargebackCurrency, creditorData, ...refundDto, }); diff --git a/src/subdomains/core/history/dto/refund-internal.dto.ts b/src/subdomains/core/history/dto/refund-internal.dto.ts index 021ec5687f..357bcc187e 100644 --- a/src/subdomains/core/history/dto/refund-internal.dto.ts +++ b/src/subdomains/core/history/dto/refund-internal.dto.ts @@ -42,6 +42,7 @@ export class BaseRefund { export class BankTxRefund extends BaseRefund { refundIban?: string; + chargebackCurrency?: string; chargebackOutput?: FiatOutput; creditorData?: CreditorData; } diff --git a/src/subdomains/core/liquidity-management/adapters/actions/base/ccxt-exchange.adapter.ts b/src/subdomains/core/liquidity-management/adapters/actions/base/ccxt-exchange.adapter.ts index e257f109a1..8c9894b866 100644 --- a/src/subdomains/core/liquidity-management/adapters/actions/base/ccxt-exchange.adapter.ts +++ b/src/subdomains/core/liquidity-management/adapters/actions/base/ccxt-exchange.adapter.ts @@ -36,7 +36,7 @@ export abstract class CcxtExchangeAdapter extends LiquidityActionAdapter { constructor( system: LiquidityManagementSystem, - private readonly exchangeService: ExchangeService, + protected readonly exchangeService: ExchangeService, private readonly exchangeRegistry: ExchangeRegistryService, private readonly dexService: DexService, private readonly orderRepo: LiquidityManagementOrderRepository, diff --git a/src/subdomains/core/liquidity-management/adapters/actions/binance.adapter.ts b/src/subdomains/core/liquidity-management/adapters/actions/binance.adapter.ts index 2037b64aa9..7677d2d9d4 100644 --- a/src/subdomains/core/liquidity-management/adapters/actions/binance.adapter.ts +++ b/src/subdomains/core/liquidity-management/adapters/actions/binance.adapter.ts @@ -1,15 +1,32 @@ import { Injectable } from '@nestjs/common'; import { BinanceService } from 'src/integration/exchange/services/binance.service'; import { ExchangeRegistryService } from 'src/integration/exchange/services/exchange-registry.service'; +import { LndInvoiceState } from 'src/integration/lightning/dto/lnd.dto'; +import { LightningClient } from 'src/integration/lightning/lightning-client'; +import { LightningHelper } from 'src/integration/lightning/lightning-helper'; +import { LightningService } from 'src/integration/lightning/services/lightning.service'; import { AssetService } from 'src/shared/models/asset/asset.service'; +import { Util } from 'src/shared/utils/util'; import { DexService } from 'src/subdomains/supporting/dex/services/dex.service'; import { PricingService } from 'src/subdomains/supporting/pricing/services/pricing.service'; +import { LiquidityManagementOrder } from '../../entities/liquidity-management-order.entity'; import { LiquidityManagementSystem } from '../../enums'; +import { OrderFailedException } from '../../exceptions/order-failed.exception'; +import { OrderNotProcessableException } from '../../exceptions/order-not-processable.exception'; +import { CorrelationId } from '../../interfaces'; import { LiquidityManagementOrderRepository } from '../../repositories/liquidity-management-order.repository'; import { CcxtExchangeAdapter } from './base/ccxt-exchange.adapter'; +export enum BinanceAdapterCommands { + LIGHTNING_WITHDRAW = 'lightning-withdraw', +} + +const BINANCE_LIGHTNING_MAX_WITHDRAWAL_BTC = 0.00999; + @Injectable() export class BinanceAdapter extends CcxtExchangeAdapter { + private readonly lightningClient: LightningClient; + constructor( binanceService: BinanceService, exchangeRegistry: ExchangeRegistryService, @@ -17,6 +34,7 @@ export class BinanceAdapter extends CcxtExchangeAdapter { liquidityOrderRepo: LiquidityManagementOrderRepository, pricingService: PricingService, assetService: AssetService, + lightningService: LightningService, ) { super( LiquidityManagementSystem.BINANCE, @@ -27,5 +45,85 @@ export class BinanceAdapter extends CcxtExchangeAdapter { pricingService, assetService, ); + + this.lightningClient = lightningService.getDefaultClient(); + this.commands.set(BinanceAdapterCommands.LIGHTNING_WITHDRAW, this.lightningWithdraw.bind(this)); + } + + // --- LIGHTNING WITHDRAW --- // + + private async lightningWithdraw(order: LiquidityManagementOrder): Promise { + const asset = order.pipeline.rule.targetAsset.dexName; + const balance = await this.exchangeService.getAvailableBalance(asset); + + const amount = Util.floor(Math.min(order.maxAmount, balance, BINANCE_LIGHTNING_MAX_WITHDRAWAL_BTC), 8); + + if (amount <= 0) + throw new OrderNotProcessableException( + `${this.exchangeService.name}: not enough balance for ${asset} (balance: ${balance}, min. requested: ${order.minAmount}, max. requested: ${order.maxAmount})`, + ); + const amountSats = LightningHelper.btcToSat(amount); + + // Generate invoice via LnBits + const invoice = await this.lightningClient.getLnBitsWalletPayment({ + amount: amountSats, + memo: `LM Order ${order.id}`, + expirySec: 1800, // 30 min (Binance limit) + }); + + order.inputAmount = amount; + order.inputAsset = asset; + order.outputAsset = asset; + + // Send invoice to Binance for withdrawal + const response = await this.exchangeService.withdrawFunds(asset, amount, invoice.pr, undefined, 'LIGHTNING'); + + return response.id; + } + + // --- COMPLETION CHECK --- // + + async checkCompletion(order: LiquidityManagementOrder): Promise { + if (order.action.command === BinanceAdapterCommands.LIGHTNING_WITHDRAW) { + return this.checkLightningWithdrawCompletion(order); + } + return super.checkCompletion(order); + } + + private async checkLightningWithdrawCompletion(order: LiquidityManagementOrder): Promise { + const asset = order.pipeline.rule.targetAsset.dexName; + const withdrawal = await this.exchangeService.getWithdraw(order.correlationId, asset); + if (!withdrawal) return false; + + if (withdrawal.status === 'failed') { + throw new OrderFailedException(`Lightning withdrawal ${order.correlationId} failed on Binance`); + } + + // For Lightning, txid = payment_hash (hex) + const paymentHash = withdrawal.txid; + if (!paymentHash) return false; + + try { + const invoice = await this.lightningClient.lookupInvoice(paymentHash); + const isComplete = invoice.state === LndInvoiceState.SETTLED; + + if (isComplete) { + order.outputAmount = LightningHelper.satToBtc(+invoice.amt_paid_sat); + } + + return isComplete; + } catch { + // Invoice not found = not yet received + return false; + } + } + + // --- VALIDATION --- // + + validateParams(command: string, params: Record): boolean { + if (command === BinanceAdapterCommands.LIGHTNING_WITHDRAW) { + return true; // No params needed for lightning-withdraw + } + return super.validateParams(command, params); } } diff --git a/src/subdomains/core/sell-crypto/process/__tests__/buy-fiat.service.spec.ts b/src/subdomains/core/sell-crypto/process/__tests__/buy-fiat.service.spec.ts index 003848c156..a836ab9768 100644 --- a/src/subdomains/core/sell-crypto/process/__tests__/buy-fiat.service.spec.ts +++ b/src/subdomains/core/sell-crypto/process/__tests__/buy-fiat.service.spec.ts @@ -18,6 +18,7 @@ import { PayInService } from 'src/subdomains/supporting/payin/services/payin.ser import { TransactionHelper } from 'src/subdomains/supporting/payment/services/transaction-helper'; import { TransactionRequestService } from 'src/subdomains/supporting/payment/services/transaction-request.service'; import { TransactionService } from 'src/subdomains/supporting/payment/services/transaction.service'; +import { SupportLogService } from 'src/subdomains/supporting/support-issue/services/support-log.service'; import { createCustomSellHistory } from '../../route/dto/__mocks__/sell-history.dto.mock'; import { SellRepository } from '../../route/sell.repository'; import { SellService } from '../../route/sell.service'; @@ -55,6 +56,7 @@ describe('BuyFiatService', () => { let amlService: AmlService; let transactionHelper: TransactionHelper; let custodyOrderService: CustodyOrderService; + let supportLogService: SupportLogService; beforeEach(async () => { buyFiatRepo = createMock(); @@ -75,6 +77,7 @@ describe('BuyFiatService', () => { amlService = createMock(); transactionHelper = createMock(); custodyOrderService = createMock(); + supportLogService = createMock(); const module: TestingModule = await Test.createTestingModule({ imports: [TestSharedModule], @@ -98,6 +101,7 @@ describe('BuyFiatService', () => { { provide: AmlService, useValue: amlService }, { provide: TransactionHelper, useValue: transactionHelper }, { provide: CustodyOrderService, useValue: custodyOrderService }, + { provide: SupportLogService, useValue: supportLogService }, ], }).compile(); diff --git a/src/subdomains/core/sell-crypto/process/buy-fiat.entity.ts b/src/subdomains/core/sell-crypto/process/buy-fiat.entity.ts index 22314def7a..c4af45bd30 100644 --- a/src/subdomains/core/sell-crypto/process/buy-fiat.entity.ts +++ b/src/subdomains/core/sell-crypto/process/buy-fiat.entity.ts @@ -469,6 +469,10 @@ export class BuyFiat extends IEntity { chargebackAllowedDateUser: null, chargebackAmount: null, chargebackAllowedBy: null, + priceSteps: null, + priceDefinitionAllowedDate: null, + usedFees: null, + bankFeeAmount: null, }; Object.assign(this, update); diff --git a/src/subdomains/core/sell-crypto/process/services/buy-fiat-preparation.service.ts b/src/subdomains/core/sell-crypto/process/services/buy-fiat-preparation.service.ts index 3ee44057ef..04fbc82b5f 100644 --- a/src/subdomains/core/sell-crypto/process/services/buy-fiat-preparation.service.ts +++ b/src/subdomains/core/sell-crypto/process/services/buy-fiat-preparation.service.ts @@ -201,7 +201,7 @@ export class BuyFiatPreparationService { CryptoPaymentMethod.CRYPTO, FiatPaymentMethod.BANK, undefined, - IbanBankName.MAERKI, + IbanBankName.YAPEAL, entity.user, ); diff --git a/src/subdomains/core/sell-crypto/process/services/buy-fiat.service.ts b/src/subdomains/core/sell-crypto/process/services/buy-fiat.service.ts index 4a896b0082..e88bedeac0 100644 --- a/src/subdomains/core/sell-crypto/process/services/buy-fiat.service.ts +++ b/src/subdomains/core/sell-crypto/process/services/buy-fiat.service.ts @@ -21,6 +21,8 @@ import { TransactionTypeInternal } from 'src/subdomains/supporting/payment/entit import { TransactionHelper } from 'src/subdomains/supporting/payment/services/transaction-helper'; import { TransactionRequestService } from 'src/subdomains/supporting/payment/services/transaction-request.service'; import { TransactionService } from 'src/subdomains/supporting/payment/services/transaction.service'; +import { SupportLogType } from 'src/subdomains/supporting/support-issue/enums/support-log.enum'; +import { SupportLogService } from 'src/subdomains/supporting/support-issue/services/support-log.service'; import { Between, FindOptionsRelations, In, MoreThan } from 'typeorm'; import { FiatOutputService } from '../../../../supporting/fiat-output/fiat-output.service'; import { CheckStatus } from '../../../aml/enums/check-status.enum'; @@ -65,6 +67,7 @@ export class BuyFiatService { private readonly transactionHelper: TransactionHelper, @Inject(forwardRef(() => CustodyOrderService)) private readonly custodyOrderService: CustodyOrderService, + private readonly supportLogService: SupportLogService, ) {} async createFromCryptoInput(cryptoInput: CryptoInput, sell: Sell, request?: TransactionRequest): Promise { @@ -324,7 +327,10 @@ export class BuyFiatService { } async resetAmlCheck(id: number): Promise { - const entity = await this.buyFiatRepo.findOne({ where: { id }, relations: { fiatOutput: true } }); + const entity = await this.buyFiatRepo.findOne({ + where: { id }, + relations: { fiatOutput: true, transaction: { userData: true }, outputAsset: true }, + }); if (!entity) throw new NotFoundException('BuyFiat not found'); if (entity.isComplete || entity.fiatOutput?.isComplete) throw new BadRequestException('BuyFiat is already complete'); @@ -332,8 +338,27 @@ export class BuyFiatService { const fiatOutputId = entity.fiatOutput?.id; + const resetDetails = { + buyFiatId: entity.id, + amlCheck: entity.amlCheck, + amlReason: entity.amlReason, + outputAmount: entity.outputAmount, + outputAsset: entity.outputAsset?.name, + fiatOutputId: fiatOutputId, + fiatOutputTransmitted: entity.fiatOutput?.isTransmittedDate, + priceDefinitionAllowedDate: entity.priceDefinitionAllowedDate, + }; + await this.buyFiatRepo.update(...entity.resetAmlCheck()); if (fiatOutputId) await this.fiatOutputService.delete(fiatOutputId); + + if (entity.transaction.userData) { + await this.supportLogService.createSupportLog(entity.transaction.userData, { + type: SupportLogType.SUPPORT, + message: `BuyFiat ${entity.id} reset`, + comment: `AML check reset. Previous state: ${JSON.stringify(resetDetails)}`, + }); + } } async updateVolumes(start = 1, end = 100000): Promise { diff --git a/src/subdomains/core/sell-crypto/sell-crypto.module.ts b/src/subdomains/core/sell-crypto/sell-crypto.module.ts index 3db81713de..a30334ba74 100644 --- a/src/subdomains/core/sell-crypto/sell-crypto.module.ts +++ b/src/subdomains/core/sell-crypto/sell-crypto.module.ts @@ -12,6 +12,7 @@ import { PayInModule } from 'src/subdomains/supporting/payin/payin.module'; import { PaymentModule } from 'src/subdomains/supporting/payment/payment.module'; import { TransactionModule } from 'src/subdomains/supporting/payment/transaction.module'; import { PricingModule } from 'src/subdomains/supporting/pricing/pricing.module'; +import { SupportIssueModule } from 'src/subdomains/supporting/support-issue/support-issue.module'; import { AmlModule } from '../aml/aml.module'; import { BuyCryptoModule } from '../buy-crypto/buy-crypto.module'; import { CustodyModule } from '../custody/custody.module'; @@ -50,6 +51,7 @@ import { SellService } from './route/sell.service'; forwardRef(() => TransactionUtilModule), RouteModule, forwardRef(() => CustodyModule), + SupportIssueModule, ], controllers: [BuyFiatController, SellController], providers: [ diff --git a/src/subdomains/core/transaction/transaction-util.service.ts b/src/subdomains/core/transaction/transaction-util.service.ts index 420bdab186..9fb2eed606 100644 --- a/src/subdomains/core/transaction/transaction-util.service.ts +++ b/src/subdomains/core/transaction/transaction-util.service.ts @@ -8,6 +8,7 @@ import { } from '@nestjs/common'; import { BigNumber } from 'ethers/lib/ethers'; import * as IbanTools from 'ibantools'; +import { Config } from 'src/config/config'; import { BlockchainRegistryService } from 'src/integration/blockchain/shared/services/blockchain-registry.service'; import { TxValidationService } from 'src/integration/blockchain/shared/services/tx-validation.service'; import { CheckoutPaymentStatus } from 'src/integration/checkout/dto/checkout.dto'; @@ -122,9 +123,7 @@ export class TransactionUtilService { throw new BadRequestException('BIC not allowed'); return ( - bankAccount && - (bankAccount.bic || iban.startsWith('CH') || iban.startsWith('LI')) && - IbanTools.validateIBAN(bankAccount.iban).valid + bankAccount && (bankAccount.bic || Config.isDomesticIban(iban)) && IbanTools.validateIBAN(bankAccount.iban).valid ); } diff --git a/src/subdomains/supporting/bank-tx/bank-tx-return/bank-tx-return.service.ts b/src/subdomains/supporting/bank-tx/bank-tx-return/bank-tx-return.service.ts index fe031718c5..2992519413 100644 --- a/src/subdomains/supporting/bank-tx/bank-tx-return/bank-tx-return.service.ts +++ b/src/subdomains/supporting/bank-tx/bank-tx-return/bank-tx-return.service.ts @@ -213,7 +213,7 @@ export class BankTxReturnService { { iban: chargebackIban, amount: chargebackAmount, - currency: bankTxReturn.bankTx?.currency, + currency: dto.chargebackCurrency ?? bankTxReturn.bankTx?.currency, ...creditorData, }, ); diff --git a/src/subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts b/src/subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts index e64006dd2e..5c217f21d5 100644 --- a/src/subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts +++ b/src/subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts @@ -207,7 +207,7 @@ export class BankTxService implements OnModuleInit { if (tx.creditDebitIndicator === BankTxIndicator.CREDIT) { // check for dedicated asset vIBAN if (tx.virtualIban) { - const virtualIban = await this.virtualIbanService.getByIbanWithBuy(tx.virtualIban); + const virtualIban = await this.virtualIbanService.getByIban(tx.virtualIban); if (virtualIban?.buy) { await this.updateInternal(tx, { type: BankTxType.BUY_CRYPTO, buyId: virtualIban.buy.id }); continue; diff --git a/src/subdomains/supporting/bank/bank-account/is-dfx-iban.validator.ts b/src/subdomains/supporting/bank/bank-account/is-dfx-iban.validator.ts index 003f9f4b6e..6c9e19f331 100644 --- a/src/subdomains/supporting/bank/bank-account/is-dfx-iban.validator.ts +++ b/src/subdomains/supporting/bank/bank-account/is-dfx-iban.validator.ts @@ -7,6 +7,7 @@ import { ValidatorConstraintInterface, } from 'class-validator'; import * as IbanTools from 'ibantools'; +import { Config } from 'src/config/config'; import { SpecialExternalAccountType } from '../../payment/entities/special-external-account.entity'; import { SpecialExternalAccountService } from '../../payment/services/special-external-account.service'; import { Bank } from '../bank/bank.entity'; @@ -79,8 +80,7 @@ export class IsDfxIbanValidator implements ValidatorConstraintInterface { if (!valid) return `${args.property} not valid`; // BIC lookup required for non-CH/LI IBANs - if (!this.currentBIC && !iban.startsWith('CH') && !iban.startsWith('LI')) - return `${args.property} BIC could not be determined`; + if (!this.currentBIC && !Config.isDomesticIban(iban)) return `${args.property} BIC could not be determined`; // check blocked IBANs const isBlocked = this.blockedIbans.some((i) => new RegExp(i.toLowerCase()).test(iban.toLowerCase())); @@ -92,8 +92,8 @@ export class IsDfxIbanValidator implements ValidatorConstraintInterface { if (this.dfxBanks.some((b) => b.iban.toLowerCase() === iban.toLowerCase())) return `${args.property} DFX IBAN not allowed`; - // check if QR IBAN - if (iban.startsWith('CH') || iban.startsWith('LI')) { + // check if QR IBAN (CH/LI only) + if (Config.isDomesticIban(iban)) { const iid = +iban.substring(4, 9); if (iid >= 30000 && iid <= 31999) return `${args.property} QR IBAN not allowed`; } diff --git a/src/subdomains/supporting/bank/bank/__mocks__/bank.entity.mock.ts b/src/subdomains/supporting/bank/bank/__mocks__/bank.entity.mock.ts index df73b715d0..cb54d1de94 100644 --- a/src/subdomains/supporting/bank/bank/__mocks__/bank.entity.mock.ts +++ b/src/subdomains/supporting/bank/bank/__mocks__/bank.entity.mock.ts @@ -19,19 +19,19 @@ export const olkyEUR = createCustomBank({ sctInst: true, }); -export const maerkiEUR = createCustomBank({ - name: IbanBankName.MAERKI, +export const yapealEUR = createCustomBank({ + name: IbanBankName.YAPEAL, currency: 'EUR', - iban: 'CH6808573177975201814', - bic: 'MAEBCHZZ', + iban: 'CH1234567890123456789', + bic: 'YAPECHCHXXX', receive: true, }); -export const maerkiCHF = createCustomBank({ - name: IbanBankName.MAERKI, +export const yapealCHF = createCustomBank({ + name: IbanBankName.YAPEAL, currency: 'CHF', - iban: 'CH3408573177975200001', - bic: 'MAEBCHZZ', + iban: 'CH9876543210987654321', + bic: 'YAPECHCHXXX', receive: true, }); @@ -44,10 +44,10 @@ export function createCustomBank(customValues: Partial): Bank { } export function createDefaultBanks(): Bank[] { - return [olkyEUR, maerkiEUR, maerkiCHF]; + return [olkyEUR, yapealEUR, yapealCHF]; } export function createDefaultDisabledBanks(): Bank[] { olkyEUR.receive = false; - return [olkyEUR, maerkiEUR, maerkiCHF]; + return [olkyEUR, yapealEUR, yapealCHF]; } diff --git a/src/subdomains/supporting/bank/bank/__tests__/bank.service.spec.ts b/src/subdomains/supporting/bank/bank/__tests__/bank.service.spec.ts index 95ae45d606..5dba56f986 100644 --- a/src/subdomains/supporting/bank/bank/__tests__/bank.service.spec.ts +++ b/src/subdomains/supporting/bank/bank/__tests__/bank.service.spec.ts @@ -14,8 +14,8 @@ import { FiatPaymentMethod } from 'src/subdomains/supporting/payment/dto/payment import { createDefaultBanks, createDefaultDisabledBanks, - maerkiCHF, - maerkiEUR, + yapealCHF, + yapealEUR, olkyEUR, } from '../__mocks__/bank.entity.mock'; import { BankRepository } from '../bank.repository'; @@ -70,10 +70,10 @@ describe('BankService', () => { service = module.get(BankService); }); - function defaultSetup(maerkiBaumannEnable = true, disabledBank = false) { + function defaultSetup(yapealEnable = true, disabledBank = false) { jest .spyOn(countryService, 'getCountryWithSymbol') - .mockResolvedValue(createCustomCountry({ maerkiBaumannEnable: maerkiBaumannEnable })); + .mockResolvedValue(createCustomCountry({ yapealEnable: yapealEnable })); const allBanks = disabledBank ? createDefaultDisabledBanks() : createDefaultBanks(); jest.spyOn(bankRepo, 'findCachedBy').mockImplementation(async (_key: string, filter?: any) => { @@ -91,8 +91,8 @@ describe('BankService', () => { it('should return first matching bank for CHF currency', async () => { defaultSetup(); const result = await service.getBank(createBankSelectorInput('CHF', 10000)); - expect(result.iban).toBe(maerkiCHF.iban); - expect(result.bic).toBe(maerkiCHF.bic); + expect(result.iban).toBe(yapealCHF.iban); + expect(result.bic).toBe(yapealCHF.bic); }); it('should return matching bank for EUR currency', async () => { @@ -112,8 +112,8 @@ describe('BankService', () => { it('should return first matching bank for CHF currency with standard payment', async () => { defaultSetup(true); const result = await service.getBank(createBankSelectorInput('CHF')); - expect(result.iban).toBe(maerkiCHF.iban); - expect(result.bic).toBe(maerkiCHF.bic); + expect(result.iban).toBe(yapealCHF.iban); + expect(result.bic).toBe(yapealCHF.bic); }); it('should fallback to EUR for unsupported currency', async () => { @@ -126,7 +126,7 @@ describe('BankService', () => { it('should fallback to first EUR bank when sctInst bank is disabled', async () => { defaultSetup(true, true); const result = await service.getBank(createBankSelectorInput('EUR', undefined, FiatPaymentMethod.INSTANT)); - expect(result.iban).toBe(maerkiEUR.iban); - expect(result.bic).toBe(maerkiEUR.bic); + expect(result.iban).toBe(yapealEUR.iban); + expect(result.bic).toBe(yapealEUR.bic); }); }); diff --git a/src/subdomains/supporting/bank/bank/bank.entity.ts b/src/subdomains/supporting/bank/bank/bank.entity.ts index de2464461b..61e9fbbfe1 100644 --- a/src/subdomains/supporting/bank/bank/bank.entity.ts +++ b/src/subdomains/supporting/bank/bank/bank.entity.ts @@ -38,9 +38,6 @@ export class Bank extends IEntity { // --- ENTITY METHODS --- // isCountryEnabled(country: Country): boolean { - return ( - (this.name === IbanBankName.YAPEAL && country.yapealEnable) || - (this.name === IbanBankName.MAERKI && country.maerkiBaumannEnable) - ); + return this.name === IbanBankName.YAPEAL && country.yapealEnable; } } diff --git a/src/subdomains/supporting/bank/bank/bank.service.ts b/src/subdomains/supporting/bank/bank/bank.service.ts index d81c9ec7fc..02b43d3075 100644 --- a/src/subdomains/supporting/bank/bank/bank.service.ts +++ b/src/subdomains/supporting/bank/bank/bank.service.ts @@ -107,8 +107,6 @@ export class BankService implements OnModuleInit { private static blockchainToBankName(blockchain: Blockchain): IbanBankName | undefined { switch (blockchain) { - case Blockchain.MAERKI_BAUMANN: - return IbanBankName.MAERKI; case Blockchain.OLKYPAY: return IbanBankName.OLKY; case Blockchain.YAPEAL: diff --git a/src/subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts b/src/subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts index 7f77616595..30d1e19bff 100644 --- a/src/subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts +++ b/src/subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts @@ -103,10 +103,10 @@ export class VirtualIbanService { }); } - async getByIbanWithBuy(iban: string): Promise { - return this.virtualIbanRepo.findOne({ + async getByIban(iban: string): Promise { + return this.virtualIbanRepo.findOneCached(iban, { where: { iban }, - relations: { userData: true, bank: true, buy: { user: { userData: true } } }, + relations: { userData: true, bank: true, buy: true }, }); } @@ -122,13 +122,6 @@ export class VirtualIbanService { }); } - async getByIban(iban: string): Promise { - return this.virtualIbanRepo.findOneCached(iban, { - where: { iban }, - relations: { userData: true, bank: true }, - }); - } - async getBaseAccountIban(iban: string): Promise { return this.getByIban(iban).then((viban) => viban?.bank.iban); } diff --git a/src/subdomains/supporting/dex/services/dex-bitcoin.service.ts b/src/subdomains/supporting/dex/services/dex-bitcoin.service.ts index c5180b7c8a..50e428ac3c 100644 --- a/src/subdomains/supporting/dex/services/dex-bitcoin.service.ts +++ b/src/subdomains/supporting/dex/services/dex-bitcoin.service.ts @@ -34,7 +34,7 @@ export class DexBitcoinService { async checkTransferCompletion(transferTxId: string): Promise { const transaction = await this.client.getTx(transferTxId); - return transaction && transaction.blockhash && transaction.confirmations > 0; + return transaction != null; } async getRecentHistory(txCount: number): Promise { diff --git a/src/subdomains/supporting/fiat-output/__tests__/fiat-output-job.service.spec.ts b/src/subdomains/supporting/fiat-output/__tests__/fiat-output-job.service.spec.ts index cd36282d25..d57ab8403e 100644 --- a/src/subdomains/supporting/fiat-output/__tests__/fiat-output-job.service.spec.ts +++ b/src/subdomains/supporting/fiat-output/__tests__/fiat-output-job.service.spec.ts @@ -17,7 +17,7 @@ import { BankTxService } from 'src/subdomains/supporting/bank-tx/bank-tx/service import { BankTxRepeatService } from '../../bank-tx/bank-tx-repeat/bank-tx-repeat.service'; import { BankTxReturnService } from '../../bank-tx/bank-tx-return/bank-tx-return.service'; import { createDefaultBankTx } from '../../bank-tx/bank-tx/__mocks__/bank-tx.entity.mock'; -import { createCustomBank, maerkiEUR } from '../../bank/bank/__mocks__/bank.entity.mock'; +import { createCustomBank, yapealEUR } from '../../bank/bank/__mocks__/bank.entity.mock'; import { BankService } from '../../bank/bank/bank.service'; import { IbanBankName } from '../../bank/bank/dto/bank.dto'; import { createCustomVirtualIban } from '../../bank/virtual-iban/__mocks__/virtual-iban.entity.mock'; @@ -117,20 +117,18 @@ describe('FiatOutputJobService', () => { }), ]); - jest - .spyOn(countryService, 'getCountryWithSymbol') - .mockResolvedValue(createCustomCountry({ maerkiBaumannEnable: true })); + jest.spyOn(countryService, 'getCountryWithSymbol').mockResolvedValue(createCustomCountry({ yapealEnable: true })); - jest.spyOn(bankService, 'getSenderBank').mockResolvedValue(maerkiEUR); + jest.spyOn(bankService, 'getSenderBank').mockResolvedValue(yapealEUR); await service['assignBankAccount'](); const updateCalls = (fiatOutputRepo.update as jest.Mock).mock.calls; expect(updateCalls[0][0]).toBe(1); - expect(updateCalls[0][1]).toMatchObject({ originEntityId: 100, accountIban: maerkiEUR.iban }); + expect(updateCalls[0][1]).toMatchObject({ originEntityId: 100, accountIban: yapealEUR.iban }); expect(updateCalls[1][0]).toBe(3); - expect(updateCalls[1][1]).toMatchObject({ originEntityId: 102, accountIban: maerkiEUR.iban }); + expect(updateCalls[1][1]).toMatchObject({ originEntityId: 102, accountIban: yapealEUR.iban }); }); it('should use virtual IBAN when user has one for BuyFiat', async () => { @@ -145,14 +143,12 @@ describe('FiatOutputJobService', () => { }), ]); - jest - .spyOn(countryService, 'getCountryWithSymbol') - .mockResolvedValue(createCustomCountry({ maerkiBaumannEnable: true })); + jest.spyOn(countryService, 'getCountryWithSymbol').mockResolvedValue(createCustomCountry({ yapealEnable: true })); // Mock virtual IBAN for user jest .spyOn(virtualIbanService, 'getActiveForUserAndCurrency') - .mockResolvedValue(createCustomVirtualIban({ iban: virtualIban, bank: maerkiEUR })); + .mockResolvedValue(createCustomVirtualIban({ iban: virtualIban, bank: yapealEUR })); await service['assignBankAccount'](); @@ -173,14 +169,12 @@ describe('FiatOutputJobService', () => { }), ]); - jest - .spyOn(countryService, 'getCountryWithSymbol') - .mockResolvedValue(createCustomCountry({ maerkiBaumannEnable: true })); + jest.spyOn(countryService, 'getCountryWithSymbol').mockResolvedValue(createCustomCountry({ yapealEnable: true })); // Mock virtual IBAN for user jest .spyOn(virtualIbanService, 'getActiveForUserAndCurrency') - .mockResolvedValue(createCustomVirtualIban({ iban: virtualIban, bank: maerkiEUR })); + .mockResolvedValue(createCustomVirtualIban({ iban: virtualIban, bank: yapealEUR })); await service['assignBankAccount'](); diff --git a/src/subdomains/supporting/payment/services/transaction-helper.ts b/src/subdomains/supporting/payment/services/transaction-helper.ts index 3cb726fb17..3d0a4d2f6e 100644 --- a/src/subdomains/supporting/payment/services/transaction-helper.ts +++ b/src/subdomains/supporting/payment/services/transaction-helper.ts @@ -401,6 +401,12 @@ export class TransactionHelper implements OnModuleInit { const inputCurrency = await this.getRefundActive(refundEntity); if (!inputCurrency.refundEnabled) throw new BadRequestException(`Refund for ${inputCurrency.name} not allowed`); + // CHF refunds only to domestic IBANs + const refundCurrency = + isFiat && inputCurrency.name === 'CHF' && refundTarget && !Config.isDomesticIban(refundTarget) + ? await this.fiatService.getFiatByName('EUR') + : inputCurrency; + const price = refundEntity.manualChfPrice ?? (await this.pricingService.getPrice(PriceCurrency.CHF, inputCurrency, PriceValidity.PREFER_VALID)); @@ -429,11 +435,21 @@ export class TransactionHelper implements OnModuleInit { ) : 0; // Bank fee buffer 1% - const totalFeeAmount = Util.roundReadable(dfxFeeAmount + networkFeeAmount + bankFeeAmount, feeAmountType); + const totalFeeAmount = dfxFeeAmount + networkFeeAmount + bankFeeAmount; if (totalFeeAmount >= inputAmount) throw new BadRequestException('Transaction fee is too expensive'); - const refundAsset = + const inputAsset = inputCurrency instanceof Asset ? AssetDtoMapper.toDto(inputCurrency) : FiatDtoMapper.toDto(inputCurrency); + const refundAsset = + refundCurrency instanceof Asset ? AssetDtoMapper.toDto(refundCurrency) : FiatDtoMapper.toDto(refundCurrency); + + // convert to refund currency + const refundPrice = await this.pricingService.getPrice(inputCurrency, refundCurrency, PriceValidity.VALID_ONLY); + + const refundAmount = Util.roundReadable(refundPrice.convert(inputAmount - totalFeeAmount), amountType); + const feeDfx = Util.roundReadable(refundPrice.convert(dfxFeeAmount), feeAmountType); + const feeNetwork = Util.roundReadable(refundPrice.convert(networkFeeAmount), feeAmountType); + const feeBank = Util.roundReadable(refundPrice.convert(bankFeeAmount), feeAmountType); // Get bank details from the refund entity const bankTx = this.getBankTxFromRefundEntity(refundEntity); @@ -441,12 +457,12 @@ export class TransactionHelper implements OnModuleInit { return { expiryDate: Util.secondsAfter(Config.transactionRefundExpirySeconds), inputAmount: Util.roundReadable(inputAmount, amountType), - inputAsset: refundAsset, - refundAmount: Util.roundReadable(inputAmount - totalFeeAmount, amountType), + inputAsset, + refundAmount, fee: { - dfx: Util.roundReadable(dfxFeeAmount, feeAmountType), - network: Util.roundReadable(networkFeeAmount, feeAmountType), - bank: Util.roundReadable(bankFeeAmount, feeAmountType), + dfx: feeDfx, + network: feeNetwork, + bank: feeBank, }, refundAsset, refundTarget, @@ -742,7 +758,7 @@ export class TransactionHelper implements OnModuleInit { static getDefaultBankByPaymentMethod(paymentMethod: PaymentMethod): CardBankName | IbanBankName { switch (paymentMethod) { case FiatPaymentMethod.BANK: - return IbanBankName.MAERKI; + return IbanBankName.YAPEAL; case FiatPaymentMethod.CARD: return CardBankName.CHECKOUT; case FiatPaymentMethod.INSTANT: diff --git a/src/subdomains/supporting/support-issue/support-issue.module.ts b/src/subdomains/supporting/support-issue/support-issue.module.ts index 48f509275c..6f4be733b7 100644 --- a/src/subdomains/supporting/support-issue/support-issue.module.ts +++ b/src/subdomains/supporting/support-issue/support-issue.module.ts @@ -55,6 +55,6 @@ import { SupportIssueController } from './support-issue.controller'; SupportLogRepository, SupportLogService, ], - exports: [SupportIssueService, LimitRequestService], + exports: [SupportIssueService, LimitRequestService, SupportLogService], }) export class SupportIssueModule {}