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:
@@ -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
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user