feat: add admin transaction create/update with auto payment status sync
Allow admins to edit transaction amount and status, recalculate payment totals, and mark invoices fully paid when paid transactions cover the payable amount.
This commit is contained in:
@@ -46,3 +46,15 @@ exports.getMyPayments = catchAsync(async (req, res, next) => {
|
|||||||
const { data, meta } = await paymentService.getMyPayments(req.user._id, req.query);
|
const { data, meta } = await paymentService.getMyPayments(req.user._id, req.query);
|
||||||
return listResponse(res, 200, data, meta);
|
return listResponse(res, 200, data, meta);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
exports.createTransaction = catchAsync(async (req, res, next) => {
|
||||||
|
const actorId = req.user?._id;
|
||||||
|
const payment = await paymentService.addTransaction(req.params.paymentId, req.body, actorId);
|
||||||
|
return successResponse(res, 201, 'Payment transaction recorded', payment);
|
||||||
|
});
|
||||||
|
|
||||||
|
exports.updateTransaction = catchAsync(async (req, res, next) => {
|
||||||
|
const actorId = req.user?._id;
|
||||||
|
const payment = await paymentService.updateTransaction(req.params.transactionId, req.body, actorId);
|
||||||
|
return successResponse(res, 200, 'Transaction updated successfully', payment);
|
||||||
|
});
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ const paymentController = require('./paymentController');
|
|||||||
const {
|
const {
|
||||||
validateCreatePayment,
|
validateCreatePayment,
|
||||||
validateUpdatePayment,
|
validateUpdatePayment,
|
||||||
validateAddTransaction
|
validateAddTransaction,
|
||||||
|
validateUpdateTransaction
|
||||||
} = require('./paymentValidator');
|
} = require('./paymentValidator');
|
||||||
const authMiddleware = require('../../middlewares/authMiddleware');
|
const authMiddleware = require('../../middlewares/authMiddleware');
|
||||||
const perm = require('../../middlewares/permissionMiddleware');
|
const perm = require('../../middlewares/permissionMiddleware');
|
||||||
@@ -25,6 +26,8 @@ router.get('/admin/get-all', perm.requires(PERMISSIONS.PAYMENTS_READ), paymentCo
|
|||||||
router.get('/admin/search', perm.requires(PERMISSIONS.PAYMENTS_SEARCH), paymentController.search);
|
router.get('/admin/search', perm.requires(PERMISSIONS.PAYMENTS_SEARCH), paymentController.search);
|
||||||
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.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);
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -62,6 +62,43 @@ const refreshPaymentTotals = async (payment) => {
|
|||||||
return payment;
|
return payment;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const emitPaymentStatusChangedIfNeeded = (payment, previousStatus, actorId = null) => {
|
||||||
|
if (payment.status === previousStatus) return;
|
||||||
|
eventEmitter.emit(EVENT_NAMES.PAYMENT_STATUS_CHANGED, {
|
||||||
|
paymentId: payment._id,
|
||||||
|
userId: payment.user,
|
||||||
|
oldStatus: previousStatus,
|
||||||
|
newStatus: payment.status,
|
||||||
|
actorId
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const reconcilePendingTransactions = async (payment) => {
|
||||||
|
const transactions = await Transaction.find({ payment: payment._id }).lean();
|
||||||
|
const remaining = getPayableAmount(payment) - sumPaidTransactions(transactions);
|
||||||
|
const pending = await Transaction.find({ payment: payment._id, status: 'pending' }).sort({ dueDate: 1 });
|
||||||
|
|
||||||
|
if (remaining <= 0) {
|
||||||
|
await Transaction.deleteMany({ payment: payment._id, status: 'pending' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pending.length) {
|
||||||
|
pending[0].amount = remaining;
|
||||||
|
await pending[0].save();
|
||||||
|
if (pending.length > 1) {
|
||||||
|
await Transaction.deleteMany({ _id: { $in: pending.slice(1).map((row) => row._id) } });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await Transaction.create(buildTransactionPayload(payment, {
|
||||||
|
amount: remaining,
|
||||||
|
status: 'pending',
|
||||||
|
dueDate: payment.dueDate || new Date()
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
const buildTransactionPayload = (payment, trxData, actorId = null) => {
|
const buildTransactionPayload = (payment, trxData, actorId = null) => {
|
||||||
const status = trxData.status === 'pending' ? 'pending' : (trxData.status === 'paid' || trxData.date ? 'paid' : 'pending');
|
const status = trxData.status === 'pending' ? 'pending' : (trxData.status === 'paid' || trxData.date ? 'paid' : 'pending');
|
||||||
const paidDate = parseDate(trxData.date) || (status === 'paid' ? new Date() : undefined);
|
const paidDate = parseDate(trxData.date) || (status === 'paid' ? new Date() : undefined);
|
||||||
@@ -248,15 +285,7 @@ const updatePayment = async (id, body, actorId = null) => {
|
|||||||
await refreshPaymentTotals(payment);
|
await refreshPaymentTotals(payment);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (body.status && body.status !== previousStatus) {
|
emitPaymentStatusChangedIfNeeded(payment, previousStatus, actorId);
|
||||||
eventEmitter.emit(EVENT_NAMES.PAYMENT_STATUS_CHANGED, {
|
|
||||||
paymentId: payment._id,
|
|
||||||
userId: payment.user,
|
|
||||||
oldStatus: previousStatus,
|
|
||||||
newStatus: payment.status,
|
|
||||||
actorId
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return getPaymentById(payment._id);
|
return getPaymentById(payment._id);
|
||||||
};
|
};
|
||||||
@@ -274,26 +303,60 @@ const addTransaction = async (paymentId, trxData, actorId = null) => {
|
|||||||
const payment = await Payment.findById(paymentId);
|
const payment = await Payment.findById(paymentId);
|
||||||
if (!payment) throw new AppError('PAYMENT_NOT_FOUND');
|
if (!payment) throw new AppError('PAYMENT_NOT_FOUND');
|
||||||
|
|
||||||
|
const previousStatus = payment.status;
|
||||||
const payload = buildTransactionPayload(payment, { ...trxData, status: trxData.status || 'paid' }, actorId);
|
const payload = buildTransactionPayload(payment, { ...trxData, status: trxData.status || 'paid' }, actorId);
|
||||||
if (!payload.amount) throw new AppError('VALIDATION_FAILED', { amount: 'Amount is required' }, 'مبلغ تراکنش الزامی است.');
|
if (!payload.amount) throw new AppError('VALIDATION_FAILED', { amount: 'Amount is required' }, 'مبلغ تراکنش الزامی است.');
|
||||||
await Transaction.create(payload);
|
await Transaction.create(payload);
|
||||||
|
|
||||||
const remaining = getPayableAmount(payment) - sumPaidTransactions(await Transaction.find({ payment: payment._id }).lean());
|
await reconcilePendingTransactions(payment);
|
||||||
const pending = await Transaction.find({ payment: payment._id, status: 'pending' }).sort({ dueDate: 1 });
|
|
||||||
if (remaining <= 0) {
|
|
||||||
await Transaction.deleteMany({ payment: payment._id, status: 'pending' });
|
|
||||||
} else if (pending.length) {
|
|
||||||
pending[0].amount = remaining;
|
|
||||||
await pending[0].save();
|
|
||||||
if (pending.length > 1) {
|
|
||||||
await Transaction.deleteMany({ _id: { $in: pending.slice(1).map((row) => row._id) } });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await refreshPaymentTotals(payment);
|
await refreshPaymentTotals(payment);
|
||||||
|
|
||||||
|
eventEmitter.emit(EVENT_NAMES.PAYMENT_TRANSACTION_ADDED, {
|
||||||
|
paymentId: payment._id,
|
||||||
|
userId: payment.user,
|
||||||
|
actorId
|
||||||
|
});
|
||||||
|
emitPaymentStatusChangedIfNeeded(payment, previousStatus, actorId);
|
||||||
|
|
||||||
return getPaymentById(paymentId);
|
return getPaymentById(paymentId);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateTransaction = async (transactionId, body, actorId = null) => {
|
||||||
|
const trx = await Transaction.findById(transactionId);
|
||||||
|
if (!trx) throw new AppError('TRANSACTION_NOT_FOUND');
|
||||||
|
|
||||||
|
const payment = await Payment.findById(trx.payment);
|
||||||
|
if (!payment) throw new AppError('PAYMENT_NOT_FOUND');
|
||||||
|
|
||||||
|
const previousStatus = payment.status;
|
||||||
|
const merged = {
|
||||||
|
amount: body.amount !== undefined ? body.amount : trx.amount,
|
||||||
|
status: body.status !== undefined ? body.status : trx.status,
|
||||||
|
method: body.method !== undefined ? body.method : trx.method,
|
||||||
|
receiptNumber: body.receiptNumber !== undefined ? body.receiptNumber : trx.receiptNumber,
|
||||||
|
notes: body.notes !== undefined ? body.notes : trx.notes,
|
||||||
|
dueDate: body.dueDate !== undefined ? body.dueDate : trx.dueDate,
|
||||||
|
date: body.date !== undefined ? body.date : trx.date
|
||||||
|
};
|
||||||
|
|
||||||
|
const payload = buildTransactionPayload(payment, merged, actorId);
|
||||||
|
trx.amount = payload.amount;
|
||||||
|
trx.status = payload.status;
|
||||||
|
trx.method = payload.method;
|
||||||
|
trx.receiptNumber = payload.receiptNumber;
|
||||||
|
trx.notes = payload.notes;
|
||||||
|
trx.dueDate = payload.dueDate;
|
||||||
|
trx.date = payload.date;
|
||||||
|
if (actorId) trx.recordedBy = actorId;
|
||||||
|
|
||||||
|
await trx.save();
|
||||||
|
await reconcilePendingTransactions(payment);
|
||||||
|
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 });
|
||||||
};
|
};
|
||||||
@@ -306,10 +369,12 @@ module.exports = {
|
|||||||
deletePayment,
|
deletePayment,
|
||||||
searchPayments,
|
searchPayments,
|
||||||
addTransaction,
|
addTransaction,
|
||||||
|
updateTransaction,
|
||||||
getMyPayments,
|
getMyPayments,
|
||||||
createTransactionsForPayment,
|
createTransactionsForPayment,
|
||||||
refreshPaymentTotals,
|
refreshPaymentTotals,
|
||||||
buildTransactionPayload,
|
buildTransactionPayload,
|
||||||
|
reconcilePendingTransactions,
|
||||||
// Aliases for older call sites
|
// Aliases for older call sites
|
||||||
getAll: getAllPayments,
|
getAll: getAllPayments,
|
||||||
getOne: getPaymentById,
|
getOne: getPaymentById,
|
||||||
|
|||||||
@@ -1,6 +1,60 @@
|
|||||||
// Stub validator — pass-through middleware (no validation yet)
|
// /components/payments/paymentValidator.js
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const AppError = require('../../utils/AppError');
|
||||||
|
|
||||||
|
const PAYMENT_METHODS = new Set(['online', 'card', 'cash']);
|
||||||
|
const TRANSACTION_STATUSES = new Set(['pending', 'paid']);
|
||||||
|
|
||||||
const passThrough = (req, res, next) => next();
|
const passThrough = (req, res, next) => next();
|
||||||
|
|
||||||
module.exports = new Proxy({}, {
|
const validateTransactionBody = (body, { requireAmount = true } = {}) => {
|
||||||
get: () => passThrough
|
const details = {};
|
||||||
});
|
|
||||||
|
if (requireAmount && (body.amount === undefined || body.amount === null || Number(body.amount) <= 0)) {
|
||||||
|
details.amount = 'مبلغ تراکنش باید بیشتر از صفر باشد';
|
||||||
|
} else if (body.amount !== undefined && Number(body.amount) < 0) {
|
||||||
|
details.amount = 'مبلغ تراکنش نمیتواند منفی باشد';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.status !== undefined && !TRANSACTION_STATUSES.has(body.status)) {
|
||||||
|
details.status = 'وضعیت تراکنش نامعتبر است';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.method !== undefined && body.method !== '' && !PAYMENT_METHODS.has(body.method)) {
|
||||||
|
details.method = 'روش پرداخت نامعتبر است';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.notes !== undefined && String(body.notes).length > 5000) {
|
||||||
|
details.notes = 'یادداشت نباید بیش از ۵۰۰۰ کاراکتر باشد';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(details).length) {
|
||||||
|
throw new AppError('VALIDATION_FAILED', details);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateAddTransaction = (req, res, next) => {
|
||||||
|
try {
|
||||||
|
validateTransactionBody(req.body || {}, { requireAmount: true });
|
||||||
|
next();
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateUpdateTransaction = (req, res, next) => {
|
||||||
|
try {
|
||||||
|
validateTransactionBody(req.body || {}, { requireAmount: false });
|
||||||
|
next();
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
validateCreatePayment: passThrough,
|
||||||
|
validateUpdatePayment: passThrough,
|
||||||
|
validateAddTransaction,
|
||||||
|
validateUpdateTransaction
|
||||||
|
};
|
||||||
|
|||||||
@@ -54,6 +54,11 @@
|
|||||||
"en": "Payment record not found.",
|
"en": "Payment record not found.",
|
||||||
"fa": "سابقه پرداخت یافت نشد."
|
"fa": "سابقه پرداخت یافت نشد."
|
||||||
},
|
},
|
||||||
|
"TRANSACTION_NOT_FOUND": {
|
||||||
|
"statusCode": 404,
|
||||||
|
"en": "Transaction not found.",
|
||||||
|
"fa": "تراکنش یافت نشد."
|
||||||
|
},
|
||||||
"ROLE_NOT_FOUND": {
|
"ROLE_NOT_FOUND": {
|
||||||
"statusCode": 404,
|
"statusCode": 404,
|
||||||
"en": "Role not found.",
|
"en": "Role not found.",
|
||||||
|
|||||||
Reference in New Issue
Block a user