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:
@@ -94,6 +94,11 @@
|
||||
"en": "Class not found.",
|
||||
"fa": "کلاس یافت نشد."
|
||||
},
|
||||
"EXPENSE_NOT_FOUND": {
|
||||
"statusCode": 404,
|
||||
"en": "Expense record not found.",
|
||||
"fa": "هزینه یافت نشد."
|
||||
},
|
||||
"DEFAULT_ROLE_NOT_FOUND": {
|
||||
"statusCode": 500,
|
||||
"en": "Default user role was not found. Please seed the database.",
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Resolves a reporting date range from either an explicit start/end pair or a
|
||||
* `month` + `year` shorthand (both 1-indexed month). Falls back to "all time"
|
||||
* when nothing is provided so the same helper can power both the lifetime and
|
||||
* the date-range/monthly reports.
|
||||
*/
|
||||
const resolveDateRange = ({ startDate, endDate, month, year } = {}) => {
|
||||
let start = startDate ? new Date(startDate) : null;
|
||||
let end = endDate ? new Date(endDate) : null;
|
||||
|
||||
if ((!start || Number.isNaN(start.getTime())) && month != null && year != null) {
|
||||
const monthIndex = Number(month) - 1;
|
||||
const yearNumber = Number(year);
|
||||
if (Number.isInteger(monthIndex) && Number.isInteger(yearNumber)) {
|
||||
start = new Date(yearNumber, monthIndex, 1, 0, 0, 0, 0);
|
||||
end = new Date(yearNumber, monthIndex + 1, 0, 23, 59, 59, 999);
|
||||
}
|
||||
}
|
||||
|
||||
if (!start || Number.isNaN(start.getTime())) {
|
||||
start = new Date(0);
|
||||
} else {
|
||||
start.setHours(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
if (!end || Number.isNaN(end.getTime())) {
|
||||
end = new Date();
|
||||
} else {
|
||||
end.setHours(23, 59, 59, 999);
|
||||
}
|
||||
|
||||
if (start.getTime() > end.getTime()) {
|
||||
const swap = start;
|
||||
start = end;
|
||||
end = swap;
|
||||
}
|
||||
|
||||
return { start, end };
|
||||
};
|
||||
|
||||
const isWithinRange = (value, start, end) => {
|
||||
if (!value) return false;
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return false;
|
||||
return date.getTime() >= start.getTime() && date.getTime() <= end.getTime();
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
resolveDateRange,
|
||||
isWithinRange
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
'use strict';
|
||||
|
||||
const { describe, it } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { resolveDateRange, isWithinRange } = require('./financialRange');
|
||||
|
||||
describe('resolveDateRange', () => {
|
||||
it('builds a range from explicit start/end dates', () => {
|
||||
const { start, end } = resolveDateRange({ startDate: '2026-01-01', endDate: '2026-01-31' });
|
||||
assert.equal(start.getFullYear(), 2026);
|
||||
assert.equal(start.getMonth(), 0);
|
||||
assert.equal(start.getDate(), 1);
|
||||
assert.equal(end.getDate(), 31);
|
||||
assert.equal(end.getHours(), 23);
|
||||
});
|
||||
|
||||
it('builds a range from month + year shorthand', () => {
|
||||
const { start, end } = resolveDateRange({ month: 2, year: 2026 });
|
||||
assert.equal(start.getMonth(), 1);
|
||||
assert.equal(start.getDate(), 1);
|
||||
assert.equal(end.getMonth(), 1);
|
||||
assert.equal(end.getDate(), 28); // 2026 is not a leap year
|
||||
});
|
||||
|
||||
it('falls back to all-time when nothing is provided', () => {
|
||||
const { start, end } = resolveDateRange({});
|
||||
assert.ok(start.getFullYear() <= 1970);
|
||||
assert.ok(end.getTime() <= Date.now());
|
||||
});
|
||||
|
||||
it('swaps start/end when given out of order', () => {
|
||||
const { start, end } = resolveDateRange({ startDate: '2026-03-01', endDate: '2026-01-01' });
|
||||
assert.ok(start.getTime() < end.getTime());
|
||||
});
|
||||
});
|
||||
|
||||
describe('isWithinRange', () => {
|
||||
it('detects dates inside and outside the range', () => {
|
||||
const { start, end } = resolveDateRange({ startDate: '2026-01-01', endDate: '2026-01-31' });
|
||||
assert.equal(isWithinRange('2026-01-15', start, end), true);
|
||||
assert.equal(isWithinRange('2026-02-01', start, end), false);
|
||||
});
|
||||
|
||||
it('returns false for missing or invalid values', () => {
|
||||
const { start, end } = resolveDateRange({ startDate: '2026-01-01', endDate: '2026-01-31' });
|
||||
assert.equal(isWithinRange(null, start, end), false);
|
||||
assert.equal(isWithinRange('not-a-date', start, end), false);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
};
|
||||
@@ -0,0 +1,177 @@
|
||||
'use strict';
|
||||
|
||||
const { describe, it } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const {
|
||||
calculateSessionDurationHours,
|
||||
resolveSessionDurationHours,
|
||||
calculatePercentageShare,
|
||||
calculateHourlyShare,
|
||||
calculateExtraExpenses,
|
||||
calculateProfessorPayout,
|
||||
calculateNetProfit,
|
||||
calculatePendingReceivables,
|
||||
calculateOverpaidAmount,
|
||||
calculateStudentRevenuePerSession
|
||||
} = require('./professorShare');
|
||||
|
||||
describe('calculateSessionDurationHours', () => {
|
||||
it('computes the duration between two clock times', () => {
|
||||
assert.equal(calculateSessionDurationHours('18:00', '20:00'), 2);
|
||||
assert.equal(calculateSessionDurationHours('9:30', '11:00'), 1.5);
|
||||
});
|
||||
|
||||
it('returns 0 for missing or invalid input', () => {
|
||||
assert.equal(calculateSessionDurationHours('', ''), 0);
|
||||
assert.equal(calculateSessionDurationHours(null, undefined), 0);
|
||||
assert.equal(calculateSessionDurationHours('25:00', '26:00'), 0);
|
||||
});
|
||||
|
||||
it('handles sessions that cross midnight', () => {
|
||||
assert.equal(calculateSessionDurationHours('23:00', '01:00'), 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveSessionDurationHours', () => {
|
||||
it('prefers the course hoursPerSection when present', () => {
|
||||
assert.equal(resolveSessionDurationHours({ course: { hoursPerSection: 2 }, startTime: '18:00', endTime: '20:30' }), 2);
|
||||
});
|
||||
|
||||
it('falls back to the class start/end time window', () => {
|
||||
assert.equal(resolveSessionDurationHours({ course: null, startTime: '18:00', endTime: '20:00' }), 2);
|
||||
assert.equal(resolveSessionDurationHours({ course: { hoursPerSection: 0 }, startTime: '18:00', endTime: '19:30' }), 1.5);
|
||||
});
|
||||
|
||||
it('returns 0 when nothing is configured', () => {
|
||||
assert.equal(resolveSessionDurationHours({}), 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculatePercentageShare (Model A)', () => {
|
||||
it('applies the percentage to revenue', () => {
|
||||
assert.equal(calculatePercentageShare({ payoutPercentage: 40, revenue: 10_000_000 }), 4_000_000);
|
||||
});
|
||||
|
||||
it('clamps percentage above 100 and negative revenue to zero', () => {
|
||||
assert.equal(calculatePercentageShare({ payoutPercentage: 150, revenue: 1_000_000 }), 1_000_000);
|
||||
assert.equal(calculatePercentageShare({ payoutPercentage: 40, revenue: -500 }), 0);
|
||||
});
|
||||
|
||||
it('returns 0 when there is no revenue', () => {
|
||||
assert.equal(calculatePercentageShare({ payoutPercentage: 40, revenue: 0 }), 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateHourlyShare (Model B)', () => {
|
||||
it('multiplies rate * duration * sessions', () => {
|
||||
// 200,000 Toman/hour * 2 hours * 10 sessions = 4,000,000
|
||||
assert.equal(
|
||||
calculateHourlyShare({ payoutHourlyRate: 200_000, sessionDurationHours: 2, sessionsCount: 10 }),
|
||||
4_000_000
|
||||
);
|
||||
});
|
||||
|
||||
it('handles zero sessions held (no payout yet)', () => {
|
||||
assert.equal(
|
||||
calculateHourlyShare({ payoutHourlyRate: 200_000, sessionDurationHours: 2, sessionsCount: 0 }),
|
||||
0
|
||||
);
|
||||
});
|
||||
|
||||
it('treats negative or missing inputs as zero', () => {
|
||||
assert.equal(calculateHourlyShare({}), 0);
|
||||
assert.equal(calculateHourlyShare({ payoutHourlyRate: -100, sessionDurationHours: 2, sessionsCount: 5 }), 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateExtraExpenses', () => {
|
||||
it('multiplies the flat per-session allowance by sessions held', () => {
|
||||
assert.equal(calculateExtraExpenses({ extraExpensePerSession: 50_000, sessionsCount: 8 }), 400_000);
|
||||
});
|
||||
|
||||
it('defaults to 0 when not applicable', () => {
|
||||
assert.equal(calculateExtraExpenses({}), 0);
|
||||
assert.equal(calculateExtraExpenses({ extraExpensePerSession: 0, sessionsCount: 12 }), 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateProfessorPayout', () => {
|
||||
it('combines base share (percentage) with extra expenses', () => {
|
||||
const result = calculateProfessorPayout({
|
||||
payoutType: 'percentage',
|
||||
payoutPercentage: 40,
|
||||
revenue: 10_000_000,
|
||||
extraExpensePerSession: 100_000,
|
||||
sessionsCount: 5
|
||||
});
|
||||
assert.equal(result.baseShare, 4_000_000);
|
||||
assert.equal(result.extraExpenses, 500_000);
|
||||
assert.equal(result.totalPayout, 4_500_000);
|
||||
});
|
||||
|
||||
it('combines base share (hourly) with extra expenses', () => {
|
||||
const result = calculateProfessorPayout({
|
||||
payoutType: 'hourly',
|
||||
payoutHourlyRate: 300_000,
|
||||
sessionDurationHours: 1.5,
|
||||
extraExpensePerSession: 80_000,
|
||||
sessionsCount: 10
|
||||
});
|
||||
assert.equal(result.baseShare, 4_500_000);
|
||||
assert.equal(result.extraExpenses, 800_000);
|
||||
assert.equal(result.totalPayout, 5_300_000);
|
||||
});
|
||||
|
||||
it('handles a class with zero sessions held gracefully', () => {
|
||||
const result = calculateProfessorPayout({
|
||||
payoutType: 'hourly',
|
||||
payoutHourlyRate: 300_000,
|
||||
sessionDurationHours: 1.5,
|
||||
extraExpensePerSession: 80_000,
|
||||
sessionsCount: 0
|
||||
});
|
||||
assert.equal(result.baseShare, 0);
|
||||
assert.equal(result.extraExpenses, 0);
|
||||
assert.equal(result.totalPayout, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateNetProfit', () => {
|
||||
it('subtracts payout from income', () => {
|
||||
assert.equal(calculateNetProfit({ totalIncome: 10_000_000, professorTotalPayout: 4_500_000 }), 5_500_000);
|
||||
});
|
||||
|
||||
it('can go negative to represent a loss', () => {
|
||||
assert.equal(calculateNetProfit({ totalIncome: 1_000_000, professorTotalPayout: 4_500_000 }), -3_500_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculatePendingReceivables / calculateOverpaidAmount', () => {
|
||||
it('computes outstanding receivables', () => {
|
||||
assert.equal(calculatePendingReceivables(10_000_000, 6_000_000), 4_000_000);
|
||||
});
|
||||
|
||||
it('never returns a negative outstanding amount when overpaid', () => {
|
||||
assert.equal(calculatePendingReceivables(10_000_000, 12_000_000), 0);
|
||||
assert.equal(calculateOverpaidAmount(10_000_000, 12_000_000), 2_000_000);
|
||||
});
|
||||
|
||||
it('returns 0 overpaid when underpaid', () => {
|
||||
assert.equal(calculateOverpaidAmount(10_000_000, 6_000_000), 0);
|
||||
});
|
||||
|
||||
it('handles a class with zero expected revenue (no students)', () => {
|
||||
assert.equal(calculatePendingReceivables(0, 0), 0);
|
||||
assert.equal(calculateOverpaidAmount(0, 0), 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateStudentRevenuePerSession', () => {
|
||||
it('divides tuition evenly across planned sessions', () => {
|
||||
assert.equal(calculateStudentRevenuePerSession(12_000_000, 12), 1_000_000);
|
||||
});
|
||||
|
||||
it('returns 0 when there are no planned sessions (avoids division by zero)', () => {
|
||||
assert.equal(calculateStudentRevenuePerSession(12_000_000, 0), 0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user