512 lines
18 KiB
JavaScript
512 lines
18 KiB
JavaScript
// /components/payments/paymentService.js
|
|
'use strict';
|
|
|
|
const Payment = require('./paymentModel');
|
|
const Transaction = require('./transactionModel');
|
|
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 { 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,
|
|
sanitizeNotes,
|
|
isPaidTransaction,
|
|
isCancelledTransaction,
|
|
isActiveTransaction,
|
|
sumPaidTransactions
|
|
} = require('../../utils/paymentAmount');
|
|
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' })
|
|
.populate({ path: 'course', select: 'title price' })
|
|
.populate({ path: 'transactions', options: { sort: { dueDate: 1, date: 1, createdAt: 1 } } });
|
|
|
|
const attachTransactions = async (payments) => {
|
|
const list = Array.isArray(payments) ? payments : [payments];
|
|
const ids = list.map((item) => item._id).filter(Boolean);
|
|
if (!ids.length) return payments;
|
|
|
|
const transactions = await Transaction.find({ payment: { $in: ids } })
|
|
.sort({ dueDate: 1, date: 1, createdAt: 1 })
|
|
.lean();
|
|
const byPayment = new Map();
|
|
for (const trx of transactions) {
|
|
const key = String(trx.payment);
|
|
if (!byPayment.has(key)) byPayment.set(key, []);
|
|
byPayment.get(key).push(trx);
|
|
}
|
|
for (const payment of list) {
|
|
payment.transactions = byPayment.get(String(payment._id)) || [];
|
|
}
|
|
return payments;
|
|
};
|
|
|
|
const refreshPaymentTotals = async (payment) => {
|
|
const transactions = await Transaction.find({ payment: payment._id }).lean();
|
|
payment.paidAmount = sumPaidTransactions(transactions);
|
|
const pending = transactions
|
|
.filter((trx) => isActiveTransaction(trx) && !isPaidTransaction(trx))
|
|
.sort((a, b) => new Date(a.dueDate || 0) - new Date(b.dueDate || 0));
|
|
payment.dueDate = pending[0]?.dueDate || payment.dueDate;
|
|
await payment.save();
|
|
return payment;
|
|
};
|
|
|
|
const emitPaymentStatusChangedIfNeeded = async (payment, previousStatus, actorId = null) => {
|
|
if (payment.status === previousStatus) return;
|
|
eventEmitter.emit(EVENT_NAMES.PAYMENT_STATUS_CHANGED, {
|
|
paymentId: payment._id,
|
|
userId: payment.user,
|
|
oldStatus: previousStatus,
|
|
newStatus: payment.status,
|
|
actorId
|
|
});
|
|
await notifyPaymentStatusChanged(payment, payment.status, previousStatus);
|
|
};
|
|
|
|
const reconcilePendingTransactions = async (payment, { adjustAmount = true } = {}) => {
|
|
const transactions = await Transaction.find({ payment: payment._id }).lean();
|
|
const remaining = getPayableAmount(payment) - sumPaidTransactions(transactions);
|
|
const pending = await Transaction.find({ payment: payment._id, status: 'pending' }).sort({ dueDate: 1 });
|
|
|
|
if (remaining <= 0) {
|
|
await Transaction.deleteMany({ payment: payment._id, status: 'pending' });
|
|
return;
|
|
}
|
|
|
|
if (pending.length) {
|
|
if (adjustAmount) {
|
|
const pendingTotal = pending.reduce((sum, row) => sum + row.amount, 0);
|
|
if (pendingTotal !== remaining) {
|
|
pending[0].amount = remaining;
|
|
await pending[0].save();
|
|
}
|
|
}
|
|
if (pending.length > 1) {
|
|
await Transaction.deleteMany({ _id: { $in: pending.slice(1).map((row) => row._id) } });
|
|
}
|
|
return;
|
|
}
|
|
|
|
await Transaction.create(buildTransactionPayload(payment, {
|
|
amount: remaining,
|
|
status: 'pending',
|
|
dueDate: payment.dueDate || new Date()
|
|
}));
|
|
};
|
|
|
|
const buildTransactionPayload = (payment, trxData, actorId = null) => {
|
|
const status = trxData.status === 'pending' ? 'pending' : (trxData.status === 'paid' || trxData.date ? 'paid' : 'pending');
|
|
const paidDate = parseDate(trxData.date) || (status === 'paid' ? new Date() : undefined);
|
|
const dueDate = parseDate(trxData.dueDate) || paidDate || parseDate(payment.dueDate) || new Date();
|
|
const payload = {
|
|
payment: payment._id,
|
|
user: payment.user,
|
|
amount: Number(trxData.amount) || 0,
|
|
receiptNumber: trxData.receiptNumber != null && trxData.receiptNumber !== ''
|
|
? String(trxData.receiptNumber)
|
|
: '',
|
|
notes: sanitizeNotes(trxData.notes),
|
|
recordedBy: actorId || trxData.recordedBy,
|
|
date: status === 'paid' ? paidDate : undefined,
|
|
dueDate,
|
|
status
|
|
};
|
|
if (PAYMENT_METHODS.has(trxData.method)) payload.method = trxData.method;
|
|
else if (status === 'paid') payload.method = 'card';
|
|
return payload;
|
|
};
|
|
|
|
const parseDate = (value) => {
|
|
if (!value) return null;
|
|
if (value instanceof Date) {
|
|
return Number.isNaN(value.getTime()) ? null : value;
|
|
}
|
|
const parsed = parseImportDate(value);
|
|
return parsed && !Number.isNaN(parsed.getTime()) ? parsed : null;
|
|
};
|
|
|
|
const createTransactionsForPayment = async (payment, transactions, actorId = null) => {
|
|
const rows = Array.isArray(transactions) ? transactions : [];
|
|
const created = [];
|
|
for (const trx of rows) {
|
|
const payload = buildTransactionPayload(payment, trx, actorId);
|
|
if (!payload.amount) continue;
|
|
created.push(await Transaction.create(payload));
|
|
}
|
|
return created;
|
|
};
|
|
|
|
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)
|
|
]);
|
|
|
|
await attachTransactions(items);
|
|
return { data: items, meta: calculateMeta(total, page, limit) };
|
|
};
|
|
|
|
const getPaymentById = async (id) => {
|
|
const payment = await populatePayment(Payment.findById(id)).lean();
|
|
if (!payment) throw new AppError('PAYMENT_NOT_FOUND');
|
|
if (!Array.isArray(payment.transactions)) payment.transactions = [];
|
|
return payment;
|
|
};
|
|
|
|
const createPayment = async (body, actorId = null) => {
|
|
const notify = await resolveNotifyFlags(body, 'invoiceCreated');
|
|
const payload = omitNotifyFields(body);
|
|
const incomingTransactions = Array.isArray(payload.transactions) ? payload.transactions : null;
|
|
delete payload.transactions;
|
|
|
|
const payment = await Payment.create({
|
|
...payload,
|
|
discount: normalizeDiscount(payload.discount, payload.amount),
|
|
notes: sanitizeNotes(payload.notes),
|
|
paidAmount: payload.paidAmount || 0
|
|
});
|
|
|
|
if (incomingTransactions?.length) {
|
|
await createTransactionsForPayment(payment, incomingTransactions, actorId);
|
|
} else if (payment.dueDate && getPayableAmount(payment) > 0) {
|
|
await Transaction.create(buildTransactionPayload(payment, {
|
|
amount: getPayableAmount(payment),
|
|
status: 'pending',
|
|
dueDate: payment.dueDate
|
|
}, actorId));
|
|
}
|
|
|
|
await refreshPaymentTotals(payment);
|
|
|
|
if (actorId) {
|
|
eventEmitter.emit(EVENT_NAMES.PAYMENT_CREATED, {
|
|
paymentId: payment._id,
|
|
userId: payment.user,
|
|
actorId
|
|
});
|
|
}
|
|
|
|
if (notify.sms || notify.email || notify.bot) {
|
|
try {
|
|
const { user, courseName, schedule, invoiceCode } = await formatPaymentContext(payment);
|
|
if (user?.phoneNumber) {
|
|
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 notification 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;
|
|
const incomingTransactions = Array.isArray(body.transactions) ? body.transactions : null;
|
|
const payload = { ...body };
|
|
delete payload.transactions;
|
|
|
|
Object.assign(payment, payload);
|
|
if (body.discount !== undefined) {
|
|
payment.discount = normalizeDiscount(body.discount, payment.amount);
|
|
}
|
|
if (body.notes !== undefined) {
|
|
payment.notes = sanitizeNotes(body.notes);
|
|
}
|
|
await payment.save();
|
|
|
|
if (incomingTransactions) {
|
|
await Transaction.deleteMany({ payment: payment._id });
|
|
await createTransactionsForPayment(payment, incomingTransactions, actorId);
|
|
await refreshPaymentTotals(payment);
|
|
}
|
|
|
|
await emitPaymentStatusChangedIfNeeded(payment, previousStatus, actorId);
|
|
|
|
return getPaymentById(payment._id);
|
|
};
|
|
|
|
const deletePayment = async (id) => {
|
|
const payment = await Payment.findByIdAndDelete(id);
|
|
if (!payment) throw new AppError('PAYMENT_NOT_FOUND');
|
|
await Transaction.deleteMany({ payment: id });
|
|
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');
|
|
|
|
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' }, 'مبلغ تراکنش الزامی است.');
|
|
const transaction = await Transaction.create(payload);
|
|
|
|
await reconcilePendingTransactions(payment);
|
|
await refreshPaymentTotals(payment);
|
|
|
|
eventEmitter.emit(EVENT_NAMES.PAYMENT_TRANSACTION_ADDED, {
|
|
paymentId: payment._id,
|
|
userId: payment.user,
|
|
actorId
|
|
});
|
|
await notifyTransactionRecorded(payment, transaction);
|
|
await emitPaymentStatusChangedIfNeeded(payment, previousStatus, actorId);
|
|
|
|
return getPaymentById(paymentId);
|
|
};
|
|
|
|
const updateTransaction = async (transactionId, body, actorId = null) => {
|
|
const trx = await Transaction.findById(transactionId);
|
|
if (!trx) throw new AppError('TRANSACTION_NOT_FOUND');
|
|
if (trx.status === 'cancelled') {
|
|
throw new AppError('VALIDATION_FAILED', {}, 'تراکنش لغوشده قابل ویرایش نیست.');
|
|
}
|
|
|
|
const payment = await Payment.findById(trx.payment);
|
|
if (!payment) throw new AppError('PAYMENT_NOT_FOUND');
|
|
|
|
const previousStatus = payment.status;
|
|
const merged = {
|
|
amount: body.amount !== undefined ? body.amount : trx.amount,
|
|
status: body.status !== undefined ? body.status : trx.status,
|
|
method: body.method !== undefined ? body.method : trx.method,
|
|
receiptNumber: body.receiptNumber !== undefined ? body.receiptNumber : trx.receiptNumber,
|
|
notes: body.notes !== undefined ? body.notes : trx.notes,
|
|
dueDate: body.dueDate !== undefined ? body.dueDate : trx.dueDate,
|
|
date: body.date !== undefined ? body.date : trx.date
|
|
};
|
|
|
|
const payload = buildTransactionPayload(payment, merged, actorId);
|
|
trx.amount = payload.amount;
|
|
trx.status = payload.status;
|
|
trx.receiptNumber = payload.receiptNumber;
|
|
trx.notes = payload.notes;
|
|
trx.dueDate = payload.dueDate;
|
|
if (payload.status === 'paid') {
|
|
trx.method = payload.method;
|
|
trx.date = payload.date;
|
|
} else {
|
|
trx.method = undefined;
|
|
trx.date = undefined;
|
|
trx.receiptNumber = '';
|
|
}
|
|
if (actorId) trx.recordedBy = actorId;
|
|
|
|
await trx.save();
|
|
// Preserve admin-edited amounts; only reconcile balance when recording new payments.
|
|
await reconcilePendingTransactions(payment, { adjustAmount: false });
|
|
await refreshPaymentTotals(payment);
|
|
await emitPaymentStatusChangedIfNeeded(payment, previousStatus, actorId);
|
|
|
|
return getPaymentById(payment._id);
|
|
};
|
|
|
|
const cancelTransaction = async (transactionId, actorId = null) => {
|
|
const trx = await Transaction.findById(transactionId);
|
|
if (!trx) throw new AppError('TRANSACTION_NOT_FOUND');
|
|
if (trx.status === 'cancelled') {
|
|
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 = 'cancelled';
|
|
if (actorId) trx.recordedBy = actorId;
|
|
await trx.save();
|
|
|
|
await refreshPaymentTotals(payment);
|
|
await emitPaymentStatusChangedIfNeeded(payment, previousStatus, actorId);
|
|
|
|
return getPaymentById(payment._id);
|
|
};
|
|
|
|
const getMyPayments = async (userId, query = {}) => {
|
|
return getAllPayments({ ...query, userId });
|
|
};
|
|
|
|
module.exports = {
|
|
getAllPayments,
|
|
getPaymentById,
|
|
createPayment,
|
|
updatePayment,
|
|
deletePayment,
|
|
searchPayments,
|
|
addTransaction,
|
|
updateTransaction,
|
|
cancelTransaction,
|
|
getMyPayments,
|
|
createTransactionsForPayment,
|
|
refreshPaymentTotals,
|
|
buildTransactionPayload,
|
|
reconcilePendingTransactions,
|
|
// Aliases for older call sites
|
|
getAll: getAllPayments,
|
|
getOne: getPaymentById,
|
|
create: createPayment,
|
|
recordTransaction: addTransaction,
|
|
remove: deletePayment
|
|
};
|