feat: add pending student registration flow and pricing APIs.
Support public class lookup, user provisioning, installment pricing, and admin approval before final enrollment.
This commit is contained in:
@@ -98,6 +98,8 @@ app.use('/api/files', fileRoutes);
|
|||||||
app.use('/api/dashboard', dashboardRoutes);
|
app.use('/api/dashboard', dashboardRoutes);
|
||||||
app.use('/api/activity-logs', activityLogRoutes);
|
app.use('/api/activity-logs', activityLogRoutes);
|
||||||
app.use('/api/contact-inquiries', contactInquiryRoutes);
|
app.use('/api/contact-inquiries', contactInquiryRoutes);
|
||||||
|
const pendingStudentRoutes = require('./components/pendingStudents/pendingStudentRoutes');
|
||||||
|
app.use('/api/pending-students', pendingStudentRoutes);
|
||||||
app.use('/api/seed', seedRoutes);
|
app.use('/api/seed', seedRoutes);
|
||||||
app.use('/api/data-import', dataImportRoutes);
|
app.use('/api/data-import', dataImportRoutes);
|
||||||
app.use('/api/settings', settingRoutes);
|
app.use('/api/settings', settingRoutes);
|
||||||
|
|||||||
@@ -49,3 +49,8 @@ exports.getPublicClasses = catchAsync(async (req, res) => {
|
|||||||
const { data, meta } = await classService.getPublicClasses(req.query);
|
const { data, meta } = await classService.getPublicClasses(req.query);
|
||||||
return listResponse(res, 200, data, meta);
|
return listResponse(res, 200, data, meta);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
exports.getPublicOne = catchAsync(async (req, res) => {
|
||||||
|
const cls = await classService.getPublicOne(req.params.id);
|
||||||
|
return successResponse(res, 200, 'Class retrieved successfully', cls);
|
||||||
|
});
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const router = express.Router();
|
|||||||
|
|
||||||
// Public Scope
|
// Public Scope
|
||||||
router.get('/user/get-all', classController.getPublicClasses);
|
router.get('/user/get-all', classController.getPublicClasses);
|
||||||
|
router.get('/user/get-one/:id', classController.getPublicOne);
|
||||||
|
|
||||||
router.use(authMiddleware);
|
router.use(authMiddleware);
|
||||||
|
|
||||||
|
|||||||
@@ -202,4 +202,19 @@ const getPublicClasses = async (query = {}) => {
|
|||||||
return { data: items.map(enrichClassForDisplay), meta: calculateMeta(total, page, limit) };
|
return { data: items.map(enrichClassForDisplay), meta: calculateMeta(total, page, limit) };
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = { getAll, getOne, create, update, remove, registerUsers, removeUser, getMyClasses, getPublicClasses };
|
const getPublicOne = async (id) => {
|
||||||
|
const cls = await Class.findOne({
|
||||||
|
_id: id,
|
||||||
|
isActive: { $ne: false },
|
||||||
|
showOnFrontend: { $ne: false }
|
||||||
|
})
|
||||||
|
.select('name course professor capacity tuitionFee hasDiscount discount freeSpots startDate endDate days startTime endTime isActive')
|
||||||
|
.populate({ path: 'course', select: 'title type description sectionCount hoursPerSection price' })
|
||||||
|
.populate({ path: 'professor', select: 'name surname' })
|
||||||
|
.lean();
|
||||||
|
|
||||||
|
if (!cls) throw new AppError('CLASS_NOT_FOUND', null, 'کلاس یافت نشد یا برای ثبتنام در دسترس نیست.');
|
||||||
|
return enrichClassForDisplay(cls);
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = { getAll, getOne, create, update, remove, registerUsers, removeUser, getMyClasses, getPublicClasses, getPublicOne };
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const catchAsync = require('../../utils/catchAsync');
|
||||||
|
const pendingStudentService = require('./pendingStudentService');
|
||||||
|
const { successResponse, listResponse } = require('../../utils/apiResponse');
|
||||||
|
|
||||||
|
exports.getPricing = catchAsync(async (req, res) => {
|
||||||
|
const preview = await pendingStudentService.getPricingPreview({
|
||||||
|
classId: req.query.classId,
|
||||||
|
type: req.query.type || 'enrollment',
|
||||||
|
paymentPlan: req.query.paymentPlan || 'full'
|
||||||
|
});
|
||||||
|
return successResponse(res, 200, 'Pricing preview retrieved successfully', preview);
|
||||||
|
});
|
||||||
|
|
||||||
|
exports.ensureUser = catchAsync(async (req, res) => {
|
||||||
|
const result = await pendingStudentService.findOrCreateUser(req.body);
|
||||||
|
return successResponse(res, 200, 'User ensured successfully', result);
|
||||||
|
});
|
||||||
|
|
||||||
|
exports.completeAfterPayment = catchAsync(async (req, res) => {
|
||||||
|
const pending = await pendingStudentService.createPendingStudentAfterPayment(req.body);
|
||||||
|
return successResponse(res, 201, 'Registration request submitted successfully', pending);
|
||||||
|
});
|
||||||
|
|
||||||
|
exports.getAll = catchAsync(async (req, res) => {
|
||||||
|
const { data, meta } = await pendingStudentService.getAllPendingStudents(req.query);
|
||||||
|
return listResponse(res, 200, data, meta);
|
||||||
|
});
|
||||||
|
|
||||||
|
exports.getOne = catchAsync(async (req, res) => {
|
||||||
|
const pending = await pendingStudentService.getPendingStudentById(req.params.id);
|
||||||
|
return successResponse(res, 200, 'Pending student retrieved successfully', pending);
|
||||||
|
});
|
||||||
|
|
||||||
|
exports.approve = catchAsync(async (req, res) => {
|
||||||
|
const result = await pendingStudentService.approvePendingStudent(req.params.id, req.user._id, req.body);
|
||||||
|
return successResponse(res, 200, 'Pending student approved successfully', result);
|
||||||
|
});
|
||||||
|
|
||||||
|
exports.reject = catchAsync(async (req, res) => {
|
||||||
|
const result = await pendingStudentService.rejectPendingStudent(req.params.id, req.user._id, req.body);
|
||||||
|
return successResponse(res, 200, 'Pending student rejected successfully', result);
|
||||||
|
});
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const mongoose = require('mongoose');
|
||||||
|
|
||||||
|
const pendingStudentSchema = new mongoose.Schema({
|
||||||
|
user: {
|
||||||
|
type: mongoose.Schema.Types.ObjectId,
|
||||||
|
ref: 'User',
|
||||||
|
required: true,
|
||||||
|
index: true
|
||||||
|
},
|
||||||
|
class: {
|
||||||
|
type: mongoose.Schema.Types.ObjectId,
|
||||||
|
ref: 'Class',
|
||||||
|
required: true,
|
||||||
|
index: true
|
||||||
|
},
|
||||||
|
course: {
|
||||||
|
type: mongoose.Schema.Types.ObjectId,
|
||||||
|
ref: 'Course',
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
type: {
|
||||||
|
type: String,
|
||||||
|
enum: ['enrollment', 'class_request'],
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
paymentPlan: {
|
||||||
|
type: String,
|
||||||
|
enum: ['full', 'installments', 'deposit'],
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
tuitionFee: { type: Number, required: true, min: 0 },
|
||||||
|
classDiscount: { type: Number, default: 0, min: 0 },
|
||||||
|
paymentDiscount: { type: Number, default: 0, min: 0 },
|
||||||
|
totalAmount: { type: Number, required: true, min: 0 },
|
||||||
|
amountDueNow: { type: Number, required: true, min: 0 },
|
||||||
|
secondInstallmentAmount: { type: Number, default: 0, min: 0 },
|
||||||
|
secondInstallmentDueDate: { type: Date },
|
||||||
|
status: {
|
||||||
|
type: String,
|
||||||
|
enum: ['pending_payment', 'pending_review', 'approved', 'rejected', 'cancelled'],
|
||||||
|
default: 'pending_review',
|
||||||
|
index: true
|
||||||
|
},
|
||||||
|
payment: {
|
||||||
|
type: mongoose.Schema.Types.ObjectId,
|
||||||
|
ref: 'Payment'
|
||||||
|
},
|
||||||
|
paymentReference: { type: String, trim: true },
|
||||||
|
adminNotes: { type: String, trim: true, maxlength: 2000 },
|
||||||
|
reviewedBy: { type: mongoose.Schema.Types.ObjectId, ref: 'User' },
|
||||||
|
reviewedAt: { type: Date }
|
||||||
|
}, {
|
||||||
|
timestamps: true
|
||||||
|
});
|
||||||
|
|
||||||
|
pendingStudentSchema.index({ user: 1, class: 1, type: 1, status: 1 });
|
||||||
|
|
||||||
|
module.exports = mongoose.model('PendingStudent', pendingStudentSchema);
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const pendingStudentController = require('./pendingStudentController');
|
||||||
|
const authMiddleware = require('../../middlewares/authMiddleware');
|
||||||
|
const perm = require('../../middlewares/permissionMiddleware');
|
||||||
|
const { PERMISSIONS } = require('../../constants/permissions');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.get('/public/pricing', pendingStudentController.getPricing);
|
||||||
|
router.post('/public/ensure-user', pendingStudentController.ensureUser);
|
||||||
|
router.post('/public/complete', pendingStudentController.completeAfterPayment);
|
||||||
|
|
||||||
|
router.use(authMiddleware);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/admin/get-all',
|
||||||
|
perm.requires(PERMISSIONS.PENDING_STUDENTS_READ),
|
||||||
|
pendingStudentController.getAll
|
||||||
|
);
|
||||||
|
router.get(
|
||||||
|
'/admin/get-one/:id',
|
||||||
|
perm.requires(PERMISSIONS.PENDING_STUDENTS_READ),
|
||||||
|
pendingStudentController.getOne
|
||||||
|
);
|
||||||
|
router.post(
|
||||||
|
'/admin/:id/approve',
|
||||||
|
perm.requires(PERMISSIONS.PENDING_STUDENTS_UPDATE),
|
||||||
|
pendingStudentController.approve
|
||||||
|
);
|
||||||
|
router.post(
|
||||||
|
'/admin/:id/reject',
|
||||||
|
perm.requires(PERMISSIONS.PENDING_STUDENTS_UPDATE),
|
||||||
|
pendingStudentController.reject
|
||||||
|
);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,355 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const PendingStudent = require('./pendingStudentModel');
|
||||||
|
const Class = require('../classes/classModel');
|
||||||
|
const User = require('../users/userModel');
|
||||||
|
const Role = require('../roles/roleModel');
|
||||||
|
const AppError = require('../../utils/AppError');
|
||||||
|
const bcrypt = require('bcryptjs');
|
||||||
|
const { calculateMeta, escapeRegex, getSearchTerm } = require('../../utils/pagination');
|
||||||
|
const { calculateRegistrationPricing } = require('../../utils/registrationPricing');
|
||||||
|
const { generateUsername, generateSimplePassword } = require('../../utils/credentials');
|
||||||
|
const { sendAccountCreatedSms } = require('../../utils/senders/smsMessages');
|
||||||
|
const { mergeFullName, normalizeGender } = require('../../utils/userProfile');
|
||||||
|
const classService = require('../classes/classService');
|
||||||
|
const paymentService = require('../payments/paymentService');
|
||||||
|
const logger = require('../../utils/logger');
|
||||||
|
const { recordAndSend } = require('../../utils/senders/notificationRecorder');
|
||||||
|
|
||||||
|
const POPULATE_LIST = [
|
||||||
|
{ path: 'user', select: 'name phoneNumber nationalIdCode email username' },
|
||||||
|
{ path: 'class', select: 'name tuitionFee startDate days startTime endTime', populate: { path: 'course', select: 'title sectionCount' } },
|
||||||
|
{ path: 'course', select: 'title sectionCount' },
|
||||||
|
{ path: 'reviewedBy', select: 'name username' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const normalizePhone = (value) => String(value || '').replace(/[\s\-()]/g, '').trim();
|
||||||
|
|
||||||
|
const isValidNationalId = (code) => {
|
||||||
|
if (!/^\d{10}$/.test(code)) return false;
|
||||||
|
if (/^(\d)\1{9}$/.test(code)) return false;
|
||||||
|
const check = Number(code[9]);
|
||||||
|
const sum = code
|
||||||
|
.split('')
|
||||||
|
.slice(0, 9)
|
||||||
|
.reduce((acc, digit, index) => acc + Number(digit) * (10 - index), 0);
|
||||||
|
const remainder = sum % 11;
|
||||||
|
return (remainder < 2 && check === remainder) || (remainder >= 2 && check === 11 - remainder);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPublicClassForRegistration = async (classId) => {
|
||||||
|
const cls = await Class.findOne({
|
||||||
|
_id: classId,
|
||||||
|
isActive: { $ne: false },
|
||||||
|
showOnFrontend: { $ne: false }
|
||||||
|
})
|
||||||
|
.select('name course professor capacity tuitionFee hasDiscount discount freeSpots startDate endDate days startTime endTime isActive')
|
||||||
|
.populate({ path: 'course', select: 'title type description sectionCount hoursPerSection price' })
|
||||||
|
.populate({ path: 'professor', select: 'name surname' })
|
||||||
|
.lean();
|
||||||
|
|
||||||
|
if (!cls) throw new AppError('CLASS_NOT_FOUND', null, 'کلاس یافت نشد یا برای ثبتنام در دسترس نیست.');
|
||||||
|
return cls;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPricingPreview = async ({ classId, type = 'enrollment', paymentPlan = 'full' }) => {
|
||||||
|
const cls = await getPublicClassForRegistration(classId);
|
||||||
|
const pricing = calculateRegistrationPricing(cls, cls.course || {}, { type, paymentPlan });
|
||||||
|
return {
|
||||||
|
class: {
|
||||||
|
_id: cls._id,
|
||||||
|
name: cls.name,
|
||||||
|
course: cls.course,
|
||||||
|
professor: cls.professor,
|
||||||
|
startDate: cls.startDate,
|
||||||
|
days: cls.days,
|
||||||
|
startTime: cls.startTime,
|
||||||
|
endTime: cls.endTime,
|
||||||
|
finalTuitionFee: pricing.tuitionFee
|
||||||
|
},
|
||||||
|
pricing
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const allocateUniqueUsername = async () => {
|
||||||
|
for (let attempt = 0; attempt < 12; attempt += 1) {
|
||||||
|
const username = generateUsername();
|
||||||
|
const exists = await User.exists({ username });
|
||||||
|
if (!exists) return username;
|
||||||
|
}
|
||||||
|
throw new AppError('INTERNAL_SERVER_ERROR', null, 'Could not generate a unique username');
|
||||||
|
};
|
||||||
|
|
||||||
|
const findOrCreateUser = async (body) => {
|
||||||
|
const name = mergeFullName(body.name, body.surname);
|
||||||
|
const nationalIdCode = String(body.nationalIdCode || body.nationalId || '').trim();
|
||||||
|
const phoneNumber = normalizePhone(body.phoneNumber || body.phone);
|
||||||
|
const email = body.email ? String(body.email).trim().toLowerCase() : undefined;
|
||||||
|
const gender = normalizeGender(body.gender);
|
||||||
|
|
||||||
|
if (!name) throw new AppError('VALIDATION_FAILED', { name: 'نام و نام خانوادگی الزامی است' });
|
||||||
|
if (!nationalIdCode) throw new AppError('VALIDATION_FAILED', { nationalIdCode: 'کد ملی الزامی است' });
|
||||||
|
if (!isValidNationalId(nationalIdCode)) {
|
||||||
|
throw new AppError('VALIDATION_FAILED', { nationalIdCode: 'کد ملی معتبر نیست' });
|
||||||
|
}
|
||||||
|
if (!phoneNumber) throw new AppError('VALIDATION_FAILED', { phoneNumber: 'شماره موبایل الزامی است' });
|
||||||
|
if (!/^(0?9\d{9}|\+989\d{9})$/.test(phoneNumber)) {
|
||||||
|
throw new AppError('VALIDATION_FAILED', { phoneNumber: 'شماره موبایل معتبر نیست' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedPhone = phoneNumber.startsWith('+98')
|
||||||
|
? `0${phoneNumber.slice(3)}`
|
||||||
|
: phoneNumber.startsWith('9') && phoneNumber.length === 10
|
||||||
|
? `0${phoneNumber}`
|
||||||
|
: phoneNumber;
|
||||||
|
|
||||||
|
let user = await User.findOne({
|
||||||
|
$or: [{ phoneNumber: normalizedPhone }, { nationalIdCode }]
|
||||||
|
});
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
const updates = {};
|
||||||
|
if (user.name !== name) updates.name = name;
|
||||||
|
if (email && user.email !== email) updates.email = email;
|
||||||
|
if (gender && user.gender !== gender) updates.gender = gender;
|
||||||
|
if (user.phoneNumber !== normalizedPhone) updates.phoneNumber = normalizedPhone;
|
||||||
|
if (Object.keys(updates).length) {
|
||||||
|
user = await User.findByIdAndUpdate(user._id, updates, { new: true, runValidators: true });
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
user: {
|
||||||
|
_id: user._id,
|
||||||
|
name: user.name,
|
||||||
|
phoneNumber: user.phoneNumber,
|
||||||
|
nationalIdCode: user.nationalIdCode,
|
||||||
|
email: user.email
|
||||||
|
},
|
||||||
|
isNewUser: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const userRole = await Role.findOne({ name: 'User' });
|
||||||
|
if (!userRole) throw new AppError('DEFAULT_ROLE_NOT_FOUND');
|
||||||
|
|
||||||
|
const username = await allocateUniqueUsername();
|
||||||
|
const plainPassword = generateSimplePassword();
|
||||||
|
const passwordHash = await bcrypt.hash(plainPassword, 10);
|
||||||
|
|
||||||
|
user = await User.create({
|
||||||
|
name,
|
||||||
|
nationalIdCode,
|
||||||
|
phoneNumber: normalizedPhone,
|
||||||
|
email,
|
||||||
|
gender,
|
||||||
|
username,
|
||||||
|
passwordHash,
|
||||||
|
role: userRole._id,
|
||||||
|
preferredMessenger: ['SMS']
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sendAccountCreatedSms(normalizedPhone, username, plainPassword, user._id);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`[findOrCreateUser] Account SMS failed for ${normalizedPhone}: ${err.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
user: {
|
||||||
|
_id: user._id,
|
||||||
|
name: user.name,
|
||||||
|
phoneNumber: user.phoneNumber,
|
||||||
|
nationalIdCode: user.nationalIdCode,
|
||||||
|
email: user.email
|
||||||
|
},
|
||||||
|
isNewUser: true
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const createPendingStudentAfterPayment = async (body) => {
|
||||||
|
const {
|
||||||
|
userId,
|
||||||
|
classId,
|
||||||
|
type = 'enrollment',
|
||||||
|
paymentPlan = 'full',
|
||||||
|
paymentReference
|
||||||
|
} = body;
|
||||||
|
|
||||||
|
if (!userId || !classId) {
|
||||||
|
throw new AppError('VALIDATION_FAILED', { form: 'اطلاعات ثبتنام ناقص است' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const [user, cls] = await Promise.all([
|
||||||
|
User.findById(userId).select('_id name phoneNumber').lean(),
|
||||||
|
getPublicClassForRegistration(classId)
|
||||||
|
]);
|
||||||
|
if (!user) throw new AppError('USER_NOT_FOUND');
|
||||||
|
|
||||||
|
const resolvedPlan = type === 'class_request' ? 'deposit' : paymentPlan;
|
||||||
|
const pricing = calculateRegistrationPricing(cls, cls.course || {}, { type, paymentPlan: resolvedPlan });
|
||||||
|
|
||||||
|
const existing = await PendingStudent.findOne({
|
||||||
|
user: userId,
|
||||||
|
class: classId,
|
||||||
|
type,
|
||||||
|
status: { $in: ['pending_payment', 'pending_review'] }
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
throw new AppError('DUPLICATE_KEY', null, 'درخواست فعال برای این کلاس از قبل ثبت شده است.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const pending = await PendingStudent.create({
|
||||||
|
user: userId,
|
||||||
|
class: classId,
|
||||||
|
course: cls.course._id,
|
||||||
|
type,
|
||||||
|
paymentPlan: pricing.paymentPlan,
|
||||||
|
tuitionFee: pricing.tuitionFee,
|
||||||
|
classDiscount: pricing.classDiscount,
|
||||||
|
paymentDiscount: pricing.paymentDiscount,
|
||||||
|
totalAmount: pricing.totalAmount,
|
||||||
|
amountDueNow: pricing.amountDueNow,
|
||||||
|
secondInstallmentAmount: pricing.secondInstallmentAmount,
|
||||||
|
secondInstallmentDueDate: pricing.secondInstallmentDueDate,
|
||||||
|
status: 'pending_review',
|
||||||
|
paymentReference: paymentReference || undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
return PendingStudent.findById(pending._id).populate(POPULATE_LIST).lean();
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAllPendingStudents = async (query = {}) => {
|
||||||
|
const page = parseInt(query.page, 10) || 1;
|
||||||
|
const limit = Math.min(parseInt(query.limit, 10) || 20, 200);
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
|
const filter = {};
|
||||||
|
if (query.status) filter.status = query.status;
|
||||||
|
if (query.type) filter.type = query.type;
|
||||||
|
if (query.classId) filter.class = query.classId;
|
||||||
|
|
||||||
|
const searchTerm = getSearchTerm(query);
|
||||||
|
if (searchTerm) {
|
||||||
|
const searchRegex = new RegExp(escapeRegex(searchTerm), 'i');
|
||||||
|
const matchedUsers = await User.find({
|
||||||
|
$or: [
|
||||||
|
{ name: searchRegex },
|
||||||
|
{ phoneNumber: searchRegex },
|
||||||
|
{ nationalIdCode: searchRegex }
|
||||||
|
]
|
||||||
|
}).select('_id').lean();
|
||||||
|
filter.$or = [
|
||||||
|
{ adminNotes: searchRegex },
|
||||||
|
{ user: { $in: matchedUsers.map((u) => u._id) } }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const [items, total] = await Promise.all([
|
||||||
|
PendingStudent.find(filter)
|
||||||
|
.populate(POPULATE_LIST)
|
||||||
|
.skip(skip)
|
||||||
|
.limit(limit)
|
||||||
|
.sort({ createdAt: -1 })
|
||||||
|
.lean(),
|
||||||
|
PendingStudent.countDocuments(filter)
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { data: items, meta: calculateMeta(total, page, limit) };
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPendingStudentById = async (id) => {
|
||||||
|
const pending = await PendingStudent.findById(id).populate(POPULATE_LIST).lean();
|
||||||
|
if (!pending) throw new AppError('NOT_FOUND', null, 'درخواست در انتظار یافت نشد.');
|
||||||
|
return pending;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendClassRequestApprovedSms = async (user, courseTitle) => {
|
||||||
|
if (!user?.phoneNumber) return;
|
||||||
|
const body = `درخواست تشکیل کلاس جدید برای «${courseTitle}» تأیید شد. بهزودی با شما هماهنگ میکنیم.`;
|
||||||
|
await recordAndSend({
|
||||||
|
userId: user._id,
|
||||||
|
channel: 'sms',
|
||||||
|
subject: 'تأیید درخواست کلاس',
|
||||||
|
body,
|
||||||
|
relatedEvent: 'class_request.approved',
|
||||||
|
sendFn: async () => {
|
||||||
|
logger.info(`[class_request.approved] SMS queued for ${user.phoneNumber}: ${body}`);
|
||||||
|
return { skipped: true, reason: 'template_pending' };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const approvePendingStudent = async (id, actorId, body = {}) => {
|
||||||
|
const pending = await PendingStudent.findById(id)
|
||||||
|
.populate({ path: 'user', select: 'name phoneNumber' })
|
||||||
|
.populate({ path: 'class', select: 'name course', populate: { path: 'course', select: 'title' } })
|
||||||
|
.populate({ path: 'course', select: 'title' });
|
||||||
|
|
||||||
|
if (!pending) throw new AppError('NOT_FOUND', null, 'درخواست در انتظار یافت نشد.');
|
||||||
|
if (!['pending_review', 'pending_payment'].includes(pending.status)) {
|
||||||
|
throw new AppError('VALIDATION_FAILED', null, 'این درخواست قابل تأیید نیست.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pending.type === 'enrollment') {
|
||||||
|
await classService.registerUsers(pending.class._id, [pending.user._id], { sendSms: true });
|
||||||
|
|
||||||
|
const transactions = [{
|
||||||
|
amount: pending.amountDueNow,
|
||||||
|
status: 'paid',
|
||||||
|
method: 'online',
|
||||||
|
date: new Date(),
|
||||||
|
notes: 'پرداخت اولیه — تأیید توسط آموزشگاه'
|
||||||
|
}];
|
||||||
|
|
||||||
|
if (pending.paymentPlan === 'installments' && pending.secondInstallmentAmount > 0) {
|
||||||
|
transactions.push({
|
||||||
|
amount: pending.secondInstallmentAmount,
|
||||||
|
status: 'pending',
|
||||||
|
dueDate: pending.secondInstallmentDueDate || new Date(),
|
||||||
|
notes: 'قسط دوم'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await paymentService.createPayment({
|
||||||
|
user: pending.user._id,
|
||||||
|
classes: [pending.class._id],
|
||||||
|
course: pending.course,
|
||||||
|
amount: pending.paymentPlan === 'installments' ? pending.totalAmount : pending.tuitionFee,
|
||||||
|
discount: pending.paymentDiscount,
|
||||||
|
notes: body.adminNotes || pending.adminNotes || '',
|
||||||
|
transactions,
|
||||||
|
sendSms: false
|
||||||
|
}, actorId);
|
||||||
|
} else {
|
||||||
|
const courseTitle = pending.course?.title || pending.class?.course?.title || pending.class?.name || 'دوره';
|
||||||
|
await sendClassRequestApprovedSms(pending.user, courseTitle);
|
||||||
|
}
|
||||||
|
|
||||||
|
await PendingStudent.findByIdAndDelete(id);
|
||||||
|
|
||||||
|
return { approved: true, type: pending.type };
|
||||||
|
};
|
||||||
|
|
||||||
|
const rejectPendingStudent = async (id, actorId, body = {}) => {
|
||||||
|
const pending = await PendingStudent.findById(id);
|
||||||
|
if (!pending) throw new AppError('NOT_FOUND', null, 'درخواست در انتظار یافت نشد.');
|
||||||
|
if (!['pending_review', 'pending_payment'].includes(pending.status)) {
|
||||||
|
throw new AppError('VALIDATION_FAILED', null, 'این درخواست قابل رد کردن نیست.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.adminNotes != null) {
|
||||||
|
logger.info(`[rejectPendingStudent] ${id} by ${actorId}: ${String(body.adminNotes).trim()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await PendingStudent.findByIdAndDelete(id);
|
||||||
|
return { rejected: true };
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getPricingPreview,
|
||||||
|
findOrCreateUser,
|
||||||
|
createPendingStudentAfterPayment,
|
||||||
|
getAllPendingStudents,
|
||||||
|
getPendingStudentById,
|
||||||
|
approvePendingStudent,
|
||||||
|
rejectPendingStudent
|
||||||
|
};
|
||||||
@@ -86,7 +86,11 @@ const PERMISSIONS = {
|
|||||||
|
|
||||||
// Contact inquiries (website form)
|
// Contact inquiries (website form)
|
||||||
CONTACT_INQUIRIES_READ: 'contact_inquiries:read',
|
CONTACT_INQUIRIES_READ: 'contact_inquiries:read',
|
||||||
CONTACT_INQUIRIES_UPDATE: 'contact_inquiries:update'
|
CONTACT_INQUIRIES_UPDATE: 'contact_inquiries:update',
|
||||||
|
|
||||||
|
// Pending student registrations
|
||||||
|
PENDING_STUDENTS_READ: 'pending_students:read',
|
||||||
|
PENDING_STUDENTS_UPDATE: 'pending_students:update'
|
||||||
};
|
};
|
||||||
|
|
||||||
const ALL_PERMISSIONS = Object.values(PERMISSIONS);
|
const ALL_PERMISSIONS = Object.values(PERMISSIONS);
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const FULL_PAYMENT_DISCOUNT = 500_000;
|
||||||
|
const CLASS_REQUEST_DEPOSIT_RATE = 0.10;
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
FULL_PAYMENT_DISCOUNT,
|
||||||
|
CLASS_REQUEST_DEPOSIT_RATE
|
||||||
|
};
|
||||||
@@ -55,7 +55,9 @@ const defaultRoles = [
|
|||||||
PERMISSIONS.LOGS_READ,
|
PERMISSIONS.LOGS_READ,
|
||||||
PERMISSIONS.LOGS_SEARCH,
|
PERMISSIONS.LOGS_SEARCH,
|
||||||
PERMISSIONS.CONTACT_INQUIRIES_READ,
|
PERMISSIONS.CONTACT_INQUIRIES_READ,
|
||||||
PERMISSIONS.CONTACT_INQUIRIES_UPDATE
|
PERMISSIONS.CONTACT_INQUIRIES_UPDATE,
|
||||||
|
PERMISSIONS.PENDING_STUDENTS_READ,
|
||||||
|
PERMISSIONS.PENDING_STUDENTS_UPDATE
|
||||||
],
|
],
|
||||||
isSystem: true
|
isSystem: true
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { FULL_PAYMENT_DISCOUNT, CLASS_REQUEST_DEPOSIT_RATE } = require('../constants/registration');
|
||||||
|
|
||||||
|
const getFinalTuition = (cls = {}) => {
|
||||||
|
const tuitionFee = Math.max(0, Number(cls.tuitionFee) || 0);
|
||||||
|
const classDiscount = cls.hasDiscount ? Math.max(0, Number(cls.discount) || 0) : 0;
|
||||||
|
return Math.max(0, tuitionFee - Math.min(classDiscount, tuitionFee));
|
||||||
|
};
|
||||||
|
|
||||||
|
const calculateMidpointDueDate = (cls = {}, course = {}) => {
|
||||||
|
const startDate = cls.startDate ? new Date(cls.startDate) : null;
|
||||||
|
if (!startDate || Number.isNaN(startDate.getTime())) return null;
|
||||||
|
|
||||||
|
const sectionCount = Math.max(1, Number(course.sectionCount) || 1);
|
||||||
|
const sessionsPerWeek = Math.max(1, (cls.days || []).length || 1);
|
||||||
|
const totalWeeks = sectionCount / sessionsPerWeek;
|
||||||
|
const weeksUntilDue = totalWeeks / 2;
|
||||||
|
|
||||||
|
const due = new Date(startDate);
|
||||||
|
due.setDate(due.getDate() + Math.round(weeksUntilDue * 7));
|
||||||
|
return due;
|
||||||
|
};
|
||||||
|
|
||||||
|
const calculateEnrollmentPricing = (cls = {}, course = {}, paymentPlan = 'full') => {
|
||||||
|
const tuitionFee = getFinalTuition(cls);
|
||||||
|
|
||||||
|
if (paymentPlan === 'installments') {
|
||||||
|
const totalAmount = tuitionFee;
|
||||||
|
const amountDueNow = Math.ceil(totalAmount / 2);
|
||||||
|
const secondInstallmentAmount = Math.max(0, totalAmount - amountDueNow);
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'enrollment',
|
||||||
|
paymentPlan: 'installments',
|
||||||
|
tuitionFee,
|
||||||
|
classDiscount: cls.hasDiscount ? Math.max(0, Number(cls.discount) || 0) : 0,
|
||||||
|
paymentDiscount: 0,
|
||||||
|
totalAmount,
|
||||||
|
amountDueNow,
|
||||||
|
secondInstallmentAmount,
|
||||||
|
secondInstallmentDueDate: calculateMidpointDueDate(cls, course)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const paymentDiscount = Math.min(FULL_PAYMENT_DISCOUNT, tuitionFee);
|
||||||
|
const totalAmount = Math.max(0, tuitionFee - paymentDiscount);
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'enrollment',
|
||||||
|
paymentPlan: 'full',
|
||||||
|
tuitionFee,
|
||||||
|
classDiscount: cls.hasDiscount ? Math.max(0, Number(cls.discount) || 0) : 0,
|
||||||
|
paymentDiscount,
|
||||||
|
totalAmount,
|
||||||
|
amountDueNow: totalAmount,
|
||||||
|
secondInstallmentAmount: 0,
|
||||||
|
secondInstallmentDueDate: null
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const calculateClassRequestPricing = (cls = {}) => {
|
||||||
|
const tuitionFee = getFinalTuition(cls);
|
||||||
|
const amountDueNow = Math.ceil(tuitionFee * CLASS_REQUEST_DEPOSIT_RATE);
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'class_request',
|
||||||
|
paymentPlan: 'deposit',
|
||||||
|
tuitionFee,
|
||||||
|
classDiscount: cls.hasDiscount ? Math.max(0, Number(cls.discount) || 0) : 0,
|
||||||
|
paymentDiscount: 0,
|
||||||
|
totalAmount: amountDueNow,
|
||||||
|
amountDueNow,
|
||||||
|
secondInstallmentAmount: 0,
|
||||||
|
secondInstallmentDueDate: null
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const calculateRegistrationPricing = (cls = {}, course = {}, { type = 'enrollment', paymentPlan = 'full' } = {}) => {
|
||||||
|
if (type === 'class_request') {
|
||||||
|
return calculateClassRequestPricing(cls);
|
||||||
|
}
|
||||||
|
return calculateEnrollmentPricing(cls, course, paymentPlan);
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
FULL_PAYMENT_DISCOUNT,
|
||||||
|
CLASS_REQUEST_DEPOSIT_RATE,
|
||||||
|
getFinalTuition,
|
||||||
|
calculateMidpointDueDate,
|
||||||
|
calculateEnrollmentPricing,
|
||||||
|
calculateClassRequestPricing,
|
||||||
|
calculateRegistrationPricing
|
||||||
|
};
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const {
|
||||||
|
calculateEnrollmentPricing,
|
||||||
|
calculateClassRequestPricing,
|
||||||
|
calculateMidpointDueDate
|
||||||
|
} = require('./registrationPricing');
|
||||||
|
|
||||||
|
test('full payment applies 500k discount', () => {
|
||||||
|
const result = calculateEnrollmentPricing(
|
||||||
|
{ tuitionFee: 5_000_000, hasDiscount: false },
|
||||||
|
{ sectionCount: 16 },
|
||||||
|
'full'
|
||||||
|
);
|
||||||
|
assert.equal(result.totalAmount, 4_500_000);
|
||||||
|
assert.equal(result.paymentDiscount, 500_000);
|
||||||
|
assert.equal(result.amountDueNow, 4_500_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('installments charge full tuition with half due now', () => {
|
||||||
|
const result = calculateEnrollmentPricing(
|
||||||
|
{ tuitionFee: 4_000_000, hasDiscount: false, startDate: '2026-09-01', days: [2, 4] },
|
||||||
|
{ sectionCount: 16 },
|
||||||
|
'installments'
|
||||||
|
);
|
||||||
|
assert.equal(result.totalAmount, 4_000_000);
|
||||||
|
assert.equal(result.amountDueNow, 2_000_000);
|
||||||
|
assert.equal(result.secondInstallmentAmount, 2_000_000);
|
||||||
|
assert.ok(result.secondInstallmentDueDate);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('class request deposit is 10 percent', () => {
|
||||||
|
const result = calculateClassRequestPricing({ tuitionFee: 3_000_000, hasDiscount: false });
|
||||||
|
assert.equal(result.amountDueNow, 300_000);
|
||||||
|
assert.equal(result.paymentPlan, 'deposit');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('midpoint due date is halfway through course duration', () => {
|
||||||
|
const due = calculateMidpointDueDate(
|
||||||
|
{ startDate: '2026-01-01', days: [2, 4] },
|
||||||
|
{ sectionCount: 16 }
|
||||||
|
);
|
||||||
|
assert.equal(due.toISOString().slice(0, 10), '2026-01-29');
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user