// /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 }, isDeleted: { $ne: true } }) .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 }, isDeleted: { $ne: true } }).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 }, isDeleted: { $ne: true } }; 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 }, isDeleted: { $ne: true } }) .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 }, isDeleted: { $ne: true } }).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 };