Add professor share calculation and financial reporting feature
Introduces per-class payout configuration (percentage or hourly rate, plus an optional per-session expense allowance), pure calculation utilities for professor payout/net profit/receivables, and a new financial-reports API surface with per-class, per-session, and date-range/monthly reports. Also adds full CRUD for institutional expenses used in the range report, and wires up the new permissions and roles. Fixes a pre-existing ReferenceError (missing parseImportDate import) in paymentService that blocked creating payments with dated transactions.
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
'use strict';
|
||||
|
||||
const { normalizeClockTime } = require('./classSchedule');
|
||||
|
||||
const PAYOUT_TYPES = {
|
||||
PERCENTAGE: 'percentage',
|
||||
HOURLY: 'hourly'
|
||||
};
|
||||
|
||||
const toNonNegativeNumber = (value) => {
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n) || n < 0) return 0;
|
||||
return n;
|
||||
};
|
||||
|
||||
/** Clamps a percentage-style value into the 0-100 range. */
|
||||
const toPercentage = (value) => Math.min(100, toNonNegativeNumber(value));
|
||||
|
||||
/**
|
||||
* Parses "HH:MM" clock strings and returns the duration between them, in hours.
|
||||
* Returns 0 for missing/invalid input. Assumes the session does not cross midnight
|
||||
* unless the end time is numerically before the start time (e.g. 23:00 -> 01:00).
|
||||
*/
|
||||
const calculateSessionDurationHours = (startTime, endTime) => {
|
||||
const start = normalizeClockTime(startTime);
|
||||
const end = normalizeClockTime(endTime);
|
||||
if (!start || !end) return 0;
|
||||
|
||||
const [startH, startM] = start.split(':').map(Number);
|
||||
const [endH, endM] = end.split(':').map(Number);
|
||||
let diffMinutes = (endH * 60 + endM) - (startH * 60 + startM);
|
||||
if (diffMinutes <= 0) diffMinutes += 24 * 60;
|
||||
|
||||
return diffMinutes / 60;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolves "Session Duration in Hours" for a class: prefers the linked course's
|
||||
* `hoursPerSection`, falling back to the class's own start/end time window.
|
||||
*/
|
||||
const resolveSessionDurationHours = (cls = {}) => {
|
||||
const course = cls.course && typeof cls.course === 'object' ? cls.course : null;
|
||||
const hoursPerSection = toNonNegativeNumber(course?.hoursPerSection);
|
||||
if (hoursPerSection > 0) return hoursPerSection;
|
||||
return calculateSessionDurationHours(cls.startTime, cls.endTime);
|
||||
};
|
||||
|
||||
/**
|
||||
* Model A — percentage of class revenue.
|
||||
* `revenue` is the amount the percentage should be applied to (e.g. actual received revenue).
|
||||
*/
|
||||
const calculatePercentageShare = ({ payoutPercentage = 0, revenue = 0 } = {}) => {
|
||||
return (toPercentage(payoutPercentage) / 100) * toNonNegativeNumber(revenue);
|
||||
};
|
||||
|
||||
/**
|
||||
* Model B — hourly rate.
|
||||
* Session Share = Hourly Rate * Session Duration in Hours
|
||||
* Total Share = Session Share * Number of Sessions
|
||||
*/
|
||||
const calculateHourlyShare = ({ payoutHourlyRate = 0, sessionDurationHours = 0, sessionsCount = 0 } = {}) => {
|
||||
const sessionShare = toNonNegativeNumber(payoutHourlyRate) * toNonNegativeNumber(sessionDurationHours);
|
||||
return sessionShare * toNonNegativeNumber(sessionsCount);
|
||||
};
|
||||
|
||||
/** Total Extra Expenses = extraExpensePerSession * Number of Sessions Held (always flat, per-session). */
|
||||
const calculateExtraExpenses = ({ extraExpensePerSession = 0, sessionsCount = 0 } = {}) => {
|
||||
return toNonNegativeNumber(extraExpensePerSession) * toNonNegativeNumber(sessionsCount);
|
||||
};
|
||||
|
||||
/**
|
||||
* Computes the professor's base share (before extra expenses) for either payout model.
|
||||
*/
|
||||
const calculateBaseShare = (params = {}) => {
|
||||
const { payoutType } = params;
|
||||
if (payoutType === PAYOUT_TYPES.HOURLY) {
|
||||
return calculateHourlyShare(params);
|
||||
}
|
||||
return calculatePercentageShare(params);
|
||||
};
|
||||
|
||||
/**
|
||||
* Professor Total Payout = Base Share + (extraExpensePerSession * sessionsCount)
|
||||
*/
|
||||
const calculateProfessorPayout = (params = {}) => {
|
||||
const baseShare = calculateBaseShare(params);
|
||||
const extraExpenses = calculateExtraExpenses(params);
|
||||
return {
|
||||
baseShare,
|
||||
extraExpenses,
|
||||
totalPayout: baseShare + extraExpenses
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Net Profit = Total Income - Professor Total Payout.
|
||||
* Intentionally NOT clamped to zero — a negative result represents a real loss.
|
||||
*/
|
||||
const calculateNetProfit = ({ totalIncome = 0, professorTotalPayout = 0 } = {}) => {
|
||||
return toNonNegativeNumber(totalIncome) - toNonNegativeNumber(professorTotalPayout);
|
||||
};
|
||||
|
||||
/** Outstanding receivables can never be negative — overpayments are surfaced separately. */
|
||||
const calculatePendingReceivables = (expectedRevenue = 0, actualReceivedRevenue = 0) => {
|
||||
return Math.max(0, toNonNegativeNumber(expectedRevenue) - toNonNegativeNumber(actualReceivedRevenue));
|
||||
};
|
||||
|
||||
/** Amount collected beyond what was actually owed (graceful handling of overpayments). */
|
||||
const calculateOverpaidAmount = (expectedRevenue = 0, actualReceivedRevenue = 0) => {
|
||||
return Math.max(0, toNonNegativeNumber(actualReceivedRevenue) - toNonNegativeNumber(expectedRevenue));
|
||||
};
|
||||
|
||||
/**
|
||||
* Per-session student revenue = Total Assigned Tuition per Student / Total Number of Planned Sessions.
|
||||
* Guards against division by zero when a class has no planned sessions.
|
||||
*/
|
||||
const calculateStudentRevenuePerSession = (totalAssignedTuitionPerStudent = 0, totalPlannedSessions = 0) => {
|
||||
const sessions = toNonNegativeNumber(totalPlannedSessions);
|
||||
if (sessions <= 0) return 0;
|
||||
return toNonNegativeNumber(totalAssignedTuitionPerStudent) / sessions;
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
PAYOUT_TYPES,
|
||||
toNonNegativeNumber,
|
||||
toPercentage,
|
||||
calculateSessionDurationHours,
|
||||
resolveSessionDurationHours,
|
||||
calculatePercentageShare,
|
||||
calculateHourlyShare,
|
||||
calculateExtraExpenses,
|
||||
calculateBaseShare,
|
||||
calculateProfessorPayout,
|
||||
calculateNetProfit,
|
||||
calculatePendingReceivables,
|
||||
calculateOverpaidAmount,
|
||||
calculateStudentRevenuePerSession
|
||||
};
|
||||
Reference in New Issue
Block a user