feat: add waitlist module, quick payment/transaction edit, and catering fee deduction from professor share
This commit is contained in:
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
@@ -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;
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user