diff --git a/components/dataImport/dataImportController.js b/components/dataImport/dataImportController.js index 8f00c34..6f94580 100644 --- a/components/dataImport/dataImportController.js +++ b/components/dataImport/dataImportController.js @@ -18,8 +18,8 @@ exports.importJson = catchAsync(async (req, res) => { } // Allow { data: { courses: [...] } } wrappers - if (payload?.data?.courses) payload = payload.data; - if (!payload?.courses && payload?.version && payload?.courses == null) { + if (payload?.data?.courses || payload?.data?.payments) payload = payload.data; + if (!payload?.courses && !payload?.payments) { throw new AppError('VALIDATION_FAILED', null, 'Invalid import payload'); } diff --git a/components/dataImport/dataImportService.js b/components/dataImport/dataImportService.js index e82ba0e..9babf0f 100644 --- a/components/dataImport/dataImportService.js +++ b/components/dataImport/dataImportService.js @@ -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 = []; diff --git a/components/payments/paymentModel.js b/components/payments/paymentModel.js index 7f3552a..aaf8397 100644 --- a/components/payments/paymentModel.js +++ b/components/payments/paymentModel.js @@ -4,19 +4,7 @@ const mongoose = require('mongoose'); const { getPayableAmount, normalizeDiscount } = require('../../utils/paymentAmount'); - -const transactionSchema = new mongoose.Schema({ - amount: { type: Number, required: true }, - method: { - type: String, - enum: ['online', 'card', 'cash'], - default: 'card' - }, - receiptNumber: { type: String, trim: true }, - notes: { type: String, trim: true, maxlength: 5000 }, - recordedBy: { type: mongoose.Schema.Types.ObjectId, ref: 'User' }, - date: { type: Date, default: Date.now } -}, { _id: true }); +require('./transactionModel'); const paymentSchema = new mongoose.Schema({ user: { @@ -56,13 +44,19 @@ const paymentSchema = new mongoose.Schema({ enum: ['pending', 'partial', 'paid', 'overdue'], default: 'pending' }, - transactions: [transactionSchema], notes: { type: String, trim: true, maxlength: 5000 } }, { - timestamps: true + timestamps: true, + toJSON: { virtuals: true }, + toObject: { virtuals: true } +}); + +paymentSchema.virtual('transactions', { + ref: 'Transaction', + localField: '_id', + foreignField: 'payment' }); -// Auto-update status based on paid amount vs payable (amount - discount) paymentSchema.pre('save', function (next) { this.discount = normalizeDiscount(this.discount, this.amount); const payable = getPayableAmount(this); diff --git a/components/payments/paymentService.js b/components/payments/paymentService.js index 5055eef..bc10d91 100644 --- a/components/payments/paymentService.js +++ b/components/payments/paymentService.js @@ -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, diff --git a/components/payments/transactionModel.js b/components/payments/transactionModel.js new file mode 100644 index 0000000..1dd1ad1 --- /dev/null +++ b/components/payments/transactionModel.js @@ -0,0 +1,48 @@ +// /components/payments/transactionModel.js +'use strict'; + +const mongoose = require('mongoose'); + +const transactionSchema = new mongoose.Schema({ + payment: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Payment', + required: true, + index: true + }, + user: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + index: true + }, + amount: { + type: Number, + required: true, + min: 0 + }, + method: { + type: String, + enum: ['online', 'card', 'cash'] + }, + receiptNumber: { type: String, trim: true }, + notes: { type: String, trim: true, maxlength: 5000 }, + recordedBy: { type: mongoose.Schema.Types.ObjectId, ref: 'User' }, + date: { type: Date }, + dueDate: { + type: Date, + required: true, + index: true + }, + status: { + type: String, + enum: ['pending', 'paid'], + default: 'pending', + index: true + } +}, { + timestamps: true +}); + +transactionSchema.index({ payment: 1, dueDate: 1 }); + +module.exports = mongoose.model('Transaction', transactionSchema); diff --git a/utils/paymentAmount.js b/utils/paymentAmount.js index f85fd09..6b8ec5b 100644 --- a/utils/paymentAmount.js +++ b/utils/paymentAmount.js @@ -22,9 +22,34 @@ const sanitizeNotes = (notes) => { return String(notes).trim().slice(0, NOTES_MAX_LENGTH); }; +const rialsToToman = (value) => Math.floor(toNonNegativeNumber(value) / 10); + +const isPaidTransaction = (trx = {}) => { + const status = String(trx.status || '').toLowerCase(); + if (status === 'pending') return false; + if (status === 'paid') return true; + return Boolean(trx.date); +}; + +const sumPaidTransactions = (transactions = []) => { + if (!Array.isArray(transactions)) return 0; + return transactions.reduce((sum, trx) => { + if (!isPaidTransaction(trx)) return sum; + return sum + toNonNegativeNumber(trx.amount); + }, 0); +}; + +const remainingPayable = (payment = {}, transactions = []) => { + return Math.max(0, getPayableAmount(payment) - sumPaidTransactions(transactions)); +}; + module.exports = { NOTES_MAX_LENGTH, getPayableAmount, normalizeDiscount, - sanitizeNotes + sanitizeNotes, + rialsToToman, + isPaidTransaction, + sumPaidTransactions, + remainingPayable }; diff --git a/utils/paymentAmount.test.js b/utils/paymentAmount.test.js index 2b06e53..ca5ed9f 100644 --- a/utils/paymentAmount.test.js +++ b/utils/paymentAmount.test.js @@ -6,7 +6,11 @@ const { getPayableAmount, normalizeDiscount, sanitizeNotes, - NOTES_MAX_LENGTH + NOTES_MAX_LENGTH, + rialsToToman, + isPaidTransaction, + sumPaidTransactions, + remainingPayable } = require('./paymentAmount'); describe('payment amount helpers', () => { @@ -36,6 +40,41 @@ describe('payment amount helpers', () => { }); }); +describe('rialsToToman', () => { + it('drops one zero so spreadsheet Rials become website Toman', () => { + assert.equal(rialsToToman(110_000_000), 11_000_000); + assert.equal(rialsToToman(5_000_000), 500_000); + assert.equal(rialsToToman(0), 0); + }); +}); + +describe('paid vs pending transactions', () => { + it('counts only paid transactions toward the paid total', () => { + const transactions = [ + { amount: 8_000_000, status: 'paid', date: '2026-08-03' }, + { amount: 3_000_000, status: 'pending', dueDate: '2026-08-23' } + ]; + assert.equal(sumPaidTransactions(transactions), 8_000_000); + assert.equal( + remainingPayable({ amount: 11_000_000, discount: 0 }, transactions), + 3_000_000 + ); + }); + + it('treats a 10m billed course as 1m off an 11m tuition', () => { + assert.equal(getPayableAmount({ amount: 11_000_000, discount: 1_000_000 }), 10_000_000); + }); + + it('treats an 8m billed course as 3m off an 11m tuition', () => { + assert.equal(getPayableAmount({ amount: 11_000_000, discount: 3_000_000 }), 8_000_000); + }); + + it('does not treat pending remainder rows as paid', () => { + assert.equal(isPaidTransaction({ status: 'pending', amount: 2_000_000 }), false); + assert.equal(isPaidTransaction({ status: 'paid', amount: 8_000_000 }), true); + }); +}); + describe('sanitizeNotes', () => { it('trims notes and caps length', () => { assert.equal(sanitizeNotes(' hello '), 'hello');