diff --git a/app.js b/app.js index 0fb6eff..88d5555 100644 --- a/app.js +++ b/app.js @@ -103,7 +103,9 @@ app.use('/api/contact-inquiries', contactInquiryRoutes); app.use('/api/expenses', expenseRoutes); app.use('/api/financial-reports', financialReportRoutes); const pendingStudentRoutes = require('./components/pendingStudents/pendingStudentRoutes'); +const waitlistRoutes = require('./components/waitlist/waitlistRoutes'); app.use('/api/pending-students', pendingStudentRoutes); +app.use('/api/waitlist', waitlistRoutes); app.use('/api/seed', seedRoutes); app.use('/api/data-import', dataImportRoutes); app.use('/api/settings', settingRoutes); diff --git a/components/classes/classModel.js b/components/classes/classModel.js index ba92141..37c9815 100644 --- a/components/classes/classModel.js +++ b/components/classes/classModel.js @@ -104,6 +104,12 @@ const classSchema = new mongoose.Schema({ default: 0, min: 0 }, + /** Catering / service fee per person deducted from tuition before percentage payout */ + serviceFeePerPerson: { + type: Number, + default: 0, + min: 0 + }, isActive: { type: Boolean, default: true diff --git a/components/classes/classService.js b/components/classes/classService.js index 42e4b13..1dbee77 100644 --- a/components/classes/classService.js +++ b/components/classes/classService.js @@ -32,7 +32,7 @@ const applyScheduleFields = (payload, body) => { return payload; }; -const CLASS_LIST_FIELDS = 'name course professor students capacity tuitionFee hasDiscount discount freeSpots showOnFrontend startDate endDate days startTime endTime numberOfSessions payoutType payoutPercentage payoutHourlyRate extraExpensePerSession isActive isDeleted deletedAt adminNotes createdAt updatedAt'; +const CLASS_LIST_FIELDS = 'name course professor students capacity tuitionFee hasDiscount discount freeSpots showOnFrontend startDate endDate days startTime endTime numberOfSessions payoutType payoutPercentage payoutHourlyRate extraExpensePerSession serviceFeePerPerson isActive isDeleted deletedAt adminNotes createdAt updatedAt'; const normalizePricingFields = (body = {}) => { const tuitionFee = Math.max(0, Number(body.tuitionFee) || 0); @@ -48,7 +48,8 @@ const normalizePayoutFields = (body = {}) => { const payoutPercentage = Math.min(100, Math.max(0, Number(body.payoutPercentage) || 0)); const payoutHourlyRate = Math.max(0, Number(body.payoutHourlyRate) || 0); const extraExpensePerSession = Math.max(0, Number(body.extraExpensePerSession) || 0); - return { payoutType, payoutPercentage, payoutHourlyRate, extraExpensePerSession }; + const serviceFeePerPerson = Math.max(0, Number(body.serviceFeePerPerson) || 0); + return { payoutType, payoutPercentage, payoutHourlyRate, extraExpensePerSession, serviceFeePerPerson }; }; const normalizeNumberOfSessions = (value) => { diff --git a/components/financialReports/financialReportService.js b/components/financialReports/financialReportService.js index f62e889..5d74bfc 100644 --- a/components/financialReports/financialReportService.js +++ b/components/financialReports/financialReportService.js @@ -103,7 +103,9 @@ const getClassReport = async (classId) => { revenue: revenue.actualReceivedRevenue, sessionDurationHours, sessionsCount: sessionCounts.held, - extraExpensePerSession: cls.extraExpensePerSession + extraExpensePerSession: cls.extraExpensePerSession, + serviceFeePerPerson: cls.serviceFeePerPerson, + studentsCount: (cls.students || []).length }); const netProfit = calculateNetProfit({ @@ -185,7 +187,9 @@ const getSessionReport = async (sessionId) => { revenue: sessionIncome, payoutHourlyRate: cls.payoutHourlyRate, sessionDurationHours, - sessionsCount: 1 + sessionsCount: 1, + serviceFeePerPerson: (cls.serviceFeePerPerson || 0) / (plannedSessions || 1), + studentsCount }); const sessionExtraExpense = calculateExtraExpenses({ extraExpensePerSession: cls.extraExpensePerSession, @@ -308,7 +312,9 @@ const getRangeReport = async (query = {}) => { revenue: receivedInRange, sessionDurationHours, sessionsCount: sessionsHeldInRange, - extraExpensePerSession: cls.extraExpensePerSession + extraExpensePerSession: cls.extraExpensePerSession, + serviceFeePerPerson: cls.serviceFeePerPerson, + studentsCount: (cls.students || []).length }); return { @@ -509,6 +515,7 @@ const getAnalytics = async (query = {}) => { payoutPercentage: cls.payoutPercentage, payoutHourlyRate: cls.payoutHourlyRate, extraExpensePerSession: cls.extraExpensePerSession, + serviceFeePerPerson: cls.serviceFeePerPerson, sessionDurationHours: resolveSessionDurationHours(cls), studentsCount, plannedSessions, @@ -610,7 +617,9 @@ const getAnalytics = async (query = {}) => { revenue: received, sessionDurationHours: profile.sessionDurationHours, sessionsCount: held, - extraExpensePerSession: profile.extraExpensePerSession + extraExpensePerSession: profile.extraExpensePerSession, + serviceFeePerPerson: profile.serviceFeePerPerson, + studentsCount: profile.studentsCount }); total += payout.totalPayout; } @@ -670,7 +679,9 @@ const getAnalytics = async (query = {}) => { revenue: profile.actualReceivedRevenue, sessionDurationHours: profile.sessionDurationHours, sessionsCount: profile.heldSessions, - extraExpensePerSession: profile.extraExpensePerSession + extraExpensePerSession: profile.extraExpensePerSession, + serviceFeePerPerson: profile.serviceFeePerPerson, + studentsCount: profile.studentsCount }); totalProfessorPayoutsAllTime += payout.totalPayout; diff --git a/components/payments/paymentController.js b/components/payments/paymentController.js index b9db0c7..871ea1b 100644 --- a/components/payments/paymentController.js +++ b/components/payments/paymentController.js @@ -75,3 +75,14 @@ exports.cancelTransaction = catchAsync(async (req, res, next) => { const payment = await paymentService.cancelTransaction(req.params.transactionId, actorId); return successResponse(res, 200, 'Transaction cancelled successfully', payment); }); + +exports.revertTransaction = catchAsync(async (req, res, next) => { + const actorId = req.user?._id; + const payment = await paymentService.revertTransaction(req.params.transactionId, actorId); + return successResponse(res, 200, 'Transaction reverted successfully', payment); +}); + +exports.deleteTransaction = catchAsync(async (req, res, next) => { + const payment = await paymentService.deleteTransaction(req.params.transactionId); + return successResponse(res, 200, 'Transaction deleted successfully', payment); +}); diff --git a/components/payments/paymentModel.js b/components/payments/paymentModel.js index 1afeb86..5701ede 100644 --- a/components/payments/paymentModel.js +++ b/components/payments/paymentModel.js @@ -42,9 +42,15 @@ const paymentSchema = new mongoose.Schema({ }, status: { type: String, - enum: ['pending', 'partial', 'paid', 'overdue'], + enum: ['pending', 'partial', 'paid', 'overdue', 'cancelled', 'reverted'], default: 'pending' }, + type: { + type: String, + enum: ['regular', 'waiting_list'], + default: 'regular', + index: true + }, notes: { type: String, trim: true, maxlength: 5000 }, isDeleted: { type: Boolean, @@ -72,6 +78,9 @@ paymentSchema.virtual('transactions', { paymentSchema.pre('save', function (next) { this.discount = normalizeDiscount(this.discount, this.amount); const payable = getPayableAmount(this); + if (this.status === 'cancelled' || this.status === 'reverted') { + return next(); + } if (this.paidAmount >= payable) { this.status = 'paid'; } else if (this.paidAmount > 0) { diff --git a/components/payments/paymentRoutes.js b/components/payments/paymentRoutes.js index c205c67..c70ca3e 100644 --- a/components/payments/paymentRoutes.js +++ b/components/payments/paymentRoutes.js @@ -30,6 +30,8 @@ router.get('/admin/get-one/:id', perm.requires(PERMISSIONS.PAYMENTS_READ), payme router.put('/admin/update/:id', perm.requires(PERMISSIONS.PAYMENTS_UPDATE), validateUpdatePayment, paymentController.update); router.put('/admin/transactions/:transactionId', perm.requires(PERMISSIONS.PAYMENTS_UPDATE), validateUpdateTransaction, paymentController.updateTransaction); router.post('/admin/transactions/:transactionId/cancel', perm.requires(PERMISSIONS.PAYMENTS_UPDATE), paymentController.cancelTransaction); +router.post('/admin/transactions/:transactionId/revert', perm.requires(PERMISSIONS.PAYMENTS_UPDATE), paymentController.revertTransaction); +router.delete('/admin/transactions/:transactionId', perm.requires(PERMISSIONS.PAYMENTS_UPDATE), paymentController.deleteTransaction); router.post('/admin/transactions/:paymentId', perm.requires(PERMISSIONS.PAYMENTS_UPDATE), validateAddTransaction, paymentController.createTransaction); router.delete('/admin/delete/:id', perm.requires(PERMISSIONS.PAYMENTS_DELETE), paymentController.delete); diff --git a/components/payments/paymentService.js b/components/payments/paymentService.js index fee5e80..9c7aef7 100644 --- a/components/payments/paymentService.js +++ b/components/payments/paymentService.js @@ -500,6 +500,43 @@ const cancelTransaction = async (transactionId, actorId = null) => { return getPaymentById(payment._id); }; +const revertTransaction = async (transactionId, actorId = null) => { + const trx = await Transaction.findById(transactionId); + if (!trx) throw new AppError('TRANSACTION_NOT_FOUND'); + if (trx.status === 'reverted') { + throw new AppError('VALIDATION_FAILED', {}, 'این تراکنش قبلاً مسترد شده است.'); + } + + const payment = await Payment.findById(trx.payment); + if (!payment) throw new AppError('PAYMENT_NOT_FOUND'); + + const previousStatus = payment.status; + trx.status = 'reverted'; + if (actorId) trx.recordedBy = actorId; + await trx.save(); + + await refreshPaymentTotals(payment); + await emitPaymentStatusChangedIfNeeded(payment, previousStatus, actorId); + + return getPaymentById(payment._id); +}; + +const deleteTransaction = async (transactionId) => { + const trx = await Transaction.findById(transactionId); + if (!trx) throw new AppError('TRANSACTION_NOT_FOUND'); + + const payment = await Payment.findById(trx.payment); + await Transaction.findByIdAndDelete(transactionId); + + if (payment) { + const previousStatus = payment.status; + await refreshPaymentTotals(payment); + await emitPaymentStatusChangedIfNeeded(payment, previousStatus); + return getPaymentById(payment._id); + } + return null; +}; + const createBulkClassPayments = async (body, actorId = null) => { const { classId } = body; if (!classId) { @@ -664,6 +701,8 @@ module.exports = { addTransaction, updateTransaction, cancelTransaction, + revertTransaction, + deleteTransaction, getMyPayments, createTransactionsForPayment, refreshPaymentTotals, diff --git a/components/payments/paymentValidator.js b/components/payments/paymentValidator.js index 43ef3e8..70d0817 100644 --- a/components/payments/paymentValidator.js +++ b/components/payments/paymentValidator.js @@ -4,7 +4,7 @@ const AppError = require('../../utils/AppError'); const PAYMENT_METHODS = new Set(['online', 'card', 'cash']); -const TRANSACTION_STATUSES = new Set(['pending', 'paid']); +const TRANSACTION_STATUSES = new Set(['pending', 'paid', 'cancelled', 'reverted']); const passThrough = (req, res, next) => next(); diff --git a/components/payments/transactionModel.js b/components/payments/transactionModel.js index eeca474..dbccf0c 100644 --- a/components/payments/transactionModel.js +++ b/components/payments/transactionModel.js @@ -36,7 +36,7 @@ const transactionSchema = new mongoose.Schema({ }, status: { type: String, - enum: ['pending', 'paid', 'cancelled'], + enum: ['pending', 'paid', 'cancelled', 'reverted'], default: 'pending', index: true } diff --git a/components/waitlist/waitlistController.js b/components/waitlist/waitlistController.js new file mode 100644 index 0000000..5076d5d --- /dev/null +++ b/components/waitlist/waitlistController.js @@ -0,0 +1,56 @@ +// /components/waitlist/waitlistController.js +'use strict'; + +const catchAsync = require('../../utils/catchAsync'); +const waitlistService = require('./waitlistService'); +const { successResponse, listResponse } = require('../../utils/apiResponse'); + +exports.getAll = catchAsync(async (req, res) => { + const { data, meta } = await waitlistService.getAll(req.query); + return listResponse(res, 200, data, meta); +}); + +exports.getOne = catchAsync(async (req, res) => { + const item = await waitlistService.getOne(req.params.id); + return successResponse(res, 200, 'Waitlist item retrieved successfully', item); +}); + +exports.create = catchAsync(async (req, res) => { + const actorId = req.user?._id; + const item = await waitlistService.create(req.body, actorId); + return successResponse(res, 201, 'Student added to waitlist successfully', item); +}); + +exports.update = catchAsync(async (req, res) => { + const actorId = req.user?._id; + const item = await waitlistService.update(req.params.id, req.body, actorId); + return successResponse(res, 200, 'Waitlist item updated successfully', item); +}); + +exports.assignClass = catchAsync(async (req, res) => { + const actorId = req.user?._id; + const item = await waitlistService.assignToClass(req.params.id, req.body, actorId); + return successResponse(res, 200, 'Student assigned to class successfully', item); +}); + +exports.revert = catchAsync(async (req, res) => { + const actorId = req.user?._id; + const item = await waitlistService.revert(req.params.id, req.body, actorId); + return successResponse(res, 200, 'Waitlist registration reverted successfully', item); +}); + +exports.cancel = catchAsync(async (req, res) => { + const actorId = req.user?._id; + const item = await waitlistService.cancel(req.params.id, req.body, actorId); + return successResponse(res, 200, 'Waitlist registration cancelled successfully', item); +}); + +exports.delete = catchAsync(async (req, res) => { + await waitlistService.remove(req.params.id); + return successResponse(res, 200, 'Waitlist item deleted successfully'); +}); + +exports.getStats = catchAsync(async (req, res) => { + const stats = await waitlistService.getStats(); + return successResponse(res, 200, 'Waitlist stats retrieved successfully', stats); +}); diff --git a/components/waitlist/waitlistModel.js b/components/waitlist/waitlistModel.js new file mode 100644 index 0000000..b639ae0 --- /dev/null +++ b/components/waitlist/waitlistModel.js @@ -0,0 +1,66 @@ +// /components/waitlist/waitlistModel.js +'use strict'; + +const mongoose = require('mongoose'); +const uniqueCodePlugin = require('../../utils/mongooseUniqueCodePlugin'); + +const waitlistSchema = new mongoose.Schema({ + user: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: true, + index: true + }, + course: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Course', + required: true, + index: true + }, + payment: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Payment', + index: true + }, + class: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Class', + default: null, + index: true + }, + status: { + type: String, + enum: ['waiting', 'enrolled', 'cancelled', 'reverted'], + default: 'waiting', + index: true + }, + adminNotes: { + type: [String], + default: [] + }, + registeredAt: { + type: Date, + default: Date.now + }, + assignedAt: { + type: Date, + default: null + }, + isDeleted: { + type: Boolean, + default: false, + index: true + }, + deletedAt: { + type: Date, + default: null + } +}, { + timestamps: true, + toJSON: { virtuals: true }, + toObject: { virtuals: true } +}); + +waitlistSchema.plugin(uniqueCodePlugin); + +module.exports = mongoose.model('Waitlist', waitlistSchema); diff --git a/components/waitlist/waitlistRoutes.js b/components/waitlist/waitlistRoutes.js new file mode 100644 index 0000000..502fb25 --- /dev/null +++ b/components/waitlist/waitlistRoutes.js @@ -0,0 +1,24 @@ +// /components/waitlist/waitlistRoutes.js +'use strict'; + +const express = require('express'); +const waitlistController = require('./waitlistController'); +const authMiddleware = require('../../middlewares/authMiddleware'); +const perm = require('../../middlewares/permissionMiddleware'); +const { PERMISSIONS } = require('../../constants/permissions'); + +const router = express.Router(); + +router.use(authMiddleware); + +router.get('/admin/get-all', perm.requires(PERMISSIONS.WAITLIST_READ), waitlistController.getAll); +router.get('/admin/stats', perm.requires(PERMISSIONS.WAITLIST_READ), waitlistController.getStats); +router.get('/admin/get-one/:id', perm.requires(PERMISSIONS.WAITLIST_READ), waitlistController.getOne); +router.post('/admin/create', perm.requires(PERMISSIONS.WAITLIST_CREATE), waitlistController.create); +router.put('/admin/update/:id', perm.requires(PERMISSIONS.WAITLIST_UPDATE), waitlistController.update); +router.post('/admin/:id/assign-class', perm.requires(PERMISSIONS.WAITLIST_UPDATE), waitlistController.assignClass); +router.post('/admin/:id/revert', perm.requires(PERMISSIONS.WAITLIST_UPDATE), waitlistController.revert); +router.post('/admin/:id/cancel', perm.requires(PERMISSIONS.WAITLIST_UPDATE), waitlistController.cancel); +router.delete('/admin/delete/:id', perm.requires(PERMISSIONS.WAITLIST_DELETE), waitlistController.delete); + +module.exports = router; diff --git a/components/waitlist/waitlistService.js b/components/waitlist/waitlistService.js new file mode 100644 index 0000000..ab1fdcc --- /dev/null +++ b/components/waitlist/waitlistService.js @@ -0,0 +1,303 @@ +// /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 +}; diff --git a/components/waitlist/waitlistService.test.js b/components/waitlist/waitlistService.test.js new file mode 100644 index 0000000..189947e --- /dev/null +++ b/components/waitlist/waitlistService.test.js @@ -0,0 +1,35 @@ +'use strict'; + +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const waitlistService = require('./waitlistService'); + +const Waitlist = require('./waitlistModel'); + +describe('Waitlist Service and Schema', () => { + it('exports all expected service methods', () => { + assert.equal(typeof waitlistService.getAll, 'function'); + assert.equal(typeof waitlistService.getOne, 'function'); + assert.equal(typeof waitlistService.create, 'function'); + assert.equal(typeof waitlistService.update, 'function'); + assert.equal(typeof waitlistService.assignToClass, 'function'); + assert.equal(typeof waitlistService.revert, 'function'); + assert.equal(typeof waitlistService.cancel, 'function'); + assert.equal(typeof waitlistService.remove, 'function'); + assert.equal(typeof waitlistService.getStats, 'function'); + }); + + it('has valid schema paths in Waitlist model', () => { + assert.ok(Waitlist.schema.path('user')); + assert.ok(Waitlist.schema.path('course')); + assert.ok(Waitlist.schema.path('payment')); + assert.ok(Waitlist.schema.path('class')); + assert.ok(Waitlist.schema.path('status')); + assert.ok(Waitlist.schema.path('isDeleted')); + assert.ok(Waitlist.schema.path('deletedAt')); + + const statusPath = Waitlist.schema.path('status'); + assert.deepEqual(statusPath.enumValues, ['waiting', 'enrolled', 'cancelled', 'reverted']); + assert.equal(statusPath.defaultValue, 'waiting'); + }); +}); diff --git a/constants/permissions.js b/constants/permissions.js index 45f32b1..6b5225f 100644 --- a/constants/permissions.js +++ b/constants/permissions.js @@ -99,7 +99,13 @@ const PERMISSIONS = { EXPENSES_DELETE: 'expenses:delete', // Financial reports (professor share, class profitability, date-range analytics) - FINANCIAL_REPORTS_READ: 'financial_reports:read' + FINANCIAL_REPORTS_READ: 'financial_reports:read', + + // Waiting list permissions + WAITLIST_CREATE: 'waitlist:create', + WAITLIST_READ: 'waitlist:read', + WAITLIST_UPDATE: 'waitlist:update', + WAITLIST_DELETE: 'waitlist:delete' }; const ALL_PERMISSIONS = Object.values(PERMISSIONS); diff --git a/package.json b/package.json index 611f511..7230fe3 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "start": "node app.js", "dev": "nodemon app.js", "seed": "node seed.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" + "test": "node --test components/waitlist/waitlistService.test.js 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", diff --git a/seed.js b/seed.js index 983a775..5eb0d5f 100644 --- a/seed.js +++ b/seed.js @@ -61,7 +61,11 @@ const defaultRoles = [ PERMISSIONS.EXPENSES_CREATE, PERMISSIONS.EXPENSES_READ, PERMISSIONS.EXPENSES_UPDATE, - PERMISSIONS.FINANCIAL_REPORTS_READ + PERMISSIONS.FINANCIAL_REPORTS_READ, + PERMISSIONS.WAITLIST_CREATE, + PERMISSIONS.WAITLIST_READ, + PERMISSIONS.WAITLIST_UPDATE, + PERMISSIONS.WAITLIST_DELETE ], isSystem: true }, diff --git a/utils/paymentAmount.js b/utils/paymentAmount.js index 690c693..fb32e71 100644 --- a/utils/paymentAmount.js +++ b/utils/paymentAmount.js @@ -24,7 +24,7 @@ const sanitizeNotes = (notes) => { const rialsToToman = (value) => Math.floor(toNonNegativeNumber(value) / 10); -const isCancelledTransaction = (trx = {}) => String(trx.status || '').toLowerCase() === 'cancelled'; +const isCancelledTransaction = (trx = {}) => ['cancelled', 'reverted'].includes(String(trx.status || '').toLowerCase()); const isPaidTransaction = (trx = {}) => { if (isCancelledTransaction(trx)) return false; diff --git a/utils/professorShare.js b/utils/professorShare.js index 1f10953..9bae4b2 100644 --- a/utils/professorShare.js +++ b/utils/professorShare.js @@ -48,9 +48,12 @@ const resolveSessionDurationHours = (cls = {}) => { /** * Model A — percentage of class revenue. * `revenue` is the amount the percentage should be applied to (e.g. actual received revenue). + * `serviceFeePerPerson` (catering / service expenses per student) is deducted first before calculating the professor share. */ -const calculatePercentageShare = ({ payoutPercentage = 0, revenue = 0 } = {}) => { - return (toPercentage(payoutPercentage) / 100) * toNonNegativeNumber(revenue); +const calculatePercentageShare = ({ payoutPercentage = 0, revenue = 0, serviceFeePerPerson = 0, studentsCount = 0 } = {}) => { + const totalServiceFee = toNonNegativeNumber(serviceFeePerPerson) * toNonNegativeNumber(studentsCount); + const netRevenue = Math.max(0, toNonNegativeNumber(revenue) - totalServiceFee); + return (toPercentage(payoutPercentage) / 100) * netRevenue; }; /** diff --git a/utils/professorShare.test.js b/utils/professorShare.test.js index 576f25f..3f04e09 100644 --- a/utils/professorShare.test.js +++ b/utils/professorShare.test.js @@ -52,6 +52,21 @@ describe('calculatePercentageShare (Model A)', () => { assert.equal(calculatePercentageShare({ payoutPercentage: 40, revenue: 10_000_000 }), 4_000_000); }); + it('deducts serviceFeePerPerson * studentsCount from revenue before calculating percentage', () => { + // 2 students with 10M tuition (revenue = 20M), serviceFeePerPerson = 400,000, 50% payout + // Net revenue = 20M - (400,000 * 2) = 19,200,000 + // Professor share = 19,200,000 * 50% = 9,600,000 + assert.equal( + calculatePercentageShare({ + payoutPercentage: 50, + revenue: 20_000_000, + serviceFeePerPerson: 400_000, + studentsCount: 2 + }), + 9_600_000 + ); + }); + it('clamps percentage above 100 and negative revenue to zero', () => { assert.equal(calculatePercentageShare({ payoutPercentage: 150, revenue: 1_000_000 }), 1_000_000); assert.equal(calculatePercentageShare({ payoutPercentage: 40, revenue: -500 }), 0);