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:
2026-08-16 07:32:27 +03:30
parent b4ca5678bf
commit 06efcd5b90
7 changed files with 341 additions and 53 deletions
+79 -18
View File
@@ -8,6 +8,8 @@ const User = require('../users/userModel');
const Role = require('../roles/roleModel');
const Session = require('../sessions/sessionModel');
const Payment = require('../payments/paymentModel');
const Transaction = require('../payments/transactionModel');
const paymentService = require('../payments/paymentService');
const AppError = require('../../utils/AppError');
const { generateUsername, generateSimplePassword } = require('../../utils/credentials');
const { mergeFullName, normalizeGender } = require('../../utils/userProfile');
@@ -21,7 +23,6 @@ const {
} = require('./importHelpers');
const ATTENDANCE_STATUSES = new Set(['present', 'absent', 'late', 'excused']);
const PAYMENT_METHODS = new Set(['online', 'card', 'cash']);
const escapeRegex = (value) => String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
@@ -286,43 +287,100 @@ const importPayments = async (classInput, course, cls, stats, warnings) => {
if (!user) continue;
await enrollUserInClass(user, course, cls, stats);
const transactions = (Array.isArray(paymentInput.transactions) ? paymentInput.transactions : [])
.map((trx) => ({
amount: Number(trx.amount) || 0,
method: PAYMENT_METHODS.has(trx.method) ? trx.method : 'card',
receiptNumber: trx.receiptNumber != null && trx.receiptNumber !== '' ? String(trx.receiptNumber) : '',
notes: trx.notes || '',
date: parseImportDate(trx.date) || new Date()
}))
.filter((trx) => trx.amount > 0);
const transactions = Array.isArray(paymentInput.transactions) ? paymentInput.transactions : [];
const pendingDue = transactions.find((trx) => trx.status === 'pending')?.dueDate;
const payload = {
user: user._id,
classes: [cls._id],
course: course._id,
amount,
discount: Number(paymentInput.discount) || 0,
paidAmount: transactions.reduce((sum, trx) => sum + trx.amount, 0),
transactions,
paidAmount: 0,
notes: paymentInput.notes || '',
dueDate: parseImportDate(paymentInput.dueDate) || undefined
dueDate: parseImportDate(paymentInput.dueDate) || parseImportDate(pendingDue) || undefined
};
const existing = await Payment.findOne({ user: user._id, classes: cls._id });
const existingTrxCount = existing ? await Transaction.countDocuments({ payment: existing._id }) : 0;
if (!existing) {
await Payment.create(payload);
const payment = await Payment.create(payload);
const created = await paymentService.createTransactionsForPayment(payment, transactions);
await paymentService.refreshPaymentTotals(payment);
stats.paymentsCreated += 1;
} else if (!existing.transactions?.length) {
stats.transactionsCreated += created.length;
} else if (existingTrxCount === 0) {
Object.assign(existing, payload);
await existing.save();
const created = await paymentService.createTransactionsForPayment(existing, transactions);
await paymentService.refreshPaymentTotals(existing);
stats.paymentsUpdated += 1;
stats.transactionsCreated += created.length;
} else {
stats.paymentsReused += 1;
}
}
};
const importData = async (payload) => {
const normalizeImportPayload = (payload) => {
if (!payload || Array.isArray(payload.courses)) return payload;
if (!Array.isArray(payload.payments)) return payload;
const useTopLevelTransactions = Array.isArray(payload.transactions);
const payments = payload.payments.map((payment, index) => {
const id = String(payment.id || payment.paymentId || payment.phoneNumber || index);
return {
...payment,
id,
transactions: useTopLevelTransactions
? []
: (Array.isArray(payment.transactions) ? payment.transactions : [])
};
});
const byId = new Map(payments.map((payment) => [payment.id, payment]));
if (useTopLevelTransactions) {
for (const trx of payload.transactions) {
const payment = byId.get(String(trx.paymentId || trx.payment || ''));
if (payment) payment.transactions.push(trx);
}
}
return {
...payload,
courses: [{
title: String(payload.courseTitle || payload.course || '').trim(),
type: 'General',
classes: [{
name: String(payload.className || payload.class || '').trim(),
payments
}]
}]
};
};
const migrateEmbeddedPaymentTransactions = async () => {
const docs = await Payment.collection.find({ 'transactions.0': { $exists: true } }).toArray();
for (const doc of docs) {
const existingCount = await Transaction.countDocuments({ payment: doc._id });
if (existingCount === 0) {
await paymentService.createTransactionsForPayment(
{ _id: doc._id, user: doc.user, dueDate: doc.dueDate },
(doc.transactions || []).map((trx) => ({
...trx,
status: trx.status || 'paid',
dueDate: trx.dueDate || trx.date || doc.dueDate
}))
);
const payment = await Payment.findById(doc._id);
if (payment) await paymentService.refreshPaymentTotals(payment);
}
await Payment.collection.updateOne({ _id: doc._id }, { $unset: { transactions: 1 } });
}
};
const importData = async (rawPayload) => {
const payload = normalizeImportPayload(rawPayload);
if (!payload || !Array.isArray(payload.courses)) {
throw new AppError('VALIDATION_FAILED', null, 'Invalid import payload: courses array required');
}
@@ -330,6 +388,8 @@ const importData = async (payload) => {
const userRole = await Role.findOne({ name: 'User' });
if (!userRole) throw new AppError('DEFAULT_ROLE_NOT_FOUND');
await migrateEmbeddedPaymentTransactions();
const stats = {
coursesCreated: 0,
coursesReused: 0,
@@ -346,7 +406,8 @@ const importData = async (payload) => {
paymentsCreated: 0,
paymentsUpdated: 0,
paymentsReused: 0,
paymentsSkipped: 0
paymentsSkipped: 0,
transactionsCreated: 0
};
const warnings = [];