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:
@@ -18,8 +18,8 @@ exports.importJson = catchAsync(async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Allow { data: { courses: [...] } } wrappers
|
// Allow { data: { courses: [...] } } wrappers
|
||||||
if (payload?.data?.courses) payload = payload.data;
|
if (payload?.data?.courses || payload?.data?.payments) payload = payload.data;
|
||||||
if (!payload?.courses && payload?.version && payload?.courses == null) {
|
if (!payload?.courses && !payload?.payments) {
|
||||||
throw new AppError('VALIDATION_FAILED', null, 'Invalid import payload');
|
throw new AppError('VALIDATION_FAILED', null, 'Invalid import payload');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ const User = require('../users/userModel');
|
|||||||
const Role = require('../roles/roleModel');
|
const Role = require('../roles/roleModel');
|
||||||
const Session = require('../sessions/sessionModel');
|
const Session = require('../sessions/sessionModel');
|
||||||
const Payment = require('../payments/paymentModel');
|
const Payment = require('../payments/paymentModel');
|
||||||
|
const Transaction = require('../payments/transactionModel');
|
||||||
|
const paymentService = require('../payments/paymentService');
|
||||||
const AppError = require('../../utils/AppError');
|
const AppError = require('../../utils/AppError');
|
||||||
const { generateUsername, generateSimplePassword } = require('../../utils/credentials');
|
const { generateUsername, generateSimplePassword } = require('../../utils/credentials');
|
||||||
const { mergeFullName, normalizeGender } = require('../../utils/userProfile');
|
const { mergeFullName, normalizeGender } = require('../../utils/userProfile');
|
||||||
@@ -21,7 +23,6 @@ const {
|
|||||||
} = require('./importHelpers');
|
} = require('./importHelpers');
|
||||||
|
|
||||||
const ATTENDANCE_STATUSES = new Set(['present', 'absent', 'late', 'excused']);
|
const ATTENDANCE_STATUSES = new Set(['present', 'absent', 'late', 'excused']);
|
||||||
const PAYMENT_METHODS = new Set(['online', 'card', 'cash']);
|
|
||||||
|
|
||||||
const escapeRegex = (value) => String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
const escapeRegex = (value) => String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
|
||||||
@@ -286,43 +287,100 @@ const importPayments = async (classInput, course, cls, stats, warnings) => {
|
|||||||
if (!user) continue;
|
if (!user) continue;
|
||||||
await enrollUserInClass(user, course, cls, stats);
|
await enrollUserInClass(user, course, cls, stats);
|
||||||
|
|
||||||
const transactions = (Array.isArray(paymentInput.transactions) ? paymentInput.transactions : [])
|
const transactions = Array.isArray(paymentInput.transactions) ? paymentInput.transactions : [];
|
||||||
.map((trx) => ({
|
const pendingDue = transactions.find((trx) => trx.status === 'pending')?.dueDate;
|
||||||
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 payload = {
|
const payload = {
|
||||||
user: user._id,
|
user: user._id,
|
||||||
classes: [cls._id],
|
classes: [cls._id],
|
||||||
course: course._id,
|
course: course._id,
|
||||||
amount,
|
amount,
|
||||||
discount: Number(paymentInput.discount) || 0,
|
discount: Number(paymentInput.discount) || 0,
|
||||||
paidAmount: transactions.reduce((sum, trx) => sum + trx.amount, 0),
|
paidAmount: 0,
|
||||||
transactions,
|
|
||||||
notes: paymentInput.notes || '',
|
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 existing = await Payment.findOne({ user: user._id, classes: cls._id });
|
||||||
|
const existingTrxCount = existing ? await Transaction.countDocuments({ payment: existing._id }) : 0;
|
||||||
|
|
||||||
if (!existing) {
|
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;
|
stats.paymentsCreated += 1;
|
||||||
} else if (!existing.transactions?.length) {
|
stats.transactionsCreated += created.length;
|
||||||
|
} else if (existingTrxCount === 0) {
|
||||||
Object.assign(existing, payload);
|
Object.assign(existing, payload);
|
||||||
await existing.save();
|
await existing.save();
|
||||||
|
const created = await paymentService.createTransactionsForPayment(existing, transactions);
|
||||||
|
await paymentService.refreshPaymentTotals(existing);
|
||||||
stats.paymentsUpdated += 1;
|
stats.paymentsUpdated += 1;
|
||||||
|
stats.transactionsCreated += created.length;
|
||||||
} else {
|
} else {
|
||||||
stats.paymentsReused += 1;
|
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)) {
|
if (!payload || !Array.isArray(payload.courses)) {
|
||||||
throw new AppError('VALIDATION_FAILED', null, 'Invalid import payload: courses array required');
|
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' });
|
const userRole = await Role.findOne({ name: 'User' });
|
||||||
if (!userRole) throw new AppError('DEFAULT_ROLE_NOT_FOUND');
|
if (!userRole) throw new AppError('DEFAULT_ROLE_NOT_FOUND');
|
||||||
|
|
||||||
|
await migrateEmbeddedPaymentTransactions();
|
||||||
|
|
||||||
const stats = {
|
const stats = {
|
||||||
coursesCreated: 0,
|
coursesCreated: 0,
|
||||||
coursesReused: 0,
|
coursesReused: 0,
|
||||||
@@ -346,7 +406,8 @@ const importData = async (payload) => {
|
|||||||
paymentsCreated: 0,
|
paymentsCreated: 0,
|
||||||
paymentsUpdated: 0,
|
paymentsUpdated: 0,
|
||||||
paymentsReused: 0,
|
paymentsReused: 0,
|
||||||
paymentsSkipped: 0
|
paymentsSkipped: 0,
|
||||||
|
transactionsCreated: 0
|
||||||
};
|
};
|
||||||
const warnings = [];
|
const warnings = [];
|
||||||
|
|
||||||
|
|||||||
@@ -4,19 +4,7 @@
|
|||||||
const mongoose = require('mongoose');
|
const mongoose = require('mongoose');
|
||||||
|
|
||||||
const { getPayableAmount, normalizeDiscount } = require('../../utils/paymentAmount');
|
const { getPayableAmount, normalizeDiscount } = require('../../utils/paymentAmount');
|
||||||
|
require('./transactionModel');
|
||||||
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 });
|
|
||||||
|
|
||||||
const paymentSchema = new mongoose.Schema({
|
const paymentSchema = new mongoose.Schema({
|
||||||
user: {
|
user: {
|
||||||
@@ -56,13 +44,19 @@ const paymentSchema = new mongoose.Schema({
|
|||||||
enum: ['pending', 'partial', 'paid', 'overdue'],
|
enum: ['pending', 'partial', 'paid', 'overdue'],
|
||||||
default: 'pending'
|
default: 'pending'
|
||||||
},
|
},
|
||||||
transactions: [transactionSchema],
|
|
||||||
notes: { type: String, trim: true, maxlength: 5000 }
|
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) {
|
paymentSchema.pre('save', function (next) {
|
||||||
this.discount = normalizeDiscount(this.discount, this.amount);
|
this.discount = normalizeDiscount(this.discount, this.amount);
|
||||||
const payable = getPayableAmount(this);
|
const payable = getPayableAmount(this);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const Payment = require('./paymentModel');
|
const Payment = require('./paymentModel');
|
||||||
|
const Transaction = require('./transactionModel');
|
||||||
const AppError = require('../../utils/AppError');
|
const AppError = require('../../utils/AppError');
|
||||||
const eventEmitter = require('../../events/eventEmitter');
|
const eventEmitter = require('../../events/eventEmitter');
|
||||||
const EVENT_NAMES = require('../../constants/eventNames');
|
const EVENT_NAMES = require('../../constants/eventNames');
|
||||||
@@ -13,9 +14,93 @@ const Session = require('../sessions/sessionModel');
|
|||||||
const { sendInvoiceCreatedSms } = require('../../utils/senders/smsMessages');
|
const { sendInvoiceCreatedSms } = require('../../utils/senders/smsMessages');
|
||||||
const { buildClassScheduleContext } = require('../../utils/classSchedule');
|
const { buildClassScheduleContext } = require('../../utils/classSchedule');
|
||||||
const { pickNotifyFlags, omitNotifyFields } = require('../../utils/notifyFlags');
|
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 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 getAllPayments = async (query) => {
|
||||||
const page = parseInt(query.page) || 1;
|
const page = parseInt(query.page) || 1;
|
||||||
const limit = Math.min(parseInt(query.limit) || 20, 200);
|
const limit = Math.min(parseInt(query.limit) || 20, 200);
|
||||||
@@ -52,22 +137,23 @@ const getAllPayments = async (query) => {
|
|||||||
Payment.countDocuments(filter)
|
Payment.countDocuments(filter)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
await attachTransactions(items);
|
||||||
return { data: items, meta: calculateMeta(total, page, limit) };
|
return { data: items, meta: calculateMeta(total, page, limit) };
|
||||||
};
|
};
|
||||||
|
|
||||||
const getPaymentById = async (id) => {
|
const getPaymentById = async (id) => {
|
||||||
const payment = await Payment.findById(id)
|
const payment = await populatePayment(Payment.findById(id)).lean();
|
||||||
.populate({ path: 'user', select: 'name phoneNumber' })
|
|
||||||
.populate({ path: 'classes', select: 'name tuitionFee' })
|
|
||||||
.populate({ path: 'course', select: 'title price' })
|
|
||||||
.lean();
|
|
||||||
if (!payment) throw new AppError('PAYMENT_NOT_FOUND');
|
if (!payment) throw new AppError('PAYMENT_NOT_FOUND');
|
||||||
|
if (!Array.isArray(payment.transactions)) payment.transactions = [];
|
||||||
return payment;
|
return payment;
|
||||||
};
|
};
|
||||||
|
|
||||||
const createPayment = async (body, actorId = null) => {
|
const createPayment = async (body, actorId = null) => {
|
||||||
const notify = pickNotifyFlags(body);
|
const notify = pickNotifyFlags(body);
|
||||||
const payload = omitNotifyFields(body);
|
const payload = omitNotifyFields(body);
|
||||||
|
const incomingTransactions = Array.isArray(payload.transactions) ? payload.transactions : null;
|
||||||
|
delete payload.transactions;
|
||||||
|
|
||||||
const payment = await Payment.create({
|
const payment = await Payment.create({
|
||||||
...payload,
|
...payload,
|
||||||
discount: normalizeDiscount(payload.discount, payload.amount),
|
discount: normalizeDiscount(payload.discount, payload.amount),
|
||||||
@@ -75,6 +161,18 @@ const createPayment = async (body, actorId = null) => {
|
|||||||
paidAmount: payload.paidAmount || 0
|
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) {
|
if (actorId) {
|
||||||
eventEmitter.emit(EVENT_NAMES.PAYMENT_CREATED, {
|
eventEmitter.emit(EVENT_NAMES.PAYMENT_CREATED, {
|
||||||
paymentId: payment._id,
|
paymentId: payment._id,
|
||||||
@@ -131,7 +229,11 @@ const updatePayment = async (id, body, actorId = null) => {
|
|||||||
if (!payment) throw new AppError('PAYMENT_NOT_FOUND');
|
if (!payment) throw new AppError('PAYMENT_NOT_FOUND');
|
||||||
|
|
||||||
const previousStatus = payment.status;
|
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) {
|
if (body.discount !== undefined) {
|
||||||
payment.discount = normalizeDiscount(body.discount, payment.amount);
|
payment.discount = normalizeDiscount(body.discount, payment.amount);
|
||||||
}
|
}
|
||||||
@@ -140,6 +242,12 @@ const updatePayment = async (id, body, actorId = null) => {
|
|||||||
}
|
}
|
||||||
await payment.save();
|
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) {
|
if (body.status && body.status !== previousStatus) {
|
||||||
eventEmitter.emit(EVENT_NAMES.PAYMENT_STATUS_CHANGED, {
|
eventEmitter.emit(EVENT_NAMES.PAYMENT_STATUS_CHANGED, {
|
||||||
paymentId: payment._id,
|
paymentId: payment._id,
|
||||||
@@ -156,6 +264,7 @@ const updatePayment = async (id, body, actorId = null) => {
|
|||||||
const deletePayment = async (id) => {
|
const deletePayment = async (id) => {
|
||||||
const payment = await Payment.findByIdAndDelete(id);
|
const payment = await Payment.findByIdAndDelete(id);
|
||||||
if (!payment) throw new AppError('PAYMENT_NOT_FOUND');
|
if (!payment) throw new AppError('PAYMENT_NOT_FOUND');
|
||||||
|
await Transaction.deleteMany({ payment: id });
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -165,14 +274,23 @@ const addTransaction = async (paymentId, trxData, actorId = null) => {
|
|||||||
const payment = await Payment.findById(paymentId);
|
const payment = await Payment.findById(paymentId);
|
||||||
if (!payment) throw new AppError('PAYMENT_NOT_FOUND');
|
if (!payment) throw new AppError('PAYMENT_NOT_FOUND');
|
||||||
|
|
||||||
payment.transactions.push({
|
const payload = buildTransactionPayload(payment, { ...trxData, status: trxData.status || 'paid' }, actorId);
|
||||||
...trxData,
|
if (!payload.amount) throw new AppError('VALIDATION_FAILED', { amount: 'Amount is required' }, 'مبلغ تراکنش الزامی است.');
|
||||||
notes: sanitizeNotes(trxData.notes),
|
await Transaction.create(payload);
|
||||||
recordedBy: actorId || trxData.recordedBy,
|
|
||||||
date: trxData.date || new Date()
|
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 });
|
||||||
payment.paidAmount = payment.transactions.reduce((sum, t) => sum + (t.amount || 0), 0);
|
if (remaining <= 0) {
|
||||||
await payment.save();
|
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);
|
return getPaymentById(paymentId);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -189,6 +307,9 @@ module.exports = {
|
|||||||
searchPayments,
|
searchPayments,
|
||||||
addTransaction,
|
addTransaction,
|
||||||
getMyPayments,
|
getMyPayments,
|
||||||
|
createTransactionsForPayment,
|
||||||
|
refreshPaymentTotals,
|
||||||
|
buildTransactionPayload,
|
||||||
// Aliases for older call sites
|
// Aliases for older call sites
|
||||||
getAll: getAllPayments,
|
getAll: getAllPayments,
|
||||||
getOne: getPaymentById,
|
getOne: getPaymentById,
|
||||||
|
|||||||
@@ -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);
|
||||||
+26
-1
@@ -22,9 +22,34 @@ const sanitizeNotes = (notes) => {
|
|||||||
return String(notes).trim().slice(0, NOTES_MAX_LENGTH);
|
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 = {
|
module.exports = {
|
||||||
NOTES_MAX_LENGTH,
|
NOTES_MAX_LENGTH,
|
||||||
getPayableAmount,
|
getPayableAmount,
|
||||||
normalizeDiscount,
|
normalizeDiscount,
|
||||||
sanitizeNotes
|
sanitizeNotes,
|
||||||
|
rialsToToman,
|
||||||
|
isPaidTransaction,
|
||||||
|
sumPaidTransactions,
|
||||||
|
remainingPayable
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,7 +6,11 @@ const {
|
|||||||
getPayableAmount,
|
getPayableAmount,
|
||||||
normalizeDiscount,
|
normalizeDiscount,
|
||||||
sanitizeNotes,
|
sanitizeNotes,
|
||||||
NOTES_MAX_LENGTH
|
NOTES_MAX_LENGTH,
|
||||||
|
rialsToToman,
|
||||||
|
isPaidTransaction,
|
||||||
|
sumPaidTransactions,
|
||||||
|
remainingPayable
|
||||||
} = require('./paymentAmount');
|
} = require('./paymentAmount');
|
||||||
|
|
||||||
describe('payment amount helpers', () => {
|
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', () => {
|
describe('sanitizeNotes', () => {
|
||||||
it('trims notes and caps length', () => {
|
it('trims notes and caps length', () => {
|
||||||
assert.equal(sanitizeNotes(' hello '), 'hello');
|
assert.equal(sanitizeNotes(' hello '), 'hello');
|
||||||
|
|||||||
Reference in New Issue
Block a user