Add sessionHolding template, unique 8-digit codes, reason slot for sessionCancelled, and disable unused notifications

This commit is contained in:
2026-08-21 10:44:51 +03:30
parent 5328f36f06
commit 091e519280
28 changed files with 2180 additions and 280 deletions
+128 -44
View File
@@ -11,10 +11,14 @@ const User = require('../users/userModel');
const Course = require('../courses/courseModel');
const Class = require('../classes/classModel');
const Session = require('../sessions/sessionModel');
const { sendInvoiceCreatedSms } = require('../../utils/senders/smsMessages');
const { buildClassScheduleContext } = require('../../utils/classSchedule');
const { parseImportDate } = require('../../utils/jalaliDate');
const { pickNotifyFlags, omitNotifyFields } = require('../../utils/notifyFlags');
const { omitNotifyFields } = require('../../utils/notifyFlags');
const { resolveNotifyFlags } = require('../../utils/notifyResolver');
const { notifyAction } = require('../../utils/actionNotify');
const {
sendInvoiceCreatedSms,
sendPaymentStatusChangedSms,
sendTransactionRecordedSms
} = require('../../utils/senders/smsMessages');
const {
getPayableAmount,
normalizeDiscount,
@@ -28,6 +32,98 @@ const logger = require('../../utils/logger');
const PAYMENT_METHODS = new Set(['online', 'card', 'cash']);
const PAYMENT_STATUS_LABELS = {
pending: 'در انتظار پرداخت',
partial: 'پرداخت جزئی',
paid: 'پرداخت‌شده',
overdue: 'معوق'
};
const formatPaymentContext = async (payment) => {
const user = await User.findById(payment.user).select('name phoneNumber email').lean();
let courseName = '';
let schedule = { classStartDate: '', classDays: '', courseTime: '' };
if (payment.course) {
const course = await Course.findById(payment.course).select('title').lean();
if (course?.title) courseName = course.title;
}
if (payment.classes?.length) {
const cls = await Class.findById(payment.classes[0]).populate('course', 'title').lean();
if (!courseName) {
courseName = cls?.course?.title || cls?.name || '';
}
if (cls) {
const sessions = await Session.find({ class: cls._id }).select('day startTime endTime').sort({ day: 1 }).lean();
schedule = buildClassScheduleContext(cls, sessions);
}
}
return {
user,
courseName: courseName || '-',
schedule,
invoiceCode: payment.uniqueCode || ''
};
};
const notifyPaymentStatusChanged = async (payment, newStatus, oldStatus) => {
try {
const notify = await resolveNotifyFlags({}, 'paymentStatusChanged');
if (!notify.sms && !notify.email && !notify.bot) return;
const { user, courseName, invoiceCode } = await formatPaymentContext(payment);
if (!user?.phoneNumber) return;
await notifyAction({
actionKey: 'paymentStatusChanged',
userId: user._id,
phoneNumber: user.phoneNumber,
email: user.email,
subject: 'تغییر وضعیت پرداخت',
body: `وضعیت صورتحساب ${invoiceCode} از «${PAYMENT_STATUS_LABELS[oldStatus] || oldStatus}» به «${PAYMENT_STATUS_LABELS[newStatus] || newStatus}» تغییر کرد.`,
smsHandler: () => sendPaymentStatusChangedSms(user.phoneNumber, {
fullName: user.name || '',
status: newStatus,
statusLabel: PAYMENT_STATUS_LABELS[newStatus] || newStatus,
amount: getPayableAmount(payment),
invoiceCode,
course: courseName
}, user._id)
});
} catch (err) {
logger.error(`[notifyPaymentStatusChanged] Failed for payment ${payment._id}: ${err.message}`);
}
};
const notifyTransactionRecorded = async (payment, transaction) => {
try {
const notify = await resolveNotifyFlags({}, 'transactionRecorded');
if (!notify.sms && !notify.email && !notify.bot) return;
const { user, invoiceCode } = await formatPaymentContext(payment);
if (!user?.phoneNumber) return;
await notifyAction({
actionKey: 'transactionRecorded',
userId: user._id,
phoneNumber: user.phoneNumber,
email: user.email,
subject: 'ثبت تراکنش',
body: `تراکنش ${transaction.uniqueCode || ''} به مبلغ ${transaction.amount} تومان ثبت شد.`,
smsHandler: () => sendTransactionRecordedSms(user.phoneNumber, {
fullName: user.name || '',
amount: transaction.amount,
invoiceCode,
transactionCode: transaction.uniqueCode || '',
receiptNumber: transaction.receiptNumber || ''
}, user._id)
});
} catch (err) {
logger.error(`[notifyTransactionRecorded] Failed for transaction ${transaction._id}: ${err.message}`);
}
};
const populatePayment = (query) => query
.populate({ path: 'user', select: 'name phoneNumber' })
.populate({ path: 'classes', select: 'name tuitionFee' })
@@ -65,7 +161,7 @@ const refreshPaymentTotals = async (payment) => {
return payment;
};
const emitPaymentStatusChangedIfNeeded = (payment, previousStatus, actorId = null) => {
const emitPaymentStatusChangedIfNeeded = async (payment, previousStatus, actorId = null) => {
if (payment.status === previousStatus) return;
eventEmitter.emit(EVENT_NAMES.PAYMENT_STATUS_CHANGED, {
paymentId: payment._id,
@@ -74,6 +170,7 @@ const emitPaymentStatusChangedIfNeeded = (payment, previousStatus, actorId = nul
newStatus: payment.status,
actorId
});
await notifyPaymentStatusChanged(payment, payment.status, previousStatus);
};
const reconcilePendingTransactions = async (payment, { adjustAmount = true } = {}) => {
@@ -197,7 +294,7 @@ const getPaymentById = async (id) => {
};
const createPayment = async (body, actorId = null) => {
const notify = pickNotifyFlags(body);
const notify = await resolveNotifyFlags(body, 'invoiceCreated');
const payload = omitNotifyFields(body);
const incomingTransactions = Array.isArray(payload.transactions) ? payload.transactions : null;
delete payload.transactions;
@@ -229,43 +326,29 @@ const createPayment = async (body, actorId = null) => {
});
}
if (notify.sms) {
if (notify.sms || notify.email || notify.bot) {
try {
const user = await User.findById(payment.user).select('name phoneNumber').lean();
const { user, courseName, schedule, invoiceCode } = await formatPaymentContext(payment);
if (user?.phoneNumber) {
let courseName = '';
let schedule = { classStartDate: '', classDays: '', courseTime: '' };
if (payment.course) {
const course = await Course.findById(payment.course).select('title').lean();
if (course?.title) courseName = course.title;
}
if (payment.classes && payment.classes.length > 0) {
const cls = await Class.findById(payment.classes[0]).populate('course', 'title').lean();
if (!courseName) {
if (cls?.course?.title) {
courseName = cls.course.title;
} else if (cls?.name) {
courseName = cls.name;
}
}
if (cls) {
const sessions = await Session.find({ class: cls._id })
.select('day startTime endTime')
.sort({ day: 1 })
.lean();
schedule = buildClassScheduleContext(cls, sessions);
}
}
await sendInvoiceCreatedSms(user.phoneNumber, {
fullName: user.name || '',
amount: getPayableAmount(payment),
course: courseName || '-',
...schedule
}, user._id);
await notifyAction({
actionKey: 'invoiceCreated',
userId: user._id,
phoneNumber: user.phoneNumber,
email: user.email,
subject: 'ایجاد صورتحساب',
body: `صورتحساب ${invoiceCode} به مبلغ ${getPayableAmount(payment)} تومان بابت «${courseName}» ایجاد شد.`,
smsHandler: () => sendInvoiceCreatedSms(user.phoneNumber, {
fullName: user.name || '',
amount: getPayableAmount(payment),
course: courseName,
invoiceCode,
...schedule
}, user._id),
requestSource: body
});
}
} catch (err) {
logger.error(`[createPayment] Invoice SMS failed for payment ${payment._id}: ${err.message}`);
logger.error(`[createPayment] Invoice notification failed for payment ${payment._id}: ${err.message}`);
}
}
@@ -296,7 +379,7 @@ const updatePayment = async (id, body, actorId = null) => {
await refreshPaymentTotals(payment);
}
emitPaymentStatusChangedIfNeeded(payment, previousStatus, actorId);
await emitPaymentStatusChangedIfNeeded(payment, previousStatus, actorId);
return getPaymentById(payment._id);
};
@@ -317,7 +400,7 @@ const addTransaction = async (paymentId, trxData, actorId = null) => {
const previousStatus = payment.status;
const payload = buildTransactionPayload(payment, { ...trxData, status: trxData.status || 'paid' }, actorId);
if (!payload.amount) throw new AppError('VALIDATION_FAILED', { amount: 'Amount is required' }, 'مبلغ تراکنش الزامی است.');
await Transaction.create(payload);
const transaction = await Transaction.create(payload);
await reconcilePendingTransactions(payment);
await refreshPaymentTotals(payment);
@@ -327,7 +410,8 @@ const addTransaction = async (paymentId, trxData, actorId = null) => {
userId: payment.user,
actorId
});
emitPaymentStatusChangedIfNeeded(payment, previousStatus, actorId);
await notifyTransactionRecorded(payment, transaction);
await emitPaymentStatusChangedIfNeeded(payment, previousStatus, actorId);
return getPaymentById(paymentId);
};
@@ -373,7 +457,7 @@ const updateTransaction = async (transactionId, body, actorId = null) => {
// Preserve admin-edited amounts; only reconcile balance when recording new payments.
await reconcilePendingTransactions(payment, { adjustAmount: false });
await refreshPaymentTotals(payment);
emitPaymentStatusChangedIfNeeded(payment, previousStatus, actorId);
await emitPaymentStatusChangedIfNeeded(payment, previousStatus, actorId);
return getPaymentById(payment._id);
};
@@ -394,7 +478,7 @@ const cancelTransaction = async (transactionId, actorId = null) => {
await trx.save();
await refreshPaymentTotals(payment);
emitPaymentStatusChangedIfNeeded(payment, previousStatus, actorId);
await emitPaymentStatusChangedIfNeeded(payment, previousStatus, actorId);
return getPaymentById(payment._id);
};