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