// /components/waitlist/waitlistService.js 'use strict'; const Waitlist = require('./waitlistModel'); const User = require('../users/userModel'); const Course = require('../courses/courseModel'); const Class = require('../classes/classModel'); const Payment = require('../payments/paymentModel'); const Transaction = require('../payments/transactionModel'); const AppError = require('../../utils/AppError'); const { calculateMeta, escapeRegex, getSearchTerm } = require('../../utils/pagination'); const { getPayableAmount, normalizeDiscount, sanitizeNotes, sumPaidTransactions } = require('../../utils/paymentAmount'); const paymentService = require('../payments/paymentService'); const populateWaitlist = (query) => query .populate({ path: 'user', select: 'name phoneNumber email nationalIdCode' }) .populate({ path: 'course', select: 'title price code' }) .populate({ path: 'class', select: 'name tuitionFee startDate days startTime endTime' }) .populate({ path: 'payment', populate: { path: 'transactions', options: { sort: { dueDate: 1, date: 1, createdAt: 1 } } } }); const getAll = 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.trash === 'true' || query.isDeleted === 'true') { filter.isDeleted = true; } else { filter.isDeleted = { $ne: true }; } if (query.courseId) filter.course = query.courseId; if (query.userId) filter.user = query.userId; if (query.status) filter.status = query.status; 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 = [ { uniqueCode: searchRegex }, { adminNotes: searchRegex }, { user: { $in: matchedUsers.map((u) => u._id) } } ]; } const [items, total] = await Promise.all([ populateWaitlist(Waitlist.find(filter)) .skip(skip) .limit(limit) .sort({ createdAt: -1 }) .lean(), Waitlist.countDocuments(filter) ]); return { data: items, meta: calculateMeta(total, page, limit) }; }; const getOne = async (id) => { const item = await populateWaitlist(Waitlist.findById(id)).lean(); if (!item) throw new AppError('WAITLIST_NOT_FOUND', {}, 'موردی در لیست انتظار یافت نشد.'); return item; }; const create = async (body, actorId = null) => { const { userId, courseId, amount, discount, dueDate, notes, initialTransaction, adminNotes } = body; const user = await User.findById(userId || body.user); if (!user) throw new AppError('USER_NOT_FOUND', {}, 'کاربر مورد نظر یافت نشد.'); const course = await Course.findById(courseId || body.course); if (!course) throw new AppError('COURSE_NOT_FOUND', {}, 'دوره آموزشی مورد نظر یافت نشد.'); const totalAmount = amount !== undefined && amount !== null && amount !== '' ? Number(amount) : (course.price || 0); const totalDiscount = discount !== undefined && discount !== null && discount !== '' ? normalizeDiscount(Number(discount), totalAmount) : 0; // Create waiting_list payment const payment = await Payment.create({ user: user._id, course: course._id, classes: [], amount: totalAmount, discount: totalDiscount, dueDate: dueDate ? new Date(dueDate) : undefined, type: 'waiting_list', status: 'pending', notes: sanitizeNotes(notes || `صورتحساب ثبت‌نام لیست انتظار دوره ${course.title}`) }); // Create initial transaction if supplied if (initialTransaction && (Number(initialTransaction.amount) > 0 || initialTransaction.status === 'paid')) { const trxStatus = initialTransaction.status || 'paid'; const trxDate = trxStatus === 'paid' ? (initialTransaction.date ? new Date(initialTransaction.date) : new Date()) : undefined; const trxDueDate = initialTransaction.dueDate ? new Date(initialTransaction.dueDate) : (trxDate || new Date()); await Transaction.create({ payment: payment._id, user: user._id, amount: Number(initialTransaction.amount) || (totalAmount - totalDiscount), status: trxStatus, method: initialTransaction.method || 'card', receiptNumber: initialTransaction.receiptNumber ? String(initialTransaction.receiptNumber) : '', date: trxDate, dueDate: trxDueDate, notes: sanitizeNotes(initialTransaction.notes || 'پرداخت بیعانه / ثبت‌نام لیست انتظار'), recordedBy: actorId }); await paymentService.refreshPaymentTotals(payment); } const notesList = []; if (Array.isArray(adminNotes)) { notesList.push(...adminNotes.filter(Boolean)); } else if (notes) { notesList.push(String(notes)); } const waitlist = await Waitlist.create({ user: user._id, course: course._id, payment: payment._id, status: 'waiting', adminNotes: notesList, registeredAt: body.registeredAt ? new Date(body.registeredAt) : new Date() }); return getOne(waitlist._id); }; const update = async (id, body) => { const waitlist = await Waitlist.findById(id); if (!waitlist) throw new AppError('WAITLIST_NOT_FOUND', {}, 'موردی در لیست انتظار یافت نشد.'); if (body.adminNotes !== undefined) { waitlist.adminNotes = Array.isArray(body.adminNotes) ? body.adminNotes.filter(Boolean) : [String(body.adminNotes)]; } if (body.status !== undefined) { waitlist.status = body.status; } if (body.registeredAt !== undefined) { waitlist.registeredAt = new Date(body.registeredAt); } await waitlist.save(); return getOne(waitlist._id); }; const assignToClass = async (id, { classId }, actorId = null) => { const waitlist = await Waitlist.findById(id); if (!waitlist) throw new AppError('WAITLIST_NOT_FOUND', {}, 'موردی در لیست انتظار یافت نشد.'); const targetClass = await Class.findById(classId).populate('course', 'title price'); if (!targetClass) throw new AppError('CLASS_NOT_FOUND', {}, 'کلاس مورد نظر یافت نشد.'); // 1. Add user to class students list if not present const studentIdStr = String(waitlist.user); const alreadyEnrolled = (targetClass.students || []).some((s) => String(s) === studentIdStr); if (!alreadyEnrolled) { targetClass.students.push(waitlist.user); await targetClass.save(); } // 2. Transition Payment to regular and link class if (waitlist.payment) { const payment = await Payment.findById(waitlist.payment); if (payment) { payment.classes = [targetClass._id]; payment.type = 'regular'; await payment.save(); await paymentService.refreshPaymentTotals(payment); } } // 3. Mark waitlist status as enrolled waitlist.class = targetClass._id; waitlist.status = 'enrolled'; waitlist.assignedAt = new Date(); await waitlist.save(); return getOne(waitlist._id); }; const revert = async (id, { notes } = {}, actorId = null) => { const waitlist = await Waitlist.findById(id); if (!waitlist) throw new AppError('WAITLIST_NOT_FOUND', {}, 'موردی در لیست انتظار یافت نشد.'); waitlist.status = 'reverted'; if (notes) { waitlist.adminNotes.push(`استرداد: ${notes}`); } await waitlist.save(); if (waitlist.payment) { const payment = await Payment.findById(waitlist.payment); if (payment) { payment.status = 'reverted'; await payment.save(); const transactions = await Transaction.find({ payment: payment._id }); for (const trx of transactions) { trx.status = 'reverted'; if (actorId) trx.recordedBy = actorId; await trx.save(); } await paymentService.refreshPaymentTotals(payment); } } return getOne(waitlist._id); }; const cancel = async (id, { notes } = {}, actorId = null) => { const waitlist = await Waitlist.findById(id); if (!waitlist) throw new AppError('WAITLIST_NOT_FOUND', {}, 'موردی در لیست انتظار یافت نشد.'); waitlist.status = 'cancelled'; if (notes) { waitlist.adminNotes.push(`لغو: ${notes}`); } await waitlist.save(); if (waitlist.payment) { const payment = await Payment.findById(waitlist.payment); if (payment) { payment.status = 'cancelled'; await payment.save(); const transactions = await Transaction.find({ payment: payment._id }); for (const trx of transactions) { trx.status = 'cancelled'; if (actorId) trx.recordedBy = actorId; await trx.save(); } await paymentService.refreshPaymentTotals(payment); } } return getOne(waitlist._id); }; const remove = async (id) => { const waitlist = await Waitlist.findById(id); if (!waitlist) throw new AppError('WAITLIST_NOT_FOUND', {}, 'موردی در لیست انتظار یافت نشد.'); waitlist.isDeleted = true; waitlist.deletedAt = new Date(); await waitlist.save(); return { success: true }; }; const getStats = async () => { const [total, waiting, enrolled, reverted, cancelled] = await Promise.all([ Waitlist.countDocuments({ isDeleted: { $ne: true } }), Waitlist.countDocuments({ status: 'waiting', isDeleted: { $ne: true } }), Waitlist.countDocuments({ status: 'enrolled', isDeleted: { $ne: true } }), Waitlist.countDocuments({ status: 'reverted', isDeleted: { $ne: true } }), Waitlist.countDocuments({ status: 'cancelled', isDeleted: { $ne: true } }) ]); return { total, waiting, enrolled, reverted, cancelled }; }; module.exports = { getAll, getOne, create, update, assignToClass, revert, cancel, remove, getStats };