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:
@@ -19,3 +19,8 @@ exports.getRangeReport = catchAsync(async (req, res) => {
|
|||||||
const report = await financialReportService.getRangeReport(req.query);
|
const report = await financialReportService.getRangeReport(req.query);
|
||||||
return successResponse(res, 200, 'Date-range financial report retrieved successfully', report);
|
return successResponse(res, 200, 'Date-range financial report retrieved successfully', report);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
exports.getAnalytics = catchAsync(async (req, res) => {
|
||||||
|
const report = await financialReportService.getAnalytics(req.query);
|
||||||
|
return successResponse(res, 200, 'Financial analytics retrieved successfully', report);
|
||||||
|
});
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const router = express.Router();
|
|||||||
router.use(authMiddleware);
|
router.use(authMiddleware);
|
||||||
router.use(perm.requires(PERMISSIONS.FINANCIAL_REPORTS_READ));
|
router.use(perm.requires(PERMISSIONS.FINANCIAL_REPORTS_READ));
|
||||||
|
|
||||||
|
router.get('/admin/analytics', financialReportController.getAnalytics);
|
||||||
router.get('/admin/range', financialReportController.getRangeReport);
|
router.get('/admin/range', financialReportController.getRangeReport);
|
||||||
router.get('/admin/classes/:classId', financialReportController.getClassReport);
|
router.get('/admin/classes/:classId', financialReportController.getClassReport);
|
||||||
router.get('/admin/sessions/:sessionId', financialReportController.getSessionReport);
|
router.get('/admin/sessions/:sessionId', financialReportController.getSessionReport);
|
||||||
|
|||||||
@@ -5,8 +5,17 @@ const Class = require('../classes/classModel');
|
|||||||
const Session = require('../sessions/sessionModel');
|
const Session = require('../sessions/sessionModel');
|
||||||
const Payment = require('../payments/paymentModel');
|
const Payment = require('../payments/paymentModel');
|
||||||
const Transaction = require('../payments/transactionModel');
|
const Transaction = require('../payments/transactionModel');
|
||||||
|
const Expense = require('../expenses/expenseModel');
|
||||||
const AppError = require('../../utils/AppError');
|
const AppError = require('../../utils/AppError');
|
||||||
const { resolveDateRange, isWithinRange } = require('../../utils/financialRange');
|
const { resolveDateRange, isWithinRange } = require('../../utils/financialRange');
|
||||||
|
const {
|
||||||
|
startOfDay,
|
||||||
|
buildDailyBuckets,
|
||||||
|
buildWeeklyBuckets,
|
||||||
|
buildMonthlyBuckets,
|
||||||
|
buildForecastWeeklyBuckets,
|
||||||
|
findBucketIndex
|
||||||
|
} = require('../../utils/reportBuckets');
|
||||||
const {
|
const {
|
||||||
getPayableAmount,
|
getPayableAmount,
|
||||||
isPaidTransaction,
|
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 = {
|
module.exports = {
|
||||||
getClassReport,
|
getClassReport,
|
||||||
getSessionReport,
|
getSessionReport,
|
||||||
getRangeReport
|
getRangeReport,
|
||||||
|
getAnalytics
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Time-bucketing helpers for financial analytics (daily / weekly / monthly /
|
||||||
|
* forward-looking forecast windows). Weeks follow the Iranian convention of
|
||||||
|
* starting on Saturday, ending on Friday.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const startOfDay = (date) => {
|
||||||
|
const d = new Date(date);
|
||||||
|
d.setHours(0, 0, 0, 0);
|
||||||
|
return d;
|
||||||
|
};
|
||||||
|
|
||||||
|
const endOfDay = (date) => {
|
||||||
|
const d = new Date(date);
|
||||||
|
d.setHours(23, 59, 59, 999);
|
||||||
|
return d;
|
||||||
|
};
|
||||||
|
|
||||||
|
const addDays = (date, days) => {
|
||||||
|
const d = new Date(date);
|
||||||
|
d.setDate(d.getDate() + days);
|
||||||
|
return d;
|
||||||
|
};
|
||||||
|
|
||||||
|
const addMonths = (date, months) => {
|
||||||
|
const d = new Date(date);
|
||||||
|
d.setMonth(d.getMonth() + months);
|
||||||
|
return d;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** JS getDay(): Sun=0..Sat=6. Iranian week starts Saturday, so Sat -> offset 0, Sun -> 1, ... Fri -> 6. */
|
||||||
|
const getWeekStart = (date) => {
|
||||||
|
const d = startOfDay(date);
|
||||||
|
const offset = (d.getDay() + 1) % 7;
|
||||||
|
return addDays(d, -offset);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Builds `days` daily buckets ending on (and including) `endDate`, oldest first. */
|
||||||
|
const buildDailyBuckets = (days, endDate = new Date()) => {
|
||||||
|
const end = startOfDay(endDate);
|
||||||
|
const buckets = [];
|
||||||
|
for (let i = days - 1; i >= 0; i--) {
|
||||||
|
const dayStart = addDays(end, -i);
|
||||||
|
buckets.push({ start: dayStart, end: endOfDay(dayStart), key: dayStart.toISOString().slice(0, 10) });
|
||||||
|
}
|
||||||
|
return buckets;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Builds `weeks` weekly buckets (Sat-Fri) ending on the week containing `endDate`, oldest first. */
|
||||||
|
const buildWeeklyBuckets = (weeks, endDate = new Date()) => {
|
||||||
|
const currentWeekStart = getWeekStart(endDate);
|
||||||
|
const buckets = [];
|
||||||
|
for (let i = weeks - 1; i >= 0; i--) {
|
||||||
|
const weekStart = addDays(currentWeekStart, -7 * i);
|
||||||
|
buckets.push({ start: weekStart, end: endOfDay(addDays(weekStart, 6)), key: weekStart.toISOString().slice(0, 10) });
|
||||||
|
}
|
||||||
|
return buckets;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Builds `weeks` forward-looking weekly buckets (Sat-Fri) starting from the week containing `startDate`. */
|
||||||
|
const buildForecastWeeklyBuckets = (weeks, startDate = new Date()) => {
|
||||||
|
const currentWeekStart = getWeekStart(startDate);
|
||||||
|
const buckets = [];
|
||||||
|
for (let i = 0; i < weeks; i++) {
|
||||||
|
const weekStart = addDays(currentWeekStart, 7 * i);
|
||||||
|
buckets.push({ start: weekStart, end: endOfDay(addDays(weekStart, 6)), key: weekStart.toISOString().slice(0, 10) });
|
||||||
|
}
|
||||||
|
return buckets;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Builds `months` monthly (Gregorian calendar month) buckets ending on the month containing `endDate`, oldest first. */
|
||||||
|
const buildMonthlyBuckets = (months, endDate = new Date()) => {
|
||||||
|
const base = new Date(endDate.getFullYear(), endDate.getMonth(), 1, 0, 0, 0, 0);
|
||||||
|
const buckets = [];
|
||||||
|
for (let i = months - 1; i >= 0; i--) {
|
||||||
|
const monthStart = addMonths(base, -i);
|
||||||
|
const monthEnd = endOfDay(addDays(addMonths(monthStart, 1), -1));
|
||||||
|
buckets.push({
|
||||||
|
start: monthStart,
|
||||||
|
end: monthEnd,
|
||||||
|
year: monthStart.getFullYear(),
|
||||||
|
month: monthStart.getMonth() + 1,
|
||||||
|
key: `${monthStart.getFullYear()}-${String(monthStart.getMonth() + 1).padStart(2, '0')}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return buckets;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Linear search is fine — bucket arrays used here are always small (<=~90 entries). */
|
||||||
|
const findBucketIndex = (buckets, date) => {
|
||||||
|
if (!date) return -1;
|
||||||
|
const d = date instanceof Date ? date : new Date(date);
|
||||||
|
if (Number.isNaN(d.getTime())) return -1;
|
||||||
|
const t = d.getTime();
|
||||||
|
return buckets.findIndex((b) => t >= b.start.getTime() && t <= b.end.getTime());
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
startOfDay,
|
||||||
|
endOfDay,
|
||||||
|
addDays,
|
||||||
|
addMonths,
|
||||||
|
getWeekStart,
|
||||||
|
buildDailyBuckets,
|
||||||
|
buildWeeklyBuckets,
|
||||||
|
buildForecastWeeklyBuckets,
|
||||||
|
buildMonthlyBuckets,
|
||||||
|
findBucketIndex
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user