From 2a481e7a863cc73250509730479367819996bb71 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Tue, 13 Jan 2026 12:45:34 +0100 Subject: [PATCH 1/6] feat(liquidity-management): add swap command to DfxDexAdapter (#2882) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(liquidity-management): add swap command to DfxDexAdapter Add new 'swap' command for cross-asset liquidity acquisition via DEX. Unlike 'purchase' (same asset), 'swap' allows converting one asset to another, e.g., USDC → EURC via Uniswap. - Add SWAP to DfxDexAdapterCommands enum - Implement swap() method with source asset from params - Add param validation for sourceAsset parameter - Reuse checkSellPurchaseCompletion for order tracking Usage: Action params {"sourceAsset": "USDC"} with rule targetAsset EURC will swap available USDC to EURC via the configured DEX pool. * refactor: consolidate SWAP into PURCHASE/SELL with tradeAsset parameter - Remove SWAP command from DfxDexAdapterCommands enum - Add optional tradeAsset parameter to PURCHASE and SELL commands - Add resolveTradeAsset() helper for cross-asset operations - Add validateTradeAssetParams() for parameter validation When tradeAsset is provided, the command uses it as the source asset for cross-asset operations (e.g., USDC → EURC via DEX). When not provided, behavior remains unchanged (same-asset operation). This creates a symmetric API where both PURCHASE and SELL support cross-asset operations through a single optional parameter. * refactor: simplify tradeAsset - only for PURCHASE, not SELL - Remove tradeAsset support from SELL (sellLiquidity not implemented for any asset) - Simplify return type to Asset instead of Awaited> - Keep SELL with original simple implementation * perf: avoid unnecessary async call when no tradeAsset provided * style: fix prettier formatting * feat: cleanup + added sell * fix: fixed swap amounts --------- Co-authored-by: David May --- .../adapters/actions/dfx-dex.adapter.ts | 135 +++++++++++++++--- .../supporting/dex/services/dex.service.ts | 10 +- 2 files changed, 122 insertions(+), 23 deletions(-) diff --git a/src/subdomains/core/liquidity-management/adapters/actions/dfx-dex.adapter.ts b/src/subdomains/core/liquidity-management/adapters/actions/dfx-dex.adapter.ts index 552fe04110..83c796df25 100644 --- a/src/subdomains/core/liquidity-management/adapters/actions/dfx-dex.adapter.ts +++ b/src/subdomains/core/liquidity-management/adapters/actions/dfx-dex.adapter.ts @@ -1,5 +1,7 @@ import { Injectable } from '@nestjs/common'; +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; import { ExchangeRegistryService } from 'src/integration/exchange/services/exchange-registry.service'; +import { Asset, AssetType } from 'src/shared/models/asset/asset.entity'; import { AssetService } from 'src/shared/models/asset/asset.service'; import { LiquidityOrderContext } from 'src/subdomains/supporting/dex/entities/liquidity-order.entity'; import { ReserveLiquidityRequest } from 'src/subdomains/supporting/dex/interfaces'; @@ -40,10 +42,8 @@ export class DfxDexAdapter extends LiquidityActionAdapter { async checkCompletion(order: LiquidityManagementOrder): Promise { switch (order.action.command) { case DfxDexAdapterCommands.PURCHASE: - return this.checkSellPurchaseCompletion(order); - case DfxDexAdapterCommands.SELL: - return this.checkSellPurchaseCompletion(order); + return this.checkSwapCompletion(order); case DfxDexAdapterCommands.WITHDRAW: return this.checkWithdrawCompletion(order); @@ -59,10 +59,8 @@ export class DfxDexAdapter extends LiquidityActionAdapter { return this.validateWithdrawParams(params); case DfxDexAdapterCommands.PURCHASE: - return true; - case DfxDexAdapterCommands.SELL: - return true; + return this.validateSwapParams(params); default: throw new Error(`Command ${command} not supported by DfxDexAdapter`); @@ -73,23 +71,43 @@ export class DfxDexAdapter extends LiquidityActionAdapter { /** * @note - * correlationId is the orderId and set by liquidity management + * correlationId is the orderId and set by liquidity management. + * targetAsset (from rule) is what we're buying, swapAsset is what we spend. + * Amount is in target asset (targetAsset). */ private async purchase(order: LiquidityManagementOrder): Promise { const { pipeline: { - rule: { targetAsset: asset }, + rule: { targetAsset }, }, - maxAmount: amount, id: correlationId, + minAmount, + maxAmount, } = order; + const { swapAsset: swapAssetName } = this.parseSwapParams(order.action.paramMap); + const swapAsset = await this.getSwapAsset(targetAsset.blockchain, swapAssetName); + + const price = await this.dexService.calculatePrice(swapAsset, targetAsset); + const minSwapAmount = minAmount * price; + const maxSwapAmount = maxAmount * price; + + const swapLiquidity = await this.resolveSwapLiquidity( + correlationId.toString(), + swapAsset, + minSwapAmount, + maxSwapAmount, + ); + + order.inputAmount = swapLiquidity.amount; + order.inputAsset = swapLiquidity.asset.name; + const request = { context: LiquidityOrderContext.LIQUIDITY_MANAGEMENT, correlationId: correlationId.toString(), - referenceAsset: asset, - referenceAmount: amount, - targetAsset: asset, + referenceAsset: swapLiquidity.asset, + referenceAmount: swapLiquidity.amount, + targetAsset, }; await this.dexService.purchaseLiquidity(request); @@ -99,25 +117,37 @@ export class DfxDexAdapter extends LiquidityActionAdapter { /** * @note - * correlationId is the orderId and set by liquidity management + * correlationId is the orderId and set by liquidity management. + * targetAsset (from rule) is what we're selling, swapAsset is what we receive. + * Amount is in source asset (targetAsset). */ private async sell(order: LiquidityManagementOrder): Promise { const { pipeline: { - rule: { targetAsset: asset }, + rule: { targetAsset }, }, - maxAmount: amount, id: correlationId, + minAmount, + maxAmount, } = order; + const { swapAsset: swapAssetName } = this.parseSwapParams(order.action.paramMap); + const swapAsset = await this.getSwapAsset(targetAsset.blockchain, swapAssetName); + + const sellLiquidity = await this.resolveSwapLiquidity(correlationId.toString(), targetAsset, minAmount, maxAmount); + + order.inputAmount = sellLiquidity.amount; + order.inputAsset = sellLiquidity.asset.name; + const request = { context: LiquidityOrderContext.LIQUIDITY_MANAGEMENT, correlationId: correlationId.toString(), - sellAsset: asset, - sellAmount: amount, + referenceAsset: sellLiquidity.asset, + referenceAmount: sellLiquidity.amount, + targetAsset: swapAsset, }; - await this.dexService.sellLiquidity(request); + await this.dexService.purchaseLiquidity(request); return correlationId.toString(); } @@ -163,7 +193,7 @@ export class DfxDexAdapter extends LiquidityActionAdapter { // --- COMPLETION CHECKS --- // - private async checkSellPurchaseCompletion(order: LiquidityManagementOrder): Promise { + private async checkSwapCompletion(order: LiquidityManagementOrder): Promise { try { const result = await this.dexService.checkOrderReady( LiquidityOrderContext.LIQUIDITY_MANAGEMENT, @@ -172,6 +202,9 @@ export class DfxDexAdapter extends LiquidityActionAdapter { if (result.isReady) { await this.dexService.completeOrders(LiquidityOrderContext.LIQUIDITY_MANAGEMENT, order.correlationId); + + order.outputAmount = result.targetAmount; + order.outputAsset = result.targetAsset; } return result.isReady; @@ -235,4 +268,68 @@ export class DfxDexAdapter extends LiquidityActionAdapter { this.exchangeRegistry.get(system) ); } + + private validateSwapParams(params: Record): boolean { + try { + this.parseSwapParams(params); + return true; + } catch { + return false; + } + } + + private parseSwapParams(params: Record): { swapAsset: string } { + const swapAsset = params?.tradeAsset as string | undefined; + + if (!(typeof swapAsset === 'string' && swapAsset.length > 0)) + throw new Error('Params provided to DfxDexAdapter swap command are invalid.'); + + return { swapAsset }; + } + + // --- SWAP HELPERS --- // + + private async resolveSwapLiquidity( + correlationId: string, + liquidityAsset: Asset, + minAmount: number, + maxAmount: number, + ): Promise<{ asset: Asset; amount: number }> { + // Check available liquidity + const checkRequest = { + context: LiquidityOrderContext.LIQUIDITY_MANAGEMENT, + correlationId, + referenceAsset: liquidityAsset, + referenceAmount: minAmount, + targetAsset: liquidityAsset, + }; + + const { + reference: { availableAmount }, + } = await this.dexService.checkLiquidity(checkRequest); + + if (availableAmount < minAmount) { + throw new OrderNotProcessableException( + `Not enough ${liquidityAsset.name} liquidity (balance: ${availableAmount}, min. requested: ${minAmount}, max. requested: ${maxAmount})`, + ); + } + + const amount = Math.min(maxAmount, availableAmount); + + return { asset: liquidityAsset, amount }; + } + + private async getSwapAsset(blockchain: Blockchain, swapAssetName: string): Promise { + const swapAsset = await this.assetService.getAssetByQuery({ + name: swapAssetName, + blockchain, + type: AssetType.TOKEN, + }); + + if (!swapAsset) { + throw new OrderNotProcessableException(`Swap asset ${swapAssetName} not found on ${blockchain}`); + } + + return swapAsset; + } } diff --git a/src/subdomains/supporting/dex/services/dex.service.ts b/src/subdomains/supporting/dex/services/dex.service.ts index a657657ba6..5d910cb424 100644 --- a/src/subdomains/supporting/dex/services/dex.service.ts +++ b/src/subdomains/supporting/dex/services/dex.service.ts @@ -170,13 +170,15 @@ export class DexService { async checkOrderReady( context: LiquidityOrderContext, correlationId: string, - ): Promise<{ isReady: boolean; purchaseTxId: string }> { + ): Promise<{ isReady: boolean; purchaseTxId: string; targetAmount: number; targetAsset: string }> { const order = await this.liquidityOrderRepo.findOneBy({ context, correlationId }); - const purchaseTxId = order && order.txId; - const isReady = order && order.isReady; + const purchaseTxId = order?.txId; + const isReady = order?.isReady ?? false; + const targetAmount = order?.targetAmount; + const targetAsset = order?.targetAsset?.name; - return { isReady, purchaseTxId }; + return { isReady, purchaseTxId, targetAmount, targetAsset }; } async checkOrderCompletion( From ab524803096820ec5d607008541ab13d17542533 Mon Sep 17 00:00:00 2001 From: David May <85513542+davidleomay@users.noreply.github.com> Date: Tue, 13 Jan 2026 12:58:48 +0100 Subject: [PATCH 2/6] [DEV-4424] Implemented forwarded pay-in return (#2913) * [DEV-4424] Implemented forwarded pay-in return * [DEV-4424] Fix tests --- .../__tests__/buy-fiat.service.spec.ts | 4 ++ .../services/buy-fiat-registration.service.ts | 40 +++++++++++++++---- .../process/services/buy-fiat.service.ts | 26 +++++++++--- .../core/sell-crypto/sell-crypto.module.ts | 2 + 4 files changed, 59 insertions(+), 13 deletions(-) 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 a836ab9768..b7dcf8fc6a 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 @@ -15,6 +15,7 @@ import { createCustomFiatOutput } from 'src/subdomains/supporting/fiat-output/__ import { FiatOutputService } from 'src/subdomains/supporting/fiat-output/fiat-output.service'; import { createCustomCryptoInput } from 'src/subdomains/supporting/payin/entities/__mocks__/crypto-input.entity.mock'; import { PayInService } from 'src/subdomains/supporting/payin/services/payin.service'; +import { PayoutService } from 'src/subdomains/supporting/payout/services/payout.service'; 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'; @@ -57,6 +58,7 @@ describe('BuyFiatService', () => { let transactionHelper: TransactionHelper; let custodyOrderService: CustodyOrderService; let supportLogService: SupportLogService; + let payoutService: PayoutService; beforeEach(async () => { buyFiatRepo = createMock(); @@ -78,6 +80,7 @@ describe('BuyFiatService', () => { transactionHelper = createMock(); custodyOrderService = createMock(); supportLogService = createMock(); + payoutService = createMock(); const module: TestingModule = await Test.createTestingModule({ imports: [TestSharedModule], @@ -102,6 +105,7 @@ describe('BuyFiatService', () => { { provide: TransactionHelper, useValue: transactionHelper }, { provide: CustodyOrderService, useValue: custodyOrderService }, { provide: SupportLogService, useValue: supportLogService }, + { provide: PayoutService, useValue: payoutService }, ], }).compile(); diff --git a/src/subdomains/core/sell-crypto/process/services/buy-fiat-registration.service.ts b/src/subdomains/core/sell-crypto/process/services/buy-fiat-registration.service.ts index 89f8d71124..0b1ee1cece 100644 --- a/src/subdomains/core/sell-crypto/process/services/buy-fiat-registration.service.ts +++ b/src/subdomains/core/sell-crypto/process/services/buy-fiat-registration.service.ts @@ -3,6 +3,8 @@ import { DfxLogger } from 'src/shared/services/dfx-logger'; import { CryptoInput, PayInPurpose, PayInStatus } from 'src/subdomains/supporting/payin/entities/crypto-input.entity'; import { PayInService } from 'src/subdomains/supporting/payin/services/payin.service'; import { TransactionHelper } from 'src/subdomains/supporting/payment/services/transaction-helper'; +import { PayoutOrderContext } from 'src/subdomains/supporting/payout/entities/payout-order.entity'; +import { PayoutService } from 'src/subdomains/supporting/payout/services/payout.service'; import { IsNull, Not } from 'typeorm'; import { SellRepository } from '../../route/sell.repository'; import { BuyFiatRepository } from '../buy-fiat.repository'; @@ -24,29 +26,53 @@ export class BuyFiatRegistrationService { private readonly sellRepository: SellRepository, private readonly payInService: PayInService, private readonly transactionHelper: TransactionHelper, + private readonly payoutService: PayoutService, ) {} async syncReturnTxId(): Promise { + const baseWhere = { chargebackAllowedDate: Not(IsNull()), chargebackTxId: IsNull() }; + const entities = await this.buyFiatRepo.find({ - where: { - cryptoInput: { returnTxId: Not(IsNull()), status: PayInStatus.RETURN_CONFIRMED }, - chargebackTxId: IsNull(), - }, + where: [ + // PayIn returned + { ...baseWhere, cryptoInput: { status: PayInStatus.RETURN_CONFIRMED, returnTxId: Not(IsNull()) } }, + // Payout forwarded + { ...baseWhere, cryptoInput: { status: PayInStatus.FORWARD_CONFIRMED } }, + ], relations: { cryptoInput: true, sell: true, transaction: { user: { wallet: true }, userData: true } }, }); for (const entity of entities) { try { - await this.buyFiatRepo.update(entity.id, { chargebackTxId: entity.cryptoInput.returnTxId, isComplete: true }); + const txId = await this.getReturnTxId(entity); + if (!txId) continue; - // send webhook + await this.buyFiatRepo.update(entity.id, { chargebackTxId: txId, isComplete: true }); await this.buyFiatService.triggerWebhook(entity); } catch (e) { - this.logger.error(`Error during buyFiat payIn returnTxId sync (${entity.id}):`, e); + this.logger.error(`Error during buyFiat returnTxId sync (${entity.id}):`, e); } } } + private async getReturnTxId(entity: { id: number; cryptoInput: CryptoInput }): Promise { + // PayIn return (funds were on deposit address) + if (entity.cryptoInput.status === PayInStatus.RETURN_CONFIRMED && entity.cryptoInput.returnTxId) { + return entity.cryptoInput.returnTxId; + } + + // Payout return (funds were forwarded to liquidity) + if (entity.cryptoInput.status === PayInStatus.FORWARD_CONFIRMED) { + const { isComplete, payoutTxId } = await this.payoutService.checkOrderCompletion( + PayoutOrderContext.BUY_FIAT_RETURN, + `${entity.id}`, + ); + return isComplete ? payoutTxId : undefined; + } + + return undefined; + } + async registerSellPayIn(): Promise { const newPayIns = await this.payInService.getNewPayIns(); 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 c83829a118..ccbbeaeca4 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 @@ -14,13 +14,15 @@ import { CreateBankDataDto } from 'src/subdomains/generic/user/models/bank-data/ import { UserService } from 'src/subdomains/generic/user/models/user/user.service'; import { WebhookService } from 'src/subdomains/generic/user/services/webhook/webhook.service'; import { BankTxService } from 'src/subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service'; -import { CryptoInput } from 'src/subdomains/supporting/payin/entities/crypto-input.entity'; +import { CryptoInput, PayInStatus } from 'src/subdomains/supporting/payin/entities/crypto-input.entity'; import { PayInService } from 'src/subdomains/supporting/payin/services/payin.service'; import { TransactionRequest } from 'src/subdomains/supporting/payment/entities/transaction-request.entity'; import { TransactionTypeInternal } from 'src/subdomains/supporting/payment/entities/transaction.entity'; 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 { PayoutOrderContext } from 'src/subdomains/supporting/payout/entities/payout-order.entity'; +import { PayoutService } from 'src/subdomains/supporting/payout/services/payout.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'; @@ -68,6 +70,7 @@ export class BuyFiatService { @Inject(forwardRef(() => CustodyOrderService)) private readonly custodyOrderService: CustodyOrderService, private readonly supportLogService: SupportLogService, + private readonly payoutService: PayoutService, ) {} async createFromCryptoInput(cryptoInput: CryptoInput, sell: Sell, request?: TransactionRequest): Promise { @@ -301,11 +304,22 @@ export class BuyFiatService { let blockchainFee: number; if (dto.chargebackAllowedDate && chargebackAmount) { blockchainFee = await this.transactionHelper.getBlockchainFee(buyFiat.cryptoInput.asset, true); - await this.payInService.returnPayIn( - buyFiat.cryptoInput, - refundUser.address ?? buyFiat.chargebackAddress, - chargebackAmount, - ); + + const returnAddress = refundUser.address ?? buyFiat.chargebackAddress; + + if (buyFiat.cryptoInput.status === PayInStatus.FORWARD_CONFIRMED) { + // Funds already forwarded to liquidity - use PayoutOrder to return + await this.payoutService.doPayout({ + context: PayoutOrderContext.BUY_FIAT_RETURN, + correlationId: `${buyFiat.id}`, + asset: buyFiat.cryptoInput.asset, + amount: chargebackAmount, + destinationAddress: returnAddress, + }); + } else { + // Funds still on deposit address - use PayIn return + await this.payInService.returnPayIn(buyFiat.cryptoInput, returnAddress, chargebackAmount); + } } await this.buyFiatRepo.update( diff --git a/src/subdomains/core/sell-crypto/sell-crypto.module.ts b/src/subdomains/core/sell-crypto/sell-crypto.module.ts index a30334ba74..dd188325c3 100644 --- a/src/subdomains/core/sell-crypto/sell-crypto.module.ts +++ b/src/subdomains/core/sell-crypto/sell-crypto.module.ts @@ -9,6 +9,7 @@ import { BankModule } from 'src/subdomains/supporting/bank/bank.module'; import { FiatOutputModule } from 'src/subdomains/supporting/fiat-output/fiat-output.module'; import { NotificationModule } from 'src/subdomains/supporting/notification/notification.module'; import { PayInModule } from 'src/subdomains/supporting/payin/payin.module'; +import { PayoutModule } from 'src/subdomains/supporting/payout/payout.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'; @@ -42,6 +43,7 @@ import { SellService } from './route/sell.service'; forwardRef(() => BankModule), forwardRef(() => BankTxModule), forwardRef(() => PayInModule), + PayoutModule, forwardRef(() => BuyCryptoModule), forwardRef(() => AddressPoolModule), FiatOutputModule, From 1cf3716cd55ce60a260c3fe399d5b0670d4ce2ca Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Tue, 13 Jan 2026 13:14:02 +0100 Subject: [PATCH 3/6] feat(aml): add NAME_TOO_SHORT check for bank payout validation (#2879) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(aml): add NAME_TOO_SHORT check for bank payout validation Add AML check to reject transactions where the user's name contains fewer than 4 letters. Banks require a minimum name length for processing. Changes: - Add AmlError.NAME_TOO_SHORT with CRUCIAL type and FAIL status - Add AmlReason.NAME_TOO_SHORT - Add countLetters() helper to count only alphabetic characters - Add name length validation in getAmlErrors() after NAME_MISSING check - Update SiftAmlDeclineMap and TransactionReasonMapper The check uses verifiedName, bankData.name, or completeName (in that order) and counts only letters (including European special characters like ä, ö, ü, ß). * feat(i18n): add name_too_short chargeback reason translations Add email translations for NAME_TOO_SHORT AML reason in all 6 languages: - EN, DE, FR, IT, ES, PT This text is shown to users when their transaction is refunded due to the account holder name having fewer than 4 letters, which banks require for processing payouts. * fix: use Unicode property escape for letter counting Replace hardcoded character list with \p{L} Unicode property escape to correctly count letters in all languages (Scandinavian, Polish, Czech, Turkish, etc.). Before: 'Åse Ødegård' counted as 6 letters (å, Ø ignored) After: 'Åse Ødegård' counted as 10 letters (correct) --- src/integration/sift/dto/sift.dto.ts | 1 + src/shared/i18n/de/mail.json | 3 ++- src/shared/i18n/en/mail.json | 3 ++- src/shared/i18n/es/mail.json | 3 ++- src/shared/i18n/fr/mail.json | 3 ++- src/shared/i18n/it/mail.json | 3 ++- src/shared/i18n/pt/mail.json | 3 ++- src/subdomains/core/aml/enums/aml-error.enum.ts | 6 ++++++ src/subdomains/core/aml/enums/aml-reason.enum.ts | 1 + .../core/aml/services/aml-helper.service.ts | 11 +++++++++++ .../supporting/payment/dto/transaction.dto.ts | 1 + 11 files changed, 32 insertions(+), 6 deletions(-) diff --git a/src/integration/sift/dto/sift.dto.ts b/src/integration/sift/dto/sift.dto.ts index bc26b98b54..843a139f0b 100644 --- a/src/integration/sift/dto/sift.dto.ts +++ b/src/integration/sift/dto/sift.dto.ts @@ -1037,6 +1037,7 @@ export const SiftAmlDeclineMap: { [method in AmlReason]: DeclineCategory } = { [AmlReason.BANK_RELEASE_PENDING]: DeclineCategory.OTHER, [AmlReason.VIRTUAL_IBAN_USER_MISMATCH]: DeclineCategory.RISKY, [AmlReason.INTERMEDIARY_WITHOUT_SENDER]: DeclineCategory.RISKY, + [AmlReason.NAME_TOO_SHORT]: DeclineCategory.OTHER, }; export interface ScoreRsponse { diff --git a/src/shared/i18n/de/mail.json b/src/shared/i18n/de/mail.json index 180c054df2..aaa27b0fe0 100644 --- a/src/shared/i18n/de/mail.json +++ b/src/shared/i18n/de/mail.json @@ -95,7 +95,8 @@ "manual_check_ip_phone": "Wir konnten dich unter deiner angegebenen Telefonnummer nicht erreichen", "manual_check_ip_country_phone": "Wir konnten dich unter deiner angegebenen Telefonnummer nicht erreichen", "merge_incomplete": "Die Email Bestätigung wurde nicht akzeptiert", - "intermediary_without_sender": "Die Absenderbank (Wise/Revolut) hat nur den Banknamen übermittelt, nicht aber den Namen des Kontoinhabers. DFX kann daher den tatsächlichen Absender nicht verifizieren und die Transaktion nicht verarbeiten." + "intermediary_without_sender": "Die Absenderbank (Wise/Revolut) hat nur den Banknamen übermittelt, nicht aber den Namen des Kontoinhabers. DFX kann daher den tatsächlichen Absender nicht verifizieren und die Transaktion nicht verarbeiten.", + "name_too_short": "Dein Name ist zu kurz für die Bankverarbeitung. Banken benötigen mindestens 4 Buchstaben im Namen des Kontoinhabers." }, "kyc_start": "Du kannst den KYC Prozess hier starten:
[url:{urlText}]" }, diff --git a/src/shared/i18n/en/mail.json b/src/shared/i18n/en/mail.json index c969d23a13..db40214c22 100644 --- a/src/shared/i18n/en/mail.json +++ b/src/shared/i18n/en/mail.json @@ -95,7 +95,8 @@ "manual_check_ip_phone": "We were unable to reach you at the phone number you provided", "manual_check_ip_country_phone": "We were unable to reach you at the phone number you provided", "merge_incomplete": "The email confirmation was not accepted", - "intermediary_without_sender": "The sender bank (Wise/Revolut) only transmitted the bank name, not the account holder's name. DFX is therefore unable to verify the actual sender and cannot process the transaction." + "intermediary_without_sender": "The sender bank (Wise/Revolut) only transmitted the bank name, not the account holder's name. DFX is therefore unable to verify the actual sender and cannot process the transaction.", + "name_too_short": "Your name is too short for bank processing. Banks require at least 4 letters in the account holder name." }, "kyc_start": "You can start the KYC process here:
[url:{urlText}]" }, diff --git a/src/shared/i18n/es/mail.json b/src/shared/i18n/es/mail.json index d9e74c2f34..d1a3aa8c6e 100644 --- a/src/shared/i18n/es/mail.json +++ b/src/shared/i18n/es/mail.json @@ -95,7 +95,8 @@ "manual_check_ip_phone": "No hemos podido contactar con usted al número de teléfono que nos facilitó", "manual_check_ip_country_phone": "No hemos podido contactar con usted al número de teléfono que nos facilitó", "merge_incomplete": "El correo electrónico de confirmación no fue aceptado", - "intermediary_without_sender": "El banco emisor (Wise/Revolut) solo transmitió el nombre del banco, no el nombre del titular de la cuenta. Por lo tanto, DFX no puede verificar el remitente real y no puede procesar la transacción." + "intermediary_without_sender": "El banco emisor (Wise/Revolut) solo transmitió el nombre del banco, no el nombre del titular de la cuenta. Por lo tanto, DFX no puede verificar el remitente real y no puede procesar la transacción.", + "name_too_short": "Tu nombre es demasiado corto para el procesamiento bancario. Los bancos requieren al menos 4 letras en el nombre del titular de la cuenta." }, "kyc_start": "Puede iniciar el proceso KYC aquí:
[url:{urlText}]" }, diff --git a/src/shared/i18n/fr/mail.json b/src/shared/i18n/fr/mail.json index 35470ecc1d..a4c7ef800d 100644 --- a/src/shared/i18n/fr/mail.json +++ b/src/shared/i18n/fr/mail.json @@ -95,7 +95,8 @@ "manual_check_ip_phone": "Nous n'avons pas réussi à vous joindre au numéro de téléphone que vous avez fourni", "manual_check_ip_country_phone": "Nous n'avons pas réussi à vous joindre au numéro de téléphone que vous avez fourni", "merge_incomplete": "L'e-mail de confirmation n'a pas été accepté", - "intermediary_without_sender": "La banque émettrice (Wise/Revolut) n'a transmis que le nom de la banque, et non le nom du titulaire du compte. DFX ne peut donc pas vérifier l'expéditeur réel et ne peut pas traiter la transaction." + "intermediary_without_sender": "La banque émettrice (Wise/Revolut) n'a transmis que le nom de la banque, et non le nom du titulaire du compte. DFX ne peut donc pas vérifier l'expéditeur réel et ne peut pas traiter la transaction.", + "name_too_short": "Votre nom est trop court pour le traitement bancaire. Les banques exigent au moins 4 lettres dans le nom du titulaire du compte." }, "kyc_start": "Vous pouvez commencer le processus KYC ici:
[url:{urlText}]" }, diff --git a/src/shared/i18n/it/mail.json b/src/shared/i18n/it/mail.json index 6cc9c6a18b..1f341d742b 100644 --- a/src/shared/i18n/it/mail.json +++ b/src/shared/i18n/it/mail.json @@ -95,7 +95,8 @@ "manual_check_ip_phone": "Non siamo riusciti a contattarti al numero di telefono che ci hai fornito", "manual_check_ip_country_phone": "Non siamo riusciti a contattarti al numero di telefono che ci hai fornito", "merge_incomplete": "L'e-mail di conferma non è stata accettata", - "intermediary_without_sender": "La banca mittente (Wise/Revolut) ha trasmesso solo il nome della banca, non il nome del titolare del conto. DFX non può quindi verificare il mittente effettivo e non può elaborare la transazione." + "intermediary_without_sender": "La banca mittente (Wise/Revolut) ha trasmesso solo il nome della banca, non il nome del titolare del conto. DFX non può quindi verificare il mittente effettivo e non può elaborare la transazione.", + "name_too_short": "Il tuo nome è troppo corto per l'elaborazione bancaria. Le banche richiedono almeno 4 lettere nel nome del titolare del conto." }, "kyc_start": "Potete iniziare il processo KYC qui:
[url:{urlText}]" }, diff --git a/src/shared/i18n/pt/mail.json b/src/shared/i18n/pt/mail.json index 08346bf21b..192b00f9a9 100644 --- a/src/shared/i18n/pt/mail.json +++ b/src/shared/i18n/pt/mail.json @@ -95,7 +95,8 @@ "manual_check_ip_phone": "We were unable to reach you at the phone number you provided", "manual_check_ip_country_phone": "We were unable to reach you at the phone number you provided", "merge_incomplete": "The email confirmation was not accepted", - "intermediary_without_sender": "O banco remetente (Wise/Revolut) transmitiu apenas o nome do banco, não o nome do titular da conta. Portanto, a DFX não pode verificar o remetente real e não pode processar a transação." + "intermediary_without_sender": "O banco remetente (Wise/Revolut) transmitiu apenas o nome do banco, não o nome do titular da conta. Portanto, a DFX não pode verificar o remetente real e não pode processar a transação.", + "name_too_short": "O seu nome é muito curto para o processamento bancário. Os bancos exigem pelo menos 4 letras no nome do titular da conta." }, "kyc_start": "You can start the KYC process here:
[url:{urlText}]" }, diff --git a/src/subdomains/core/aml/enums/aml-error.enum.ts b/src/subdomains/core/aml/enums/aml-error.enum.ts index 533cdf83a1..8c75ff38a5 100644 --- a/src/subdomains/core/aml/enums/aml-error.enum.ts +++ b/src/subdomains/core/aml/enums/aml-error.enum.ts @@ -26,6 +26,7 @@ export enum AmlError { INVALID_KYC_TYPE = 'InvalidKycType', NO_VERIFIED_NAME = 'NoVerifiedName', NAME_MISSING = 'NameMissing', + NAME_TOO_SHORT = 'NameTooShort', VERIFIED_COUNTRY_NOT_ALLOWED = 'VerifiedCountryNotAllowed', IBAN_COUNTRY_FATF_NOT_ALLOWED = 'IbanCountryFatfNotAllowed', TX_COUNTRY_NOT_ALLOWED = 'TxCountryNotAllowed', @@ -151,6 +152,11 @@ export const AmlErrorResult: { amlCheck: CheckStatus.PENDING, amlReason: AmlReason.KYC_DATA_NEEDED, }, + [AmlError.NAME_TOO_SHORT]: { + type: AmlErrorType.CRUCIAL, + amlCheck: CheckStatus.FAIL, + amlReason: AmlReason.NAME_TOO_SHORT, + }, [AmlError.VERIFIED_COUNTRY_NOT_ALLOWED]: { type: AmlErrorType.CRUCIAL, amlCheck: CheckStatus.GSHEET, diff --git a/src/subdomains/core/aml/enums/aml-reason.enum.ts b/src/subdomains/core/aml/enums/aml-reason.enum.ts index b7773a74de..5f55419276 100644 --- a/src/subdomains/core/aml/enums/aml-reason.enum.ts +++ b/src/subdomains/core/aml/enums/aml-reason.enum.ts @@ -39,6 +39,7 @@ export enum AmlReason { BANK_RELEASE_PENDING = 'BankReleasePending', VIRTUAL_IBAN_USER_MISMATCH = 'VirtualIbanUserMismatch', INTERMEDIARY_WITHOUT_SENDER = 'IntermediaryWithoutSender', + NAME_TOO_SHORT = 'NameTooShort', } export const KycAmlReasons = [ diff --git a/src/subdomains/core/aml/services/aml-helper.service.ts b/src/subdomains/core/aml/services/aml-helper.service.ts index e8cd420bc0..2a021e9ee2 100644 --- a/src/subdomains/core/aml/services/aml-helper.service.ts +++ b/src/subdomains/core/aml/services/aml-helper.service.ts @@ -69,6 +69,13 @@ export class AmlHelperService { if (!entity.userData.verifiedName) errors.push(AmlError.NO_VERIFIED_NAME); if (!entity.userData.verifiedName && !bankData?.name && !entity.userData.completeName) errors.push(AmlError.NAME_MISSING); + + // Check name length (min 4 letters for bank processing) + const completeName = entity.userData.verifiedName ?? bankData?.name ?? entity.userData.completeName; + if (completeName && this.countLetters(completeName) < 4) { + errors.push(AmlError.NAME_TOO_SHORT); + } + if (entity.userData.verifiedCountry && !entity.userData.verifiedCountry.fatfEnable) errors.push(AmlError.VERIFIED_COUNTRY_NOT_ALLOWED); if (ibanCountry && !ibanCountry.fatfEnable) errors.push(AmlError.IBAN_COUNTRY_FATF_NOT_ALLOWED); @@ -606,4 +613,8 @@ export class AmlHelperService { // No Result - only comment return { bankData, comment }; } + + private static countLetters(str: string): number { + return str.replace(/[^\p{L}]/gu, '').length; + } } diff --git a/src/subdomains/supporting/payment/dto/transaction.dto.ts b/src/subdomains/supporting/payment/dto/transaction.dto.ts index 6dd3d3a86d..2f7876c026 100644 --- a/src/subdomains/supporting/payment/dto/transaction.dto.ts +++ b/src/subdomains/supporting/payment/dto/transaction.dto.ts @@ -114,6 +114,7 @@ export const TransactionReasonMapper: { [AmlReason.BANK_RELEASE_PENDING]: TransactionReason.BANK_RELEASE_PENDING, [AmlReason.VIRTUAL_IBAN_USER_MISMATCH]: TransactionReason.UNKNOWN, [AmlReason.INTERMEDIARY_WITHOUT_SENDER]: TransactionReason.BANK_NOT_ALLOWED, + [AmlReason.NAME_TOO_SHORT]: TransactionReason.KYC_DATA_NEEDED, }; export class UnassignedTransactionDto { From 889ad50d8ad878d5689c08458381cf791113d42e Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Tue, 13 Jan 2026 14:27:03 +0100 Subject: [PATCH 4/6] fix: use Sepolia blockchain for REALU on DEV/LOC (#2915) * fix: use Sepolia blockchain for REALU on DEV/LOC On DEV and LOC environments, REALU transactions were being broadcast to Ethereum Mainnet instead of Sepolia testnet. This caused the sell confirm flow to fail since actual tokens are on Sepolia. Change tokenBlockchain to be environment-aware: use Sepolia on DEV/LOC, Ethereum on PROD/STG. * fix: support Sepolia for EIP-7702 delegation on DEV/LOC Update isDelegationSupportedForRealUnit() to accept Sepolia blockchain on DEV/LOC environments. Without this, the EIP-7702 transfer would fail with 'delegation not supported' error even though the asset blockchain is correctly set to Sepolia. * fix: update buy simulation to use Sepolia REALU on DEV/LOC Since TransactionRequests are now created with Sepolia REALU asset, the buy simulation must also search for Sepolia REALU targetId. - Remove mainnet REALU lookup (no longer needed) - Search for Sepolia REALU targetId instead of mainnet - Update tests to match new behavior * fix: always use Mainnet asset for REALU price lookups Price data is only available for Mainnet REALU asset. Add separate getMainnetRealuAsset() method for price-related functions while keeping getRealuAsset() environment-based for transactions. - getRealUnitPrice() now uses Mainnet asset - getHistoricalPrice() now uses Mainnet asset - Transaction methods (buy/sell) still use environment-based asset * refactor RealUnitDevService variables. * chore: revert fetch prices only from mainnet. --------- Co-authored-by: TuanLamNguyen --- .../delegation/eip7702-delegation.service.ts | 7 ++-- .../__tests__/realunit-dev.service.spec.ts | 33 +++---------------- .../realunit/realunit-dev.service.ts | 30 ++++++++--------- .../supporting/realunit/realunit.service.ts | 6 ++-- 4 files changed, 28 insertions(+), 48 deletions(-) diff --git a/src/integration/blockchain/shared/evm/delegation/eip7702-delegation.service.ts b/src/integration/blockchain/shared/evm/delegation/eip7702-delegation.service.ts index 928f40c28b..77e57cf827 100644 --- a/src/integration/blockchain/shared/evm/delegation/eip7702-delegation.service.ts +++ b/src/integration/blockchain/shared/evm/delegation/eip7702-delegation.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { GetConfig } from 'src/config/config'; +import { Config, Environment, GetConfig } from 'src/config/config'; import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; import { Asset } from 'src/shared/models/asset/asset.entity'; import { DfxLogger } from 'src/shared/services/dfx-logger'; @@ -84,7 +84,10 @@ export class Eip7702DelegationService { * RealUnit app supports eth_sign (unlike MetaMask), so EIP-7702 works */ isDelegationSupportedForRealUnit(blockchain: Blockchain): boolean { - return blockchain === Blockchain.ETHEREUM && CHAIN_CONFIG[blockchain] !== undefined; + const expectedBlockchain = [Environment.DEV, Environment.LOC].includes(Config.environment) + ? Blockchain.SEPOLIA + : Blockchain.ETHEREUM; + return blockchain === expectedBlockchain && CHAIN_CONFIG[blockchain] !== undefined; } /** diff --git a/src/subdomains/supporting/realunit/__tests__/realunit-dev.service.spec.ts b/src/subdomains/supporting/realunit/__tests__/realunit-dev.service.spec.ts index 652abe997b..6df4881e3e 100644 --- a/src/subdomains/supporting/realunit/__tests__/realunit-dev.service.spec.ts +++ b/src/subdomains/supporting/realunit/__tests__/realunit-dev.service.spec.ts @@ -100,14 +100,6 @@ describe('RealUnitDevService', () => { let transactionService: jest.Mocked; let buyCryptoRepo: jest.Mocked; - const mainnetRealuAsset = createCustomAsset({ - id: 399, - name: 'REALU', - blockchain: Blockchain.ETHEREUM, - type: AssetType.TOKEN, - decimals: 0, - }); - const sepoliaRealuAsset = createCustomAsset({ id: 408, name: 'REALU', @@ -144,7 +136,7 @@ describe('RealUnitDevService', () => { id: 7, amount: 100, sourceId: 1, - targetId: 399, + targetId: 408, // Sepolia REALU asset ID routeId: 1, status: TransactionRequestStatus.WAITING_FOR_PAYMENT, type: TransactionRequestType.BUY, @@ -244,37 +236,25 @@ describe('RealUnitDevService', () => { it('should execute on DEV environment', async () => { (global as any).__mockEnvironment = 'dev'; - assetService.getAssetByQuery.mockResolvedValueOnce(mainnetRealuAsset); assetService.getAssetByQuery.mockResolvedValueOnce(sepoliaRealuAsset); transactionRequestRepo.find.mockResolvedValue([]); await service.simulateRealuPayments(); - expect(assetService.getAssetByQuery).toHaveBeenCalledTimes(2); + expect(assetService.getAssetByQuery).toHaveBeenCalledTimes(1); }); it('should execute on LOC environment', async () => { (global as any).__mockEnvironment = 'loc'; - assetService.getAssetByQuery.mockResolvedValueOnce(mainnetRealuAsset); assetService.getAssetByQuery.mockResolvedValueOnce(sepoliaRealuAsset); transactionRequestRepo.find.mockResolvedValue([]); await service.simulateRealuPayments(); - expect(assetService.getAssetByQuery).toHaveBeenCalledTimes(2); - }); - - it('should skip if mainnet REALU asset not found', async () => { - assetService.getAssetByQuery.mockResolvedValueOnce(null); - assetService.getAssetByQuery.mockResolvedValueOnce(sepoliaRealuAsset); - - await service.simulateRealuPayments(); - - expect(transactionRequestRepo.find).not.toHaveBeenCalled(); + expect(assetService.getAssetByQuery).toHaveBeenCalledTimes(1); }); it('should skip if sepolia REALU asset not found', async () => { - assetService.getAssetByQuery.mockResolvedValueOnce(mainnetRealuAsset); assetService.getAssetByQuery.mockResolvedValueOnce(null); await service.simulateRealuPayments(); @@ -283,7 +263,6 @@ describe('RealUnitDevService', () => { }); it('should skip if no waiting requests', async () => { - assetService.getAssetByQuery.mockResolvedValueOnce(mainnetRealuAsset); assetService.getAssetByQuery.mockResolvedValueOnce(sepoliaRealuAsset); transactionRequestRepo.find.mockResolvedValue([]); @@ -292,8 +271,7 @@ describe('RealUnitDevService', () => { expect(buyService.getBuyByKey).not.toHaveBeenCalled(); }); - it('should query for WAITING_FOR_PAYMENT requests with mainnet REALU targetId', async () => { - assetService.getAssetByQuery.mockResolvedValueOnce(mainnetRealuAsset); + it('should query for WAITING_FOR_PAYMENT requests with sepolia REALU targetId', async () => { assetService.getAssetByQuery.mockResolvedValueOnce(sepoliaRealuAsset); transactionRequestRepo.find.mockResolvedValue([]); @@ -303,7 +281,7 @@ describe('RealUnitDevService', () => { where: { status: TransactionRequestStatus.WAITING_FOR_PAYMENT, type: TransactionRequestType.BUY, - targetId: 399, + targetId: 408, }, }); }); @@ -311,7 +289,6 @@ describe('RealUnitDevService', () => { describe('simulatePaymentForRequest', () => { beforeEach(() => { - assetService.getAssetByQuery.mockResolvedValueOnce(mainnetRealuAsset); assetService.getAssetByQuery.mockResolvedValueOnce(sepoliaRealuAsset); }); diff --git a/src/subdomains/supporting/realunit/realunit-dev.service.ts b/src/subdomains/supporting/realunit/realunit-dev.service.ts index 174ecc52d2..9025c16cca 100644 --- a/src/subdomains/supporting/realunit/realunit-dev.service.ts +++ b/src/subdomains/supporting/realunit/realunit-dev.service.ts @@ -28,6 +28,12 @@ import { TransactionService } from '../payment/services/transaction.service'; @Injectable() export class RealUnitDevService { private readonly logger = new DfxLogger(RealUnitDevService); + private readonly tokenName = 'REALU'; + private readonly tokenBlockchain = Blockchain.SEPOLIA; + + private get isDevEnvironment(): boolean { + return [Environment.DEV, Environment.LOC].includes(Config.environment); + } constructor( private readonly transactionRequestRepo: TransactionRequestRepository, @@ -44,7 +50,7 @@ export class RealUnitDevService { @Cron(CronExpression.EVERY_MINUTE) @Lock(60) async simulateRealuPayments(): Promise { - if (![Environment.DEV, Environment.LOC].includes(Config.environment)) return; + if (!this.isDevEnvironment) return; try { await this.processWaitingRealuRequests(); @@ -54,22 +60,14 @@ export class RealUnitDevService { } private async processWaitingRealuRequests(): Promise { - // TransactionRequests are created with Mainnet REALU (via realunit.service.ts) - const mainnetRealuAsset = await this.assetService.getAssetByQuery({ - name: 'REALU', - blockchain: Blockchain.ETHEREUM, - type: AssetType.TOKEN, - }); - - // But payouts go to Sepolia in DEV environment - const sepoliaRealuAsset = await this.assetService.getAssetByQuery({ - name: 'REALU', - blockchain: Blockchain.SEPOLIA, + const realuAsset = await this.assetService.getAssetByQuery({ + name: this.tokenName, + blockchain: this.tokenBlockchain, type: AssetType.TOKEN, }); - if (!mainnetRealuAsset || !sepoliaRealuAsset) { - this.logger.warn('REALU asset not found (mainnet or sepolia) - skipping simulation'); + if (!realuAsset) { + this.logger.warn('REALU asset not found - skipping buy simulation'); return; } @@ -77,7 +75,7 @@ export class RealUnitDevService { where: { status: TransactionRequestStatus.WAITING_FOR_PAYMENT, type: TransactionRequestType.BUY, - targetId: mainnetRealuAsset.id, + targetId: realuAsset.id, }, }); @@ -87,7 +85,7 @@ export class RealUnitDevService { for (const request of waitingRequests) { try { - await this.simulatePaymentForRequest(request, sepoliaRealuAsset); + await this.simulatePaymentForRequest(request, realuAsset); } catch (e) { this.logger.error(`Failed to simulate payment for TransactionRequest ${request.id}:`, e); } diff --git a/src/subdomains/supporting/realunit/realunit.service.ts b/src/subdomains/supporting/realunit/realunit.service.ts index 33b0abaf93..3d539bf539 100644 --- a/src/subdomains/supporting/realunit/realunit.service.ts +++ b/src/subdomains/supporting/realunit/realunit.service.ts @@ -8,7 +8,7 @@ import { } from '@nestjs/common'; import { verifyTypedData } from 'ethers/lib/utils'; import { request } from 'graphql-request'; -import { Config, GetConfig } from 'src/config/config'; +import { Config, Environment, GetConfig } from 'src/config/config'; import { BrokerbotBuyPriceDto, BrokerbotInfoDto, @@ -75,7 +75,9 @@ export class RealUnitService { private readonly ponderUrl: string; private readonly genesisDate = new Date('2022-04-12 07:46:41.000'); private readonly tokenName = 'REALU'; - private readonly tokenBlockchain = Blockchain.ETHEREUM; + private readonly tokenBlockchain = [Environment.DEV, Environment.LOC].includes(Config.environment) + ? Blockchain.SEPOLIA + : Blockchain.ETHEREUM; private readonly historicalPriceCache = new AsyncCache(CacheItemResetPeriod.EVERY_6_HOURS); constructor( From 503208438988c6ac0dd42f2bd5234cda7106e95d Mon Sep 17 00:00:00 2001 From: bernd2022 <104787072+bernd2022@users.noreply.github.com> Date: Tue, 13 Jan 2026 15:52:57 +0100 Subject: [PATCH 5/6] fix(bank-data): transform empty string names to null in DTOs (#2918) Empty strings in bankData.name bypass nullish coalescing fallback logic because ?? treats "" as a present value. This causes incorrect behavior in AML name resolution where empty strings are used instead of actual user data. Add @Transform decorator to name field in CreateBankDataDto and UpdateBankDataDto that trims whitespace and converts empty strings to null, preventing invalid data at the source. Closes #2911 --- .../dto/__tests__/bank-data-dto.spec.ts | 73 +++++++++++++++++++ .../bank-data/dto/create-bank-data.dto.ts | 1 + .../bank-data/dto/update-bank-data.dto.ts | 2 + 3 files changed, 76 insertions(+) create mode 100644 src/subdomains/generic/user/models/bank-data/dto/__tests__/bank-data-dto.spec.ts diff --git a/src/subdomains/generic/user/models/bank-data/dto/__tests__/bank-data-dto.spec.ts b/src/subdomains/generic/user/models/bank-data/dto/__tests__/bank-data-dto.spec.ts new file mode 100644 index 0000000000..a8c058c4fb --- /dev/null +++ b/src/subdomains/generic/user/models/bank-data/dto/__tests__/bank-data-dto.spec.ts @@ -0,0 +1,73 @@ +import { plainToInstance } from 'class-transformer'; +import { CreateBankDataDto } from '../create-bank-data.dto'; +import { UpdateBankDataDto } from '../update-bank-data.dto'; + +describe('BankDataDto', () => { + describe('CreateBankDataDto', () => { + describe('name transform', () => { + it('should keep valid name unchanged', () => { + const dto = plainToInstance(CreateBankDataDto, { iban: 'CH123', name: 'Max Mustermann' }); + expect(dto.name).toBe('Max Mustermann'); + }); + + it('should trim whitespace from name', () => { + const dto = plainToInstance(CreateBankDataDto, { iban: 'CH123', name: ' Max Mustermann ' }); + expect(dto.name).toBe('Max Mustermann'); + }); + + it('should transform empty string to null', () => { + const dto = plainToInstance(CreateBankDataDto, { iban: 'CH123', name: '' }); + expect(dto.name).toBeUndefined(); + }); + + it('should transform whitespace-only string to null', () => { + const dto = plainToInstance(CreateBankDataDto, { iban: 'CH123', name: ' ' }); + expect(dto.name).toBeUndefined(); + }); + + it('should transform undefined to null', () => { + const dto = plainToInstance(CreateBankDataDto, { iban: 'CH123', name: undefined }); + expect(dto.name).toBeUndefined(); + }); + + it('should transform null to null', () => { + const dto = plainToInstance(CreateBankDataDto, { iban: 'CH123', name: null }); + expect(dto.name).toBeUndefined(); + }); + }); + }); + + describe('UpdateBankDataDto', () => { + describe('name transform', () => { + it('should keep valid name unchanged', () => { + const dto = plainToInstance(UpdateBankDataDto, { name: 'Max Mustermann' }); + expect(dto.name).toBe('Max Mustermann'); + }); + + it('should trim whitespace from name', () => { + const dto = plainToInstance(UpdateBankDataDto, { name: ' Max Mustermann ' }); + expect(dto.name).toBe('Max Mustermann'); + }); + + it('should transform empty string to null', () => { + const dto = plainToInstance(UpdateBankDataDto, { name: '' }); + expect(dto.name).toBeUndefined(); + }); + + it('should transform whitespace-only string to null', () => { + const dto = plainToInstance(UpdateBankDataDto, { name: ' ' }); + expect(dto.name).toBeUndefined(); + }); + + it('should transform undefined to null', () => { + const dto = plainToInstance(UpdateBankDataDto, { name: undefined }); + expect(dto.name).toBeUndefined(); + }); + + it('should transform null to null', () => { + const dto = plainToInstance(UpdateBankDataDto, { name: null }); + expect(dto.name).toBeUndefined(); + }); + }); + }); +}); diff --git a/src/subdomains/generic/user/models/bank-data/dto/create-bank-data.dto.ts b/src/subdomains/generic/user/models/bank-data/dto/create-bank-data.dto.ts index ec32ae40e5..3f6d6aa905 100644 --- a/src/subdomains/generic/user/models/bank-data/dto/create-bank-data.dto.ts +++ b/src/subdomains/generic/user/models/bank-data/dto/create-bank-data.dto.ts @@ -17,6 +17,7 @@ export class CreateBankDataDto { @IsOptional() @IsString() + @Transform(({ value }) => value?.trim() || undefined) name?: string; @IsOptional() diff --git a/src/subdomains/generic/user/models/bank-data/dto/update-bank-data.dto.ts b/src/subdomains/generic/user/models/bank-data/dto/update-bank-data.dto.ts index 0dfc6102fb..d4c13117d5 100644 --- a/src/subdomains/generic/user/models/bank-data/dto/update-bank-data.dto.ts +++ b/src/subdomains/generic/user/models/bank-data/dto/update-bank-data.dto.ts @@ -1,3 +1,4 @@ +import { Transform } from 'class-transformer'; import { IsBoolean, IsEnum, IsOptional, IsString } from 'class-validator'; import { ReviewStatus } from 'src/subdomains/generic/kyc/enums/review-status.enum'; import { UpdateBankAccountDto } from 'src/subdomains/supporting/bank/bank-account/dto/update-bank-account.dto'; @@ -6,6 +7,7 @@ import { BankDataType } from '../bank-data.entity'; export class UpdateBankDataDto extends UpdateBankAccountDto { @IsOptional() @IsString() + @Transform(({ value }) => value?.trim() || undefined) name?: string; @IsOptional() From 029d05c2791fd67c8fe80f2f4efc5c3adb8b6490 Mon Sep 17 00:00:00 2001 From: Danswar <48102227+Danswar@users.noreply.github.com> Date: Tue, 13 Jan 2026 15:55:26 +0100 Subject: [PATCH 6/6] Update XT/DEURO liquidity minimum from 4300 to 10000 (#2920) --- ...315830503-UpdateXtDeuroLiquidityMinimum.js | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 migration/1768315830503-UpdateXtDeuroLiquidityMinimum.js diff --git a/migration/1768315830503-UpdateXtDeuroLiquidityMinimum.js b/migration/1768315830503-UpdateXtDeuroLiquidityMinimum.js new file mode 100644 index 0000000000..deb2000733 --- /dev/null +++ b/migration/1768315830503-UpdateXtDeuroLiquidityMinimum.js @@ -0,0 +1,36 @@ +/** + * @typedef {import('typeorm').MigrationInterface} MigrationInterface + * @typedef {import('typeorm').QueryRunner} QueryRunner + */ + +/** + * @class + * @implements {MigrationInterface} + */ +module.exports = class UpdateXtDeuroLiquidityMinimum1768315830503 { + name = 'UpdateXtDeuroLiquidityMinimum1768315830503' + + /** + * @param {QueryRunner} queryRunner + */ + async up(queryRunner) { + // Update XT/DEURO liquidity rule minimum from 4300 to 10000 + await queryRunner.query(` + UPDATE "dbo"."liquidity_management_rule" + SET "minimal" = 10000 + WHERE "id" = 295 + `); + } + + /** + * @param {QueryRunner} queryRunner + */ + async down(queryRunner) { + // Revert XT/DEURO liquidity rule minimum back to 4300 + await queryRunner.query(` + UPDATE "dbo"."liquidity_management_rule" + SET "minimal" = 4300 + WHERE "id" = 295 + `); + } +}