Expose schedule fields on class and invoice templates so SMS can include Jalali start date, weekdays, and hour range.
184 lines
5.6 KiB
JavaScript
184 lines
5.6 KiB
JavaScript
// /components/payments/paymentService.js
|
|
'use strict';
|
|
|
|
const Payment = require('./paymentModel');
|
|
const AppError = require('../../utils/AppError');
|
|
const eventEmitter = require('../../events/eventEmitter');
|
|
const EVENT_NAMES = require('../../constants/eventNames');
|
|
const { calculateMeta, escapeRegex, getSearchTerm } = require('../../utils/pagination');
|
|
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 logger = require('../../utils/logger');
|
|
|
|
const getAllPayments = async (query) => {
|
|
const page = parseInt(query.page) || 1;
|
|
const limit = Math.min(parseInt(query.limit) || 20, 200);
|
|
const skip = (page - 1) * limit;
|
|
|
|
const filter = {};
|
|
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 = [
|
|
{ notes: searchRegex },
|
|
{ status: searchRegex },
|
|
{ user: { $in: matchedUsers.map((u) => u._id) } }
|
|
];
|
|
}
|
|
|
|
const [items, total] = await Promise.all([
|
|
Payment.find(filter)
|
|
.populate({ path: 'user', select: 'name' })
|
|
.populate({ path: 'classes', select: 'name' })
|
|
.populate({ path: 'course', select: 'title' })
|
|
.skip(skip).limit(limit).sort({ createdAt: -1 }).lean(),
|
|
Payment.countDocuments(filter)
|
|
]);
|
|
|
|
return { data: items, meta: calculateMeta(total, page, limit) };
|
|
};
|
|
|
|
const getPaymentById = async (id) => {
|
|
const payment = await Payment.findById(id)
|
|
.populate({ path: 'user', select: 'name phoneNumber' })
|
|
.populate({ path: 'classes', select: 'name tuitionFee' })
|
|
.populate({ path: 'course', select: 'title price' })
|
|
.lean();
|
|
if (!payment) throw new AppError('PAYMENT_NOT_FOUND');
|
|
return payment;
|
|
};
|
|
|
|
const createPayment = async (body, actorId = null) => {
|
|
const payment = await Payment.create({
|
|
...body,
|
|
paidAmount: body.paidAmount || 0
|
|
});
|
|
|
|
if (actorId) {
|
|
eventEmitter.emit(EVENT_NAMES.PAYMENT_CREATED, {
|
|
paymentId: payment._id,
|
|
userId: payment.user,
|
|
actorId
|
|
});
|
|
}
|
|
|
|
try {
|
|
const user = await User.findById(payment.user).select('name phoneNumber').lean();
|
|
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: payment.amount,
|
|
course: courseName || '-',
|
|
...schedule
|
|
}, user._id);
|
|
}
|
|
} catch (err) {
|
|
logger.error(`[createPayment] Invoice SMS failed for payment ${payment._id}: ${err.message}`);
|
|
}
|
|
|
|
return getPaymentById(payment._id);
|
|
};
|
|
|
|
const updatePayment = async (id, body, actorId = null) => {
|
|
const payment = await Payment.findById(id);
|
|
if (!payment) throw new AppError('PAYMENT_NOT_FOUND');
|
|
|
|
const previousStatus = payment.status;
|
|
Object.assign(payment, body);
|
|
await payment.save();
|
|
|
|
if (body.status && body.status !== previousStatus) {
|
|
eventEmitter.emit(EVENT_NAMES.PAYMENT_STATUS_CHANGED, {
|
|
paymentId: payment._id,
|
|
userId: payment.user,
|
|
oldStatus: previousStatus,
|
|
newStatus: payment.status,
|
|
actorId
|
|
});
|
|
}
|
|
|
|
return getPaymentById(payment._id);
|
|
};
|
|
|
|
const deletePayment = async (id) => {
|
|
const payment = await Payment.findByIdAndDelete(id);
|
|
if (!payment) throw new AppError('PAYMENT_NOT_FOUND');
|
|
return null;
|
|
};
|
|
|
|
const searchPayments = async (query) => getAllPayments(query);
|
|
|
|
const addTransaction = async (paymentId, trxData, actorId = null) => {
|
|
const payment = await Payment.findById(paymentId);
|
|
if (!payment) throw new AppError('PAYMENT_NOT_FOUND');
|
|
|
|
payment.transactions.push({
|
|
...trxData,
|
|
recordedBy: actorId || trxData.recordedBy,
|
|
date: trxData.date || new Date()
|
|
});
|
|
payment.paidAmount = payment.transactions.reduce((sum, t) => sum + (t.amount || 0), 0);
|
|
await payment.save();
|
|
return getPaymentById(paymentId);
|
|
};
|
|
|
|
const getMyPayments = async (userId, query = {}) => {
|
|
return getAllPayments({ ...query, userId });
|
|
};
|
|
|
|
module.exports = {
|
|
getAllPayments,
|
|
getPaymentById,
|
|
createPayment,
|
|
updatePayment,
|
|
deletePayment,
|
|
searchPayments,
|
|
addTransaction,
|
|
getMyPayments,
|
|
// Aliases for older call sites
|
|
getAll: getAllPayments,
|
|
getOne: getPaymentById,
|
|
create: createPayment,
|
|
recordTransaction: addTransaction,
|
|
remove: deletePayment
|
|
};
|