Skip to content

Commit

Permalink
feat(payment): invoice through s3 (#6733)
Browse files Browse the repository at this point in the history
* 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 e0214db.

* 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
  • Loading branch information
KenLSM authored Sep 27, 2023
1 parent 01ebcd3 commit 2c6c47e
Show file tree
Hide file tree
Showing 21 changed files with 670 additions and 259 deletions.
1 change: 1 addition & 0 deletions .template-env
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
1 change: 1 addition & 0 deletions __tests__/setup/.test-env
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions init-localstack.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions shared/types/payment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export type CompletedPaymentMeta = {
submissionId: string
transactionFee: number
receiptUrl: string
hasReceiptStoredInS3: boolean
}

export type PayoutMeta = {
Expand Down
1 change: 1 addition & 0 deletions src/app/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
12 changes: 12 additions & 0 deletions src/app/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,12 @@ export const compulsoryVarsSchema: Schema<ICompulsoryVarsSchema> = {
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,
Expand Down Expand Up @@ -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,
},
}
}
1 change: 1 addition & 0 deletions src/app/constants/time.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const DAY_IN_SECONDS = 24 * 60 * 60
5 changes: 5 additions & 0 deletions src/app/models/payment.server.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,11 @@ const PaymentSchema = new Schema<IPaymentSchema, IPaymentModel>(
type: String,
required: true,
},
hasReceiptStoredInS3: {
type: Boolean,
required: true,
default: false,
},
},
},

Expand Down
267 changes: 267 additions & 0 deletions src/app/modules/payments/__tests__/payment-proof.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -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: '[email protected]',
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: '<html>some html</html>' })

const convertInvoiceSpy = jest
.spyOn(StripeUtils, 'convertToProofOfPaymentFormat')
.mockReturnValueOnce('<html>some converted html</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: '<html>some html</html>' })

const convertInvoiceSpy = jest
.spyOn(StripeUtils, 'convertToProofOfPaymentFormat')
.mockReturnValueOnce('<html>some converted html</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)
})
})
})
Loading

0 comments on commit 2c6c47e

Please sign in to comment.