Add professor share calculation and financial reporting feature
Introduces per-class payout configuration (percentage or hourly rate, plus an optional per-session expense allowance), pure calculation utilities for professor payout/net profit/receivables, and a new financial-reports API surface with per-class, per-session, and date-range/monthly reports. Also adds full CRUD for institutional expenses used in the range report, and wires up the new permissions and roles. Fixes a pre-existing ReferenceError (missing parseImportDate import) in paymentService that blocked creating payments with dated transactions.
This commit is contained in:
@@ -29,6 +29,8 @@ const fileRoutes = require('./components/files/fileRoutes');
|
|||||||
const dashboardRoutes = require('./components/dashboard/dashboardRoutes');
|
const dashboardRoutes = require('./components/dashboard/dashboardRoutes');
|
||||||
const activityLogRoutes = require('./components/activityLogs/activityLogRoutes');
|
const activityLogRoutes = require('./components/activityLogs/activityLogRoutes');
|
||||||
const contactInquiryRoutes = require('./components/contactInquiries/contactInquiryRoutes');
|
const contactInquiryRoutes = require('./components/contactInquiries/contactInquiryRoutes');
|
||||||
|
const expenseRoutes = require('./components/expenses/expenseRoutes');
|
||||||
|
const financialReportRoutes = require('./components/financialReports/financialReportRoutes');
|
||||||
const activityLogger = require('./middlewares/activityLogger');
|
const activityLogger = require('./middlewares/activityLogger');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
@@ -98,6 +100,8 @@ app.use('/api/files', fileRoutes);
|
|||||||
app.use('/api/dashboard', dashboardRoutes);
|
app.use('/api/dashboard', dashboardRoutes);
|
||||||
app.use('/api/activity-logs', activityLogRoutes);
|
app.use('/api/activity-logs', activityLogRoutes);
|
||||||
app.use('/api/contact-inquiries', contactInquiryRoutes);
|
app.use('/api/contact-inquiries', contactInquiryRoutes);
|
||||||
|
app.use('/api/expenses', expenseRoutes);
|
||||||
|
app.use('/api/financial-reports', financialReportRoutes);
|
||||||
const pendingStudentRoutes = require('./components/pendingStudents/pendingStudentRoutes');
|
const pendingStudentRoutes = require('./components/pendingStudents/pendingStudentRoutes');
|
||||||
app.use('/api/pending-students', pendingStudentRoutes);
|
app.use('/api/pending-students', pendingStudentRoutes);
|
||||||
app.use('/api/seed', seedRoutes);
|
app.use('/api/seed', seedRoutes);
|
||||||
|
|||||||
@@ -79,6 +79,31 @@ const classSchema = new mongoose.Schema({
|
|||||||
min: 1,
|
min: 1,
|
||||||
default: null
|
default: null
|
||||||
},
|
},
|
||||||
|
/** Professor compensation model for this class */
|
||||||
|
payoutType: {
|
||||||
|
type: String,
|
||||||
|
enum: ['percentage', 'hourly'],
|
||||||
|
default: 'percentage'
|
||||||
|
},
|
||||||
|
/** Used when payoutType === 'percentage' — professor's share of class revenue (0-100) */
|
||||||
|
payoutPercentage: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
min: 0,
|
||||||
|
max: 100
|
||||||
|
},
|
||||||
|
/** Used when payoutType === 'hourly' — professor's rate per teaching hour (Toman) */
|
||||||
|
payoutHourlyRate: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
min: 0
|
||||||
|
},
|
||||||
|
/** Optional flat allowance (travel/catering/etc.) paid per session held, regardless of payout model */
|
||||||
|
extraExpensePerSession: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
min: 0
|
||||||
|
},
|
||||||
isActive: {
|
isActive: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: true
|
default: true
|
||||||
|
|||||||
@@ -19,7 +19,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 isActive 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 isActive 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);
|
||||||
@@ -30,6 +30,14 @@ const normalizePricingFields = (body = {}) => {
|
|||||||
return { tuitionFee, hasDiscount, discount };
|
return { tuitionFee, hasDiscount, discount };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const normalizePayoutFields = (body = {}) => {
|
||||||
|
const payoutType = body.payoutType === 'hourly' ? 'hourly' : 'percentage';
|
||||||
|
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 normalizeNumberOfSessions = (value) => {
|
const normalizeNumberOfSessions = (value) => {
|
||||||
if (value === '' || value === null || value === undefined) return null;
|
if (value === '' || value === null || value === undefined) return null;
|
||||||
const parsed = Number(value);
|
const parsed = Number(value);
|
||||||
@@ -65,7 +73,7 @@ const getAll = async (query) => {
|
|||||||
const [items, total] = await Promise.all([
|
const [items, total] = await Promise.all([
|
||||||
Class.find(filter)
|
Class.find(filter)
|
||||||
.select(CLASS_LIST_FIELDS)
|
.select(CLASS_LIST_FIELDS)
|
||||||
.populate({ path: 'course', select: 'title type price' })
|
.populate({ path: 'course', select: 'title type price sectionCount hoursPerSection' })
|
||||||
.populate({ path: 'professor', select: 'name surname' })
|
.populate({ path: 'professor', select: 'name surname' })
|
||||||
.skip(skip).limit(limit).sort({ createdAt: -1 }).lean(),
|
.skip(skip).limit(limit).sort({ createdAt: -1 }).lean(),
|
||||||
Class.countDocuments(filter)
|
Class.countDocuments(filter)
|
||||||
@@ -76,7 +84,7 @@ const getAll = async (query) => {
|
|||||||
|
|
||||||
const getOne = async (id) => {
|
const getOne = async (id) => {
|
||||||
const cls = await Class.findById(id)
|
const cls = await Class.findById(id)
|
||||||
.populate({ path: 'course', select: 'title type price' })
|
.populate({ path: 'course', select: 'title type price sectionCount hoursPerSection' })
|
||||||
.populate({ path: 'professor', select: 'name surname phoneNumber' })
|
.populate({ path: 'professor', select: 'name surname phoneNumber' })
|
||||||
.populate({ path: 'students', select: 'name phoneNumber gender' })
|
.populate({ path: 'students', select: 'name phoneNumber gender' })
|
||||||
.lean();
|
.lean();
|
||||||
@@ -88,6 +96,7 @@ const create = async (body) => {
|
|||||||
const payload = applyScheduleFields({
|
const payload = applyScheduleFields({
|
||||||
...body,
|
...body,
|
||||||
...normalizePricingFields(body),
|
...normalizePricingFields(body),
|
||||||
|
...normalizePayoutFields(body),
|
||||||
numberOfSessions: normalizeNumberOfSessions(body.numberOfSessions)
|
numberOfSessions: normalizeNumberOfSessions(body.numberOfSessions)
|
||||||
}, body);
|
}, body);
|
||||||
if (body.freeSpots === '' || body.freeSpots === null || body.freeSpots === undefined) {
|
if (body.freeSpots === '' || body.freeSpots === null || body.freeSpots === undefined) {
|
||||||
@@ -103,6 +112,7 @@ const update = async (id, body) => {
|
|||||||
const payload = applyScheduleFields({
|
const payload = applyScheduleFields({
|
||||||
...body,
|
...body,
|
||||||
...normalizePricingFields(body),
|
...normalizePricingFields(body),
|
||||||
|
...normalizePayoutFields(body),
|
||||||
...(body.numberOfSessions !== undefined
|
...(body.numberOfSessions !== undefined
|
||||||
? { numberOfSessions: normalizeNumberOfSessions(body.numberOfSessions) }
|
? { numberOfSessions: normalizeNumberOfSessions(body.numberOfSessions) }
|
||||||
: {})
|
: {})
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
// /components/expenses/expenseController.js
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const catchAsync = require('../../utils/catchAsync');
|
||||||
|
const expenseService = require('./expenseService');
|
||||||
|
const { successResponse, listResponse } = require('../../utils/apiResponse');
|
||||||
|
|
||||||
|
exports.getAll = catchAsync(async (req, res) => {
|
||||||
|
const { data, meta } = await expenseService.getAllExpenses(req.query);
|
||||||
|
return listResponse(res, 200, data, meta);
|
||||||
|
});
|
||||||
|
|
||||||
|
exports.getOne = catchAsync(async (req, res) => {
|
||||||
|
const expense = await expenseService.getExpenseById(req.params.id);
|
||||||
|
return successResponse(res, 200, 'Expense retrieved successfully', expense);
|
||||||
|
});
|
||||||
|
|
||||||
|
exports.create = catchAsync(async (req, res) => {
|
||||||
|
const expense = await expenseService.createExpense(req.body, req.user?._id);
|
||||||
|
return successResponse(res, 201, 'Expense created successfully', expense);
|
||||||
|
});
|
||||||
|
|
||||||
|
exports.update = catchAsync(async (req, res) => {
|
||||||
|
const expense = await expenseService.updateExpense(req.params.id, req.body);
|
||||||
|
return successResponse(res, 200, 'Expense updated successfully', expense);
|
||||||
|
});
|
||||||
|
|
||||||
|
exports.delete = catchAsync(async (req, res) => {
|
||||||
|
await expenseService.deleteExpense(req.params.id);
|
||||||
|
return successResponse(res, 200, 'Expense deleted successfully');
|
||||||
|
});
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
// /components/expenses/expenseModel.js
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const mongoose = require('mongoose');
|
||||||
|
|
||||||
|
const expenseSchema = new mongoose.Schema({
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
trim: true
|
||||||
|
},
|
||||||
|
/** Free-form category label (e.g. "اجاره", "قبوض", "تجهیزات") */
|
||||||
|
category: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: 'عمومی'
|
||||||
|
},
|
||||||
|
amount: {
|
||||||
|
type: Number,
|
||||||
|
required: true,
|
||||||
|
min: 0
|
||||||
|
},
|
||||||
|
/** Date the expense applies to — used to bucket expenses into date-range/monthly reports */
|
||||||
|
date: {
|
||||||
|
type: Date,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
notes: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
maxlength: 2000
|
||||||
|
},
|
||||||
|
recordedBy: {
|
||||||
|
type: mongoose.Schema.Types.ObjectId,
|
||||||
|
ref: 'User'
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
timestamps: true
|
||||||
|
});
|
||||||
|
|
||||||
|
expenseSchema.index({ date: -1 });
|
||||||
|
|
||||||
|
module.exports = mongoose.model('Expense', expenseSchema);
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
// /components/expenses/expenseRoutes.js
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const expenseController = require('./expenseController');
|
||||||
|
const { validateCreateExpense, validateUpdateExpense } = require('./expenseValidator');
|
||||||
|
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.EXPENSES_READ), expenseController.getAll);
|
||||||
|
router.get('/admin/get-one/:id', perm.requires(PERMISSIONS.EXPENSES_READ), expenseController.getOne);
|
||||||
|
router.post('/admin/create', perm.requires(PERMISSIONS.EXPENSES_CREATE), validateCreateExpense, expenseController.create);
|
||||||
|
router.put('/admin/update/:id', perm.requires(PERMISSIONS.EXPENSES_UPDATE), validateUpdateExpense, expenseController.update);
|
||||||
|
router.delete('/admin/delete/:id', perm.requires(PERMISSIONS.EXPENSES_DELETE), expenseController.delete);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
// /components/expenses/expenseService.js
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const Expense = require('./expenseModel');
|
||||||
|
const AppError = require('../../utils/AppError');
|
||||||
|
const { parsePaginationAndSort, buildFilterQuery, calculateMeta } = require('../../utils/pagination');
|
||||||
|
const { resolveDateRange } = require('../../utils/financialRange');
|
||||||
|
|
||||||
|
const sanitizePayload = (body = {}) => {
|
||||||
|
const payload = {
|
||||||
|
title: String(body.title || '').trim(),
|
||||||
|
category: body.category ? String(body.category).trim() : 'عمومی',
|
||||||
|
amount: Math.max(0, Number(body.amount) || 0),
|
||||||
|
date: body.date ? new Date(body.date) : new Date(),
|
||||||
|
notes: body.notes ? String(body.notes).trim().slice(0, 2000) : ''
|
||||||
|
};
|
||||||
|
return payload;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createExpense = async (body, actorId = null) => {
|
||||||
|
const payload = sanitizePayload(body);
|
||||||
|
const expense = await Expense.create({ ...payload, recordedBy: actorId || undefined });
|
||||||
|
return expense;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAllExpenses = async (queryParams) => {
|
||||||
|
const { page, limit, skip, sort } = parsePaginationAndSort(queryParams, 'date', 'desc');
|
||||||
|
const filter = buildFilterQuery(queryParams, ['title', 'category', 'notes'], [
|
||||||
|
'page', 'limit', 'sortBy', 'sortOrder', 'q', 'search', 'lang', 'startDate', 'endDate'
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (queryParams.startDate || queryParams.endDate) {
|
||||||
|
const { start, end } = resolveDateRange(queryParams);
|
||||||
|
filter.date = { $gte: start, $lte: end };
|
||||||
|
}
|
||||||
|
|
||||||
|
const [items, total] = await Promise.all([
|
||||||
|
Expense.find(filter).populate('recordedBy', 'name').sort(sort).skip(skip).limit(limit).lean(),
|
||||||
|
Expense.countDocuments(filter)
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { data: items, meta: calculateMeta(total, page, limit) };
|
||||||
|
};
|
||||||
|
|
||||||
|
const getExpenseById = async (id) => {
|
||||||
|
const expense = await Expense.findById(id).populate('recordedBy', 'name').lean();
|
||||||
|
if (!expense) throw new AppError('EXPENSE_NOT_FOUND');
|
||||||
|
return expense;
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateExpense = async (id, body) => {
|
||||||
|
const expense = await Expense.findById(id);
|
||||||
|
if (!expense) throw new AppError('EXPENSE_NOT_FOUND');
|
||||||
|
|
||||||
|
if (body.title !== undefined) expense.title = String(body.title).trim();
|
||||||
|
if (body.category !== undefined) expense.category = String(body.category).trim() || 'عمومی';
|
||||||
|
if (body.amount !== undefined) expense.amount = Math.max(0, Number(body.amount) || 0);
|
||||||
|
if (body.date !== undefined) expense.date = new Date(body.date);
|
||||||
|
if (body.notes !== undefined) expense.notes = String(body.notes).trim().slice(0, 2000);
|
||||||
|
|
||||||
|
await expense.save();
|
||||||
|
return expense;
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteExpense = async (id) => {
|
||||||
|
const expense = await Expense.findByIdAndDelete(id);
|
||||||
|
if (!expense) throw new AppError('EXPENSE_NOT_FOUND');
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Sum of expense amounts whose `date` falls within [start, end] — used by date-range financial reports. */
|
||||||
|
const sumExpensesInRange = async (start, end) => {
|
||||||
|
const result = await Expense.aggregate([
|
||||||
|
{ $match: { date: { $gte: start, $lte: end } } },
|
||||||
|
{ $group: { _id: null, total: { $sum: '$amount' } } }
|
||||||
|
]);
|
||||||
|
return result[0]?.total || 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
createExpense,
|
||||||
|
getAllExpenses,
|
||||||
|
getExpenseById,
|
||||||
|
updateExpense,
|
||||||
|
deleteExpense,
|
||||||
|
sumExpensesInRange
|
||||||
|
};
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
// /components/expenses/expenseValidator.js
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const AppError = require('../../utils/AppError');
|
||||||
|
|
||||||
|
const validateBody = (body, { isUpdate = false } = {}) => {
|
||||||
|
const details = {};
|
||||||
|
|
||||||
|
if (!isUpdate || body.title !== undefined) {
|
||||||
|
if (!body.title || !String(body.title).trim()) {
|
||||||
|
details.title = 'عنوان هزینه الزامی است';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isUpdate || body.amount !== undefined) {
|
||||||
|
if (body.amount === undefined || body.amount === null || Number(body.amount) <= 0) {
|
||||||
|
details.amount = 'مبلغ هزینه باید بیشتر از صفر باشد';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isUpdate || body.date !== undefined) {
|
||||||
|
if (!body.date || Number.isNaN(new Date(body.date).getTime())) {
|
||||||
|
details.date = 'تاریخ هزینه الزامی و باید معتبر باشد';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(details).length) {
|
||||||
|
throw new AppError('VALIDATION_FAILED', details);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateCreateExpense = (req, res, next) => {
|
||||||
|
try {
|
||||||
|
validateBody(req.body, { isUpdate: false });
|
||||||
|
next();
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateUpdateExpense = (req, res, next) => {
|
||||||
|
try {
|
||||||
|
validateBody(req.body, { isUpdate: true });
|
||||||
|
next();
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = { validateCreateExpense, validateUpdateExpense };
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
// /components/financialReports/financialReportController.js
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const catchAsync = require('../../utils/catchAsync');
|
||||||
|
const financialReportService = require('./financialReportService');
|
||||||
|
const { successResponse } = require('../../utils/apiResponse');
|
||||||
|
|
||||||
|
exports.getClassReport = catchAsync(async (req, res) => {
|
||||||
|
const report = await financialReportService.getClassReport(req.params.classId);
|
||||||
|
return successResponse(res, 200, 'Class financial report retrieved successfully', report);
|
||||||
|
});
|
||||||
|
|
||||||
|
exports.getSessionReport = catchAsync(async (req, res) => {
|
||||||
|
const report = await financialReportService.getSessionReport(req.params.sessionId);
|
||||||
|
return successResponse(res, 200, 'Session financial report retrieved successfully', report);
|
||||||
|
});
|
||||||
|
|
||||||
|
exports.getRangeReport = catchAsync(async (req, res) => {
|
||||||
|
const report = await financialReportService.getRangeReport(req.query);
|
||||||
|
return successResponse(res, 200, 'Date-range financial report retrieved successfully', report);
|
||||||
|
});
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
// /components/financialReports/financialReportRoutes.js
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const financialReportController = require('./financialReportController');
|
||||||
|
const authMiddleware = require('../../middlewares/authMiddleware');
|
||||||
|
const perm = require('../../middlewares/permissionMiddleware');
|
||||||
|
const { PERMISSIONS } = require('../../constants/permissions');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.use(authMiddleware);
|
||||||
|
router.use(perm.requires(PERMISSIONS.FINANCIAL_REPORTS_READ));
|
||||||
|
|
||||||
|
router.get('/admin/range', financialReportController.getRangeReport);
|
||||||
|
router.get('/admin/classes/:classId', financialReportController.getClassReport);
|
||||||
|
router.get('/admin/sessions/:sessionId', financialReportController.getSessionReport);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
// /components/financialReports/financialReportService.js
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const Class = require('../classes/classModel');
|
||||||
|
const Session = require('../sessions/sessionModel');
|
||||||
|
const Payment = require('../payments/paymentModel');
|
||||||
|
const Transaction = require('../payments/transactionModel');
|
||||||
|
const AppError = require('../../utils/AppError');
|
||||||
|
const { resolveDateRange, isWithinRange } = require('../../utils/financialRange');
|
||||||
|
const {
|
||||||
|
getPayableAmount,
|
||||||
|
isPaidTransaction,
|
||||||
|
isActiveTransaction
|
||||||
|
} = require('../../utils/paymentAmount');
|
||||||
|
const {
|
||||||
|
resolveSessionDurationHours,
|
||||||
|
calculateSessionDurationHours,
|
||||||
|
calculateBaseShare,
|
||||||
|
calculateExtraExpenses,
|
||||||
|
calculateProfessorPayout,
|
||||||
|
calculateNetProfit,
|
||||||
|
calculatePendingReceivables,
|
||||||
|
calculateOverpaidAmount,
|
||||||
|
calculateStudentRevenuePerSession
|
||||||
|
} = require('../../utils/professorShare');
|
||||||
|
const { sumExpensesInRange } = require('../expenses/expenseService');
|
||||||
|
|
||||||
|
const normalizeId = (value) => {
|
||||||
|
if (value == null) return '';
|
||||||
|
if (typeof value === 'object' && value._id != null) return String(value._id);
|
||||||
|
return String(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Revenue collected/owed for a set of classIds, derived from Payment records (one payment ~ one class enrollment). */
|
||||||
|
const getRevenueByClass = async (classIds) => {
|
||||||
|
const payments = await Payment.find({ classes: { $in: classIds } })
|
||||||
|
.select('classes amount discount paidAmount')
|
||||||
|
.lean();
|
||||||
|
|
||||||
|
const byClass = new Map(classIds.map((id) => [String(id), { expectedRevenue: 0, actualReceivedRevenue: 0 }]));
|
||||||
|
for (const payment of payments) {
|
||||||
|
const payable = getPayableAmount(payment);
|
||||||
|
const paid = Math.max(0, Number(payment.paidAmount) || 0);
|
||||||
|
for (const classRef of payment.classes || []) {
|
||||||
|
const key = String(classRef);
|
||||||
|
if (!byClass.has(key)) continue;
|
||||||
|
const entry = byClass.get(key);
|
||||||
|
entry.expectedRevenue += payable;
|
||||||
|
entry.actualReceivedRevenue += paid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return byClass;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSessionCountsByClass = async (classIds) => {
|
||||||
|
const sessions = await Session.find({ class: { $in: classIds } }).select('class status').lean();
|
||||||
|
const byClass = new Map(classIds.map((id) => [String(id), { total: 0, held: 0, scheduled: 0, cancelled: 0 }]));
|
||||||
|
for (const session of sessions) {
|
||||||
|
const key = String(session.class);
|
||||||
|
if (!byClass.has(key)) continue;
|
||||||
|
const entry = byClass.get(key);
|
||||||
|
entry.total += 1;
|
||||||
|
if (session.status === 'held') entry.held += 1;
|
||||||
|
else if (session.status === 'cancelled') entry.cancelled += 1;
|
||||||
|
else entry.scheduled += 1;
|
||||||
|
}
|
||||||
|
return byClass;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-Class Report (Lifetime): aggregates all sessions, student payments (received vs.
|
||||||
|
* pending), professor payout (share + expenses), and net class profit.
|
||||||
|
*/
|
||||||
|
const getClassReport = async (classId) => {
|
||||||
|
const cls = await Class.findById(classId)
|
||||||
|
.populate({ path: 'course', select: 'title hoursPerSection sectionCount' })
|
||||||
|
.populate({ path: 'professor', select: 'name surname' })
|
||||||
|
.lean();
|
||||||
|
if (!cls) throw new AppError('CLASS_NOT_FOUND');
|
||||||
|
|
||||||
|
const [revenueByClass, sessionCountsByClass] = await Promise.all([
|
||||||
|
getRevenueByClass([cls._id]),
|
||||||
|
getSessionCountsByClass([cls._id])
|
||||||
|
]);
|
||||||
|
|
||||||
|
const revenue = revenueByClass.get(String(cls._id)) || { expectedRevenue: 0, actualReceivedRevenue: 0 };
|
||||||
|
const sessionCounts = sessionCountsByClass.get(String(cls._id)) || { total: 0, held: 0, scheduled: 0, cancelled: 0 };
|
||||||
|
|
||||||
|
const sessionDurationHours = resolveSessionDurationHours(cls);
|
||||||
|
const payout = calculateProfessorPayout({
|
||||||
|
payoutType: cls.payoutType,
|
||||||
|
payoutPercentage: cls.payoutPercentage,
|
||||||
|
payoutHourlyRate: cls.payoutHourlyRate,
|
||||||
|
revenue: revenue.actualReceivedRevenue,
|
||||||
|
sessionDurationHours,
|
||||||
|
sessionsCount: sessionCounts.held,
|
||||||
|
extraExpensePerSession: cls.extraExpensePerSession
|
||||||
|
});
|
||||||
|
|
||||||
|
const netProfit = calculateNetProfit({
|
||||||
|
totalIncome: revenue.actualReceivedRevenue,
|
||||||
|
professorTotalPayout: payout.totalPayout
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
class: {
|
||||||
|
_id: cls._id,
|
||||||
|
name: cls.name,
|
||||||
|
course: cls.course,
|
||||||
|
professor: cls.professor,
|
||||||
|
numberOfSessions: cls.numberOfSessions,
|
||||||
|
studentsCount: (cls.students || []).length,
|
||||||
|
payoutType: cls.payoutType,
|
||||||
|
payoutPercentage: cls.payoutPercentage,
|
||||||
|
payoutHourlyRate: cls.payoutHourlyRate,
|
||||||
|
extraExpensePerSession: cls.extraExpensePerSession,
|
||||||
|
sessionDurationHours
|
||||||
|
},
|
||||||
|
sessions: {
|
||||||
|
planned: cls.numberOfSessions ?? sessionCounts.total,
|
||||||
|
held: sessionCounts.held,
|
||||||
|
scheduled: sessionCounts.scheduled,
|
||||||
|
cancelled: sessionCounts.cancelled
|
||||||
|
},
|
||||||
|
revenue: {
|
||||||
|
expectedRevenue: revenue.expectedRevenue,
|
||||||
|
actualReceivedRevenue: revenue.actualReceivedRevenue,
|
||||||
|
pendingReceivables: calculatePendingReceivables(revenue.expectedRevenue, revenue.actualReceivedRevenue),
|
||||||
|
overpaidAmount: calculateOverpaidAmount(revenue.expectedRevenue, revenue.actualReceivedRevenue)
|
||||||
|
},
|
||||||
|
professorPayout: payout,
|
||||||
|
netProfit
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-Session Report: evaluates one specific session — student revenue attributable to
|
||||||
|
* this session, the professor's session-level share/expenses, and the session net margin.
|
||||||
|
*/
|
||||||
|
const getSessionReport = async (sessionId) => {
|
||||||
|
const session = await Session.findById(sessionId)
|
||||||
|
.populate({
|
||||||
|
path: 'class',
|
||||||
|
populate: [
|
||||||
|
{ path: 'course', select: 'title hoursPerSection sectionCount' },
|
||||||
|
{ path: 'professor', select: 'name surname' }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
.lean();
|
||||||
|
if (!session) throw new AppError('SESSION_NOT_FOUND');
|
||||||
|
|
||||||
|
const cls = session.class;
|
||||||
|
if (!cls) throw new AppError('CLASS_NOT_FOUND');
|
||||||
|
|
||||||
|
const studentsCount = (cls.students || []).length;
|
||||||
|
const plannedSessions = cls.numberOfSessions || (await Session.countDocuments({ class: cls._id }));
|
||||||
|
|
||||||
|
const revenueByClass = await getRevenueByClass([cls._id]);
|
||||||
|
const revenue = revenueByClass.get(String(cls._id)) || { expectedRevenue: 0, actualReceivedRevenue: 0 };
|
||||||
|
|
||||||
|
// Prefer real payment data (handles per-student discounts); fall back to the class tuition when no payments exist yet.
|
||||||
|
const finalTuitionFee = cls.hasDiscount ? Math.max(0, (cls.tuitionFee || 0) - (cls.discount || 0)) : (cls.tuitionFee || 0);
|
||||||
|
const assignedTuitionPerStudent = studentsCount > 0 && revenue.expectedRevenue > 0
|
||||||
|
? revenue.expectedRevenue / studentsCount
|
||||||
|
: finalTuitionFee;
|
||||||
|
|
||||||
|
const studentRevenuePerSession = calculateStudentRevenuePerSession(assignedTuitionPerStudent, plannedSessions);
|
||||||
|
const sessionIncome = studentRevenuePerSession * studentsCount;
|
||||||
|
|
||||||
|
const sessionDurationHours = calculateSessionDurationHours(session.startTime, session.endTime)
|
||||||
|
|| resolveSessionDurationHours(cls);
|
||||||
|
|
||||||
|
const sessionShare = calculateBaseShare({
|
||||||
|
payoutType: cls.payoutType,
|
||||||
|
payoutPercentage: cls.payoutPercentage,
|
||||||
|
revenue: sessionIncome,
|
||||||
|
payoutHourlyRate: cls.payoutHourlyRate,
|
||||||
|
sessionDurationHours,
|
||||||
|
sessionsCount: 1
|
||||||
|
});
|
||||||
|
const sessionExtraExpense = calculateExtraExpenses({
|
||||||
|
extraExpensePerSession: cls.extraExpensePerSession,
|
||||||
|
sessionsCount: 1
|
||||||
|
});
|
||||||
|
|
||||||
|
const sessionNetMargin = calculateNetProfit({
|
||||||
|
totalIncome: sessionIncome,
|
||||||
|
professorTotalPayout: sessionShare + sessionExtraExpense
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
session: {
|
||||||
|
_id: session._id,
|
||||||
|
topic: session.topic,
|
||||||
|
day: session.day,
|
||||||
|
startTime: session.startTime,
|
||||||
|
endTime: session.endTime,
|
||||||
|
status: session.status
|
||||||
|
},
|
||||||
|
class: {
|
||||||
|
_id: cls._id,
|
||||||
|
name: cls.name,
|
||||||
|
course: cls.course,
|
||||||
|
professor: cls.professor,
|
||||||
|
studentsCount,
|
||||||
|
plannedSessions
|
||||||
|
},
|
||||||
|
financials: {
|
||||||
|
studentRevenuePerSession,
|
||||||
|
sessionIncome,
|
||||||
|
sessionDurationHours,
|
||||||
|
professorSessionShare: sessionShare,
|
||||||
|
sessionExtraExpense,
|
||||||
|
sessionTotalPayout: sessionShare + sessionExtraExpense,
|
||||||
|
sessionNetMargin
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Date-Range / Monthly Report: sessions held across all active classes within [start, end],
|
||||||
|
* combined with general institutional expenses recorded in that same window.
|
||||||
|
*/
|
||||||
|
const getRangeReport = async (query = {}) => {
|
||||||
|
const { start, end } = resolveDateRange(query);
|
||||||
|
|
||||||
|
const classFilter = { isActive: { $ne: false } };
|
||||||
|
if (query.classId) classFilter._id = query.classId;
|
||||||
|
|
||||||
|
const classes = await Class.find(classFilter)
|
||||||
|
.populate({ path: 'course', select: 'title hoursPerSection' })
|
||||||
|
.populate({ path: 'professor', select: 'name surname' })
|
||||||
|
.lean();
|
||||||
|
const classIds = classes.map((c) => c._id);
|
||||||
|
|
||||||
|
if (!classIds.length) {
|
||||||
|
const generalExpenses = await sumExpensesInRange(start, end);
|
||||||
|
return {
|
||||||
|
range: { start, end },
|
||||||
|
classes: [],
|
||||||
|
totals: {
|
||||||
|
totalReceived: 0,
|
||||||
|
totalOutstanding: 0,
|
||||||
|
totalProfessorPayouts: 0,
|
||||||
|
generalExpenses,
|
||||||
|
netProfit: -generalExpenses
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessions = await Session.find({ class: { $in: classIds }, day: { $gte: start, $lte: end } })
|
||||||
|
.select('class status day')
|
||||||
|
.lean();
|
||||||
|
|
||||||
|
const heldByClass = new Map();
|
||||||
|
for (const session of sessions) {
|
||||||
|
if (session.status !== 'held') continue;
|
||||||
|
const key = String(session.class);
|
||||||
|
heldByClass.set(key, (heldByClass.get(key) || 0) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payments = await Payment.find({ classes: { $in: classIds } }).select('_id classes').lean();
|
||||||
|
const paymentClassByPaymentId = new Map();
|
||||||
|
const paymentIds = [];
|
||||||
|
for (const payment of payments) {
|
||||||
|
paymentIds.push(payment._id);
|
||||||
|
const matchedClassId = (payment.classes || []).map(normalizeId).find((id) => classIds.some((c) => String(c) === id));
|
||||||
|
paymentClassByPaymentId.set(String(payment._id), matchedClassId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const transactions = paymentIds.length
|
||||||
|
? await Transaction.find({ payment: { $in: paymentIds } }).select('payment amount status date dueDate').lean()
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const receivedByClass = new Map();
|
||||||
|
const outstandingByClass = new Map();
|
||||||
|
for (const trx of transactions) {
|
||||||
|
const classKey = paymentClassByPaymentId.get(String(trx.payment));
|
||||||
|
if (!classKey) continue;
|
||||||
|
|
||||||
|
if (isPaidTransaction(trx) && isWithinRange(trx.date, start, end)) {
|
||||||
|
receivedByClass.set(classKey, (receivedByClass.get(classKey) || 0) + (Number(trx.amount) || 0));
|
||||||
|
} else if (isActiveTransaction(trx) && !isPaidTransaction(trx) && isWithinRange(trx.dueDate, start, end)) {
|
||||||
|
outstandingByClass.set(classKey, (outstandingByClass.get(classKey) || 0) + (Number(trx.amount) || 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const classRows = classes.map((cls) => {
|
||||||
|
const key = String(cls._id);
|
||||||
|
const sessionsHeldInRange = heldByClass.get(key) || 0;
|
||||||
|
const receivedInRange = receivedByClass.get(key) || 0;
|
||||||
|
const outstandingInRange = outstandingByClass.get(key) || 0;
|
||||||
|
const sessionDurationHours = resolveSessionDurationHours(cls);
|
||||||
|
|
||||||
|
const payout = calculateProfessorPayout({
|
||||||
|
payoutType: cls.payoutType,
|
||||||
|
payoutPercentage: cls.payoutPercentage,
|
||||||
|
payoutHourlyRate: cls.payoutHourlyRate,
|
||||||
|
revenue: receivedInRange,
|
||||||
|
sessionDurationHours,
|
||||||
|
sessionsCount: sessionsHeldInRange,
|
||||||
|
extraExpensePerSession: cls.extraExpensePerSession
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
class: {
|
||||||
|
_id: cls._id,
|
||||||
|
name: cls.name,
|
||||||
|
course: cls.course,
|
||||||
|
professor: cls.professor
|
||||||
|
},
|
||||||
|
sessionsHeldInRange,
|
||||||
|
receivedInRange,
|
||||||
|
outstandingInRange,
|
||||||
|
professorPayout: payout
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const generalExpenses = await sumExpensesInRange(start, end);
|
||||||
|
const totalReceived = classRows.reduce((sum, row) => sum + row.receivedInRange, 0);
|
||||||
|
const totalOutstanding = classRows.reduce((sum, row) => sum + row.outstandingInRange, 0);
|
||||||
|
const totalProfessorPayouts = classRows.reduce((sum, row) => sum + row.professorPayout.totalPayout, 0);
|
||||||
|
const netProfit = totalReceived - totalProfessorPayouts - generalExpenses;
|
||||||
|
|
||||||
|
return {
|
||||||
|
range: { start, end },
|
||||||
|
classes: classRows,
|
||||||
|
totals: {
|
||||||
|
totalReceived,
|
||||||
|
totalOutstanding,
|
||||||
|
totalProfessorPayouts,
|
||||||
|
generalExpenses,
|
||||||
|
netProfit
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getClassReport,
|
||||||
|
getSessionReport,
|
||||||
|
getRangeReport
|
||||||
|
};
|
||||||
@@ -12,6 +12,7 @@ const Course = require('../courses/courseModel');
|
|||||||
const Class = require('../classes/classModel');
|
const Class = require('../classes/classModel');
|
||||||
const Session = require('../sessions/sessionModel');
|
const Session = require('../sessions/sessionModel');
|
||||||
const { omitNotifyFields } = require('../../utils/notifyFlags');
|
const { omitNotifyFields } = require('../../utils/notifyFlags');
|
||||||
|
const { parseImportDate } = require('../../utils/jalaliDate');
|
||||||
const { resolveNotifyFlags } = require('../../utils/notifyResolver');
|
const { resolveNotifyFlags } = require('../../utils/notifyResolver');
|
||||||
const { notifyAction } = require('../../utils/actionNotify');
|
const { notifyAction } = require('../../utils/actionNotify');
|
||||||
const {
|
const {
|
||||||
|
|||||||
@@ -90,7 +90,16 @@ const PERMISSIONS = {
|
|||||||
|
|
||||||
// Pending student registrations
|
// Pending student registrations
|
||||||
PENDING_STUDENTS_READ: 'pending_students:read',
|
PENDING_STUDENTS_READ: 'pending_students:read',
|
||||||
PENDING_STUDENTS_UPDATE: 'pending_students:update'
|
PENDING_STUDENTS_UPDATE: 'pending_students:update',
|
||||||
|
|
||||||
|
// Institutional expenses (used for financial reporting)
|
||||||
|
EXPENSES_CREATE: 'expenses:create',
|
||||||
|
EXPENSES_READ: 'expenses:read',
|
||||||
|
EXPENSES_UPDATE: 'expenses:update',
|
||||||
|
EXPENSES_DELETE: 'expenses:delete',
|
||||||
|
|
||||||
|
// Financial reports (professor share, class profitability, date-range analytics)
|
||||||
|
FINANCIAL_REPORTS_READ: 'financial_reports:read'
|
||||||
};
|
};
|
||||||
|
|
||||||
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/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"
|
"test": "node --test 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",
|
||||||
|
|||||||
@@ -57,7 +57,11 @@ const defaultRoles = [
|
|||||||
PERMISSIONS.CONTACT_INQUIRIES_READ,
|
PERMISSIONS.CONTACT_INQUIRIES_READ,
|
||||||
PERMISSIONS.CONTACT_INQUIRIES_UPDATE,
|
PERMISSIONS.CONTACT_INQUIRIES_UPDATE,
|
||||||
PERMISSIONS.PENDING_STUDENTS_READ,
|
PERMISSIONS.PENDING_STUDENTS_READ,
|
||||||
PERMISSIONS.PENDING_STUDENTS_UPDATE
|
PERMISSIONS.PENDING_STUDENTS_UPDATE,
|
||||||
|
PERMISSIONS.EXPENSES_CREATE,
|
||||||
|
PERMISSIONS.EXPENSES_READ,
|
||||||
|
PERMISSIONS.EXPENSES_UPDATE,
|
||||||
|
PERMISSIONS.FINANCIAL_REPORTS_READ
|
||||||
],
|
],
|
||||||
isSystem: true
|
isSystem: true
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -94,6 +94,11 @@
|
|||||||
"en": "Class not found.",
|
"en": "Class not found.",
|
||||||
"fa": "کلاس یافت نشد."
|
"fa": "کلاس یافت نشد."
|
||||||
},
|
},
|
||||||
|
"EXPENSE_NOT_FOUND": {
|
||||||
|
"statusCode": 404,
|
||||||
|
"en": "Expense record not found.",
|
||||||
|
"fa": "هزینه یافت نشد."
|
||||||
|
},
|
||||||
"DEFAULT_ROLE_NOT_FOUND": {
|
"DEFAULT_ROLE_NOT_FOUND": {
|
||||||
"statusCode": 500,
|
"statusCode": 500,
|
||||||
"en": "Default user role was not found. Please seed the database.",
|
"en": "Default user role was not found. Please seed the database.",
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a reporting date range from either an explicit start/end pair or a
|
||||||
|
* `month` + `year` shorthand (both 1-indexed month). Falls back to "all time"
|
||||||
|
* when nothing is provided so the same helper can power both the lifetime and
|
||||||
|
* the date-range/monthly reports.
|
||||||
|
*/
|
||||||
|
const resolveDateRange = ({ startDate, endDate, month, year } = {}) => {
|
||||||
|
let start = startDate ? new Date(startDate) : null;
|
||||||
|
let end = endDate ? new Date(endDate) : null;
|
||||||
|
|
||||||
|
if ((!start || Number.isNaN(start.getTime())) && month != null && year != null) {
|
||||||
|
const monthIndex = Number(month) - 1;
|
||||||
|
const yearNumber = Number(year);
|
||||||
|
if (Number.isInteger(monthIndex) && Number.isInteger(yearNumber)) {
|
||||||
|
start = new Date(yearNumber, monthIndex, 1, 0, 0, 0, 0);
|
||||||
|
end = new Date(yearNumber, monthIndex + 1, 0, 23, 59, 59, 999);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!start || Number.isNaN(start.getTime())) {
|
||||||
|
start = new Date(0);
|
||||||
|
} else {
|
||||||
|
start.setHours(0, 0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!end || Number.isNaN(end.getTime())) {
|
||||||
|
end = new Date();
|
||||||
|
} else {
|
||||||
|
end.setHours(23, 59, 59, 999);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (start.getTime() > end.getTime()) {
|
||||||
|
const swap = start;
|
||||||
|
start = end;
|
||||||
|
end = swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { start, end };
|
||||||
|
};
|
||||||
|
|
||||||
|
const isWithinRange = (value, start, end) => {
|
||||||
|
if (!value) return false;
|
||||||
|
const date = value instanceof Date ? value : new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return false;
|
||||||
|
return date.getTime() >= start.getTime() && date.getTime() <= end.getTime();
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
resolveDateRange,
|
||||||
|
isWithinRange
|
||||||
|
};
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { describe, it } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { resolveDateRange, isWithinRange } = require('./financialRange');
|
||||||
|
|
||||||
|
describe('resolveDateRange', () => {
|
||||||
|
it('builds a range from explicit start/end dates', () => {
|
||||||
|
const { start, end } = resolveDateRange({ startDate: '2026-01-01', endDate: '2026-01-31' });
|
||||||
|
assert.equal(start.getFullYear(), 2026);
|
||||||
|
assert.equal(start.getMonth(), 0);
|
||||||
|
assert.equal(start.getDate(), 1);
|
||||||
|
assert.equal(end.getDate(), 31);
|
||||||
|
assert.equal(end.getHours(), 23);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds a range from month + year shorthand', () => {
|
||||||
|
const { start, end } = resolveDateRange({ month: 2, year: 2026 });
|
||||||
|
assert.equal(start.getMonth(), 1);
|
||||||
|
assert.equal(start.getDate(), 1);
|
||||||
|
assert.equal(end.getMonth(), 1);
|
||||||
|
assert.equal(end.getDate(), 28); // 2026 is not a leap year
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to all-time when nothing is provided', () => {
|
||||||
|
const { start, end } = resolveDateRange({});
|
||||||
|
assert.ok(start.getFullYear() <= 1970);
|
||||||
|
assert.ok(end.getTime() <= Date.now());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('swaps start/end when given out of order', () => {
|
||||||
|
const { start, end } = resolveDateRange({ startDate: '2026-03-01', endDate: '2026-01-01' });
|
||||||
|
assert.ok(start.getTime() < end.getTime());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isWithinRange', () => {
|
||||||
|
it('detects dates inside and outside the range', () => {
|
||||||
|
const { start, end } = resolveDateRange({ startDate: '2026-01-01', endDate: '2026-01-31' });
|
||||||
|
assert.equal(isWithinRange('2026-01-15', start, end), true);
|
||||||
|
assert.equal(isWithinRange('2026-02-01', start, end), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for missing or invalid values', () => {
|
||||||
|
const { start, end } = resolveDateRange({ startDate: '2026-01-01', endDate: '2026-01-31' });
|
||||||
|
assert.equal(isWithinRange(null, start, end), false);
|
||||||
|
assert.equal(isWithinRange('not-a-date', start, end), false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { normalizeClockTime } = require('./classSchedule');
|
||||||
|
|
||||||
|
const PAYOUT_TYPES = {
|
||||||
|
PERCENTAGE: 'percentage',
|
||||||
|
HOURLY: 'hourly'
|
||||||
|
};
|
||||||
|
|
||||||
|
const toNonNegativeNumber = (value) => {
|
||||||
|
const n = Number(value);
|
||||||
|
if (!Number.isFinite(n) || n < 0) return 0;
|
||||||
|
return n;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Clamps a percentage-style value into the 0-100 range. */
|
||||||
|
const toPercentage = (value) => Math.min(100, toNonNegativeNumber(value));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses "HH:MM" clock strings and returns the duration between them, in hours.
|
||||||
|
* Returns 0 for missing/invalid input. Assumes the session does not cross midnight
|
||||||
|
* unless the end time is numerically before the start time (e.g. 23:00 -> 01:00).
|
||||||
|
*/
|
||||||
|
const calculateSessionDurationHours = (startTime, endTime) => {
|
||||||
|
const start = normalizeClockTime(startTime);
|
||||||
|
const end = normalizeClockTime(endTime);
|
||||||
|
if (!start || !end) return 0;
|
||||||
|
|
||||||
|
const [startH, startM] = start.split(':').map(Number);
|
||||||
|
const [endH, endM] = end.split(':').map(Number);
|
||||||
|
let diffMinutes = (endH * 60 + endM) - (startH * 60 + startM);
|
||||||
|
if (diffMinutes <= 0) diffMinutes += 24 * 60;
|
||||||
|
|
||||||
|
return diffMinutes / 60;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves "Session Duration in Hours" for a class: prefers the linked course's
|
||||||
|
* `hoursPerSection`, falling back to the class's own start/end time window.
|
||||||
|
*/
|
||||||
|
const resolveSessionDurationHours = (cls = {}) => {
|
||||||
|
const course = cls.course && typeof cls.course === 'object' ? cls.course : null;
|
||||||
|
const hoursPerSection = toNonNegativeNumber(course?.hoursPerSection);
|
||||||
|
if (hoursPerSection > 0) return hoursPerSection;
|
||||||
|
return calculateSessionDurationHours(cls.startTime, cls.endTime);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Model A — percentage of class revenue.
|
||||||
|
* `revenue` is the amount the percentage should be applied to (e.g. actual received revenue).
|
||||||
|
*/
|
||||||
|
const calculatePercentageShare = ({ payoutPercentage = 0, revenue = 0 } = {}) => {
|
||||||
|
return (toPercentage(payoutPercentage) / 100) * toNonNegativeNumber(revenue);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Model B — hourly rate.
|
||||||
|
* Session Share = Hourly Rate * Session Duration in Hours
|
||||||
|
* Total Share = Session Share * Number of Sessions
|
||||||
|
*/
|
||||||
|
const calculateHourlyShare = ({ payoutHourlyRate = 0, sessionDurationHours = 0, sessionsCount = 0 } = {}) => {
|
||||||
|
const sessionShare = toNonNegativeNumber(payoutHourlyRate) * toNonNegativeNumber(sessionDurationHours);
|
||||||
|
return sessionShare * toNonNegativeNumber(sessionsCount);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Total Extra Expenses = extraExpensePerSession * Number of Sessions Held (always flat, per-session). */
|
||||||
|
const calculateExtraExpenses = ({ extraExpensePerSession = 0, sessionsCount = 0 } = {}) => {
|
||||||
|
return toNonNegativeNumber(extraExpensePerSession) * toNonNegativeNumber(sessionsCount);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes the professor's base share (before extra expenses) for either payout model.
|
||||||
|
*/
|
||||||
|
const calculateBaseShare = (params = {}) => {
|
||||||
|
const { payoutType } = params;
|
||||||
|
if (payoutType === PAYOUT_TYPES.HOURLY) {
|
||||||
|
return calculateHourlyShare(params);
|
||||||
|
}
|
||||||
|
return calculatePercentageShare(params);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Professor Total Payout = Base Share + (extraExpensePerSession * sessionsCount)
|
||||||
|
*/
|
||||||
|
const calculateProfessorPayout = (params = {}) => {
|
||||||
|
const baseShare = calculateBaseShare(params);
|
||||||
|
const extraExpenses = calculateExtraExpenses(params);
|
||||||
|
return {
|
||||||
|
baseShare,
|
||||||
|
extraExpenses,
|
||||||
|
totalPayout: baseShare + extraExpenses
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Net Profit = Total Income - Professor Total Payout.
|
||||||
|
* Intentionally NOT clamped to zero — a negative result represents a real loss.
|
||||||
|
*/
|
||||||
|
const calculateNetProfit = ({ totalIncome = 0, professorTotalPayout = 0 } = {}) => {
|
||||||
|
return toNonNegativeNumber(totalIncome) - toNonNegativeNumber(professorTotalPayout);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Outstanding receivables can never be negative — overpayments are surfaced separately. */
|
||||||
|
const calculatePendingReceivables = (expectedRevenue = 0, actualReceivedRevenue = 0) => {
|
||||||
|
return Math.max(0, toNonNegativeNumber(expectedRevenue) - toNonNegativeNumber(actualReceivedRevenue));
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Amount collected beyond what was actually owed (graceful handling of overpayments). */
|
||||||
|
const calculateOverpaidAmount = (expectedRevenue = 0, actualReceivedRevenue = 0) => {
|
||||||
|
return Math.max(0, toNonNegativeNumber(actualReceivedRevenue) - toNonNegativeNumber(expectedRevenue));
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-session student revenue = Total Assigned Tuition per Student / Total Number of Planned Sessions.
|
||||||
|
* Guards against division by zero when a class has no planned sessions.
|
||||||
|
*/
|
||||||
|
const calculateStudentRevenuePerSession = (totalAssignedTuitionPerStudent = 0, totalPlannedSessions = 0) => {
|
||||||
|
const sessions = toNonNegativeNumber(totalPlannedSessions);
|
||||||
|
if (sessions <= 0) return 0;
|
||||||
|
return toNonNegativeNumber(totalAssignedTuitionPerStudent) / sessions;
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
PAYOUT_TYPES,
|
||||||
|
toNonNegativeNumber,
|
||||||
|
toPercentage,
|
||||||
|
calculateSessionDurationHours,
|
||||||
|
resolveSessionDurationHours,
|
||||||
|
calculatePercentageShare,
|
||||||
|
calculateHourlyShare,
|
||||||
|
calculateExtraExpenses,
|
||||||
|
calculateBaseShare,
|
||||||
|
calculateProfessorPayout,
|
||||||
|
calculateNetProfit,
|
||||||
|
calculatePendingReceivables,
|
||||||
|
calculateOverpaidAmount,
|
||||||
|
calculateStudentRevenuePerSession
|
||||||
|
};
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { describe, it } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const {
|
||||||
|
calculateSessionDurationHours,
|
||||||
|
resolveSessionDurationHours,
|
||||||
|
calculatePercentageShare,
|
||||||
|
calculateHourlyShare,
|
||||||
|
calculateExtraExpenses,
|
||||||
|
calculateProfessorPayout,
|
||||||
|
calculateNetProfit,
|
||||||
|
calculatePendingReceivables,
|
||||||
|
calculateOverpaidAmount,
|
||||||
|
calculateStudentRevenuePerSession
|
||||||
|
} = require('./professorShare');
|
||||||
|
|
||||||
|
describe('calculateSessionDurationHours', () => {
|
||||||
|
it('computes the duration between two clock times', () => {
|
||||||
|
assert.equal(calculateSessionDurationHours('18:00', '20:00'), 2);
|
||||||
|
assert.equal(calculateSessionDurationHours('9:30', '11:00'), 1.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 0 for missing or invalid input', () => {
|
||||||
|
assert.equal(calculateSessionDurationHours('', ''), 0);
|
||||||
|
assert.equal(calculateSessionDurationHours(null, undefined), 0);
|
||||||
|
assert.equal(calculateSessionDurationHours('25:00', '26:00'), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles sessions that cross midnight', () => {
|
||||||
|
assert.equal(calculateSessionDurationHours('23:00', '01:00'), 2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveSessionDurationHours', () => {
|
||||||
|
it('prefers the course hoursPerSection when present', () => {
|
||||||
|
assert.equal(resolveSessionDurationHours({ course: { hoursPerSection: 2 }, startTime: '18:00', endTime: '20:30' }), 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the class start/end time window', () => {
|
||||||
|
assert.equal(resolveSessionDurationHours({ course: null, startTime: '18:00', endTime: '20:00' }), 2);
|
||||||
|
assert.equal(resolveSessionDurationHours({ course: { hoursPerSection: 0 }, startTime: '18:00', endTime: '19:30' }), 1.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 0 when nothing is configured', () => {
|
||||||
|
assert.equal(resolveSessionDurationHours({}), 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('calculatePercentageShare (Model A)', () => {
|
||||||
|
it('applies the percentage to revenue', () => {
|
||||||
|
assert.equal(calculatePercentageShare({ payoutPercentage: 40, revenue: 10_000_000 }), 4_000_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);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 0 when there is no revenue', () => {
|
||||||
|
assert.equal(calculatePercentageShare({ payoutPercentage: 40, revenue: 0 }), 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('calculateHourlyShare (Model B)', () => {
|
||||||
|
it('multiplies rate * duration * sessions', () => {
|
||||||
|
// 200,000 Toman/hour * 2 hours * 10 sessions = 4,000,000
|
||||||
|
assert.equal(
|
||||||
|
calculateHourlyShare({ payoutHourlyRate: 200_000, sessionDurationHours: 2, sessionsCount: 10 }),
|
||||||
|
4_000_000
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles zero sessions held (no payout yet)', () => {
|
||||||
|
assert.equal(
|
||||||
|
calculateHourlyShare({ payoutHourlyRate: 200_000, sessionDurationHours: 2, sessionsCount: 0 }),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats negative or missing inputs as zero', () => {
|
||||||
|
assert.equal(calculateHourlyShare({}), 0);
|
||||||
|
assert.equal(calculateHourlyShare({ payoutHourlyRate: -100, sessionDurationHours: 2, sessionsCount: 5 }), 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('calculateExtraExpenses', () => {
|
||||||
|
it('multiplies the flat per-session allowance by sessions held', () => {
|
||||||
|
assert.equal(calculateExtraExpenses({ extraExpensePerSession: 50_000, sessionsCount: 8 }), 400_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults to 0 when not applicable', () => {
|
||||||
|
assert.equal(calculateExtraExpenses({}), 0);
|
||||||
|
assert.equal(calculateExtraExpenses({ extraExpensePerSession: 0, sessionsCount: 12 }), 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('calculateProfessorPayout', () => {
|
||||||
|
it('combines base share (percentage) with extra expenses', () => {
|
||||||
|
const result = calculateProfessorPayout({
|
||||||
|
payoutType: 'percentage',
|
||||||
|
payoutPercentage: 40,
|
||||||
|
revenue: 10_000_000,
|
||||||
|
extraExpensePerSession: 100_000,
|
||||||
|
sessionsCount: 5
|
||||||
|
});
|
||||||
|
assert.equal(result.baseShare, 4_000_000);
|
||||||
|
assert.equal(result.extraExpenses, 500_000);
|
||||||
|
assert.equal(result.totalPayout, 4_500_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('combines base share (hourly) with extra expenses', () => {
|
||||||
|
const result = calculateProfessorPayout({
|
||||||
|
payoutType: 'hourly',
|
||||||
|
payoutHourlyRate: 300_000,
|
||||||
|
sessionDurationHours: 1.5,
|
||||||
|
extraExpensePerSession: 80_000,
|
||||||
|
sessionsCount: 10
|
||||||
|
});
|
||||||
|
assert.equal(result.baseShare, 4_500_000);
|
||||||
|
assert.equal(result.extraExpenses, 800_000);
|
||||||
|
assert.equal(result.totalPayout, 5_300_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles a class with zero sessions held gracefully', () => {
|
||||||
|
const result = calculateProfessorPayout({
|
||||||
|
payoutType: 'hourly',
|
||||||
|
payoutHourlyRate: 300_000,
|
||||||
|
sessionDurationHours: 1.5,
|
||||||
|
extraExpensePerSession: 80_000,
|
||||||
|
sessionsCount: 0
|
||||||
|
});
|
||||||
|
assert.equal(result.baseShare, 0);
|
||||||
|
assert.equal(result.extraExpenses, 0);
|
||||||
|
assert.equal(result.totalPayout, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('calculateNetProfit', () => {
|
||||||
|
it('subtracts payout from income', () => {
|
||||||
|
assert.equal(calculateNetProfit({ totalIncome: 10_000_000, professorTotalPayout: 4_500_000 }), 5_500_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can go negative to represent a loss', () => {
|
||||||
|
assert.equal(calculateNetProfit({ totalIncome: 1_000_000, professorTotalPayout: 4_500_000 }), -3_500_000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('calculatePendingReceivables / calculateOverpaidAmount', () => {
|
||||||
|
it('computes outstanding receivables', () => {
|
||||||
|
assert.equal(calculatePendingReceivables(10_000_000, 6_000_000), 4_000_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never returns a negative outstanding amount when overpaid', () => {
|
||||||
|
assert.equal(calculatePendingReceivables(10_000_000, 12_000_000), 0);
|
||||||
|
assert.equal(calculateOverpaidAmount(10_000_000, 12_000_000), 2_000_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 0 overpaid when underpaid', () => {
|
||||||
|
assert.equal(calculateOverpaidAmount(10_000_000, 6_000_000), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles a class with zero expected revenue (no students)', () => {
|
||||||
|
assert.equal(calculatePendingReceivables(0, 0), 0);
|
||||||
|
assert.equal(calculateOverpaidAmount(0, 0), 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('calculateStudentRevenuePerSession', () => {
|
||||||
|
it('divides tuition evenly across planned sessions', () => {
|
||||||
|
assert.equal(calculateStudentRevenuePerSession(12_000_000, 12), 1_000_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 0 when there are no planned sessions (avoids division by zero)', () => {
|
||||||
|
assert.equal(calculateStudentRevenuePerSession(12_000_000, 0), 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user