// /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 Expense = require('../expenses/expenseModel'); const AppError = require('../../utils/AppError'); const { resolveDateRange, isWithinRange } = require('../../utils/financialRange'); const { startOfDay, buildDailyBuckets, buildWeeklyBuckets, buildMonthlyBuckets, buildForecastWeeklyBuckets, findBucketIndex } = require('../../utils/reportBuckets'); 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, serviceFeePerPerson: cls.serviceFeePerPerson, studentsCount: (cls.students || []).length }); 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, serviceFeePerPerson: (cls.serviceFeePerPerson || 0) / (plannedSessions || 1), studentsCount }); 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, serviceFeePerPerson: cls.serviceFeePerPerson, studentsCount: (cls.students || []).length }); 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 } }; }; const clampInt = (value, fallback, min, max) => { const n = parseInt(value, 10); if (!Number.isFinite(n)) return fallback; return Math.min(max, Math.max(min, n)); }; const buildStudentName = (user) => { if (!user || typeof user !== 'object') return '—'; return user.name || '—'; }; const emptyDaily = (buckets) => buckets.map((b) => ({ date: b.key, received: 0, sessionsHeld: 0, sessionIncome: 0 })); const emptyWeekly = (buckets) => buckets.map((b) => ({ weekStart: b.key, weekEnd: b.end.toISOString().slice(0, 10), received: 0, outstanding: 0, sessionsHeld: 0, sessionIncome: 0 })); const emptyMonthly = (buckets) => buckets.map((b) => ({ year: b.year, month: b.month, monthStart: b.start, received: 0, outstanding: 0, sessionsHeld: 0, professorPayouts: 0, generalExpenses: 0, netProfit: 0 })); const emptyForecastBuckets = (buckets) => buckets.map((b) => ({ weekStart: b.key, weekEnd: b.end.toISOString().slice(0, 10), expectedAmount: 0, count: 0 })); /** * Institute-wide financial analytics: daily & weekly income trends (cash received vs. * accrued session income), a multi-month received/payout/expense/profit breakdown, * per-class income leaderboard, payment-status distribution, and a forward-looking * cash-flow forecast built from outstanding transactions' due dates. */ const getAnalytics = async (query = {}) => { const now = new Date(); const dailyDays = clampInt(query.dailyDays, 30, 7, 90); const weeklyWeeks = clampInt(query.weeklyWeeks, 12, 4, 26); const monthlyMonths = clampInt(query.monthlyMonths, 6, 3, 24); const forecastWeeks = clampInt(query.forecastWeeks, 8, 4, 16); const dailyBuckets = buildDailyBuckets(dailyDays, now); const weeklyBuckets = buildWeeklyBuckets(weeklyWeeks, now); const monthlyBuckets = buildMonthlyBuckets(monthlyMonths, now); const forecastBuckets = buildForecastWeeklyBuckets(forecastWeeks, now); const earliestWindowStart = [dailyBuckets[0]?.start, weeklyBuckets[0]?.start, monthlyBuckets[0]?.start] .filter(Boolean) .reduce((min, d) => (d.getTime() < min.getTime() ? d : min), now); const classes = await Class.find({ isDeleted: { $ne: true } }) .populate({ path: 'course', select: 'title hoursPerSection' }) .populate({ path: 'professor', select: 'name surname' }) .lean(); const classIds = classes.map((c) => c._id); if (!classIds.length) { const generalExpensesAllTime = await sumExpensesInRange(new Date(0), now); return { generatedAt: now, daily: emptyDaily(dailyBuckets), weekly: emptyWeekly(weeklyBuckets), monthly: emptyMonthly(monthlyBuckets), overview: { totalExpectedRevenue: 0, totalReceived: 0, totalPendingReceivables: 0, overdueAmount: 0, overdueCount: 0, totalProfessorPayoutsAllTime: 0, totalGeneralExpensesAllTime: generalExpensesAllTime, netProfitAllTime: -generalExpensesAllTime, totalEnrollments: 0, totalActiveClasses: 0, totalClasses: 0, totalSessionsHeld: 0, totalSessionsPlanned: 0, avgIncomePerHeldSession: 0, avgExpectedIncomePerPlannedSession: 0 }, incomeByClass: [], paymentStatusBreakdown: [], upcomingDue: [], forecast: { overdueAmount: 0, overdueCount: 0, next7DaysAmount: 0, next30DaysAmount: 0, next60DaysAmount: 0, buckets: emptyForecastBuckets(forecastBuckets) } }; } const [revenueByClass, sessions, payments] = await Promise.all([ getRevenueByClass(classIds), Session.find({ class: { $in: classIds }, isDeleted: { $ne: true } }).select('class day status').lean(), Payment.find({ classes: { $in: classIds }, isDeleted: { $ne: true } }) .select('classes user') .populate('user', 'name') .lean() ]); const paymentClassByPaymentId = new Map(); const paymentUserByPaymentId = 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); paymentUserByPaymentId.set(String(payment._id), payment.user); } const [transactions, expenses, generalExpensesAllTime, paymentsFull] = await Promise.all([ paymentIds.length ? Transaction.find({ payment: { $in: paymentIds } }).select('payment amount status date dueDate').lean() : [], Expense.find({ date: { $gte: startOfDay(earliestWindowStart) } }).select('date amount').lean(), sumExpensesInRange(new Date(0), now), Payment.find({ isDeleted: { $ne: true } }).select('status amount discount').lean() ]); const sessionCountsByClass = new Map(classIds.map((id) => [String(id), { total: 0, held: 0 }])); for (const s of sessions) { const key = String(s.class); const entry = sessionCountsByClass.get(key); if (!entry) continue; entry.total += 1; if (s.status === 'held') entry.held += 1; } // ── Per-class financial profile (used throughout every bucket + the leaderboard) ── const classProfiles = new Map(); for (const cls of classes) { const key = String(cls._id); const revenue = revenueByClass.get(key) || { expectedRevenue: 0, actualReceivedRevenue: 0 }; const sessionCounts = sessionCountsByClass.get(key) || { total: 0, held: 0 }; const studentsCount = (cls.students || []).length; const plannedSessions = cls.numberOfSessions ?? sessionCounts.total; 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 perSessionIncomeEstimate = calculateStudentRevenuePerSession(assignedTuitionPerStudent, plannedSessions) * studentsCount; classProfiles.set(key, { id: cls._id, name: cls.name, professorName: cls.professor ? `${cls.professor.name || ''} ${cls.professor.surname || ''}`.trim() || '—' : '—', courseName: (cls.course && cls.course.title) || '—', isActive: cls.isActive !== false, payoutType: cls.payoutType, payoutPercentage: cls.payoutPercentage, payoutHourlyRate: cls.payoutHourlyRate, extraExpensePerSession: cls.extraExpensePerSession, serviceFeePerPerson: cls.serviceFeePerPerson, sessionDurationHours: resolveSessionDurationHours(cls), studentsCount, plannedSessions, heldSessions: sessionCounts.held, expectedRevenue: revenue.expectedRevenue, actualReceivedRevenue: revenue.actualReceivedRevenue, perSessionIncomeEstimate }); } // ── Daily / weekly / monthly buckets ────────────────────────────────────── const dailyReceived = new Array(dailyBuckets.length).fill(0); const dailyHeld = new Array(dailyBuckets.length).fill(0); const dailySessionIncome = new Array(dailyBuckets.length).fill(0); const weeklyReceived = new Array(weeklyBuckets.length).fill(0); const weeklyOutstanding = new Array(weeklyBuckets.length).fill(0); const weeklyHeld = new Array(weeklyBuckets.length).fill(0); const weeklySessionIncome = new Array(weeklyBuckets.length).fill(0); const monthlyReceived = new Array(monthlyBuckets.length).fill(0); const monthlyOutstanding = new Array(monthlyBuckets.length).fill(0); const monthlyHeld = new Array(monthlyBuckets.length).fill(0); const monthlyExpenses = new Array(monthlyBuckets.length).fill(0); const monthlyClassReceived = monthlyBuckets.map(() => new Map()); const monthlyClassHeld = monthlyBuckets.map(() => new Map()); for (const s of sessions) { if (s.status !== 'held' || !s.day) continue; const classKey = String(s.class); const profile = classProfiles.get(classKey); const perSessionIncome = profile ? profile.perSessionIncomeEstimate : 0; const di = findBucketIndex(dailyBuckets, s.day); if (di >= 0) { dailyHeld[di] += 1; dailySessionIncome[di] += perSessionIncome; } const wi = findBucketIndex(weeklyBuckets, s.day); if (wi >= 0) { weeklyHeld[wi] += 1; weeklySessionIncome[wi] += perSessionIncome; } const mi = findBucketIndex(monthlyBuckets, s.day); if (mi >= 0) { monthlyHeld[mi] += 1; const map = monthlyClassHeld[mi]; map.set(classKey, (map.get(classKey) || 0) + 1); } } for (const trx of transactions) { const classKey = paymentClassByPaymentId.get(String(trx.payment)); const paid = isPaidTransaction(trx); const active = isActiveTransaction(trx); const amount = Number(trx.amount) || 0; if (paid && trx.date) { const di = findBucketIndex(dailyBuckets, trx.date); if (di >= 0) dailyReceived[di] += amount; const wi = findBucketIndex(weeklyBuckets, trx.date); if (wi >= 0) weeklyReceived[wi] += amount; const mi = findBucketIndex(monthlyBuckets, trx.date); if (mi >= 0) { monthlyReceived[mi] += amount; if (classKey) { const map = monthlyClassReceived[mi]; map.set(classKey, (map.get(classKey) || 0) + amount); } } } else if (active && !paid && trx.dueDate) { const wi = findBucketIndex(weeklyBuckets, trx.dueDate); if (wi >= 0) weeklyOutstanding[wi] += amount; const mi = findBucketIndex(monthlyBuckets, trx.dueDate); if (mi >= 0) monthlyOutstanding[mi] += amount; } } for (const exp of expenses) { const mi = findBucketIndex(monthlyBuckets, exp.date); if (mi >= 0) monthlyExpenses[mi] += Number(exp.amount) || 0; } const monthlyProfessorPayouts = monthlyBuckets.map((_, mi) => { let total = 0; for (const [classKey, profile] of classProfiles.entries()) { const received = monthlyClassReceived[mi].get(classKey) || 0; const held = monthlyClassHeld[mi].get(classKey) || 0; if (!received && !held) continue; const payout = calculateProfessorPayout({ payoutType: profile.payoutType, payoutPercentage: profile.payoutPercentage, payoutHourlyRate: profile.payoutHourlyRate, revenue: received, sessionDurationHours: profile.sessionDurationHours, sessionsCount: held, extraExpensePerSession: profile.extraExpensePerSession, serviceFeePerPerson: profile.serviceFeePerPerson, studentsCount: profile.studentsCount }); total += payout.totalPayout; } return total; }); const daily = dailyBuckets.map((b, i) => ({ date: b.key, received: dailyReceived[i], sessionsHeld: dailyHeld[i], sessionIncome: dailySessionIncome[i] })); const weekly = weeklyBuckets.map((b, i) => ({ weekStart: b.key, weekEnd: b.end.toISOString().slice(0, 10), received: weeklyReceived[i], outstanding: weeklyOutstanding[i], sessionsHeld: weeklyHeld[i], sessionIncome: weeklySessionIncome[i] })); const monthly = monthlyBuckets.map((b, i) => ({ year: b.year, month: b.month, monthStart: b.start, received: monthlyReceived[i], outstanding: monthlyOutstanding[i], sessionsHeld: monthlyHeld[i], professorPayouts: monthlyProfessorPayouts[i], generalExpenses: monthlyExpenses[i], netProfit: monthlyReceived[i] - monthlyProfessorPayouts[i] - monthlyExpenses[i] })); // ── All-time overview + per-class leaderboard ───────────────────────────── let totalExpectedRevenue = 0; let totalReceived = 0; let totalProfessorPayoutsAllTime = 0; let totalSessionsPlanned = 0; let totalSessionsHeldAllTime = 0; let totalEnrollments = 0; let totalActiveClasses = 0; const incomeByClass = []; for (const profile of classProfiles.values()) { totalExpectedRevenue += profile.expectedRevenue; totalReceived += profile.actualReceivedRevenue; totalSessionsPlanned += profile.plannedSessions || 0; totalSessionsHeldAllTime += profile.heldSessions; totalEnrollments += profile.studentsCount; if (profile.isActive) totalActiveClasses += 1; const payout = calculateProfessorPayout({ payoutType: profile.payoutType, payoutPercentage: profile.payoutPercentage, payoutHourlyRate: profile.payoutHourlyRate, revenue: profile.actualReceivedRevenue, sessionDurationHours: profile.sessionDurationHours, sessionsCount: profile.heldSessions, extraExpensePerSession: profile.extraExpensePerSession, serviceFeePerPerson: profile.serviceFeePerPerson, studentsCount: profile.studentsCount }); totalProfessorPayoutsAllTime += payout.totalPayout; incomeByClass.push({ classId: profile.id, className: profile.name, professorName: profile.professorName, courseName: profile.courseName, studentsCount: profile.studentsCount, expectedRevenue: profile.expectedRevenue, actualReceivedRevenue: profile.actualReceivedRevenue, pendingReceivables: calculatePendingReceivables(profile.expectedRevenue, profile.actualReceivedRevenue), sessionsHeld: profile.heldSessions, sessionsPlanned: profile.plannedSessions, incomePerSessionActual: profile.heldSessions > 0 ? profile.actualReceivedRevenue / profile.heldSessions : 0, incomePerSessionExpected: profile.plannedSessions > 0 ? profile.expectedRevenue / profile.plannedSessions : 0 }); } incomeByClass.sort((a, b) => b.actualReceivedRevenue - a.actualReceivedRevenue); const totalPendingReceivables = calculatePendingReceivables(totalExpectedRevenue, totalReceived); const netProfitAllTime = totalReceived - totalProfessorPayoutsAllTime - generalExpensesAllTime; let overdueAmount = 0; let overdueCount = 0; for (const trx of transactions) { if (isActiveTransaction(trx) && !isPaidTransaction(trx) && trx.dueDate && new Date(trx.dueDate).getTime() < now.getTime()) { overdueAmount += Number(trx.amount) || 0; overdueCount += 1; } } const statusMap = new Map(); for (const p of paymentsFull) { const status = p.status || 'pending'; const entry = statusMap.get(status) || { status, count: 0, amount: 0 }; entry.count += 1; entry.amount += getPayableAmount(p); statusMap.set(status, entry); } const paymentStatusBreakdown = Array.from(statusMap.values()); // ── Forecast: upcoming due dates → expected cash inflow ─────────────────── const pendingTransactions = transactions.filter((t) => isActiveTransaction(t) && !isPaidTransaction(t) && t.dueDate); const forecastBucketTotals = forecastBuckets.map(() => ({ amount: 0, count: 0 })); const in7 = now.getTime() + 7 * 86400000; const in30 = now.getTime() + 30 * 86400000; const in60 = now.getTime() + 60 * 86400000; let next7DaysAmount = 0; let next30DaysAmount = 0; let next60DaysAmount = 0; for (const trx of pendingTransactions) { const due = new Date(trx.dueDate); const dueTime = due.getTime(); const amount = Number(trx.amount) || 0; if (dueTime >= now.getTime()) { if (dueTime <= in7) next7DaysAmount += amount; if (dueTime <= in30) next30DaysAmount += amount; if (dueTime <= in60) next60DaysAmount += amount; } const fi = findBucketIndex(forecastBuckets, due); if (fi >= 0) { forecastBucketTotals[fi].amount += amount; forecastBucketTotals[fi].count += 1; } } const upcomingDue = pendingTransactions .slice() .sort((a, b) => new Date(a.dueDate).getTime() - new Date(b.dueDate).getTime()) .slice(0, 50) .map((trx) => { const classKey = paymentClassByPaymentId.get(String(trx.payment)); const profile = classProfiles.get(classKey); const studentUser = paymentUserByPaymentId.get(String(trx.payment)); const due = new Date(trx.dueDate); const daysUntilDue = Math.round((due.getTime() - now.getTime()) / 86400000); return { transactionId: trx._id, studentName: buildStudentName(studentUser), className: profile ? profile.name : '—', amount: Number(trx.amount) || 0, dueDate: trx.dueDate, daysUntilDue, isOverdue: daysUntilDue < 0 }; }); return { generatedAt: now, daily, weekly, monthly, overview: { totalExpectedRevenue, totalReceived, totalPendingReceivables, overdueAmount, overdueCount, totalProfessorPayoutsAllTime, totalGeneralExpensesAllTime: generalExpensesAllTime, netProfitAllTime, totalEnrollments, totalActiveClasses, totalClasses: classes.length, totalSessionsHeld: totalSessionsHeldAllTime, totalSessionsPlanned, avgIncomePerHeldSession: totalSessionsHeldAllTime > 0 ? totalReceived / totalSessionsHeldAllTime : 0, avgExpectedIncomePerPlannedSession: totalSessionsPlanned > 0 ? totalExpectedRevenue / totalSessionsPlanned : 0 }, incomeByClass, paymentStatusBreakdown, upcomingDue, forecast: { overdueAmount, overdueCount, next7DaysAmount, next30DaysAmount, next60DaysAmount, buckets: forecastBuckets.map((b, i) => ({ weekStart: b.key, weekEnd: b.end.toISOString().slice(0, 10), expectedAmount: forecastBucketTotals[i].amount, count: forecastBucketTotals[i].count })) } }; }; module.exports = { getClassReport, getSessionReport, getRangeReport, getAnalytics };