diff --git a/shared/types/form/__tests__/product.spec.ts b/shared/types/form/__tests__/product.spec.ts new file mode 100644 index 0000000000..9b304c302a --- /dev/null +++ b/shared/types/form/__tests__/product.spec.ts @@ -0,0 +1,126 @@ +import { ObjectId } from 'bson' +import { isPaymentsProducts } from '../product' +import { Product } from '../product' + +describe('Product validation', () => { + it('should return false if products is not an array', () => { + // Arrange + const mockProductNonArray = 'some thing' + + // Assert + expect(isPaymentsProducts(mockProductNonArray)).toBe(false) + }) + + it('should return true if products is an empty array', () => { + // Arrange + const mockProductEmptyArray: Product[] = [] + + // Assert + expect(isPaymentsProducts(mockProductEmptyArray)).toBe(false) + }) + + it('should return false if product has invalid object id', () => { + // Arrange + const mockProductInvalidId: any = [ + { + name: 'some name', + description: 'some description', + multi_qty: true, + min_qty: 1, + max_qty: 1, + amount_cents: 1, + _id: 'some id', + }, + ] + + // Assert + expect(isPaymentsProducts(mockProductInvalidId)).toBe(false) + }) + + it('should return false if product has no name', () => { + // Arrange + const mockProductWrongName = [ + { + description: 'some description', + multi_qty: true, + min_qty: 1, + max_qty: 1, + amount_cents: 1, + _id: new ObjectId(), + }, + ] as unknown as Product[] + + // Assert + expect(isPaymentsProducts(mockProductWrongName)).toBe(false) + }) + + it('should return false if there are multiple products and at least one has no name', () => { + // Arrange + const mockMultipleProductOneNoName = [ + { + description: 'some description', + multi_qty: true, + min_qty: 1, + max_qty: 1, + amount_cents: 1, + _id: new ObjectId(), + }, + { + name: 'has name', + multi_qty: true, + min_qty: 1, + max_qty: 1, + amount_cents: 1, + _id: new ObjectId(), + }, + ] as unknown as Product[] + + // Assert + expect(isPaymentsProducts(mockMultipleProductOneNoName)).toBe(false) + }) + + it('should return true if product has valid object id and name', () => { + // Arrange + const mockProductsCorrectShape = [ + { + name: 'some name', + description: 'some description', + multi_qty: true, + min_qty: 1, + max_qty: 1, + amount_cents: 1, + _id: new ObjectId(), + }, + ] as unknown as Product[] + + // Assert + expect(isPaymentsProducts(mockProductsCorrectShape)).toBe(true) + }) + + it('should return true if multiple products have valid object id and name', () => { + // Arrange + const mockProductsCorrectShapeMultiple = [ + { + name: 'some name', + description: 'some description', + multi_qty: true, + min_qty: 1, + max_qty: 1, + amount_cents: 1, + _id: new ObjectId(), + }, + { + name: 'another name', + description: 'another description', + multi_qty: true, + min_qty: 1, + max_qty: 1, + amount_cents: 1, + _id: new ObjectId(), + }, + ] as unknown as Product[] + + // Assert + expect(isPaymentsProducts(mockProductsCorrectShapeMultiple)).toBe(true) + }) +}) diff --git a/shared/types/form/product.ts b/shared/types/form/product.ts index 3220c66e81..533e5d195c 100644 --- a/shared/types/form/product.ts +++ b/shared/types/form/product.ts @@ -16,3 +16,23 @@ export type ProductItem = { selected: boolean quantity: number } + +// Typeguard for Product +export const isPaymentsProducts = ( + products: unknown, +): products is Product[] => { + if (!Array.isArray(products)) { + return false + } + return ( + products.length > 0 && + products.every((product) => { + return ( + product._id && + String(product._id).match(/^[0-9a-fA-F]{24}$/) && + product.name && + typeof product.name === 'string' + ) + }) + ) +} diff --git a/src/app/modules/payments/__tests__/payments.service.spec.ts b/src/app/modules/payments/__tests__/payments.service.spec.ts index e9113b8d96..dc08076e33 100644 --- a/src/app/modules/payments/__tests__/payments.service.spec.ts +++ b/src/app/modules/payments/__tests__/payments.service.spec.ts @@ -2,13 +2,14 @@ import dbHandler from '__tests__/unit/backend/helpers/jest-db' import { ObjectId } from 'bson' import moment from 'moment-timezone' import mongoose, { Query } from 'mongoose' -import { PaymentStatus } from 'shared/types' +import { PaymentStatus, Product, ProductId, ProductItem } from 'shared/types' import getAgencyModel from 'src/app/models/agency.server.model' import getPaymentModel from 'src/app/models/payment.server.model' import { InvalidDomainError } from '../../auth/auth.errors' import { DatabaseError } from '../../core/core.errors' +import { InvalidPaymentProductsError } from '../payments.errors' import * as PaymentsService from '../payments.service' const Payment = getPaymentModel(mongoose) @@ -73,6 +74,357 @@ describe('payments.service', () => { }) }) + describe('validatePaymentProducts', () => { + const mockValidProduct = { + name: 'some name', + description: 'some description', + multi_qty: false, + min_qty: 1, + max_qty: 1, + amount_cents: 1000, + _id: new ObjectId(), + } as unknown as Product + + const mockValidProductsDefinition = [mockValidProduct] + + const mockValidProductSubmission: ProductItem[] = [ + { data: mockValidProduct, quantity: 1, selected: true }, + ] + + it('should return without error if payment products are valid', () => { + // Act + const result = PaymentsService.validatePaymentProducts( + mockValidProductsDefinition, + mockValidProductSubmission, + ) + + // Assert + expect(result.isOk()).toBeTrue() + }) + + it('should return with error if there are duplicate payment products', () => { + // Arrange + const mockDuplicatedProductSubmission: ProductItem[] = [ + { data: mockValidProduct, quantity: 1, selected: true }, + { data: mockValidProduct, quantity: 1, selected: true }, + ] + + // Act + const result = PaymentsService.validatePaymentProducts( + mockValidProductsDefinition, + mockDuplicatedProductSubmission, + ) + + // Assert + expect(result.isErr()).toBeTrue() + expect(result._unsafeUnwrapErr()).toBeInstanceOf( + InvalidPaymentProductsError, + ) + expect(result._unsafeUnwrapErr().message).toContain( + 'You have selected a duplicate product.', + ) + }) + + it('should return with error if the payment product id cannot be found', () => { + // Arrange + const mockInvalidProductSubmission: ProductItem[] = [ + { + data: { + ...mockValidProduct, + _id: new ObjectId() as unknown as ProductId, + }, + quantity: 1, + selected: true, + }, + { + data: mockValidProduct, + quantity: 1, + selected: true, + }, + ] + + // Act + const result = PaymentsService.validatePaymentProducts( + mockValidProductsDefinition, + mockInvalidProductSubmission, + ) + + // Assert + expect(result.isErr()).toBeTrue() + expect(result._unsafeUnwrapErr()).toBeInstanceOf( + InvalidPaymentProductsError, + ) + expect(result._unsafeUnwrapErr().message).toContain( + 'There has been a change in the products available.', + ) + }) + + it('should return with error if the description has changed', () => { + // Arrange + const mockInvalidProductSubmission: ProductItem[] = [ + { + data: { + ...mockValidProduct, + description: 'some other description', + }, + quantity: 1, + selected: true, + }, + ] + + // Act + const result = PaymentsService.validatePaymentProducts( + mockValidProductsDefinition, + mockInvalidProductSubmission, + ) + + // Assert + expect(result.isErr()).toBeTrue() + expect(result._unsafeUnwrapErr()).toBeInstanceOf( + InvalidPaymentProductsError, + ) + expect(result._unsafeUnwrapErr().message).toContain( + 'There has been a change in the products available.', + ) + }) + + it('should return with error if the name has changed', () => { + // Arrange + const mockInvalidProductSubmission: ProductItem[] = [ + { + data: { + ...mockValidProduct, + name: 'some other name', + }, + quantity: 1, + selected: true, + }, + ] + + // Act + const result = PaymentsService.validatePaymentProducts( + mockValidProductsDefinition, + mockInvalidProductSubmission, + ) + + // Assert + expect(result.isErr()).toBeTrue() + expect(result._unsafeUnwrapErr()).toBeInstanceOf( + InvalidPaymentProductsError, + ) + expect(result._unsafeUnwrapErr().message).toContain( + 'There has been a change in the products available.', + ) + }) + + it('should return with error if multi_qty has changed', () => { + // Arrange + const mockInvalidProductSubmission: ProductItem[] = [ + { + data: { + ...mockValidProduct, + multi_qty: true, + }, + quantity: 1, + selected: true, + }, + ] + + // Act + const result = PaymentsService.validatePaymentProducts( + mockValidProductsDefinition, + mockInvalidProductSubmission, + ) + + // Assert + expect(result.isErr()).toBeTrue() + expect(result._unsafeUnwrapErr()).toBeInstanceOf( + InvalidPaymentProductsError, + ) + expect(result._unsafeUnwrapErr().message).toContain( + 'There has been a change in the products available.', + ) + }) + + it('should return with error if the max_qty has changed', () => { + // Arrange + const mockInvalidProductSubmission: ProductItem[] = [ + { + data: { + ...mockValidProduct, + max_qty: 5, + }, + quantity: 1, + selected: true, + }, + ] + + // Act + const result = PaymentsService.validatePaymentProducts( + mockValidProductsDefinition, + mockInvalidProductSubmission, + ) + + // Assert + expect(result.isErr()).toBeTrue() + expect(result._unsafeUnwrapErr()).toBeInstanceOf( + InvalidPaymentProductsError, + ) + expect(result._unsafeUnwrapErr().message).toContain( + 'There has been a change in the products available.', + ) + }) + + it('should return with error if more than 1 quantity selected when multi_qty is disabled', () => { + // Arrange + const mockSingleQuantityProduct = { + name: 'some name', + description: 'some description', + multi_qty: false, + min_qty: 1, + max_qty: 5, + amount_cents: 1000, + _id: new ObjectId(), + } as unknown as Product + + const mockProductSubmission = [ + { data: mockSingleQuantityProduct, quantity: 2, selected: true }, + ] + + const mockProductDefinition = [mockSingleQuantityProduct] + + // Act + + const result = PaymentsService.validatePaymentProducts( + mockProductDefinition, + mockProductSubmission, + ) + + // Assert + expect(result.isErr()).toBeTrue() + expect(result._unsafeUnwrapErr()).toBeInstanceOf( + InvalidPaymentProductsError, + ) + expect(result._unsafeUnwrapErr().message).toContain( + 'Selected more than 1 quantity when it is not allowed', + ) + }) + + it('should return with error if less than min quantity selected when multi_qty is enabled', () => { + // Arrange + const mockMultiQuantityProduct = { + name: 'some name', + description: 'some description', + multi_qty: true, + min_qty: 3, + max_qty: 5, + amount_cents: 1000, + _id: new ObjectId(), + } as unknown as Product + + const mockProductSubmission = [ + { data: mockMultiQuantityProduct, quantity: 1, selected: true }, + ] + + const mockProductDefinition = [mockMultiQuantityProduct] + + // Act + + const result = PaymentsService.validatePaymentProducts( + mockProductDefinition, + mockProductSubmission, + ) + + // Assert + expect(result.isErr()).toBeTrue() + expect(result._unsafeUnwrapErr()).toBeInstanceOf( + InvalidPaymentProductsError, + ) + expect(result._unsafeUnwrapErr().message).toContain( + 'Selected an invalid quantity below the limit', + ) + }) + + it('should return with error if more than max quantity selected when multi_qty is enabled', () => { + // Arrange + const mockMultiQuantityProduct = { + name: 'some name', + description: 'some description', + multi_qty: true, + min_qty: 3, + max_qty: 5, + amount_cents: 1000, + _id: new ObjectId(), + } as unknown as Product + + const mockProductSubmission = [ + { data: mockMultiQuantityProduct, quantity: 10, selected: true }, + ] + + const mockProductDefinition = [mockMultiQuantityProduct] + + // Act + + const result = PaymentsService.validatePaymentProducts( + mockProductDefinition, + mockProductSubmission, + ) + + // Assert + expect(result.isErr()).toBeTrue() + expect(result._unsafeUnwrapErr()).toBeInstanceOf( + InvalidPaymentProductsError, + ) + expect(result._unsafeUnwrapErr().message).toContain( + 'Selected an invalid quantity above the limit', + ) + }) + + it('should return with error if submitted price is not the same as in form definition', () => { + // Arrange + const mockProductWithCorrectPrice = { + name: 'some name', + description: 'some description', + multi_qty: true, + min_qty: 3, + max_qty: 5, + amount_cents: 1000, + _id: new ObjectId(), + } as unknown as Product + + const mockProductWithIncorrectPrice = { + ...mockProductWithCorrectPrice, + amount_cents: 500, + } + + const mockProductSubmissionWithIncorrectPrice = [ + { + data: mockProductWithIncorrectPrice, + quantity: 3, + selected: true, + }, + ] + + const mockProductDefinition = [mockProductWithCorrectPrice] + + // Act + + const result = PaymentsService.validatePaymentProducts( + mockProductDefinition, + mockProductSubmissionWithIncorrectPrice, + ) + + // Assert + expect(result.isErr()).toBeTrue() + expect(result._unsafeUnwrapErr()).toBeInstanceOf( + InvalidPaymentProductsError, + ) + expect(result._unsafeUnwrapErr().message).toContain( + 'There has been a change in the products available', + ) + }) + }) + describe('findLatestSuccessfulPaymentByEmailAndFormId', () => { const expectedObjectId = new ObjectId() const email = 'someone@mail.com' diff --git a/src/app/modules/payments/payments.errors.ts b/src/app/modules/payments/payments.errors.ts index eec9479fac..c943365faa 100644 --- a/src/app/modules/payments/payments.errors.ts +++ b/src/app/modules/payments/payments.errors.ts @@ -29,3 +29,9 @@ export class PaymentAccountInformationError extends ApplicationError { super(message) } } + +export class InvalidPaymentProductsError extends ApplicationError { + constructor(message = 'Invalid payment submission') { + super(message) + } +} diff --git a/src/app/modules/payments/payments.service.ts b/src/app/modules/payments/payments.service.ts index 68fad2e851..3ed2630f3d 100644 --- a/src/app/modules/payments/payments.service.ts +++ b/src/app/modules/payments/payments.service.ts @@ -1,8 +1,9 @@ +import { isEqual, omit } from 'lodash' import moment from 'moment-timezone' import mongoose, { Types } from 'mongoose' -import { errAsync, okAsync, ResultAsync } from 'neverthrow' +import { err, errAsync, ok, okAsync, Result, ResultAsync } from 'neverthrow' -import { PaymentStatus } from '../../../../shared/types' +import { PaymentStatus, Product, ProductItem } from '../../../../shared/types' import { IPaymentSchema } from '../../../types' import { createLoggerWithLabel } from '../../config/logger' import getPaymentModel from '../../models/payment.server.model' @@ -25,6 +26,7 @@ import { findSubmissionById } from '../submission/submission.service' import { ConfirmedPaymentNotFoundError, + InvalidPaymentProductsError, PaymentAlreadyConfirmedError, PaymentNotFoundError, } from './payments.errors' @@ -368,3 +370,106 @@ export const sendOnboardingEmailIfEligible = ( MailService.sendPaymentOnboardingEmail({ email }), ) } + +/** + * Validates that payment by product is valid + */ +export const validatePaymentProducts = ( + formProductsDefinition: Product[], + submittedPaymentProducts: ProductItem[], +): Result => { + const logMeta = { + action: 'validatePayments', + } + + // Check that no duplicate payment products (by id) are selected + const selectedProducts = submittedPaymentProducts.filter( + (product) => product.selected, + ) + + const selectedProductIds = new Set( + selectedProducts.map((product) => product.data._id), + ) + + if (selectedProductIds.size !== selectedProducts.length) { + logger.error({ + message: 'Duplicate payment products selected', + meta: logMeta, + }) + + return err( + new InvalidPaymentProductsError( + 'You have selected a duplicate product. Please refresh and try again.', + ), + ) + } + + for (const product of submittedPaymentProducts) { + // Check that every selected product matches the form definition + + const productIdSubmitted = product.data._id + const productDefinition = formProductsDefinition.find( + (product) => String(product._id) === String(productIdSubmitted), + ) + + if ( + !productDefinition || + !isEqual(omit(productDefinition, '_id'), omit(product.data, '_id')) + ) { + logger.error({ + message: 'Invalid payment product selected.', + meta: logMeta, + }) + return err( + new InvalidPaymentProductsError( + 'There has been a change in the products available. Please refresh and try again.', + ), + ) + } + + // Check that the quantity of the product is valid + + if (!productDefinition.multi_qty && product.quantity > 1) { + logger.error({ + message: 'Invalid payment product quantity', + meta: logMeta, + }) + return err( + new InvalidPaymentProductsError( + 'Selected more than 1 quantity when it is not allowed. Please refresh and try again.', + ), + ) + } + + if (productDefinition.multi_qty) { + if (product.quantity < productDefinition.min_qty) { + logger.error({ + message: + 'Selected an invalid payment product quantity below the limit', + meta: logMeta, + }) + + return err( + new InvalidPaymentProductsError( + `Selected an invalid quantity below the limit. Please refresh and try again.`, + ), + ) + } + if (product.quantity > productDefinition.max_qty) { + logger.error({ + message: + 'Selected an invalid payment product quantity above the limit.', + meta: logMeta, + }) + + return err( + new InvalidPaymentProductsError( + `Selected an invalid quantity above the limit. Please refresh and try again.`, + ), + ) + } + } + } + + return ok(true) +} diff --git a/src/app/modules/submission/encrypt-submission/encrypt-submission.controller.ts b/src/app/modules/submission/encrypt-submission/encrypt-submission.controller.ts index 084c0353f8..7bcc1a5a5d 100644 --- a/src/app/modules/submission/encrypt-submission/encrypt-submission.controller.ts +++ b/src/app/modules/submission/encrypt-submission/encrypt-submission.controller.ts @@ -630,6 +630,7 @@ export const handleStorageSubmission = [ EncryptSubmissionMiddleware.createFormsgAndRetrieveForm, EncryptSubmissionMiddleware.scanAndRetrieveAttachments, EncryptSubmissionMiddleware.validateStorageSubmission, + EncryptSubmissionMiddleware.validatePaymentSubmission, EncryptSubmissionMiddleware.encryptSubmission, submitEncryptModeForm, ] as ControllerHandler[] diff --git a/src/app/modules/submission/encrypt-submission/encrypt-submission.middleware.ts b/src/app/modules/submission/encrypt-submission/encrypt-submission.middleware.ts index d9b9bacb93..a5a3550572 100644 --- a/src/app/modules/submission/encrypt-submission/encrypt-submission.middleware.ts +++ b/src/app/modules/submission/encrypt-submission/encrypt-submission.middleware.ts @@ -7,7 +7,9 @@ import { BasicField, FormAuthType, FormResponseMode, + isPaymentsProducts, } from '../../../../../shared/types' +import { IPopulatedForm } from '../../../../types' import { EncryptAttachmentResponse, EncryptFormFieldResponse, @@ -24,6 +26,7 @@ import { JoiPaymentProduct } from '../../form/admin-form/admin-form.payments.con import * as FormService from '../../form/form.service' import { MyInfoService } from '../../myinfo/myinfo.service' import { extractMyInfoLoginJwt } from '../../myinfo/myinfo.util' +import * as PaymentsService from '../../payments/payments.service' import { IPopulatedStorageFormWithResponsesAndHash } from '../email-submission/email-submission.types' import ParsedResponsesObject from '../ParsedResponsesObject.class' import { sharedSubmissionParams } from '../submission.constants' @@ -274,6 +277,60 @@ export const scanAndRetrieveAttachments = async ( return next() } +/** + * Middleware to validate payment content + */ +export const validatePaymentSubmission = async ( + req: ValidateSubmissionMiddlewareHandlerRequest, + res: Parameters[1], + next: NextFunction, +) => { + const formDefDoc = req.formsg.formDef as IPopulatedForm + + const formDef = formDefDoc.toObject() // Convert to POJO + + const logMeta = { + action: 'validatePaymentSubmission', + formId: String(formDef._id), + ...createReqMeta(req), + } + + const formDefProducts = formDef?.payments_field?.products + const submittedPaymentProducts = req.body.paymentProducts + if (submittedPaymentProducts) { + if (!isPaymentsProducts(formDefProducts)) { + // Payment definition does not allow for payment by product + + logger.error({ + message: 'Invalid form definition for payment by product', + meta: logMeta, + }) + + return res.status(StatusCodes.BAD_REQUEST).json({ + message: + 'The payment settings in this form have been updated. Please refresh and try again.', + }) + } + return PaymentsService.validatePaymentProducts( + formDefProducts, + submittedPaymentProducts, + ) + .map(() => next()) + .mapErr((error) => { + logger.error({ + message: 'Error validating payment submission', + meta: logMeta, + error, + }) + const { statusCode, errorMessage } = mapRouteError(error) + return res.status(statusCode).json({ + message: errorMessage, + }) + }) + } + return next() +} + /** * Validates storage submissions to the new endpoint (/api/v3/forms/:formId/submissions/storage). * This uses the same validators as email mode submissions. diff --git a/src/app/modules/submission/submission.utils.ts b/src/app/modules/submission/submission.utils.ts index 871aa4b3d1..dcf7f104f8 100644 --- a/src/app/modules/submission/submission.utils.ts +++ b/src/app/modules/submission/submission.utils.ts @@ -90,7 +90,10 @@ import { } from '../myinfo/myinfo.errors' import { MyInfoKey } from '../myinfo/myinfo.types' import { getMyInfoChildHashKey } from '../myinfo/myinfo.util' -import { PaymentNotFoundError } from '../payments/payments.errors' +import { + InvalidPaymentProductsError, + PaymentNotFoundError, +} from '../payments/payments.errors' import { SgidInvalidJwtError, SgidMissingJwtError, @@ -207,6 +210,7 @@ const errorMapper: MapRouteError = ( errorMessage: error.message, } case ResponseModeError: + case InvalidPaymentProductsError: return { statusCode: StatusCodes.BAD_REQUEST, errorMessage: error.message,