Allow admins to edit transaction amount and status, recalculate payment totals, and mark invoices fully paid when paid transactions cover the payable amount.
385 lines
13 KiB
JavaScript
385 lines
13 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 { sendInvoiceCreatedSms } = require('../../utils/senders/smsMessages');
|
|
const { buildClassScheduleContext } = require('../../utils/classSchedule');
|
|
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) => {
|
|
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) {
|
|
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;
|
|
const date = value instanceof Date ? value : new Date(value);
|
|
return Number.isNaN(date.getTime()) ? null : date;
|
|
};
|
|
|
|
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.method = payload.method;
|
|
trx.receiptNumber = payload.receiptNumber;
|
|
trx.notes = payload.notes;
|
|
trx.dueDate = payload.dueDate;
|
|
trx.date = payload.date;
|
|
if (actorId) trx.recordedBy = actorId;
|
|
|
|
await trx.save();
|
|
await reconcilePendingTransactions(payment);
|
|
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
|
|
};
|