From df5d49cc4cb1a969f21f18807de6caad51260b39 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Wed, 11 Mar 2026 09:01:39 +0100 Subject: [PATCH 1/2] feat: add Edge exchange provider API requirements (#3347) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add Edge exchange provider API requirements - Add evmChainId to AssetInDto for EVM chain identification - Add EvmUtil.getBlockchain() reverse lookup (chainId → Blockchain) - Extend resolveAsset() to support evmChainId + chainId combination - Add RegionRestricted and AssetUnsupported to QuoteError enum - Add structured errors array to Buy/Sell/Swap quote DTOs - Add QuoteErrorUtil to map internal errors to Edge format - Add GET /transaction/status/:orderId with simplified status mapping - Update EDGE_REQUIREMENTS.md tracking * feat 2: removed unused endpoint * feat 6: added missing response fields and result limit * feat 8: monthly ref payout * feat 2: find transaction by order UID (if available) * feat: Spark channel retry * feat 10: currency name support, country param, quote error non-400 * feat 3: multiple quote errors, standardized structure * fix: tests * fix: PR comments * chore: more reactoring * chore: improved Swagger docu * chore: more refactoring * chore: more refactoring --------- Co-authored-by: David May --- migration/1772756747747-RefPayoutFrequency.js | 27 +++++ .../blockchain/shared/evm/evm.util.ts | 7 ++ .../blockchain/spark/spark-client.ts | 90 ++++++++------- src/shared/models/asset/asset.entity.ts | 5 + src/shared/models/asset/dto/asset.dto.ts | 27 +++-- src/shared/models/fiat/dto/fiat.dto.ts | 17 ++- src/shared/services/payment-info.service.ts | 107 ++++++++++++------ src/shared/utils/async-field.ts | 5 + src/shared/utils/util.ts | 20 ++++ .../buy-crypto/routes/buy/buy.controller.ts | 25 ++-- .../routes/buy/dto/buy-quote.dto.ts | 6 +- .../routes/buy/dto/get-buy-quote.dto.ts | 11 +- .../routes/swap/dto/swap-quote.dto.ts | 6 +- .../buy-crypto/routes/swap/swap.controller.ts | 24 ++-- .../__tests__/transaction-helper.spec.ts | 4 + .../controllers/transaction.controller.ts | 16 ++- .../core/history/services/history.service.ts | 2 +- .../route/dto/get-sell-quote.dto.ts | 11 +- .../sell-crypto/route/dto/sell-quote.dto.ts | 6 +- .../core/sell-crypto/route/sell.controller.ts | 24 ++-- .../kyc/controllers/kyc-client.controller.ts | 9 +- .../kyc/services/kyc-client.service.ts | 20 ++-- .../user/models/user/dto/user-v2.dto.ts | 14 ++- .../generic/user/models/user/user.entity.ts | 5 +- .../generic/user/models/user/user.enum.ts | 5 + .../generic/user/models/user/user.service.ts | 41 ++++--- .../webhook/dto/payment-webhook.dto.ts | 17 ++- .../webhook/mapper/webhook-data.mapper.ts | 28 +++++ .../transaction-helper/quote-error.enum.ts | 3 + .../transaction-helper/quote-error.util.ts | 87 ++++++++++++++ .../structured-error.dto.ts | 13 +++ .../transaction-details.dto.ts | 2 + .../payment/services/transaction-helper.ts | 74 +++++++----- .../payment/services/transaction.service.ts | 46 +++++--- .../supporting/realunit/realunit.service.ts | 2 +- 35 files changed, 613 insertions(+), 193 deletions(-) create mode 100644 migration/1772756747747-RefPayoutFrequency.js create mode 100644 src/subdomains/supporting/payment/dto/transaction-helper/quote-error.util.ts create mode 100644 src/subdomains/supporting/payment/dto/transaction-helper/structured-error.dto.ts diff --git a/migration/1772756747747-RefPayoutFrequency.js b/migration/1772756747747-RefPayoutFrequency.js new file mode 100644 index 0000000000..2d68638fd3 --- /dev/null +++ b/migration/1772756747747-RefPayoutFrequency.js @@ -0,0 +1,27 @@ +/** + * @typedef {import('typeorm').MigrationInterface} MigrationInterface + * @typedef {import('typeorm').QueryRunner} QueryRunner + */ + +/** + * @class + * @implements {MigrationInterface} + */ +module.exports = class RefPayoutFrequency1772756747747 { + name = 'RefPayoutFrequency1772756747747' + + /** + * @param {QueryRunner} queryRunner + */ + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "user" ADD "refPayoutFrequency" nvarchar(256) NOT NULL CONSTRAINT "DF_925ad625277b6513eaee6172211" DEFAULT 'Daily'`); + } + + /** + * @param {QueryRunner} queryRunner + */ + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "user" DROP CONSTRAINT "DF_925ad625277b6513eaee6172211"`); + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "refPayoutFrequency"`); + } +} diff --git a/src/integration/blockchain/shared/evm/evm.util.ts b/src/integration/blockchain/shared/evm/evm.util.ts index f5fe4cc4ea..fc3450a9d8 100644 --- a/src/integration/blockchain/shared/evm/evm.util.ts +++ b/src/integration/blockchain/shared/evm/evm.util.ts @@ -57,6 +57,13 @@ export class EvmUtil { return this.blockchainToChainIdMap.get(blockchain); } + static getBlockchain(chainId: number): Blockchain | undefined { + for (const [blockchain, id] of this.blockchainToChainIdMap.entries()) { + if (id === chainId) return blockchain; + } + return undefined; + } + static createWallet({ seed, index }: WalletAccount, provider?: ethers.providers.JsonRpcProvider): ethers.Wallet { const wallet = ethers.Wallet.fromMnemonic(seed, this.getPathFor(index)); return provider ? wallet.connect(provider) : wallet; diff --git a/src/integration/blockchain/spark/spark-client.ts b/src/integration/blockchain/spark/spark-client.ts index 522bf20cd6..e90353c166 100644 --- a/src/integration/blockchain/spark/spark-client.ts +++ b/src/integration/blockchain/spark/spark-client.ts @@ -56,6 +56,22 @@ export class SparkClient extends BlockchainClient { this.cachedAddress = new AsyncField(() => this.wallet.then((w) => w.getSparkAddress()), true); } + private async call(operation: (wallet: SparkWallet) => Promise): Promise { + try { + const wallet = await this.wallet; + return await operation(wallet); + } catch (e) { + if (e?.message?.includes('Channel has been shut down')) { + this.logger.info('Spark channel shut down, reinitializing wallet...'); + this.wallet.reset(); + this.cachedAddress.reset(); + const wallet = await this.wallet; + return operation(wallet); + } + throw e; + } + } + get walletAddress(): string { return this.cachedAddress.value; } @@ -63,44 +79,43 @@ export class SparkClient extends BlockchainClient { // --- TRANSACTION METHODS --- // async sendTransaction(to: string, amount: number): Promise<{ txid: string; fee: number }> { - const wallet = await this.wallet; + return this.call(async (wallet) => { + const amountSats = Math.round(amount * 1e8); - await this.syncLeaves(wallet); + await this.syncLeaves(wallet); - const amountSats = Math.round(amount * 1e8); + const result = await wallet.transfer({ + amountSats, + receiverSparkAddress: to, + }); - const result = await wallet.transfer({ - amountSats, - receiverSparkAddress: to, + return { txid: result.id, fee: 0 }; }); - - return { txid: result.id, fee: 0 }; } async getTransaction(txId: string): Promise { - const wallet = await this.wallet; - - await this.syncLeaves(wallet); - - const transfer = await wallet.getTransfer(txId); - - if (!transfer) { - throw new Error(`Transaction ${txId} not found`); - } - - // Outgoing: complete once sender key is tweaked (funds left our wallet) - // Incoming: complete once receiver has claimed - const isConfirmed = - transfer.status === 'TRANSFER_STATUS_SENDER_KEY_TWEAKED' || transfer.status === 'TRANSFER_STATUS_COMPLETED'; - - return { - txid: transfer.id, - blockhash: isConfirmed ? 'confirmed' : undefined, - confirmations: isConfirmed ? 1 : 0, - time: transfer.createdTime ? Math.floor(transfer.createdTime.getTime() / 1000) : undefined, - blocktime: transfer.updatedTime ? Math.floor(transfer.updatedTime.getTime() / 1000) : undefined, - fee: 0, - }; + return this.call(async (wallet) => { + await this.syncLeaves(wallet); + + const transfer = await wallet.getTransfer(txId); + + if (!transfer) { + throw new Error(`Transaction ${txId} not found`); + } + + // Outgoing: complete once sender key is tweaked (funds left our wallet) + // Incoming: complete once receiver has claimed + const isConfirmed = ['TRANSFER_STATUS_SENDER_KEY_TWEAKED', 'TRANSFER_STATUS_COMPLETED'].includes(transfer.status); + + return { + txid: transfer.id, + blockhash: isConfirmed ? 'confirmed' : undefined, + confirmations: isConfirmed ? 1 : 0, + time: transfer.createdTime ? Math.floor(transfer.createdTime.getTime() / 1000) : undefined, + blocktime: transfer.updatedTime ? Math.floor(transfer.updatedTime.getTime() / 1000) : undefined, + fee: 0, + }; + }); } async getTransfers(limit = 100, offset = 0): Promise { @@ -192,8 +207,7 @@ export class SparkClient extends BlockchainClient { async isHealthy(): Promise { try { - const wallet = await this.wallet; - return wallet != null; + return await this.call(async (wallet) => wallet != null); } catch { return false; } @@ -202,13 +216,13 @@ export class SparkClient extends BlockchainClient { // --- BLOCKCHAIN CLIENT INTERFACE --- // async getNativeCoinBalance(): Promise { - const wallet = await this.wallet; + return this.call(async (wallet) => { + const { balance } = await wallet.getBalance(); - await this.syncLeaves(wallet); + await this.syncLeaves(wallet); - const { balance } = await wallet.getBalance(); - - return Number(balance) / 1e8; + return Number(balance) / 1e8; + }); } async getNativeCoinBalanceForAddress(_address: string): Promise { diff --git a/src/shared/models/asset/asset.entity.ts b/src/shared/models/asset/asset.entity.ts index d45aa87cfc..f1520becaa 100644 --- a/src/shared/models/asset/asset.entity.ts +++ b/src/shared/models/asset/asset.entity.ts @@ -1,4 +1,5 @@ import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { EvmUtil } from 'src/integration/blockchain/shared/evm/evm.util'; import { AmlRule } from 'src/subdomains/core/aml/enums/aml-rule.enum'; import { LiquidityBalance } from 'src/subdomains/core/liquidity-management/entities/liquidity-balance.entity'; import { LiquidityManagementRule } from 'src/subdomains/core/liquidity-management/entities/liquidity-management-rule.entity'; @@ -131,6 +132,10 @@ export class Asset extends IEntity { return this.approxPriceChf ? 1 / this.approxPriceChf : 1; } + get evmChainId(): number | undefined { + return EvmUtil.getChainId(this.blockchain); + } + isBuyableOn(blockchains: Blockchain[]): boolean { return blockchains.includes(this.blockchain) || this.type === AssetType.CUSTOM; } diff --git a/src/shared/models/asset/dto/asset.dto.ts b/src/shared/models/asset/dto/asset.dto.ts index aa936e0ab4..1364eeea44 100644 --- a/src/shared/models/asset/dto/asset.dto.ts +++ b/src/shared/models/asset/dto/asset.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsEnum, IsInt, IsNotEmpty, IsString, ValidateIf } from 'class-validator'; +import { IsEnum, IsInt, IsNotEmpty, IsOptional, IsString, ValidateIf } from 'class-validator'; import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; import { AssetCategory, AssetType } from '../asset.entity'; @@ -75,23 +75,34 @@ export class AssetDto { } export class AssetInDto { - @ApiPropertyOptional() + @ApiPropertyOptional({ description: 'DFX asset ID. Use either id alone OR blockchain/evmChainId with chainId.' }) @IsNotEmpty() - @ValidateIf((a: AssetInDto) => Boolean(a.id || !(a.chainId || a.blockchain))) + @ValidateIf((a: AssetInDto) => a.id != null || !(a.blockchain || a.evmChainId)) @IsInt() id?: number; - @ApiPropertyOptional() - @IsNotEmpty() - @ValidateIf((a: AssetInDto) => Boolean(a.chainId || !a.id)) + @ApiPropertyOptional({ + description: 'On-chain contract address (for tokens). If omitted with blockchain/evmChainId, uses native coin.', + }) + @IsOptional() @IsString() chainId?: string; - @ApiPropertyOptional() + @ApiPropertyOptional({ + description: 'Blockchain name (e.g. Ethereum, Polygon). Use with chainId.', + }) @IsNotEmpty() - @ValidateIf((a: AssetInDto) => Boolean(a.blockchain || !a.id)) + @ValidateIf((a: AssetInDto) => a.blockchain != null || (!a.id && !a.evmChainId)) @IsEnum(Blockchain) blockchain?: Blockchain; + + @ApiPropertyOptional({ + description: 'Numeric EVM chain ID (e.g. 1 for Ethereum, 137 for Polygon). Alternative to blockchain.', + }) + @IsNotEmpty() + @ValidateIf((a: AssetInDto) => a.evmChainId != null || (!a.id && !a.blockchain)) + @IsInt() + evmChainId?: number; } export class AssetLimitsDto { diff --git a/src/shared/models/fiat/dto/fiat.dto.ts b/src/shared/models/fiat/dto/fiat.dto.ts index 7b12f6b5e7..f20df83d1f 100644 --- a/src/shared/models/fiat/dto/fiat.dto.ts +++ b/src/shared/models/fiat/dto/fiat.dto.ts @@ -1,6 +1,21 @@ -import { ApiProperty } from '@nestjs/swagger'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsInt, IsNotEmpty, IsString, ValidateIf } from 'class-validator'; import { FiatPaymentMethod } from 'src/subdomains/supporting/payment/dto/payment-method.enum'; +export class FiatInDto { + @ApiPropertyOptional({ description: 'Fiat currency ID' }) + @IsNotEmpty() + @ValidateIf((f: FiatInDto) => Boolean(f.id || !f.name)) + @IsInt() + id?: number; + + @ApiPropertyOptional({ description: 'Fiat currency code (e.g. EUR, USD, CHF)' }) + @IsNotEmpty() + @ValidateIf((f: FiatInDto) => Boolean(f.name || !f.id)) + @IsString() + name?: string; +} + export class FiatDto { @ApiProperty() id: number; diff --git a/src/shared/services/payment-info.service.ts b/src/shared/services/payment-info.service.ts index cd7f3e57c8..7a66f77207 100644 --- a/src/shared/services/payment-info.service.ts +++ b/src/shared/services/payment-info.service.ts @@ -1,5 +1,6 @@ -import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { BadRequestException, Injectable } from '@nestjs/common'; import { Config, Environment } from 'src/config/config'; +import { EvmUtil } from 'src/integration/blockchain/shared/evm/evm.util'; import { AmlRule } from 'src/subdomains/core/aml/enums/aml-rule.enum'; import { CreateBuyDto } from 'src/subdomains/core/buy-crypto/routes/buy/dto/create-buy.dto'; import { GetBuyPaymentInfoDto } from 'src/subdomains/core/buy-crypto/routes/buy/dto/get-buy-payment-info.dto'; @@ -14,9 +15,12 @@ import { GetSellQuoteDto } from 'src/subdomains/core/sell-crypto/route/dto/get-s import { KycLevel } from 'src/subdomains/generic/user/models/user-data/user-data.enum'; import { User } from 'src/subdomains/generic/user/models/user/user.entity'; import { FiatPaymentMethod } from 'src/subdomains/supporting/payment/dto/payment-method.enum'; +import { QuoteError } from 'src/subdomains/supporting/payment/dto/transaction-helper/quote-error.enum'; +import { QuoteException } from 'src/subdomains/supporting/payment/dto/transaction-helper/quote-error.util'; import { JwtPayload } from '../auth/jwt-payload.interface'; import { Asset } from '../models/asset/asset.entity'; import { AssetService } from '../models/asset/asset.service'; +import { Fiat } from '../models/fiat/fiat.entity'; import { FiatService } from '../models/fiat/fiat.service'; import { DisabledProcess, Process } from './process.service'; @@ -31,26 +35,37 @@ export class PaymentInfoService { dto: T, jwt?: JwtPayload, user?: User, + forQuote = false, ): Promise { if ('currency' in dto) { - dto.currency = await this.fiatService.getFiat(dto.currency.id); - if (!dto.currency) throw new NotFoundException('Currency not found'); + dto.currency = await this.resolveFiat(dto.currency); + if (!dto.currency) throw this.createError('Currency not found', QuoteError.CURRENCY_UNSUPPORTED, forQuote); } - dto.asset = await this.resolveAsset(dto.asset); - if (!dto.asset) throw new NotFoundException('Asset not found'); - if (jwt && !dto.asset.isBuyableOn(jwt.blockchains)) throw new BadRequestException('Asset blockchain mismatch'); + dto.asset = await this.resolveAsset(dto.asset, forQuote); + if (!dto.asset) throw this.createError('Asset not found', QuoteError.ASSET_UNSUPPORTED, forQuote); + if (jwt && !dto.asset.isBuyableOn(jwt.blockchains)) + throw this.createError('Asset blockchain mismatch', QuoteError.ASSET_UNSUPPORTED, forQuote); if ('paymentMethod' in dto && dto.paymentMethod === FiatPaymentMethod.CARD) { - if (!dto.currency.cardSellable) throw new BadRequestException('Currency not sellable via Card'); - if (!dto.asset.cardBuyable) throw new BadRequestException('Asset not buyable via Card'); + if (!dto.currency.cardSellable) + throw this.createError('Currency not sellable via Card', QuoteError.CURRENCY_UNSUPPORTED, forQuote); + if (!dto.asset.cardBuyable) + throw this.createError('Asset not buyable via Card', QuoteError.ASSET_UNSUPPORTED, forQuote); } else if ('paymentMethod' in dto && dto.paymentMethod === FiatPaymentMethod.INSTANT) { - if (!dto.currency.instantSellable) throw new BadRequestException('Currency not sellable via Instant'); - if (!dto.asset.instantBuyable) throw new BadRequestException('Asset not buyable via Instant'); + if (!dto.currency.instantSellable) + throw this.createError('Currency not sellable via Instant', QuoteError.CURRENCY_UNSUPPORTED, forQuote); + if (!dto.asset.instantBuyable) + throw this.createError('Asset not buyable via Instant', QuoteError.ASSET_UNSUPPORTED, forQuote); } else { - if ('currency' in dto && !dto.currency.sellable) throw new BadRequestException('Currency not sellable via Bank'); + if ('currency' in dto && !dto.currency.sellable) + throw this.createError('Currency not sellable via Bank', QuoteError.CURRENCY_UNSUPPORTED, forQuote); if (!dto.asset.buyable) - throw new BadRequestException(`Asset not buyable ${'paymentMethod' in dto ? 'via Bank' : ''}`); + throw this.createError( + `Asset not buyable ${'paymentMethod' in dto ? 'via Bank' : ''}`, + QuoteError.ASSET_UNSUPPORTED, + forQuote, + ); } if ('discountCode' in dto) dto.specialCode = dto.discountCode; @@ -74,21 +89,25 @@ export class PaymentInfoService { async sellCheck( dto: T, jwt?: JwtPayload, + forQuote = false, ): Promise { if ('asset' in dto) { - dto.asset = await this.resolveAsset(dto.asset); - if (!dto.asset) throw new NotFoundException('Asset not found'); - if (!dto.asset.sellable) throw new BadRequestException('Asset not sellable'); - if (jwt && !dto.asset.isBuyableOn(jwt.blockchains)) throw new BadRequestException('Asset blockchain mismatch'); + dto.asset = await this.resolveAsset(dto.asset, forQuote); + if (!dto.asset) throw this.createError('Asset not found', QuoteError.ASSET_UNSUPPORTED, forQuote); + if (!dto.asset.sellable) throw this.createError('Asset not sellable', QuoteError.ASSET_UNSUPPORTED, forQuote); + if (jwt && !dto.asset.isBuyableOn(jwt.blockchains)) + throw this.createError('Asset blockchain mismatch', QuoteError.ASSET_UNSUPPORTED, forQuote); } if ('blockchain' in dto) { - if (jwt && !jwt.blockchains.includes(dto.blockchain)) throw new BadRequestException('Asset blockchain mismatch'); + if (jwt && !jwt.blockchains.includes(dto.blockchain)) + throw this.createError('Asset blockchain mismatch', QuoteError.ASSET_UNSUPPORTED, forQuote); } - dto.currency = await this.fiatService.getFiat(dto.currency.id); - if (!dto.currency) throw new NotFoundException('Currency not found'); - if (!dto.currency.buyable) throw new BadRequestException('Currency not buyable'); + dto.currency = await this.resolveFiat(dto.currency); + if (!dto.currency) throw this.createError('Currency not found', QuoteError.CURRENCY_UNSUPPORTED, forQuote); + if (!dto.currency.buyable) + throw this.createError('Currency not buyable', QuoteError.CURRENCY_UNSUPPORTED, forQuote); if ('iban' in dto && dto.currency?.name === 'CHF' && !Config.isDomesticIban(dto.iban)) throw new BadRequestException( @@ -103,34 +122,56 @@ export class PaymentInfoService { async swapCheck( dto: T, jwt?: JwtPayload, + forQuote = false, ): Promise { if ('sourceAsset' in dto) { - dto.sourceAsset = await this.resolveAsset(dto.sourceAsset); - if (!dto.sourceAsset) throw new NotFoundException('Source asset not found'); - if (!dto.sourceAsset.sellable) throw new BadRequestException('Source asset not sellable'); + dto.sourceAsset = await this.resolveAsset(dto.sourceAsset, forQuote); + if (!dto.sourceAsset) throw this.createError('Source asset not found', QuoteError.ASSET_UNSUPPORTED, forQuote); + if (!dto.sourceAsset.sellable) + throw this.createError('Source asset not sellable', QuoteError.ASSET_UNSUPPORTED, forQuote); if (NoSwapBlockchains.includes(dto.sourceAsset.blockchain)) - throw new BadRequestException('Assets on this blockchain are not swappable'); + throw this.createError('Assets on this blockchain are not swappable', QuoteError.ASSET_UNSUPPORTED, forQuote); } if ('blockchain' in dto) { if (NoSwapBlockchains.includes(dto.blockchain)) - throw new BadRequestException('Assets on this blockchain are not swappable'); + throw this.createError('Assets on this blockchain are not swappable', QuoteError.ASSET_UNSUPPORTED, forQuote); } - dto.targetAsset = await this.resolveAsset(dto.targetAsset); - if (!dto.targetAsset) throw new NotFoundException('Asset not found'); - if (!dto.targetAsset.buyable) throw new BadRequestException('Asset not buyable'); + dto.targetAsset = await this.resolveAsset(dto.targetAsset, forQuote); + if (!dto.targetAsset) throw this.createError('Target asset not found', QuoteError.ASSET_UNSUPPORTED, forQuote); + if (!dto.targetAsset.buyable) + throw this.createError('Target asset not buyable', QuoteError.ASSET_UNSUPPORTED, forQuote); if (jwt && !dto.targetAsset.isBuyableOn(jwt.blockchains)) - throw new BadRequestException('Asset blockchain mismatch'); + throw this.createError('Target asset blockchain mismatch', QuoteError.ASSET_UNSUPPORTED, forQuote); if ('discountCode' in dto) dto.specialCode = dto.discountCode; return dto; } - async resolveAsset(asset: Asset): Promise { - return asset.id - ? this.assetService.getAssetById(asset.id) - : this.assetService.getAssetByChainId(asset.blockchain, asset.chainId); + async resolveAsset(asset: Asset, forQuote = false): Promise { + if (asset.id) return this.assetService.getAssetById(asset.id); + + let blockchain = asset.blockchain; + if (asset.evmChainId && !blockchain) { + blockchain = EvmUtil.getBlockchain(asset.evmChainId); + if (!blockchain) throw this.createError('Unsupported EVM chain ID', QuoteError.ASSET_UNSUPPORTED, forQuote); + } + + return asset.chainId + ? this.assetService.getAssetByChainId(blockchain, asset.chainId) + : this.assetService.getNativeAsset(blockchain); + } + + async resolveFiat(fiat: Fiat): Promise { + if (fiat.id) return this.fiatService.getFiat(fiat.id); + if (fiat.name) return this.fiatService.getFiatByName(fiat.name); + + return null; + } + + private createError(message: string, quoteError: QuoteError, forQuote: boolean): Error { + return forQuote ? new QuoteException(quoteError) : new BadRequestException(message); } } diff --git a/src/shared/utils/async-field.ts b/src/shared/utils/async-field.ts index 2612ff1515..74ce143a13 100644 --- a/src/shared/utils/async-field.ts +++ b/src/shared/utils/async-field.ts @@ -27,6 +27,11 @@ export class AsyncField implements Promise { return this.resolvedValue; } + reset(): void { + this.internalPromise = undefined; + this.resolvedValue = undefined; + } + then( onfulfilled?: ((value: T) => TResult1 | PromiseLike) | undefined | null, onrejected?: ((reason: any) => TResult2 | PromiseLike) | undefined | null, diff --git a/src/shared/utils/util.ts b/src/shared/utils/util.ts index a65426a8a3..74a4991d3d 100644 --- a/src/shared/utils/util.ts +++ b/src/shared/utils/util.ts @@ -573,6 +573,26 @@ export class Util { return batches.reduce((prev, curr) => prev.concat(curr), []); } + static async doInBatchesWithLimit( + list: T[], + action: (batch: T[], remaining?: number) => Promise, + batchSize: number, + limit?: number, + ): Promise { + const results: U[] = []; + + for (let i = 0; i < list.length; i += batchSize) { + const batch = list.slice(i, i + batchSize); + const remaining = limit ? limit - results.length : undefined; + const batchResults = await action(batch, remaining); + results.push(...batchResults); + + if (limit && results.length >= limit) break; + } + + return results; + } + static async doGetFulfilled(tasks: Promise[]): Promise { return Promise.allSettled(tasks).then((results) => results.filter(this.filterFulfilledCalls).map((r) => r.value)); } diff --git a/src/subdomains/core/buy-crypto/routes/buy/buy.controller.ts b/src/subdomains/core/buy-crypto/routes/buy/buy.controller.ts index 52bb482272..e8d9e5a164 100644 --- a/src/subdomains/core/buy-crypto/routes/buy/buy.controller.ts +++ b/src/subdomains/core/buy-crypto/routes/buy/buy.controller.ts @@ -31,6 +31,10 @@ import { VirtualIbanDto } from 'src/subdomains/supporting/bank/virtual-iban/dto/ import { VirtualIbanMapper } from 'src/subdomains/supporting/bank/virtual-iban/dto/virtual-iban.mapper'; import { VirtualIbanService } from 'src/subdomains/supporting/bank/virtual-iban/virtual-iban.service'; import { CryptoPaymentMethod, FiatPaymentMethod } from 'src/subdomains/supporting/payment/dto/payment-method.enum'; +import { + QuoteErrorUtil, + QuoteException, +} from 'src/subdomains/supporting/payment/dto/transaction-helper/quote-error.util'; import { TransactionRequestStatus } from 'src/subdomains/supporting/payment/entities/transaction-request.entity'; import { SwissQRService } from 'src/subdomains/supporting/payment/services/swiss-qr.service'; import { TransactionHelper } from 'src/subdomains/supporting/payment/services/transaction-helper'; @@ -85,14 +89,15 @@ export class BuyController { @Put('/quote') @ApiOkResponse({ type: BuyQuoteDto }) async getBuyQuote(@Body() dto: GetBuyQuoteDto): Promise { - const { - amount: sourceAmount, - currency, - asset, - targetAmount, - paymentMethod, - specialCode, - } = await this.paymentInfoService.buyCheck(dto); + let checkedDto: GetBuyQuoteDto; + try { + checkedDto = await this.paymentInfoService.buyCheck(dto, undefined, undefined, true); + } catch (e) { + if (e instanceof QuoteException) return QuoteErrorUtil.createErrorQuote(e); + throw e; + } + + const { amount: sourceAmount, currency, asset, targetAmount, paymentMethod, specialCode } = checkedDto; const { rate, @@ -107,6 +112,7 @@ export class BuyController { feeTarget, isValid, error, + errors, priceSteps, } = await this.transactionHelper.getTxDetails( sourceAmount, @@ -119,6 +125,8 @@ export class BuyController { undefined, dto.wallet, specialCode ? [specialCode] : [], + undefined, + dto.country, ); return { @@ -136,6 +144,7 @@ export class BuyController { priceSteps, isValid, error, + errors: QuoteErrorUtil.mapToStructuredErrors(errors, minVolume, minVolumeTarget, maxVolume, maxVolumeTarget), }; } diff --git a/src/subdomains/core/buy-crypto/routes/buy/dto/buy-quote.dto.ts b/src/subdomains/core/buy-crypto/routes/buy/dto/buy-quote.dto.ts index ed3f39fb24..e604501398 100644 --- a/src/subdomains/core/buy-crypto/routes/buy/dto/buy-quote.dto.ts +++ b/src/subdomains/core/buy-crypto/routes/buy/dto/buy-quote.dto.ts @@ -1,6 +1,7 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { FeeDto } from 'src/subdomains/supporting/payment/dto/fee.dto'; import { QuoteError } from 'src/subdomains/supporting/payment/dto/transaction-helper/quote-error.enum'; +import { StructuredErrorDto } from 'src/subdomains/supporting/payment/dto/transaction-helper/structured-error.dto'; import { PriceStep } from 'src/subdomains/supporting/pricing/domain/entities/price'; export class BuyQuoteDto { @@ -43,6 +44,9 @@ export class BuyQuoteDto { @ApiProperty() isValid: boolean; - @ApiPropertyOptional({ enum: QuoteError, description: 'Error message in case isValid is false' }) + @ApiPropertyOptional({ enum: QuoteError, description: 'Error message in case isValid is false', deprecated: true }) error?: QuoteError; + + @ApiPropertyOptional({ type: StructuredErrorDto, isArray: true, description: 'Structured errors array' }) + errors?: StructuredErrorDto[]; } diff --git a/src/subdomains/core/buy-crypto/routes/buy/dto/get-buy-quote.dto.ts b/src/subdomains/core/buy-crypto/routes/buy/dto/get-buy-quote.dto.ts index 05371c4dda..3e40a13ad8 100644 --- a/src/subdomains/core/buy-crypto/routes/buy/dto/get-buy-quote.dto.ts +++ b/src/subdomains/core/buy-crypto/routes/buy/dto/get-buy-quote.dto.ts @@ -11,18 +11,18 @@ import { ValidateIf, ValidateNested, } from 'class-validator'; -import { EntityDto } from 'src/shared/dto/entity.dto'; import { Asset } from 'src/shared/models/asset/asset.entity'; import { AssetInDto } from 'src/shared/models/asset/dto/asset.dto'; import { Fiat } from 'src/shared/models/fiat/fiat.entity'; +import { FiatInDto } from 'src/shared/models/fiat/dto/fiat.dto'; import { XOR } from 'src/shared/validators/xor.validator'; import { FiatPaymentMethod } from 'src/subdomains/supporting/payment/dto/payment-method.enum'; export class GetBuyQuoteDto { - @ApiProperty({ type: EntityDto, description: 'Source currency' }) + @ApiProperty({ type: FiatInDto, description: 'Source currency (by ID or name)' }) @IsNotEmptyObject() @ValidateNested() - @Type(() => EntityDto) + @Type(() => FiatInDto) currency: Fiat; @ApiProperty({ type: AssetInDto, description: 'Target asset' }) @@ -64,4 +64,9 @@ export class GetBuyQuoteDto { @IsOptional() @IsString() wallet: string; + + @ApiPropertyOptional({ description: 'Country code (ISO 3166-1 alpha-2, e.g. DE, CH, US)' }) + @IsOptional() + @IsString() + country?: string; } diff --git a/src/subdomains/core/buy-crypto/routes/swap/dto/swap-quote.dto.ts b/src/subdomains/core/buy-crypto/routes/swap/dto/swap-quote.dto.ts index 77729eb240..fcf61aea0f 100644 --- a/src/subdomains/core/buy-crypto/routes/swap/dto/swap-quote.dto.ts +++ b/src/subdomains/core/buy-crypto/routes/swap/dto/swap-quote.dto.ts @@ -1,6 +1,7 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { FeeDto } from 'src/subdomains/supporting/payment/dto/fee.dto'; import { QuoteError } from 'src/subdomains/supporting/payment/dto/transaction-helper/quote-error.enum'; +import { StructuredErrorDto } from 'src/subdomains/supporting/payment/dto/transaction-helper/structured-error.dto'; import { PriceStep } from 'src/subdomains/supporting/pricing/domain/entities/price'; export class SwapQuoteDto { @@ -40,6 +41,9 @@ export class SwapQuoteDto { @ApiProperty() isValid: boolean; - @ApiPropertyOptional({ enum: QuoteError, description: 'Error message in case isValid is false' }) + @ApiPropertyOptional({ enum: QuoteError, description: 'Error message in case isValid is false', deprecated: true }) error?: QuoteError; + + @ApiPropertyOptional({ type: StructuredErrorDto, isArray: true, description: 'Structured errors array' }) + errors?: StructuredErrorDto[]; } diff --git a/src/subdomains/core/buy-crypto/routes/swap/swap.controller.ts b/src/subdomains/core/buy-crypto/routes/swap/swap.controller.ts index c9efedb5a1..c8eaf10bb1 100644 --- a/src/subdomains/core/buy-crypto/routes/swap/swap.controller.ts +++ b/src/subdomains/core/buy-crypto/routes/swap/swap.controller.ts @@ -33,6 +33,10 @@ import { UnsignedTxDto } from 'src/subdomains/core/sell-crypto/route/dto/unsigne import { UserService } from 'src/subdomains/generic/user/models/user/user.service'; import { DepositDtoMapper } from 'src/subdomains/supporting/address-pool/deposit/dto/deposit-dto.mapper'; import { CryptoPaymentMethod } from 'src/subdomains/supporting/payment/dto/payment-method.enum'; +import { + QuoteErrorUtil, + QuoteException, +} from 'src/subdomains/supporting/payment/dto/transaction-helper/quote-error.util'; import { TransactionDto } from 'src/subdomains/supporting/payment/dto/transaction.dto'; import { TransactionHelper } from 'src/subdomains/supporting/payment/services/transaction-helper'; import { TransactionRequestService } from 'src/subdomains/supporting/payment/services/transaction-request.service'; @@ -89,13 +93,15 @@ export class SwapController { @Put('/quote') @ApiOkResponse({ type: SwapQuoteDto }) async getSwapQuote(@Body() dto: GetSwapQuoteDto): Promise { - const { - amount: sourceAmount, - sourceAsset, - targetAsset, - targetAmount, - specialCode, - } = await this.paymentInfoService.swapCheck(dto); + let checkedDto: GetSwapQuoteDto; + try { + checkedDto = await this.paymentInfoService.swapCheck(dto, undefined, true); + } catch (e) { + if (e instanceof QuoteException) return QuoteErrorUtil.createErrorQuote(e); + throw e; + } + + const { amount: sourceAmount, sourceAsset, targetAsset, targetAmount, specialCode } = checkedDto; const { exchangeRate, @@ -107,6 +113,7 @@ export class SwapController { maxVolumeTarget, isValid, error, + errors, feeSource, feeTarget, priceSteps, @@ -121,6 +128,8 @@ export class SwapController { undefined, dto.wallet, specialCode ? [specialCode] : [], + undefined, + undefined, ); return { @@ -137,6 +146,7 @@ export class SwapController { priceSteps, isValid, error, + errors: QuoteErrorUtil.mapToStructuredErrors(errors, minVolume, minVolumeTarget, maxVolume, maxVolumeTarget), }; } diff --git a/src/subdomains/core/history/__tests__/transaction-helper.spec.ts b/src/subdomains/core/history/__tests__/transaction-helper.spec.ts index 1d209f5459..bc107910c9 100644 --- a/src/subdomains/core/history/__tests__/transaction-helper.spec.ts +++ b/src/subdomains/core/history/__tests__/transaction-helper.spec.ts @@ -3,6 +3,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { BlockchainRegistryService } from 'src/integration/blockchain/shared/services/blockchain-registry.service'; import { createCustomPrice } from 'src/integration/exchange/dto/__mocks__/price.dto.mock'; import { AssetService } from 'src/shared/models/asset/asset.service'; +import { CountryService } from 'src/shared/models/country/country.service'; import { createCustomFiat, createDefaultFiat } from 'src/shared/models/fiat/__mocks__/fiat.entity.mock'; import { FiatService } from 'src/shared/models/fiat/fiat.service'; import { TestSharedModule } from 'src/shared/utils/test.shared.module'; @@ -44,6 +45,7 @@ describe('TransactionHelper', () => { let transactionService: TransactionService; let buyService: BuyService; let assetService: AssetService; + let countryService: CountryService; beforeEach(async () => { specRepo = createMock(); @@ -57,6 +59,7 @@ describe('TransactionHelper', () => { transactionService = createMock(); buyService = createMock(); assetService = createMock(); + countryService = createMock(); const module: TestingModule = await Test.createTestingModule({ imports: [TestSharedModule], @@ -73,6 +76,7 @@ describe('TransactionHelper', () => { { provide: TransactionService, useValue: transactionService }, { provide: BuyService, useValue: buyService }, { provide: AssetService, useValue: assetService }, + { provide: CountryService, useValue: countryService }, TestUtil.provideConfig(), ], }).compile(); diff --git a/src/subdomains/core/history/controllers/transaction.controller.ts b/src/subdomains/core/history/controllers/transaction.controller.ts index 756524710a..77feb5ec8a 100644 --- a/src/subdomains/core/history/controllers/transaction.controller.ts +++ b/src/subdomains/core/history/controllers/transaction.controller.ts @@ -725,17 +725,23 @@ export class TransactionController { let tx: Transaction | TransactionRequest; if (id) tx = await this.transactionService.getTransactionById(+id, relations); - if (uid) - tx = - (await this.transactionService.getTransactionByUid(uid, relations)) ?? - (await this.transactionRequestService.getTransactionRequestByUid(uid, { user: { userData: true } })); - if (orderUid) tx = await this.transactionService.getTransactionByRequestUid(orderUid, relations); + + const uidParam = uid ?? orderUid; + if (uidParam) { + tx = Config.formats.transactionUid.test(uidParam) + ? await this.transactionService.getTransactionByUid(uidParam, relations) + : ((await this.transactionService.getTransactionByRequestUid(uidParam, relations)) ?? + (await this.transactionRequestService.getTransactionRequestByUid(uidParam, { user: { userData: true } }))); + } + if (orderId) tx = (await this.transactionService.getTransactionByRequestId(+orderId, relations)) ?? (await this.transactionRequestService.getTransactionRequest(+orderId, { user: { userData: true } })); + if (externalId && accountId) tx = await this.transactionService.getTransactionByExternalId(externalId, accountId, relations); + if (ckoId) tx = await this.transactionService.getTransactionByCkoId(ckoId, relations); return tx; diff --git a/src/subdomains/core/history/services/history.service.ts b/src/subdomains/core/history/services/history.service.ts index f7a60bf78a..e72a61f947 100644 --- a/src/subdomains/core/history/services/history.service.ts +++ b/src/subdomains/core/history/services/history.service.ts @@ -123,7 +123,7 @@ export class HistoryService { const transactions = user instanceof UserData ? await this.transactionService.getTransactionsForAccount(user.id, query.from, query.to) - : await this.transactionService.getTransactionsForUser(user.id, query.from, query.to); + : await this.transactionService.getTransactionsForUsers([user.id], query.from, query.to); const all = query.buy == null && query.sell == null && query.staking == null && query.ref == null && query.lm == null; diff --git a/src/subdomains/core/sell-crypto/route/dto/get-sell-quote.dto.ts b/src/subdomains/core/sell-crypto/route/dto/get-sell-quote.dto.ts index cae84f80da..2d3e8b0e18 100644 --- a/src/subdomains/core/sell-crypto/route/dto/get-sell-quote.dto.ts +++ b/src/subdomains/core/sell-crypto/route/dto/get-sell-quote.dto.ts @@ -10,10 +10,10 @@ import { ValidateIf, ValidateNested, } from 'class-validator'; -import { EntityDto } from 'src/shared/dto/entity.dto'; import { Asset } from 'src/shared/models/asset/asset.entity'; import { AssetInDto } from 'src/shared/models/asset/dto/asset.dto'; import { Fiat } from 'src/shared/models/fiat/fiat.entity'; +import { FiatInDto } from 'src/shared/models/fiat/dto/fiat.dto'; import { XOR } from 'src/shared/validators/xor.validator'; export class GetSellQuoteDto { @@ -30,10 +30,10 @@ export class GetSellQuoteDto { @IsNumber() amount: number; - @ApiProperty({ type: EntityDto, description: 'Target currency' }) + @ApiProperty({ type: FiatInDto, description: 'Target currency (by ID or name)' }) @IsNotEmptyObject() @ValidateNested() - @Type(() => EntityDto) + @Type(() => FiatInDto) currency: Fiat; @ApiPropertyOptional({ description: 'Amount in target currency' }) @@ -57,4 +57,9 @@ export class GetSellQuoteDto { @IsOptional() @IsString() wallet: string; + + @ApiPropertyOptional({ description: 'Country code (ISO 3166-1 alpha-2, e.g. DE, CH, US)' }) + @IsOptional() + @IsString() + country?: string; } diff --git a/src/subdomains/core/sell-crypto/route/dto/sell-quote.dto.ts b/src/subdomains/core/sell-crypto/route/dto/sell-quote.dto.ts index f60e6603e3..4c81537007 100644 --- a/src/subdomains/core/sell-crypto/route/dto/sell-quote.dto.ts +++ b/src/subdomains/core/sell-crypto/route/dto/sell-quote.dto.ts @@ -1,6 +1,7 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { FeeDto } from 'src/subdomains/supporting/payment/dto/fee.dto'; import { QuoteError } from 'src/subdomains/supporting/payment/dto/transaction-helper/quote-error.enum'; +import { StructuredErrorDto } from 'src/subdomains/supporting/payment/dto/transaction-helper/structured-error.dto'; import { PriceStep } from 'src/subdomains/supporting/pricing/domain/entities/price'; export class SellQuoteDto { @@ -43,6 +44,9 @@ export class SellQuoteDto { @ApiProperty() isValid: boolean; - @ApiPropertyOptional({ enum: QuoteError, description: 'Error message in case isValid is false' }) + @ApiPropertyOptional({ enum: QuoteError, description: 'Error message in case isValid is false', deprecated: true }) error?: QuoteError; + + @ApiPropertyOptional({ type: StructuredErrorDto, isArray: true, description: 'Structured errors array' }) + errors?: StructuredErrorDto[]; } diff --git a/src/subdomains/core/sell-crypto/route/sell.controller.ts b/src/subdomains/core/sell-crypto/route/sell.controller.ts index 19e7f5ef15..ecaa9693dc 100644 --- a/src/subdomains/core/sell-crypto/route/sell.controller.ts +++ b/src/subdomains/core/sell-crypto/route/sell.controller.ts @@ -28,6 +28,10 @@ import { Util } from 'src/shared/utils/util'; import { UserService } from 'src/subdomains/generic/user/models/user/user.service'; import { DepositDtoMapper } from 'src/subdomains/supporting/address-pool/deposit/dto/deposit-dto.mapper'; import { CryptoPaymentMethod, FiatPaymentMethod } from 'src/subdomains/supporting/payment/dto/payment-method.enum'; +import { + QuoteErrorUtil, + QuoteException, +} from 'src/subdomains/supporting/payment/dto/transaction-helper/quote-error.util'; import { TransactionDto } from 'src/subdomains/supporting/payment/dto/transaction.dto'; import { TransactionHelper } from 'src/subdomains/supporting/payment/services/transaction-helper'; import { TransactionRequestService } from 'src/subdomains/supporting/payment/services/transaction-request.service'; @@ -89,13 +93,15 @@ export class SellController { @Put('/quote') @ApiOkResponse({ type: SellQuoteDto }) async getSellQuote(@Body() dto: GetSellQuoteDto): Promise { - const { - amount: sourceAmount, - asset, - currency, - targetAmount, - specialCode, - } = await this.paymentInfoService.sellCheck(dto); + let checkedDto: GetSellQuoteDto; + try { + checkedDto = await this.paymentInfoService.sellCheck(dto, undefined, true); + } catch (e) { + if (e instanceof QuoteException) return QuoteErrorUtil.createErrorQuote(e); + throw e; + } + + const { amount: sourceAmount, asset, currency, targetAmount, specialCode } = checkedDto; const { rate, @@ -108,6 +114,7 @@ export class SellController { maxVolumeTarget, isValid, error, + errors, feeSource, feeTarget, priceSteps, @@ -122,6 +129,8 @@ export class SellController { undefined, dto.wallet, specialCode ? [specialCode] : [], + undefined, + dto.country, ); return { @@ -139,6 +148,7 @@ export class SellController { priceSteps, isValid, error, + errors: QuoteErrorUtil.mapToStructuredErrors(errors, minVolume, minVolumeTarget, maxVolume, maxVolumeTarget), }; } diff --git a/src/subdomains/generic/kyc/controllers/kyc-client.controller.ts b/src/subdomains/generic/kyc/controllers/kyc-client.controller.ts index e46bae79b2..42a39d0086 100644 --- a/src/subdomains/generic/kyc/controllers/kyc-client.controller.ts +++ b/src/subdomains/generic/kyc/controllers/kyc-client.controller.ts @@ -1,6 +1,6 @@ import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; -import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger'; +import { ApiBearerAuth, ApiOkResponse, ApiQuery, ApiTags } from '@nestjs/swagger'; import { GetConfig } from 'src/config/config'; import { GetJwt } from 'src/shared/auth/get-jwt.decorator'; import { JwtPayload } from 'src/shared/auth/jwt-payload.interface'; @@ -27,15 +27,22 @@ export class KycClientController { @ApiBearerAuth() @UseGuards(AuthGuard(), RoleGuard(UserRole.CLIENT_COMPANY)) @ApiOkResponse({ type: PaymentWebhookData, isArray: true }) + @ApiQuery({ name: 'from', required: false, description: 'Start date filter' }) + @ApiQuery({ name: 'to', required: false, description: 'End date filter' }) + @ApiQuery({ name: 'limit', required: false, description: 'Maximum number of results (default/max: 1000)' }) async getAllPayments( @GetJwt() jwt: JwtPayload, @Query('from') from: string, @Query('to') to: string, + @Query('limit') limit?: string, ): Promise { + const parsedLimit = Math.min(limit ? parseInt(limit) : 1000, 1000); + return this.kycClientService.getAllPayments( jwt.user, from ? new Date(from) : undefined, to ? new Date(to) : undefined, + parsedLimit, ); } diff --git a/src/subdomains/generic/kyc/services/kyc-client.service.ts b/src/subdomains/generic/kyc/services/kyc-client.service.ts index a5fa70ce32..f8ce48a56e 100644 --- a/src/subdomains/generic/kyc/services/kyc-client.service.ts +++ b/src/subdomains/generic/kyc/services/kyc-client.service.ts @@ -3,6 +3,7 @@ import { Util } from 'src/shared/utils/util'; import { BuyCrypto } from 'src/subdomains/core/buy-crypto/process/entities/buy-crypto.entity'; import { BuyCryptoWebhookService } from 'src/subdomains/core/buy-crypto/process/services/buy-crypto-webhook.service'; import { BuyFiatService } from 'src/subdomains/core/sell-crypto/process/services/buy-fiat.service'; +import { Transaction } from 'src/subdomains/supporting/payment/entities/transaction.entity'; import { TransactionService } from 'src/subdomains/supporting/payment/services/transaction.service'; import { User } from '../../user/models/user/user.entity'; import { UserService } from '../../user/models/user/user.service'; @@ -32,13 +33,14 @@ export class KycClientService { return wallet.users.map((b) => this.toKycDataDto(b)); } - async getAllPayments(walletId: number, dateFrom: Date, dateTo: Date): Promise { + async getAllPayments(walletId: number, dateFrom: Date, dateTo: Date, limit?: number): Promise { const wallet = await this.walletService.getByIdOrName(walletId, undefined, { users: { userData: true } }); if (!wallet) throw new NotFoundException('Wallet not found'); - return Util.asyncMap(wallet.users, async (user) => { - return this.toPaymentDto(user.id, dateFrom, dateTo); - }).then((dto) => dto.flat()); + const userIds = wallet.users.map((u) => u.id); + const transactions = await this.transactionService.getTransactionsForUsers(userIds, dateFrom, dateTo, limit); + + return this.toPaymentDtos(transactions); } async getAllUserPayments( @@ -53,7 +55,9 @@ export class KycClientService { const user = wallet.users.find((u) => u.address === userAddress); if (!user) throw new NotFoundException('User not found'); - return this.toPaymentDto(user.id, dateFrom, dateTo); + const transactions = await this.transactionService.getTransactionsForUsers([user.id], dateFrom, dateTo); + + return this.toPaymentDtos(transactions); } async getKycFiles(userAddress: string, walletId: number): Promise { @@ -83,10 +87,8 @@ export class KycClientService { } // --- HELPER METHODS --- // - private async toPaymentDto(userId: number, dateFrom: Date, dateTo: Date): Promise { - const txList = await this.transactionService - .getTransactionsForUser(userId, dateFrom, dateTo) - .then((txs) => txs.filter((t) => t.buyCrypto || t.buyFiat).map((t) => t.buyCrypto || t.buyFiat)); + private async toPaymentDtos(transactions: Transaction[]): Promise { + const txList = transactions.filter((t) => t.buyCrypto || t.buyFiat).map((t) => t.buyCrypto || t.buyFiat); return Util.asyncMap(txList, async (tx) => { if (tx instanceof BuyCrypto) { diff --git a/src/subdomains/generic/user/models/user/dto/user-v2.dto.ts b/src/subdomains/generic/user/models/user/dto/user-v2.dto.ts index 129bc761b6..e329dbd886 100644 --- a/src/subdomains/generic/user/models/user/dto/user-v2.dto.ts +++ b/src/subdomains/generic/user/models/user/dto/user-v2.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; -import { IsNotEmptyObject, ValidateNested } from 'class-validator'; +import { IsEnum, IsOptional, ValidateNested } from 'class-validator'; import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; import { EntityDto } from 'src/shared/dto/entity.dto'; import { Asset } from 'src/shared/models/asset/asset.entity'; @@ -10,6 +10,7 @@ import { LanguageDto } from 'src/shared/models/language/dto/language.dto'; import { HistoryFilterKey } from 'src/subdomains/core/history/dto/history-filter.dto'; import { AccountType } from '../../user-data/account-type.enum'; import { KycLevel } from '../../user-data/user-data.enum'; +import { RefPayoutFrequency } from '../user.enum'; import { TradingLimit, VolumeInformation } from './user.dto'; export class VolumesDto { @@ -50,11 +51,16 @@ export class ReferralDto { } export class UpdateRefDto { - @ApiProperty({ type: EntityDto, description: 'Referral payout asset' }) - @IsNotEmptyObject() + @ApiPropertyOptional({ type: EntityDto, description: 'Referral payout asset' }) + @IsOptional() @ValidateNested() @Type(() => EntityDto) - payoutAsset: Asset; + payoutAsset?: Asset; + + @ApiPropertyOptional({ enum: RefPayoutFrequency, description: 'Referral payout frequency' }) + @IsOptional() + @IsEnum(RefPayoutFrequency) + payoutFrequency?: RefPayoutFrequency; } export class UserAddressDto { diff --git a/src/subdomains/generic/user/models/user/user.entity.ts b/src/subdomains/generic/user/models/user/user.entity.ts index fed8bcc713..d3e9af129c 100644 --- a/src/subdomains/generic/user/models/user/user.entity.ts +++ b/src/subdomains/generic/user/models/user/user.entity.ts @@ -19,7 +19,7 @@ import { Wallet } from 'src/subdomains/generic/user/models/wallet/wallet.entity' import { Transaction } from 'src/subdomains/supporting/payment/entities/transaction.entity'; import { Column, Entity, Index, ManyToOne, OneToMany } from 'typeorm'; import { CustodyProvider } from '../custody-provider/custody-provider.entity'; -import { UserAddressType, UserStatus, WalletType } from './user.enum'; +import { RefPayoutFrequency, UserAddressType, UserStatus, WalletType } from './user.enum'; @Entity() export class User extends IEntity { @@ -128,6 +128,9 @@ export class User extends IEntity { @Column({ type: 'float', default: 0.25 }) refFeePercent: number; + @Column({ length: 256, default: RefPayoutFrequency.DAILY }) + refPayoutFrequency: RefPayoutFrequency; + @Column({ type: 'float', default: 0 }) refVolume: number; // EUR diff --git a/src/subdomains/generic/user/models/user/user.enum.ts b/src/subdomains/generic/user/models/user/user.enum.ts index 6d7aad91af..d3279db5af 100644 --- a/src/subdomains/generic/user/models/user/user.enum.ts +++ b/src/subdomains/generic/user/models/user/user.enum.ts @@ -27,6 +27,11 @@ export enum UserAddressType { OTHER = 'Other', } +export enum RefPayoutFrequency { + DAILY = 'Daily', + MONTHLY = 'Monthly', +} + export enum WalletType { METAMASK = 'MetaMask', RABBY = 'Rabby', diff --git a/src/subdomains/generic/user/models/user/user.service.ts b/src/subdomains/generic/user/models/user/user.service.ts index be1beb2a86..7a242ff9f6 100644 --- a/src/subdomains/generic/user/models/user/user.service.ts +++ b/src/subdomains/generic/user/models/user/user.service.ts @@ -55,7 +55,7 @@ import { UserDetailDto, UserDetails } from './dto/user.dto'; import { UpdateMailStatus } from './dto/verify-mail.dto'; import { VolumeQuery } from './dto/volume-query.dto'; import { User } from './user.entity'; -import { UserAddressType, UserStatus } from './user.enum'; +import { RefPayoutFrequency, UserAddressType, UserStatus } from './user.enum'; import { UserRepository } from './user.repository'; @Injectable() @@ -152,7 +152,9 @@ export class UserService { } async getOpenRefCreditUser(): Promise { - return this.userRepo + const isFirstDayOfMonth = new Date().getDate() === 1; + + const query = this.userRepo .createQueryBuilder('user') .leftJoinAndSelect('user.userData', 'userData') .leftJoinAndSelect('user.refAsset', 'refAsset') @@ -161,8 +163,12 @@ export class UserService { .andWhere('userData.status NOT IN (:...userDataStatus)', { userDataStatus: [UserDataStatus.BLOCKED, UserDataStatus.DEACTIVATED], }) - .andWhere('userData.kycLevel != :kycLevel', { kycLevel: KycLevel.REJECTED }) - .getMany(); + .andWhere('userData.kycLevel != :kycLevel', { kycLevel: KycLevel.REJECTED }); + + if (!isFirstDayOfMonth) + query.andWhere('user.refPayoutFrequency = :frequency', { frequency: RefPayoutFrequency.DAILY }); + + return query.getMany(); } async getRefUser(ref: string): Promise { @@ -194,20 +200,25 @@ export class UserService { } async updateRef(userId: number, dto: UpdateRefDto): Promise { - const [user, refAsset] = await Promise.all([ - this.userRepo.findOne({ where: { id: userId }, relations: { wallet: true } }), - this.assetService.getAssetById(dto.payoutAsset.id), - ]); - + const user = await this.userRepo.findOne({ where: { id: userId }, relations: { wallet: true } }); if (!user) throw new NotFoundException('User not found'); - if (user.addressType !== UserAddressType.EVM) - throw new BadRequestException('Ref asset can only be set for EVM addresses'); - if (!refAsset) throw new BadRequestException('Asset not found'); - if (refAsset.refEnabled === false) throw new BadRequestException('Asset is not enabled for ref payout'); - if (!user.blockchains.includes(refAsset.blockchain)) throw new BadRequestException('Asset blockchain mismatch'); + if (dto.payoutAsset) { + if (user.addressType !== UserAddressType.EVM) + throw new BadRequestException('Ref asset can only be set for EVM addresses'); + + const refAsset = await this.assetService.getAssetById(dto.payoutAsset.id); + if (!refAsset) throw new BadRequestException('Asset not found'); + if (refAsset.refEnabled === false) throw new BadRequestException('Asset is not enabled for ref payout'); + if (!user.blockchains.includes(refAsset.blockchain)) throw new BadRequestException('Asset blockchain mismatch'); + + user.refAsset = refAsset; + } + + if (dto.payoutFrequency) { + user.refPayoutFrequency = dto.payoutFrequency; + } - user.refAsset = refAsset; const savedUser = await this.userRepo.save(user); return this.mapRefDtoV2(savedUser); diff --git a/src/subdomains/generic/user/services/webhook/dto/payment-webhook.dto.ts b/src/subdomains/generic/user/services/webhook/dto/payment-webhook.dto.ts index de7a960d90..9db5e34717 100644 --- a/src/subdomains/generic/user/services/webhook/dto/payment-webhook.dto.ts +++ b/src/subdomains/generic/user/services/webhook/dto/payment-webhook.dto.ts @@ -1,4 +1,4 @@ -import { ApiProperty } from '@nestjs/swagger'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { TransactionDetailDto } from 'src/subdomains/supporting/payment/dto/transaction.dto'; import { WebhookDto, WebhookType } from './webhook.dto'; @@ -21,6 +21,21 @@ export enum PaymentWebhookState { export class PaymentWebhookData extends TransactionDetailDto { @ApiProperty() dfxReference: number; + + @ApiPropertyOptional({ description: 'Source token contract address' }) + sourceChainId?: string; + + @ApiPropertyOptional({ description: 'Destination token contract address' }) + destinationChainId?: string; + + @ApiPropertyOptional({ description: 'Source EVM chain ID (e.g. 1, 56, 137)' }) + sourceEvmChainId?: number; + + @ApiPropertyOptional({ description: 'Destination EVM chain ID (e.g. 1, 56, 137)' }) + destinationEvmChainId?: number; + + @ApiPropertyOptional({ description: 'Deposit address for crypto inputs' }) + depositAddress?: string; } export class PaymentWebhookDto extends WebhookDto { diff --git a/src/subdomains/generic/user/services/webhook/mapper/webhook-data.mapper.ts b/src/subdomains/generic/user/services/webhook/mapper/webhook-data.mapper.ts index 2739439681..ee020c9a4b 100644 --- a/src/subdomains/generic/user/services/webhook/mapper/webhook-data.mapper.ts +++ b/src/subdomains/generic/user/services/webhook/mapper/webhook-data.mapper.ts @@ -1,3 +1,4 @@ +import { Asset } from 'src/shared/models/asset/asset.entity'; import { CountryDtoMapper } from 'src/shared/models/country/dto/country-dto.mapper'; import { BuyCryptoExtended, @@ -32,9 +33,16 @@ export class WebhookDataMapper { } static mapCryptoFiatData(payment: BuyFiatExtended): PaymentWebhookData { + const inputAsset = payment.inputAssetEntity as Asset; + return { ...TransactionDtoMapper.mapBuyFiatTransactionDetail(payment), dfxReference: payment.id, + sourceChainId: inputAsset.chainId, + destinationChainId: null, + sourceEvmChainId: inputAsset.evmChainId ?? null, + destinationEvmChainId: null, + depositAddress: payment.cryptoInput?.address?.address ?? null, }; } @@ -42,20 +50,40 @@ export class WebhookDataMapper { return { ...TransactionDtoMapper.mapBuyFiatTransactionDetail(payment), dfxReference: payment.id, + sourceChainId: null, + destinationChainId: null, + sourceEvmChainId: null, + destinationEvmChainId: null, + depositAddress: null, }; } static mapCryptoCryptoData(payment: BuyCryptoExtended): PaymentWebhookData { + const inputAsset = payment.inputAssetEntity as Asset; + const outputAsset = payment.outputAsset; + return { ...TransactionDtoMapper.mapBuyCryptoTransactionDetail(payment), dfxReference: payment.id, + sourceChainId: inputAsset.chainId, + destinationChainId: outputAsset?.chainId ?? null, + sourceEvmChainId: inputAsset.evmChainId ?? null, + destinationEvmChainId: outputAsset?.evmChainId ?? null, + depositAddress: payment.cryptoInput?.address?.address ?? null, }; } static mapFiatCryptoData(payment: BuyCryptoExtended): PaymentWebhookData { + const outputAsset = payment.outputAsset; + return { ...TransactionDtoMapper.mapBuyCryptoTransactionDetail(payment), dfxReference: payment.id, + sourceChainId: null, + destinationChainId: outputAsset?.chainId ?? null, + sourceEvmChainId: null, + destinationEvmChainId: outputAsset?.evmChainId ?? null, + depositAddress: null, }; } diff --git a/src/subdomains/supporting/payment/dto/transaction-helper/quote-error.enum.ts b/src/subdomains/supporting/payment/dto/transaction-helper/quote-error.enum.ts index f947c87230..99eda09e1e 100644 --- a/src/subdomains/supporting/payment/dto/transaction-helper/quote-error.enum.ts +++ b/src/subdomains/supporting/payment/dto/transaction-helper/quote-error.enum.ts @@ -13,4 +13,7 @@ export enum QuoteError { IBAN_CURRENCY_MISMATCH = 'IbanCurrencyMismatch', RECOMMENDATION_REQUIRED = 'RecommendationRequired', EMAIL_REQUIRED = 'EmailRequired', + COUNTRY_NOT_ALLOWED = 'CountryNotAllowed', + ASSET_UNSUPPORTED = 'AssetUnsupported', + CURRENCY_UNSUPPORTED = 'CurrencyUnsupported', } diff --git a/src/subdomains/supporting/payment/dto/transaction-helper/quote-error.util.ts b/src/subdomains/supporting/payment/dto/transaction-helper/quote-error.util.ts new file mode 100644 index 0000000000..825d6e4c6d --- /dev/null +++ b/src/subdomains/supporting/payment/dto/transaction-helper/quote-error.util.ts @@ -0,0 +1,87 @@ +import { QuoteError } from './quote-error.enum'; +import { StructuredErrorDto } from './structured-error.dto'; + +interface ErrorQuote { + feeAmount: number; + amount: number; + estimatedAmount: number; + exchangeRate: number; + rate: number; + minVolume: number; + maxVolume: number; + minVolumeTarget: number; + maxVolumeTarget: number; + fees: { rate: number; fixed: number; network: number; min: number; dfx: number; bank: number; total: number }; + feesTarget: { rate: number; fixed: number; network: number; min: number; dfx: number; bank: number; total: number }; + priceSteps: []; + isValid: false; + /** @deprecated Use `errors` instead */ + error: QuoteError; + errors: StructuredErrorDto[]; +} + +export class QuoteException extends Error { + constructor(public readonly error: QuoteError) { + super(error); + this.name = 'QuoteException'; + } +} + +export class QuoteErrorUtil { + static mapToStructuredErrors( + errors: QuoteError[], + minVolume?: number, + minVolumeTarget?: number, + maxVolume?: number, + maxVolumeTarget?: number, + ): StructuredErrorDto[] { + if (!errors || errors.length === 0) return []; + + return errors.flatMap((error) => + this.mapSingleError(error, minVolume, minVolumeTarget, maxVolume, maxVolumeTarget), + ); + } + + private static mapSingleError( + error: QuoteError, + minVolume?: number, + minVolumeTarget?: number, + maxVolume?: number, + maxVolumeTarget?: number, + ): StructuredErrorDto[] { + switch (error) { + case QuoteError.AMOUNT_TOO_LOW: + return [{ error, limit: minVolume, limitTarget: minVolumeTarget }]; + + case QuoteError.AMOUNT_TOO_HIGH: + case QuoteError.LIMIT_EXCEEDED: + return [{ error, limit: maxVolume, limitTarget: maxVolumeTarget }]; + + default: + return [{ error }]; + } + } + + static createErrorQuote(error: QuoteError | QuoteException): ErrorQuote { + const quoteError = error instanceof QuoteException ? error.error : error; + const emptyFee = { rate: 0, fixed: 0, network: 0, min: 0, dfx: 0, bank: 0, total: 0 }; + + return { + feeAmount: 0, + amount: 0, + estimatedAmount: 0, + exchangeRate: 0, + rate: 0, + minVolume: 0, + maxVolume: 0, + minVolumeTarget: 0, + maxVolumeTarget: 0, + fees: emptyFee, + feesTarget: emptyFee, + priceSteps: [], + isValid: false, + error: quoteError, + errors: this.mapToStructuredErrors([quoteError]), + }; + } +} diff --git a/src/subdomains/supporting/payment/dto/transaction-helper/structured-error.dto.ts b/src/subdomains/supporting/payment/dto/transaction-helper/structured-error.dto.ts new file mode 100644 index 0000000000..0f9e3948e7 --- /dev/null +++ b/src/subdomains/supporting/payment/dto/transaction-helper/structured-error.dto.ts @@ -0,0 +1,13 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { QuoteError } from './quote-error.enum'; + +export class StructuredErrorDto { + @ApiProperty({ enum: QuoteError, description: 'Error code' }) + error: QuoteError; + + @ApiPropertyOptional({ description: 'Volume limit in source asset/currency' }) + limit?: number; + + @ApiPropertyOptional({ description: 'Volume limit in target asset/currency' }) + limitTarget?: number; +} diff --git a/src/subdomains/supporting/payment/dto/transaction-helper/transaction-details.dto.ts b/src/subdomains/supporting/payment/dto/transaction-helper/transaction-details.dto.ts index 30f518e131..3a5313db87 100644 --- a/src/subdomains/supporting/payment/dto/transaction-helper/transaction-details.dto.ts +++ b/src/subdomains/supporting/payment/dto/transaction-helper/transaction-details.dto.ts @@ -20,5 +20,7 @@ export interface TransactionDetails extends TargetEstimation { maxVolume: number; maxVolumeTarget: number; isValid: boolean; + /** @deprecated Use `errors` instead */ error?: QuoteError; + errors: QuoteError[]; } diff --git a/src/subdomains/supporting/payment/services/transaction-helper.ts b/src/subdomains/supporting/payment/services/transaction-helper.ts index 3950768736..eb9d0b74ad 100644 --- a/src/subdomains/supporting/payment/services/transaction-helper.ts +++ b/src/subdomains/supporting/payment/services/transaction-helper.ts @@ -9,6 +9,8 @@ import { Active, amountType, feeAmountType, isAsset, isFiat } from 'src/shared/m import { Asset, AssetType } from 'src/shared/models/asset/asset.entity'; import { AssetService } from 'src/shared/models/asset/asset.service'; import { AssetDtoMapper } from 'src/shared/models/asset/dto/asset-dto.mapper'; +import { Country } from 'src/shared/models/country/country.entity'; +import { CountryService } from 'src/shared/models/country/country.service'; import { FiatDtoMapper } from 'src/shared/models/fiat/dto/fiat-dto.mapper'; import { Fiat } from 'src/shared/models/fiat/fiat.entity'; import { FiatService } from 'src/shared/models/fiat/fiat.service'; @@ -74,6 +76,7 @@ export class TransactionHelper implements OnModuleInit { private readonly transactionService: TransactionService, private readonly buyService: BuyService, private readonly assetService: AssetService, + private readonly countryService: CountryService, ) {} onModuleInit() { @@ -258,6 +261,7 @@ export class TransactionHelper implements OnModuleInit { walletName?: string, specialCodes: string[] = [], ibanCountry?: string, + country?: string, ): Promise { const priceValidity = exactPrice ? PriceValidity.PREFER_VALID : PriceValidity.ANY; @@ -293,7 +297,9 @@ export class TransactionHelper implements OnModuleInit { const { kycLimit, defaultLimit } = await this.getLimits(from, to, paymentMethodIn, paymentMethodOut, user); - const error = this.getTxError( + const resolvedCountry = country ? await this.countryService.getCountryWithSymbol(country) : undefined; + + const errors = this.getTxErrors( from, to, paymentMethodIn, @@ -303,6 +309,7 @@ export class TransactionHelper implements OnModuleInit { kycLimit, user, ibanCountry, + resolvedCountry, ); // target estimation @@ -316,7 +323,7 @@ export class TransactionHelper implements OnModuleInit { }, volume: { min: specs.minVolume, - max: error === QuoteError.LIMIT_EXCEEDED ? kycLimit : Math.min(kycLimit, defaultLimit), + max: errors.includes(QuoteError.LIMIT_EXCEEDED) ? kycLimit : Math.min(kycLimit, defaultLimit), }, }; @@ -343,8 +350,9 @@ export class TransactionHelper implements OnModuleInit { minVolumeTarget: targetSpecs.volume.min, maxVolume: sourceSpecs.volume.max ?? undefined, maxVolumeTarget: targetSpecs.volume.max ?? undefined, - isValid: error == null, - error, + isValid: !errors.length, + error: errors[0], + errors, }; } @@ -961,7 +969,7 @@ export class TransactionHelper implements OnModuleInit { return { kycLimit: Math.max(0, kycLimit), defaultLimit }; } - private getTxError( + private getTxErrors( from: Active, to: Active, paymentMethodIn: PaymentMethod, @@ -971,46 +979,55 @@ export class TransactionHelper implements OnModuleInit { kycLimitChf: number, user?: User, ibanCountry?: string, - ): QuoteError | undefined { + country?: Country, + ): QuoteError[] { + const errors: QuoteError[] = []; const nationality = user?.userData.nationality; const isBuy = isFiat(from) && isAsset(to); const isSell = isAsset(from) && isFiat(to); const isSwap = isAsset(from) && isAsset(to); const isRealUnit = this.isRealUnitTransaction(from, to); + // Skip all checks for test environments with SKIP_AML_CHECK if ( user?.wallet.amlRuleList.includes(AmlRule.SKIP_AML_CHECK) && [Environment.LOC, Environment.DEV].includes(Config.environment) ) - return; + return []; + // Trade approval check (mutually exclusive: EMAIL_REQUIRED or RECOMMENDATION_REQUIRED) if ( !DisabledProcess(Process.TRADE_APPROVAL_DATE) && user?.userData && !user.userData.tradeApprovalDate && !user.wallet.autoTradeApproval ) { - return user.userData.kycLevel >= KycLevel.LEVEL_10 - ? QuoteError.RECOMMENDATION_REQUIRED - : QuoteError.EMAIL_REQUIRED; + errors.push( + user.userData.kycLevel >= KycLevel.LEVEL_10 ? QuoteError.RECOMMENDATION_REQUIRED : QuoteError.EMAIL_REQUIRED, + ); } // Credit card payments disabled - if (paymentMethodIn === FiatPaymentMethod.CARD) return QuoteError.PAYMENT_METHOD_NOT_ALLOWED; + if (paymentMethodIn === FiatPaymentMethod.CARD) errors.push(QuoteError.PAYMENT_METHOD_NOT_ALLOWED); - if (isSell && ibanCountry && !to.isIbanCountryAllowed(ibanCountry)) return QuoteError.IBAN_CURRENCY_MISMATCH; + // IBAN country check + if (isSell && ibanCountry && !to.isIbanCountryAllowed(ibanCountry)) errors.push(QuoteError.IBAN_CURRENCY_MISMATCH); + // Country restriction check (address country) + if (country && !country.dfxEnable) errors.push(QuoteError.COUNTRY_NOT_ALLOWED); + + // Nationality check if (nationality && ((isBuy && !nationality.bankEnable) || ((isSell || isSwap) && !nationality.cryptoEnable))) - return QuoteError.NATIONALITY_NOT_ALLOWED; + errors.push(QuoteError.NATIONALITY_NOT_ALLOWED); - // KYC checks + // KYC checks - AML rules const amlRuleError = AmlHelperService.amlRuleQuoteCheck( [from.amlRuleFrom, to.amlRuleTo, nationality?.amlRule], user?.wallet.exceptAmlRuleList, user, paymentMethodIn, ); - if (amlRuleError) return amlRuleError; + if (amlRuleError) errors.push(amlRuleError); const walletAmlRuleError = isBuy && @@ -1020,23 +1037,24 @@ export class TransactionHelper implements OnModuleInit { user, paymentMethodIn, ); - if (walletAmlRuleError) return walletAmlRuleError; + if (walletAmlRuleError) errors.push(walletAmlRuleError); + // KYC level checks if (isSwap && user?.userData.kycLevel < KycLevel.LEVEL_30 && user?.userData.status !== UserDataStatus.ACTIVE) - return QuoteError.KYC_REQUIRED; + errors.push(QuoteError.KYC_REQUIRED); if ((isSell || isSwap) && user?.userData.kycLevel < KycLevel.LEVEL_30 && from.dexName === 'XMR') - return QuoteError.KYC_REQUIRED; + errors.push(QuoteError.KYC_REQUIRED); if (paymentMethodIn === FiatPaymentMethod.INSTANT && user && !user.userData.olkypayAllowed) - return QuoteError.KYC_REQUIRED_INSTANT; + errors.push(QuoteError.KYC_REQUIRED_INSTANT); - if (isSell && user && !user.userData.isDataComplete) return QuoteError.KYC_DATA_REQUIRED; + if (isSell && user && !user.userData.isDataComplete) errors.push(QuoteError.KYC_DATA_REQUIRED); - // limit checks - if (user && txAmountChf > kycLimitChf) return QuoteError.LIMIT_EXCEEDED; + // Limit checks + if (user && txAmountChf > kycLimitChf) errors.push(QuoteError.LIMIT_EXCEEDED); - // verification checks + // Verification checks if ( ((isSell && to.name !== 'CHF') || isSwap) && !isRealUnit && @@ -1044,11 +1062,13 @@ export class TransactionHelper implements OnModuleInit { !user.userData.hasBankTxVerification && txAmountChf > Config.tradingLimits.monthlyDefaultWoKyc ) - return QuoteError.BANK_TRANSACTION_OR_VIDEO_MISSING; + errors.push(QuoteError.BANK_TRANSACTION_OR_VIDEO_MISSING); + + // Amount checks (mutually exclusive: only one can apply) + if (txAmountChf < minAmountChf) errors.push(QuoteError.AMOUNT_TOO_LOW); + else if (txAmountChf > maxAmountChf) errors.push(QuoteError.AMOUNT_TOO_HIGH); - // amount checks - if (txAmountChf < minAmountChf) return QuoteError.AMOUNT_TOO_LOW; - if (txAmountChf > maxAmountChf) return QuoteError.AMOUNT_TOO_HIGH; + return errors; } private async getInvoiceCurrency(userData: UserData): Promise { diff --git a/src/subdomains/supporting/payment/services/transaction.service.ts b/src/subdomains/supporting/payment/services/transaction.service.ts index 884a04c6d9..d10608ea92 100644 --- a/src/subdomains/supporting/payment/services/transaction.service.ts +++ b/src/subdomains/supporting/payment/services/transaction.service.ts @@ -4,7 +4,7 @@ import { Util } from 'src/shared/utils/util'; import { BankDataType } from 'src/subdomains/generic/user/models/bank-data/bank-data.entity'; import { BankDataService } from 'src/subdomains/generic/user/models/bank-data/bank-data.service'; import { UserDataService } from 'src/subdomains/generic/user/models/user-data/user-data.service'; -import { Between, Brackets, FindOptionsRelations, IsNull, LessThanOrEqual, Not } from 'typeorm'; +import { Between, Brackets, FindOptionsRelations, In, IsNull, LessThanOrEqual, Not } from 'typeorm'; import { CreateTransactionDto } from '../dto/input/create-transaction.dto'; import { UpdateTransactionInternalDto } from '../dto/input/update-transaction-internal.dto'; import { UpdateTransactionDto } from '../dto/update-transaction.dto'; @@ -191,22 +191,34 @@ export class TransactionService { }); } - async getTransactionsForUser(userId: number, from = new Date(0), to = new Date()): Promise { - return this.repo.find({ - where: { user: { id: userId }, type: Not(IsNull()), created: Between(from, to) }, - relations: { - buyCrypto: { - buy: true, - cryptoRoute: true, - bankTx: true, - checkoutTx: true, - cryptoInput: true, - chargebackOutput: true, - }, - buyFiat: { sell: true, cryptoInput: true, bankTx: true, fiatOutput: true }, - refReward: true, - }, - }); + async getTransactionsForUsers( + userIds: number[], + from = new Date(0), + to = new Date(), + limit?: number, + ): Promise { + return Util.doInBatchesWithLimit( + userIds, + (batch, remaining) => + this.repo.find({ + where: { user: { id: In(batch) }, type: Not(IsNull()), created: Between(from, to) }, + relations: { + buyCrypto: { + buy: true, + cryptoRoute: true, + bankTx: true, + checkoutTx: true, + cryptoInput: true, + chargebackOutput: true, + }, + buyFiat: { sell: true, cryptoInput: true, bankTx: true, fiatOutput: true }, + refReward: true, + }, + take: remaining, + }), + 100, + limit, + ); } async getManualRefVolume(ref: string): Promise<{ volume: number; credit: number }> { diff --git a/src/subdomains/supporting/realunit/realunit.service.ts b/src/subdomains/supporting/realunit/realunit.service.ts index 9a7fdc8864..9e344df85f 100644 --- a/src/subdomains/supporting/realunit/realunit.service.ts +++ b/src/subdomains/supporting/realunit/realunit.service.ts @@ -941,7 +941,7 @@ export class RealUnitService { depositAddress: sellPaymentInfo.depositAddress, amount: sellPaymentInfo.amount, tokenAddress: realuAsset.chainId, - chainId: EvmUtil.getChainId(realuAsset.blockchain), + chainId: realuAsset.evmChainId, // Fee Info fees: sellPaymentInfo.fees, From 80f96dd0134abfd6f2f98f2357b23e9fd072637e Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Wed, 11 Mar 2026 09:51:17 +0100 Subject: [PATCH 2/2] fix: exclude fiat_output 79958 from pending balance calculation (#3390) Unconfirmed fiat_output 79958 (52,939 EUR) blocks available liquidity on Olkypay, preventing new outputs like #79984 from being processed. --- src/subdomains/supporting/fiat-output/fiat-output-job.service.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/subdomains/supporting/fiat-output/fiat-output-job.service.ts b/src/subdomains/supporting/fiat-output/fiat-output-job.service.ts index fb90d6cd98..9b3702f153 100644 --- a/src/subdomains/supporting/fiat-output/fiat-output-job.service.ts +++ b/src/subdomains/supporting/fiat-output/fiat-output-job.service.ts @@ -223,6 +223,7 @@ export class FiatOutputJobService { const pendingFiatOutputs = accountIbanGroup.filter((tx) => { if (!tx.isReadyDate) return false; + if (tx.id === 79958) return false; // TODO: excluded from pending balance calculation switch (tx.bank?.name) { case IbanBankName.YAPEAL: