Files
gameno-api/components/financialReports/financialReportService.js
T

823 lines
30 KiB
JavaScript

// /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
};