fix: format prices in notifications, change partial payment label, and add session holding notify API

This commit is contained in:
2026-08-21 20:17:30 +03:30
parent 6839685a21
commit 4c07ca2ae1
9 changed files with 145 additions and 14 deletions
+5 -4
View File
@@ -28,7 +28,8 @@ const {
isPaidTransaction, isPaidTransaction,
isCancelledTransaction, isCancelledTransaction,
isActiveTransaction, isActiveTransaction,
sumPaidTransactions sumPaidTransactions,
formatPrice
} = require('../../utils/paymentAmount'); } = require('../../utils/paymentAmount');
const logger = require('../../utils/logger'); const logger = require('../../utils/logger');
@@ -36,7 +37,7 @@ const PAYMENT_METHODS = new Set(['online', 'card', 'cash']);
const PAYMENT_STATUS_LABELS = { const PAYMENT_STATUS_LABELS = {
pending: 'در انتظار پرداخت', pending: 'در انتظار پرداخت',
partial: 'پرداخت جزئی', partial: یش پرداخت',
paid: 'پرداخت‌شده', paid: 'پرداخت‌شده',
overdue: 'معوق' overdue: 'معوق'
}; };
@@ -112,7 +113,7 @@ const notifyTransactionRecorded = async (payment, transaction, source = {}) => {
phoneNumber: user.phoneNumber, phoneNumber: user.phoneNumber,
email: user.email, email: user.email,
subject: 'ثبت تراکنش', subject: 'ثبت تراکنش',
body: `تراکنش ${transaction.uniqueCode || ''} به مبلغ ${transaction.amount} تومان ثبت شد.`, body: `تراکنش ${transaction.uniqueCode || ''} به مبلغ ${formatPrice(transaction.amount)} تومان ثبت شد.`,
smsHandler: () => sendTransactionRecordedSms(user.phoneNumber, { smsHandler: () => sendTransactionRecordedSms(user.phoneNumber, {
fullName: user.name || '', fullName: user.name || '',
amount: transaction.amount, amount: transaction.amount,
@@ -333,7 +334,7 @@ const createPayment = async (body, actorId = null) => {
phoneNumber: user.phoneNumber, phoneNumber: user.phoneNumber,
email: user.email, email: user.email,
subject: 'ایجاد صورتحساب', subject: 'ایجاد صورتحساب',
body: `صورتحساب ${invoiceCode} به مبلغ ${getPayableAmount(payment)} تومان بابت «${courseName}» ایجاد شد.`, body: `صورتحساب ${invoiceCode} به مبلغ ${formatPrice(getPayableAmount(payment))} تومان بابت «${courseName}» ایجاد شد.`,
smsHandler: () => sendInvoiceCreatedSms(user.phoneNumber, { smsHandler: () => sendInvoiceCreatedSms(user.phoneNumber, {
fullName: user.name || '', fullName: user.name || '',
amount: getPayableAmount(payment), amount: getPayableAmount(payment),
+6
View File
@@ -57,6 +57,12 @@ exports.updateAttendance = catchAsync(async (req, res, next) => {
return successResponse(res, 200, 'Session attendance updated successfully', session); 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) => { exports.getMySessions = catchAsync(async (req, res, next) => {
const { data, meta } = await sessionService.getMySessions(req.user._id, req.query); const { data, meta } = await sessionService.getMySessions(req.user._id, req.query);
return listResponse(res, 200, data, meta); 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-delete', perm.requires(PERMISSIONS.SESSIONS_DELETE), sessionController.bulkDelete);
router.post('/admin/bulk-status', perm.requires(PERMISSIONS.SESSIONS_UPDATE), sessionController.bulkUpdateStatus); 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.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; module.exports = router;
+75
View File
@@ -9,6 +9,9 @@ const AppError = require('../../utils/AppError');
const eventEmitter = require('../../events/eventEmitter'); const eventEmitter = require('../../events/eventEmitter');
const EVENT_NAMES = require('../../constants/eventNames'); const EVENT_NAMES = require('../../constants/eventNames');
const { parsePaginationAndSort, buildFilterQuery, calculateMeta } = require('../../utils/pagination'); 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 = { const STATUS_MAP = {
scheduled: 'scheduled', scheduled: 'scheduled',
@@ -430,6 +433,77 @@ const getMySessions = async (userId, queryParams) => {
return { data: sessions, meta }; 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 = { module.exports = {
createSession, createSession,
getSessionById, getSessionById,
@@ -441,6 +515,7 @@ module.exports = {
searchSessions, searchSessions,
updateSessionAttendance, updateSessionAttendance,
getMySessions, getMySessions,
notifySessionHolding,
isSessionDue, isSessionDue,
hasCompleteAttendance, hasCompleteAttendance,
isAttendancePending isAttendancePending
+21 -1
View File
@@ -382,13 +382,33 @@ const resolveVariablesList = (def, storedVariables) => {
.filter(Boolean); .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 = {}) => { const buildSmsParameters = (variables, valuesBySlot = {}) => {
return normalizeVariablesInput(variables, null) return normalizeVariablesInput(variables, null)
.map((item) => { .map((item) => {
const name = sanitizeVariableName(item?.name); const name = sanitizeVariableName(item?.name);
if (!name) return null; if (!name) return null;
const slot = item?.slot; 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 { return {
name, name,
value: String(value ?? '') value: String(value ?? '')
+3 -2
View File
@@ -7,7 +7,7 @@ const Course = require('../components/courses/courseModel');
const eventEmitter = require('../events/eventEmitter'); const eventEmitter = require('../events/eventEmitter');
const EVENT_NAMES = require('../constants/eventNames'); const EVENT_NAMES = require('../constants/eventNames');
const logger = require('../utils/logger'); const logger = require('../utils/logger');
const { getPayableAmount } = require('../utils/paymentAmount'); const { getPayableAmount, formatPrice } = require('../utils/paymentAmount');
const { notifyAction } = require('../utils/actionNotify'); const { notifyAction } = require('../utils/actionNotify');
const { sendPaymentReminderSms } = require('../utils/senders/smsMessages'); const { sendPaymentReminderSms } = require('../utils/senders/smsMessages');
const { resolveNotifyFlags } = require('../utils/notifyResolver'); const { resolveNotifyFlags } = require('../utils/notifyResolver');
@@ -30,6 +30,7 @@ const notifyPaymentReminder = async (payment, user) => {
} }
const amountDue = getPayableAmount(payment) - (payment.paidAmount || 0); const amountDue = getPayableAmount(payment) - (payment.paidAmount || 0);
const formattedAmountDue = formatPrice(amountDue);
await notifyAction({ await notifyAction({
actionKey: 'paymentReminder', actionKey: 'paymentReminder',
@@ -37,7 +38,7 @@ const notifyPaymentReminder = async (payment, user) => {
phoneNumber: user.phoneNumber, phoneNumber: user.phoneNumber,
email: user.email, email: user.email,
subject: 'یادآوری سررسید پرداخت', subject: 'یادآوری سررسید پرداخت',
body: `یادآوری: مبلغ ${amountDue} تومان تا ${formatDueDate(payment.dueDate)} سررسید دارد. کد صورتحساب: ${payment.uniqueCode || ''}`, body: `یادآوری: مبلغ ${formattedAmountDue} تومان تا ${formatDueDate(payment.dueDate)} سررسید دارد. کد صورتحساب: ${payment.uniqueCode || ''}`,
smsHandler: () => sendPaymentReminderSms(user.phoneNumber, { smsHandler: () => sendPaymentReminderSms(user.phoneNumber, {
fullName: user.name || '', fullName: user.name || '',
amount: amountDue, amount: amountDue,
+10
View File
@@ -44,6 +44,15 @@ const sumPaidTransactions = (transactions = []) => {
}, 0); }, 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 = []) => { const remainingPayable = (payment = {}, transactions = []) => {
return Math.max(0, getPayableAmount(payment) - sumPaidTransactions(transactions)); return Math.max(0, getPayableAmount(payment) - sumPaidTransactions(transactions));
}; };
@@ -53,6 +62,7 @@ module.exports = {
getPayableAmount, getPayableAmount,
normalizeDiscount, normalizeDiscount,
sanitizeNotes, sanitizeNotes,
formatPrice,
rialsToToman, rialsToToman,
isPaidTransaction, isPaidTransaction,
isCancelledTransaction, isCancelledTransaction,
+13 -1
View File
@@ -10,7 +10,8 @@ const {
rialsToToman, rialsToToman,
isPaidTransaction, isPaidTransaction,
sumPaidTransactions, sumPaidTransactions,
remainingPayable remainingPayable,
formatPrice
} = require('./paymentAmount'); } = require('./paymentAmount');
describe('payment amount helpers', () => { 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', () => { describe('rialsToToman', () => {
it('drops one zero so spreadsheet Rials become website Toman', () => { it('drops one zero so spreadsheet Rials become website Toman', () => {
assert.equal(rialsToToman(110_000_000), 11_000_000); assert.equal(rialsToToman(110_000_000), 11_000_000);
+11 -6
View File
@@ -175,11 +175,13 @@ const sendClassReminderSms = async (receiver, className, time, place = '', userI
}); });
}; };
const { formatPrice } = require('../paymentAmount');
const sendInvoiceCreatedSms = async (receiver, payload, userId = null) => { const sendInvoiceCreatedSms = async (receiver, payload, userId = null) => {
const resolvedUserId = userId || await resolveUserIdByPhone(receiver); const resolvedUserId = userId || await resolveUserIdByPhone(receiver);
const data = typeof payload === 'object' && payload !== null ? payload : { amount: payload }; const data = typeof payload === 'object' && payload !== null ? payload : { amount: payload };
const fullNameLabel = data.fullName || 'کارآموز'; const fullNameLabel = data.fullName || 'کارآموز';
const amountLabel = data.amount != null ? String(data.amount) : ''; const amountLabel = data.amount != null ? formatPrice(data.amount) : '';
const courseLabel = data.course || '-'; const courseLabel = data.course || '-';
return sendTemplateSms({ return sendTemplateSms({
@@ -203,6 +205,7 @@ const sendInvoiceCreatedSms = async (receiver, payload, userId = null) => {
const sendPaymentStatusChangedSms = async (receiver, payload, userId = null) => { const sendPaymentStatusChangedSms = async (receiver, payload, userId = null) => {
const data = payload || {}; const data = payload || {};
const amountLabel = data.amount != null ? formatPrice(data.amount) : '';
return sendTemplateSms({ return sendTemplateSms({
templateKey: 'paymentStatusChanged', templateKey: 'paymentStatusChanged',
receiver, receiver,
@@ -213,7 +216,7 @@ const sendPaymentStatusChangedSms = async (receiver, payload, userId = null) =>
slotValues: { slotValues: {
fullName: data.fullName || 'کارآموز', fullName: data.fullName || 'کارآموز',
status: data.statusLabel || data.status || '-', status: data.statusLabel || data.status || '-',
amount: data.amount != null ? String(data.amount) : '', amount: amountLabel,
invoiceCode: data.invoiceCode || '', invoiceCode: data.invoiceCode || '',
course: data.course || '-' course: data.course || '-'
} }
@@ -222,16 +225,17 @@ const sendPaymentStatusChangedSms = async (receiver, payload, userId = null) =>
const sendPaymentReminderSms = async (receiver, payload, userId = null) => { const sendPaymentReminderSms = async (receiver, payload, userId = null) => {
const data = payload || {}; const data = payload || {};
const amountLabel = data.amount != null ? formatPrice(data.amount) : '-';
return sendTemplateSms({ return sendTemplateSms({
templateKey: 'paymentReminder', templateKey: 'paymentReminder',
receiver, receiver,
userId, userId,
subject: 'یادآوری سررسید پرداخت', subject: 'یادآوری سررسید پرداخت',
body: `یادآوری: مبلغ ${data.amount || '-'} تومان تا ${data.dueDate || '-'} سررسید دارد.`, body: `یادآوری: مبلغ ${amountLabel} تومان تا ${data.dueDate || '-'} سررسید دارد.`,
relatedEvent: 'payment.reminder_due', relatedEvent: 'payment.reminder_due',
slotValues: { slotValues: {
fullName: data.fullName || 'کارآموز', fullName: data.fullName || 'کارآموز',
amount: data.amount != null ? String(data.amount) : '', amount: data.amount != null ? formatPrice(data.amount) : '',
dueDate: data.dueDate || '', dueDate: data.dueDate || '',
invoiceCode: data.invoiceCode || '', invoiceCode: data.invoiceCode || '',
course: data.course || '-' course: data.course || '-'
@@ -241,16 +245,17 @@ const sendPaymentReminderSms = async (receiver, payload, userId = null) => {
const sendTransactionRecordedSms = async (receiver, payload, userId = null) => { const sendTransactionRecordedSms = async (receiver, payload, userId = null) => {
const data = payload || {}; const data = payload || {};
const amountLabel = data.amount != null ? formatPrice(data.amount) : '-';
return sendTemplateSms({ return sendTemplateSms({
templateKey: 'transactionRecorded', templateKey: 'transactionRecorded',
receiver, receiver,
userId, userId,
subject: 'ثبت تراکنش', subject: 'ثبت تراکنش',
body: `تراکنش به مبلغ ${data.amount || '-'} تومان ثبت شد.`, body: `تراکنش به مبلغ ${amountLabel} تومان ثبت شد.`,
relatedEvent: 'payment.transaction_added', relatedEvent: 'payment.transaction_added',
slotValues: { slotValues: {
fullName: data.fullName || 'کارآموز', fullName: data.fullName || 'کارآموز',
amount: data.amount != null ? String(data.amount) : '', amount: data.amount != null ? formatPrice(data.amount) : '',
invoiceCode: data.invoiceCode || '', invoiceCode: data.invoiceCode || '',
transactionCode: data.transactionCode || '', transactionCode: data.transactionCode || '',
receiptNumber: data.receiptNumber || '' receiptNumber: data.receiptNumber || ''