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,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