feat: store payment installments in a transactions collection
Move embedded payment rows into Transaction documents with due dates so remaining tuition can be tracked separately from paid amounts.
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
'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');
|
||||
@@ -13,9 +14,93 @@ 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 } = require('../../utils/paymentAmount');
|
||||
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 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);
|
||||
@@ -52,22 +137,23 @@ const getAllPayments = async (query) => {
|
||||
Payment.countDocuments(filter)
|
||||
]);
|
||||
|
||||
await attachTransactions(items);
|
||||
return { data: items, meta: calculateMeta(total, page, limit) };
|
||||
};
|
||||
|
||||
const getPaymentById = async (id) => {
|
||||
const payment = await Payment.findById(id)
|
||||
.populate({ path: 'user', select: 'name phoneNumber' })
|
||||
.populate({ path: 'classes', select: 'name tuitionFee' })
|
||||
.populate({ path: 'course', select: 'title price' })
|
||||
.lean();
|
||||
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),
|
||||
@@ -75,6 +161,18 @@ const createPayment = async (body, actorId = null) => {
|
||||
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,
|
||||
@@ -131,7 +229,11 @@ const updatePayment = async (id, body, actorId = null) => {
|
||||
if (!payment) throw new AppError('PAYMENT_NOT_FOUND');
|
||||
|
||||
const previousStatus = payment.status;
|
||||
Object.assign(payment, body);
|
||||
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);
|
||||
}
|
||||
@@ -140,6 +242,12 @@ const updatePayment = async (id, body, actorId = null) => {
|
||||
}
|
||||
await payment.save();
|
||||
|
||||
if (incomingTransactions) {
|
||||
await Transaction.deleteMany({ payment: payment._id });
|
||||
await createTransactionsForPayment(payment, incomingTransactions, actorId);
|
||||
await refreshPaymentTotals(payment);
|
||||
}
|
||||
|
||||
if (body.status && body.status !== previousStatus) {
|
||||
eventEmitter.emit(EVENT_NAMES.PAYMENT_STATUS_CHANGED, {
|
||||
paymentId: payment._id,
|
||||
@@ -156,6 +264,7 @@ const updatePayment = async (id, body, actorId = null) => {
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -165,14 +274,23 @@ const addTransaction = async (paymentId, trxData, actorId = null) => {
|
||||
const payment = await Payment.findById(paymentId);
|
||||
if (!payment) throw new AppError('PAYMENT_NOT_FOUND');
|
||||
|
||||
payment.transactions.push({
|
||||
...trxData,
|
||||
notes: sanitizeNotes(trxData.notes),
|
||||
recordedBy: actorId || trxData.recordedBy,
|
||||
date: trxData.date || new Date()
|
||||
});
|
||||
payment.paidAmount = payment.transactions.reduce((sum, t) => sum + (t.amount || 0), 0);
|
||||
await payment.save();
|
||||
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);
|
||||
|
||||
const remaining = getPayableAmount(payment) - sumPaidTransactions(await Transaction.find({ payment: payment._id }).lean());
|
||||
const pending = await Transaction.find({ payment: payment._id, status: 'pending' }).sort({ dueDate: 1 });
|
||||
if (remaining <= 0) {
|
||||
await Transaction.deleteMany({ payment: payment._id, status: 'pending' });
|
||||
} else 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) } });
|
||||
}
|
||||
}
|
||||
|
||||
await refreshPaymentTotals(payment);
|
||||
return getPaymentById(paymentId);
|
||||
};
|
||||
|
||||
@@ -189,6 +307,9 @@ module.exports = {
|
||||
searchPayments,
|
||||
addTransaction,
|
||||
getMyPayments,
|
||||
createTransactionsForPayment,
|
||||
refreshPaymentTotals,
|
||||
buildTransactionPayload,
|
||||
// Aliases for older call sites
|
||||
getAll: getAllPayments,
|
||||
getOne: getPaymentById,
|
||||
|
||||
Reference in New Issue
Block a user