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.
All checks were successful
farmcontrol/farmcontrol-api/pipeline/head This commit looks good

This commit is contained in:
Tom Butcher 2026-06-21 22:32:57 +01:00
parent 2996b1670f
commit f74b85bb88
4 changed files with 210 additions and 2 deletions

View File

@ -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' },

View File

@ -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);
});

View File

@ -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).',
})
);
});
});
});

View File

@ -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;