Initial commit: teaching institution management API.
Express/MongoDB backend with JWT auth, RBAC, S3 uploads, notifications, and scheduled jobs.
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
// /components/payments/paymentController.js
|
||||
|
||||
const catchAsync = require('../../utils/catchAsync');
|
||||
const paymentService = require('./paymentService');
|
||||
const { successResponse, listResponse } = require('../../utils/apiResponse');
|
||||
|
||||
exports.create = catchAsync(async (req, res, next) => {
|
||||
const actorId = req.user?._id;
|
||||
const payment = await paymentService.createPayment(req.body, actorId);
|
||||
return successResponse(res, 201, 'Payment created successfully', payment);
|
||||
});
|
||||
|
||||
exports.getOne = catchAsync(async (req, res, next) => {
|
||||
const payment = await paymentService.getPaymentById(req.params.id);
|
||||
return successResponse(res, 200, 'Payment retrieved successfully', payment);
|
||||
});
|
||||
|
||||
exports.getAll = catchAsync(async (req, res, next) => {
|
||||
const { data, meta } = await paymentService.getAllPayments(req.query);
|
||||
return listResponse(res, 200, data, meta);
|
||||
});
|
||||
|
||||
exports.update = catchAsync(async (req, res, next) => {
|
||||
const actorId = req.user?._id;
|
||||
const payment = await paymentService.updatePayment(req.params.id, req.body, actorId);
|
||||
return successResponse(res, 200, 'Payment updated successfully', payment);
|
||||
});
|
||||
|
||||
exports.delete = catchAsync(async (req, res, next) => {
|
||||
await paymentService.deletePayment(req.params.id);
|
||||
return successResponse(res, 200, 'Payment deleted successfully');
|
||||
});
|
||||
|
||||
exports.search = catchAsync(async (req, res, next) => {
|
||||
const { data, meta } = await paymentService.searchPayments(req.query);
|
||||
return listResponse(res, 200, data, meta);
|
||||
});
|
||||
|
||||
exports.payUser = catchAsync(async (req, res, next) => {
|
||||
const actorId = req.user?._id;
|
||||
const payment = await paymentService.addTransaction(req.params.id, req.body, actorId);
|
||||
return successResponse(res, 200, 'Payment transaction recorded', payment);
|
||||
});
|
||||
|
||||
exports.getMyPayments = catchAsync(async (req, res, next) => {
|
||||
const { data, meta } = await paymentService.getMyPayments(req.user._id, req.query);
|
||||
return listResponse(res, 200, data, meta);
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
// /components/payments/paymentModel.js
|
||||
'use strict';
|
||||
|
||||
const mongoose = require('mongoose');
|
||||
|
||||
const transactionSchema = new mongoose.Schema({
|
||||
amount: { type: Number, required: true },
|
||||
method: {
|
||||
type: String,
|
||||
enum: ['online', 'card', 'cash'],
|
||||
default: 'card'
|
||||
},
|
||||
receiptNumber: { type: String, trim: true },
|
||||
recordedBy: { type: mongoose.Schema.Types.ObjectId, ref: 'User' },
|
||||
date: { type: Date, default: Date.now }
|
||||
}, { _id: true });
|
||||
|
||||
const paymentSchema = new mongoose.Schema({
|
||||
user: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'User',
|
||||
required: true,
|
||||
index: true
|
||||
},
|
||||
classes: [{
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'Class'
|
||||
}],
|
||||
course: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'Course'
|
||||
},
|
||||
amount: {
|
||||
type: Number,
|
||||
required: true,
|
||||
min: 0
|
||||
},
|
||||
paidAmount: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
min: 0
|
||||
},
|
||||
dueDate: {
|
||||
type: Date
|
||||
},
|
||||
status: {
|
||||
type: String,
|
||||
enum: ['pending', 'partial', 'paid', 'overdue'],
|
||||
default: 'pending'
|
||||
},
|
||||
transactions: [transactionSchema],
|
||||
notes: { type: String, trim: true }
|
||||
}, {
|
||||
timestamps: true
|
||||
});
|
||||
|
||||
// Auto-update status based on paid amount
|
||||
paymentSchema.pre('save', function (next) {
|
||||
if (this.paidAmount >= this.amount) {
|
||||
this.status = 'paid';
|
||||
} else if (this.paidAmount > 0) {
|
||||
this.status = 'partial';
|
||||
} else if (this.dueDate && new Date() > this.dueDate) {
|
||||
this.status = 'overdue';
|
||||
} else {
|
||||
this.status = 'pending';
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
module.exports = mongoose.model('Payment', paymentSchema);
|
||||
@@ -0,0 +1,30 @@
|
||||
// /components/payments/paymentRoutes.js
|
||||
|
||||
const express = require('express');
|
||||
const paymentController = require('./paymentController');
|
||||
const {
|
||||
validateCreatePayment,
|
||||
validateUpdatePayment,
|
||||
validateAddTransaction
|
||||
} = require('./paymentValidator');
|
||||
const authMiddleware = require('../../middlewares/authMiddleware');
|
||||
const perm = require('../../middlewares/permissionMiddleware');
|
||||
const { PERMISSIONS } = require('../../constants/permissions');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.use(authMiddleware);
|
||||
|
||||
// User Scope
|
||||
router.get('/user/my-payments', paymentController.getMyPayments);
|
||||
router.post('/user/pay/:id', validateAddTransaction, paymentController.payUser);
|
||||
|
||||
// Admin Scope
|
||||
router.post('/admin/create', perm.requires(PERMISSIONS.PAYMENTS_CREATE), validateCreatePayment, paymentController.create);
|
||||
router.get('/admin/get-all', perm.requires(PERMISSIONS.PAYMENTS_READ), paymentController.getAll);
|
||||
router.get('/admin/search', perm.requires(PERMISSIONS.PAYMENTS_SEARCH), paymentController.search);
|
||||
router.get('/admin/get-one/:id', perm.requires(PERMISSIONS.PAYMENTS_READ), paymentController.getOne);
|
||||
router.put('/admin/update/:id', perm.requires(PERMISSIONS.PAYMENTS_UPDATE), validateUpdatePayment, paymentController.update);
|
||||
router.delete('/admin/delete/:id', perm.requires(PERMISSIONS.PAYMENTS_DELETE), paymentController.delete);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,120 @@
|
||||
// /components/payments/paymentService.js
|
||||
'use strict';
|
||||
|
||||
const Payment = require('./paymentModel');
|
||||
const AppError = require('../../utils/AppError');
|
||||
const eventEmitter = require('../../events/eventEmitter');
|
||||
const EVENT_NAMES = require('../../constants/eventNames');
|
||||
const { calculateMeta } = require('../../utils/pagination');
|
||||
|
||||
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 [items, total] = await Promise.all([
|
||||
Payment.find(filter)
|
||||
.populate({ path: 'user', select: 'name surname' })
|
||||
.populate({ path: 'classes', select: 'name' })
|
||||
.populate({ path: 'course', select: 'title' })
|
||||
.skip(skip).limit(limit).sort({ createdAt: -1 }).lean(),
|
||||
Payment.countDocuments(filter)
|
||||
]);
|
||||
|
||||
return { data: items, meta: calculateMeta(total, page, limit) };
|
||||
};
|
||||
|
||||
const getPaymentById = async (id) => {
|
||||
const payment = await Payment.findById(id)
|
||||
.populate({ path: 'user', select: 'name surname phoneNumber' })
|
||||
.populate({ path: 'classes', select: 'name tuitionFee' })
|
||||
.populate({ path: 'course', select: 'title price' })
|
||||
.lean();
|
||||
if (!payment) throw new AppError('PAYMENT_NOT_FOUND');
|
||||
return payment;
|
||||
};
|
||||
|
||||
const createPayment = async (body, actorId = null) => {
|
||||
const payment = await Payment.create({
|
||||
...body,
|
||||
paidAmount: body.paidAmount || 0
|
||||
});
|
||||
|
||||
if (actorId) {
|
||||
eventEmitter.emit(EVENT_NAMES.PAYMENT_CREATED, {
|
||||
paymentId: payment._id,
|
||||
userId: payment.user,
|
||||
actorId
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
Object.assign(payment, body);
|
||||
await payment.save();
|
||||
|
||||
if (body.status && body.status !== previousStatus) {
|
||||
eventEmitter.emit(EVENT_NAMES.PAYMENT_STATUS_CHANGED, {
|
||||
paymentId: payment._id,
|
||||
userId: payment.user,
|
||||
oldStatus: previousStatus,
|
||||
newStatus: payment.status,
|
||||
actorId
|
||||
});
|
||||
}
|
||||
|
||||
return getPaymentById(payment._id);
|
||||
};
|
||||
|
||||
const deletePayment = async (id) => {
|
||||
const payment = await Payment.findByIdAndDelete(id);
|
||||
if (!payment) throw new AppError('PAYMENT_NOT_FOUND');
|
||||
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');
|
||||
|
||||
payment.transactions.push({
|
||||
...trxData,
|
||||
recordedBy: actorId || trxData.recordedBy,
|
||||
date: trxData.date || new Date()
|
||||
});
|
||||
payment.paidAmount = payment.transactions.reduce((sum, t) => sum + (t.amount || 0), 0);
|
||||
await payment.save();
|
||||
return getPaymentById(paymentId);
|
||||
};
|
||||
|
||||
const getMyPayments = async (userId, query = {}) => {
|
||||
return getAllPayments({ ...query, userId });
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getAllPayments,
|
||||
getPaymentById,
|
||||
createPayment,
|
||||
updatePayment,
|
||||
deletePayment,
|
||||
searchPayments,
|
||||
addTransaction,
|
||||
getMyPayments,
|
||||
// Aliases for older call sites
|
||||
getAll: getAllPayments,
|
||||
getOne: getPaymentById,
|
||||
create: createPayment,
|
||||
recordTransaction: addTransaction,
|
||||
remove: deletePayment
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
// Stub validator — pass-through middleware (no validation yet)
|
||||
const passThrough = (req, res, next) => next();
|
||||
|
||||
module.exports = new Proxy({}, {
|
||||
get: () => passThrough
|
||||
});
|
||||
Reference in New Issue
Block a user