diff --git a/components/payments/bulkPayment.test.js b/components/payments/bulkPayment.test.js new file mode 100644 index 0000000..d4288a1 --- /dev/null +++ b/components/payments/bulkPayment.test.js @@ -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; + } + ); + }); +}); diff --git a/components/payments/paymentController.js b/components/payments/paymentController.js index bc43ad1..b9db0c7 100644 --- a/components/payments/paymentController.js +++ b/components/payments/paymentController.js @@ -10,6 +10,17 @@ exports.create = catchAsync(async (req, res, next) => { 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) => { const payment = await paymentService.getPaymentById(req.params.id); return successResponse(res, 200, 'Payment retrieved successfully', payment); diff --git a/components/payments/paymentRoutes.js b/components/payments/paymentRoutes.js index 8f3895e..c205c67 100644 --- a/components/payments/paymentRoutes.js +++ b/components/payments/paymentRoutes.js @@ -22,6 +22,8 @@ router.post('/user/pay/:id', validateAddTransaction, paymentController.payUser); // Admin Scope 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/search', perm.requires(PERMISSIONS.PAYMENTS_SEARCH), paymentController.search); router.get('/admin/get-one/:id', perm.requires(PERMISSIONS.PAYMENTS_READ), paymentController.getOne); diff --git a/components/payments/paymentService.js b/components/payments/paymentService.js index 98f2826..0971ff8 100644 --- a/components/payments/paymentService.js +++ b/components/payments/paymentService.js @@ -258,6 +258,13 @@ const getAllPayments = async (query) => { } if (query.userId) filter.user = query.userId; 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); if (searchTerm) { @@ -482,6 +489,127 @@ const cancelTransaction = async (transactionId, actorId = null) => { 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 = {}) => { return getAllPayments({ ...query, userId }); }; @@ -490,6 +618,8 @@ module.exports = { getAllPayments, getPaymentById, createPayment, + createBulkClassPayments, + checkDuplicatePayment, updatePayment, deletePayment, searchPayments, diff --git a/package.json b/package.json index 24311c0..611f511 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "start": "node app.js", "dev": "nodemon app.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": [ "express",