diff --git a/components/payments/paymentController.js b/components/payments/paymentController.js index a3a6268..1d78a20 100644 --- a/components/payments/paymentController.js +++ b/components/payments/paymentController.js @@ -46,3 +46,15 @@ exports.getMyPayments = catchAsync(async (req, res, next) => { const { data, meta } = await paymentService.getMyPayments(req.user._id, req.query); return listResponse(res, 200, data, meta); }); + +exports.createTransaction = catchAsync(async (req, res, next) => { + const actorId = req.user?._id; + const payment = await paymentService.addTransaction(req.params.paymentId, req.body, actorId); + return successResponse(res, 201, 'Payment transaction recorded', payment); +}); + +exports.updateTransaction = catchAsync(async (req, res, next) => { + const actorId = req.user?._id; + const payment = await paymentService.updateTransaction(req.params.transactionId, req.body, actorId); + return successResponse(res, 200, 'Transaction updated successfully', payment); +}); diff --git a/components/payments/paymentRoutes.js b/components/payments/paymentRoutes.js index 83dcd3e..1093230 100644 --- a/components/payments/paymentRoutes.js +++ b/components/payments/paymentRoutes.js @@ -5,7 +5,8 @@ const paymentController = require('./paymentController'); const { validateCreatePayment, validateUpdatePayment, - validateAddTransaction + validateAddTransaction, + validateUpdateTransaction } = require('./paymentValidator'); const authMiddleware = require('../../middlewares/authMiddleware'); const perm = require('../../middlewares/permissionMiddleware'); @@ -25,6 +26,8 @@ router.get('/admin/get-all', perm.requires(PERMISSIONS.PAYMENTS_READ), paymentCo 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.put('/admin/transactions/:transactionId', perm.requires(PERMISSIONS.PAYMENTS_UPDATE), validateUpdateTransaction, paymentController.updateTransaction); +router.post('/admin/transactions/:paymentId', perm.requires(PERMISSIONS.PAYMENTS_UPDATE), validateAddTransaction, paymentController.createTransaction); router.delete('/admin/delete/:id', perm.requires(PERMISSIONS.PAYMENTS_DELETE), paymentController.delete); module.exports = router; diff --git a/components/payments/paymentService.js b/components/payments/paymentService.js index bc10d91..de9b212 100644 --- a/components/payments/paymentService.js +++ b/components/payments/paymentService.js @@ -62,6 +62,43 @@ const refreshPaymentTotals = async (payment) => { return payment; }; +const emitPaymentStatusChangedIfNeeded = (payment, previousStatus, actorId = null) => { + if (payment.status === previousStatus) return; + eventEmitter.emit(EVENT_NAMES.PAYMENT_STATUS_CHANGED, { + paymentId: payment._id, + userId: payment.user, + oldStatus: previousStatus, + newStatus: payment.status, + actorId + }); +}; + +const reconcilePendingTransactions = async (payment) => { + const transactions = await Transaction.find({ payment: payment._id }).lean(); + const remaining = getPayableAmount(payment) - sumPaidTransactions(transactions); + const pending = await Transaction.find({ payment: payment._id, status: 'pending' }).sort({ dueDate: 1 }); + + if (remaining <= 0) { + await Transaction.deleteMany({ payment: payment._id, status: 'pending' }); + return; + } + + 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) } }); + } + return; + } + + await Transaction.create(buildTransactionPayload(payment, { + amount: remaining, + status: 'pending', + dueDate: payment.dueDate || new Date() + })); +}; + 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); @@ -248,15 +285,7 @@ const updatePayment = async (id, body, actorId = null) => { await refreshPaymentTotals(payment); } - 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 - }); - } + emitPaymentStatusChangedIfNeeded(payment, previousStatus, actorId); return getPaymentById(payment._id); }; @@ -274,26 +303,60 @@ const addTransaction = async (paymentId, trxData, actorId = null) => { const payment = await Payment.findById(paymentId); if (!payment) throw new AppError('PAYMENT_NOT_FOUND'); + const previousStatus = payment.status; 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 reconcilePendingTransactions(payment); await refreshPaymentTotals(payment); + + eventEmitter.emit(EVENT_NAMES.PAYMENT_TRANSACTION_ADDED, { + paymentId: payment._id, + userId: payment.user, + actorId + }); + emitPaymentStatusChangedIfNeeded(payment, previousStatus, actorId); + return getPaymentById(paymentId); }; +const updateTransaction = async (transactionId, body, actorId = null) => { + const trx = await Transaction.findById(transactionId); + if (!trx) throw new AppError('TRANSACTION_NOT_FOUND'); + + const payment = await Payment.findById(trx.payment); + if (!payment) throw new AppError('PAYMENT_NOT_FOUND'); + + const previousStatus = payment.status; + const merged = { + amount: body.amount !== undefined ? body.amount : trx.amount, + status: body.status !== undefined ? body.status : trx.status, + method: body.method !== undefined ? body.method : trx.method, + receiptNumber: body.receiptNumber !== undefined ? body.receiptNumber : trx.receiptNumber, + notes: body.notes !== undefined ? body.notes : trx.notes, + dueDate: body.dueDate !== undefined ? body.dueDate : trx.dueDate, + date: body.date !== undefined ? body.date : trx.date + }; + + const payload = buildTransactionPayload(payment, merged, actorId); + trx.amount = payload.amount; + trx.status = payload.status; + trx.method = payload.method; + trx.receiptNumber = payload.receiptNumber; + trx.notes = payload.notes; + trx.dueDate = payload.dueDate; + trx.date = payload.date; + if (actorId) trx.recordedBy = actorId; + + await trx.save(); + await reconcilePendingTransactions(payment); + await refreshPaymentTotals(payment); + emitPaymentStatusChangedIfNeeded(payment, previousStatus, actorId); + + return getPaymentById(payment._id); +}; + const getMyPayments = async (userId, query = {}) => { return getAllPayments({ ...query, userId }); }; @@ -306,10 +369,12 @@ module.exports = { deletePayment, searchPayments, addTransaction, + updateTransaction, getMyPayments, createTransactionsForPayment, refreshPaymentTotals, buildTransactionPayload, + reconcilePendingTransactions, // Aliases for older call sites getAll: getAllPayments, getOne: getPaymentById, diff --git a/components/payments/paymentValidator.js b/components/payments/paymentValidator.js index f2f8f46..43ef3e8 100644 --- a/components/payments/paymentValidator.js +++ b/components/payments/paymentValidator.js @@ -1,6 +1,60 @@ -// Stub validator — pass-through middleware (no validation yet) +// /components/payments/paymentValidator.js +'use strict'; + +const AppError = require('../../utils/AppError'); + +const PAYMENT_METHODS = new Set(['online', 'card', 'cash']); +const TRANSACTION_STATUSES = new Set(['pending', 'paid']); + const passThrough = (req, res, next) => next(); -module.exports = new Proxy({}, { - get: () => passThrough -}); +const validateTransactionBody = (body, { requireAmount = true } = {}) => { + const details = {}; + + if (requireAmount && (body.amount === undefined || body.amount === null || Number(body.amount) <= 0)) { + details.amount = 'مبلغ تراکنش باید بیشتر از صفر باشد'; + } else if (body.amount !== undefined && Number(body.amount) < 0) { + details.amount = 'مبلغ تراکنش نمی‌تواند منفی باشد'; + } + + if (body.status !== undefined && !TRANSACTION_STATUSES.has(body.status)) { + details.status = 'وضعیت تراکنش نامعتبر است'; + } + + if (body.method !== undefined && body.method !== '' && !PAYMENT_METHODS.has(body.method)) { + details.method = 'روش پرداخت نامعتبر است'; + } + + if (body.notes !== undefined && String(body.notes).length > 5000) { + details.notes = 'یادداشت نباید بیش از ۵۰۰۰ کاراکتر باشد'; + } + + if (Object.keys(details).length) { + throw new AppError('VALIDATION_FAILED', details); + } +}; + +const validateAddTransaction = (req, res, next) => { + try { + validateTransactionBody(req.body || {}, { requireAmount: true }); + next(); + } catch (err) { + next(err); + } +}; + +const validateUpdateTransaction = (req, res, next) => { + try { + validateTransactionBody(req.body || {}, { requireAmount: false }); + next(); + } catch (err) { + next(err); + } +}; + +module.exports = { + validateCreatePayment: passThrough, + validateUpdatePayment: passThrough, + validateAddTransaction, + validateUpdateTransaction +}; diff --git a/utils/errors.json b/utils/errors.json index cb580ad..c0c548a 100644 --- a/utils/errors.json +++ b/utils/errors.json @@ -54,6 +54,11 @@ "en": "Payment record not found.", "fa": "سابقه پرداخت یافت نشد." }, + "TRANSACTION_NOT_FOUND": { + "statusCode": 404, + "en": "Transaction not found.", + "fa": "تراکنش یافت نشد." + }, "ROLE_NOT_FOUND": { "statusCode": 404, "en": "Role not found.",