feat(financial-reports): add institute-wide analytics endpoint

Adds GET /financial-reports/admin/analytics returning daily/weekly income
trends (cash received vs. accrued session income), a multi-month
received/payout/expense/profit breakdown, a per-class income-per-session
leaderboard, payment-status distribution, and a due-date driven cash-flow
forecast (overdue + upcoming installments) to power richer charts on the
dashboard's financial reports page.
This commit is contained in:
2026-08-23 18:24:32 +03:30
parent 3cc813f24e
commit e3b51a1629
4 changed files with 587 additions and 1 deletions
@@ -5,8 +5,17 @@ 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,
@@ -335,8 +344,468 @@ const getRangeReport = async (query = {}) => {
};
};
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,
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
});
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
});
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
getRangeReport,
getAnalytics
};