From 1e8a04d7f27ba415fdcb097c241b01c7917ac6cb Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Wed, 4 Mar 2026 11:51:23 +0100 Subject: [PATCH 1/5] feat: add Edge exchange provider API requirements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- EDGE_REQUIREMENTS.md | 214 ++++++++++++++++++ .../blockchain/shared/evm/evm.util.ts | 7 + src/shared/models/asset/dto/asset.dto.ts | 11 +- src/shared/services/payment-info.service.ts | 14 +- .../buy-crypto/routes/buy/buy.controller.ts | 2 + .../routes/buy/dto/buy-quote.dto.ts | 4 + .../routes/swap/dto/swap-quote.dto.ts | 4 + .../buy-crypto/routes/swap/swap.controller.ts | 2 + .../controllers/transaction.controller.ts | 15 ++ .../sell-crypto/route/dto/sell-quote.dto.ts | 4 + .../core/sell-crypto/route/sell.controller.ts | 2 + .../transaction-helper/quote-error.enum.ts | 2 + .../transaction-helper/quote-error.util.ts | 29 +++ .../structured-error.dto.ts | 12 + .../payment/dto/transaction-status.dto.ts | 41 ++++ 15 files changed, 357 insertions(+), 6 deletions(-) create mode 100644 EDGE_REQUIREMENTS.md 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 create mode 100644 src/subdomains/supporting/payment/dto/transaction-status.dto.ts diff --git a/EDGE_REQUIREMENTS.md b/EDGE_REQUIREMENTS.md new file mode 100644 index 0000000000..dda22aa497 --- /dev/null +++ b/EDGE_REQUIREMENTS.md @@ -0,0 +1,214 @@ +# Edge Exchange Provider API Requirements - Tracking + +## Status-Legende +- [ ] Nicht begonnen +- [~] In Arbeit +- [x] Erledigt + +--- + +## Allgemeine Anforderungen (Swap + On/Off Ramp) + +### 1. Chain and Token Identification `[~]` ~70% +**Anforderung:** Quotes/Orders via `chainNetwork` + `tokenId` (Contract Address). Für EVM-Chains zusätzlich `chainNetwork: "evmGeneric"` + `chainId`. + +**Ist-Zustand:** +- Asset-Entity hat `chainId` (Contract Address) und `blockchain` Enum +- EVM Chain-Config mit numerischen chainIds vorhanden (ETH=1, BSC=56, etc.) +- `evmChainId` Feld in `AssetInDto` (numerische EVM Chain ID) +- `EvmUtil.getBlockchain()` Reverse-Lookup (chainId → Blockchain) +- `resolveAsset()` unterstützt `evmChainId` + `chainId` Kombination + +**Offen:** +- [ ] Support für `chainNetwork` als String-Identifier (Edge-Format) +- [ ] Mapping zwischen Edge chain identifiers und DFX Blockchain-Enum + +--- + +### 2. Order Identification and Status Page `[~]` ~70% +**Anforderung:** Unique `orderId` pro Order, abfragbar via unauthenticated API. Status-Page URL: `https://status.provider.com/orderStatus/{orderId}` + +**Ist-Zustand:** +- TransactionRequest hat `uid` als unique identifier +- `GET /transaction/single?uid=...` existiert (unauthenticated) + +**Offen:** +- [ ] Konsistentes `orderId` Feld in allen Responses +- [ ] Status-Page URL bereitstellen (Frontend oder Redirect) +- [ ] Path-Parameter statt Query-Parameter: `GET /api/status/{orderId}` + +--- + +### 3. Error Handling `[~]` ~80% +**Anforderung:** Alle möglichen Fehler gleichzeitig zurückgeben. Hardened error codes: `RegionRestricted`, `AssetUnsupported`, `OverLimitError` (mit min/max in Source UND Destination Asset). + +**Ist-Zustand:** +- QuoteError Enum: `AmountTooLow`, `AmountTooHigh`, `KycRequired`, `LimitExceeded`, `NationalityNotAllowed`, `PaymentMethodNotAllowed`, `RegionRestricted`, `AssetUnsupported` +- `minVolume`/`maxVolume` + `minVolumeTarget`/`maxVolumeTarget` in Quote-Response +- `errors` Array in allen Quote-DTOs (Buy/Sell/Swap) mit `StructuredErrorDto` +- `QuoteErrorUtil.mapToStructuredErrors()` mappt bestehende Errors auf Edge-Format +- Error-Mapping: `NationalityNotAllowed` → `RegionRestricted`, Amount-Errors → `OverLimitError`/`UnderLimitError` mit `sourceAmountLimit`/`destinationAmountLimit` + +**Offen:** +- [ ] Alle Fehler gleichzeitig als Array zurückgeben (aktuell: einzelner Error → Array mit einem Element) + +--- + +### 4. Quoting Requirements `[x]` 100% +**Anforderung:** Bi-directional quoting (Source ODER Destination Amount). + +**Ist-Zustand:** +- XOR-Validation in `GetSwapQuoteDto`: `amount` oder `targetAmount` +- Gleiche Logik in Sell-Quotes +- **Vollständig erfüllt.** + +--- + +### 5. Transaction Status API `[x]` 100% +**Anforderung:** `GET /api/status/{orderId}` mit Status: `pending | processing | infoNeeded | expired | refunded | completed` + +**Ist-Zustand:** +- `GET /transaction/single` existiert (unauthenticated) +- TransactionState hat 14 verschiedene States +- `GET /transaction/status/:orderId` Endpoint (unauthenticated) +- `ProviderTransactionStatus` Enum mit Edge-Format +- `mapToProviderStatus()` mappt alle 14 States auf 6 Edge-Status-Werte +- Lookup via `uid` und `orderUid` + +**Erledigt.** + +--- + +### 6. Reporting API `[~]` ~40% +**Anforderung:** Authentifizierte API für alle Transaktionen. Pagination (start date, end date, count). Felder: orderId, status, dates, source/dest network/token/amount, payin/payout addresses, txids, EVM chainIds. + +**Ist-Zustand:** +- `GET /history/:exportType` mit API-Key Auth +- `GET /transaction/detail` mit Bearer Token +- Date-Filter vorhanden + +**Offen:** +- [ ] Explizite Pagination (startDate, endDate, limit/offset) +- [ ] Response-Format mit allen Edge-Pflichtfeldern +- [ ] `orderId` konsistent (statt `id`/`uid`) +- [ ] EVM `chainId` im Response +- [ ] Source/Dest Network + TokenId + Amount + Addresses + Txids + +--- + +### 7. Account Activation `[ ]` 0% +**Anforderung:** Bei XRP/HBAR: unactivated Addresses erkennen und Aktivierungs-Transaktion senden. + +**Ist-Zustand:** +- XRP und HBAR sind nicht in der Blockchain-Enum + +**Offen:** +- [ ] Prüfen ob XRP/HBAR überhaupt unterstützt werden sollen +- [ ] Falls ja: Activation-Logik implementieren + +--- + +### 8. Affiliate Revenue Withdrawal `[ ]` ~10% +**Anforderung:** Auto-Withdraw innerhalb 24h nach Monatsende. In BTC/ETH/USDC an feste, verifizierte Adresse. + +**Ist-Zustand:** +- Referral/RefReward System vorhanden + +**Offen:** +- [ ] Klären ob DFX Affiliate-Programm mit Edge hat +- [ ] Auto-Withdrawal Mechanismus (Cron-Job) +- [ ] Fixed address Verwaltung mit 2FA/Email-Verification bei Änderung + +--- + +## Zusätzliche Anforderungen für Fiat On/Off Ramp + +### 9. User Authentication `[~]` ~60% +**Anforderung:** Auth via cryptographic random `authKey`. Auto-Account-Erstellung wenn authKey nicht existiert. KYC-Info via API. + +**Ist-Zustand:** +- Wallet-Signature basierte Auth (`POST /auth`) +- Auto-Signup bei `POST /auth/signUp` + +**Offen:** +- [ ] `authKey`-basierte Authentifizierung (random key statt wallet signature) +- [ ] Auto-Create Account bei unbekanntem authKey +- [ ] authKey in Quote/Order-Endpoints akzeptieren + +--- + +### 10. Regional and Fiat Currency Support `[~]` ~40% +**Anforderung:** Quote-Request mit Region (Country/Province) und Fiat-Currency. Proper Errors bei unsupported. + +**Ist-Zustand:** +- IP-basierte Country-Detection +- Fiat-Currency Support in Quotes + +**Offen:** +- [ ] Expliziter `region`/`country` Parameter in Quote-Requests +- [ ] Expliziter `fiatCurrency` Parameter +- [ ] Spezifische Errors: `RegionNotSupported`, `CurrencyNotSupported` + +--- + +### 11. KYC Information `[~]` ~60% +**Anforderung:** KYC-Daten (Name, Address, Phone, Email) via API submitten (kein Widget). + +**Ist-Zustand:** +- UserData-Update mit firstname, surname, street, phone, mail etc. +- KYC-Controller vorhanden + +**Offen:** +- [ ] Dedizierter KYC-Submission-Endpoint für Edge +- [ ] Sicherstellen dass alle Felder via API setzbar sind (ohne Widget) + +--- + +### 12. Bank Information `[~]` ~40% +**Anforderung:** Bank-Info (Account Number, IBAN, Routing Number) via API submitten. + +**Ist-Zustand:** +- BankData-Controller existiert (Admin-only) +- CreateBankDataDto: IBAN, BIC, Name + +**Offen:** +- [ ] User-zugängliche API (nicht nur Admin) +- [ ] `accountNumber` und `routingNumber` Felder (für US-Markt) + +--- + +### 13. Verification `[ ]` ~10% +**Anforderung:** KYC-Verification-Codes (Phone/Email) über API senden. Edge informieren wenn KYC-Info fehlt/veraltet. + +**Ist-Zustand:** +- KYC via externen Provider (SumSub) + +**Offen:** +- [ ] Phone-Verification-Code senden via API +- [ ] Email-Verification-Code senden via API +- [ ] Verification-Code validieren via API +- [ ] "KYC info missing/outdated" Status in API-Response + +--- + +### 14. Widgets `[~]` ~30% +**Anforderung:** Widgets müssen Return-URIs unterstützen, damit Edge Webviews schließen kann. + +**Ist-Zustand:** +- Redirect-Logik in Auth vorhanden + +**Offen:** +- [ ] Return-URI Parameter für alle Widget-Flows +- [ ] Callback nach Widget-Completion + +--- + +### 15. Off-Ramp Flow `[~]` ~50% +**Anforderung:** No-Widget Off-Ramp: Crypto-Address + Expiration-Time bereitstellen. + +**Ist-Zustand:** +- Sell-Flow mit Deposit-Address vorhanden + +**Offen:** +- [ ] Expiration-Time für Deposit-Addresses +- [ ] Sicherstellen dass Flow komplett ohne Widget funktioniert 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/shared/models/asset/dto/asset.dto.ts b/src/shared/models/asset/dto/asset.dto.ts index aa936e0ab4..d9d195cdff 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'; @@ -77,7 +77,7 @@ export class AssetDto { export class AssetInDto { @ApiPropertyOptional() @IsNotEmpty() - @ValidateIf((a: AssetInDto) => Boolean(a.id || !(a.chainId || a.blockchain))) + @ValidateIf((a: AssetInDto) => Boolean(a.id || !(a.chainId || a.blockchain || a.evmChainId))) @IsInt() id?: number; @@ -89,9 +89,14 @@ export class AssetInDto { @ApiPropertyOptional() @IsNotEmpty() - @ValidateIf((a: AssetInDto) => Boolean(a.blockchain || !a.id)) + @ValidateIf((a: AssetInDto) => Boolean(a.blockchain || (!a.id && !a.evmChainId))) @IsEnum(Blockchain) blockchain?: Blockchain; + + @ApiPropertyOptional({ description: 'Numeric EVM chain ID (e.g. 1, 56, 137)' }) + @IsOptional() + @IsInt() + evmChainId?: number; } export class AssetLimitsDto { diff --git a/src/shared/services/payment-info.service.ts b/src/shared/services/payment-info.service.ts index cd7f3e57c8..1bfdde4490 100644 --- a/src/shared/services/payment-info.service.ts +++ b/src/shared/services/payment-info.service.ts @@ -1,4 +1,5 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { EvmUtil } from 'src/integration/blockchain/shared/evm/evm.util'; import { Config, Environment } from 'src/config/config'; 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'; @@ -129,8 +130,15 @@ export class PaymentInfoService { } async resolveAsset(asset: Asset): Promise { - return asset.id - ? this.assetService.getAssetById(asset.id) - : this.assetService.getAssetByChainId(asset.blockchain, asset.chainId); + if (asset.id) return this.assetService.getAssetById(asset.id); + + let blockchain = asset.blockchain; + const evmChainId = (asset as any).evmChainId as number | undefined; + if (evmChainId && !blockchain) { + blockchain = EvmUtil.getBlockchain(evmChainId); + if (!blockchain) throw new BadRequestException(`Unsupported EVM chain ID: ${evmChainId}`); + } + + return this.assetService.getAssetByChainId(blockchain, asset.chainId); } } 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..dea5892aaa 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,7 @@ 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 } 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'; @@ -136,6 +137,7 @@ export class BuyController { priceSteps, isValid, error, + errors: QuoteErrorUtil.mapToStructuredErrors(error, 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..eb4e789794 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 { @@ -45,4 +46,7 @@ export class BuyQuoteDto { @ApiPropertyOptional({ enum: QuoteError, description: 'Error message in case isValid is false' }) error?: QuoteError; + + @ApiPropertyOptional({ type: [StructuredErrorDto], description: 'Structured errors array' }) + errors?: StructuredErrorDto[]; } 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..05dbc8a30c 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 { @@ -42,4 +43,7 @@ export class SwapQuoteDto { @ApiPropertyOptional({ enum: QuoteError, description: 'Error message in case isValid is false' }) error?: QuoteError; + + @ApiPropertyOptional({ type: [StructuredErrorDto], 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..3cf66d5a4b 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,7 @@ 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 } 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'; @@ -137,6 +138,7 @@ export class SwapController { priceSteps, isValid, error, + errors: QuoteErrorUtil.mapToStructuredErrors(error, minVolume, minVolumeTarget, maxVolume, maxVolumeTarget), }; } diff --git a/src/subdomains/core/history/controllers/transaction.controller.ts b/src/subdomains/core/history/controllers/transaction.controller.ts index 756524710a..469347630f 100644 --- a/src/subdomains/core/history/controllers/transaction.controller.ts +++ b/src/subdomains/core/history/controllers/transaction.controller.ts @@ -56,6 +56,7 @@ import { TransactionHelper } from 'src/subdomains/supporting/payment/services/tr import { TransactionRequestService } from 'src/subdomains/supporting/payment/services/transaction-request.service'; import { TransactionService } from 'src/subdomains/supporting/payment/services/transaction.service'; import { FindOptionsRelations } from 'typeorm'; +import { TransactionStatusDto, mapToProviderStatus } from '../../../supporting/payment/dto/transaction-status.dto'; import { TransactionDetailDto, TransactionDto, @@ -147,6 +148,20 @@ export class TransactionController { return dto; } + @Get('status/:orderId') + @ApiOkResponse({ type: TransactionStatusDto }) + async getTransactionStatus(@Param('orderId') orderId: string): Promise { + const tx = await this.getTransaction({ uid: orderId, orderUid: orderId }); + + const dto = await this.getTransactionDto(tx); + if (!dto) throw new NotFoundException('Transaction not found'); + + return { + orderId, + status: mapToProviderStatus(dto.state), + }; + } + @Put('csv') @ApiOkResponse() @ApiOperation({ description: 'Initiate CSV history export' }) 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..2ed6351316 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 { @@ -45,4 +46,7 @@ export class SellQuoteDto { @ApiPropertyOptional({ enum: QuoteError, description: 'Error message in case isValid is false' }) error?: QuoteError; + + @ApiPropertyOptional({ type: [StructuredErrorDto], 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..301353beb3 100644 --- a/src/subdomains/core/sell-crypto/route/sell.controller.ts +++ b/src/subdomains/core/sell-crypto/route/sell.controller.ts @@ -28,6 +28,7 @@ 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 } 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'; @@ -139,6 +140,7 @@ export class SellController { priceSteps, isValid, error, + errors: QuoteErrorUtil.mapToStructuredErrors(error, minVolume, minVolumeTarget, maxVolume, maxVolumeTarget), }; } 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..56e4e9499a 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,6 @@ export enum QuoteError { IBAN_CURRENCY_MISMATCH = 'IbanCurrencyMismatch', RECOMMENDATION_REQUIRED = 'RecommendationRequired', EMAIL_REQUIRED = 'EmailRequired', + REGION_RESTRICTED = 'RegionRestricted', + ASSET_UNSUPPORTED = 'AssetUnsupported', } 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..31e352a569 --- /dev/null +++ b/src/subdomains/supporting/payment/dto/transaction-helper/quote-error.util.ts @@ -0,0 +1,29 @@ +import { QuoteError } from './quote-error.enum'; +import { StructuredErrorDto } from './structured-error.dto'; + +export class QuoteErrorUtil { + static mapToStructuredErrors( + error: QuoteError | undefined, + minVolume?: number, + minVolumeTarget?: number, + maxVolume?: number, + maxVolumeTarget?: number, + ): StructuredErrorDto[] { + if (!error) return []; + + switch (error) { + case QuoteError.NATIONALITY_NOT_ALLOWED: + return [{ error: QuoteError.REGION_RESTRICTED }]; + + case QuoteError.AMOUNT_TOO_LOW: + return [{ error: 'UnderLimitError', sourceAmountLimit: minVolume, destinationAmountLimit: minVolumeTarget }]; + + case QuoteError.AMOUNT_TOO_HIGH: + case QuoteError.LIMIT_EXCEEDED: + return [{ error: 'OverLimitError', sourceAmountLimit: maxVolume, destinationAmountLimit: maxVolumeTarget }]; + + default: + return [{ error }]; + } + } +} 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..2cda75fa26 --- /dev/null +++ b/src/subdomains/supporting/payment/dto/transaction-helper/structured-error.dto.ts @@ -0,0 +1,12 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class StructuredErrorDto { + @ApiProperty({ description: 'Error code' }) + error: string; + + @ApiPropertyOptional({ description: 'Source amount limit' }) + sourceAmountLimit?: number; + + @ApiPropertyOptional({ description: 'Destination amount limit' }) + destinationAmountLimit?: number; +} diff --git a/src/subdomains/supporting/payment/dto/transaction-status.dto.ts b/src/subdomains/supporting/payment/dto/transaction-status.dto.ts new file mode 100644 index 0000000000..03c74ec612 --- /dev/null +++ b/src/subdomains/supporting/payment/dto/transaction-status.dto.ts @@ -0,0 +1,41 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { TransactionState } from './transaction.dto'; + +export enum ProviderTransactionStatus { + PENDING = 'pending', + PROCESSING = 'processing', + INFO_NEEDED = 'infoNeeded', + EXPIRED = 'expired', + REFUNDED = 'refunded', + COMPLETED = 'completed', +} + +export class TransactionStatusDto { + @ApiProperty({ description: 'Order ID' }) + orderId: string; + + @ApiProperty({ enum: ProviderTransactionStatus, description: 'Simplified transaction status' }) + status: ProviderTransactionStatus; +} + +const TransactionStateToProviderStatus: Record = { + [TransactionState.CREATED]: ProviderTransactionStatus.PENDING, + [TransactionState.WAITING_FOR_PAYMENT]: ProviderTransactionStatus.PENDING, + [TransactionState.PROCESSING]: ProviderTransactionStatus.PROCESSING, + [TransactionState.LIQUIDITY_PENDING]: ProviderTransactionStatus.PROCESSING, + [TransactionState.CHECK_PENDING]: ProviderTransactionStatus.PROCESSING, + [TransactionState.PAYOUT_IN_PROGRESS]: ProviderTransactionStatus.PROCESSING, + [TransactionState.UNASSIGNED]: ProviderTransactionStatus.PROCESSING, + [TransactionState.KYC_REQUIRED]: ProviderTransactionStatus.INFO_NEEDED, + [TransactionState.LIMIT_EXCEEDED]: ProviderTransactionStatus.INFO_NEEDED, + [TransactionState.FAILED]: ProviderTransactionStatus.EXPIRED, + [TransactionState.FEE_TOO_HIGH]: ProviderTransactionStatus.EXPIRED, + [TransactionState.PRICE_UNDETERMINABLE]: ProviderTransactionStatus.EXPIRED, + [TransactionState.RETURN_PENDING]: ProviderTransactionStatus.REFUNDED, + [TransactionState.RETURNED]: ProviderTransactionStatus.REFUNDED, + [TransactionState.COMPLETED]: ProviderTransactionStatus.COMPLETED, +}; + +export function mapToProviderStatus(state: TransactionState): ProviderTransactionStatus { + return TransactionStateToProviderStatus[state] ?? ProviderTransactionStatus.PROCESSING; +} From 65234e3fe9c6e29ef3fc87f16d9e5477a8b30112 Mon Sep 17 00:00:00 2001 From: David May Date: Fri, 6 Mar 2026 00:14:04 +0100 Subject: [PATCH 2/5] feat 2: removed unused endpoint --- .../controllers/transaction.controller.ts | 15 ------- .../payment/dto/transaction-status.dto.ts | 41 ------------------- 2 files changed, 56 deletions(-) delete mode 100644 src/subdomains/supporting/payment/dto/transaction-status.dto.ts diff --git a/src/subdomains/core/history/controllers/transaction.controller.ts b/src/subdomains/core/history/controllers/transaction.controller.ts index 469347630f..756524710a 100644 --- a/src/subdomains/core/history/controllers/transaction.controller.ts +++ b/src/subdomains/core/history/controllers/transaction.controller.ts @@ -56,7 +56,6 @@ import { TransactionHelper } from 'src/subdomains/supporting/payment/services/tr import { TransactionRequestService } from 'src/subdomains/supporting/payment/services/transaction-request.service'; import { TransactionService } from 'src/subdomains/supporting/payment/services/transaction.service'; import { FindOptionsRelations } from 'typeorm'; -import { TransactionStatusDto, mapToProviderStatus } from '../../../supporting/payment/dto/transaction-status.dto'; import { TransactionDetailDto, TransactionDto, @@ -148,20 +147,6 @@ export class TransactionController { return dto; } - @Get('status/:orderId') - @ApiOkResponse({ type: TransactionStatusDto }) - async getTransactionStatus(@Param('orderId') orderId: string): Promise { - const tx = await this.getTransaction({ uid: orderId, orderUid: orderId }); - - const dto = await this.getTransactionDto(tx); - if (!dto) throw new NotFoundException('Transaction not found'); - - return { - orderId, - status: mapToProviderStatus(dto.state), - }; - } - @Put('csv') @ApiOkResponse() @ApiOperation({ description: 'Initiate CSV history export' }) diff --git a/src/subdomains/supporting/payment/dto/transaction-status.dto.ts b/src/subdomains/supporting/payment/dto/transaction-status.dto.ts deleted file mode 100644 index 03c74ec612..0000000000 --- a/src/subdomains/supporting/payment/dto/transaction-status.dto.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { TransactionState } from './transaction.dto'; - -export enum ProviderTransactionStatus { - PENDING = 'pending', - PROCESSING = 'processing', - INFO_NEEDED = 'infoNeeded', - EXPIRED = 'expired', - REFUNDED = 'refunded', - COMPLETED = 'completed', -} - -export class TransactionStatusDto { - @ApiProperty({ description: 'Order ID' }) - orderId: string; - - @ApiProperty({ enum: ProviderTransactionStatus, description: 'Simplified transaction status' }) - status: ProviderTransactionStatus; -} - -const TransactionStateToProviderStatus: Record = { - [TransactionState.CREATED]: ProviderTransactionStatus.PENDING, - [TransactionState.WAITING_FOR_PAYMENT]: ProviderTransactionStatus.PENDING, - [TransactionState.PROCESSING]: ProviderTransactionStatus.PROCESSING, - [TransactionState.LIQUIDITY_PENDING]: ProviderTransactionStatus.PROCESSING, - [TransactionState.CHECK_PENDING]: ProviderTransactionStatus.PROCESSING, - [TransactionState.PAYOUT_IN_PROGRESS]: ProviderTransactionStatus.PROCESSING, - [TransactionState.UNASSIGNED]: ProviderTransactionStatus.PROCESSING, - [TransactionState.KYC_REQUIRED]: ProviderTransactionStatus.INFO_NEEDED, - [TransactionState.LIMIT_EXCEEDED]: ProviderTransactionStatus.INFO_NEEDED, - [TransactionState.FAILED]: ProviderTransactionStatus.EXPIRED, - [TransactionState.FEE_TOO_HIGH]: ProviderTransactionStatus.EXPIRED, - [TransactionState.PRICE_UNDETERMINABLE]: ProviderTransactionStatus.EXPIRED, - [TransactionState.RETURN_PENDING]: ProviderTransactionStatus.REFUNDED, - [TransactionState.RETURNED]: ProviderTransactionStatus.REFUNDED, - [TransactionState.COMPLETED]: ProviderTransactionStatus.COMPLETED, -}; - -export function mapToProviderStatus(state: TransactionState): ProviderTransactionStatus { - return TransactionStateToProviderStatus[state] ?? ProviderTransactionStatus.PROCESSING; -} From 47ff1c90f2953b9507997ace58cb6fd439d9854f Mon Sep 17 00:00:00 2001 From: David May Date: Fri, 6 Mar 2026 01:05:41 +0100 Subject: [PATCH 3/5] feat 6: added missing response fields and result limit --- .../core/history/services/history.service.ts | 2 +- .../kyc/controllers/kyc-client.controller.ts | 9 +++++- .../kyc/services/kyc-client.service.ts | 20 +++++++------ .../webhook/dto/payment-webhook.dto.ts | 17 ++++++++++- .../webhook/mapper/webhook-data.mapper.ts | 29 +++++++++++++++++++ .../payment/services/transaction.service.ts | 14 +++++++-- 6 files changed, 76 insertions(+), 15 deletions(-) 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/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/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..1f5ebfff19 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,5 @@ +import { EvmUtil } from 'src/integration/blockchain/shared/evm/evm.util'; +import { Asset } from 'src/shared/models/asset/asset.entity'; import { CountryDtoMapper } from 'src/shared/models/country/dto/country-dto.mapper'; import { BuyCryptoExtended, @@ -32,9 +34,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: EvmUtil.getChainId(inputAsset.blockchain), + destinationEvmChainId: null, + depositAddress: payment.cryptoInput?.address?.address ?? null, }; } @@ -42,20 +51,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: EvmUtil.getChainId(inputAsset.blockchain), + destinationEvmChainId: outputAsset?.blockchain ? EvmUtil.getChainId(outputAsset.blockchain) : 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?.blockchain ? EvmUtil.getChainId(outputAsset.blockchain) : null, + depositAddress: null, }; } diff --git a/src/subdomains/supporting/payment/services/transaction.service.ts b/src/subdomains/supporting/payment/services/transaction.service.ts index 884a04c6d9..c70ce9453a 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,9 +191,15 @@ export class TransactionService { }); } - async getTransactionsForUser(userId: number, from = new Date(0), to = new Date()): Promise { + async getTransactionsForUsers( + userIds: number[], + from = new Date(0), + to = new Date(), + limit?: number, + offset?: number, + ): Promise { return this.repo.find({ - where: { user: { id: userId }, type: Not(IsNull()), created: Between(from, to) }, + where: { user: { id: In(userIds) }, type: Not(IsNull()), created: Between(from, to) }, relations: { buyCrypto: { buy: true, @@ -206,6 +212,8 @@ export class TransactionService { buyFiat: { sell: true, cryptoInput: true, bankTx: true, fiatOutput: true }, refReward: true, }, + take: limit, + skip: offset, }); } From b82f0b822a44af439ac2483127a3c6c251876578 Mon Sep 17 00:00:00 2001 From: David May Date: Fri, 6 Mar 2026 01:33:24 +0100 Subject: [PATCH 4/5] feat 8: monthly ref payout --- migration/1772756747747-RefPayoutFrequency.js | 27 ++++++++++++ .../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 ++++++++++++------- 5 files changed, 72 insertions(+), 20 deletions(-) create mode 100644 migration/1772756747747-RefPayoutFrequency.js 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/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 bb0a5147be..e3b3121b55 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); From ee393ba94c0d347185a604a35a869b0293ac8751 Mon Sep 17 00:00:00 2001 From: David May Date: Fri, 6 Mar 2026 13:21:47 +0100 Subject: [PATCH 5/5] feat 2: find transaction by order UID (if available) --- .../core/history/controllers/transaction.controller.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/subdomains/core/history/controllers/transaction.controller.ts b/src/subdomains/core/history/controllers/transaction.controller.ts index 756524710a..2258c1dce2 100644 --- a/src/subdomains/core/history/controllers/transaction.controller.ts +++ b/src/subdomains/core/history/controllers/transaction.controller.ts @@ -729,7 +729,10 @@ export class TransactionController { tx = (await this.transactionService.getTransactionByUid(uid, relations)) ?? (await this.transactionRequestService.getTransactionRequestByUid(uid, { user: { userData: true } })); - if (orderUid) tx = await this.transactionService.getTransactionByRequestUid(orderUid, relations); + if (orderUid) + tx = + (await this.transactionService.getTransactionByRequestUid(orderUid, relations)) ?? + (await this.transactionRequestService.getTransactionRequestByUid(orderUid, { user: { userData: true } })); if (orderId) tx = (await this.transactionService.getTransactionByRequestId(+orderId, relations)) ??