Files
gameno-api/components/payments/paymentService.js
T

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
};