diff --git a/components/payments/paymentController.js b/components/payments/paymentController.js index 1d78a20..bc43ad1 100644 --- a/components/payments/paymentController.js +++ b/components/payments/paymentController.js @@ -58,3 +58,9 @@ exports.updateTransaction = catchAsync(async (req, res, next) => { const payment = await paymentService.updateTransaction(req.params.transactionId, req.body, actorId); return successResponse(res, 200, 'Transaction updated successfully', payment); }); + +exports.cancelTransaction = catchAsync(async (req, res, next) => { + const actorId = req.user?._id; + const payment = await paymentService.cancelTransaction(req.params.transactionId, actorId); + return successResponse(res, 200, 'Transaction cancelled successfully', payment); +}); diff --git a/components/payments/paymentRoutes.js b/components/payments/paymentRoutes.js index 1093230..8f3895e 100644 --- a/components/payments/paymentRoutes.js +++ b/components/payments/paymentRoutes.js @@ -27,6 +27,7 @@ router.get('/admin/search', perm.requires(PERMISSIONS.PAYMENTS_SEARCH), paymentC router.get('/admin/get-one/:id', perm.requires(PERMISSIONS.PAYMENTS_READ), paymentController.getOne); router.put('/admin/update/:id', perm.requires(PERMISSIONS.PAYMENTS_UPDATE), validateUpdatePayment, paymentController.update); router.put('/admin/transactions/:transactionId', perm.requires(PERMISSIONS.PAYMENTS_UPDATE), validateUpdateTransaction, paymentController.updateTransaction); +router.post('/admin/transactions/:transactionId/cancel', perm.requires(PERMISSIONS.PAYMENTS_UPDATE), paymentController.cancelTransaction); router.post('/admin/transactions/:paymentId', perm.requires(PERMISSIONS.PAYMENTS_UPDATE), validateAddTransaction, paymentController.createTransaction); router.delete('/admin/delete/:id', perm.requires(PERMISSIONS.PAYMENTS_DELETE), paymentController.delete); diff --git a/components/payments/paymentService.js b/components/payments/paymentService.js index b17c1d0..77f7498 100644 --- a/components/payments/paymentService.js +++ b/components/payments/paymentService.js @@ -20,6 +20,8 @@ const { normalizeDiscount, sanitizeNotes, isPaidTransaction, + isCancelledTransaction, + isActiveTransaction, sumPaidTransactions } = require('../../utils/paymentAmount'); const logger = require('../../utils/logger'); @@ -56,7 +58,7 @@ const refreshPaymentTotals = async (payment) => { const transactions = await Transaction.find({ payment: payment._id }).lean(); payment.paidAmount = sumPaidTransactions(transactions); const pending = transactions - .filter((trx) => !isPaidTransaction(trx)) + .filter((trx) => isActiveTransaction(trx) && !isPaidTransaction(trx)) .sort((a, b) => new Date(a.dueDate || 0) - new Date(b.dueDate || 0)); payment.dueDate = pending[0]?.dueDate || payment.dueDate; await payment.save(); @@ -333,6 +335,9 @@ const addTransaction = async (paymentId, trxData, actorId = null) => { const updateTransaction = async (transactionId, body, actorId = null) => { const trx = await Transaction.findById(transactionId); if (!trx) throw new AppError('TRANSACTION_NOT_FOUND'); + if (trx.status === 'cancelled') { + throw new AppError('VALIDATION_FAILED', {}, 'تراکنش لغوشده قابل ویرایش نیست.'); + } const payment = await Payment.findById(trx.payment); if (!payment) throw new AppError('PAYMENT_NOT_FOUND'); @@ -373,6 +378,27 @@ const updateTransaction = async (transactionId, body, actorId = null) => { return getPaymentById(payment._id); }; +const cancelTransaction = async (transactionId, actorId = null) => { + const trx = await Transaction.findById(transactionId); + if (!trx) throw new AppError('TRANSACTION_NOT_FOUND'); + if (trx.status === 'cancelled') { + throw new AppError('VALIDATION_FAILED', {}, 'این تراکنش قبلاً لغو شده است.'); + } + + const payment = await Payment.findById(trx.payment); + if (!payment) throw new AppError('PAYMENT_NOT_FOUND'); + + const previousStatus = payment.status; + trx.status = 'cancelled'; + if (actorId) trx.recordedBy = actorId; + await trx.save(); + + await refreshPaymentTotals(payment); + emitPaymentStatusChangedIfNeeded(payment, previousStatus, actorId); + + return getPaymentById(payment._id); +}; + const getMyPayments = async (userId, query = {}) => { return getAllPayments({ ...query, userId }); }; @@ -386,6 +412,7 @@ module.exports = { searchPayments, addTransaction, updateTransaction, + cancelTransaction, getMyPayments, createTransactionsForPayment, refreshPaymentTotals, diff --git a/components/payments/transactionModel.js b/components/payments/transactionModel.js index 1dd1ad1..09565b9 100644 --- a/components/payments/transactionModel.js +++ b/components/payments/transactionModel.js @@ -35,7 +35,7 @@ const transactionSchema = new mongoose.Schema({ }, status: { type: String, - enum: ['pending', 'paid'], + enum: ['pending', 'paid', 'cancelled'], default: 'pending', index: true } diff --git a/utils/paymentAmount.js b/utils/paymentAmount.js index 6b8ec5b..852d18f 100644 --- a/utils/paymentAmount.js +++ b/utils/paymentAmount.js @@ -24,13 +24,18 @@ const sanitizeNotes = (notes) => { const rialsToToman = (value) => Math.floor(toNonNegativeNumber(value) / 10); +const isCancelledTransaction = (trx = {}) => String(trx.status || '').toLowerCase() === 'cancelled'; + const isPaidTransaction = (trx = {}) => { + if (isCancelledTransaction(trx)) return false; const status = String(trx.status || '').toLowerCase(); if (status === 'pending') return false; if (status === 'paid') return true; return Boolean(trx.date); }; +const isActiveTransaction = (trx = {}) => !isCancelledTransaction(trx); + const sumPaidTransactions = (transactions = []) => { if (!Array.isArray(transactions)) return 0; return transactions.reduce((sum, trx) => { @@ -50,6 +55,8 @@ module.exports = { sanitizeNotes, rialsToToman, isPaidTransaction, + isCancelledTransaction, + isActiveTransaction, sumPaidTransactions, remainingPayable }; diff --git a/utils/paymentAmount.test.js b/utils/paymentAmount.test.js index ca5ed9f..171b9e0 100644 --- a/utils/paymentAmount.test.js +++ b/utils/paymentAmount.test.js @@ -73,6 +73,16 @@ describe('paid vs pending transactions', () => { assert.equal(isPaidTransaction({ status: 'pending', amount: 2_000_000 }), false); assert.equal(isPaidTransaction({ status: 'paid', amount: 8_000_000 }), true); }); + + it('ignores cancelled transactions in paid totals', () => { + const transactions = [ + { amount: 5_000_000, status: 'paid', date: '2026-08-03' }, + { amount: 5_000_000, status: 'cancelled' }, + { amount: 2_000_000, status: 'pending', dueDate: '2026-08-23' } + ]; + assert.equal(sumPaidTransactions(transactions), 5_000_000); + assert.equal(isPaidTransaction({ status: 'cancelled', amount: 5_000_000, date: '2026-08-03' }), false); + }); }); describe('sanitizeNotes', () => {