diff --git a/bot/commands.ts b/bot/commands.ts index 617d45fd..86ea392c 100644 --- a/bot/commands.ts +++ b/bot/commands.ts @@ -598,13 +598,8 @@ const addInvoicePHI = async ( ctx.deleteMessage(); const order = await Order.findOne({ _id: orderId }); if (order === null) throw new Error('order was not found'); - // orders with status PAID_HOLD_INVOICE or COMPLETED_BY_ADMIN are released payments - if ( - order.status !== 'PAID_HOLD_INVOICE' && - order.status !== 'COMPLETED_BY_ADMIN' - ) { - return; - } + // only orders with status PAID_HOLD_INVOICE are released payments + if (order.status !== 'PAID_HOLD_INVOICE') return; const buyer = await User.findOne({ _id: order.buyer_id }); if (buyer === null) return; diff --git a/bot/modules/block/commands.ts b/bot/modules/block/commands.ts index c0040e74..836129ed 100644 --- a/bot/modules/block/commands.ts +++ b/bot/modules/block/commands.ts @@ -23,7 +23,6 @@ const block = async (ctx: MainContext, username: string): Promise => { 'CLOSED', 'CANCELED_BY_ADMIN', 'EXPIRED', - 'COMPLETED_BY_ADMIN', 'SUCCESS', 'PAID_HOLD_INVOICE', 'CANCELED', diff --git a/bot/start.ts b/bot/start.ts index 34ca46d4..5c8ab43d 100644 --- a/bot/start.ts +++ b/bot/start.ts @@ -133,16 +133,10 @@ const askForConfirmation = async (user: UserDocument, command: string) => { orders = await Order.find(where); } else if (command === '/setinvoice') { const where: FilterQuery = { - $and: [ - { buyer_id: user._id }, - { - $or: [ - { status: 'PAID_HOLD_INVOICE' }, - { status: 'COMPLETED_BY_ADMIN' }, - ], - }, - ], + buyer_id: user._id, + status: 'PAID_HOLD_INVOICE', }; + orders = await Order.find(where); } @@ -550,15 +544,17 @@ const initialize = ( } } - if (order.secret) await settleHoldInvoice({ secret: order.secret }); + if (order.secret) { + await settleHoldInvoice({ secret: order.secret }); + order.settled_by_admin = true; + await order.save(); + } if (dispute) { dispute.status = 'SETTLED'; await dispute.save(); } - order.status = 'COMPLETED_BY_ADMIN'; - await order.save(); const buyer = await User.findOne({ _id: order.buyer_id }); const seller = await User.findOne({ _id: order.seller_id }); if (buyer === null || seller === null) diff --git a/locales/de.yaml b/locales/de.yaml index a3c387cd..04303b1e 100644 --- a/locales/de.yaml +++ b/locales/de.yaml @@ -178,6 +178,8 @@ order_detail: | Status: ${status} + Von Admin abgeschlossen: ${settledByAdmin} + Ersteller: @${creator || ''} Käufer: @${buyerUsername || ''} diff --git a/locales/en.yaml b/locales/en.yaml index 3913a201..444d0bea 100644 --- a/locales/en.yaml +++ b/locales/en.yaml @@ -182,6 +182,8 @@ order_detail: | Status: ${status} + Settled by admin: ${settledByAdmin} + Creator: @${creator || ''} Buyer: @${buyerUsername || ''} diff --git a/locales/es.yaml b/locales/es.yaml index 938712a2..fc9b85d4 100644 --- a/locales/es.yaml +++ b/locales/es.yaml @@ -178,6 +178,8 @@ order_detail: | Status: ${status} + Completada por admin: ${settledByAdmin} + Creador: @${creator || ''} Comprador: @${buyerUsername || ''} diff --git a/locales/fa.yaml b/locales/fa.yaml index 16ae0fa6..fb377486 100644 --- a/locales/fa.yaml +++ b/locales/fa.yaml @@ -186,6 +186,8 @@ order_detail: | وضعیت: ${status} + تسویه توسط مدیر: ${settledByAdmin} + ایجاد کننده: @${creator || ''} خریدار: @${buyerUsername || ''} diff --git a/locales/fr.yaml b/locales/fr.yaml index 2294aa79..7efc35a0 100644 --- a/locales/fr.yaml +++ b/locales/fr.yaml @@ -180,6 +180,8 @@ order_detail: | Statut : ${status} + Complété par l'administrateur : ${settledByAdmin} + Créateur : @${creator || ''} Acheteur : @${buyerUsername || ''} diff --git a/locales/it.yaml b/locales/it.yaml index 16d35a4d..66bfd1f3 100644 --- a/locales/it.yaml +++ b/locales/it.yaml @@ -178,6 +178,8 @@ order_detail: | Stato: ${status} + Completato dall'amministratore: ${settledByAdmin} + Creato da: @${creator || ''} Acquirente: @${buyerUsername || ''} diff --git a/locales/ko.yaml b/locales/ko.yaml index abf7bf1f..7a933b0a 100644 --- a/locales/ko.yaml +++ b/locales/ko.yaml @@ -179,6 +179,8 @@ order_detail: | 상태: ${status} + 관리자에 의해 완료됨: ${settledByAdmin} + 생성자: @${creator || ''} 구매자: @${buyerUsername || ''} diff --git a/locales/pt.yaml b/locales/pt.yaml index 7dd2c495..777d92a3 100644 --- a/locales/pt.yaml +++ b/locales/pt.yaml @@ -179,6 +179,8 @@ order_detail: | Status: ${status} + Completado por admin: ${settledByAdmin} + Criadora: @${creator || ''} Compradora: @${buyerUsername || ''} diff --git a/locales/ru.yaml b/locales/ru.yaml index 23c13fc1..8696d089 100644 --- a/locales/ru.yaml +++ b/locales/ru.yaml @@ -177,6 +177,8 @@ order_detail: | Статус: ${status} + Завершено администратором: ${settledByAdmin} + Создатель: @${creator || ''} Покупатель: @${buyerUsername || ''} diff --git a/locales/uk.yaml b/locales/uk.yaml index 46044ed7..a9c9981e 100644 --- a/locales/uk.yaml +++ b/locales/uk.yaml @@ -177,6 +177,8 @@ order_detail: | Статус: ${status} + Завершено адміністратором: ${settledByAdmin} + Автор: @${creator || ''} Покупець: @${buyerUsername || ''} diff --git a/models/order.ts b/models/order.ts index 01e99e40..a267f45e 100644 --- a/models/order.ts +++ b/models/order.ts @@ -47,6 +47,7 @@ export interface IOrder extends Document { is_public: boolean; random_image: string; is_golden_honey_badger?: boolean; + settled_by_admin?: boolean; } const orderSchema = new Schema>({ @@ -118,10 +119,10 @@ const orderSchema = new Schema>({ 'PAID_HOLD_INVOICE', // seller released funds 'CANCELED_BY_ADMIN', 'EXPIRED', // Expired orders, stated changed by a job - 'COMPLETED_BY_ADMIN', 'FROZEN', ], }, + settled_by_admin: { type: Boolean, default: false }, type: { type: String }, fiat_amount: { type: Number, min: 1 }, // amount in fiat fiat_code: { type: String }, diff --git a/scripts/migrate_completed_by_admin_orders.ts b/scripts/migrate_completed_by_admin_orders.ts new file mode 100644 index 00000000..25c32537 --- /dev/null +++ b/scripts/migrate_completed_by_admin_orders.ts @@ -0,0 +1,39 @@ +import 'dotenv/config'; +import { connect as mongoConnect } from '../db_connect'; +import Order from '../models/order'; +import { logger } from '../logger'; + +const migrate = async () => { + try { + const mongoose = mongoConnect(); + await new Promise((resolve, reject) => { + mongoose.connection.once('open', resolve); + mongoose.connection.on('error', reject); + }); + + logger.info('Connected to MongoDB for migration.'); + + const query = { status: 'COMPLETED_BY_ADMIN' }; + const update = { + $set: { + status: 'SUCCESS', + settled_by_admin: true, + }, + }; + + const result = await Order.updateMany(query, update); + + logger.info(`Migration completed.`); + logger.info(`Matched: ${result.matchedCount} orders.`); + logger.info(`Modified: ${result.modifiedCount} orders.`); + + await mongoose.connection.close(); + logger.info('Database connection closed.'); + process.exit(0); + } catch (error) { + logger.error(`Migration failed: ${error}`); + process.exit(1); + } +}; + +migrate(); diff --git a/tests/bot/bot.spec.ts b/tests/bot/bot.spec.ts index e6ce9838..a491d87d 100644 --- a/tests/bot/bot.spec.ts +++ b/tests/bot/bot.spec.ts @@ -609,4 +609,110 @@ describe('Bot Initialization', () => { ctx.reply.calledWithExactly('This is an unknown command.'), ).to.be.equal(true); }); + + it('should set settled_by_admin when admin settles with secret', async () => { + const orderMock = { + _id: 'orderId', + status: 'DISPUTE', + secret: 'secret', + community_id: null, + buyer_id: 'buyer', + seller_id: 'seller', + save: sinon.stub().resolves(), + } as any; + + const OrderFindOneStub = sinon.stub().resolves(orderMock); + const settleHoldInvoiceStub = sinon.stub().resolves(); + + const startModule = proxyquire('../../bot/start', { + telegraf: { Telegraf: sinon.stub().returns(botStub) }, + '../models': { + Order: { findOne: OrderFindOneStub }, + User: { findOne: sinon.stub().resolves({ id: 'user' }) }, + Dispute: { findOne: sinon.stub().resolves(null) }, + }, + './validations': { + validateParams: sinon.stub().resolves(['orderId']), + validateObjectId: sinon.stub().resolves(true), + }, + '../ln': { settleHoldInvoice: settleHoldInvoiceStub }, + './messages': { + successCompleteOrderMessage: sinon.stub().resolves(), + successCompleteOrderByAdminMessage: sinon.stub().resolves(), + }, + }); + + startModule.initialize('dummy-token', {}); + const settleOrderCall = botStub.command + .getCalls() + .find((c: any) => c.args[0] === 'settleorder'); + const handler = settleOrderCall.args[2]; + + const ctx = { + admin: { admin: true }, + match: ['/settleorder orderId', 'orderId'], + reply: sinon.stub().resolves(), + i18n: { t: sinon.stub().returns('Success') }, + telegram: { sendMessage: sinon.stub().resolves() }, + }; + + await handler(ctx); + + expect(settleHoldInvoiceStub.calledWith({ secret: 'secret' })).to.be.equal( + true, + ); + expect(orderMock.settled_by_admin).to.be.equal(true); + expect(orderMock.save.called).to.be.equal(true); + }); + + it('should not modify order if settleHoldInvoice fails', async () => { + const orderMock = { + _id: 'orderId', + status: 'DISPUTE', + secret: 'invalidsecret', + community_id: null, + buyer_id: 'buyer', + seller_id: 'seller', + save: sinon.stub().resolves(), + } as any; + + const OrderFindOneStub = sinon.stub().resolves(orderMock); + const settleHoldInvoiceStub = sinon.stub().rejects(new Error('LND failed')); + + const startModule = proxyquire('../../bot/start', { + telegraf: { Telegraf: sinon.stub().returns(botStub) }, + '../models': { + Order: { findOne: OrderFindOneStub }, + User: { findOne: sinon.stub().resolves({ id: 'user' }) }, + Dispute: { findOne: sinon.stub().resolves(null) }, + }, + './validations': { + validateParams: sinon.stub().resolves(['orderId']), + validateObjectId: sinon.stub().resolves(true), + }, + '../ln': { settleHoldInvoice: settleHoldInvoiceStub }, + './messages': { + successCompleteOrderMessage: sinon.stub().resolves(), + successCompleteOrderByAdminMessage: sinon.stub().resolves(), + }, + }); + + startModule.initialize('dummy-token', {}); + const settleOrderCall = botStub.command + .getCalls() + .find((c: any) => c.args[0] === 'settleorder'); + const handler = settleOrderCall.args[2]; + + const ctx = { + admin: { admin: true }, + match: ['/settleorder orderId', 'orderId'], + reply: sinon.stub().resolves(), + i18n: { t: sinon.stub().returns('Success') }, + }; + + await handler(ctx); + + expect(orderMock.save.called).to.be.equal(false); + expect(orderMock.settled_by_admin).to.equal(undefined); + }); }); diff --git a/tests/bot/modules/block.spec.ts b/tests/bot/modules/block.spec.ts new file mode 100644 index 00000000..099e717f --- /dev/null +++ b/tests/bot/modules/block.spec.ts @@ -0,0 +1,76 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); +const proxyquire = require('proxyquire'); + +describe('Block Module block query', () => { + let sandbox: any; + let orderExistsStub: any; + let blockExistsStub: any; + let blockSaveStub: any; + let userFindOneStub: any; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + + orderExistsStub = sandbox.stub(); + blockExistsStub = sandbox.stub(); + blockSaveStub = sandbox.stub().resolves(); + + userFindOneStub = sandbox.stub().resolves({ + id: '2', + tg_id: 2, + username: 'badguy', + }); + + // We need to proxyquire the Block model constructor too, since `const block = new Block(...)` is used. + // Instead of full proxyquire, let's just test the `Order.exists` query passed to it, as requested by the review. + }); + + afterEach(() => { + sandbox.restore(); + }); + + it('should exclude settled orders from pending count when blocking', async () => { + const ctx = { + user: { + id: '1', + tg_id: 1, + username: 'goodguy', + }, + }; + + orderExistsStub.resolves(false); + blockExistsStub.resolves(false); + + // We stub Block constructor for the `new Block` call at the end + const BlockMock = function (this: any) { + this.save = blockSaveStub; + }; + BlockMock.exists = blockExistsStub; + + const blockModuleFixed = proxyquire('../../../bot/modules/block/commands', { + '../../../models': { + Order: { exists: orderExistsStub }, + Block: BlockMock, + User: { findOne: userFindOneStub }, + }, + './messages': { + ordersInProcess: sandbox.stub().resolves(), + userBlocked: sandbox.stub().resolves(), + }, + }); + + await blockModuleFixed.block(ctx, '@badguy'); + + expect(orderExistsStub.calledOnce).to.equal(true); + + const queryArgs = orderExistsStub.firstCall.args[0]; + + // The review requires that we verify the query excludes settled orders from pending count + // The query excludes these statuses using $nin. + // PAID_HOLD_INVOICE is one of them, which now represents completed orders along with settled_by_admin: true. + expect(queryArgs.status.$nin).to.include('PAID_HOLD_INVOICE'); + expect(queryArgs.status.$nin).to.not.include('COMPLETED_BY_ADMIN'); + }); +}); +export {}; diff --git a/tests/scripts/migrate_completed_by_admin_orders.spec.ts b/tests/scripts/migrate_completed_by_admin_orders.spec.ts new file mode 100644 index 00000000..4d10a5ba --- /dev/null +++ b/tests/scripts/migrate_completed_by_admin_orders.spec.ts @@ -0,0 +1,81 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); +const proxyquire = require('proxyquire'); + +describe('Migration Script: migrate_completed_by_admin_orders', () => { + let sandbox: any; + let updateManyStub: any; + let exitStub: any; + let infoStub: any; + let errorStub: any; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + + updateManyStub = sandbox.stub().resolves({ + matchedCount: 2, + modifiedCount: 2, + }); + + // Mock logger + infoStub = sandbox.stub(); + errorStub = sandbox.stub(); + + // Mock process.exit + exitStub = sandbox.stub(process, 'exit'); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it('should migrate COMPLETED_BY_ADMIN orders to SUCCESS + settled_by_admin', async () => { + // We proxyquire the script to inject mocks + proxyquire('../../scripts/migrate_completed_by_admin_orders', { + '../db_connect': { + connect: sandbox.stub().returns({ + connection: { + once: sandbox.stub().callsFake((event: any, cb: any) => { + if (event === 'open') cb(); + }), + on: sandbox.stub(), + close: sandbox.stub().resolves(), + }, + }), + }, + '../models/order': { + default: { + updateMany: updateManyStub, + }, + }, + '../logger': { + logger: { + info: infoStub, + error: errorStub, + }, + }, + }); + + // We need to wait a tick for the async immediately-invoked function to resolve + await new Promise(resolve => setTimeout(resolve, 10)); + + expect(updateManyStub.calledOnce).to.equal(true); + + const [query, update] = updateManyStub.firstCall.args; + + // Verify query + expect(query).to.deep.equal({ status: 'COMPLETED_BY_ADMIN' }); + + // Verify update + expect(update).to.deep.equal({ + $set: { + status: 'SUCCESS', + settled_by_admin: true, + }, + }); + + expect(exitStub.calledWith(0)).to.equal(true); + }); +}); + +export {}; diff --git a/tests/util/index.spec.ts b/tests/util/index.spec.ts index 7df03a23..09ba4cde 100644 --- a/tests/util/index.spec.ts +++ b/tests/util/index.spec.ts @@ -6,6 +6,7 @@ import { plural, isFloat, toKebabCase, + getDetailedOrder, } from '../../util/index'; const { expect } = require('chai'); @@ -136,4 +137,64 @@ describe('Utility Functions', () => { expect(toKebabCase('hello_world')).to.equal('hello-world'); }); }); + + describe('getDetailedOrder', () => { + it('should show "Settled by admin: Yes" when settled_by_admin = true', async () => { + const i18n = { + t: (key: string, props?: any) => { + if (key === 'yes') return 'Yes'; + if (key === 'no') return 'No'; + if (key === 'no_community') return 'None'; + if (key === 'order_detail') + return `Settled by admin: ${props.settledByAdmin}`; + return key; + }, + } as any; + + const order = { + _id: '123', + created_at: new Date(), + status: 'PAID_HOLD_INVOICE', + settled_by_admin: true, + payment_method: 'bank', + price_margin: 0, + fee: 0, + } as any; + + const buyer = null; + const seller = null; + + const result = await getDetailedOrder(i18n, order, buyer, seller); + expect(result).to.equal('Settled by admin: Yes'); + }); + + it('should show "Settled by admin: No" when settled_by_admin = false', async () => { + const i18n = { + t: (key: string, props?: any) => { + if (key === 'yes') return 'Yes'; + if (key === 'no') return 'No'; + if (key === 'no_community') return 'None'; + if (key === 'order_detail') + return `Settled by admin: ${props.settledByAdmin}`; + return key; + }, + } as any; + + const order = { + _id: '123', + created_at: new Date(), + status: 'SUCCESS', + settled_by_admin: false, + payment_method: 'bank', + price_margin: 0, + fee: 0, + } as any; + + const buyer = null; + const seller = null; + + const result = await getDetailedOrder(i18n, order, buyer, seller); + expect(result).to.equal('Settled by admin: No'); + }); + }); }); diff --git a/tsconfig.json b/tsconfig.json index 63ff67a6..f1492da5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,7 +4,10 @@ "esModuleInterop": true, "resolveJsonModule": true, "downlevelIteration": true, - "lib":["ES2021", "DOM"], + "lib": [ + "ES2021", + "DOM" + ], "outDir": "./dist", "rootDir": ".", "moduleResolution": "node" @@ -18,6 +21,12 @@ "models/**/*", "util/**/*", "locales/**/*", + "scripts/**/*" ], - "exclude": ["node_modules", "dist", "tests", "locales"] -} + "exclude": [ + "node_modules", + "dist", + "tests", + "locales" + ] +} \ No newline at end of file diff --git a/util/index.ts b/util/index.ts index 0c4f99e8..64cd99cc 100644 --- a/util/index.ts +++ b/util/index.ts @@ -402,6 +402,9 @@ const getDetailedOrder = async ( const sellerAge = seller ? getUserAge(seller) : ''; const buyerTrades = buyer ? buyer.trades_completed : 0; const sellerTrades = seller ? seller.trades_completed : 0; + const settledByAdmin = order.settled_by_admin + ? i18n.t('yes') + : i18n.t('no'); // Add order community name let communityName: string | undefined; @@ -435,6 +438,7 @@ const getDetailedOrder = async ( sellerAge, buyerTrades, sellerTrades, + settledByAdmin, communityName, });