From f74b85bb88a3e594c5edb66e521bc2857dd2e3bd Mon Sep 17 00:00:00 2001 From: Tom Butcher Date: Sun, 21 Jun 2026 22:32:57 +0100 Subject: [PATCH] Added authorisation and decline functionality for payments, including new route handlers and schema updates to track authorisation and decline timestamps. Updated payment cancellation logic to reflect changes in payment states. --- .../schemas/finance/payment.schema.js | 18 ++++ src/routes/finance/payments.js | 10 ++ .../finance/__tests__/payments.test.js | 98 +++++++++++++++++++ src/services/finance/payments.js | 86 +++++++++++++++- 4 files changed, 210 insertions(+), 2 deletions(-) diff --git a/src/database/schemas/finance/payment.schema.js b/src/database/schemas/finance/payment.schema.js index 437583d..4c35a28 100644 --- a/src/database/schemas/finance/payment.schema.js +++ b/src/database/schemas/finance/payment.schema.js @@ -15,6 +15,8 @@ const paymentSchema = new Schema( }, paymentDate: { type: Date, required: false }, postedAt: { type: Date, required: false }, + authorisedAt: { type: Date, required: false }, + declinedAt: { type: Date, required: false }, cancelledAt: { type: Date, required: false }, paymentMethod: { type: String, required: false }, notes: { type: String, required: false }, @@ -39,6 +41,22 @@ const rollupConfigs = [ { name: 'postedAmount', property: 'amount', operation: 'sum' }, ], }, + { + name: 'authorised', + filter: { 'state.type': 'authorised' }, + rollups: [ + { name: 'authorisedCount', property: 'state.type', operation: 'count' }, + { name: 'authorisedAmount', property: 'amount', operation: 'sum' }, + ], + }, + { + name: 'declined', + filter: { 'state.type': 'declined' }, + rollups: [ + { name: 'declinedCount', property: 'state.type', operation: 'count' }, + { name: 'declinedAmount', property: 'amount', operation: 'sum' }, + ], + }, { name: 'cancelled', filter: { 'state.type': 'cancelled' }, diff --git a/src/routes/finance/payments.js b/src/routes/finance/payments.js index 61cbc32..05b55d6 100644 --- a/src/routes/finance/payments.js +++ b/src/routes/finance/payments.js @@ -14,6 +14,8 @@ import { getPaymentStatsRouteHandler, getPaymentHistoryRouteHandler, postPaymentRouteHandler, + authorisePaymentRouteHandler, + declinePaymentRouteHandler, cancelPaymentRouteHandler, } from '../../services/finance/payments.js'; @@ -88,6 +90,14 @@ router.post('/:id/post', isAuthenticated, async (req, res) => { postPaymentRouteHandler(req, res); }); +router.post('/:id/authorise', isAuthenticated, async (req, res) => { + authorisePaymentRouteHandler(req, res); +}); + +router.post('/:id/decline', isAuthenticated, async (req, res) => { + declinePaymentRouteHandler(req, res); +}); + router.post('/:id/cancel', isAuthenticated, async (req, res) => { cancelPaymentRouteHandler(req, res); }); diff --git a/src/services/finance/__tests__/payments.test.js b/src/services/finance/__tests__/payments.test.js index 891bd24..e57a1ef 100644 --- a/src/services/finance/__tests__/payments.test.js +++ b/src/services/finance/__tests__/payments.test.js @@ -38,6 +38,9 @@ const { getPaymentRouteHandler, newPaymentRouteHandler, postPaymentRouteHandler, + authorisePaymentRouteHandler, + declinePaymentRouteHandler, + cancelPaymentRouteHandler, } = await import('../payments.js'); const { listObjects, getObject, editObject, newObject, checkStates } = await import( @@ -120,5 +123,100 @@ describe('Payment Service Route Handlers', () => { ); }); }); + + describe('authorisePaymentRouteHandler', () => { + it('should authorise a posted payment', async () => { + req.params.id = '507f1f77bcf86cd799439011'; + checkStates.mockResolvedValue(true); + editObject.mockResolvedValue({ + _id: '507f1f77bcf86cd799439011', + state: { type: 'authorised' }, + }); + + await authorisePaymentRouteHandler(req, res); + + expect(checkStates).toHaveBeenCalledWith( + expect.objectContaining({ states: ['posted'] }) + ); + expect(editObject).toHaveBeenCalledWith( + expect.objectContaining({ + updateData: expect.objectContaining({ + state: { type: 'authorised' }, + }), + }) + ); + expect(res.send).toHaveBeenCalled(); + }); + + it('should fail if payment is not in posted state', async () => { + req.params.id = '507f1f77bcf86cd799439011'; + checkStates.mockResolvedValue(false); + + await authorisePaymentRouteHandler(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.send).toHaveBeenCalledWith( + expect.objectContaining({ error: 'Payment is not in posted state.' }) + ); + }); + }); + + describe('declinePaymentRouteHandler', () => { + it('should decline a posted payment', async () => { + req.params.id = '507f1f77bcf86cd799439011'; + checkStates.mockResolvedValue(true); + editObject.mockResolvedValue({ + _id: '507f1f77bcf86cd799439011', + state: { type: 'declined' }, + }); + + await declinePaymentRouteHandler(req, res); + + expect(checkStates).toHaveBeenCalledWith( + expect.objectContaining({ states: ['posted'] }) + ); + expect(editObject).toHaveBeenCalledWith( + expect.objectContaining({ + updateData: expect.objectContaining({ + state: { type: 'declined' }, + }), + }) + ); + expect(res.send).toHaveBeenCalled(); + }); + }); + + describe('cancelPaymentRouteHandler', () => { + it('should cancel a posted payment', async () => { + req.params.id = '507f1f77bcf86cd799439011'; + checkStates.mockResolvedValue(true); + editObject.mockResolvedValue({ + _id: '507f1f77bcf86cd799439011', + state: { type: 'cancelled' }, + }); + + await cancelPaymentRouteHandler(req, res); + + expect(checkStates).toHaveBeenCalledWith( + expect.objectContaining({ states: ['posted'] }) + ); + expect(editObject).toHaveBeenCalled(); + expect(res.send).toHaveBeenCalled(); + }); + + it('should fail if payment is not in posted state', async () => { + req.params.id = '507f1f77bcf86cd799439011'; + checkStates.mockResolvedValue(false); + + await cancelPaymentRouteHandler(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.send).toHaveBeenCalledWith( + expect.objectContaining({ + error: 'Payment is not in a cancellable state (must be posted).', + }) + ); + }); + }); }); diff --git a/src/services/finance/payments.js b/src/services/finance/payments.js index e946536..9a82e93 100644 --- a/src/services/finance/payments.js +++ b/src/services/finance/payments.js @@ -322,6 +322,88 @@ export const postPaymentRouteHandler = async (req, res) => { res.send(result); }; +export const authorisePaymentRouteHandler = async (req, res) => { + const id = new mongoose.Types.ObjectId(req.params.id); + + logger.trace(`Payment with ID: ${id}`); + + const checkStatesResult = await checkStates({ model: paymentModel, id, states: ['posted'] }); + + if (checkStatesResult.error) { + logger.error('Error checking payment states:', checkStatesResult.error); + res.status(checkStatesResult.code).send(checkStatesResult); + return; + } + + if (checkStatesResult === false) { + logger.error('Payment is not in posted state.'); + res.status(400).send({ error: 'Payment is not in posted state.', code: 400 }); + return; + } + + const updateData = { + updatedAt: new Date(), + state: { type: 'authorised' }, + authorisedAt: new Date(), + }; + const result = await editObject({ + model: paymentModel, + id, + updateData, + user: req.user, + }); + + if (result.error) { + logger.error('Error authorising payment:', result.error); + res.status(result.code).send(result); + return; + } + + logger.debug(`Authorised payment with ID: ${id}`); + res.send(result); +}; + +export const declinePaymentRouteHandler = async (req, res) => { + const id = new mongoose.Types.ObjectId(req.params.id); + + logger.trace(`Payment with ID: ${id}`); + + const checkStatesResult = await checkStates({ model: paymentModel, id, states: ['posted'] }); + + if (checkStatesResult.error) { + logger.error('Error checking payment states:', checkStatesResult.error); + res.status(checkStatesResult.code).send(checkStatesResult); + return; + } + + if (checkStatesResult === false) { + logger.error('Payment is not in posted state.'); + res.status(400).send({ error: 'Payment is not in posted state.', code: 400 }); + return; + } + + const updateData = { + updatedAt: new Date(), + state: { type: 'declined' }, + declinedAt: new Date(), + }; + const result = await editObject({ + model: paymentModel, + id, + updateData, + user: req.user, + }); + + if (result.error) { + logger.error('Error declining payment:', result.error); + res.status(result.code).send(result); + return; + } + + logger.debug(`Declined payment with ID: ${id}`); + res.send(result); +}; + export const cancelPaymentRouteHandler = async (req, res) => { const id = new mongoose.Types.ObjectId(req.params.id); @@ -330,7 +412,7 @@ export const cancelPaymentRouteHandler = async (req, res) => { const checkStatesResult = await checkStates({ model: paymentModel, id, - states: ['draft', 'posted'], + states: ['posted'], }); if (checkStatesResult.error) { @@ -342,7 +424,7 @@ export const cancelPaymentRouteHandler = async (req, res) => { if (checkStatesResult === false) { logger.error('Payment is not in a cancellable state.'); res.status(400).send({ - error: 'Payment is not in a cancellable state (must be draft or posted).', + error: 'Payment is not in a cancellable state (must be posted).', code: 400, }); return;