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.
812 lines
29 KiB
JavaScript
812 lines
29 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
|
|
});
|
|
|
|
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
|
|
}
|
|
};
|
|
};
|
|
|
|
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,
|
|
getAnalytics
|
|
};
|