142 lines
5.3 KiB
JavaScript
142 lines
5.3 KiB
JavaScript
'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).
|
|
* `serviceFeePerPerson` (catering / service expenses per student) is deducted first before calculating the professor share.
|
|
*/
|
|
const calculatePercentageShare = ({ payoutPercentage = 0, revenue = 0, serviceFeePerPerson = 0, studentsCount = 0 } = {}) => {
|
|
const totalServiceFee = toNonNegativeNumber(serviceFeePerPerson) * toNonNegativeNumber(studentsCount);
|
|
const netRevenue = Math.max(0, toNonNegativeNumber(revenue) - totalServiceFee);
|
|
return (toPercentage(payoutPercentage) / 100) * netRevenue;
|
|
};
|
|
|
|
/**
|
|
* 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
|
|
};
|