diff --git a/src/subdomains/core/custody/controllers/custody.controller.ts b/src/subdomains/core/custody/controllers/custody.controller.ts index 419c56f2e4..917c592d2e 100644 --- a/src/subdomains/core/custody/controllers/custody.controller.ts +++ b/src/subdomains/core/custody/controllers/custody.controller.ts @@ -8,13 +8,14 @@ import { RoleGuard } from 'src/shared/auth/role.guard'; import { UserActiveGuard } from 'src/shared/auth/user-active.guard'; import { UserRole } from 'src/shared/auth/user-role.enum'; import { AssetService } from 'src/shared/models/asset/asset.service'; -import { UserService } from 'src/subdomains/generic/user/models/user/user.service'; import { PdfDto } from 'src/subdomains/core/buy-crypto/routes/buy/dto/pdf.dto'; +import { UserService } from 'src/subdomains/generic/user/models/user/user.service'; import { CustodySignupDto } from '../dto/input/custody-signup.dto'; import { GetCustodyInfoDto } from '../dto/input/get-custody-info.dto'; import { GetCustodyPdfDto } from '../dto/input/get-custody-pdf.dto'; import { CustodyAuthDto } from '../dto/output/custody-auth.dto'; import { CustodyBalanceDto, CustodyHistoryDto } from '../dto/output/custody-balance.dto'; +import { CustodyOrderHistoryDto } from '../dto/output/custody-order-history.dto'; import { CustodyOrderDto } from '../dto/output/custody-order.dto'; import { CustodyOrderService } from '../services/custody-order.service'; import { CustodyPdfService } from '../services/custody-pdf.service'; @@ -66,6 +67,14 @@ export class CustodyController { return this.service.createCustodyAccount(jwt.account, dto, ip); } + @Get('order') + @ApiBearerAuth() + @UseGuards(AuthGuard(), RoleGuard(UserRole.ACCOUNT), UserActiveGuard()) + @ApiOkResponse({ type: CustodyOrderHistoryDto, isArray: true }) + async getOrders(@GetJwt() jwt: JwtPayload): Promise { + return this.custodyOrderService.getOrdersByUserData(jwt.account); + } + @Post('order') @ApiBearerAuth() @UseGuards(AuthGuard(), RoleGuard(UserRole.CUSTODY), UserActiveGuard()) diff --git a/src/subdomains/core/custody/dto/output/custody-order-history.dto.ts b/src/subdomains/core/custody/dto/output/custody-order-history.dto.ts new file mode 100644 index 0000000000..4db68a4966 --- /dev/null +++ b/src/subdomains/core/custody/dto/output/custody-order-history.dto.ts @@ -0,0 +1,30 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { CustodyOrderType } from '../../enums/custody'; + +export enum CustodyOrderHistoryStatus { + WAITING_FOR_PAYMENT = 'WaitingForPayment', + CHECK_PENDING = 'CheckPending', + PROCESSING = 'Processing', + COMPLETED = 'Completed', + FAILED = 'Failed', +} + +export class CustodyOrderHistoryDto { + @ApiProperty({ enum: CustodyOrderType }) + type: CustodyOrderType; + + @ApiProperty({ enum: CustodyOrderHistoryStatus }) + status: CustodyOrderHistoryStatus; + + @ApiPropertyOptional() + inputAmount?: number; + + @ApiPropertyOptional() + inputAsset?: string; + + @ApiPropertyOptional() + outputAmount?: number; + + @ApiPropertyOptional() + outputAsset?: string; +} diff --git a/src/subdomains/core/custody/entities/custody-order.entity.ts b/src/subdomains/core/custody/entities/custody-order.entity.ts index 86b942bc81..8fa0099b6a 100644 --- a/src/subdomains/core/custody/entities/custody-order.entity.ts +++ b/src/subdomains/core/custody/entities/custody-order.entity.ts @@ -91,4 +91,16 @@ export class CustodyOrder extends IEntity { status: CustodyOrderStatus.COMPLETED, }); } + + fail(): UpdateResult { + return Util.updateEntity(this, { + status: CustodyOrderStatus.FAILED, + }); + } + + reset(): UpdateResult { + return Util.updateEntity(this, { + status: CustodyOrderStatus.CREATED, + }); + } } diff --git a/src/subdomains/core/custody/enums/custody.ts b/src/subdomains/core/custody/enums/custody.ts index 5baef3a174..3f3625911a 100644 --- a/src/subdomains/core/custody/enums/custody.ts +++ b/src/subdomains/core/custody/enums/custody.ts @@ -16,12 +16,20 @@ export enum CustodyOrderType { SAVING_WITHDRAWAL = 'SavingWithdrawal', } +export const CustodyIncomingTypes = [CustodyOrderType.DEPOSIT, CustodyOrderType.RECEIVE]; +export const CustodySwapTypes = [ + CustodyOrderType.SWAP, + CustodyOrderType.SAVING_DEPOSIT, + CustodyOrderType.SAVING_WITHDRAWAL, +]; + export enum CustodyOrderStatus { CREATED = 'Created', CONFIRMED = 'Confirmed', APPROVED = 'Approved', IN_PROGRESS = 'InProgress', COMPLETED = 'Completed', + FAILED = 'Failed', } export enum CustodyOrderStepStatus { diff --git a/src/subdomains/core/custody/mappers/custody-order-history-dto.mapper.ts b/src/subdomains/core/custody/mappers/custody-order-history-dto.mapper.ts new file mode 100644 index 0000000000..cc2eceaf13 --- /dev/null +++ b/src/subdomains/core/custody/mappers/custody-order-history-dto.mapper.ts @@ -0,0 +1,43 @@ +import { CustodyOrderHistoryDto, CustodyOrderHistoryStatus } from '../dto/output/custody-order-history.dto'; +import { CustodyOrder } from '../entities/custody-order.entity'; +import { CustodyIncomingTypes, CustodyOrderStatus, CustodySwapTypes } from '../enums/custody'; + +export class CustodyOrderHistoryDtoMapper { + static mapList(orders: CustodyOrder[]): CustodyOrderHistoryDto[] { + return orders.map((order) => this.map(order)); + } + + static map(order: CustodyOrder): CustodyOrderHistoryDto { + const isIncoming = CustodyIncomingTypes.includes(order.type); + const isSwap = CustodySwapTypes.includes(order.type); + + return { + type: order.type, + status: this.mapStatus(order), + inputAmount: + isIncoming || isSwap ? (order.inputAmount ?? order.transactionRequest?.estimatedAmount) : order.inputAmount, + inputAsset: order.inputAsset?.name, + outputAmount: isIncoming ? order.outputAmount : (order.outputAmount ?? order.transactionRequest?.amount), + outputAsset: order.outputAsset?.name, + }; + } + + private static mapStatus(order: CustodyOrder): CustodyOrderHistoryStatus { + const isIncoming = CustodyIncomingTypes.includes(order.type); + + switch (order.status) { + case CustodyOrderStatus.CONFIRMED: + return isIncoming ? CustodyOrderHistoryStatus.WAITING_FOR_PAYMENT : CustodyOrderHistoryStatus.CHECK_PENDING; + + case CustodyOrderStatus.APPROVED: + case CustodyOrderStatus.IN_PROGRESS: + return CustodyOrderHistoryStatus.PROCESSING; + + case CustodyOrderStatus.COMPLETED: + return CustodyOrderHistoryStatus.COMPLETED; + + case CustodyOrderStatus.FAILED: + return CustodyOrderHistoryStatus.FAILED; + } + } +} diff --git a/src/subdomains/core/custody/services/custody-job.service.ts b/src/subdomains/core/custody/services/custody-job.service.ts index 2443bc4dfb..0f444d1483 100644 --- a/src/subdomains/core/custody/services/custody-job.service.ts +++ b/src/subdomains/core/custody/services/custody-job.service.ts @@ -1,10 +1,13 @@ import { Injectable } from '@nestjs/common'; import { CronExpression } from '@nestjs/schedule'; +import { LessThan } from 'typeorm'; import { DfxOrderStepAdapter } from '../adapter/dfx-order-step.adapter'; import { OrderConfig } from '../config/order-config'; +import { Config } from 'src/config/config'; import { Process } from 'src/shared/services/process.service'; import { DfxCron } from 'src/shared/utils/cron'; +import { Util } from 'src/shared/utils/util'; import { CustodyOrderStatus, CustodyOrderStepContext, CustodyOrderStepStatus } from '../enums/custody'; import { CustodyOrderStepRepository } from '../repositories/custody-order-step.repository'; import { CustodyOrderRepository } from '../repositories/custody-order.repository'; @@ -26,6 +29,22 @@ export class CustodyJobService { await this.checkStep(); } + @DfxCron(CronExpression.EVERY_DAY_AT_4AM, { process: Process.CUSTODY }) + async resetExpiredConfirmedOrders() { + const expiryDate = Util.daysBefore(Config.txRequestWaitingExpiryDays); + + const expiredOrders = await this.custodyOrderRepo.find({ + where: { + status: CustodyOrderStatus.CONFIRMED, + updated: LessThan(expiryDate), + }, + }); + + for (const order of expiredOrders) { + await this.custodyOrderRepo.update(...order.reset()); + } + } + private async executeOrder() { const approvedOrders = await this.custodyOrderRepo.find({ where: { status: CustodyOrderStatus.APPROVED }, diff --git a/src/subdomains/core/custody/services/custody-order.service.ts b/src/subdomains/core/custody/services/custody-order.service.ts index a39d52e981..1f92b5d5fd 100644 --- a/src/subdomains/core/custody/services/custody-order.service.ts +++ b/src/subdomains/core/custody/services/custody-order.service.ts @@ -11,9 +11,10 @@ import { JwtPayload } from 'src/shared/auth/jwt-payload.interface'; import { Asset } from 'src/shared/models/asset/asset.entity'; import { AssetService } from 'src/shared/models/asset/asset.service'; import { FiatService } from 'src/shared/models/fiat/fiat.service'; +import { User } from 'src/subdomains/generic/user/models/user/user.entity'; import { UserService } from 'src/subdomains/generic/user/models/user/user.service'; import { TransactionRequest } from 'src/subdomains/supporting/payment/entities/transaction-request.entity'; -import { Equal } from 'typeorm'; +import { Equal, In, Not } from 'typeorm'; import { BuyCrypto } from '../../buy-crypto/process/entities/buy-crypto.entity'; import { BuyService } from '../../buy-crypto/routes/buy/buy.service'; import { SwapService } from '../../buy-crypto/routes/swap/swap.service'; @@ -23,12 +24,18 @@ import { OrderConfig } from '../config/order-config'; import { CreateCustodyOrderInternalDto } from '../dto/input/create-custody-order.dto'; import { GetCustodyInfoDto } from '../dto/input/get-custody-info.dto'; import { UpdateCustodyOrderInternalDto } from '../dto/input/update-custody-order.dto'; +import { CustodyOrderHistoryDto } from '../dto/output/custody-order-history.dto'; import { CustodyOrderResponseDto } from '../dto/output/custody-order-response.dto'; import { CustodyOrderDto } from '../dto/output/custody-order.dto'; -import { CustodyBalance } from '../entities/custody-balance.entity'; import { CustodyOrderStep } from '../entities/custody-order-step.entity'; import { CustodyOrder } from '../entities/custody-order.entity'; -import { CustodyOrderStepCommand, CustodyOrderStepContext, CustodyOrderType } from '../enums/custody'; +import { + CustodyOrderStatus, + CustodyOrderStepCommand, + CustodyOrderStepContext, + CustodyOrderType, +} from '../enums/custody'; +import { CustodyOrderHistoryDtoMapper } from '../mappers/custody-order-history-dto.mapper'; import { CustodyOrderResponseDtoMapper } from '../mappers/custody-order-response-dto.mapper'; import { GetCustodyOrderDtoMapper } from '../mappers/get-custody-order-dto.mapper'; import { CustodyOrderStepRepository } from '../repositories/custody-order-step.repository'; @@ -81,6 +88,7 @@ export class CustodyOrderService { paymentInfo = CustodyOrderResponseDtoMapper.mapBuyPaymentInfo(buyPaymentInfo); break; } + case CustodyOrderType.WITHDRAWAL: { const sourceAsset = await this.getCustodyAsset(dto.sourceAsset); if (!sourceAsset) throw new NotFoundException('Source asset not found'); @@ -88,7 +96,7 @@ export class CustodyOrderService { const targetCurrency = await this.fiatService.getFiatByName(dto.targetAsset); if (!targetCurrency) throw new NotFoundException('Target currency not found'); - this.checkBalance(sourceAsset, dto.sourceAmount, user.custodyBalances); + await this.checkBalance(sourceAsset, dto.sourceAmount, user); const sellPaymentInfo = await this.sellService.createSellPaymentInfo( jwt.user, @@ -102,6 +110,7 @@ export class CustodyOrderService { paymentInfo = CustodyOrderResponseDtoMapper.mapSellPaymentInfo(sellPaymentInfo); break; } + case CustodyOrderType.SWAP: { const sourceAsset = await this.getCustodyAsset(dto.sourceAsset); if (!sourceAsset) throw new NotFoundException('Source asset not found'); @@ -109,7 +118,7 @@ export class CustodyOrderService { const targetAsset = await this.getCustodyAsset(dto.targetAsset); if (!targetAsset) throw new NotFoundException('Target asset not found'); - this.checkBalance(sourceAsset, dto.sourceAmount, user.custodyBalances); + await this.checkBalance(sourceAsset, dto.sourceAmount, user); const swapPaymentInfo = await this.swapService.createSwapPaymentInfo( jwt.user, @@ -123,6 +132,7 @@ export class CustodyOrderService { paymentInfo = CustodyOrderResponseDtoMapper.mapSwapPaymentInfo(swapPaymentInfo); break; } + case CustodyOrderType.SEND: { const sourceAsset = await this.getCustodyAsset(dto.sourceAsset); if (!sourceAsset) throw new NotFoundException('Source asset not found'); @@ -134,7 +144,7 @@ export class CustodyOrderService { }); if (!targetAsset) throw new NotFoundException('Target asset not found'); - this.checkBalance(sourceAsset, dto.sourceAmount, user.custodyBalances); + await this.checkBalance(sourceAsset, dto.sourceAmount, user); const targetUser = await this.userService.getUserByAddress(dto.targetAddress, { userData: true }); if (!targetUser || targetUser.userData.id !== user.userData.id) @@ -152,6 +162,7 @@ export class CustodyOrderService { paymentInfo = CustodyOrderResponseDtoMapper.mapSwapPaymentInfo(swapPaymentInfo); break; } + case CustodyOrderType.RECEIVE: { const sourceAsset = await this.getCustodyAsset(dto.sourceAsset); if (!sourceAsset) throw new NotFoundException('Asset not found'); @@ -181,6 +192,17 @@ export class CustodyOrderService { }; } + async getOrdersByUserData(userDataId: number): Promise { + const orders = await this.custodyOrderRepo.find({ + where: { user: { userData: { id: userDataId } }, status: Not(CustodyOrderStatus.CREATED) }, + relations: { inputAsset: true, outputAsset: true, transactionRequest: true }, + order: { created: 'DESC' }, + take: 100, + }); + + return CustodyOrderHistoryDtoMapper.mapList(orders); + } + async createOrderInternal(dto: CreateCustodyOrderInternalDto): Promise { const order = this.custodyOrderRepo.create(dto); @@ -265,9 +287,17 @@ export class CustodyOrderService { .sort((a, b) => this.CustodyChains.indexOf(a.blockchain) - this.CustodyChains.indexOf(b.blockchain))[0]; } - private checkBalance(asset: Asset, amount: number, custodyBalances: CustodyBalance[]): void { - const assetBalance = custodyBalances.find((a) => a.asset.id === asset.id); - if (!assetBalance || assetBalance.balance < amount) + private async checkBalance(asset: Asset, amount: number, user: User): Promise { + const assetBalance = user.custodyBalances.find((a) => a.asset.id === asset.id); + const balance = assetBalance?.balance ?? 0; + + const pendingAmount = await this.custodyOrderRepo.sum('outputAmount', { + user: { id: user.id }, + outputAsset: { id: asset.id }, + status: In([CustodyOrderStatus.CONFIRMED, CustodyOrderStatus.APPROVED, CustodyOrderStatus.IN_PROGRESS]), + }); + + if (balance < pendingAmount + amount) throw new BadRequestException('This transaction can only be created manually by support'); } } diff --git a/src/subdomains/core/custody/services/custody.service.ts b/src/subdomains/core/custody/services/custody.service.ts index 7feda31aad..c03944e27b 100644 --- a/src/subdomains/core/custody/services/custody.service.ts +++ b/src/subdomains/core/custody/services/custody.service.ts @@ -125,7 +125,7 @@ export class CustodyService { .select('SUM(custodyOrder.outputAmount)', 'withdrawal') .where('custodyOrder.userId = :id', { id: user.id }) .andWhere('custodyOrder.outputAssetId = :asset', { asset: asset.id }) - .andWhere('custodyOrder.status != :status', { status: CustodyOrderStatus.CREATED }) + .andWhere('custodyOrder.status = :status', { status: CustodyOrderStatus.COMPLETED }) .getRawOne<{ withdrawal: number }>(); const balance = deposit - withdrawal;