diff --git a/app.js b/app.js index 00905cc..b35cb07 100644 --- a/app.js +++ b/app.js @@ -98,6 +98,8 @@ app.use('/api/files', fileRoutes); app.use('/api/dashboard', dashboardRoutes); app.use('/api/activity-logs', activityLogRoutes); 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/data-import', dataImportRoutes); app.use('/api/settings', settingRoutes); diff --git a/components/classes/classController.js b/components/classes/classController.js index 0a56bd0..8dd9062 100644 --- a/components/classes/classController.js +++ b/components/classes/classController.js @@ -49,3 +49,8 @@ exports.getPublicClasses = catchAsync(async (req, res) => { const { data, meta } = await classService.getPublicClasses(req.query); 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); +}); diff --git a/components/classes/classRoutes.js b/components/classes/classRoutes.js index 7d7b617..8181bc0 100644 --- a/components/classes/classRoutes.js +++ b/components/classes/classRoutes.js @@ -11,6 +11,7 @@ const router = express.Router(); // Public Scope router.get('/user/get-all', classController.getPublicClasses); +router.get('/user/get-one/:id', classController.getPublicOne); router.use(authMiddleware); diff --git a/components/classes/classService.js b/components/classes/classService.js index 7b04b24..abb178e 100644 --- a/components/classes/classService.js +++ b/components/classes/classService.js @@ -202,4 +202,19 @@ const getPublicClasses = async (query = {}) => { 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 }; diff --git a/components/pendingStudents/pendingStudentController.js b/components/pendingStudents/pendingStudentController.js new file mode 100644 index 0000000..5b3a2a9 --- /dev/null +++ b/components/pendingStudents/pendingStudentController.js @@ -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); +}); diff --git a/components/pendingStudents/pendingStudentModel.js b/components/pendingStudents/pendingStudentModel.js new file mode 100644 index 0000000..e7d560d --- /dev/null +++ b/components/pendingStudents/pendingStudentModel.js @@ -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); diff --git a/components/pendingStudents/pendingStudentRoutes.js b/components/pendingStudents/pendingStudentRoutes.js new file mode 100644 index 0000000..d391fab --- /dev/null +++ b/components/pendingStudents/pendingStudentRoutes.js @@ -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; diff --git a/components/pendingStudents/pendingStudentService.js b/components/pendingStudents/pendingStudentService.js new file mode 100644 index 0000000..9028ed8 --- /dev/null +++ b/components/pendingStudents/pendingStudentService.js @@ -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 +}; diff --git a/constants/permissions.js b/constants/permissions.js index 4c935d8..625f87a 100644 --- a/constants/permissions.js +++ b/constants/permissions.js @@ -86,7 +86,11 @@ const PERMISSIONS = { // Contact inquiries (website form) 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); diff --git a/constants/registration.js b/constants/registration.js new file mode 100644 index 0000000..0bcbe7c --- /dev/null +++ b/constants/registration.js @@ -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 +}; diff --git a/seed.js b/seed.js index 269580a..8ce03ec 100644 --- a/seed.js +++ b/seed.js @@ -55,7 +55,9 @@ const defaultRoles = [ PERMISSIONS.LOGS_READ, PERMISSIONS.LOGS_SEARCH, PERMISSIONS.CONTACT_INQUIRIES_READ, - PERMISSIONS.CONTACT_INQUIRIES_UPDATE + PERMISSIONS.CONTACT_INQUIRIES_UPDATE, + PERMISSIONS.PENDING_STUDENTS_READ, + PERMISSIONS.PENDING_STUDENTS_UPDATE ], isSystem: true }, diff --git a/utils/registrationPricing.js b/utils/registrationPricing.js new file mode 100644 index 0000000..b63e143 --- /dev/null +++ b/utils/registrationPricing.js @@ -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 +}; diff --git a/utils/registrationPricing.test.js b/utils/registrationPricing.test.js new file mode 100644 index 0000000..af4d9a7 --- /dev/null +++ b/utils/registrationPricing.test.js @@ -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'); +});