feat(payments): add bulk class payments creation and duplicate check

This commit is contained in:
2026-08-23 13:28:06 +03:30
parent 4c07ca2ae1
commit 28d401fd0e
5 changed files with 177 additions and 1 deletions
+33
View File
@@ -0,0 +1,33 @@
'use strict';
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const paymentService = require('./paymentService');
describe('Bulk Payment and Duplicate Check Service', () => {
it('exports createBulkClassPayments and checkDuplicatePayment', () => {
assert.equal(typeof paymentService.createBulkClassPayments, 'function');
assert.equal(typeof paymentService.checkDuplicatePayment, 'function');
});
it('checkDuplicatePayment returns false when userId is not provided', async () => {
const result = await paymentService.checkDuplicatePayment({});
assert.deepEqual(result, {
hasDuplicate: false,
count: 0,
payments: []
});
});
it('createBulkClassPayments throws error when classId is missing', async () => {
await assert.rejects(
async () => {
await paymentService.createBulkClassPayments({});
},
(err) => {
assert.equal(err.errorCode, 'VALIDATION_FAILED');
return true;
}
);
});
});
+11
View File
@@ -10,6 +10,17 @@ exports.create = catchAsync(async (req, res, next) => {
return successResponse(res, 201, 'Payment created successfully', payment); return successResponse(res, 201, 'Payment created successfully', payment);
}); });
exports.createBulkClass = catchAsync(async (req, res, next) => {
const actorId = req.user?._id;
const result = await paymentService.createBulkClassPayments(req.body, actorId);
return successResponse(res, 201, 'Bulk payments created successfully', result);
});
exports.checkDuplicate = catchAsync(async (req, res, next) => {
const result = await paymentService.checkDuplicatePayment(req.query);
return successResponse(res, 200, 'Duplicate check completed', result);
});
exports.getOne = catchAsync(async (req, res, next) => { exports.getOne = catchAsync(async (req, res, next) => {
const payment = await paymentService.getPaymentById(req.params.id); const payment = await paymentService.getPaymentById(req.params.id);
return successResponse(res, 200, 'Payment retrieved successfully', payment); return successResponse(res, 200, 'Payment retrieved successfully', payment);
+2
View File
@@ -22,6 +22,8 @@ router.post('/user/pay/:id', validateAddTransaction, paymentController.payUser);
// Admin Scope // Admin Scope
router.post('/admin/create', perm.requires(PERMISSIONS.PAYMENTS_CREATE), validateCreatePayment, paymentController.create); router.post('/admin/create', perm.requires(PERMISSIONS.PAYMENTS_CREATE), validateCreatePayment, paymentController.create);
router.post('/admin/bulk-class', perm.requires(PERMISSIONS.PAYMENTS_CREATE), paymentController.createBulkClass);
router.get('/admin/check-duplicate', perm.requires(PERMISSIONS.PAYMENTS_READ), paymentController.checkDuplicate);
router.get('/admin/get-all', perm.requires(PERMISSIONS.PAYMENTS_READ), paymentController.getAll); router.get('/admin/get-all', perm.requires(PERMISSIONS.PAYMENTS_READ), paymentController.getAll);
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);
+130
View File
@@ -258,6 +258,13 @@ const getAllPayments = async (query) => {
} }
if (query.userId) filter.user = query.userId; if (query.userId) filter.user = query.userId;
if (query.status) filter.status = query.status; if (query.status) filter.status = query.status;
if (query.classId) filter.classes = query.classId;
if (query.classes) {
const classList = Array.isArray(query.classes)
? query.classes
: query.classes.split(',').map((s) => s.trim()).filter(Boolean);
if (classList.length) filter.classes = { $in: classList };
}
const searchTerm = getSearchTerm(query); const searchTerm = getSearchTerm(query);
if (searchTerm) { if (searchTerm) {
@@ -482,6 +489,127 @@ const cancelTransaction = async (transactionId, actorId = null) => {
return getPaymentById(payment._id); return getPaymentById(payment._id);
}; };
const createBulkClassPayments = async (body, actorId = null) => {
const { classId } = body;
if (!classId) {
throw new AppError('VALIDATION_FAILED', { classId: 'Class ID is required' }, 'شناسه کلاس الزامی است.');
}
const classDoc = await Class.findById(classId).populate('course', 'title price').lean();
if (!classDoc) {
throw new AppError('CLASS_NOT_FOUND', {}, 'کلاس مورد نظر یافت نشد.');
}
const allStudentIds = (classDoc.students || []).map((s) => String(s._id || s.id || s));
const targetStudentIds = (body.studentIds && Array.isArray(body.studentIds) && body.studentIds.length > 0)
? body.studentIds.map(String).filter((id) => allStudentIds.includes(id))
: allStudentIds;
if (!targetStudentIds.length) {
return {
message: 'هیچ دانشجویی در این کلاس ثبت‌نام نشده است.',
totalStudents: 0,
createdCount: 0,
skippedCount: 0,
payments: []
};
}
const skipExisting = body.skipExisting !== false;
let existingUserIds = new Set();
if (skipExisting) {
const existing = await Payment.find({
classes: classId,
user: { $in: targetStudentIds },
isDeleted: { $ne: true }
}).select('user').lean();
existingUserIds = new Set(existing.map((p) => String(p.user)));
}
const amount = body.amount !== undefined && body.amount !== null && body.amount !== ''
? Number(body.amount)
: (classDoc.tuitionFee || classDoc.course?.price || 0);
const discount = body.discount !== undefined && body.discount !== null && body.discount !== ''
? Number(body.discount)
: (classDoc.hasDiscount ? (classDoc.discount || 0) : 0);
const dueDate = parseDate(body.dueDate) || classDoc.startDate || new Date();
const notify = body.notify || {};
const notifySms = body.notifySms;
const notifyEmail = body.notifyEmail;
const notifyBot = body.notifyBot;
const createdPayments = [];
let skippedCount = 0;
for (const studentId of targetStudentIds) {
if (skipExisting && existingUserIds.has(studentId)) {
skippedCount++;
continue;
}
const payment = await createPayment({
user: studentId,
classes: [classId],
course: classDoc.course?._id || classDoc.course,
amount,
discount,
dueDate,
notes: body.notes,
notify,
notifySms,
notifyEmail,
notifyBot
}, actorId);
createdPayments.push(payment);
}
return {
totalStudents: targetStudentIds.length,
createdCount: createdPayments.length,
skippedCount,
payments: createdPayments
};
};
const checkDuplicatePayment = async (query = {}) => {
const { userId, classId, classes } = query;
if (!userId) {
return { hasDuplicate: false, count: 0, payments: [] };
}
const filter = {
user: userId,
isDeleted: { $ne: true }
};
const targetClasses = [];
if (classId) targetClasses.push(classId);
if (classes) {
if (Array.isArray(classes)) targetClasses.push(...classes);
else targetClasses.push(...String(classes).split(',').map((s) => s.trim()).filter(Boolean));
}
if (targetClasses.length) {
filter.classes = { $in: targetClasses };
}
const payments = await Payment.find(filter)
.populate({ path: 'classes', select: 'name' })
.populate({ path: 'course', select: 'title' })
.select('_id uniqueCode amount discount paidAmount status classes course createdAt')
.lean();
return {
hasDuplicate: payments.length > 0,
count: payments.length,
payments
};
};
const getMyPayments = async (userId, query = {}) => { const getMyPayments = async (userId, query = {}) => {
return getAllPayments({ ...query, userId }); return getAllPayments({ ...query, userId });
}; };
@@ -490,6 +618,8 @@ module.exports = {
getAllPayments, getAllPayments,
getPaymentById, getPaymentById,
createPayment, createPayment,
createBulkClassPayments,
checkDuplicatePayment,
updatePayment, updatePayment,
deletePayment, deletePayment,
searchPayments, searchPayments,
+1 -1
View File
@@ -7,7 +7,7 @@
"start": "node app.js", "start": "node app.js",
"dev": "nodemon app.js", "dev": "nodemon app.js",
"seed": "node seed.js", "seed": "node seed.js",
"test": "node --test components/classes/classSoftDelete.test.js components/settings/smsTemplates.test.js components/users/passwordReset.test.js components/dataImport/importHelpers.test.js utils/classSchedule.test.js utils/jalaliDate.test.js utils/messagingChannels.test.js utils/notifyFlags.test.js utils/passwordRules.test.js utils/paymentAmount.test.js utils/professorShare.test.js utils/financialRange.test.js" "test": "node --test components/payments/bulkPayment.test.js components/classes/classSoftDelete.test.js components/settings/smsTemplates.test.js components/users/passwordReset.test.js components/dataImport/importHelpers.test.js utils/classSchedule.test.js utils/jalaliDate.test.js utils/messagingChannels.test.js utils/notifyFlags.test.js utils/passwordRules.test.js utils/paymentAmount.test.js utils/professorShare.test.js utils/financialRange.test.js"
}, },
"keywords": [ "keywords": [
"express", "express",