diff --git a/app.js b/app.js index b35cb07..0fb6eff 100644 --- a/app.js +++ b/app.js @@ -29,6 +29,8 @@ const fileRoutes = require('./components/files/fileRoutes'); const dashboardRoutes = require('./components/dashboard/dashboardRoutes'); const activityLogRoutes = require('./components/activityLogs/activityLogRoutes'); const contactInquiryRoutes = require('./components/contactInquiries/contactInquiryRoutes'); +const expenseRoutes = require('./components/expenses/expenseRoutes'); +const financialReportRoutes = require('./components/financialReports/financialReportRoutes'); const activityLogger = require('./middlewares/activityLogger'); const app = express(); @@ -98,6 +100,8 @@ app.use('/api/files', fileRoutes); app.use('/api/dashboard', dashboardRoutes); app.use('/api/activity-logs', activityLogRoutes); app.use('/api/contact-inquiries', contactInquiryRoutes); +app.use('/api/expenses', expenseRoutes); +app.use('/api/financial-reports', financialReportRoutes); const pendingStudentRoutes = require('./components/pendingStudents/pendingStudentRoutes'); app.use('/api/pending-students', pendingStudentRoutes); app.use('/api/seed', seedRoutes); diff --git a/components/classes/classModel.js b/components/classes/classModel.js index aa304d7..b1428d6 100644 --- a/components/classes/classModel.js +++ b/components/classes/classModel.js @@ -79,6 +79,31 @@ const classSchema = new mongoose.Schema({ min: 1, 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: { type: Boolean, default: true diff --git a/components/classes/classService.js b/components/classes/classService.js index 1908eaf..b6c9582 100644 --- a/components/classes/classService.js +++ b/components/classes/classService.js @@ -19,7 +19,7 @@ const applyScheduleFields = (payload, body) => { return payload; }; -const CLASS_LIST_FIELDS = 'name course professor students capacity tuitionFee hasDiscount discount freeSpots showOnFrontend startDate endDate days startTime endTime numberOfSessions 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 tuitionFee = Math.max(0, Number(body.tuitionFee) || 0); @@ -30,6 +30,14 @@ const normalizePricingFields = (body = {}) => { 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) => { if (value === '' || value === null || value === undefined) return null; const parsed = Number(value); @@ -65,7 +73,7 @@ const getAll = async (query) => { const [items, total] = await Promise.all([ Class.find(filter) .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' }) .skip(skip).limit(limit).sort({ createdAt: -1 }).lean(), Class.countDocuments(filter) @@ -76,7 +84,7 @@ const getAll = async (query) => { const getOne = async (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: 'students', select: 'name phoneNumber gender' }) .lean(); @@ -88,6 +96,7 @@ const create = async (body) => { const payload = applyScheduleFields({ ...body, ...normalizePricingFields(body), + ...normalizePayoutFields(body), numberOfSessions: normalizeNumberOfSessions(body.numberOfSessions) }, body); if (body.freeSpots === '' || body.freeSpots === null || body.freeSpots === undefined) { @@ -103,6 +112,7 @@ const update = async (id, body) => { const payload = applyScheduleFields({ ...body, ...normalizePricingFields(body), + ...normalizePayoutFields(body), ...(body.numberOfSessions !== undefined ? { numberOfSessions: normalizeNumberOfSessions(body.numberOfSessions) } : {}) diff --git a/components/expenses/expenseController.js b/components/expenses/expenseController.js new file mode 100644 index 0000000..b0b571f --- /dev/null +++ b/components/expenses/expenseController.js @@ -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'); +}); diff --git a/components/expenses/expenseModel.js b/components/expenses/expenseModel.js new file mode 100644 index 0000000..47faf7c --- /dev/null +++ b/components/expenses/expenseModel.js @@ -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); diff --git a/components/expenses/expenseRoutes.js b/components/expenses/expenseRoutes.js new file mode 100644 index 0000000..73ff08a --- /dev/null +++ b/components/expenses/expenseRoutes.js @@ -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; diff --git a/components/expenses/expenseService.js b/components/expenses/expenseService.js new file mode 100644 index 0000000..a8f4452 --- /dev/null +++ b/components/expenses/expenseService.js @@ -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 +}; diff --git a/components/expenses/expenseValidator.js b/components/expenses/expenseValidator.js new file mode 100644 index 0000000..bb0d48d --- /dev/null +++ b/components/expenses/expenseValidator.js @@ -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 }; diff --git a/components/financialReports/financialReportController.js b/components/financialReports/financialReportController.js new file mode 100644 index 0000000..1e49dee --- /dev/null +++ b/components/financialReports/financialReportController.js @@ -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); +}); diff --git a/components/financialReports/financialReportRoutes.js b/components/financialReports/financialReportRoutes.js new file mode 100644 index 0000000..b9bb205 --- /dev/null +++ b/components/financialReports/financialReportRoutes.js @@ -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; diff --git a/components/financialReports/financialReportService.js b/components/financialReports/financialReportService.js new file mode 100644 index 0000000..bc14f30 --- /dev/null +++ b/components/financialReports/financialReportService.js @@ -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 +}; diff --git a/components/payments/paymentService.js b/components/payments/paymentService.js index 9149641..9234a1f 100644 --- a/components/payments/paymentService.js +++ b/components/payments/paymentService.js @@ -12,6 +12,7 @@ const Course = require('../courses/courseModel'); const Class = require('../classes/classModel'); const Session = require('../sessions/sessionModel'); const { omitNotifyFields } = require('../../utils/notifyFlags'); +const { parseImportDate } = require('../../utils/jalaliDate'); const { resolveNotifyFlags } = require('../../utils/notifyResolver'); const { notifyAction } = require('../../utils/actionNotify'); const { diff --git a/constants/permissions.js b/constants/permissions.js index 625f87a..45f32b1 100644 --- a/constants/permissions.js +++ b/constants/permissions.js @@ -90,7 +90,16 @@ const PERMISSIONS = { // Pending student registrations 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); diff --git a/package.json b/package.json index b68db1f..8f494b4 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "start": "node app.js", "dev": "nodemon app.js", "seed": "node seed.js", - "test": "node --test components/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": [ "express", diff --git a/seed.js b/seed.js index 8ce03ec..983a775 100644 --- a/seed.js +++ b/seed.js @@ -57,7 +57,11 @@ const defaultRoles = [ PERMISSIONS.CONTACT_INQUIRIES_READ, PERMISSIONS.CONTACT_INQUIRIES_UPDATE, 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 }, diff --git a/utils/errors.json b/utils/errors.json index c0c548a..a1a5306 100644 --- a/utils/errors.json +++ b/utils/errors.json @@ -94,6 +94,11 @@ "en": "Class not found.", "fa": "کلاس یافت نشد." }, + "EXPENSE_NOT_FOUND": { + "statusCode": 404, + "en": "Expense record not found.", + "fa": "هزینه یافت نشد." + }, "DEFAULT_ROLE_NOT_FOUND": { "statusCode": 500, "en": "Default user role was not found. Please seed the database.", diff --git a/utils/financialRange.js b/utils/financialRange.js new file mode 100644 index 0000000..85e8c8f --- /dev/null +++ b/utils/financialRange.js @@ -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 +}; diff --git a/utils/financialRange.test.js b/utils/financialRange.test.js new file mode 100644 index 0000000..478ff05 --- /dev/null +++ b/utils/financialRange.test.js @@ -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); + }); +}); diff --git a/utils/professorShare.js b/utils/professorShare.js new file mode 100644 index 0000000..1f10953 --- /dev/null +++ b/utils/professorShare.js @@ -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 +}; diff --git a/utils/professorShare.test.js b/utils/professorShare.test.js new file mode 100644 index 0000000..576f25f --- /dev/null +++ b/utils/professorShare.test.js @@ -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); + }); +});