feat: add waitlist module, quick payment/transaction edit, and catering fee deduction from professor share

This commit is contained in:
2026-08-23 23:00:02 +03:30
parent e3b51a1629
commit 53fce86ad5
21 changed files with 609 additions and 16 deletions
+2
View File
@@ -103,7 +103,9 @@ app.use('/api/contact-inquiries', contactInquiryRoutes);
app.use('/api/expenses', expenseRoutes); app.use('/api/expenses', expenseRoutes);
app.use('/api/financial-reports', financialReportRoutes); app.use('/api/financial-reports', financialReportRoutes);
const pendingStudentRoutes = require('./components/pendingStudents/pendingStudentRoutes'); const pendingStudentRoutes = require('./components/pendingStudents/pendingStudentRoutes');
const waitlistRoutes = require('./components/waitlist/waitlistRoutes');
app.use('/api/pending-students', pendingStudentRoutes); app.use('/api/pending-students', pendingStudentRoutes);
app.use('/api/waitlist', waitlistRoutes);
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);
+6
View File
@@ -104,6 +104,12 @@ const classSchema = new mongoose.Schema({
default: 0, default: 0,
min: 0 min: 0
}, },
/** Catering / service fee per person deducted from tuition before percentage payout */
serviceFeePerPerson: {
type: Number,
default: 0,
min: 0
},
isActive: { isActive: {
type: Boolean, type: Boolean,
default: true default: true
+3 -2
View File
@@ -32,7 +32,7 @@ const applyScheduleFields = (payload, body) => {
return payload; 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 normalizePricingFields = (body = {}) => {
const tuitionFee = Math.max(0, Number(body.tuitionFee) || 0); 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 payoutPercentage = Math.min(100, Math.max(0, Number(body.payoutPercentage) || 0));
const payoutHourlyRate = Math.max(0, Number(body.payoutHourlyRate) || 0); const payoutHourlyRate = Math.max(0, Number(body.payoutHourlyRate) || 0);
const extraExpensePerSession = Math.max(0, Number(body.extraExpensePerSession) || 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) => { const normalizeNumberOfSessions = (value) => {
@@ -103,7 +103,9 @@ const getClassReport = async (classId) => {
revenue: revenue.actualReceivedRevenue, revenue: revenue.actualReceivedRevenue,
sessionDurationHours, sessionDurationHours,
sessionsCount: sessionCounts.held, sessionsCount: sessionCounts.held,
extraExpensePerSession: cls.extraExpensePerSession extraExpensePerSession: cls.extraExpensePerSession,
serviceFeePerPerson: cls.serviceFeePerPerson,
studentsCount: (cls.students || []).length
}); });
const netProfit = calculateNetProfit({ const netProfit = calculateNetProfit({
@@ -185,7 +187,9 @@ const getSessionReport = async (sessionId) => {
revenue: sessionIncome, revenue: sessionIncome,
payoutHourlyRate: cls.payoutHourlyRate, payoutHourlyRate: cls.payoutHourlyRate,
sessionDurationHours, sessionDurationHours,
sessionsCount: 1 sessionsCount: 1,
serviceFeePerPerson: (cls.serviceFeePerPerson || 0) / (plannedSessions || 1),
studentsCount
}); });
const sessionExtraExpense = calculateExtraExpenses({ const sessionExtraExpense = calculateExtraExpenses({
extraExpensePerSession: cls.extraExpensePerSession, extraExpensePerSession: cls.extraExpensePerSession,
@@ -308,7 +312,9 @@ const getRangeReport = async (query = {}) => {
revenue: receivedInRange, revenue: receivedInRange,
sessionDurationHours, sessionDurationHours,
sessionsCount: sessionsHeldInRange, sessionsCount: sessionsHeldInRange,
extraExpensePerSession: cls.extraExpensePerSession extraExpensePerSession: cls.extraExpensePerSession,
serviceFeePerPerson: cls.serviceFeePerPerson,
studentsCount: (cls.students || []).length
}); });
return { return {
@@ -509,6 +515,7 @@ const getAnalytics = async (query = {}) => {
payoutPercentage: cls.payoutPercentage, payoutPercentage: cls.payoutPercentage,
payoutHourlyRate: cls.payoutHourlyRate, payoutHourlyRate: cls.payoutHourlyRate,
extraExpensePerSession: cls.extraExpensePerSession, extraExpensePerSession: cls.extraExpensePerSession,
serviceFeePerPerson: cls.serviceFeePerPerson,
sessionDurationHours: resolveSessionDurationHours(cls), sessionDurationHours: resolveSessionDurationHours(cls),
studentsCount, studentsCount,
plannedSessions, plannedSessions,
@@ -610,7 +617,9 @@ const getAnalytics = async (query = {}) => {
revenue: received, revenue: received,
sessionDurationHours: profile.sessionDurationHours, sessionDurationHours: profile.sessionDurationHours,
sessionsCount: held, sessionsCount: held,
extraExpensePerSession: profile.extraExpensePerSession extraExpensePerSession: profile.extraExpensePerSession,
serviceFeePerPerson: profile.serviceFeePerPerson,
studentsCount: profile.studentsCount
}); });
total += payout.totalPayout; total += payout.totalPayout;
} }
@@ -670,7 +679,9 @@ const getAnalytics = async (query = {}) => {
revenue: profile.actualReceivedRevenue, revenue: profile.actualReceivedRevenue,
sessionDurationHours: profile.sessionDurationHours, sessionDurationHours: profile.sessionDurationHours,
sessionsCount: profile.heldSessions, sessionsCount: profile.heldSessions,
extraExpensePerSession: profile.extraExpensePerSession extraExpensePerSession: profile.extraExpensePerSession,
serviceFeePerPerson: profile.serviceFeePerPerson,
studentsCount: profile.studentsCount
}); });
totalProfessorPayoutsAllTime += payout.totalPayout; totalProfessorPayoutsAllTime += payout.totalPayout;
+11
View File
@@ -75,3 +75,14 @@ exports.cancelTransaction = catchAsync(async (req, res, next) => {
const payment = await paymentService.cancelTransaction(req.params.transactionId, actorId); const payment = await paymentService.cancelTransaction(req.params.transactionId, actorId);
return successResponse(res, 200, 'Transaction cancelled successfully', payment); 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);
});
+10 -1
View File
@@ -42,9 +42,15 @@ const paymentSchema = new mongoose.Schema({
}, },
status: { status: {
type: String, type: String,
enum: ['pending', 'partial', 'paid', 'overdue'], enum: ['pending', 'partial', 'paid', 'overdue', 'cancelled', 'reverted'],
default: 'pending' default: 'pending'
}, },
type: {
type: String,
enum: ['regular', 'waiting_list'],
default: 'regular',
index: true
},
notes: { type: String, trim: true, maxlength: 5000 }, notes: { type: String, trim: true, maxlength: 5000 },
isDeleted: { isDeleted: {
type: Boolean, type: Boolean,
@@ -72,6 +78,9 @@ paymentSchema.virtual('transactions', {
paymentSchema.pre('save', function (next) { paymentSchema.pre('save', function (next) {
this.discount = normalizeDiscount(this.discount, this.amount); this.discount = normalizeDiscount(this.discount, this.amount);
const payable = getPayableAmount(this); const payable = getPayableAmount(this);
if (this.status === 'cancelled' || this.status === 'reverted') {
return next();
}
if (this.paidAmount >= payable) { if (this.paidAmount >= payable) {
this.status = 'paid'; this.status = 'paid';
} else if (this.paidAmount > 0) { } else if (this.paidAmount > 0) {
+2
View File
@@ -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/update/:id', perm.requires(PERMISSIONS.PAYMENTS_UPDATE), validateUpdatePayment, paymentController.update);
router.put('/admin/transactions/:transactionId', perm.requires(PERMISSIONS.PAYMENTS_UPDATE), validateUpdateTransaction, paymentController.updateTransaction); 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/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.post('/admin/transactions/:paymentId', perm.requires(PERMISSIONS.PAYMENTS_UPDATE), validateAddTransaction, paymentController.createTransaction);
router.delete('/admin/delete/:id', perm.requires(PERMISSIONS.PAYMENTS_DELETE), paymentController.delete); router.delete('/admin/delete/:id', perm.requires(PERMISSIONS.PAYMENTS_DELETE), paymentController.delete);
+39
View File
@@ -500,6 +500,43 @@ const cancelTransaction = async (transactionId, actorId = null) => {
return getPaymentById(payment._id); 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 createBulkClassPayments = async (body, actorId = null) => {
const { classId } = body; const { classId } = body;
if (!classId) { if (!classId) {
@@ -664,6 +701,8 @@ module.exports = {
addTransaction, addTransaction,
updateTransaction, updateTransaction,
cancelTransaction, cancelTransaction,
revertTransaction,
deleteTransaction,
getMyPayments, getMyPayments,
createTransactionsForPayment, createTransactionsForPayment,
refreshPaymentTotals, refreshPaymentTotals,
+1 -1
View File
@@ -4,7 +4,7 @@
const AppError = require('../../utils/AppError'); const AppError = require('../../utils/AppError');
const PAYMENT_METHODS = new Set(['online', 'card', 'cash']); 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(); const passThrough = (req, res, next) => next();
+1 -1
View File
@@ -36,7 +36,7 @@ const transactionSchema = new mongoose.Schema({
}, },
status: { status: {
type: String, type: String,
enum: ['pending', 'paid', 'cancelled'], enum: ['pending', 'paid', 'cancelled', 'reverted'],
default: 'pending', default: 'pending',
index: true index: true
} }
+56
View File
@@ -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);
});
+66
View File
@@ -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);
+24
View File
@@ -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;
+303
View File
@@ -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
};
@@ -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');
});
});
+7 -1
View File
@@ -99,7 +99,13 @@ const PERMISSIONS = {
EXPENSES_DELETE: 'expenses:delete', EXPENSES_DELETE: 'expenses:delete',
// Financial reports (professor share, class profitability, date-range analytics) // 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); const ALL_PERMISSIONS = Object.values(PERMISSIONS);
+1 -1
View File
@@ -7,7 +7,7 @@
"start": "node app.js", "start": "node app.js",
"dev": "nodemon app.js", "dev": "nodemon app.js",
"seed": "node seed.js", "seed": "node seed.js",
"test": "node --test components/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": [ "keywords": [
"express", "express",
+5 -1
View File
@@ -61,7 +61,11 @@ const defaultRoles = [
PERMISSIONS.EXPENSES_CREATE, PERMISSIONS.EXPENSES_CREATE,
PERMISSIONS.EXPENSES_READ, PERMISSIONS.EXPENSES_READ,
PERMISSIONS.EXPENSES_UPDATE, 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 isSystem: true
}, },
+1 -1
View File
@@ -24,7 +24,7 @@ const sanitizeNotes = (notes) => {
const rialsToToman = (value) => Math.floor(toNonNegativeNumber(value) / 10); 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 = {}) => { const isPaidTransaction = (trx = {}) => {
if (isCancelledTransaction(trx)) return false; if (isCancelledTransaction(trx)) return false;
+5 -2
View File
@@ -48,9 +48,12 @@ const resolveSessionDurationHours = (cls = {}) => {
/** /**
* Model A percentage of class revenue. * Model A percentage of class revenue.
* `revenue` is the amount the percentage should be applied to (e.g. actual received 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 } = {}) => { const calculatePercentageShare = ({ payoutPercentage = 0, revenue = 0, serviceFeePerPerson = 0, studentsCount = 0 } = {}) => {
return (toPercentage(payoutPercentage) / 100) * toNonNegativeNumber(revenue); const totalServiceFee = toNonNegativeNumber(serviceFeePerPerson) * toNonNegativeNumber(studentsCount);
const netRevenue = Math.max(0, toNonNegativeNumber(revenue) - totalServiceFee);
return (toPercentage(payoutPercentage) / 100) * netRevenue;
}; };
/** /**
+15
View File
@@ -52,6 +52,21 @@ describe('calculatePercentageShare (Model A)', () => {
assert.equal(calculatePercentageShare({ payoutPercentage: 40, revenue: 10_000_000 }), 4_000_000); 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', () => { 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: 150, revenue: 1_000_000 }), 1_000_000);
assert.equal(calculatePercentageShare({ payoutPercentage: 40, revenue: -500 }), 0); assert.equal(calculatePercentageShare({ payoutPercentage: 40, revenue: -500 }), 0);