Add admin transaction cancellation without deleting history.
Cancelled transactions are excluded from paid totals and due-date calculations while remaining visible for audit purposes.
This commit is contained in:
@@ -58,3 +58,9 @@ exports.updateTransaction = catchAsync(async (req, res, next) => {
|
|||||||
const payment = await paymentService.updateTransaction(req.params.transactionId, req.body, actorId);
|
const payment = await paymentService.updateTransaction(req.params.transactionId, req.body, actorId);
|
||||||
return successResponse(res, 200, 'Transaction updated successfully', payment);
|
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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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.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/update/:id', perm.requires(PERMISSIONS.PAYMENTS_UPDATE), validateUpdatePayment, paymentController.update);
|
||||||
router.put('/admin/transactions/:transactionId', perm.requires(PERMISSIONS.PAYMENTS_UPDATE), validateUpdateTransaction, paymentController.updateTransaction);
|
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.post('/admin/transactions/:paymentId', perm.requires(PERMISSIONS.PAYMENTS_UPDATE), validateAddTransaction, paymentController.createTransaction);
|
||||||
router.delete('/admin/delete/:id', perm.requires(PERMISSIONS.PAYMENTS_DELETE), paymentController.delete);
|
router.delete('/admin/delete/:id', perm.requires(PERMISSIONS.PAYMENTS_DELETE), paymentController.delete);
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ const {
|
|||||||
normalizeDiscount,
|
normalizeDiscount,
|
||||||
sanitizeNotes,
|
sanitizeNotes,
|
||||||
isPaidTransaction,
|
isPaidTransaction,
|
||||||
|
isCancelledTransaction,
|
||||||
|
isActiveTransaction,
|
||||||
sumPaidTransactions
|
sumPaidTransactions
|
||||||
} = require('../../utils/paymentAmount');
|
} = require('../../utils/paymentAmount');
|
||||||
const logger = require('../../utils/logger');
|
const logger = require('../../utils/logger');
|
||||||
@@ -56,7 +58,7 @@ const refreshPaymentTotals = async (payment) => {
|
|||||||
const transactions = await Transaction.find({ payment: payment._id }).lean();
|
const transactions = await Transaction.find({ payment: payment._id }).lean();
|
||||||
payment.paidAmount = sumPaidTransactions(transactions);
|
payment.paidAmount = sumPaidTransactions(transactions);
|
||||||
const pending = 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));
|
.sort((a, b) => new Date(a.dueDate || 0) - new Date(b.dueDate || 0));
|
||||||
payment.dueDate = pending[0]?.dueDate || payment.dueDate;
|
payment.dueDate = pending[0]?.dueDate || payment.dueDate;
|
||||||
await payment.save();
|
await payment.save();
|
||||||
@@ -333,6 +335,9 @@ const addTransaction = async (paymentId, trxData, actorId = null) => {
|
|||||||
const updateTransaction = async (transactionId, body, actorId = null) => {
|
const updateTransaction = async (transactionId, body, actorId = null) => {
|
||||||
const trx = await Transaction.findById(transactionId);
|
const trx = await Transaction.findById(transactionId);
|
||||||
if (!trx) throw new AppError('TRANSACTION_NOT_FOUND');
|
if (!trx) throw new AppError('TRANSACTION_NOT_FOUND');
|
||||||
|
if (trx.status === 'cancelled') {
|
||||||
|
throw new AppError('VALIDATION_FAILED', {}, 'تراکنش لغوشده قابل ویرایش نیست.');
|
||||||
|
}
|
||||||
|
|
||||||
const payment = await Payment.findById(trx.payment);
|
const payment = await Payment.findById(trx.payment);
|
||||||
if (!payment) throw new AppError('PAYMENT_NOT_FOUND');
|
if (!payment) throw new AppError('PAYMENT_NOT_FOUND');
|
||||||
@@ -373,6 +378,27 @@ const updateTransaction = async (transactionId, body, actorId = null) => {
|
|||||||
return getPaymentById(payment._id);
|
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 = {}) => {
|
const getMyPayments = async (userId, query = {}) => {
|
||||||
return getAllPayments({ ...query, userId });
|
return getAllPayments({ ...query, userId });
|
||||||
};
|
};
|
||||||
@@ -386,6 +412,7 @@ module.exports = {
|
|||||||
searchPayments,
|
searchPayments,
|
||||||
addTransaction,
|
addTransaction,
|
||||||
updateTransaction,
|
updateTransaction,
|
||||||
|
cancelTransaction,
|
||||||
getMyPayments,
|
getMyPayments,
|
||||||
createTransactionsForPayment,
|
createTransactionsForPayment,
|
||||||
refreshPaymentTotals,
|
refreshPaymentTotals,
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ const transactionSchema = new mongoose.Schema({
|
|||||||
},
|
},
|
||||||
status: {
|
status: {
|
||||||
type: String,
|
type: String,
|
||||||
enum: ['pending', 'paid'],
|
enum: ['pending', 'paid', 'cancelled'],
|
||||||
default: 'pending',
|
default: 'pending',
|
||||||
index: true
|
index: true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,13 +24,18 @@ const sanitizeNotes = (notes) => {
|
|||||||
|
|
||||||
const rialsToToman = (value) => Math.floor(toNonNegativeNumber(value) / 10);
|
const rialsToToman = (value) => Math.floor(toNonNegativeNumber(value) / 10);
|
||||||
|
|
||||||
|
const isCancelledTransaction = (trx = {}) => String(trx.status || '').toLowerCase() === 'cancelled';
|
||||||
|
|
||||||
const isPaidTransaction = (trx = {}) => {
|
const isPaidTransaction = (trx = {}) => {
|
||||||
|
if (isCancelledTransaction(trx)) return false;
|
||||||
const status = String(trx.status || '').toLowerCase();
|
const status = String(trx.status || '').toLowerCase();
|
||||||
if (status === 'pending') return false;
|
if (status === 'pending') return false;
|
||||||
if (status === 'paid') return true;
|
if (status === 'paid') return true;
|
||||||
return Boolean(trx.date);
|
return Boolean(trx.date);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isActiveTransaction = (trx = {}) => !isCancelledTransaction(trx);
|
||||||
|
|
||||||
const sumPaidTransactions = (transactions = []) => {
|
const sumPaidTransactions = (transactions = []) => {
|
||||||
if (!Array.isArray(transactions)) return 0;
|
if (!Array.isArray(transactions)) return 0;
|
||||||
return transactions.reduce((sum, trx) => {
|
return transactions.reduce((sum, trx) => {
|
||||||
@@ -50,6 +55,8 @@ module.exports = {
|
|||||||
sanitizeNotes,
|
sanitizeNotes,
|
||||||
rialsToToman,
|
rialsToToman,
|
||||||
isPaidTransaction,
|
isPaidTransaction,
|
||||||
|
isCancelledTransaction,
|
||||||
|
isActiveTransaction,
|
||||||
sumPaidTransactions,
|
sumPaidTransactions,
|
||||||
remainingPayable
|
remainingPayable
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -73,6 +73,16 @@ describe('paid vs pending transactions', () => {
|
|||||||
assert.equal(isPaidTransaction({ status: 'pending', amount: 2_000_000 }), false);
|
assert.equal(isPaidTransaction({ status: 'pending', amount: 2_000_000 }), false);
|
||||||
assert.equal(isPaidTransaction({ status: 'paid', amount: 8_000_000 }), true);
|
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', () => {
|
describe('sanitizeNotes', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user