From 2c6c47ecff6cc7271a2d4247014137d8b1746c56 Mon Sep 17 00:00:00 2001 From: Ken Lee Shu Ming Date: Wed, 27 Sep 2023 23:44:09 +0800 Subject: [PATCH] feat(payment): invoice through s3 (#6733) * feat: payment proof through s3 * feat: add memory of upload to s3 to payments model * chore: add s3 bucket url to config * feat: refetch from stripe.charges * chore: add debug logs * chore: switch redirect to json message * test: update test config * fix: increase nginx proxy buffer size * Revert "chore: switch redirect to json message" This reverts commit e0214db42b293d838d42637bf61a43e317ee503d. * fix: include .platform in ECR * chore: refactor with refined completed payment schema * chore: follow repo convention of returning true for success result * refactor: move invoice generation code to payment-proof folder * test: add cases for payment-proof * chore: remove stray comments * feat: remove ebs .platform config * refactor: uppercase for global constants * test: update mock http to https * chore: fix duplicates, empty imports, terser mock return statements * test: fix missing mock for s3upload --- .template-env | 1 + __tests__/setup/.test-env | 1 + docker-compose.yml | 1 + init-localstack.sh | 3 + shared/types/payment.ts | 1 + src/app/config/config.ts | 1 + src/app/config/schema.ts | 12 + src/app/constants/time.ts | 1 + src/app/models/payment.server.model.ts | 5 + .../payment-proof.controller.spec.ts | 267 ++++++++++++++++++ .../__tests__/stripe.controller.spec.ts | 142 +--------- .../payments/payment-proof.controller.ts | 76 +++++ .../modules/payments/payment-proof.errors.ts | 19 ++ .../modules/payments/payment-proof.service.ts | 263 +++++++++++++++++ .../modules/payments/payment-proof.utils.ts | 7 + src/app/modules/payments/payments.service.ts | 1 + src/app/modules/payments/stripe.controller.ts | 58 ---- src/app/modules/payments/stripe.service.ts | 60 ---- .../routes/api/v3/payments/payments.routes.ts | 3 +- src/types/config.ts | 3 + src/types/payment.ts | 4 + 21 files changed, 670 insertions(+), 259 deletions(-) create mode 100644 src/app/constants/time.ts create mode 100644 src/app/modules/payments/__tests__/payment-proof.controller.spec.ts create mode 100644 src/app/modules/payments/payment-proof.controller.ts create mode 100644 src/app/modules/payments/payment-proof.errors.ts create mode 100644 src/app/modules/payments/payment-proof.service.ts create mode 100644 src/app/modules/payments/payment-proof.utils.ts diff --git a/.template-env b/.template-env index e08ace8c84..854cad8929 100644 --- a/.template-env +++ b/.template-env @@ -12,6 +12,7 @@ DB_HOST= ## AWS Config ATTACHMENT_S3_BUCKET= +PAYMENT_PROOF_S3_BUCKET= IMAGE_S3_BUCKET= STATIC_ASSETS_S3_BUCKET= LOGO_S3_BUCKET= diff --git a/__tests__/setup/.test-env b/__tests__/setup/.test-env index 3e370164ac..1530e39629 100644 --- a/__tests__/setup/.test-env +++ b/__tests__/setup/.test-env @@ -60,6 +60,7 @@ ATTACHMENT_S3_BUCKET=local-attachment-bucket STATIC_ASSETS_S3_BUCKET=local-static-assets-bucket VIRUS_SCANNER_QUARANTINE_S3_BUCKET=local-virus-scanner-quarantine-bucket VIRUS_SCANNER_CLEAN_S3_BUCKET=local-virus-scanner-clean-bucket +PAYMENT_PROOF_S3_BUCKET=local-payment-proof-bucket NODE_ENV=test FORMSG_SDK_MODE=test diff --git a/docker-compose.yml b/docker-compose.yml index 7b356c7f60..6e2331b721 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -22,6 +22,7 @@ services: - APP_URL=http://localhost:5001 - FE_APP_URL=http://localhost:3000 - ATTACHMENT_S3_BUCKET=local-attachment-bucket + - PAYMENT_PROOF_S3_BUCKET=local-payment-proof-bucket - IMAGE_S3_BUCKET=local-image-bucket - LOGO_S3_BUCKET=local-logo-bucket - STATIC_ASSETS_S3_BUCKET=local-static-assets-bucket diff --git a/init-localstack.sh b/init-localstack.sh index 86cb788e05..65084dd836 100755 --- a/init-localstack.sh +++ b/init-localstack.sh @@ -34,4 +34,7 @@ awslocal s3api put-bucket-versioning --bucket $VIRUS_SCANNER_QUARANTINE_S3_BUCKE awslocal s3 mb s3://$VIRUS_SCANNER_CLEAN_S3_BUCKET awslocal s3api put-bucket-versioning --bucket $VIRUS_SCANNER_CLEAN_S3_BUCKET --versioning-configuration Status=Enabled +# Buckets for payment proof +awslocal s3 mb s3://$PAYMENT_PROOF_S3_BUCKET + set +x diff --git a/shared/types/payment.ts b/shared/types/payment.ts index 4a6b6d4fb2..eba2003d31 100644 --- a/shared/types/payment.ts +++ b/shared/types/payment.ts @@ -29,6 +29,7 @@ export type CompletedPaymentMeta = { submissionId: string transactionFee: number receiptUrl: string + hasReceiptStoredInS3: boolean } export type PayoutMeta = { diff --git a/src/app/config/config.ts b/src/app/config/config.ts index f424d50cb1..4c7bc502ac 100644 --- a/src/app/config/config.ts +++ b/src/app/config/config.ts @@ -77,6 +77,7 @@ const s3BucketUrlVars = convict(s3BucketUrlSchema) attachmentBucketUrl: `${awsEndpoint}/${basicVars.awsConfig.attachmentS3Bucket}/`, virusScannerQuarantineS3BucketUrl: `${awsEndpoint}/${basicVars.awsConfig.virusScannerQuarantineS3Bucket}`, virusScannerCleanS3BucketUrl: `${awsEndpoint}/${basicVars.awsConfig.virusScannerCleanS3Bucket}`, + paymentProofS3BucketUrl: `${awsEndpoint}/${basicVars.awsConfig.paymentProofS3Bucket}`, }) .validate({ allowed: 'strict' }) .getProperties() diff --git a/src/app/config/schema.ts b/src/app/config/schema.ts index ad25c10401..590bce121b 100644 --- a/src/app/config/schema.ts +++ b/src/app/config/schema.ts @@ -87,6 +87,12 @@ export const compulsoryVarsSchema: Schema = { default: null, env: 'ATTACHMENT_S3_BUCKET', }, + paymentProofS3Bucket: { + doc: 'S3 Bucket to upload payment proof to', + format: String, + default: null, + env: 'PAYMENT_PROOF_S3_BUCKET', + }, staticAssetsS3Bucket: { doc: 'S3 Bucket containing static assets', format: String, @@ -491,5 +497,11 @@ export const loadS3BucketUrlSchema = ({ validateS3BucketUrl(val, { isDev, hasTrailingSlash: false, region }), default: null, }, + paymentProofS3BucketUrl: { + doc: 'Url of payment proof S3 bucket.', + format: (val) => + validateS3BucketUrl(val, { isDev, hasTrailingSlash: false, region }), + default: null, + }, } } diff --git a/src/app/constants/time.ts b/src/app/constants/time.ts new file mode 100644 index 0000000000..3ff1c45d4d --- /dev/null +++ b/src/app/constants/time.ts @@ -0,0 +1 @@ +export const DAY_IN_SECONDS = 24 * 60 * 60 diff --git a/src/app/models/payment.server.model.ts b/src/app/models/payment.server.model.ts index 47a2c52ca1..8a4a32bcc2 100644 --- a/src/app/models/payment.server.model.ts +++ b/src/app/models/payment.server.model.ts @@ -76,6 +76,11 @@ const PaymentSchema = new Schema( type: String, required: true, }, + hasReceiptStoredInS3: { + type: Boolean, + required: true, + default: false, + }, }, }, diff --git a/src/app/modules/payments/__tests__/payment-proof.controller.spec.ts b/src/app/modules/payments/__tests__/payment-proof.controller.spec.ts new file mode 100644 index 0000000000..9d0d6a9a68 --- /dev/null +++ b/src/app/modules/payments/__tests__/payment-proof.controller.spec.ts @@ -0,0 +1,267 @@ +import dbHandler from '__tests__/unit/backend/helpers/jest-db' +import expressHandler from '__tests__/unit/backend/helpers/jest-express' +import axios from 'axios' +import { ObjectId } from 'bson' +import mongoose from 'mongoose' +import { errAsync, ok, okAsync } from 'neverthrow' +import { PaymentStatus, SubmissionType } from 'shared/types' + +import getPaymentModel from 'src/app/models/payment.server.model' +import { getEncryptPendingSubmissionModel } from 'src/app/models/pending_submission.server.model' +import * as ConvertHtmlToPdf from 'src/app/utils/convert-html-to-pdf' +import { + IPaymentSchema, + IPopulatedEncryptedForm, + IPopulatedForm, +} from 'src/types' + +import * as FormService from '../../form/form.service' +import * as EncryptSubmissionService from '../../submission/encrypt-submission/encrypt-submission.service' +import * as PaymentProofController from '../payment-proof.controller' +import { PaymentProofUploadS3Error } from '../payment-proof.errors' +import * as PaymentProofService from '../payment-proof.service' +import { StripeFetchError } from '../stripe.errors' +import * as StripeUtils from '../stripe.utils' + +const Payment = getPaymentModel(mongoose) +const EncryptPendingSubmission = getEncryptPendingSubmissionModel(mongoose) + +const MOCK_FORM_ID = new ObjectId().toHexString() + +jest.mock('axios') +jest.mock('src/app/modules/payments/stripe.utils') +jest.mock('src/app/utils/convert-html-to-pdf') + +jest.mock( + 'src/app/modules/submission/encrypt-submission/encrypt-submission.service', +) +const MockEncryptSubmissionService = jest.mocked(EncryptSubmissionService) + +jest.mock('../../form/form.service') +const MockFormService = jest.mocked(FormService) + +describe('stripe.controller', () => { + beforeAll(async () => await dbHandler.connect()) + afterAll(async () => await dbHandler.closeDatabase()) + beforeEach(() => jest.clearAllMocks()) + + describe('downloadPaymentInvoice', () => { + const mockBusinessInfo = { + address: 'localhost', + gstRegNo: 'G123456', + } + const mockFormTitle = 'Mock Form Title' + const mockSubmissionId = 'MOCK_SUBMISSION_ID' + const mockForm = { + _id: MOCK_FORM_ID, + admin: { + agency: { + business: mockBusinessInfo, + }, + }, + title: mockFormTitle, + } as IPopulatedForm + + let payment: IPaymentSchema + + beforeEach(async () => { + await dbHandler.clearCollection(Payment.collection.name) + await dbHandler.clearCollection(EncryptPendingSubmission.collection.name) + const pendingSubmission = await EncryptPendingSubmission.create({ + submissionType: SubmissionType.Encrypt, + form: MOCK_FORM_ID, + encryptedContent: 'some random encrypted content', + version: 1, + }) + + payment = await Payment.create({ + formId: mockForm._id, + targetAccountId: 'acct_MOCK_ACCOUNT_ID', + pendingSubmissionId: pendingSubmission._id, + amount: 12345, + status: PaymentStatus.Succeeded, + paymentIntentId: 'pi_MOCK_PAYMENT_INTENT', + email: 'formsg@tech.gov.sg', + completedPayment: { + receiptUrl: 'https://form.gov.sg', + submissionId: mockSubmissionId, + }, + gstEnabled: false, + }) + }) + + it('should reject when receipt url is not present', async () => { + // Arrange + MockFormService.retrieveFullFormById.mockReturnValue(okAsync(mockForm)) + MockEncryptSubmissionService.checkFormIsEncryptMode.mockReturnValue( + ok(mockForm as IPopulatedEncryptedForm), + ) + const mockReq = expressHandler.mockRequest({ + params: { formId: mockForm._id, paymentId: payment._id }, + }) + const mockRes = expressHandler.mockResponse() + const checkStripeReceiptIsReadySpy = jest + .spyOn(PaymentProofService, 'checkStripeReceiptIsReady') + .mockReturnValueOnce( + errAsync(new StripeFetchError('Receipt url not ready')), + ) + + // Act + await PaymentProofController.downloadPaymentInvoice( + mockReq, + mockRes, + jest.fn(), + ) + + // Assert + expect(checkStripeReceiptIsReadySpy).toHaveBeenCalledOnce() + expect(mockRes.status).toHaveBeenCalledWith(404) + }) + + it('should reject when receipt download from stripe fails', async () => { + // Arrange + MockFormService.retrieveFullFormById.mockReturnValue(okAsync(mockForm)) + MockEncryptSubmissionService.checkFormIsEncryptMode.mockReturnValue( + ok(mockForm as IPopulatedEncryptedForm), + ) + const mockReq = expressHandler.mockRequest({ + params: { formId: mockForm._id, paymentId: payment._id }, + }) + const mockRes = expressHandler.mockResponse() + const axiosSpy = jest + .spyOn(axios, 'get') + .mockRejectedValueOnce({ data: 'missing resource' }) + + const retrieveReceiptUrlFromStripeSpy = jest + .spyOn(PaymentProofService, '_retrieveReceiptUrlFromStripe') + .mockReturnValueOnce(okAsync('https://form.gov.sg')) + + // Act + await PaymentProofController.downloadPaymentInvoice( + mockReq, + mockRes, + jest.fn(), + ) + + // Assert + expect(MockFormService.retrieveFullFormById).toHaveBeenCalledWith( + mockForm._id, + ) + expect(retrieveReceiptUrlFromStripeSpy).toHaveBeenCalledOnce() + expect(axiosSpy).toHaveBeenCalledOnce() + expect(mockRes.status).toHaveBeenCalledWith(404) + }) + + it('should return with error if upload to s3 fails', async () => { + // Arrange + MockFormService.retrieveFullFormById.mockReturnValue(okAsync(mockForm)) + MockEncryptSubmissionService.checkFormIsEncryptMode.mockReturnValue( + ok(mockForm as IPopulatedEncryptedForm), + ) + + const mockReq = expressHandler.mockRequest({ + params: { formId: mockForm._id, paymentId: payment._id }, + }) + const mockRes = expressHandler.mockResponse() + const axiosSpy = jest + .spyOn(axios, 'get') + .mockResolvedValueOnce({ data: 'some html' }) + + const convertInvoiceSpy = jest + .spyOn(StripeUtils, 'convertToProofOfPaymentFormat') + .mockReturnValueOnce('some converted html') + + const generatePdfFromHtmlSpy = jest + .spyOn(ConvertHtmlToPdf, 'generatePdfFromHtml') + .mockResolvedValueOnce(Buffer.from('123')) + + const retrieveReceiptUrlFromStripeSpy = jest + .spyOn(PaymentProofService, '_retrieveReceiptUrlFromStripe') + .mockReturnValueOnce(okAsync('https://form.gov.sg')) + + const storePaymentProofInS3Spy = jest + .spyOn(PaymentProofService, '_storePaymentProofInS3') + .mockReturnValueOnce(errAsync(new PaymentProofUploadS3Error())) + + const mockRedirectUrl = 'mockRedirectUrl' + const getPaymentProofPresignedS3UrlSpy = jest + .spyOn(PaymentProofService, '_getPaymentProofPresignedS3Url') + .mockReturnValueOnce(okAsync(mockRedirectUrl)) + + // Act + await PaymentProofController.downloadPaymentInvoice( + mockReq, + mockRes, + jest.fn(), + ) + + // Assert + expect(MockFormService.retrieveFullFormById).toHaveBeenCalledWith( + mockForm._id, + ) + expect(retrieveReceiptUrlFromStripeSpy).toHaveBeenCalledOnce() + expect(axiosSpy).toHaveBeenCalledOnce() + expect(convertInvoiceSpy).toHaveBeenCalledOnce() + expect(generatePdfFromHtmlSpy).toHaveBeenCalledOnce() + expect(storePaymentProofInS3Spy).toHaveBeenCalledOnce() + expect(getPaymentProofPresignedS3UrlSpy).not.toHaveBeenCalled() + expect(mockRes.status).toHaveBeenCalledWith(404) + }) + + it('should return with redirect link', async () => { + // Arrange + MockFormService.retrieveFullFormById.mockReturnValue(okAsync(mockForm)) + MockEncryptSubmissionService.checkFormIsEncryptMode.mockReturnValue( + ok(mockForm as IPopulatedEncryptedForm), + ) + + const mockReq = expressHandler.mockRequest({ + params: { formId: mockForm._id, paymentId: payment._id }, + }) + const mockRes = expressHandler.mockResponse() + const axiosSpy = jest + .spyOn(axios, 'get') + .mockResolvedValueOnce({ data: 'some html' }) + + const convertInvoiceSpy = jest + .spyOn(StripeUtils, 'convertToProofOfPaymentFormat') + .mockReturnValueOnce('some converted html') + + const generatePdfFromHtmlSpy = jest + .spyOn(ConvertHtmlToPdf, 'generatePdfFromHtml') + .mockResolvedValueOnce(Buffer.from('123')) + + const retrieveReceiptUrlFromStripeSpy = jest + .spyOn(PaymentProofService, '_retrieveReceiptUrlFromStripe') + .mockReturnValueOnce(okAsync('https://form.gov.sg')) + + const storePaymentProofInS3Spy = jest + .spyOn(PaymentProofService, '_storePaymentProofInS3') + .mockReturnValueOnce(okAsync(true)) + + const mockRedirectUrl = 'mockRedirectUrl' + const getPaymentProofPresignedS3UrlSpy = jest + .spyOn(PaymentProofService, '_getPaymentProofPresignedS3Url') + .mockReturnValueOnce(okAsync(mockRedirectUrl)) + + // Act + await PaymentProofController.downloadPaymentInvoice( + mockReq, + mockRes, + jest.fn(), + ) + + // Assert + expect(MockFormService.retrieveFullFormById).toHaveBeenCalledWith( + mockForm._id, + ) + expect(retrieveReceiptUrlFromStripeSpy).toHaveBeenCalledOnce() + expect(axiosSpy).toHaveBeenCalledOnce() + expect(convertInvoiceSpy).toHaveBeenCalledOnce() + expect(generatePdfFromHtmlSpy).toHaveBeenCalledOnce() + expect(storePaymentProofInS3Spy).toHaveBeenCalledOnce() + expect(getPaymentProofPresignedS3UrlSpy).toHaveBeenCalledOnce() + expect(mockRes.redirect).toHaveBeenCalledWith(mockRedirectUrl) + }) + }) +}) diff --git a/src/app/modules/payments/__tests__/stripe.controller.spec.ts b/src/app/modules/payments/__tests__/stripe.controller.spec.ts index 0c57459d0e..b316e83124 100644 --- a/src/app/modules/payments/__tests__/stripe.controller.spec.ts +++ b/src/app/modules/payments/__tests__/stripe.controller.spec.ts @@ -1,22 +1,15 @@ import dbHandler from '__tests__/unit/backend/helpers/jest-db' import expressHandler from '__tests__/unit/backend/helpers/jest-express' -import axios from 'axios' import { ObjectId } from 'bson' import { StatusCodes } from 'http-status-codes' import mongoose from 'mongoose' import { errAsync, ok, okAsync } from 'neverthrow' -import { PaymentStatus, ProductItem, SubmissionType } from 'shared/types' import Stripe from 'stripe' import { MarkRequired } from 'ts-essentials' import getPaymentModel from 'src/app/models/payment.server.model' import { getEncryptPendingSubmissionModel } from 'src/app/models/pending_submission.server.model' -import * as ConvertHtmlToPdf from 'src/app/utils/convert-html-to-pdf' -import { - IPaymentSchema, - IPopulatedEncryptedForm, - IPopulatedForm, -} from 'src/types' +import { IPopulatedEncryptedForm, IPopulatedForm } from 'src/types' import config from '../../../config/config' import { FormNotFoundError } from '../../form/form.errors' @@ -24,14 +17,12 @@ import * as FormService from '../../form/form.service' import * as EncryptSubmissionService from '../../submission/encrypt-submission/encrypt-submission.service' import * as StripeController from '../stripe.controller' import * as StripeService from '../stripe.service' -import * as StripeUtils from '../stripe.utils' const Payment = getPaymentModel(mongoose) const EncryptPendingSubmission = getEncryptPendingSubmissionModel(mongoose) const MOCK_FORM_ID = new ObjectId().toHexString() -jest.mock('axios') jest.mock('src/app/modules/payments/stripe.utils') jest.mock('src/app/utils/convert-html-to-pdf') @@ -44,7 +35,7 @@ jest.mock('../../form/form.service') const MockFormService = jest.mocked(FormService) jest.mock('src/app/modules/payments/stripe.service', () => { - const allAutoMocked = jest.createMockFromModule( + const allAutoMocked = jest.createMockFromModule( 'src/app/modules/payments/stripe.service', ) const actual = jest.requireActual('src/app/modules/payments/stripe.service') @@ -65,135 +56,6 @@ describe('stripe.controller', () => { afterAll(async () => await dbHandler.closeDatabase()) beforeEach(() => jest.clearAllMocks()) - describe('downloadPaymentInvoice', () => { - const mockBusinessInfo = { - address: 'localhost', - gstRegNo: 'G123456', - } - const mockFormTitle = 'Mock Form Title' - const mockSubmissionId = 'MOCK_SUBMISSION_ID' - const mockProducts: ProductItem[] = expect.any(Array) - const mockInvoiceArgs = { - ...mockBusinessInfo, - formTitle: mockFormTitle, - submissionId: mockSubmissionId, - gstApplicable: false, - products: mockProducts, - } - const mockForm = { - _id: MOCK_FORM_ID, - admin: { - agency: { - business: mockBusinessInfo, - }, - }, - title: mockFormTitle, - } as IPopulatedForm - - let payment: IPaymentSchema - - beforeEach(async () => { - await dbHandler.clearCollection(Payment.collection.name) - await dbHandler.clearCollection(EncryptPendingSubmission.collection.name) - const pendingSubmission = await EncryptPendingSubmission.create({ - submissionType: SubmissionType.Encrypt, - form: MOCK_FORM_ID, - encryptedContent: 'some random encrypted content', - version: 1, - }) - - payment = await Payment.create({ - formId: mockForm._id, - targetAccountId: 'acct_MOCK_ACCOUNT_ID', - pendingSubmissionId: pendingSubmission._id, - amount: 12345, - status: PaymentStatus.Succeeded, - paymentIntentId: 'pi_MOCK_PAYMENT_INTENT', - email: 'formsg@tech.gov.sg', - completedPayment: { - receiptUrl: 'http://form.gov.sg', - submissionId: mockSubmissionId, - }, - gstEnabled: false, - }) - }) - it('should generate return a pdf file when receipt url is present', async () => { - MockFormService.retrieveFullFormById.mockReturnValue(okAsync(mockForm)) - MockEncryptSubmissionService.checkFormIsEncryptMode.mockReturnValue( - ok(mockForm as IPopulatedEncryptedForm), - ) - const mockReq = expressHandler.mockRequest({ - params: { formId: mockForm._id, paymentId: payment._id }, - }) - const mockRes = expressHandler.mockResponse() - const axiosSpy = jest - .spyOn(axios, 'get') - .mockResolvedValueOnce({ data: 'some html' }) - - const convertInvoiceSpy = jest - .spyOn(StripeUtils, 'convertToProofOfPaymentFormat') - .mockReturnValueOnce('some converted html') - - const generatePdfFromHtmlSpy = jest - .spyOn(ConvertHtmlToPdf, 'generatePdfFromHtml') - .mockReturnValueOnce(Promise.resolve(Buffer.from('123'))) - - // Act - await StripeController.downloadPaymentInvoice(mockReq, mockRes, jest.fn()) - expect(MockFormService.retrieveFullFormById).toHaveBeenCalledWith( - mockForm._id, - ) - - // Assert - expect(axiosSpy).toHaveBeenCalledOnce() - expect(convertInvoiceSpy).toHaveBeenCalledWith( - expect.any(String), - mockInvoiceArgs, - ) - expect(generatePdfFromHtmlSpy).toHaveBeenCalledWith(expect.any(String)) - expect(mockRes.send).toHaveBeenCalledOnce() - expect(mockRes.status).toHaveBeenCalledWith(200) - }) - - it('should return 404 if StripeService returns error', async () => { - MockFormService.retrieveFullFormById.mockReturnValue(okAsync(mockForm)) - MockEncryptSubmissionService.checkFormIsEncryptMode.mockReturnValue( - ok(mockForm as IPopulatedEncryptedForm), - ) - - const mockReq = expressHandler.mockRequest({ - params: { formId: mockForm._id, paymentId: payment._id }, - }) - const mockRes = expressHandler.mockResponse() - const axiosSpy = jest - .spyOn(axios, 'get') - .mockRejectedValueOnce({ data: 'missing resource' }) - - const convertInvoiceSpy = jest.spyOn( - StripeUtils, - 'convertToProofOfPaymentFormat', - ) - - const generatePdfFromHtmlSpy = jest.spyOn( - ConvertHtmlToPdf, - 'generatePdfFromHtml', - ) - - // Act - await StripeController.downloadPaymentInvoice(mockReq, mockRes, jest.fn()) - expect(MockFormService.retrieveFullFormById).toHaveBeenCalledWith( - mockForm._id, - ) - - // Assert - expect(axiosSpy).toHaveBeenCalledOnce() - expect(convertInvoiceSpy).not.toHaveBeenCalled() - expect(generatePdfFromHtmlSpy).not.toHaveBeenCalled() - expect(mockRes.json).toHaveBeenCalledOnce() - expect(mockRes.status).toHaveBeenCalledWith(404) - }) - }) - describe('_handleConnectOauthCallback', () => { beforeEach(async () => { await dbHandler.clearCollection(Payment.collection.name) diff --git a/src/app/modules/payments/payment-proof.controller.ts b/src/app/modules/payments/payment-proof.controller.ts new file mode 100644 index 0000000000..2d0993b0e1 --- /dev/null +++ b/src/app/modules/payments/payment-proof.controller.ts @@ -0,0 +1,76 @@ +import { StatusCodes } from 'http-status-codes' +import { ResultAsync } from 'neverthrow' + +import { createLoggerWithLabel } from '../../config/logger' +import { ControllerHandler } from '../core/core.types' +import * as FormService from '../form/form.service' +import { checkFormIsEncryptMode } from '../submission/encrypt-submission/encrypt-submission.service' + +import * as PaymentProofService from './payment-proof.service' +import * as PaymentService from './payments.service' + +const logger = createLoggerWithLabel(module) +/** + * Handler for GET /api/v3/payments/:formId/:paymentId/invoice/download + * Receives Stripe webhooks and updates the database with transaction details. + * + * @returns 200 if webhook is successfully processed + * @returns 404 if the PaymentId is not found + * @returns 404 if the FormId is not found + * @returns 404 if payment.completedPayment?.receiptUrl is not found + */ +export const downloadPaymentInvoice: ControllerHandler<{ + formId: string + paymentId: string +}> = (req, res) => { + const { formId, paymentId } = req.params + logger.info({ + message: 'downloadPaymentInvoice endpoint called', + meta: { + action: 'downloadPaymentInvoice', + formId, + paymentId, + }, + }) + + return ResultAsync.combine([ + PaymentService.findPaymentById(paymentId), + FormService.retrieveFullFormById(formId).andThen(checkFormIsEncryptMode), + ]) + .andThen(([payment, populatedForm]) => { + logger.info({ + message: 'Found paymentId in payment document', + meta: { + action: 'downloadPaymentInvoice', + payment, + }, + }) + return PaymentProofService.generatePaymentInvoiceUrl( + payment, + populatedForm, + ) + }) + .map((pdfUrl) => { + logger.info({ + message: `received generated payment invoice url, redirecting to ${pdfUrl}`, + meta: { + action: 'downloadPaymentInvoice', + formId, + paymentId, + }, + }) + return res.redirect(pdfUrl) + }) + .mapErr((error) => { + logger.error({ + message: 'Error retrieving invoice', + meta: { + action: 'downloadPaymentInvoice', + formId, + paymentId, + }, + error, + }) + return res.status(StatusCodes.NOT_FOUND).json({ message: error }) + }) +} diff --git a/src/app/modules/payments/payment-proof.errors.ts b/src/app/modules/payments/payment-proof.errors.ts new file mode 100644 index 0000000000..63972b00c9 --- /dev/null +++ b/src/app/modules/payments/payment-proof.errors.ts @@ -0,0 +1,19 @@ +import { ApplicationError } from '../core/core.errors' + +export class InvoicePdfGenerationError extends ApplicationError { + constructor(message = 'Error while generating invoice pdf') { + super(message) + } +} + +export class PaymentProofUploadS3Error extends ApplicationError { + constructor(message = "Can't upload payment proof to S3") { + super(message) + } +} + +export class PaymentProofPresignS3Error extends ApplicationError { + constructor(message = "Can't generate payment proof presign url from S3") { + super(message) + } +} diff --git a/src/app/modules/payments/payment-proof.service.ts b/src/app/modules/payments/payment-proof.service.ts new file mode 100644 index 0000000000..56827df083 --- /dev/null +++ b/src/app/modules/payments/payment-proof.service.ts @@ -0,0 +1,263 @@ +import axios from 'axios' +import { errAsync, ok, okAsync, ResultAsync } from 'neverthrow' +import Stripe from 'stripe' + +import { + ICompletedPaymentSchema, + IPaymentSchema, + IPopulatedEncryptedForm, + IPopulatedForm, +} from '../../../types' +import { aws as AwsConfig } from '../../config/config' +import { createLoggerWithLabel } from '../../config/logger' +import { DAY_IN_SECONDS } from '../../constants/time' +import { stripe } from '../../loaders/stripe' +import { generatePdfFromHtml } from '../../utils/convert-html-to-pdf' + +import { + InvoicePdfGenerationError, + PaymentProofPresignS3Error, + PaymentProofUploadS3Error, +} from './payment-proof.errors' +import { getPaymentProofS3ObjectPath } from './payment-proof.utils' +import { StripeFetchError } from './stripe.errors' +import { convertToProofOfPaymentFormat } from './stripe.utils' + +const logger = createLoggerWithLabel(module) +export const checkStripeReceiptIsReady = ( + payment: IPaymentSchema, +): ResultAsync => { + if (!payment.completedPayment?.receiptUrl) { + return errAsync(new StripeFetchError('Receipt url not ready')) + } + return okAsync(payment as ICompletedPaymentSchema) +} + +/** + * @Exported only for testing purposes + * + * Function that stores payment proof into s3 + * + * @param {IPaymentSchema} payment the payment object, used to form the object path + * @param {Buffer} pdfBuffer the pdf to store into s3 + * + * @returns ok(undefined) if no errors are thrown while uploading to s3 + * @returns err(InvoiceUploadS3Error) if an error is thrown while uploading to s3 + */ +export const _storePaymentProofInS3 = ( + payment: ICompletedPaymentSchema, + pdfBuffer: Buffer, +): ResultAsync => { + const objectPath = getPaymentProofS3ObjectPath(payment) + + logger.info({ + message: 'Uploading payment proof to s3', + meta: { + action: '_storePaymentProofInS3', + paymentId: payment._id, + objectPath, + bucket: AwsConfig.paymentProofS3Bucket, + }, + }) + + return ResultAsync.fromPromise( + AwsConfig.s3 + .upload({ + Bucket: AwsConfig.paymentProofS3Bucket, + Key: objectPath, + Body: Buffer.from(pdfBuffer), + }) + .promise(), + (error) => { + logger.error({ + message: 'Error occured whilst uploading pdfBuffer to S3', + meta: { + action: 'storePaymentProofInS3', + paymentId: payment._id, + objectPath, + }, + error, + }) + return new PaymentProofUploadS3Error() + }, + ) + .map(() => { + payment.completedPayment = { + ...payment.completedPayment, + hasReceiptStoredInS3: true, + } + + return ResultAsync.fromPromise(payment.save(), (error) => { + logger.error({ + message: 'Error occured whilst updating payment', + meta: { + action: 'storePaymentProofInS3', + paymentId: payment._id, + objectPath, + }, + error, + }) + return new PaymentProofUploadS3Error() + }) + }) + .map(() => { + return true + }) +} + +/** + * @Exported only for testing purposes + * + * Function to generates a presigned url for payment proof stored in s3 + * + * Presigned link expires in 1 day; URL is returned as a redirected immediate download link, thus the link is not meant to be long lasting + * + * @param {IPaymentSchema} payment the payment object, used to form the object path + * + * @returns ok(string) which represents the presigned url if no errors are thrown while generating the presigned url + * @returns err(InvoicePresignS3Error) if an error is thrown while generating the presigned link + */ +export const _getPaymentProofPresignedS3Url = ( + payment: IPaymentSchema, +): ResultAsync => { + const objectPath = getPaymentProofS3ObjectPath(payment) + + logger.info({ + message: 'Generating payment proof presigned s3 link', + meta: { + action: '_getPaymentProofPresignedS3Url', + paymentId: payment._id, + objectPath, + }, + }) + return ResultAsync.fromPromise( + AwsConfig.s3.getSignedUrlPromise('getObject', { + Bucket: AwsConfig.paymentProofS3Bucket, + Key: objectPath, + Expires: 1 * DAY_IN_SECONDS, + }), + (error) => { + logger.error({ + message: 'Error occured whilst retrieving signed URL from S3', + meta: { + action: 'getPresignedS3Invoice', + paymentId: payment._id, + objectPath, + }, + error, + }) + return new PaymentProofPresignS3Error() + }, + ) +} + +/** + * @Exported only for testing purposes + */ +export const _retrieveReceiptUrlFromStripe = ( + payment: ICompletedPaymentSchema, +): ResultAsync => { + return ResultAsync.fromPromise( + stripe.paymentIntents.retrieve( + payment.paymentIntentId, + { expand: ['latest_charge'] }, + { stripeAccount: payment.targetAccountId }, + ), + (error) => new StripeFetchError(String(error)), + ).andThen((paymentIntent) => { + const receiptUrl = (paymentIntent.latest_charge as Stripe.Charge) + .receipt_url + if (!receiptUrl) { + return errAsync( + new StripeFetchError('Receipt url not found in stripe latest_charge'), + ) + } + return ok(receiptUrl) + }) +} + +const _generatePaymentInvoiceAsPdf = ( + payment: ICompletedPaymentSchema, + populatedForm: IPopulatedEncryptedForm, + receiptUrl: string, +): ResultAsync => { + if (!payment.completedPayment?.receiptUrl) { + return errAsync(new StripeFetchError('Receipt url not ready')) + } + return ResultAsync.fromPromise( + axios.get(receiptUrl), + (error) => new StripeFetchError(String(error)), + ).andThen((receiptUrlResponse) => { + // retrieve receiptURL as html + const html = receiptUrlResponse.data + const agencyBusinessInfo = (populatedForm as IPopulatedForm).admin.agency + .business + const formBusinessInfo = populatedForm.business + + const businessAddress = [ + formBusinessInfo?.address, + agencyBusinessInfo?.address, + ].find(Boolean) + + const businessGstRegNo = [ + formBusinessInfo?.gstRegNo, + agencyBusinessInfo?.gstRegNo, + ].find(Boolean) + + // we will still continute the invoice generation even if there's no address/gstregno + if (!businessAddress || !businessGstRegNo) + logger.warn({ + message: + 'Some business info not available during invoice generation. Expecting either agency or form to have business info', + meta: { + action: 'downloadPaymentInvoice', + payment, + agencyName: populatedForm.admin.agency.fullName, + agencyBusinessInfo, + formBusinessInfo, + }, + }) + const invoiceHtml = convertToProofOfPaymentFormat(html, { + address: businessAddress || '', + gstRegNo: businessGstRegNo || '', + formTitle: populatedForm.title, + submissionId: payment.completedPayment?.submissionId || '', + gstApplicable: payment.gstEnabled, + products: payment.products || [], + }) + + return ResultAsync.fromPromise( + generatePdfFromHtml(invoiceHtml), + (error) => new InvoicePdfGenerationError(String(error)), + ) + }) +} + +export const generatePaymentInvoiceUrl = ( + payment: IPaymentSchema, + populatedForm: IPopulatedEncryptedForm, +): ResultAsync< + string, + | StripeFetchError + | PaymentProofUploadS3Error + | PaymentProofPresignS3Error + | InvoicePdfGenerationError +> => { + if (!payment.completedPayment?.hasReceiptStoredInS3) { + return checkStripeReceiptIsReady(payment).andThen((completedPayment) => + _retrieveReceiptUrlFromStripe(completedPayment) + .andThen((receiptUrl) => + _generatePaymentInvoiceAsPdf( + completedPayment, + populatedForm, + receiptUrl, + ), + ) + .andThen((pdfBuffer) => + _storePaymentProofInS3(completedPayment, pdfBuffer), + ) + .andThen(() => _getPaymentProofPresignedS3Url(completedPayment)), + ) + } + return _getPaymentProofPresignedS3Url(payment) +} diff --git a/src/app/modules/payments/payment-proof.utils.ts b/src/app/modules/payments/payment-proof.utils.ts new file mode 100644 index 0000000000..20dd5dfbe6 --- /dev/null +++ b/src/app/modules/payments/payment-proof.utils.ts @@ -0,0 +1,7 @@ +import { IPaymentSchema } from '../../../types' + +export const getPaymentProofS3ObjectPath = ( + payment: Pick, +) => { + return payment.formId + '/' + payment._id + '.pdf' +} diff --git a/src/app/modules/payments/payments.service.ts b/src/app/modules/payments/payments.service.ts index 90552c148f..324cf9f4f3 100644 --- a/src/app/modules/payments/payments.service.ts +++ b/src/app/modules/payments/payments.service.ts @@ -183,6 +183,7 @@ export const confirmPaymentPendingSubmission = ( paymentDate, receiptUrl, transactionFee, + hasReceiptStoredInS3: false, } return okAsync(payment) }) diff --git a/src/app/modules/payments/stripe.controller.ts b/src/app/modules/payments/stripe.controller.ts index ca05057d78..de75d04e07 100644 --- a/src/app/modules/payments/stripe.controller.ts +++ b/src/app/modules/payments/stripe.controller.ts @@ -77,64 +77,6 @@ export const checkPaymentReceiptStatus: ControllerHandler<{ }) } -/** - * Handler for GET /api/v3/payments/:formId/:paymentId/invoice/download - * Receives Stripe webhooks and updates the database with transaction details. - * - * @returns 200 if webhook is successfully processed - * @returns 404 if the PaymentId is not found - * @returns 404 if the FormId is not found - * @returns 404 if payment.completedPayment?.receiptUrl is not found - */ -export const downloadPaymentInvoice: ControllerHandler<{ - formId: string - paymentId: string -}> = (req, res) => { - const { formId, paymentId } = req.params - logger.info({ - message: 'downloadPaymentInvoice endpoint called', - meta: { - action: 'downloadPaymentInvoice', - formId, - paymentId, - }, - }) - - return ResultAsync.combine([ - PaymentService.findPaymentById(paymentId), - FormService.retrieveFullFormById(formId).andThen(checkFormIsEncryptMode), - ]) - .andThen(([payment, populatedForm]) => { - logger.info({ - message: 'Found paymentId in payment document', - meta: { - action: 'downloadPaymentInvoice', - payment, - }, - }) - return StripeService.generatePaymentInvoice(payment, populatedForm) - }) - .map((pdfBuffer) => { - res.set({ - 'Content-Type': 'application/pdf', - 'Content-Disposition': `attachment; filename=${paymentId}-proofofpayment.pdf`, - }) - return res.status(StatusCodes.OK).send(pdfBuffer) - }) - .mapErr((error) => { - logger.error({ - message: 'Error retrieving invoice', - meta: { - action: 'downloadPaymentInvoice', - formId, - paymentId, - }, - error, - }) - return res.status(StatusCodes.NOT_FOUND).json({ message: error }) - }) -} - const _handleConnectOauthCallback: ControllerHandler< unknown, unknown, diff --git a/src/app/modules/payments/stripe.service.ts b/src/app/modules/payments/stripe.service.ts index aa3d8ef335..a3562ad00e 100644 --- a/src/app/modules/payments/stripe.service.ts +++ b/src/app/modules/payments/stripe.service.ts @@ -1,6 +1,5 @@ // Use 'stripe-event-types' for better type discrimination. /// -import axios from 'axios' import cuid from 'cuid' import mongoose from 'mongoose' import { errAsync, ok, okAsync, ResultAsync } from 'neverthrow' @@ -17,13 +16,11 @@ import { IEncryptedFormSchema, IPaymentSchema, IPopulatedEncryptedForm, - IPopulatedForm, } from '../../../types' import config from '../../config/config' import { paymentConfig } from '../../config/features/payment.config' import { createLoggerWithLabel } from '../../config/logger' import { stripe } from '../../loaders/stripe' -import { generatePdfFromHtml } from '../../utils/convert-html-to-pdf' import { getMongoErrorMessage, transformMongoError, @@ -57,7 +54,6 @@ import { import { computePaymentState, computePayoutDetails, - convertToProofOfPaymentFormat, getChargeIdFromNestedCharge, getMetadataPaymentId, } from './stripe.utils' @@ -1008,59 +1004,3 @@ export const verifyPaymentStatusWithStripe = ( } }) } - -export const generatePaymentInvoice = ( - payment: IPaymentSchema, - populatedForm: IPopulatedEncryptedForm, -): ResultAsync => { - if (!payment.completedPayment?.receiptUrl) { - return errAsync(new StripeFetchError('Receipt url not ready')) - } - return ResultAsync.fromPromise( - axios.get(payment.completedPayment.receiptUrl), - (error) => new StripeFetchError(String(error)), - ).andThen((receiptUrlResponse) => { - // retrieve receiptURL as html - const html = receiptUrlResponse.data - const agencyBusinessInfo = (populatedForm as IPopulatedForm).admin.agency - .business - const formBusinessInfo = populatedForm.business - - const businessAddress = [ - formBusinessInfo?.address, - agencyBusinessInfo?.address, - ].find(Boolean) - - const businessGstRegNo = [ - formBusinessInfo?.gstRegNo, - agencyBusinessInfo?.gstRegNo, - ].find(Boolean) - - // we will still continute the invoice generation even if there's no address/gstregno - if (!businessAddress || !businessGstRegNo) - logger.warn({ - message: - 'Some business info not available during invoice generation. Expecting either agency or form to have business info', - meta: { - action: 'downloadPaymentInvoice', - payment, - agencyName: populatedForm.admin.agency.fullName, - agencyBusinessInfo, - formBusinessInfo, - }, - }) - const invoiceHtml = convertToProofOfPaymentFormat(html, { - address: businessAddress || '', - gstRegNo: businessGstRegNo || '', - formTitle: populatedForm.title, - submissionId: payment.completedPayment?.submissionId || '', - gstApplicable: payment.gstEnabled, - products: payment.products || [], - }) - - return ResultAsync.fromPromise( - generatePdfFromHtml(invoiceHtml), - (error) => new StripeFetchError(String(error)), - ) - }) -} diff --git a/src/app/routes/api/v3/payments/payments.routes.ts b/src/app/routes/api/v3/payments/payments.routes.ts index 19c27e72d9..038de35d5b 100644 --- a/src/app/routes/api/v3/payments/payments.routes.ts +++ b/src/app/routes/api/v3/payments/payments.routes.ts @@ -2,6 +2,7 @@ import { Router } from 'express' import { rateLimitConfig } from '../../../../config/config' import { withCronPaymentSecretAuthentication } from '../../../../modules/auth/auth.middlewares' +import * as PaymentProofController from '../../../../modules/payments/payment-proof.controller' import * as PaymentsController from '../../../../modules/payments/payments.controller' import * as StripeController from '../../../../modules/payments/stripe.controller' import { limitRate } from '../../../../utils/limit-rate' @@ -30,7 +31,7 @@ PaymentsRouter.get( PaymentsRouter.get( '/:formId([a-fA-F0-9]{24})/:paymentId([a-fA-F0-9]{24})/invoice/download', limitRate({ max: rateLimitConfig.downloadPaymentReceipt }), - StripeController.downloadPaymentInvoice, + PaymentProofController.downloadPaymentInvoice, ) PaymentsRouter.get( diff --git a/src/types/config.ts b/src/types/config.ts index ca6cf3ff2f..7a90f16ef9 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -32,6 +32,7 @@ export type AwsConfig = { imageS3Bucket: string logoS3Bucket: string attachmentS3Bucket: string + paymentProofS3Bucket: string region: string logoBucketUrl: string imageBucketUrl: string @@ -128,6 +129,7 @@ export interface ICompulsoryVarsSchema { attachmentS3Bucket: string virusScannerQuarantineS3Bucket: string virusScannerCleanS3Bucket: string + paymentProofS3Bucket: string } } @@ -203,5 +205,6 @@ export interface IBucketUrlSchema { staticAssetsBucketUrl: string virusScannerQuarantineS3BucketUrl: string virusScannerCleanS3BucketUrl: string + paymentProofS3BucketUrl: string endPoint: string } diff --git a/src/types/payment.ts b/src/types/payment.ts index b8f1b49068..1310ee284e 100644 --- a/src/types/payment.ts +++ b/src/types/payment.ts @@ -11,6 +11,10 @@ export interface IPaymentSchema extends Payment, Document { responses: any[] } +export interface ICompletedPaymentSchema extends IPaymentSchema { + completedPayment: NonNullable +} + export interface IPaymentModel extends Model { /** * Gets payment documents by status