Compare commits

...
10 Commits
Author SHA1 Message Date
kavehhn dca7250be0 feat(users,professors): add promote student to professor and full student profile aggregation 2026-08-24 22:52:38 +03:30
kavehhn 282dd42422 feat: add employee timing system, notification templates, and professor class plan sms 2026-08-24 19:31:07 +03:30
kavehhn 53fce86ad5 feat: add waitlist module, quick payment/transaction edit, and catering fee deduction from professor share 2026-08-23 23:00:02 +03:30
kavehhn e3b51a1629 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.
2026-08-23 18:24:32 +03:30
kavehhn 3cc813f24e fix(payments): enhance duplicate payment check query and auto-populate course 2026-08-23 16:55:48 +03:30
kavehhn dde0efc72a fix(sessions): respect database sort order in getAllSessions and getMySessions 2026-08-23 16:41:49 +03:30
kavehhn 22ce6c3fb1 fix(payments,dates): improve duplicate payment detection and Jalali date parsing 2026-08-23 14:15:06 +03:30
kavehhn 945b4e6332 fix(payments): ensure ObjectId casting for duplicate check query 2026-08-23 13:35:30 +03:30
kavehhn 28d401fd0e feat(payments): add bulk class payments creation and duplicate check 2026-08-23 13:28:06 +03:30
kavehhn 4c07ca2ae1 fix: format prices in notifications, change partial payment label, and add session holding notify API 2026-08-21 20:17:30 +03:30
51 changed files with 3142 additions and 211 deletions
+4
View File
@@ -103,7 +103,11 @@ app.use('/api/contact-inquiries', contactInquiryRoutes);
app.use('/api/expenses', expenseRoutes);
app.use('/api/financial-reports', financialReportRoutes);
const pendingStudentRoutes = require('./components/pendingStudents/pendingStudentRoutes');
const waitlistRoutes = require('./components/waitlist/waitlistRoutes');
app.use('/api/pending-students', pendingStudentRoutes);
app.use('/api/waitlist', waitlistRoutes);
const employeeTimingRoutes = require('./components/employeeTimings/employeeTimingRoutes');
app.use('/api/employee-timings', employeeTimingRoutes);
app.use('/api/seed', seedRoutes);
app.use('/api/data-import', dataImportRoutes);
app.use('/api/settings', settingRoutes);
+5
View File
@@ -59,3 +59,8 @@ exports.getPublicOne = catchAsync(async (req, res) => {
const cls = await classService.getPublicOne(req.params.id);
return successResponse(res, 200, 'Class retrieved successfully', cls);
});
exports.sendClassPlanToProfessor = catchAsync(async (req, res) => {
const result = await classService.sendClassPlanToProfessor(req.params.id);
return successResponse(res, 200, 'برنامه کلاس با موفقیت برای استاد ارسال شد.', result);
});
+6
View File
@@ -104,6 +104,12 @@ const classSchema = new mongoose.Schema({
default: 0,
min: 0
},
/** Catering / service fee per person deducted from tuition before percentage payout */
serviceFeePerPerson: {
type: Number,
default: 0,
min: 0
},
isActive: {
type: Boolean,
default: true
+1
View File
@@ -27,5 +27,6 @@ router.delete('/admin/delete/:id', perm.requires([PERMISSIONS.CLASSES_DELETE, PE
router.post('/admin/restore/:id', perm.requires([PERMISSIONS.CLASSES_DELETE, PERMISSIONS.CLASSES_UPDATE]), classController.restore);
router.post('/admin/:id/register-users', perm.requires(PERMISSIONS.CLASSES_REGISTER_USERS), classController.registerUsers);
router.delete('/admin/:id/students/:userId', perm.requires(PERMISSIONS.CLASSES_REGISTER_USERS), classController.removeUser);
router.post('/admin/:id/send-plan-professor', perm.requires(PERMISSIONS.CLASSES_UPDATE), classController.sendClassPlanToProfessor);
module.exports = router;
+71 -5
View File
@@ -8,8 +8,9 @@ const PendingStudent = require('../pendingStudents/pendingStudentModel');
const Payment = require('../payments/paymentModel');
const AppError = require('../../utils/AppError');
const { calculateMeta, escapeRegex, getSearchTerm } = require('../../utils/pagination');
const { sendClassRegisteredSms } = require('../../utils/senders/smsMessages');
const { buildClassScheduleContext, normalizeWeekdays, normalizeClockTime, calculateClassEndDate } = require('../../utils/classSchedule');
const { sendClassRegisteredSms, sendClassPlanProfessorSms } = require('../../utils/senders/smsMessages');
const { buildClassScheduleContext, normalizeWeekdays, normalizeClockTime, calculateClassEndDate, formatClassDaysFromIndexes } = require('../../utils/classSchedule');
const { formatJalaliDate } = require('../../utils/jalaliDate');
const { resolveNotifyFlags } = require('../../utils/notifyResolver');
const { notifyAction } = require('../../utils/actionNotify');
const logger = require('../../utils/logger');
@@ -32,7 +33,7 @@ const applyScheduleFields = (payload, body) => {
return payload;
};
const CLASS_LIST_FIELDS = 'name course professor students capacity tuitionFee hasDiscount discount freeSpots showOnFrontend startDate endDate days startTime endTime numberOfSessions payoutType payoutPercentage payoutHourlyRate extraExpensePerSession isActive isDeleted deletedAt adminNotes createdAt updatedAt';
const CLASS_LIST_FIELDS = 'name course professor students capacity tuitionFee hasDiscount discount freeSpots showOnFrontend startDate endDate days startTime endTime numberOfSessions payoutType payoutPercentage payoutHourlyRate extraExpensePerSession serviceFeePerPerson isActive isDeleted deletedAt adminNotes createdAt updatedAt';
const normalizePricingFields = (body = {}) => {
const tuitionFee = Math.max(0, Number(body.tuitionFee) || 0);
@@ -48,7 +49,8 @@ const normalizePayoutFields = (body = {}) => {
const payoutPercentage = Math.min(100, Math.max(0, Number(body.payoutPercentage) || 0));
const payoutHourlyRate = Math.max(0, Number(body.payoutHourlyRate) || 0);
const extraExpensePerSession = Math.max(0, Number(body.extraExpensePerSession) || 0);
return { payoutType, payoutPercentage, payoutHourlyRate, extraExpensePerSession };
const serviceFeePerPerson = Math.max(0, Number(body.serviceFeePerPerson) || 0);
return { payoutType, payoutPercentage, payoutHourlyRate, extraExpensePerSession, serviceFeePerPerson };
};
const normalizeNumberOfSessions = (value) => {
@@ -293,4 +295,68 @@ const getPublicOne = async (id) => {
return enrichClassForDisplay(cls);
};
module.exports = { getAll, getOne, create, update, remove, restore, registerUsers, removeUser, getMyClasses, getPublicClasses, getPublicOne };
const sendClassPlanToProfessor = async (classId) => {
const classDoc = await Class.findById(classId)
.populate('course', 'title')
.populate('professor', 'name surname phoneNumber');
if (!classDoc) {
throw new AppError('CLASS_NOT_FOUND', null, 'کلاس یافت نشد.');
}
if (!classDoc.professor) {
throw new AppError('VALIDATION_FAILED', { field: 'professor' }, 'برای این کلاس هیچ استادی تعیین نشده است.');
}
const professor = classDoc.professor;
if (!professor.phoneNumber) {
throw new AppError('VALIDATION_FAILED', { field: 'phoneNumber' }, 'شماره همراه استاد ثبت نشده است.');
}
const profName = `${professor.name || ''} ${professor.surname || ''}`.trim() || 'استاد';
const className = classDoc.name || classDoc.course?.title || 'کلاس';
const classDays = formatClassDaysFromIndexes(classDoc.days) || 'طبق هماهنگی';
const classTimes = (classDoc.startTime && classDoc.endTime)
? `${classDoc.startTime} الی ${classDoc.endTime}`
: (classDoc.startTime || classDoc.endTime || 'طبق هماهنگی');
const classStartDate = classDoc.startDate ? formatJalaliDate(classDoc.startDate) : '';
const classEndDate = classDoc.endDate ? formatJalaliDate(classDoc.endDate) : '';
const slotValues = {
professorName: profName,
className,
classDays,
classTimes,
classStartDate,
classEndDate,
phoneNumber: professor.phoneNumber
};
const result = await sendClassPlanProfessorSms(professor.phoneNumber, slotValues, professor._id);
return {
success: true,
result,
recipient: {
name: profName,
phoneNumber: professor.phoneNumber
},
slotValues
};
};
module.exports = {
getAll,
getOne,
create,
update,
remove,
restore,
registerUsers,
removeUser,
getMyClasses,
getPublicClasses,
getPublicOne,
sendClassPlanToProfessor
};
@@ -0,0 +1,61 @@
// /components/employeeTimings/employeeTiming.test.js
'use strict';
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const {
computeStatus,
computeDurationMinutes,
computeDurationFormatted
} = require('./employeeTimingService');
describe('EmployeeTiming status and warning computations', () => {
it('returns "complete" when both entry and exit times are present', () => {
assert.equal(computeStatus('08:30', '17:00'), 'complete');
assert.equal(computeStatus('09:00', '14:30'), 'complete');
});
it('returns "missing_exit" when entry is present but exit is missing', () => {
assert.equal(computeStatus('08:30', ''), 'missing_exit');
assert.equal(computeStatus('08:30', null), 'missing_exit');
assert.equal(computeStatus('08:30', undefined), 'missing_exit');
assert.equal(computeStatus('08:30', ' '), 'missing_exit');
});
it('returns "missing_entry" when exit is present but entry is missing', () => {
assert.equal(computeStatus('', '17:00'), 'missing_entry');
assert.equal(computeStatus(null, '17:00'), 'missing_entry');
assert.equal(computeStatus(undefined, '17:00'), 'missing_entry');
assert.equal(computeStatus(' ', '17:00'), 'missing_entry');
});
it('returns "incomplete" when neither entry nor exit is present', () => {
assert.equal(computeStatus('', ''), 'incomplete');
assert.equal(computeStatus(null, null), 'incomplete');
assert.equal(computeStatus(undefined, undefined), 'incomplete');
});
});
describe('EmployeeTiming duration computations', () => {
it('calculates duration in minutes correctly for standard day hours', () => {
assert.equal(computeDurationMinutes('08:00', '16:30'), 510);
assert.equal(computeDurationMinutes('09:15', '10:45'), 90);
});
it('handles shift crossing midnight', () => {
assert.equal(computeDurationMinutes('22:00', '02:00'), 240);
});
it('returns null for missing times', () => {
assert.equal(computeDurationMinutes('', '17:00'), null);
assert.equal(computeDurationMinutes('08:00', ''), null);
assert.equal(computeDurationMinutes(null, null), null);
});
it('formats duration in Persian string', () => {
assert.equal(computeDurationFormatted(510), '8 ساعت و 30 دقیقه');
assert.equal(computeDurationFormatted(120), '2 ساعت');
assert.equal(computeDurationFormatted(45), '45 دقیقه');
assert.equal(computeDurationFormatted(null), '—');
});
});
@@ -0,0 +1,36 @@
// /components/employeeTimings/employeeTimingController.js
'use strict';
const catchAsync = require('../../utils/catchAsync');
const employeeTimingService = require('./employeeTimingService');
const { successResponse } = require('../../utils/apiResponse');
exports.getAll = catchAsync(async (req, res) => {
const result = await employeeTimingService.getAll(req.query);
return successResponse(res, 200, 'Employee timing records retrieved', result.items, result.meta);
});
exports.getSummary = catchAsync(async (req, res) => {
const summary = await employeeTimingService.getSummary(req.query);
return successResponse(res, 200, 'Employee timing summary retrieved', summary);
});
exports.getOne = catchAsync(async (req, res) => {
const record = await employeeTimingService.getOne(req.params.id);
return successResponse(res, 200, 'Employee timing record retrieved', record);
});
exports.create = catchAsync(async (req, res) => {
const record = await employeeTimingService.create(req.body, req.user?._id);
return successResponse(res, 201, 'Employee timing record created', record);
});
exports.update = catchAsync(async (req, res) => {
const record = await employeeTimingService.update(req.params.id, req.body, req.user?._id);
return successResponse(res, 200, 'Employee timing record updated', record);
});
exports.delete = catchAsync(async (req, res) => {
const result = await employeeTimingService.remove(req.params.id);
return successResponse(res, 200, 'Employee timing record deleted', result);
});
@@ -0,0 +1,120 @@
// /components/employeeTimings/employeeTimingModel.js
'use strict';
const mongoose = require('mongoose');
const employeeTimingSchema = new mongoose.Schema({
user: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true,
index: true
},
date: {
type: Date,
required: true,
default: Date.now,
index: true
},
entryTime: {
type: String,
trim: true,
default: ''
},
exitTime: {
type: String,
trim: true,
default: ''
},
note: {
type: String,
trim: true,
default: ''
},
createdBy: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User'
},
updatedBy: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User'
}
}, {
timestamps: true,
toJSON: { virtuals: true },
toObject: { virtuals: true }
});
/**
* Status virtual property:
* - 'complete': both entryTime and exitTime are provided
* - 'missing_exit': entryTime provided, but exitTime missing (Warning)
* - 'missing_entry': exitTime provided, but entryTime missing (Warning)
* - 'incomplete': both entryTime and exitTime are missing (Warning)
*/
employeeTimingSchema.virtual('status').get(function () {
const hasEntry = Boolean(this.entryTime && String(this.entryTime).trim());
const hasExit = Boolean(this.exitTime && String(this.exitTime).trim());
if (hasEntry && hasExit) return 'complete';
if (hasEntry && !hasExit) return 'missing_exit';
if (!hasEntry && hasExit) return 'missing_entry';
return 'incomplete';
});
/**
* Warning flag virtual property:
* True if record is incomplete (missing entry, missing exit, or both)
*/
employeeTimingSchema.virtual('hasWarning').get(function () {
const hasEntry = Boolean(this.entryTime && String(this.entryTime).trim());
const hasExit = Boolean(this.exitTime && String(this.exitTime).trim());
return !(hasEntry && hasExit);
});
/**
* Warning message helper
*/
employeeTimingSchema.virtual('warningMessage').get(function () {
const hasEntry = Boolean(this.entryTime && String(this.entryTime).trim());
const hasExit = Boolean(this.exitTime && String(this.exitTime).trim());
if (hasEntry && !hasExit) return 'ورود ثبت شده ولی خروج ثبت نشده است';
if (!hasEntry && hasExit) return 'خروج ثبت شده ولی ورود ثبت نشده است';
if (!hasEntry && !hasExit) return 'زمان ورود و خروج هیچ‌کدام ثبت نشده است';
return null;
});
/**
* Duration in minutes calculation (if both entry and exit times are valid HH:mm)
*/
employeeTimingSchema.virtual('durationMinutes').get(function () {
if (!this.entryTime || !this.exitTime) return null;
const [inH, inM] = String(this.entryTime).split(':').map(Number);
const [outH, outM] = String(this.exitTime).split(':').map(Number);
if (Number.isNaN(inH) || Number.isNaN(inM) || Number.isNaN(outH) || Number.isNaN(outM)) {
return null;
}
const startMinutes = inH * 60 + inM;
let endMinutes = outH * 60 + outM;
if (endMinutes < startMinutes) {
// Crosses midnight
endMinutes += 24 * 60;
}
return endMinutes - startMinutes;
});
/**
* Formatted duration in Persian/hours
*/
employeeTimingSchema.virtual('durationFormatted').get(function () {
const minutes = this.durationMinutes;
if (minutes === null || minutes === undefined) return '—';
const h = Math.floor(minutes / 60);
const m = minutes % 60;
if (h === 0) return `${m} دقیقه`;
if (m === 0) return `${h} ساعت`;
return `${h} ساعت و ${m} دقیقه`;
});
module.exports = mongoose.model('EmployeeTiming', employeeTimingSchema);
@@ -0,0 +1,21 @@
// /components/employeeTimings/employeeTimingRoutes.js
'use strict';
const express = require('express');
const employeeTimingController = require('./employeeTimingController');
const authMiddleware = require('../../middlewares/authMiddleware');
const perm = require('../../middlewares/permissionMiddleware');
const { PERMISSIONS } = require('../../constants/permissions');
const router = express.Router();
router.use(authMiddleware);
router.get('/admin/get-all', perm.requires(PERMISSIONS.EMPLOYEE_TIMINGS_READ), employeeTimingController.getAll);
router.get('/admin/summary', perm.requires(PERMISSIONS.EMPLOYEE_TIMINGS_READ), employeeTimingController.getSummary);
router.get('/admin/get-one/:id', perm.requires(PERMISSIONS.EMPLOYEE_TIMINGS_READ), employeeTimingController.getOne);
router.post('/admin/create', perm.requires(PERMISSIONS.EMPLOYEE_TIMINGS_CREATE), employeeTimingController.create);
router.put('/admin/update/:id', perm.requires(PERMISSIONS.EMPLOYEE_TIMINGS_UPDATE), employeeTimingController.update);
router.delete('/admin/delete/:id', perm.requires(PERMISSIONS.EMPLOYEE_TIMINGS_DELETE), employeeTimingController.delete);
module.exports = router;
@@ -0,0 +1,327 @@
// /components/employeeTimings/employeeTimingService.js
'use strict';
const EmployeeTiming = require('./employeeTimingModel');
const User = require('../users/userModel');
const AppError = require('../../utils/AppError');
const { calculateMeta, escapeRegex, getSearchTerm } = require('../../utils/pagination');
const normalizeTime = (timeStr) => {
if (timeStr === undefined || timeStr === null) return '';
const trimmed = String(timeStr).trim();
if (!trimmed) return '';
// Check HH:mm format
const match = /^([01]?[0-9]|2[0-3]):([0-5][0-9])$/.exec(trimmed);
if (match) {
const hh = match[1].padStart(2, '0');
const mm = match[2];
return `${hh}:${mm}`;
}
return trimmed;
};
const normalizeDate = (rawDate) => {
if (!rawDate) return new Date();
const d = new Date(rawDate);
if (Number.isNaN(d.getTime())) {
throw new AppError('VALIDATION_FAILED', { field: 'date' }, 'تاریخ نامعتبر است');
}
return d;
};
const sanitizeNote = (note) => {
if (!note) return '';
return String(note).trim().slice(0, 1000);
};
const computeStatus = (entryTime, exitTime) => {
const hasEntry = Boolean(entryTime && String(entryTime).trim());
const hasExit = Boolean(exitTime && String(exitTime).trim());
if (hasEntry && hasExit) return 'complete';
if (hasEntry && !hasExit) return 'missing_exit';
if (!hasEntry && hasExit) return 'missing_entry';
return 'incomplete';
};
const computeDurationMinutes = (entryTime, exitTime) => {
if (!entryTime || !exitTime) return null;
const [inH, inM] = String(entryTime).split(':').map(Number);
const [outH, outM] = String(exitTime).split(':').map(Number);
if (Number.isNaN(inH) || Number.isNaN(inM) || Number.isNaN(outH) || Number.isNaN(outM)) {
return null;
}
const startMinutes = inH * 60 + inM;
let endMinutes = outH * 60 + outM;
if (endMinutes < startMinutes) {
endMinutes += 24 * 60;
}
return endMinutes - startMinutes;
};
const computeDurationFormatted = (minutes) => {
if (minutes === null || minutes === undefined) return '—';
const h = Math.floor(minutes / 60);
const m = minutes % 60;
if (h === 0) return `${m} دقیقه`;
if (m === 0) return `${h} ساعت`;
return `${h} ساعت و ${m} دقیقه`;
};
const enrichRecord = (record) => {
const obj = record.toObject ? record.toObject({ virtuals: true }) : { ...record };
const entryTime = obj.entryTime || '';
const exitTime = obj.exitTime || '';
const status = computeStatus(entryTime, exitTime);
const hasWarning = status !== 'complete';
const durationMinutes = computeDurationMinutes(entryTime, exitTime);
const durationFormatted = computeDurationFormatted(durationMinutes);
let warningMessage = null;
if (status === 'missing_exit') warningMessage = 'ورود ثبت شده ولی خروج ثبت نشده است';
else if (status === 'missing_entry') warningMessage = 'خروج ثبت شده ولی ورود ثبت نشده است';
else if (status === 'incomplete') warningMessage = 'زمان ورود و خروج هیچ‌کدام ثبت نشده است';
return {
...obj,
status,
hasWarning,
warningMessage,
durationMinutes,
durationFormatted
};
};
const getAll = async (query = {}) => {
const page = parseInt(query.page, 10) || 1;
const limit = Math.min(parseInt(query.limit, 10) || 20, 200);
const skip = (page - 1) * limit;
const filter = {};
if (query.user || query.userId) {
filter.user = query.user || query.userId;
}
// Date range filter
if (query.startDate || query.endDate) {
filter.date = {};
if (query.startDate) {
const start = new Date(query.startDate);
start.setHours(0, 0, 0, 0);
filter.date.$gte = start;
}
if (query.endDate) {
const end = new Date(query.endDate);
end.setHours(23, 59, 59, 999);
filter.date.$lte = end;
}
}
// Search by user name / username / nationalIdCode
const searchTerm = getSearchTerm(query);
if (searchTerm) {
const escaped = escapeRegex(searchTerm);
const matchingUsers = await User.find({
$or: [
{ name: new RegExp(escaped, 'i') },
{ username: new RegExp(escaped, 'i') },
{ nationalIdCode: new RegExp(escaped, 'i') },
{ phoneNumber: new RegExp(escaped, 'i') }
]
}).select('_id').lean();
const userIds = matchingUsers.map(u => u._id);
if (filter.user) {
if (!userIds.some(id => String(id) === String(filter.user))) {
return {
items: [],
meta: calculateMeta(0, page, limit)
};
}
} else {
filter.user = { $in: userIds };
}
}
// Filter by status (complete, incomplete, missing_exit, missing_entry)
if (query.status) {
const s = String(query.status).trim();
if (s === 'complete') {
filter.entryTime = { $nin: ['', null] };
filter.exitTime = { $nin: ['', null] };
} else if (s === 'missing_exit') {
filter.entryTime = { $nin: ['', null] };
filter.$or = [{ exitTime: '' }, { exitTime: null }, { exitTime: { $exists: false } }];
} else if (s === 'missing_entry') {
filter.exitTime = { $nin: ['', null] };
filter.$or = [{ entryTime: '' }, { entryTime: null }, { entryTime: { $exists: false } }];
} else if (s === 'incomplete') {
filter.$or = [
{ entryTime: '' },
{ entryTime: null },
{ entryTime: { $exists: false } },
{ exitTime: '' },
{ exitTime: null },
{ exitTime: { $exists: false } }
];
}
}
const [records, total] = await Promise.all([
EmployeeTiming.find(filter)
.populate('user', 'name nationalIdCode phoneNumber username role uniqueCode')
.populate('createdBy', 'name username')
.populate('updatedBy', 'name username')
.sort({ date: -1, createdAt: -1 })
.skip(skip)
.limit(limit),
EmployeeTiming.countDocuments(filter)
]);
return {
items: records.map(enrichRecord),
meta: calculateMeta(total, page, limit)
};
};
const getSummary = async (query = {}) => {
const baseFilter = {};
if (query.user || query.userId) {
baseFilter.user = query.user || query.userId;
}
if (query.startDate || query.endDate) {
baseFilter.date = {};
if (query.startDate) {
const start = new Date(query.startDate);
start.setHours(0, 0, 0, 0);
baseFilter.date.$gte = start;
}
if (query.endDate) {
const end = new Date(query.endDate);
end.setHours(23, 59, 59, 999);
baseFilter.date.$lte = end;
}
}
const [total, complete, missingExit, missingEntry] = await Promise.all([
EmployeeTiming.countDocuments(baseFilter),
EmployeeTiming.countDocuments({
...baseFilter,
entryTime: { $nin: ['', null] },
exitTime: { $nin: ['', null] }
}),
EmployeeTiming.countDocuments({
...baseFilter,
entryTime: { $nin: ['', null] },
$or: [{ exitTime: '' }, { exitTime: null }, { exitTime: { $exists: false } }]
}),
EmployeeTiming.countDocuments({
...baseFilter,
exitTime: { $nin: ['', null] },
$or: [{ entryTime: '' }, { entryTime: null }, { entryTime: { $exists: false } }]
})
]);
const incomplete = total - complete;
return {
total,
complete,
incomplete,
missingExit,
missingEntry
};
};
const getOne = async (id) => {
const record = await EmployeeTiming.findById(id)
.populate('user', 'name nationalIdCode phoneNumber username role uniqueCode')
.populate('createdBy', 'name username')
.populate('updatedBy', 'name username');
if (!record) {
throw new AppError('NOT_FOUND', { resource: 'EmployeeTiming' }, 'رکورد تردد یافت نشد');
}
return enrichRecord(record);
};
const create = async (body, authorId) => {
if (!body.user && !body.userId) {
throw new AppError('VALIDATION_FAILED', { field: 'user' }, 'انتخاب کارمند / کاربر الزامی است');
}
const userId = body.user || body.userId;
const userExists = await User.findById(userId).select('_id name').lean();
if (!userExists) {
throw new AppError('NOT_FOUND', { resource: 'User' }, 'کاربر انتخاب‌شده یافت نشد');
}
const record = await EmployeeTiming.create({
user: userId,
date: normalizeDate(body.date),
entryTime: normalizeTime(body.entryTime),
exitTime: normalizeTime(body.exitTime),
note: sanitizeNote(body.note),
createdBy: authorId || undefined,
updatedBy: authorId || undefined
});
return getOne(record._id);
};
const update = async (id, body, authorId) => {
const record = await EmployeeTiming.findById(id);
if (!record) {
throw new AppError('NOT_FOUND', { resource: 'EmployeeTiming' }, 'رکورد تردد یافت نشد');
}
if (body.user !== undefined || body.userId !== undefined) {
const targetUserId = body.user || body.userId;
const userExists = await User.findById(targetUserId).select('_id').lean();
if (!userExists) {
throw new AppError('NOT_FOUND', { resource: 'User' }, 'کاربر انتخاب‌شده یافت نشد');
}
record.user = targetUserId;
}
if (body.date !== undefined) {
record.date = normalizeDate(body.date);
}
if (body.entryTime !== undefined) {
record.entryTime = normalizeTime(body.entryTime);
}
if (body.exitTime !== undefined) {
record.exitTime = normalizeTime(body.exitTime);
}
if (body.note !== undefined) {
record.note = sanitizeNote(body.note);
}
if (authorId) {
record.updatedBy = authorId;
}
await record.save();
return getOne(record._id);
};
const remove = async (id) => {
const record = await EmployeeTiming.findByIdAndDelete(id);
if (!record) {
throw new AppError('NOT_FOUND', { resource: 'EmployeeTiming' }, 'رکورد تردد یافت نشد');
}
return { success: true };
};
module.exports = {
getAll,
getSummary,
getOne,
create,
update,
remove,
computeStatus,
computeDurationMinutes,
computeDurationFormatted
};
@@ -19,3 +19,8 @@ exports.getRangeReport = catchAsync(async (req, res) => {
const report = await financialReportService.getRangeReport(req.query);
return successResponse(res, 200, 'Date-range financial report retrieved successfully', report);
});
exports.getAnalytics = catchAsync(async (req, res) => {
const report = await financialReportService.getAnalytics(req.query);
return successResponse(res, 200, 'Financial analytics retrieved successfully', report);
});
@@ -12,6 +12,7 @@ const router = express.Router();
router.use(authMiddleware);
router.use(perm.requires(PERMISSIONS.FINANCIAL_REPORTS_READ));
router.get('/admin/analytics', financialReportController.getAnalytics);
router.get('/admin/range', financialReportController.getRangeReport);
router.get('/admin/classes/:classId', financialReportController.getClassReport);
router.get('/admin/sessions/:sessionId', financialReportController.getSessionReport);
@@ -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,
@@ -94,7 +103,9 @@ const getClassReport = async (classId) => {
revenue: revenue.actualReceivedRevenue,
sessionDurationHours,
sessionsCount: sessionCounts.held,
extraExpensePerSession: cls.extraExpensePerSession
extraExpensePerSession: cls.extraExpensePerSession,
serviceFeePerPerson: cls.serviceFeePerPerson,
studentsCount: (cls.students || []).length
});
const netProfit = calculateNetProfit({
@@ -176,7 +187,9 @@ const getSessionReport = async (sessionId) => {
revenue: sessionIncome,
payoutHourlyRate: cls.payoutHourlyRate,
sessionDurationHours,
sessionsCount: 1
sessionsCount: 1,
serviceFeePerPerson: (cls.serviceFeePerPerson || 0) / (plannedSessions || 1),
studentsCount
});
const sessionExtraExpense = calculateExtraExpenses({
extraExpensePerSession: cls.extraExpensePerSession,
@@ -299,7 +312,9 @@ const getRangeReport = async (query = {}) => {
revenue: receivedInRange,
sessionDurationHours,
sessionsCount: sessionsHeldInRange,
extraExpensePerSession: cls.extraExpensePerSession
extraExpensePerSession: cls.extraExpensePerSession,
serviceFeePerPerson: cls.serviceFeePerPerson,
studentsCount: (cls.students || []).length
});
return {
@@ -335,8 +350,473 @@ 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,
serviceFeePerPerson: cls.serviceFeePerPerson,
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,
serviceFeePerPerson: profile.serviceFeePerPerson,
studentsCount: profile.studentsCount
});
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,
serviceFeePerPerson: profile.serviceFeePerPerson,
studentsCount: profile.studentsCount
});
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
};
+33
View File
@@ -0,0 +1,33 @@
'use strict';
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const paymentService = require('./paymentService');
describe('Bulk Payment and Duplicate Check Service', () => {
it('exports createBulkClassPayments and checkDuplicatePayment', () => {
assert.equal(typeof paymentService.createBulkClassPayments, 'function');
assert.equal(typeof paymentService.checkDuplicatePayment, 'function');
});
it('checkDuplicatePayment returns false when userId is not provided', async () => {
const result = await paymentService.checkDuplicatePayment({});
assert.deepEqual(result, {
hasDuplicate: false,
count: 0,
payments: []
});
});
it('createBulkClassPayments throws error when classId is missing', async () => {
await assert.rejects(
async () => {
await paymentService.createBulkClassPayments({});
},
(err) => {
assert.equal(err.errorCode, 'VALIDATION_FAILED');
return true;
}
);
});
});
+22
View File
@@ -10,6 +10,17 @@ exports.create = catchAsync(async (req, res, next) => {
return successResponse(res, 201, 'Payment created successfully', payment);
});
exports.createBulkClass = catchAsync(async (req, res, next) => {
const actorId = req.user?._id;
const result = await paymentService.createBulkClassPayments(req.body, actorId);
return successResponse(res, 201, 'Bulk payments created successfully', result);
});
exports.checkDuplicate = catchAsync(async (req, res, next) => {
const result = await paymentService.checkDuplicatePayment(req.query);
return successResponse(res, 200, 'Duplicate check completed', result);
});
exports.getOne = catchAsync(async (req, res, next) => {
const payment = await paymentService.getPaymentById(req.params.id);
return successResponse(res, 200, 'Payment retrieved successfully', payment);
@@ -64,3 +75,14 @@ exports.cancelTransaction = catchAsync(async (req, res, next) => {
const payment = await paymentService.cancelTransaction(req.params.transactionId, actorId);
return successResponse(res, 200, 'Transaction cancelled successfully', payment);
});
exports.revertTransaction = catchAsync(async (req, res, next) => {
const actorId = req.user?._id;
const payment = await paymentService.revertTransaction(req.params.transactionId, actorId);
return successResponse(res, 200, 'Transaction reverted successfully', payment);
});
exports.deleteTransaction = catchAsync(async (req, res, next) => {
const payment = await paymentService.deleteTransaction(req.params.transactionId);
return successResponse(res, 200, 'Transaction deleted successfully', payment);
});
+10 -1
View File
@@ -42,9 +42,15 @@ const paymentSchema = new mongoose.Schema({
},
status: {
type: String,
enum: ['pending', 'partial', 'paid', 'overdue'],
enum: ['pending', 'partial', 'paid', 'overdue', 'cancelled', 'reverted'],
default: 'pending'
},
type: {
type: String,
enum: ['regular', 'waiting_list'],
default: 'regular',
index: true
},
notes: { type: String, trim: true, maxlength: 5000 },
isDeleted: {
type: Boolean,
@@ -72,6 +78,9 @@ paymentSchema.virtual('transactions', {
paymentSchema.pre('save', function (next) {
this.discount = normalizeDiscount(this.discount, this.amount);
const payable = getPayableAmount(this);
if (this.status === 'cancelled' || this.status === 'reverted') {
return next();
}
if (this.paidAmount >= payable) {
this.status = 'paid';
} else if (this.paidAmount > 0) {
+4
View File
@@ -22,12 +22,16 @@ router.post('/user/pay/:id', validateAddTransaction, paymentController.payUser);
// Admin Scope
router.post('/admin/create', perm.requires(PERMISSIONS.PAYMENTS_CREATE), validateCreatePayment, paymentController.create);
router.post('/admin/bulk-class', perm.requires(PERMISSIONS.PAYMENTS_CREATE), paymentController.createBulkClass);
router.get('/admin/check-duplicate', perm.requires(PERMISSIONS.PAYMENTS_READ), paymentController.checkDuplicate);
router.get('/admin/get-all', perm.requires(PERMISSIONS.PAYMENTS_READ), paymentController.getAll);
router.get('/admin/search', perm.requires(PERMISSIONS.PAYMENTS_SEARCH), paymentController.search);
router.get('/admin/get-one/:id', perm.requires(PERMISSIONS.PAYMENTS_READ), paymentController.getOne);
router.put('/admin/update/:id', perm.requires(PERMISSIONS.PAYMENTS_UPDATE), validateUpdatePayment, paymentController.update);
router.put('/admin/transactions/:transactionId', perm.requires(PERMISSIONS.PAYMENTS_UPDATE), validateUpdateTransaction, paymentController.updateTransaction);
router.post('/admin/transactions/:transactionId/cancel', perm.requires(PERMISSIONS.PAYMENTS_UPDATE), paymentController.cancelTransaction);
router.post('/admin/transactions/:transactionId/revert', perm.requires(PERMISSIONS.PAYMENTS_UPDATE), paymentController.revertTransaction);
router.delete('/admin/transactions/:transactionId', perm.requires(PERMISSIONS.PAYMENTS_UPDATE), paymentController.deleteTransaction);
router.post('/admin/transactions/:paymentId', perm.requires(PERMISSIONS.PAYMENTS_UPDATE), validateAddTransaction, paymentController.createTransaction);
router.delete('/admin/delete/:id', perm.requires(PERMISSIONS.PAYMENTS_DELETE), paymentController.delete);
+212 -4
View File
@@ -1,6 +1,7 @@
// /components/payments/paymentService.js
'use strict';
const mongoose = require('mongoose');
const Payment = require('./paymentModel');
const Transaction = require('./transactionModel');
const AppError = require('../../utils/AppError');
@@ -28,7 +29,8 @@ const {
isPaidTransaction,
isCancelledTransaction,
isActiveTransaction,
sumPaidTransactions
sumPaidTransactions,
formatPrice
} = require('../../utils/paymentAmount');
const logger = require('../../utils/logger');
@@ -36,7 +38,7 @@ const PAYMENT_METHODS = new Set(['online', 'card', 'cash']);
const PAYMENT_STATUS_LABELS = {
pending: 'در انتظار پرداخت',
partial: 'پرداخت جزئی',
partial: یش پرداخت',
paid: 'پرداخت‌شده',
overdue: 'معوق'
};
@@ -112,7 +114,7 @@ const notifyTransactionRecorded = async (payment, transaction, source = {}) => {
phoneNumber: user.phoneNumber,
email: user.email,
subject: 'ثبت تراکنش',
body: `تراکنش ${transaction.uniqueCode || ''} به مبلغ ${transaction.amount} تومان ثبت شد.`,
body: `تراکنش ${transaction.uniqueCode || ''} به مبلغ ${formatPrice(transaction.amount)} تومان ثبت شد.`,
smsHandler: () => sendTransactionRecordedSms(user.phoneNumber, {
fullName: user.name || '',
amount: transaction.amount,
@@ -257,6 +259,13 @@ const getAllPayments = async (query) => {
}
if (query.userId) filter.user = query.userId;
if (query.status) filter.status = query.status;
if (query.classId) filter.classes = query.classId;
if (query.classes) {
const classList = Array.isArray(query.classes)
? query.classes
: query.classes.split(',').map((s) => s.trim()).filter(Boolean);
if (classList.length) filter.classes = { $in: classList };
}
const searchTerm = getSearchTerm(query);
if (searchTerm) {
@@ -302,6 +311,16 @@ const createPayment = async (body, actorId = null) => {
const incomingTransactions = Array.isArray(payload.transactions) ? payload.transactions : null;
delete payload.transactions;
if (!payload.course && payload.classes && payload.classes.length) {
const firstClassId = Array.isArray(payload.classes) ? payload.classes[0] : payload.classes;
if (firstClassId) {
const firstClass = await Class.findById(firstClassId).select('course').lean();
if (firstClass?.course) {
payload.course = firstClass.course;
}
}
}
const payment = await Payment.create({
...payload,
discount: normalizeDiscount(payload.discount, payload.amount),
@@ -333,7 +352,7 @@ const createPayment = async (body, actorId = null) => {
phoneNumber: user.phoneNumber,
email: user.email,
subject: 'ایجاد صورتحساب',
body: `صورتحساب ${invoiceCode} به مبلغ ${getPayableAmount(payment)} تومان بابت «${courseName}» ایجاد شد.`,
body: `صورتحساب ${invoiceCode} به مبلغ ${formatPrice(getPayableAmount(payment))} تومان بابت «${courseName}» ایجاد شد.`,
smsHandler: () => sendInvoiceCreatedSms(user.phoneNumber, {
fullName: user.name || '',
amount: getPayableAmount(payment),
@@ -481,6 +500,191 @@ const cancelTransaction = async (transactionId, actorId = null) => {
return getPaymentById(payment._id);
};
const revertTransaction = async (transactionId, actorId = null) => {
const trx = await Transaction.findById(transactionId);
if (!trx) throw new AppError('TRANSACTION_NOT_FOUND');
if (trx.status === 'reverted') {
throw new AppError('VALIDATION_FAILED', {}, 'این تراکنش قبلاً مسترد شده است.');
}
const payment = await Payment.findById(trx.payment);
if (!payment) throw new AppError('PAYMENT_NOT_FOUND');
const previousStatus = payment.status;
trx.status = 'reverted';
if (actorId) trx.recordedBy = actorId;
await trx.save();
await refreshPaymentTotals(payment);
await emitPaymentStatusChangedIfNeeded(payment, previousStatus, actorId);
return getPaymentById(payment._id);
};
const deleteTransaction = async (transactionId) => {
const trx = await Transaction.findById(transactionId);
if (!trx) throw new AppError('TRANSACTION_NOT_FOUND');
const payment = await Payment.findById(trx.payment);
await Transaction.findByIdAndDelete(transactionId);
if (payment) {
const previousStatus = payment.status;
await refreshPaymentTotals(payment);
await emitPaymentStatusChangedIfNeeded(payment, previousStatus);
return getPaymentById(payment._id);
}
return null;
};
const createBulkClassPayments = async (body, actorId = null) => {
const { classId } = body;
if (!classId) {
throw new AppError('VALIDATION_FAILED', { classId: 'Class ID is required' }, 'شناسه کلاس الزامی است.');
}
const classDoc = await Class.findById(classId).populate('course', 'title price').lean();
if (!classDoc) {
throw new AppError('CLASS_NOT_FOUND', {}, 'کلاس مورد نظر یافت نشد.');
}
const allStudentIds = (classDoc.students || []).map((s) => String(s._id || s.id || s));
const targetStudentIds = (body.studentIds && Array.isArray(body.studentIds) && body.studentIds.length > 0)
? body.studentIds.map(String).filter((id) => allStudentIds.includes(id))
: allStudentIds;
if (!targetStudentIds.length) {
return {
message: 'هیچ دانشجویی در این کلاس ثبت‌نام نشده است.',
totalStudents: 0,
createdCount: 0,
skippedCount: 0,
payments: []
};
}
const skipExisting = body.skipExisting !== false;
let existingUserIds = new Set();
if (skipExisting) {
const existing = await Payment.find({
classes: classId,
user: { $in: targetStudentIds },
isDeleted: { $ne: true }
}).select('user').lean();
existingUserIds = new Set(existing.map((p) => String(p.user)));
}
const amount = body.amount !== undefined && body.amount !== null && body.amount !== ''
? Number(body.amount)
: (classDoc.tuitionFee || classDoc.course?.price || 0);
const discount = body.discount !== undefined && body.discount !== null && body.discount !== ''
? Number(body.discount)
: (classDoc.hasDiscount ? (classDoc.discount || 0) : 0);
const dueDate = parseDate(body.dueDate) || classDoc.startDate || new Date();
const notify = body.notify || {};
const notifySms = body.notifySms;
const notifyEmail = body.notifyEmail;
const notifyBot = body.notifyBot;
const createdPayments = [];
let skippedCount = 0;
for (const studentId of targetStudentIds) {
if (skipExisting && existingUserIds.has(studentId)) {
skippedCount++;
continue;
}
const payment = await createPayment({
user: studentId,
classes: [classId],
course: classDoc.course?._id || classDoc.course,
amount,
discount,
dueDate,
notes: body.notes,
notify,
notifySms,
notifyEmail,
notifyBot
}, actorId);
createdPayments.push(payment);
}
return {
totalStudents: targetStudentIds.length,
createdCount: createdPayments.length,
skippedCount,
payments: createdPayments
};
};
const checkDuplicatePayment = async (query = {}) => {
const { userId, classId, classes } = query;
if (!userId) {
return { hasDuplicate: false, count: 0, payments: [] };
}
const filter = {
isDeleted: { $ne: true }
};
const userConditions = [userId];
if (mongoose.isValidObjectId(userId)) {
userConditions.push(new mongoose.Types.ObjectId(String(userId)));
}
filter.user = { $in: userConditions };
const rawClasses = [];
if (classId) rawClasses.push(classId);
if (classes) {
if (Array.isArray(classes)) rawClasses.push(...classes);
else rawClasses.push(...String(classes).split(',').map((s) => s.trim()).filter(Boolean));
}
if (rawClasses.length) {
const classObjectIds = [];
rawClasses.forEach((id) => {
classObjectIds.push(id);
if (mongoose.isValidObjectId(id)) {
classObjectIds.push(new mongoose.Types.ObjectId(String(id)));
}
});
const classDocs = await Class.find({ _id: { $in: classObjectIds } }).select('course').lean();
const courseIds = classDocs.map((c) => c.course).filter(Boolean);
const courseObjectIds = [];
courseIds.forEach((id) => {
courseObjectIds.push(id);
if (mongoose.isValidObjectId(id)) {
courseObjectIds.push(new mongoose.Types.ObjectId(String(id)));
}
});
const orConditions = [{ classes: { $in: classObjectIds } }];
if (courseObjectIds.length) {
orConditions.push({ course: { $in: courseObjectIds } });
}
filter.$or = orConditions;
}
const payments = await Payment.find(filter)
.populate({ path: 'classes', select: 'name' })
.populate({ path: 'course', select: 'title' })
.select('_id uniqueCode amount discount paidAmount status classes course createdAt')
.lean();
return {
hasDuplicate: payments.length > 0,
count: payments.length,
payments
};
};
const getMyPayments = async (userId, query = {}) => {
return getAllPayments({ ...query, userId });
};
@@ -489,12 +693,16 @@ module.exports = {
getAllPayments,
getPaymentById,
createPayment,
createBulkClassPayments,
checkDuplicatePayment,
updatePayment,
deletePayment,
searchPayments,
addTransaction,
updateTransaction,
cancelTransaction,
revertTransaction,
deleteTransaction,
getMyPayments,
createTransactionsForPayment,
refreshPaymentTotals,
+1 -1
View File
@@ -4,7 +4,7 @@
const AppError = require('../../utils/AppError');
const PAYMENT_METHODS = new Set(['online', 'card', 'cash']);
const TRANSACTION_STATUSES = new Set(['pending', 'paid']);
const TRANSACTION_STATUSES = new Set(['pending', 'paid', 'cancelled', 'reverted']);
const passThrough = (req, res, next) => next();
+1 -1
View File
@@ -36,7 +36,7 @@ const transactionSchema = new mongoose.Schema({
},
status: {
type: String,
enum: ['pending', 'paid', 'cancelled'],
enum: ['pending', 'paid', 'cancelled', 'reverted'],
default: 'pending',
index: true
}
@@ -9,6 +9,12 @@ exports.create = catchAsync(async (req, res, next) => {
return successResponse(res, 201, 'Professor created successfully', professor);
});
exports.createFromUser = catchAsync(async (req, res, next) => {
const userId = req.body.userId || req.params.userId || req.body.id;
const result = await professorService.createProfessorFromUser(userId, req.body);
return successResponse(res, 201, 'Professor created from user successfully', result);
});
exports.getOne = catchAsync(async (req, res, next) => {
const professor = await professorService.getProfessorById(req.params.id);
return successResponse(res, 200, 'Professor retrieved successfully', professor);
+1
View File
@@ -12,6 +12,7 @@ const router = express.Router();
router.use(authMiddleware);
router.post('/admin/create', perm.requires(PERMISSIONS.PROFESSORS_CREATE), validateCreateProfessor, professorController.create);
router.post('/admin/create-from-user', perm.requires(PERMISSIONS.PROFESSORS_CREATE), professorController.createFromUser);
router.get('/admin/get-all', perm.requires(PERMISSIONS.PROFESSORS_READ), professorController.getAll);
router.get('/admin/search', perm.requires(PERMISSIONS.PROFESSORS_SEARCH), professorController.search);
router.get('/admin/get-one/:id', perm.requires(PERMISSIONS.PROFESSORS_READ), professorController.getOne);
+109
View File
@@ -1,6 +1,8 @@
// /components/professors/professorService.js
const Professor = require('./professorModel');
const User = require('../users/userModel');
const Role = require('../roles/roleModel');
const AppError = require('../../utils/AppError');
const eventEmitter = require('../../events/eventEmitter');
const EVENT_NAMES = require('../../constants/eventNames');
@@ -43,6 +45,112 @@ const createProfessor = async (data) => {
return professor;
};
const createProfessorFromUser = async (userId, additionalData = {}) => {
if (!userId) {
throw new AppError('VALIDATION_FAILED', null, 'User ID is required');
}
const user = await User.findById(userId);
if (!user) {
throw new AppError('USER_NOT_FOUND');
}
const professorRole = await Role.findOne({ name: 'Professor' });
if (!professorRole) {
throw new AppError('DEFAULT_ROLE_NOT_FOUND', null, 'Professor role not found');
}
// Update user role to Professor if not already
if (String(user.role) !== String(professorRole._id)) {
user.role = professorRole._id;
await user.save();
}
// Determine name and surname
let firstName = String(additionalData.name || '').trim();
let lastName = String(additionalData.surname || '').trim();
if (!firstName && !lastName) {
const rawName = String(user.name || '').trim();
const parts = rawName.split(/\s+/);
if (parts.length > 1) {
firstName = parts[0];
lastName = parts.slice(1).join(' ');
} else {
firstName = rawName || 'استاد';
lastName = rawName || 'استاد';
}
} else if (!lastName && firstName) {
lastName = firstName;
} else if (!firstName && lastName) {
firstName = lastName;
}
let nationalIdCode = String(additionalData.nationalIdCode || additionalData.nationalId || user.nationalIdCode || '').trim();
const phoneNumber = String(additionalData.phoneNumber || additionalData.phone || user.phoneNumber || '').trim();
if (!nationalIdCode) {
nationalIdCode = await allocatePlaceholderNationalId(phoneNumber);
}
const email = additionalData.email ? String(additionalData.email).trim().toLowerCase() : (user.email ? String(user.email).trim().toLowerCase() : undefined);
const cardNumber = additionalData.cardNumber ? String(additionalData.cardNumber).trim() : (user.cardNumber ? String(user.cardNumber).trim() : undefined);
const shabaNumber = additionalData.shabaNumber || additionalData.iban ? String(additionalData.shabaNumber || additionalData.iban).trim() : (user.shabaNumber ? String(user.shabaNumber).trim() : undefined);
const bio = additionalData.bio ? String(additionalData.bio).trim() : undefined;
let expertise = [];
if (Array.isArray(additionalData.expertise)) {
expertise = additionalData.expertise.map(String).map(s => s.trim()).filter(Boolean);
} else if (typeof additionalData.expertise === 'string' && additionalData.expertise.trim()) {
expertise = additionalData.expertise.split(',').map(s => s.trim()).filter(Boolean);
}
// Check if a Professor record already exists for this nationalIdCode or phoneNumber
let professor = await Professor.findOne({
$or: [
{ nationalIdCode },
{ phoneNumber }
]
});
if (professor) {
professor.name = firstName;
professor.surname = lastName;
if (email) professor.email = email;
if (cardNumber) professor.cardNumber = cardNumber;
if (shabaNumber) professor.shabaNumber = shabaNumber;
if (bio) professor.bio = bio;
if (expertise.length > 0) professor.expertise = expertise;
professor.isActive = true;
await professor.save();
} else {
professor = await Professor.create({
name: firstName,
surname: lastName,
nationalIdCode,
phoneNumber,
email,
cardNumber,
shabaNumber,
bio,
expertise,
isActive: true
});
}
eventEmitter.emit(EVENT_NAMES.PROFESSOR_CREATED, {
professorId: professor._id,
name: `${professor.name} ${professor.surname}`
});
const updatedUser = await User.findById(user._id)
.select('-passwordHash -refreshTokens')
.populate({ path: 'role', select: 'name permissions' })
.lean();
return { professor, user: updatedUser };
};
const getProfessorById = async (id) => {
const professor = await Professor.findById(id).populate('courses', 'title type price');
if (!professor) {
@@ -117,6 +225,7 @@ const searchProfessors = async (queryParams) => {
module.exports = {
createProfessor,
createProfessorFromUser,
getProfessorById,
getAllProfessors,
updateProfessor,
+6
View File
@@ -57,6 +57,12 @@ exports.updateAttendance = catchAsync(async (req, res, next) => {
return successResponse(res, 200, 'Session attendance updated successfully', session);
});
exports.notifyHolding = catchAsync(async (req, res, next) => {
const actorId = req.user?._id;
const result = await sessionService.notifySessionHolding(req.params.id, actorId);
return successResponse(res, 200, 'اطلاع‌رسانی برگزاری جلسه با موفقیت انجام شد', result);
});
exports.getMySessions = catchAsync(async (req, res, next) => {
const { data, meta } = await sessionService.getMySessions(req.user._id, req.query);
return listResponse(res, 200, data, meta);
+1
View File
@@ -28,5 +28,6 @@ router.delete('/admin/delete/:id', perm.requires(PERMISSIONS.SESSIONS_DELETE), s
router.post('/admin/bulk-delete', perm.requires(PERMISSIONS.SESSIONS_DELETE), sessionController.bulkDelete);
router.post('/admin/bulk-status', perm.requires(PERMISSIONS.SESSIONS_UPDATE), sessionController.bulkUpdateStatus);
router.put('/admin/:id/attendance', perm.requires(PERMISSIONS.SESSIONS_ATTENDANCE), validateUpdateAttendanceList, sessionController.updateAttendance);
router.post('/admin/:id/notify-holding', perm.requires(PERMISSIONS.SESSIONS_READ), sessionController.notifyHolding);
module.exports = router;
+88 -6
View File
@@ -9,6 +9,9 @@ const AppError = require('../../utils/AppError');
const eventEmitter = require('../../events/eventEmitter');
const EVENT_NAMES = require('../../constants/eventNames');
const { parsePaginationAndSort, buildFilterQuery, calculateMeta } = require('../../utils/pagination');
const { notifyAction } = require('../../utils/actionNotify');
const { sendSessionHoldingSms } = require('../../utils/senders/smsMessages');
const logger = require('../../utils/logger');
const STATUS_MAP = {
scheduled: 'scheduled',
@@ -234,7 +237,7 @@ const populateSessionList = (query, { includeClassStudents = false } = {}) => {
};
const getAllSessions = async (queryParams) => {
const { page, limit, skip } = parsePaginationAndSort(queryParams, 'day', 'asc');
const { page, limit, skip, sort } = parsePaginationAndSort(queryParams, 'day', 'asc');
const attendanceScope = queryParams.attendanceScope;
const filter = buildFilterQuery(queryParams, ['topic', 'place', 'note'], [
'page',
@@ -269,10 +272,9 @@ const getAllSessions = async (queryParams) => {
const includeClassStudents = Boolean(attendanceScope);
const matched = await populateSessionList(Session.find(filter), { includeClassStudents }).lean();
let sessions = sortByClosestAttendance(matched);
if (attendanceScope) {
const matched = await populateSessionList(Session.find(filter), { includeClassStudents }).lean();
let sessions = sortByClosestAttendance(matched);
const studentsByClassId = await buildStudentsByClassId(matched);
sessions = filterByAttendanceScope(sessions, attendanceScope, studentsByClassId)
.map((session) => enrichSessionAttendance(session, studentsByClassId));
@@ -282,8 +284,16 @@ const getAllSessions = async (queryParams) => {
return { data: sessions, meta };
}
const totalCount = await Session.countDocuments(filter);
sessions = sessions.slice(skip, skip + limit);
const effectiveSort = { ...sort };
if (effectiveSort.day) {
effectiveSort.startTime = effectiveSort.day;
}
const [sessions, totalCount] = await Promise.all([
populateSessionList(Session.find(filter).sort(effectiveSort).skip(skip).limit(limit), { includeClassStudents: false }).lean(),
Session.countDocuments(filter)
]);
const meta = calculateMeta(totalCount, page, limit);
return { data: sessions, meta };
};
@@ -430,6 +440,77 @@ const getMySessions = async (userId, queryParams) => {
return { data: sessions, meta };
};
const notifySessionHolding = async (sessionId, actorId = null) => {
const session = await Session.findById(sessionId)
.populate({
path: 'class',
select: 'name uniqueCode students startTime place'
})
.populate('course', 'title');
if (!session) {
throw new AppError('SESSION_NOT_FOUND');
}
const classDoc = session.class;
let studentIds = classDoc?.students?.length
? classDoc.students
: (await User.find({ courses: session.course?._id || session.course }).select('_id')).map((u) => u._id);
if (!studentIds.length) {
throw new AppError('NOT_FOUND', null, 'هیچ دانشجویی در این کلاس ثبت‌نام نشده است.');
}
const sessionDate = session.day
? new Date(session.day).toLocaleDateString('fa-IR')
: '';
const className = classDoc?.name || session.course?.title || 'کلاس';
const classCode = classDoc?.uniqueCode || '';
const timeLabel = session.startTime || '';
const topicLabel = session.topic || className;
const users = await User.find({ _id: { $in: studentIds } }).select('name phoneNumber email').lean();
const validUsers = users.filter((u) => u.phoneNumber);
if (!validUsers.length) {
throw new AppError('NOT_FOUND', null, 'هیچ دانشجویی با شماره همراه معتبر در این کلاس یافت نشد.');
}
let sentCount = 0;
for (const student of validUsers) {
try {
await notifyAction({
actionKey: 'sessionHolding',
userId: student._id,
phoneNumber: student.phoneNumber,
email: student.email,
subject: 'برگزاری جلسه طبق برنامه',
body: `جلسه «${topicLabel}» کلاس ${className} در تاریخ ${sessionDate} و ساعت ${timeLabel} طبق برنامه برگزار خواهد شد.`,
smsHandler: () => sendSessionHoldingSms(student.phoneNumber, {
fullName: student.name || '',
className,
topic: topicLabel,
sessionDate,
classTime: timeLabel,
courseName: session.course?.title || className,
classCode,
place: session.place || '-'
}, student._id),
requestSource: { notifySms: true, notifyEmail: true, notifyBot: true }
});
sentCount += 1;
} catch (err) {
logger.error(`[notifySessionHolding] Failed for user ${student._id}: ${err.message}`);
}
}
return {
success: true,
sentCount,
totalStudents: validUsers.length
};
};
module.exports = {
createSession,
getSessionById,
@@ -441,6 +522,7 @@ module.exports = {
searchSessions,
updateSessionAttendance,
getMySessions,
notifySessionHolding,
isSessionDue,
hasCompleteAttendance,
isAttendancePending
@@ -65,6 +65,15 @@ const NOTIFICATION_ACTION_DEFS = [
smsTemplateKey: 'sessionHolding',
defaultChannels: { sms: true, email: false, bot: false }
},
{
key: 'classPlanProfessor',
label: 'ارسال برنامه کلاس به استاد',
description: 'ارسال جزئیات و زمان‌بندی برگزاری کلاس به استاد',
category: 'classes',
relatedEvent: 'class.plan_to_professor',
smsTemplateKey: 'classPlanProfessor',
defaultChannels: { sms: true, email: false, bot: false }
},
{
key: 'invoiceCreated',
label: 'ایجاد صورتحساب',
+8 -2
View File
@@ -61,6 +61,8 @@ const toPublicTemplates = (storedMap, notificationMap = {}) => {
return {
key: def.key,
label: def.label,
category: entry.category || def.category || 'اطلاع‌رسانی',
text: entry.text || def.defaultText || '',
enabled: isEnabled,
templateId: resolveTemplateId(entry, def),
availableSlots: (def.slots || []).map((slot) => ({
@@ -115,7 +117,7 @@ const getSettings = async () => {
const getSmsTemplate = async (key) => {
const def = SMS_TEMPLATE_DEFS.find((item) => item.key === key);
if (!def) {
return { templateId: '', variables: [], enabled: true };
return { templateId: '', variables: [], enabled: true, text: '', category: 'اطلاع‌رسانی' };
}
const doc = await Setting.findOne({ key: SETTINGS_KEY }).lean();
const entry = normalizeStoredEntry(readStoredMap(doc)[key], def);
@@ -128,6 +130,8 @@ const getSmsTemplate = async (key) => {
return {
enabled: isEnabled,
templateId: resolveTemplateId(entry, def),
category: entry.category || def.category || 'اطلاع‌رسانی',
text: entry.text || def.defaultText || '',
variables: resolveVariablesList(def, entry.variables)
};
};
@@ -140,12 +144,14 @@ const getSmsTemplateId = async (key) => {
const parseIncomingEntry = (raw) => {
if (raw == null) return null;
if (typeof raw === 'string' || typeof raw === 'number') {
return { templateId: raw, variables: undefined, enabled: undefined };
return { templateId: raw, variables: undefined, enabled: undefined, text: undefined, category: undefined };
}
if (typeof raw !== 'object') return null;
return {
enabled: raw.enabled !== undefined ? Boolean(raw.enabled) : undefined,
templateId: raw.templateId,
category: raw.category,
text: raw.text,
variables: raw.variables
};
};
@@ -0,0 +1,63 @@
// /components/settings/smsTemplateRender.test.js
'use strict';
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const { renderSmsText, SMS_TEMPLATE_DEFS } = require('./smsTemplates');
const { formatJalaliDate } = require('../../utils/jalaliDate');
describe('SMS Template Text Rendering', () => {
it('renders class plan to professor template correctly with variables', () => {
const def = SMS_TEMPLATE_DEFS.find((d) => d.key === 'classPlanProfessor');
assert.ok(def);
const slotValues = {
professorName: 'علی رضایی',
className: 'برنامه‌نویسی وب',
classDays: 'شنبه و دوشنبه',
classTimes: '۱۶:۰۰ الی ۱۸:۰۰',
classStartDate: '1405/06/01',
classEndDate: '1405/08/15'
};
const rendered = renderSmsText(def.defaultText, def.slots.map(s => ({ slot: s.key, name: s.defaultName })), slotValues);
assert.match(rendered, /استاد علی رضایی/);
assert.match(rendered, /برنامه‌نویسی وب/);
assert.match(rendered, /شنبه و دوشنبه/);
assert.match(rendered, /۱۶:۰۰ الی ۱۸:۰۰/);
assert.match(rendered, /1405\/06\/01/);
assert.match(rendered, /1405\/08\/15/);
});
it('renders sessionHolding template correctly', () => {
const def = SMS_TEMPLATE_DEFS.find((d) => d.key === 'sessionHolding');
assert.ok(def);
const slotValues = {
fullName: 'سارا محمدی',
topic: 'مقدمه‌ای بر پایگاه داده',
className: 'کلاس بک‌اند',
sessionDate: '1405/06/10',
classTime: '17:00'
};
const rendered = renderSmsText(def.defaultText, [
{ slot: 'fullName', name: 'FULLNAME' },
{ slot: 'topic', name: 'TOPIC' },
{ slot: 'className', name: 'CLASSNAME' },
{ slot: 'sessionDate', name: 'SESSIONDATE' },
{ slot: 'classTime', name: 'CLASSTIME' }
], slotValues);
assert.match(rendered, /سارا محمدی عزیز/);
assert.match(rendered, /مقدمه‌ای بر پایگاه داده/);
assert.match(rendered, /کلاس بک‌اند/);
assert.match(rendered, /1405\/06\/10/);
assert.match(rendered, /17:00/);
});
it('formats dates into Jalali correctly', () => {
const formatted = formatJalaliDate('2026-08-24T00:00:00.000Z');
assert.equal(formatted, '1405/06/02');
});
});
+368 -159
View File
@@ -1,137 +1,17 @@
// /components/settings/smsTemplates.js
'use strict';
const SMS_TEMPLATE_DEFS = [
{
key: 'accountCreated',
label: 'ایجاد حساب کاربری',
envKey: 'SMS_TEMPLATE_ACCOUNT_CREATED',
slots: [
{ key: 'username', label: 'نام کاربری', defaultName: 'user' },
{ key: 'password', label: 'رمز عبور', defaultName: 'password' },
{ key: 'fullName', label: 'نام کاربر', defaultName: 'name' },
{ key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' },
{ key: 'userCode', label: 'کد کاربری', defaultName: 'userCode' }
]
},
{
key: 'classRegistered',
label: 'ثبت‌نام در کلاس',
envKey: 'SMS_TEMPLATE_CLASS_REGISTERED',
slots: [
{ key: 'className', label: 'نام کلاس', defaultName: 'className' },
{ key: 'courseName', label: 'نام دوره', defaultName: 'courseName' },
{ key: 'fullName', label: 'نام کارآموز', defaultName: 'fullName' },
{ key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' },
{ key: 'classStartDate', label: 'تاریخ شروع کلاس', defaultName: 'classStartDate' },
{ key: 'classDays', label: 'روزهای برگزاری', defaultName: 'classDays' },
{ key: 'courseTime', label: 'ساعت دوره', defaultName: 'courseTime' },
{ key: 'classCode', label: 'کد کلاس', defaultName: 'classCode' }
]
},
{
key: 'classReminder',
label: 'یادآوری کلاس',
envKey: 'SMS_TEMPLATE_CLASS_REMINDER',
slots: [
{ key: 'className', label: 'نام کلاس', defaultName: 'className' },
{ key: 'courseName', label: 'نام دوره', defaultName: 'courseName' },
{ key: 'topic', label: 'موضوع جلسه', defaultName: 'topic' },
{ key: 'time', label: 'ساعت', defaultName: 'time' },
{ key: 'classTime', label: 'ساعت کلاس', defaultName: 'classTime' },
{ key: 'sessionDate', label: 'تاریخ جلسه', defaultName: 'sessionDate' },
{ key: 'place', label: 'مکان', defaultName: 'place' },
{ key: 'fullName', label: 'نام کاربر', defaultName: 'fullName' },
{ key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' },
{ key: 'classStartDate', label: 'تاریخ شروع کلاس', defaultName: 'classStartDate' },
{ key: 'classDays', label: 'روزهای برگزاری', defaultName: 'classDays' },
{ key: 'courseTime', label: 'ساعت دوره', defaultName: 'courseTime' },
{ key: 'classCode', label: 'کد کلاس', defaultName: 'classCode' }
]
},
{
key: 'invoiceCreated',
label: 'ایجاد صورتحساب',
envKey: 'SMS_TEMPLATE_INVOICE_CREATED',
slots: [
{ key: 'fullName', label: 'نام کارآموز', defaultName: 'FULLNAME' },
{ key: 'amount', label: 'مبلغ', defaultName: 'PAYMENT_PRICE' },
{ key: 'course', label: 'نام دوره', defaultName: 'COURSE' },
{ key: 'phoneNumber', label: 'شماره همراه', defaultName: 'MOBILE' },
{ key: 'classStartDate', label: 'تاریخ شروع کلاس', defaultName: 'classStartDate' },
{ key: 'classDays', label: 'روزهای برگزاری', defaultName: 'classDays' },
{ key: 'courseTime', label: 'ساعت دوره', defaultName: 'courseTime' },
{ key: 'invoiceCode', label: 'کد صورتحساب', defaultName: 'invoiceCode' }
]
},
{
key: 'passwordReset',
label: 'بازنشانی رمز عبور',
envKey: 'SMS_TEMPLATE_PASSWORD_RESET',
slots: [
{ key: 'username', label: 'نام کاربری', defaultName: 'user' },
{ key: 'password', label: 'رمز عبور', defaultName: 'password' },
{ key: 'fullName', label: 'نام کاربر', defaultName: 'name' },
{ key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' },
{ key: 'userCode', label: 'کد کاربری', defaultName: 'userCode' }
]
},
{
key: 'paymentStatusChanged',
label: 'تغییر وضعیت پرداخت',
envKey: 'SMS_TEMPLATE_PAYMENT_STATUS_CHANGED',
slots: [
{ key: 'fullName', label: 'نام کارآموز', defaultName: 'fullName' },
{ key: 'status', label: 'وضعیت', defaultName: 'status' },
{ key: 'amount', label: 'مبلغ', defaultName: 'amount' },
{ key: 'invoiceCode', label: 'کد صورتحساب', defaultName: 'invoiceCode' },
{ key: 'course', label: 'نام دوره', defaultName: 'course' },
{ key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' }
]
},
{
key: 'paymentReminder',
label: 'یادآوری سررسید پرداخت',
envKey: 'SMS_TEMPLATE_PAYMENT_REMINDER',
slots: [
{ key: 'fullName', label: 'نام کارآموز', defaultName: 'fullName' },
{ key: 'amount', label: 'مبلغ باقی‌مانده', defaultName: 'amount' },
{ key: 'dueDate', label: 'تاریخ سررسید', defaultName: 'dueDate' },
{ key: 'invoiceCode', label: 'کد صورتحساب', defaultName: 'invoiceCode' },
{ key: 'course', label: 'نام دوره', defaultName: 'course' },
{ key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' }
]
},
{
key: 'transactionRecorded',
label: 'ثبت تراکنش',
envKey: 'SMS_TEMPLATE_TRANSACTION_RECORDED',
slots: [
{ key: 'fullName', label: 'نام کارآموز', defaultName: 'fullName' },
{ key: 'amount', label: 'مبلغ', defaultName: 'amount' },
{ key: 'invoiceCode', label: 'کد صورتحساب', defaultName: 'invoiceCode' },
{ key: 'transactionCode', label: 'کد تراکنش', defaultName: 'transactionCode' },
{ key: 'receiptNumber', label: 'شماره رسید', defaultName: 'receiptNumber' },
{ key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' }
]
},
{
key: 'sessionCancelled',
label: 'لغو جلسه',
envKey: 'SMS_TEMPLATE_SESSION_CANCELLED',
slots: [
{ key: 'fullName', label: 'نام کارآموز', defaultName: 'fullName' },
{ key: 'className', label: 'نام کلاس', defaultName: 'className' },
{ key: 'topic', label: 'موضوع جلسه', defaultName: 'topic' },
{ key: 'sessionDate', label: 'تاریخ جلسه', defaultName: 'sessionDate' },
{ key: 'reason', label: 'دلیل لغو', defaultName: 'reason' },
{ key: 'classCode', label: 'کد کلاس', defaultName: 'classCode' },
{ key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' }
]
},
{
key: 'sessionHolding',
label: 'برگزاری جلسه طبق برنامه',
label: 'کلاس طبق برنامه',
category: 'اطلاع‌رسانی',
defaultTemplateId: '720661',
envKey: 'SMS_TEMPLATE_SESSION_HOLDING',
defaultText: ` #FULLNAME# عزیز، «#TOPIC#» کلاس #CLASSNAME# در تاریخ #SESSIONDATE# و ساعت #CLASSTIME# طبق برنامه برگزار خواهد شد.
آموزشگاه گام نو
game-no.ir`,
slots: [
{ key: 'fullName', label: 'نام کارآموز', defaultName: 'FULLNAME' },
{ key: 'topic', label: 'موضوع جلسه', defaultName: 'TOPIC' },
@@ -144,14 +24,245 @@ const SMS_TEMPLATE_DEFS = [
{ key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' }
]
},
{
key: 'certificateIssued',
label: 'صدور گواهی',
category: 'اطلاع‌رسانی',
defaultTemplateId: '854493',
envKey: 'SMS_TEMPLATE_CERTIFICATE_ISSUED',
defaultText: ` #FULLNAME# عزیز، گواهی «#CERTIFICATETITLE#» شما، برای دوره #COURSENAME# صادر شد.
جهت مشاهده و دانلود گواهی به پروفایل خود در وبسایت آموزشگاه مراجعه کنید.
آموزشگاه گام نو
game-no.ir`,
slots: [
{ key: 'fullName', label: 'نام کارآموز', defaultName: 'FULLNAME' },
{ key: 'certificateTitle', label: 'عنوان گواهینامه', defaultName: 'CERTIFICATETITLE' },
{ key: 'courseName', label: 'نام دوره', defaultName: 'COURSENAME' },
{ key: 'certificateCode', label: 'کد گواهینامه', defaultName: 'certificateCode' },
{ key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' }
]
},
{
key: 'sessionCancelled',
label: 'لغو کلاس',
category: 'اطلاع‌رسانی',
defaultTemplateId: '174248',
envKey: 'SMS_TEMPLATE_SESSION_CANCELLED',
defaultText: ` #FULLNAME# عزیز، جلسه «#TOPIC#» کلاس #CLASSNAME# در تاریخ #SESSIONDATE# برگزار نخواهد شد.
آموزشگاه گام نو
game-no.ir`,
slots: [
{ key: 'fullName', label: 'نام کارآموز', defaultName: 'FULLNAME' },
{ key: 'topic', label: 'موضوع جلسه', defaultName: 'TOPIC' },
{ key: 'className', label: 'نام کلاس', defaultName: 'CLASSNAME' },
{ key: 'sessionDate', label: 'تاریخ جلسه', defaultName: 'SESSIONDATE' },
{ key: 'reason', label: 'دلیل لغو', defaultName: 'reason' },
{ key: 'classCode', label: 'کد کلاس', defaultName: 'classCode' },
{ key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' }
]
},
{
key: 'transactionRecorded',
label: 'ثبت تراکنش',
category: 'اطلاع‌رسانی',
defaultTemplateId: '580983',
envKey: 'SMS_TEMPLATE_TRANSACTION_RECORDED',
defaultText: ` #FULLNAME# عزیز، تراکنش #TRANSACTIONCODE# به مبلغ #AMOUNT# تومان ثبت شد.
صورتحساب: #INVOICECODE# رسید: #RECEIPTNUMBER#
آموزشگاه گام نو
game-no.ir`,
slots: [
{ key: 'fullName', label: 'نام کارآموز', defaultName: 'FULLNAME' },
{ key: 'transactionCode', label: 'کد تراکنش', defaultName: 'TRANSACTIONCODE' },
{ key: 'amount', label: 'مبلغ', defaultName: 'AMOUNT' },
{ key: 'invoiceCode', label: 'کد صورتحساب', defaultName: 'INVOICECODE' },
{ key: 'receiptNumber', label: 'شماره رسید', defaultName: 'RECEIPTNUMBER' },
{ key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' }
]
},
{
key: 'paymentReminder',
label: 'یادآوری پرداخت',
category: 'اطلاع‌رسانی',
defaultTemplateId: '357550',
envKey: 'SMS_TEMPLATE_PAYMENT_REMINDER',
defaultText: ` #FULLNAME# عزیز، یادآوری پرداخت:
صورتحساب #INVOICECODE# مبلغ باقیمانده: #AMOUNT# تومان
سررسید: #DUEDATE#
دوره: #COURSE#
آموزشگاه گام نو
game-no.ir`,
slots: [
{ key: 'fullName', label: 'نام کارآموز', defaultName: 'FULLNAME' },
{ key: 'invoiceCode', label: 'کد صورتحساب', defaultName: 'INVOICECODE' },
{ key: 'amount', label: 'مبلغ باقیمانده', defaultName: 'AMOUNT' },
{ key: 'dueDate', label: 'تاریخ سررسید', defaultName: 'DUEDATE' },
{ key: 'course', label: 'نام دوره', defaultName: 'COURSE' },
{ key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' }
]
},
{
key: 'paymentStatusChanged',
label: 'وضعیت صورتحساب',
category: 'وضعیت سفارش',
defaultTemplateId: '270174',
envKey: 'SMS_TEMPLATE_PAYMENT_STATUS_CHANGED',
defaultText: ` #FULLNAME# عزیز، وضعیت صورتحساب #INVOICECODE# به «#STATUS#» تغییر کرد.
مبلغ: #AMOUNT# تومان دوره: #COURSE#
آموزشگاه گام نو
game-no.ir`,
slots: [
{ key: 'fullName', label: 'نام کارآموز', defaultName: 'FULLNAME' },
{ key: 'invoiceCode', label: 'کد صورتحساب', defaultName: 'INVOICECODE' },
{ key: 'status', label: 'وضعیت', defaultName: 'STATUS' },
{ key: 'amount', label: 'مبلغ', defaultName: 'AMOUNT' },
{ key: 'course', label: 'نام دوره', defaultName: 'COURSE' },
{ key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' }
]
},
{
key: 'accountCreated',
label: 'اطلاعات اکانت',
category: 'اطلاع‌رسانی',
defaultTemplateId: '250973',
envKey: 'SMS_TEMPLATE_ACCOUNT_CREATED',
defaultText: ` کارآموز گرامی، یک حساب کاربری برای شما در وبسایت آموزشگاه گام نو ایجاد شد.
نام کاربری: #USER#
رمز عبور: #PASSWORD#
game-no.ir`,
slots: [
{ key: 'username', label: 'نام کاربری', defaultName: 'USER' },
{ key: 'password', label: 'رمز عبور', defaultName: 'PASSWORD' },
{ key: 'fullName', label: 'نام کاربر', defaultName: 'name' },
{ key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' },
{ key: 'userCode', label: 'کد کاربری', defaultName: 'userCode' }
]
},
{
key: 'invoiceCreated',
label: 'پرداخت',
category: 'اطلاع‌رسانی',
defaultTemplateId: '580892',
envKey: 'SMS_TEMPLATE_INVOICE_CREATED',
defaultText: ` کارآموز عزیز، #FULLNAME#، یک صورتحساب به مبلغ #PAYMENT_PRICE# تومان، بابت دوره ی #COURSE# برای شما ایجاد شده است.
آموزشگاه گام نو
game-no.ir`,
slots: [
{ key: 'fullName', label: 'نام کارآموز', defaultName: 'FULLNAME' },
{ key: 'amount', label: 'مبلغ', defaultName: 'PAYMENT_PRICE' },
{ key: 'course', label: 'نام دوره', defaultName: 'COURSE' },
{ key: 'phoneNumber', label: 'شماره همراه', defaultName: 'MOBILE' },
{ key: 'classStartDate', label: 'تاریخ شروع کلاس', defaultName: 'classStartDate' },
{ key: 'classDays', label: 'روزهای برگزاری', defaultName: 'classDays' },
{ key: 'courseTime', label: 'ساعت دوره', defaultName: 'courseTime' },
{ key: 'invoiceCode', label: 'کد صورتحساب', defaultName: 'invoiceCode' }
]
},
{
key: 'classRegistered',
label: 'ثبت نام دوره',
category: 'اطلاع‌رسانی',
defaultTemplateId: '910210',
envKey: 'SMS_TEMPLATE_CLASS_REGISTERED',
defaultText: ` کارآموز عزیز، #FULLNAME#، ثبت نام شما در دوره #CLASS# انجام شد.
هر هفته #CLASSDAYS#، #CLASSTIME#
آموزشگاه گام نو
game-no.ir`,
slots: [
{ key: 'fullName', label: 'نام کارآموز', defaultName: 'FULLNAME' },
{ key: 'className', label: 'نام کلاس', defaultName: 'CLASS' },
{ key: 'classDays', label: 'روزهای برگزاری', defaultName: 'CLASSDAYS' },
{ key: 'courseTime', label: 'ساعت دوره', defaultName: 'CLASSTIME' },
{ key: 'courseName', label: 'نام دوره', defaultName: 'courseName' },
{ key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' },
{ key: 'classStartDate', label: 'تاریخ شروع کلاس', defaultName: 'classStartDate' },
{ key: 'classCode', label: 'کد کلاس', defaultName: 'classCode' }
]
},
{
key: 'classPlanProfessor',
label: 'برنامه کلاس به استاد',
category: 'اطلاع‌رسانی',
defaultTemplateId: '',
envKey: 'SMS_TEMPLATE_CLASS_PLAN_PROFESSOR',
defaultText: `با سلام و وقت بخیر، استاد #professorName#،
برنامه کلاس #className# شما به شرح زیر می باشد:
#classDays#، #classTimes#
از #classStartDate# الی #classEndDate#`,
slots: [
{ key: 'professorName', label: 'نام استاد', defaultName: 'professorName' },
{ key: 'className', label: 'نام کلاس', defaultName: 'className' },
{ key: 'classDays', label: 'روزهای برگزاری', defaultName: 'classDays' },
{ key: 'classTimes', label: 'ساعت برگزاری', defaultName: 'classTimes' },
{ key: 'classStartDate', label: 'تاریخ شروع', defaultName: 'classStartDate' },
{ key: 'classEndDate', label: 'تاریخ پایان', defaultName: 'classEndDate' },
{ key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' }
]
},
{
key: 'passwordReset',
label: 'بازنشانی رمز عبور',
category: 'اطلاع‌رسانی',
defaultTemplateId: '250973',
envKey: 'SMS_TEMPLATE_PASSWORD_RESET',
defaultText: ` کارآموز گرامی، رمز عبور حساب کاربری شما در وبسایت آموزشگاه گام نو بازنشانی شد.
نام کاربری: #USER#
رمز عبور: #PASSWORD#
game-no.ir`,
slots: [
{ key: 'username', label: 'نام کاربری', defaultName: 'USER' },
{ key: 'password', label: 'رمز عبور', defaultName: 'PASSWORD' },
{ key: 'fullName', label: 'نام کاربر', defaultName: 'name' },
{ key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' },
{ key: 'userCode', label: 'کد کاربری', defaultName: 'userCode' }
]
},
{
key: 'classReminder',
label: 'یادآوری کلاس',
category: 'اطلاع‌رسانی',
defaultTemplateId: '',
envKey: 'SMS_TEMPLATE_CLASS_REMINDER',
defaultText: ` #FULLNAME# عزیز، یادآوری کلاس «#CLASSNAME#» در تاریخ #SESSIONDATE# ساعت #CLASSTIME#.
مکان: #PLACE#
آموزشگاه گام نو
game-no.ir`,
slots: [
{ key: 'className', label: 'نام کلاس', defaultName: 'CLASSNAME' },
{ key: 'topic', label: 'موضوع جلسه', defaultName: 'topic' },
{ key: 'classTime', label: 'ساعت کلاس', defaultName: 'CLASSTIME' },
{ key: 'sessionDate', label: 'تاریخ جلسه', defaultName: 'SESSIONDATE' },
{ key: 'place', label: 'مکان', defaultName: 'PLACE' },
{ key: 'fullName', label: 'نام کاربر', defaultName: 'FULLNAME' },
{ key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' },
{ key: 'classStartDate', label: 'تاریخ شروع کلاس', defaultName: 'classStartDate' },
{ key: 'classDays', label: 'روزهای برگزاری', defaultName: 'classDays' },
{ key: 'courseTime', label: 'ساعت دوره', defaultName: 'courseTime' },
{ key: 'classCode', label: 'کد کلاس', defaultName: 'classCode' }
]
},
{
key: 'classRequestApproved',
label: 'تأیید درخواست تشکیل کلاس',
category: 'ثبت‌نام و درخواست‌ها',
defaultTemplateId: '',
envKey: 'SMS_TEMPLATE_CLASS_REQUEST_APPROVED',
defaultEnabled: false,
defaultText: ` #FULLNAME# عزیز، درخواست شما برای تشکیل کلاس #COURSENAME# تأیید شد.
آموزشگاه گام نو
game-no.ir`,
slots: [
{ key: 'fullName', label: 'نام کارآموز', defaultName: 'fullName' },
{ key: 'courseName', label: 'نام دوره', defaultName: 'courseName' },
{ key: 'fullName', label: 'نام کارآموز', defaultName: 'FULLNAME' },
{ key: 'courseName', label: 'نام دوره', defaultName: 'COURSENAME' },
{ key: 'classCode', label: 'کد کلاس', defaultName: 'classCode' },
{ key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' }
]
@@ -159,12 +270,19 @@ const SMS_TEMPLATE_DEFS = [
{
key: 'classRequestRejected',
label: 'رد درخواست ثبت‌نام',
category: 'ثبت‌نام و درخواست‌ها',
defaultTemplateId: '',
envKey: 'SMS_TEMPLATE_CLASS_REQUEST_REJECTED',
defaultEnabled: false,
defaultText: ` #FULLNAME# عزیز، درخواست ثبت‌نام شما برای دوره #COURSENAME# مورد پذیرش قرار نگرفت.
دلیل: #REASON#
آموزشگاه گام نو
game-no.ir`,
slots: [
{ key: 'fullName', label: 'نام کارآموز', defaultName: 'fullName' },
{ key: 'courseName', label: 'نام دوره', defaultName: 'courseName' },
{ key: 'reason', label: 'دلیل', defaultName: 'reason' },
{ key: 'fullName', label: 'نام کارآموز', defaultName: 'FULLNAME' },
{ key: 'courseName', label: 'نام دوره', defaultName: 'COURSENAME' },
{ key: 'reason', label: 'دلیل', defaultName: 'REASON' },
{ key: 'registrationCode', label: 'کد درخواست', defaultName: 'registrationCode' },
{ key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' }
]
@@ -172,24 +290,18 @@ const SMS_TEMPLATE_DEFS = [
{
key: 'pendingRegistration',
label: 'دریافت درخواست ثبت‌نام',
category: 'ثبت‌نام و درخواست‌ها',
defaultTemplateId: '',
envKey: 'SMS_TEMPLATE_PENDING_REGISTRATION',
defaultEnabled: false,
defaultText: ` #FULLNAME# عزیز، درخواست ثبت‌نام شما در کلاس #CLASSNAME# با کد پیگیری #REGISTRATIONCODE# دریافت شد.
آموزشگاه گام نو
game-no.ir`,
slots: [
{ key: 'fullName', label: 'نام کارآموز', defaultName: 'fullName' },
{ key: 'className', label: 'نام کلاس', defaultName: 'className' },
{ key: 'registrationCode', label: 'کد درخواست', defaultName: 'registrationCode' },
{ key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' }
]
},
{
key: 'certificateIssued',
label: 'صدور گواهینامه',
envKey: 'SMS_TEMPLATE_CERTIFICATE_ISSUED',
slots: [
{ key: 'fullName', label: 'نام کارآموز', defaultName: 'fullName' },
{ key: 'certificateTitle', label: 'عنوان گواهینامه', defaultName: 'certificateTitle' },
{ key: 'certificateCode', label: 'کد گواهینامه', defaultName: 'certificateCode' },
{ key: 'courseName', label: 'نام دوره', defaultName: 'courseName' },
{ key: 'fullName', label: 'نام کارآموز', defaultName: 'FULLNAME' },
{ key: 'className', label: 'نام کلاس', defaultName: 'CLASSNAME' },
{ key: 'registrationCode', label: 'کد درخواست', defaultName: 'REGISTRATIONCODE' },
{ key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' }
]
}
@@ -214,12 +326,15 @@ const sanitizeVariableName = (value) => {
if (value == null) return '';
const name = String(value).trim().replace(/^#+|#+$/g, '').trim();
if (!name) return '';
// Check for dangerous injection or control chars
if (/[\r\n\t\0<>"'`]/.test(name)) return null;
if (name.length > 100) return null;
return name;
};
const escapeRegExp = (string) => {
return String(string).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
};
const defaultVariablesList = (def) => {
return (def?.slots || []).map((slot) => ({
slot: slot.key,
@@ -229,7 +344,9 @@ const defaultVariablesList = (def) => {
const emptyTemplateEntry = (def) => ({
enabled: def ? (def.defaultEnabled !== false) : true,
templateId: '',
templateId: def?.defaultTemplateId || '',
category: def?.category || 'اطلاع‌رسانی',
text: def?.defaultText || '',
variables: defaultVariablesList(def)
});
@@ -271,10 +388,16 @@ const normalizeVariablesInput = (rawVariables, def) => {
const normalizeStoredEntry = (raw, def) => {
const defaultEnabled = def ? (def.defaultEnabled !== false) : true;
const defaultText = def?.defaultText || '';
const defaultTemplateId = def?.defaultTemplateId || '';
const defaultCategory = def?.category || 'اطلاع‌رسانی';
if (raw == null || raw === '') {
return {
enabled: defaultEnabled,
templateId: '',
templateId: defaultTemplateId,
category: defaultCategory,
text: defaultText,
variables: def ? defaultVariablesList(def) : []
};
}
@@ -282,13 +405,17 @@ const normalizeStoredEntry = (raw, def) => {
return {
enabled: true,
templateId: String(raw),
category: defaultCategory,
text: defaultText,
variables: def ? defaultVariablesList(def) : []
};
}
if (typeof raw !== 'object') {
return {
enabled: defaultEnabled,
templateId: '',
templateId: defaultTemplateId,
category: defaultCategory,
text: defaultText,
variables: def ? defaultVariablesList(def) : []
};
}
@@ -301,9 +428,21 @@ const normalizeStoredEntry = (raw, def) => {
? Boolean(raw.enabled)
: defaultEnabled;
const templateId = raw.templateId != null && String(raw.templateId).trim() !== ''
? String(raw.templateId).trim()
: defaultTemplateId;
const text = raw.text != null && String(raw.text).trim() !== ''
? String(raw.text)
: defaultText;
const category = raw.category || defaultCategory;
return {
enabled,
templateId: raw.templateId != null ? String(raw.templateId) : '',
templateId,
category,
text,
variables
};
};
@@ -315,15 +454,22 @@ const parseIncomingVariables = (rawVariables, def) => {
const mergeTemplateEntry = (def, storedRaw, incomingRaw = null) => {
const storedEntry = normalizeStoredEntry(storedRaw, def);
let templateId = storedEntry.templateId || envFallbackFor(def);
let templateId = storedEntry.templateId || envFallbackFor(def) || def?.defaultTemplateId || '';
let variables = storedEntry.variables;
let enabled = storedEntry.enabled;
let text = storedEntry.text || def?.defaultText || '';
let category = storedEntry.category || def?.category || 'اطلاع‌رسانی';
if (incomingRaw) {
if (incomingRaw.enabled !== undefined) {
enabled = Boolean(incomingRaw.enabled);
}
if (incomingRaw.text !== undefined) {
text = String(incomingRaw.text || '');
}
if (incomingRaw.category !== undefined) {
category = String(incomingRaw.category || '');
}
if (incomingRaw.templateId !== undefined) {
const sanitized = sanitizeTemplateId(incomingRaw.templateId);
if (sanitized === null) {
@@ -334,7 +480,6 @@ const mergeTemplateEntry = (def, storedRaw, incomingRaw = null) => {
}
templateId = sanitized;
}
if (incomingRaw.variables !== undefined) {
const parsed = parseIncomingVariables(incomingRaw.variables, def) || [];
for (const item of parsed) {
@@ -350,12 +495,14 @@ const mergeTemplateEntry = (def, storedRaw, incomingRaw = null) => {
variables = parsed;
}
} else if (!storedEntry.templateId) {
templateId = envFallbackFor(def);
templateId = envFallbackFor(def) || def?.defaultTemplateId || '';
}
return {
enabled,
templateId,
category,
text,
variables: resolveVariablesList(def, variables)
};
};
@@ -382,13 +529,33 @@ const resolveVariablesList = (def, storedVariables) => {
.filter(Boolean);
};
const isPriceSlot = (slot = '', name = '') => {
const s = String(slot).toLowerCase();
const n = String(name).toLowerCase();
return s.includes('amount') || s.includes('price') || s.includes('tuition') || s.includes('fee')
|| n.includes('amount') || n.includes('price') || n.includes('tuition') || n.includes('fee') || n.includes('cost');
};
const formatPriceValue = (val) => {
if (val === null || val === undefined || val === '') return '';
const str = String(val).trim();
const rawNum = Number(str.replace(/,/g, ''));
if (!Number.isNaN(rawNum) && Number.isFinite(rawNum)) {
return rawNum.toLocaleString('en-US');
}
return str;
};
const buildSmsParameters = (variables, valuesBySlot = {}) => {
return normalizeVariablesInput(variables, null)
.map((item) => {
const name = sanitizeVariableName(item?.name);
if (!name) return null;
const slot = item?.slot;
const value = slot ? valuesBySlot?.[slot] : '';
let value = slot ? valuesBySlot?.[slot] : '';
if (isPriceSlot(slot, name) && value != null && value !== '') {
value = formatPriceValue(value);
}
return {
name,
value: String(value ?? '')
@@ -397,6 +564,46 @@ const buildSmsParameters = (variables, valuesBySlot = {}) => {
.filter(Boolean);
};
/**
* Renders the exact text of an SMS template by replacing variable placeholders (e.g. #FULLNAME# or #className#)
* with the corresponding values provided in slotValues.
*/
const renderSmsText = (templateText, variables = [], slotValues = {}) => {
if (!templateText || typeof templateText !== 'string') return '';
let result = templateText;
// 1. Replace defined template variables (mapped by slot and variable name in sms.ir)
if (Array.isArray(variables)) {
for (const item of variables) {
if (!item || !item.name) continue;
const varName = String(item.name).trim().replace(/^#+|#+$/g, '');
if (!varName) continue;
const slotKey = item.slot;
let val = slotKey !== undefined && slotValues[slotKey] !== undefined ? slotValues[slotKey] : '';
if (isPriceSlot(slotKey, varName) && val != null && val !== '') {
val = formatPriceValue(val);
}
const pattern = new RegExp(`#${escapeRegExp(varName)}#`, 'gi');
result = result.replace(pattern, String(val ?? ''));
}
}
// 2. Direct slot replacements (e.g., #professorName#, #className#, #amount#, #fullName#)
if (slotValues && typeof slotValues === 'object') {
for (const [slotKey, rawVal] of Object.entries(slotValues)) {
if (!slotKey) continue;
let val = rawVal;
if (isPriceSlot(slotKey, slotKey) && val != null && val !== '') {
val = formatPriceValue(val);
}
const pattern = new RegExp(`#${escapeRegExp(slotKey)}#`, 'gi');
result = result.replace(pattern, String(val ?? ''));
}
}
return result;
};
module.exports = {
SMS_TEMPLATE_DEFS,
SMS_TEMPLATE_KEYS,
@@ -409,5 +616,7 @@ module.exports = {
parseIncomingVariables,
mergeTemplateEntry,
resolveVariablesList,
buildSmsParameters
buildSmsParameters,
renderSmsText,
formatPriceValue
};
@@ -0,0 +1,24 @@
'use strict';
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const userService = require('./userService');
const professorService = require('../professors/professorService');
describe('Student Profile and Professor Promotion Service Exports', () => {
it('exports getUserFullProfile and promoteToProfessor in userService', () => {
assert.equal(typeof userService.getUserFullProfile, 'function');
assert.equal(typeof userService.promoteToProfessor, 'function');
});
it('exports createProfessorFromUser in professorService', () => {
assert.equal(typeof professorService.createProfessorFromUser, 'function');
});
it('throws validation error when userId is missing in createProfessorFromUser', async () => {
await assert.rejects(
() => professorService.createProfessorFromUser(null),
(err) => err.errorCode === 'VALIDATION_FAILED' || err.statusCode === 400
);
});
});
+10
View File
@@ -36,6 +36,16 @@ exports.getOne = catchAsync(async (req, res, next) => {
return successResponse(res, 200, 'User retrieved successfully', user);
});
exports.getFullProfile = catchAsync(async (req, res, next) => {
const profile = await userService.getUserFullProfile(req.params.id);
return successResponse(res, 200, 'User full profile retrieved successfully', profile);
});
exports.promoteToProfessor = catchAsync(async (req, res, next) => {
const result = await userService.promoteToProfessor(req.params.id, req.body);
return successResponse(res, 200, 'User promoted to professor successfully', result);
});
exports.getAll = catchAsync(async (req, res, next) => {
const { data, meta } = await userService.getAllUsers(req.query);
return listResponse(res, 200, data, meta);
+2
View File
@@ -24,7 +24,9 @@ router.post('/admin/create', authMiddleware, perm.requires(PERMISSIONS.USERS_CRE
router.get('/admin/get-all', authMiddleware, perm.requires(PERMISSIONS.USERS_READ), userController.getAll);
router.get('/admin/search', authMiddleware, perm.requires(PERMISSIONS.USERS_SEARCH), userController.search);
router.get('/admin/get-one/:id', authMiddleware, perm.requires(PERMISSIONS.USERS_READ), userController.getOne);
router.get('/admin/:id/full-profile', authMiddleware, perm.requires(PERMISSIONS.USERS_READ), userController.getFullProfile);
router.put('/admin/update/:id', authMiddleware, perm.requires(PERMISSIONS.USERS_UPDATE), validateUpdateUser, userController.update);
router.post('/admin/:id/promote-to-professor', authMiddleware, perm.requires(PERMISSIONS.USERS_UPDATE), userController.promoteToProfessor);
router.post('/admin/:id/reset-password-sms', authMiddleware, perm.requires(PERMISSIONS.USERS_UPDATE), userController.resetPasswordAndSendSms);
router.delete('/admin/delete/:id', authMiddleware, perm.requires(PERMISSIONS.USERS_DELETE), userController.delete);
router.post('/admin/:userId/enroll', authMiddleware, perm.requires(PERMISSIONS.USERS_ENROLL), validateEnroll, userController.enroll);
+248 -1
View File
@@ -4,6 +4,15 @@
const User = require('./userModel');
const Role = require('../roles/roleModel');
const Class = require('../classes/classModel');
const Session = require('../sessions/sessionModel');
const Payment = require('../payments/paymentModel');
const Transaction = require('../payments/transactionModel');
const Certificate = require('../certificates/certificateModel');
const Document = require('../documents/documentModel');
const Waitlist = require('../waitlist/waitlistModel');
const Notification = require('../notifications/notificationModel');
const ActivityLog = require('../activityLogs/activityLogModel');
const professorService = require('../professors/professorService');
const bcrypt = require('bcryptjs');
const AppError = require('../../utils/AppError');
const { calculateMeta, escapeRegex, getSearchTerm } = require('../../utils/pagination');
@@ -363,14 +372,252 @@ const enrollUserInCourse = async (userId, courseId) => {
return User.findById(userId).select(SAFE_FIELDS).populate('courses').lean();
};
const getUserFullProfile = async (id) => {
const user = await User.findById(id).select(SAFE_FIELDS).populate(POPULATE_ROLE).lean();
if (!user) throw new AppError('USER_NOT_FOUND');
// 1. Enrolled Classes
const classes = await Class.find({ students: id, isDeleted: { $ne: true } })
.populate('course', 'title type price hoursPerSection')
.populate('professor', 'name surname phoneNumber email')
.sort({ createdAt: -1 })
.lean();
const classIds = classes.map((c) => c._id);
// 2. All Sessions for user's classes or where user is in attendanceList
const sessions = await Session.find({
$or: [
{ class: { $in: classIds } },
{ 'attendanceList.user': id }
],
isDeleted: { $ne: true }
})
.populate('course', 'title')
.populate('class', 'name')
.populate('professor', 'name surname')
.populate('attendanceList.recordedBy', 'name username')
.sort({ day: -1, startTime: -1 })
.lean();
// Map attendance records specifically for this student
let presentCount = 0;
let absentCount = 0;
let lateCount = 0;
let excusedCount = 0;
const userAttendances = sessions.map((sess) => {
const record = (sess.attendanceList || []).find((r) => String(r.user) === String(id));
const status = record ? record.status : 'scheduled';
if (status === 'present') presentCount++;
else if (status === 'absent') absentCount++;
else if (status === 'late') lateCount++;
else if (status === 'excused') excusedCount++;
return {
_id: sess._id,
sessionId: sess._id,
day: sess.day,
startTime: sess.startTime,
endTime: sess.endTime,
place: sess.place,
topic: sess.topic,
sessionStatus: sess.status,
course: sess.course,
class: sess.class,
professor: sess.professor,
attendanceStatus: status,
attendanceNote: record?.note || '',
recordedBy: record?.recordedBy || null
};
});
const totalRecordedAttendance = presentCount + absentCount + lateCount + excusedCount;
const attendanceRate = totalRecordedAttendance > 0
? Math.round(((presentCount + lateCount) / totalRecordedAttendance) * 100)
: 0;
// 3. Payments & Invoices
const payments = await Payment.find({ user: id, isDeleted: { $ne: true } })
.populate('classes', 'name tuitionFee')
.populate('course', 'title')
.sort({ createdAt: -1 })
.lean();
const paymentIds = payments.map((p) => p._id);
// 4. Bank Transactions
const transactions = await Transaction.find({
$or: [
{ user: id },
{ payment: { $in: paymentIds } }
]
})
.populate('payment', 'uniqueCode amount status')
.populate('recordedBy', 'name username')
.sort({ date: -1, createdAt: -1 })
.lean();
// 5. Financial Summary Calculations
let totalTuition = 0;
let totalDiscount = 0;
let totalPayable = 0;
let totalPaid = 0;
let overdueAmount = 0;
let pendingAmount = 0;
const paymentStatusMap = {
paid: { count: 0, amount: 0 },
partial: { count: 0, amount: 0 },
pending: { count: 0, amount: 0 },
overdue: { count: 0, amount: 0 },
cancelled: { count: 0, amount: 0 },
reverted: { count: 0, amount: 0 }
};
payments.forEach((p) => {
const payable = Math.max(0, (p.amount || 0) - (p.discount || 0));
totalTuition += (p.amount || 0);
totalDiscount += (p.discount || 0);
totalPayable += payable;
totalPaid += (p.paidAmount || 0);
const st = p.status || 'pending';
if (paymentStatusMap[st]) {
paymentStatusMap[st].count++;
paymentStatusMap[st].amount += payable;
}
if (st === 'overdue') {
overdueAmount += Math.max(0, payable - (p.paidAmount || 0));
} else if (st === 'pending' || st === 'partial') {
pendingAmount += Math.max(0, payable - (p.paidAmount || 0));
}
});
const remainingDebt = Math.max(0, totalPayable - totalPaid);
const financialSummary = {
totalTuition,
totalDiscount,
totalPayable,
totalPaid,
remainingDebt,
overdueAmount,
pendingAmount,
totalInvoicesCount: payments.length,
totalTransactionsCount: transactions.length
};
// Monthly transaction timeline for charts
const monthlyTimelineMap = {};
transactions.forEach((t) => {
if (t.status !== 'cancelled' && t.status !== 'reverted') {
const d = t.date || t.createdAt;
if (d) {
const monthKey = new Date(d).toISOString().slice(0, 7); // YYYY-MM
if (!monthlyTimelineMap[monthKey]) {
monthlyTimelineMap[monthKey] = { month: monthKey, amount: 0, count: 0 };
}
monthlyTimelineMap[monthKey].amount += (t.amount || 0);
monthlyTimelineMap[monthKey].count++;
}
}
});
const monthlyTransactions = Object.values(monthlyTimelineMap).sort((a, b) => a.month.localeCompare(b.month));
// 6. Certificates & Documents
const certificates = await Certificate.find({ user: id })
.populate('course', 'title')
.sort({ issuedAt: -1 })
.lean();
const documents = await Document.find({ user: id })
.populate('uploadedBy', 'name username')
.sort({ createdAt: -1 })
.lean();
// 7. Waitlist Entries
const waitlist = await Waitlist.find({ user: id, isDeleted: { $ne: true } })
.populate('course', 'title type price')
.populate('class', 'name startDate')
.sort({ createdAt: -1 })
.lean();
// 8. Notifications
const notifications = await Notification.find({ user: id })
.sort({ createdAt: -1 })
.limit(50)
.lean();
// 9. Activity Logs
const activityLogs = await ActivityLog.find({
$or: [
{ actor: id },
{ resourceId: String(id) },
{ 'metadata.userId': String(id) }
]
})
.sort({ createdAt: -1 })
.limit(50)
.lean();
const chartsData = {
attendance: {
present: presentCount,
absent: absentCount,
late: lateCount,
excused: excusedCount,
total: totalRecordedAttendance,
attendanceRate
},
paymentsBreakdown: [
{ status: 'paid', count: paymentStatusMap.paid.count, amount: paymentStatusMap.paid.amount },
{ status: 'partial', count: paymentStatusMap.partial.count, amount: paymentStatusMap.partial.amount },
{ status: 'pending', count: paymentStatusMap.pending.count, amount: paymentStatusMap.pending.amount },
{ status: 'overdue', count: paymentStatusMap.overdue.count, amount: paymentStatusMap.overdue.amount }
],
financialSummary,
monthlyTransactions
};
return {
user: {
...user,
nationalId: user.nationalIdCode,
registeredClassesCount: classes.length
},
classes,
sessions,
attendances: userAttendances,
payments,
transactions,
financialSummary,
attendanceSummary: chartsData.attendance,
chartsData,
certificates,
documents,
waitlist,
notifications,
activityLogs
};
};
const promoteToProfessor = async (id, data = {}) => {
return professorService.createProfessorFromUser(id, data);
};
module.exports = {
signUp,
getUserById,
getUserFullProfile,
getAllUsers,
searchUsers,
createUserAdmin,
updateUser,
deleteUser,
resetPasswordAndSendSms,
enrollUserInCourse
enrollUserInCourse,
promoteToProfessor
};
+56
View File
@@ -0,0 +1,56 @@
// /components/waitlist/waitlistController.js
'use strict';
const catchAsync = require('../../utils/catchAsync');
const waitlistService = require('./waitlistService');
const { successResponse, listResponse } = require('../../utils/apiResponse');
exports.getAll = catchAsync(async (req, res) => {
const { data, meta } = await waitlistService.getAll(req.query);
return listResponse(res, 200, data, meta);
});
exports.getOne = catchAsync(async (req, res) => {
const item = await waitlistService.getOne(req.params.id);
return successResponse(res, 200, 'Waitlist item retrieved successfully', item);
});
exports.create = catchAsync(async (req, res) => {
const actorId = req.user?._id;
const item = await waitlistService.create(req.body, actorId);
return successResponse(res, 201, 'Student added to waitlist successfully', item);
});
exports.update = catchAsync(async (req, res) => {
const actorId = req.user?._id;
const item = await waitlistService.update(req.params.id, req.body, actorId);
return successResponse(res, 200, 'Waitlist item updated successfully', item);
});
exports.assignClass = catchAsync(async (req, res) => {
const actorId = req.user?._id;
const item = await waitlistService.assignToClass(req.params.id, req.body, actorId);
return successResponse(res, 200, 'Student assigned to class successfully', item);
});
exports.revert = catchAsync(async (req, res) => {
const actorId = req.user?._id;
const item = await waitlistService.revert(req.params.id, req.body, actorId);
return successResponse(res, 200, 'Waitlist registration reverted successfully', item);
});
exports.cancel = catchAsync(async (req, res) => {
const actorId = req.user?._id;
const item = await waitlistService.cancel(req.params.id, req.body, actorId);
return successResponse(res, 200, 'Waitlist registration cancelled successfully', item);
});
exports.delete = catchAsync(async (req, res) => {
await waitlistService.remove(req.params.id);
return successResponse(res, 200, 'Waitlist item deleted successfully');
});
exports.getStats = catchAsync(async (req, res) => {
const stats = await waitlistService.getStats();
return successResponse(res, 200, 'Waitlist stats retrieved successfully', stats);
});
+66
View File
@@ -0,0 +1,66 @@
// /components/waitlist/waitlistModel.js
'use strict';
const mongoose = require('mongoose');
const uniqueCodePlugin = require('../../utils/mongooseUniqueCodePlugin');
const waitlistSchema = new mongoose.Schema({
user: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true,
index: true
},
course: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Course',
required: true,
index: true
},
payment: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Payment',
index: true
},
class: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Class',
default: null,
index: true
},
status: {
type: String,
enum: ['waiting', 'enrolled', 'cancelled', 'reverted'],
default: 'waiting',
index: true
},
adminNotes: {
type: [String],
default: []
},
registeredAt: {
type: Date,
default: Date.now
},
assignedAt: {
type: Date,
default: null
},
isDeleted: {
type: Boolean,
default: false,
index: true
},
deletedAt: {
type: Date,
default: null
}
}, {
timestamps: true,
toJSON: { virtuals: true },
toObject: { virtuals: true }
});
waitlistSchema.plugin(uniqueCodePlugin);
module.exports = mongoose.model('Waitlist', waitlistSchema);
+24
View File
@@ -0,0 +1,24 @@
// /components/waitlist/waitlistRoutes.js
'use strict';
const express = require('express');
const waitlistController = require('./waitlistController');
const authMiddleware = require('../../middlewares/authMiddleware');
const perm = require('../../middlewares/permissionMiddleware');
const { PERMISSIONS } = require('../../constants/permissions');
const router = express.Router();
router.use(authMiddleware);
router.get('/admin/get-all', perm.requires(PERMISSIONS.WAITLIST_READ), waitlistController.getAll);
router.get('/admin/stats', perm.requires(PERMISSIONS.WAITLIST_READ), waitlistController.getStats);
router.get('/admin/get-one/:id', perm.requires(PERMISSIONS.WAITLIST_READ), waitlistController.getOne);
router.post('/admin/create', perm.requires(PERMISSIONS.WAITLIST_CREATE), waitlistController.create);
router.put('/admin/update/:id', perm.requires(PERMISSIONS.WAITLIST_UPDATE), waitlistController.update);
router.post('/admin/:id/assign-class', perm.requires(PERMISSIONS.WAITLIST_UPDATE), waitlistController.assignClass);
router.post('/admin/:id/revert', perm.requires(PERMISSIONS.WAITLIST_UPDATE), waitlistController.revert);
router.post('/admin/:id/cancel', perm.requires(PERMISSIONS.WAITLIST_UPDATE), waitlistController.cancel);
router.delete('/admin/delete/:id', perm.requires(PERMISSIONS.WAITLIST_DELETE), waitlistController.delete);
module.exports = router;
+303
View File
@@ -0,0 +1,303 @@
// /components/waitlist/waitlistService.js
'use strict';
const Waitlist = require('./waitlistModel');
const User = require('../users/userModel');
const Course = require('../courses/courseModel');
const Class = require('../classes/classModel');
const Payment = require('../payments/paymentModel');
const Transaction = require('../payments/transactionModel');
const AppError = require('../../utils/AppError');
const { calculateMeta, escapeRegex, getSearchTerm } = require('../../utils/pagination');
const {
getPayableAmount,
normalizeDiscount,
sanitizeNotes,
sumPaidTransactions
} = require('../../utils/paymentAmount');
const paymentService = require('../payments/paymentService');
const populateWaitlist = (query) => query
.populate({ path: 'user', select: 'name phoneNumber email nationalIdCode' })
.populate({ path: 'course', select: 'title price code' })
.populate({ path: 'class', select: 'name tuitionFee startDate days startTime endTime' })
.populate({
path: 'payment',
populate: {
path: 'transactions',
options: { sort: { dueDate: 1, date: 1, createdAt: 1 } }
}
});
const getAll = async (query = {}) => {
const page = parseInt(query.page, 10) || 1;
const limit = Math.min(parseInt(query.limit, 10) || 20, 200);
const skip = (page - 1) * limit;
const filter = {};
if (query.trash === 'true' || query.isDeleted === 'true') {
filter.isDeleted = true;
} else {
filter.isDeleted = { $ne: true };
}
if (query.courseId) filter.course = query.courseId;
if (query.userId) filter.user = query.userId;
if (query.status) filter.status = query.status;
const searchTerm = getSearchTerm(query);
if (searchTerm) {
const searchRegex = new RegExp(escapeRegex(searchTerm), 'i');
const matchedUsers = await User.find({
$or: [
{ name: searchRegex },
{ phoneNumber: searchRegex },
{ nationalIdCode: searchRegex }
]
}).select('_id').lean();
filter.$or = [
{ uniqueCode: searchRegex },
{ adminNotes: searchRegex },
{ user: { $in: matchedUsers.map((u) => u._id) } }
];
}
const [items, total] = await Promise.all([
populateWaitlist(Waitlist.find(filter))
.skip(skip)
.limit(limit)
.sort({ createdAt: -1 })
.lean(),
Waitlist.countDocuments(filter)
]);
return { data: items, meta: calculateMeta(total, page, limit) };
};
const getOne = async (id) => {
const item = await populateWaitlist(Waitlist.findById(id)).lean();
if (!item) throw new AppError('WAITLIST_NOT_FOUND', {}, 'موردی در لیست انتظار یافت نشد.');
return item;
};
const create = async (body, actorId = null) => {
const { userId, courseId, amount, discount, dueDate, notes, initialTransaction, adminNotes } = body;
const user = await User.findById(userId || body.user);
if (!user) throw new AppError('USER_NOT_FOUND', {}, 'کاربر مورد نظر یافت نشد.');
const course = await Course.findById(courseId || body.course);
if (!course) throw new AppError('COURSE_NOT_FOUND', {}, 'دوره آموزشی مورد نظر یافت نشد.');
const totalAmount = amount !== undefined && amount !== null && amount !== ''
? Number(amount)
: (course.price || 0);
const totalDiscount = discount !== undefined && discount !== null && discount !== ''
? normalizeDiscount(Number(discount), totalAmount)
: 0;
// Create waiting_list payment
const payment = await Payment.create({
user: user._id,
course: course._id,
classes: [],
amount: totalAmount,
discount: totalDiscount,
dueDate: dueDate ? new Date(dueDate) : undefined,
type: 'waiting_list',
status: 'pending',
notes: sanitizeNotes(notes || `صورتحساب ثبت‌نام لیست انتظار دوره ${course.title}`)
});
// Create initial transaction if supplied
if (initialTransaction && (Number(initialTransaction.amount) > 0 || initialTransaction.status === 'paid')) {
const trxStatus = initialTransaction.status || 'paid';
const trxDate = trxStatus === 'paid' ? (initialTransaction.date ? new Date(initialTransaction.date) : new Date()) : undefined;
const trxDueDate = initialTransaction.dueDate ? new Date(initialTransaction.dueDate) : (trxDate || new Date());
await Transaction.create({
payment: payment._id,
user: user._id,
amount: Number(initialTransaction.amount) || (totalAmount - totalDiscount),
status: trxStatus,
method: initialTransaction.method || 'card',
receiptNumber: initialTransaction.receiptNumber ? String(initialTransaction.receiptNumber) : '',
date: trxDate,
dueDate: trxDueDate,
notes: sanitizeNotes(initialTransaction.notes || 'پرداخت بیعانه / ثبت‌نام لیست انتظار'),
recordedBy: actorId
});
await paymentService.refreshPaymentTotals(payment);
}
const notesList = [];
if (Array.isArray(adminNotes)) {
notesList.push(...adminNotes.filter(Boolean));
} else if (notes) {
notesList.push(String(notes));
}
const waitlist = await Waitlist.create({
user: user._id,
course: course._id,
payment: payment._id,
status: 'waiting',
adminNotes: notesList,
registeredAt: body.registeredAt ? new Date(body.registeredAt) : new Date()
});
return getOne(waitlist._id);
};
const update = async (id, body) => {
const waitlist = await Waitlist.findById(id);
if (!waitlist) throw new AppError('WAITLIST_NOT_FOUND', {}, 'موردی در لیست انتظار یافت نشد.');
if (body.adminNotes !== undefined) {
waitlist.adminNotes = Array.isArray(body.adminNotes)
? body.adminNotes.filter(Boolean)
: [String(body.adminNotes)];
}
if (body.status !== undefined) {
waitlist.status = body.status;
}
if (body.registeredAt !== undefined) {
waitlist.registeredAt = new Date(body.registeredAt);
}
await waitlist.save();
return getOne(waitlist._id);
};
const assignToClass = async (id, { classId }, actorId = null) => {
const waitlist = await Waitlist.findById(id);
if (!waitlist) throw new AppError('WAITLIST_NOT_FOUND', {}, 'موردی در لیست انتظار یافت نشد.');
const targetClass = await Class.findById(classId).populate('course', 'title price');
if (!targetClass) throw new AppError('CLASS_NOT_FOUND', {}, 'کلاس مورد نظر یافت نشد.');
// 1. Add user to class students list if not present
const studentIdStr = String(waitlist.user);
const alreadyEnrolled = (targetClass.students || []).some((s) => String(s) === studentIdStr);
if (!alreadyEnrolled) {
targetClass.students.push(waitlist.user);
await targetClass.save();
}
// 2. Transition Payment to regular and link class
if (waitlist.payment) {
const payment = await Payment.findById(waitlist.payment);
if (payment) {
payment.classes = [targetClass._id];
payment.type = 'regular';
await payment.save();
await paymentService.refreshPaymentTotals(payment);
}
}
// 3. Mark waitlist status as enrolled
waitlist.class = targetClass._id;
waitlist.status = 'enrolled';
waitlist.assignedAt = new Date();
await waitlist.save();
return getOne(waitlist._id);
};
const revert = async (id, { notes } = {}, actorId = null) => {
const waitlist = await Waitlist.findById(id);
if (!waitlist) throw new AppError('WAITLIST_NOT_FOUND', {}, 'موردی در لیست انتظار یافت نشد.');
waitlist.status = 'reverted';
if (notes) {
waitlist.adminNotes.push(`استرداد: ${notes}`);
}
await waitlist.save();
if (waitlist.payment) {
const payment = await Payment.findById(waitlist.payment);
if (payment) {
payment.status = 'reverted';
await payment.save();
const transactions = await Transaction.find({ payment: payment._id });
for (const trx of transactions) {
trx.status = 'reverted';
if (actorId) trx.recordedBy = actorId;
await trx.save();
}
await paymentService.refreshPaymentTotals(payment);
}
}
return getOne(waitlist._id);
};
const cancel = async (id, { notes } = {}, actorId = null) => {
const waitlist = await Waitlist.findById(id);
if (!waitlist) throw new AppError('WAITLIST_NOT_FOUND', {}, 'موردی در لیست انتظار یافت نشد.');
waitlist.status = 'cancelled';
if (notes) {
waitlist.adminNotes.push(`لغو: ${notes}`);
}
await waitlist.save();
if (waitlist.payment) {
const payment = await Payment.findById(waitlist.payment);
if (payment) {
payment.status = 'cancelled';
await payment.save();
const transactions = await Transaction.find({ payment: payment._id });
for (const trx of transactions) {
trx.status = 'cancelled';
if (actorId) trx.recordedBy = actorId;
await trx.save();
}
await paymentService.refreshPaymentTotals(payment);
}
}
return getOne(waitlist._id);
};
const remove = async (id) => {
const waitlist = await Waitlist.findById(id);
if (!waitlist) throw new AppError('WAITLIST_NOT_FOUND', {}, 'موردی در لیست انتظار یافت نشد.');
waitlist.isDeleted = true;
waitlist.deletedAt = new Date();
await waitlist.save();
return { success: true };
};
const getStats = async () => {
const [total, waiting, enrolled, reverted, cancelled] = await Promise.all([
Waitlist.countDocuments({ isDeleted: { $ne: true } }),
Waitlist.countDocuments({ status: 'waiting', isDeleted: { $ne: true } }),
Waitlist.countDocuments({ status: 'enrolled', isDeleted: { $ne: true } }),
Waitlist.countDocuments({ status: 'reverted', isDeleted: { $ne: true } }),
Waitlist.countDocuments({ status: 'cancelled', isDeleted: { $ne: true } })
]);
return { total, waiting, enrolled, reverted, cancelled };
};
module.exports = {
getAll,
getOne,
create,
update,
assignToClass,
revert,
cancel,
remove,
getStats
};
@@ -0,0 +1,35 @@
'use strict';
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const waitlistService = require('./waitlistService');
const Waitlist = require('./waitlistModel');
describe('Waitlist Service and Schema', () => {
it('exports all expected service methods', () => {
assert.equal(typeof waitlistService.getAll, 'function');
assert.equal(typeof waitlistService.getOne, 'function');
assert.equal(typeof waitlistService.create, 'function');
assert.equal(typeof waitlistService.update, 'function');
assert.equal(typeof waitlistService.assignToClass, 'function');
assert.equal(typeof waitlistService.revert, 'function');
assert.equal(typeof waitlistService.cancel, 'function');
assert.equal(typeof waitlistService.remove, 'function');
assert.equal(typeof waitlistService.getStats, 'function');
});
it('has valid schema paths in Waitlist model', () => {
assert.ok(Waitlist.schema.path('user'));
assert.ok(Waitlist.schema.path('course'));
assert.ok(Waitlist.schema.path('payment'));
assert.ok(Waitlist.schema.path('class'));
assert.ok(Waitlist.schema.path('status'));
assert.ok(Waitlist.schema.path('isDeleted'));
assert.ok(Waitlist.schema.path('deletedAt'));
const statusPath = Waitlist.schema.path('status');
assert.deepEqual(statusPath.enumValues, ['waiting', 'enrolled', 'cancelled', 'reverted']);
assert.equal(statusPath.defaultValue, 'waiting');
});
});
+13 -1
View File
@@ -99,7 +99,19 @@ const PERMISSIONS = {
EXPENSES_DELETE: 'expenses:delete',
// Financial reports (professor share, class profitability, date-range analytics)
FINANCIAL_REPORTS_READ: 'financial_reports:read'
FINANCIAL_REPORTS_READ: 'financial_reports:read',
// Waiting list permissions
WAITLIST_CREATE: 'waitlist:create',
WAITLIST_READ: 'waitlist:read',
WAITLIST_UPDATE: 'waitlist:update',
WAITLIST_DELETE: 'waitlist:delete',
// Employee timing / attendance permissions
EMPLOYEE_TIMINGS_CREATE: 'employee_timings:create',
EMPLOYEE_TIMINGS_READ: 'employee_timings:read',
EMPLOYEE_TIMINGS_UPDATE: 'employee_timings:update',
EMPLOYEE_TIMINGS_DELETE: 'employee_timings:delete'
};
const ALL_PERMISSIONS = Object.values(PERMISSIONS);
+3 -2
View File
@@ -7,7 +7,7 @@ const Course = require('../components/courses/courseModel');
const eventEmitter = require('../events/eventEmitter');
const EVENT_NAMES = require('../constants/eventNames');
const logger = require('../utils/logger');
const { getPayableAmount } = require('../utils/paymentAmount');
const { getPayableAmount, formatPrice } = require('../utils/paymentAmount');
const { notifyAction } = require('../utils/actionNotify');
const { sendPaymentReminderSms } = require('../utils/senders/smsMessages');
const { resolveNotifyFlags } = require('../utils/notifyResolver');
@@ -30,6 +30,7 @@ const notifyPaymentReminder = async (payment, user) => {
}
const amountDue = getPayableAmount(payment) - (payment.paidAmount || 0);
const formattedAmountDue = formatPrice(amountDue);
await notifyAction({
actionKey: 'paymentReminder',
@@ -37,7 +38,7 @@ const notifyPaymentReminder = async (payment, user) => {
phoneNumber: user.phoneNumber,
email: user.email,
subject: 'یادآوری سررسید پرداخت',
body: `یادآوری: مبلغ ${amountDue} تومان تا ${formatDueDate(payment.dueDate)} سررسید دارد. کد صورتحساب: ${payment.uniqueCode || ''}`,
body: `یادآوری: مبلغ ${formattedAmountDue} تومان تا ${formatDueDate(payment.dueDate)} سررسید دارد. کد صورتحساب: ${payment.uniqueCode || ''}`,
smsHandler: () => sendPaymentReminderSms(user.phoneNumber, {
fullName: user.name || '',
amount: amountDue,
+1 -1
View File
@@ -7,7 +7,7 @@
"start": "node app.js",
"dev": "nodemon app.js",
"seed": "node seed.js",
"test": "node --test components/classes/classSoftDelete.test.js components/settings/smsTemplates.test.js components/users/passwordReset.test.js components/dataImport/importHelpers.test.js utils/classSchedule.test.js utils/jalaliDate.test.js utils/messagingChannels.test.js utils/notifyFlags.test.js utils/passwordRules.test.js utils/paymentAmount.test.js utils/professorShare.test.js utils/financialRange.test.js"
"test": "node --test components/employeeTimings/employeeTiming.test.js components/settings/smsTemplateRender.test.js components/waitlist/waitlistService.test.js components/payments/bulkPayment.test.js components/classes/classSoftDelete.test.js components/settings/smsTemplates.test.js components/users/passwordReset.test.js components/users/studentProfileAndPromotion.test.js components/dataImport/importHelpers.test.js utils/classSchedule.test.js utils/jalaliDate.test.js utils/messagingChannels.test.js utils/notifyFlags.test.js utils/passwordRules.test.js utils/paymentAmount.test.js utils/professorShare.test.js utils/financialRange.test.js"
},
"keywords": [
"express",
+9 -1
View File
@@ -61,7 +61,15 @@ const defaultRoles = [
PERMISSIONS.EXPENSES_CREATE,
PERMISSIONS.EXPENSES_READ,
PERMISSIONS.EXPENSES_UPDATE,
PERMISSIONS.FINANCIAL_REPORTS_READ
PERMISSIONS.FINANCIAL_REPORTS_READ,
PERMISSIONS.WAITLIST_CREATE,
PERMISSIONS.WAITLIST_READ,
PERMISSIONS.WAITLIST_UPDATE,
PERMISSIONS.WAITLIST_DELETE,
PERMISSIONS.EMPLOYEE_TIMINGS_CREATE,
PERMISSIONS.EMPLOYEE_TIMINGS_READ,
PERMISSIONS.EMPLOYEE_TIMINGS_UPDATE,
PERMISSIONS.EMPLOYEE_TIMINGS_DELETE
],
isSystem: true
},
+57 -3
View File
@@ -70,16 +70,68 @@ const parseImportDate = (raw) => {
if (raw instanceof Date) {
return Number.isNaN(raw.getTime()) ? null : raw;
}
const text = String(raw).trim();
const text = toEnglishDigits(raw).trim();
if (!text) return null;
const gregorian = text.match(/^(\d{4}-\d{2}-\d{2})/);
if (gregorian) return new Date(`${gregorian[1]}T00:00:00.000Z`);
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(text)) {
const d = new Date(text);
return Number.isNaN(d.getTime()) ? null : d;
}
const dateMatch = text.match(/^(\d{3,4})[./\-](\d{1,2})[./\-](\d{1,2})/);
if (dateMatch) {
const year = Number(dateMatch[1]);
const month = Number(dateMatch[2]);
const day = Number(dateMatch[3]);
if (year >= 1200 && year <= 1599) {
const gDate = jalaliToGregorian(year, month, day);
if (gDate) return new Date(`${gDate}T00:00:00.000Z`);
} else if (year >= 1900 && year <= 2200) {
const mStr = String(month).padStart(2, '0');
const dStr = String(day).padStart(2, '0');
return new Date(`${year}-${mStr}-${dStr}T00:00:00.000Z`);
}
}
const jalali = parseJalaliDate(text);
if (jalali) return new Date(`${jalali}T00:00:00.000Z`);
const date = new Date(text);
return Number.isNaN(date.getTime()) ? null : date;
};
const gregorianToJalali = (gy, gm, gd) => {
const g_d_m = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334];
let jy;
if (gy > 1600) {
jy = 979;
gy -= 1600;
} else {
jy = 0;
gy -= 621;
}
const gy2 = gm > 2 ? gy + 1 : gy;
let days = 365 * gy + Math.floor((gy2 + 3) / 4) - Math.floor((gy2 + 99) / 100) + Math.floor((gy2 + 399) / 400) - 80 + gd + g_d_m[gm - 1];
jy += 33 * Math.floor(days / 12053);
days %= 12053;
jy += 4 * Math.floor(days / 1461);
days %= 1461;
if (days > 365) {
jy += Math.floor((days - 1) / 365);
days = (days - 1) % 365;
}
const jm = days < 186 ? 1 + Math.floor(days / 31) : 7 + Math.floor((days - 186) / 30);
const jd = 1 + (days < 186 ? days % 31 : (days - 186) % 30);
return `${jy}/${String(jm).padStart(2, '0')}/${String(jd).padStart(2, '0')}`;
};
const formatJalaliDate = (rawDate) => {
if (!rawDate) return '';
const date = rawDate instanceof Date ? rawDate : new Date(rawDate);
if (Number.isNaN(date.getTime())) return '';
return gregorianToJalali(date.getUTCFullYear(), date.getUTCMonth() + 1, date.getUTCDate());
};
const utcDayRange = (value) => {
const date = parseImportDate(value);
if (!date) return null;
@@ -91,6 +143,8 @@ const utcDayRange = (value) => {
module.exports = {
toEnglishDigits,
jalaliToGregorian,
gregorianToJalali,
formatJalaliDate,
parseJalaliDate,
parseImportDate,
utcDayRange
+4
View File
@@ -14,5 +14,9 @@ describe('jalaliDate', () => {
it('parses ISO and jalali values into UTC midnight dates', () => {
assert.equal(parseImportDate('2026-07-14').toISOString(), '2026-07-14T00:00:00.000Z');
assert.equal(parseImportDate('1405.5.12').toISOString(), '2026-08-03T00:00:00.000Z');
assert.equal(parseImportDate('1405/05/26').toISOString(), '2026-08-17T00:00:00.000Z');
assert.equal(parseImportDate('1405-05-26').toISOString(), '2026-08-17T00:00:00.000Z');
assert.equal(parseImportDate('۱۴۰۵/۰۵/۲۶').toISOString(), '2026-08-17T00:00:00.000Z');
assert.equal(parseImportDate('2026-08-17T00:00:00.000Z').toISOString(), '2026-08-17T00:00:00.000Z');
});
});
+11 -1
View File
@@ -24,7 +24,7 @@ const sanitizeNotes = (notes) => {
const rialsToToman = (value) => Math.floor(toNonNegativeNumber(value) / 10);
const isCancelledTransaction = (trx = {}) => String(trx.status || '').toLowerCase() === 'cancelled';
const isCancelledTransaction = (trx = {}) => ['cancelled', 'reverted'].includes(String(trx.status || '').toLowerCase());
const isPaidTransaction = (trx = {}) => {
if (isCancelledTransaction(trx)) return false;
@@ -44,6 +44,15 @@ const sumPaidTransactions = (transactions = []) => {
}, 0);
};
const formatPrice = (value) => {
if (value === null || value === undefined || value === '') return '';
const num = typeof value === 'number' ? value : Number(String(value).replace(/,/g, ''));
if (!Number.isNaN(num) && Number.isFinite(num)) {
return num.toLocaleString('en-US');
}
return String(value);
};
const remainingPayable = (payment = {}, transactions = []) => {
return Math.max(0, getPayableAmount(payment) - sumPaidTransactions(transactions));
};
@@ -53,6 +62,7 @@ module.exports = {
getPayableAmount,
normalizeDiscount,
sanitizeNotes,
formatPrice,
rialsToToman,
isPaidTransaction,
isCancelledTransaction,
+13 -1
View File
@@ -10,7 +10,8 @@ const {
rialsToToman,
isPaidTransaction,
sumPaidTransactions,
remainingPayable
remainingPayable,
formatPrice
} = require('./paymentAmount');
describe('payment amount helpers', () => {
@@ -40,6 +41,17 @@ describe('payment amount helpers', () => {
});
});
describe('formatPrice', () => {
it('formats numbers and numeric strings with decimal thousand separators', () => {
assert.equal(formatPrice(8000000), '8,000,000');
assert.equal(formatPrice('8000000'), '8,000,000');
assert.equal(formatPrice(500000), '500,000');
assert.equal(formatPrice(0), '0');
assert.equal(formatPrice(''), '');
assert.equal(formatPrice(null), '');
});
});
describe('rialsToToman', () => {
it('drops one zero so spreadsheet Rials become website Toman', () => {
assert.equal(rialsToToman(110_000_000), 11_000_000);
+5 -2
View File
@@ -48,9 +48,12 @@ const resolveSessionDurationHours = (cls = {}) => {
/**
* 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 } = {}) => {
return (toPercentage(payoutPercentage) / 100) * toNonNegativeNumber(revenue);
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;
};
/**
+15
View File
@@ -52,6 +52,21 @@ describe('calculatePercentageShare (Model A)', () => {
assert.equal(calculatePercentageShare({ payoutPercentage: 40, revenue: 10_000_000 }), 4_000_000);
});
it('deducts serviceFeePerPerson * studentsCount from revenue before calculating percentage', () => {
// 2 students with 10M tuition (revenue = 20M), serviceFeePerPerson = 400,000, 50% payout
// Net revenue = 20M - (400,000 * 2) = 19,200,000
// Professor share = 19,200,000 * 50% = 9,600,000
assert.equal(
calculatePercentageShare({
payoutPercentage: 50,
revenue: 20_000_000,
serviceFeePerPerson: 400_000,
studentsCount: 2
}),
9_600_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);
+111
View File
@@ -0,0 +1,111 @@
'use strict';
/**
* Time-bucketing helpers for financial analytics (daily / weekly / monthly /
* forward-looking forecast windows). Weeks follow the Iranian convention of
* starting on Saturday, ending on Friday.
*/
const startOfDay = (date) => {
const d = new Date(date);
d.setHours(0, 0, 0, 0);
return d;
};
const endOfDay = (date) => {
const d = new Date(date);
d.setHours(23, 59, 59, 999);
return d;
};
const addDays = (date, days) => {
const d = new Date(date);
d.setDate(d.getDate() + days);
return d;
};
const addMonths = (date, months) => {
const d = new Date(date);
d.setMonth(d.getMonth() + months);
return d;
};
/** JS getDay(): Sun=0..Sat=6. Iranian week starts Saturday, so Sat -> offset 0, Sun -> 1, ... Fri -> 6. */
const getWeekStart = (date) => {
const d = startOfDay(date);
const offset = (d.getDay() + 1) % 7;
return addDays(d, -offset);
};
/** Builds `days` daily buckets ending on (and including) `endDate`, oldest first. */
const buildDailyBuckets = (days, endDate = new Date()) => {
const end = startOfDay(endDate);
const buckets = [];
for (let i = days - 1; i >= 0; i--) {
const dayStart = addDays(end, -i);
buckets.push({ start: dayStart, end: endOfDay(dayStart), key: dayStart.toISOString().slice(0, 10) });
}
return buckets;
};
/** Builds `weeks` weekly buckets (Sat-Fri) ending on the week containing `endDate`, oldest first. */
const buildWeeklyBuckets = (weeks, endDate = new Date()) => {
const currentWeekStart = getWeekStart(endDate);
const buckets = [];
for (let i = weeks - 1; i >= 0; i--) {
const weekStart = addDays(currentWeekStart, -7 * i);
buckets.push({ start: weekStart, end: endOfDay(addDays(weekStart, 6)), key: weekStart.toISOString().slice(0, 10) });
}
return buckets;
};
/** Builds `weeks` forward-looking weekly buckets (Sat-Fri) starting from the week containing `startDate`. */
const buildForecastWeeklyBuckets = (weeks, startDate = new Date()) => {
const currentWeekStart = getWeekStart(startDate);
const buckets = [];
for (let i = 0; i < weeks; i++) {
const weekStart = addDays(currentWeekStart, 7 * i);
buckets.push({ start: weekStart, end: endOfDay(addDays(weekStart, 6)), key: weekStart.toISOString().slice(0, 10) });
}
return buckets;
};
/** Builds `months` monthly (Gregorian calendar month) buckets ending on the month containing `endDate`, oldest first. */
const buildMonthlyBuckets = (months, endDate = new Date()) => {
const base = new Date(endDate.getFullYear(), endDate.getMonth(), 1, 0, 0, 0, 0);
const buckets = [];
for (let i = months - 1; i >= 0; i--) {
const monthStart = addMonths(base, -i);
const monthEnd = endOfDay(addDays(addMonths(monthStart, 1), -1));
buckets.push({
start: monthStart,
end: monthEnd,
year: monthStart.getFullYear(),
month: monthStart.getMonth() + 1,
key: `${monthStart.getFullYear()}-${String(monthStart.getMonth() + 1).padStart(2, '0')}`
});
}
return buckets;
};
/** Linear search is fine — bucket arrays used here are always small (<=~90 entries). */
const findBucketIndex = (buckets, date) => {
if (!date) return -1;
const d = date instanceof Date ? date : new Date(date);
if (Number.isNaN(d.getTime())) return -1;
const t = d.getTime();
return buckets.findIndex((b) => t >= b.start.getTime() && t <= b.end.getTime());
};
module.exports = {
startOfDay,
endOfDay,
addDays,
addMonths,
getWeekStart,
buildDailyBuckets,
buildWeeklyBuckets,
buildForecastWeeklyBuckets,
buildMonthlyBuckets,
findBucketIndex
};
+48 -15
View File
@@ -4,7 +4,7 @@
const { sendSingleSms } = require('./sms.base');
const { recordAndSend } = require('./notificationRecorder');
const { getSmsTemplate } = require('../../components/settings/settingService');
const { buildSmsParameters } = require('../../components/settings/smsTemplates');
const { buildSmsParameters, renderSmsText } = require('../../components/settings/smsTemplates');
const User = require('../../components/users/userModel');
const logger = require('../logger');
@@ -33,22 +33,28 @@ const sendTemplateSms = async ({
slotValues = {}
}) => {
const resolvedUserId = userId || await resolveUserIdByPhone(receiver);
const { templateId, variables, enabled } = await getSmsTemplate(templateKey);
const { templateId, variables, enabled, text: templateText } = await getSmsTemplate(templateKey);
if (enabled === false) {
logger.info(`[SMS] Template ${templateKey} is disabled. Skipping SMS send to ${receiver}.`);
return { success: true, skipped: true, reason: 'template_disabled' };
}
const paramsContext = {
phoneNumber: receiver,
mobile: receiver,
...slotValues
};
const renderedExactBody = renderSmsText(templateText, variables, paramsContext);
const finalBody = (renderedExactBody && renderedExactBody.trim()) ? renderedExactBody.trim() : (body || subject || 'پیامک');
return recordAndSend({
userId: resolvedUserId,
channel: 'sms',
subject,
body,
body: finalBody,
relatedEvent,
sendFn: () => sendSingleSms(receiver, templateId, buildSmsParameters(variables, {
phoneNumber: receiver,
mobile: receiver,
...slotValues
}))
sendFn: () => sendSingleSms(receiver, templateId, buildSmsParameters(variables, paramsContext))
});
};
@@ -175,11 +181,13 @@ const sendClassReminderSms = async (receiver, className, time, place = '', userI
});
};
const { formatPrice } = require('../paymentAmount');
const sendInvoiceCreatedSms = async (receiver, payload, userId = null) => {
const resolvedUserId = userId || await resolveUserIdByPhone(receiver);
const data = typeof payload === 'object' && payload !== null ? payload : { amount: payload };
const fullNameLabel = data.fullName || 'کارآموز';
const amountLabel = data.amount != null ? String(data.amount) : '';
const amountLabel = data.amount != null ? formatPrice(data.amount) : '';
const courseLabel = data.course || '-';
return sendTemplateSms({
@@ -203,6 +211,7 @@ const sendInvoiceCreatedSms = async (receiver, payload, userId = null) => {
const sendPaymentStatusChangedSms = async (receiver, payload, userId = null) => {
const data = payload || {};
const amountLabel = data.amount != null ? formatPrice(data.amount) : '';
return sendTemplateSms({
templateKey: 'paymentStatusChanged',
receiver,
@@ -213,7 +222,7 @@ const sendPaymentStatusChangedSms = async (receiver, payload, userId = null) =>
slotValues: {
fullName: data.fullName || 'کارآموز',
status: data.statusLabel || data.status || '-',
amount: data.amount != null ? String(data.amount) : '',
amount: amountLabel,
invoiceCode: data.invoiceCode || '',
course: data.course || '-'
}
@@ -222,16 +231,17 @@ const sendPaymentStatusChangedSms = async (receiver, payload, userId = null) =>
const sendPaymentReminderSms = async (receiver, payload, userId = null) => {
const data = payload || {};
const amountLabel = data.amount != null ? formatPrice(data.amount) : '-';
return sendTemplateSms({
templateKey: 'paymentReminder',
receiver,
userId,
subject: 'یادآوری سررسید پرداخت',
body: `یادآوری: مبلغ ${data.amount || '-'} تومان تا ${data.dueDate || '-'} سررسید دارد.`,
body: `یادآوری: مبلغ ${amountLabel} تومان تا ${data.dueDate || '-'} سررسید دارد.`,
relatedEvent: 'payment.reminder_due',
slotValues: {
fullName: data.fullName || 'کارآموز',
amount: data.amount != null ? String(data.amount) : '',
amount: data.amount != null ? formatPrice(data.amount) : '',
dueDate: data.dueDate || '',
invoiceCode: data.invoiceCode || '',
course: data.course || '-'
@@ -241,16 +251,17 @@ const sendPaymentReminderSms = async (receiver, payload, userId = null) => {
const sendTransactionRecordedSms = async (receiver, payload, userId = null) => {
const data = payload || {};
const amountLabel = data.amount != null ? formatPrice(data.amount) : '-';
return sendTemplateSms({
templateKey: 'transactionRecorded',
receiver,
userId,
subject: 'ثبت تراکنش',
body: `تراکنش به مبلغ ${data.amount || '-'} تومان ثبت شد.`,
body: `تراکنش به مبلغ ${amountLabel} تومان ثبت شد.`,
relatedEvent: 'payment.transaction_added',
slotValues: {
fullName: data.fullName || 'کارآموز',
amount: data.amount != null ? String(data.amount) : '',
amount: data.amount != null ? formatPrice(data.amount) : '',
invoiceCode: data.invoiceCode || '',
transactionCode: data.transactionCode || '',
receiptNumber: data.receiptNumber || ''
@@ -370,6 +381,27 @@ const sendCertificateIssuedSms = async (receiver, payload, userId = null) => {
});
};
const sendClassPlanProfessorSms = async (receiver, payload, userId = null) => {
const data = payload || {};
return sendTemplateSms({
templateKey: 'classPlanProfessor',
receiver,
userId,
subject: 'برنامه کلاس به استاد',
body: `استاد ${data.professorName || ''}، برنامه کلاس ${data.className || ''}: ${data.classDays || ''}، ${data.classTimes || ''} از ${data.classStartDate || ''} الی ${data.classEndDate || ''}`,
relatedEvent: 'class.plan_to_professor',
slotValues: {
professorName: data.professorName || 'استاد',
className: data.className || '-',
classDays: data.classDays || '',
classTimes: data.classTimes || '',
classStartDate: data.classStartDate || '',
classEndDate: data.classEndDate || '',
phoneNumber: receiver
}
});
};
module.exports = {
sendAccountCreatedSms,
sendPasswordResetSms,
@@ -384,5 +416,6 @@ module.exports = {
sendClassRequestApprovedSms,
sendClassRequestRejectedSms,
sendPendingRegistrationSms,
sendCertificateIssuedSms
sendCertificateIssuedSms,
sendClassPlanProfessorSms
};