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);
|
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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user