diff --git a/src/controllers/orders.controller.ts b/src/controllers/orders.controller.ts index 08c3adf..518318d 100644 --- a/src/controllers/orders.controller.ts +++ b/src/controllers/orders.controller.ts @@ -12,6 +12,9 @@ import GetUserOrdersAllPairsRes, { GetUserOrdersAllPairsErrorCode, GetUserOrdersAllPairsResPair, } from '@/interfaces/responses/orders/GetUserOrdersAllPairsRes'; +import CancelAllBody, { CancelAllBodyOrderType } from '@/interfaces/bodies/orders/CancelAllBody'; +import sequelize from '@/sequelize'; +import CancelAllRes, { CancelAllErrorCode } from '@/interfaces/responses/orders/CancelAllRes'; import candlesModel from '../models/Candles'; import ordersModel from '../models/Orders'; import CreateOrderBody from '../interfaces/bodies/orders/CreateOrderBody'; @@ -452,6 +455,51 @@ class OrdersController { res.status(500).send({ success: false, data: 'Unhandled error' }); } } + + async cancelAll(req: Request, res: Response) { + try { + const body = req.body as CancelAllBody; + const { userData, filterInfo } = body; + + const filterType = (() => { + if (filterInfo.type === undefined) { + return undefined; + } + + return filterInfo.type === CancelAllBodyOrderType.BUY + ? GetUserOrdersBodyType.BUY + : GetUserOrdersBodyType.SELL; + })(); + + await sequelize.transaction(async (transaction) => { + await ordersModel.cancelAll( + { + address: userData.address, + filterInfo: { + pairId: filterInfo.pairId, + type: filterType, + date: + filterInfo.date !== undefined + ? { + from: filterInfo.date.from, + to: filterInfo.date.to, + } + : undefined, + }, + }, + { transaction }, + ); + }); + + res.status(200).send({ success: true }); + } catch (err) { + console.log(err); + res.status(500).send({ + success: false, + data: CancelAllErrorCode.UNHANDLED_ERROR, + }); + } + } } const ordersController = new OrdersController(); diff --git a/src/interfaces/bodies/orders/CancelAllBody.ts b/src/interfaces/bodies/orders/CancelAllBody.ts new file mode 100644 index 0000000..31438fa --- /dev/null +++ b/src/interfaces/bodies/orders/CancelAllBody.ts @@ -0,0 +1,50 @@ +import UserData from '@/interfaces/common/UserData'; +import { body } from 'express-validator'; + +export enum CancelAllBodyOrderType { + // eslint-disable-next-line no-unused-vars + BUY = 'buy', + // eslint-disable-next-line no-unused-vars + SELL = 'sell', +} + +interface CancelAllBody { + userData: UserData; + + filterInfo: { + pairId?: number; + type?: CancelAllBodyOrderType; + date?: { + // UNIX timestamps in milliseconds + from: number; + to: number; + }; + }; +} + +export const cancelAllValidator = [ + body('filterInfo').isObject().withMessage('filterInfo must be an object'), + body('filterInfo.pairId') + .optional() + .isInt({ min: 0 }) + .withMessage('filterInfo.pairId must be a non-negative integer'), + body('filterInfo.type') + .optional() + .isIn(Object.values(CancelAllBodyOrderType)) + .withMessage(`Invalid filterInfo.type value`), + body('filterInfo.date').optional().isObject().withMessage('filterInfo.date must be an object'), + body('filterInfo.date.from') + .if(body('filterInfo.date').isObject()) + .isInt({ min: 0 }) + .withMessage( + 'filterInfo.date.from must be a non-negative integer representing a UNIX timestamp in milliseconds', + ), + body('filterInfo.date.to') + .if(body('filterInfo.date').isObject()) + .isInt({ min: 0 }) + .withMessage( + 'filterInfo.date.to must be a non-negative integer representing a UNIX timestamp in milliseconds', + ), +]; + +export default CancelAllBody; diff --git a/src/interfaces/responses/orders/CancelAllRes.ts b/src/interfaces/responses/orders/CancelAllRes.ts new file mode 100644 index 0000000..1fe1096 --- /dev/null +++ b/src/interfaces/responses/orders/CancelAllRes.ts @@ -0,0 +1,17 @@ +export type CancelAllSuccessRes = { + success: true; +}; + +export enum CancelAllErrorCode { + // eslint-disable-next-line no-unused-vars + UNHANDLED_ERROR = 'Unhandled error', +} + +export type CancelAllErrorRes = { + success: false; + data: CancelAllErrorCode; +}; + +type CancelAllRes = CancelAllSuccessRes | CancelAllErrorRes; + +export default CancelAllRes; diff --git a/src/models/Orders.ts b/src/models/Orders.ts index 16fede8..c2aa8a7 100644 --- a/src/models/Orders.ts +++ b/src/models/Orders.ts @@ -1,4 +1,4 @@ -import { Op, WhereOptions } from 'sequelize'; +import { Op, Transaction as SequelizeTransaction, WhereOptions } from 'sequelize'; import Decimal from 'decimal.js'; import TransactionWithOrders from '@/interfaces/common/Transaction.js'; import Currency from '@/schemes/Currency.js'; @@ -876,6 +876,114 @@ class OrdersModel { data: pairs, }; }; + + static CANCEL_ALL_USER_NOT_FOUND = 'No user found'; + static CANCEL_ALL_ORDER_NOT_FOUND = 'Order not found during cancel all process'; + cancelAll = async ( + { + address, + filterInfo: { pairId, type, date }, + }: { + address: string; + filterInfo: { + pairId?: number; + type?: 'buy' | 'sell'; + date?: { + from: number; + to: number; + }; + }; + }, + { transaction }: { transaction: SequelizeTransaction }, + ): Promise<{ + success: true; + }> => { + const userRow = await userModel.getUserRow(address); + + if (!userRow) { + throw new Error(OrdersModel.CANCEL_ALL_USER_NOT_FOUND); + } + + const ordersToCancelWhereClause: WhereOptions = { + user_id: userRow.id, + status: { + [Op.ne]: OrderStatus.FINISHED, + }, + ...(pairId !== undefined ? { pair_id: pairId } : {}), + ...(type !== undefined + ? { type: type === 'buy' ? OrderType.BUY : OrderType.SELL } + : {}), + ...(date !== undefined ? { timestamp: { [Op.between]: [date.from, date.to] } } : {}), + }; + + const ordersToCancelCount = await Order.count({ + where: ordersToCancelWhereClause, + }); + + const cancelPromises = []; + + for (let offset = 0; offset < ordersToCancelCount; offset += 1) { + cancelPromises.push(async () => { + const orderRow = await Order.findOne({ + where: ordersToCancelWhereClause, + order: [['timestamp', 'ASC']], + offset, + limit: 1, + }); + + if (!orderRow) { + throw new Error(OrdersModel.CANCEL_ALL_ORDER_NOT_FOUND); + } + + await this.cancelOrderNotifications(orderRow, userRow); + + const eps = new Decimal(1e-8); + const leftDecimal = new Decimal(orderRow.left); + const amountDecimal = new Decimal(orderRow.amount); + + // if order was partially filled + if (leftDecimal.minus(amountDecimal).abs().greaterThan(eps)) { + const connectedTransactions = await Transaction.findAll({ + where: { + [Op.or]: [ + { buy_order_id: orderRow.id }, + { sell_order_id: orderRow.id }, + ], + status: 'pending', + }, + }); + + for (const tx of connectedTransactions) { + await exchangeModel.returnTransactionAmount(tx.id, transaction); + } + + await Order.update( + { status: OrderStatus.FINISHED }, + { + where: { id: orderRow.id, user_id: userRow.id }, + transaction, + }, + ); + } else { + await Order.destroy({ + where: { + id: orderRow.id, + user_id: userRow.id, + }, + transaction, + }); + } + + transaction.afterCommit(() => { + sendDeleteOrderMessage(io, orderRow.pair_id.toString(), orderRow.id.toString()); + }); + }); + } + + await Promise.all(cancelPromises); + + return { success: true }; + }; } const ordersModel = new OrdersModel(); diff --git a/src/routes/orders.router.ts b/src/routes/orders.router.ts index fdf1896..71a6ec4 100644 --- a/src/routes/orders.router.ts +++ b/src/routes/orders.router.ts @@ -3,6 +3,7 @@ import express from 'express'; import { createOrderValidator } from '@/interfaces/bodies/orders/CreateOrderBody.js'; import { getUserOrdersValidator } from '@/interfaces/bodies/orders/GetUserOrdersBody.js'; import { getUserOrdersAllPairsValidator } from '@/interfaces/bodies/orders/GetUserOrdersAllPairsBody.js'; +import { cancelAllValidator } from '@/interfaces/bodies/orders/CancelAllBody.js'; import middleware from '../middleware/middleware.js'; import ordersController from '../controllers/orders.controller.js'; @@ -43,5 +44,10 @@ ordersRouter.patch( middleware.expressValidator(getUserOrdersAllPairsValidator), ordersController.getUserOrdersAllPairs.bind(ordersController), ); +ordersRouter.patch( + '/orders/cancel-all', + middleware.expressValidator(cancelAllValidator), + ordersController.cancelAll.bind(ordersController), +); export default ordersRouter;