// /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 { sendInvoiceCreatedSms } = require('../../utils/senders/smsMessages'); const { buildClassScheduleContext } = require('../../utils/classSchedule'); const { parseImportDate } = require('../../utils/jalaliDate'); const { pickNotifyFlags, omitNotifyFields } = require('../../utils/notifyFlags'); const { getPayableAmount, normalizeDiscount, sanitizeNotes, isPaidTransaction, sumPaidTransactions } = require('../../utils/paymentAmount'); const logger = require('../../utils/logger'); const PAYMENT_METHODS = new Set(['online', 'card', 'cash']); 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) => !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 = (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 }); }; 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 = pickNotifyFlags(body); 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) { 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: getPayableAmount(payment), 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; 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); } 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' }, 'مبلغ تراکنش الزامی است.'); await Transaction.create(payload); await reconcilePendingTransactions(payment); await refreshPaymentTotals(payment); eventEmitter.emit(EVENT_NAMES.PAYMENT_TRANSACTION_ADDED, { paymentId: payment._id, userId: payment.user, actorId }); 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'); 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); 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, getMyPayments, createTransactionsForPayment, refreshPaymentTotals, buildTransactionPayload, reconcilePendingTransactions, // Aliases for older call sites getAll: getAllPayments, getOne: getPaymentById, create: createPayment, recordTransaction: addTransaction, remove: deletePayment };