Files
gameno-api/components/payments/paymentService.js
T
kavehhn 8cd355c24b Fix transaction due date parsing and preserve edited pending amounts.
Parse Jalali date strings correctly when saving transactions, and skip
reconcile amount overwrites when pending totals already match the balance.
2026-08-16 14:53:46 +03:30

398 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 { 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) => {
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) {
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();
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
};