feat(payments): add bulk class payments creation and duplicate check
This commit is contained in:
@@ -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;
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user