diff --git a/components/payments/paymentService.js b/components/payments/paymentService.js index 8473553..98f2826 100644 --- a/components/payments/paymentService.js +++ b/components/payments/paymentService.js @@ -28,7 +28,8 @@ const { isPaidTransaction, isCancelledTransaction, isActiveTransaction, - sumPaidTransactions + sumPaidTransactions, + formatPrice } = require('../../utils/paymentAmount'); const logger = require('../../utils/logger'); @@ -36,7 +37,7 @@ const PAYMENT_METHODS = new Set(['online', 'card', 'cash']); const PAYMENT_STATUS_LABELS = { pending: 'در انتظار پرداخت', - partial: 'پرداخت جزئی', + partial: 'پیش پرداخت', paid: 'پرداخت‌شده', overdue: 'معوق' }; @@ -112,7 +113,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, @@ -333,7 +334,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), diff --git a/components/sessions/sessionController.js b/components/sessions/sessionController.js index 8fec059..d232fe4 100644 --- a/components/sessions/sessionController.js +++ b/components/sessions/sessionController.js @@ -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); diff --git a/components/sessions/sessionRoutes.js b/components/sessions/sessionRoutes.js index 9e55123..371287e 100644 --- a/components/sessions/sessionRoutes.js +++ b/components/sessions/sessionRoutes.js @@ -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; diff --git a/components/sessions/sessionService.js b/components/sessions/sessionService.js index 1b647e3..53411fb 100644 --- a/components/sessions/sessionService.js +++ b/components/sessions/sessionService.js @@ -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', @@ -430,6 +433,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 +515,7 @@ module.exports = { searchSessions, updateSessionAttendance, getMySessions, + notifySessionHolding, isSessionDue, hasCompleteAttendance, isAttendancePending diff --git a/components/settings/smsTemplates.js b/components/settings/smsTemplates.js index dec1a5a..22d16a7 100644 --- a/components/settings/smsTemplates.js +++ b/components/settings/smsTemplates.js @@ -382,13 +382,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 ?? '') diff --git a/jobs/paymentReminderJob.js b/jobs/paymentReminderJob.js index b510c3d..4b90994 100644 --- a/jobs/paymentReminderJob.js +++ b/jobs/paymentReminderJob.js @@ -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, diff --git a/utils/paymentAmount.js b/utils/paymentAmount.js index 852d18f..690c693 100644 --- a/utils/paymentAmount.js +++ b/utils/paymentAmount.js @@ -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, diff --git a/utils/paymentAmount.test.js b/utils/paymentAmount.test.js index 171b9e0..bff1090 100644 --- a/utils/paymentAmount.test.js +++ b/utils/paymentAmount.test.js @@ -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); diff --git a/utils/senders/smsMessages.js b/utils/senders/smsMessages.js index 06ad894..d83c028 100644 --- a/utils/senders/smsMessages.js +++ b/utils/senders/smsMessages.js @@ -175,11 +175,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 +205,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 +216,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 +225,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 +245,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 || ''