From 3d4d832c9d69a07fef94ee1085e596343bf9eafb Mon Sep 17 00:00:00 2001 From: Ken Lee Shu Ming Date: Mon, 5 Feb 2024 14:08:37 +0800 Subject: [PATCH] feat(payment): webhook with charge information (#7058) * feat: populate payment fields * fix: incorrect populate params * fix: update test cases to include paymentContent * feat: update paymentFields to be object mapped instead of array * fix: fully resolve receipt download url * fix: update import from absolute to relative * test: add webhook tests for paymentContents * feat: set empty webhooks for fixed payment * refactor: add contextual comments for payment webhook event object * fix: typing on paymetnContent --- .../__tests__/submission.server.model.spec.ts | 206 +++++++++++++++++- src/app/models/submission.server.model.ts | 27 ++- .../modules/payments/payment.service.utils.ts | 37 ++++ src/app/modules/webhook/webhook.service.ts | 12 +- src/app/modules/webhook/webhook.types.ts | 7 + src/types/submission.ts | 15 +- 6 files changed, 285 insertions(+), 19 deletions(-) create mode 100644 src/app/modules/payments/payment.service.utils.ts diff --git a/src/app/models/__tests__/submission.server.model.spec.ts b/src/app/models/__tests__/submission.server.model.spec.ts index a81bf6e397..50d183351e 100644 --- a/src/app/models/__tests__/submission.server.model.spec.ts +++ b/src/app/models/__tests__/submission.server.model.spec.ts @@ -12,10 +12,12 @@ import getSubmissionModel, { import { BasicField, FormAuthType, + PaymentType, SubmissionType, WebhookResponse, } from '../../../../shared/types' import { ISubmissionSchema } from '../../../../src/types' +import getPaymentModel from '../payment.server.model' jest.mock('dns', () => ({ promises: { @@ -27,6 +29,7 @@ const MockDns = jest.mocked(dns) const Submission = getSubmissionModel(mongoose) const EncryptedSubmission = getEncryptSubmissionModel(mongoose) const EmailSubmission = getEmailSubmissionModel(mongoose) +const PaymentSubmission = getPaymentModel(mongoose) // TODO: Add more tests for the rest of the submission schema. describe('Submission Model', () => { @@ -265,6 +268,132 @@ describe('Submission Model', () => { verifiedContent: undefined, version: 0, created: submission.created, + paymentContent: {}, + }, + }, + }) + }) + + it('should return the paymentContent when the submission, payment, and webhook URL exist', async () => { + const { form } = await dbHandler.insertEncryptForm({ + formOptions: { + webhook: { + url: MOCK_WEBHOOK_URL, + isRetryEnabled: true, + }, + }, + }) + const submission = await EncryptedSubmission.create({ + form: form._id, + encryptedContent: MOCK_ENCRYPTED_CONTENT, + version: 0, + }) + + const MOCK_PAYMENT_INTENT_ID = 'MOCK_PAYMENT_INTENT_ID' + const MOCK_EMAIL = 'MOCK_EMAIL' + const MOCK_PAYMENT_STATUS = 'succeeded' + const payment = await PaymentSubmission.create({ + amount: 100, + paymentStatus: 'successful', + submission: submission._id, + gstEnabled: false, + paymentIntentId: MOCK_PAYMENT_INTENT_ID, + email: MOCK_EMAIL, + targetAccountId: 'targetAccountId', + formId: form._id, + pendingSubmissionId: submission._id, + status: MOCK_PAYMENT_STATUS, + }) + submission.paymentId = payment._id + await submission.save() + + const result = await EncryptedSubmission.retrieveWebhookInfoById( + String(submission._id), + ) + + expect(result).toEqual({ + webhookUrl: MOCK_WEBHOOK_URL, + isRetryEnabled: true, + webhookView: { + data: { + attachmentDownloadUrls: {}, + formId: String(form._id), + submissionId: String(submission._id), + encryptedContent: MOCK_ENCRYPTED_CONTENT, + verifiedContent: undefined, + version: 0, + created: submission.created, + paymentContent: { + amount: '1.00', + dateTime: '-', + payer: MOCK_EMAIL, + paymentIntent: MOCK_PAYMENT_INTENT_ID, + productService: '-', + status: MOCK_PAYMENT_STATUS, + transactionFee: '-', + type: 'payment_charge', + url: expect.stringContaining( + `api/v3/payments/${form._id}/${payment._id}/invoice/download`, + ), + }, + }, + }, + }) + }) + + it('should not return the paymentContent when the payment type is Fixed', async () => { + const { form } = await dbHandler.insertEncryptForm({ + formOptions: { + webhook: { + url: MOCK_WEBHOOK_URL, + isRetryEnabled: true, + }, + }, + }) + const submission = await EncryptedSubmission.create({ + form: form._id, + encryptedContent: MOCK_ENCRYPTED_CONTENT, + version: 0, + }) + + const MOCK_PAYMENT_INTENT_ID = 'MOCK_PAYMENT_INTENT_ID' + const MOCK_EMAIL = 'MOCK_EMAIL' + const MOCK_PAYMENT_STATUS = 'succeeded' + const payment = await PaymentSubmission.create({ + amount: 100, + paymentStatus: 'successful', + submission: submission._id, + gstEnabled: false, + paymentIntentId: MOCK_PAYMENT_INTENT_ID, + email: MOCK_EMAIL, + targetAccountId: 'targetAccountId', + formId: form._id, + pendingSubmissionId: submission._id, + status: MOCK_PAYMENT_STATUS, + payment_fields_snapshot: { + payment_type: PaymentType.Fixed, + }, + }) + submission.paymentId = payment._id + await submission.save() + + const result = await EncryptedSubmission.retrieveWebhookInfoById( + String(submission._id), + ) + + expect(result).toEqual({ + webhookUrl: MOCK_WEBHOOK_URL, + isRetryEnabled: true, + webhookView: { + data: { + attachmentDownloadUrls: {}, + formId: String(form._id), + submissionId: String(submission._id), + encryptedContent: MOCK_ENCRYPTED_CONTENT, + verifiedContent: undefined, + version: 0, + created: submission.created, + paymentContent: {}, }, }, }) @@ -318,6 +447,7 @@ describe('Submission Model', () => { verifiedContent: undefined, version: 0, created: submission.created, + paymentContent: {}, }, }, }) @@ -354,6 +484,7 @@ describe('Submission Model', () => { verifiedContent: undefined, version: 0, created: submission.created, + paymentContent: {}, }, }, }) @@ -365,7 +496,7 @@ describe('Submission Model', () => { // Arrange const formCounts = [4, 2, 4] const formIdsAndCounts = times(formCounts.length, (it) => ({ - _id: mongoose.Types.ObjectId(), + _id: new mongoose.Types.ObjectId(), count: formCounts[it], })) const submissionPromises: Promise[] = [] @@ -401,7 +532,7 @@ describe('Submission Model', () => { // Arrange const formCounts = [1, 1, 2] const formIdsAndCounts = times(formCounts.length, (it) => ({ - _id: mongoose.Types.ObjectId(), + _id: new mongoose.Types.ObjectId(), count: formCounts[it], })) const submissionPromises: Promise[] = [] @@ -436,6 +567,66 @@ describe('Submission Model', () => { describe('Methods', () => { describe('getWebhookView', () => { + it('should returnt non-null view with paymentContent when submission has paymentId', async () => { + const formId = new ObjectId() + + const submission = await EncryptedSubmission.create({ + submissionType: SubmissionType.Encrypt, + form: formId, + encryptedContent: MOCK_ENCRYPTED_CONTENT, + version: 1, + authType: FormAuthType.NIL, + myInfoFields: [], + webhookResponses: [], + }) + + const MOCK_PAYMENT_INTENT_ID = 'MOCK_PAYMENT_INTENT_ID' + const MOCK_EMAIL = 'MOCK_EMAIL' + const MOCK_PAYMENT_STATUS = 'succeeded' + const payment = await PaymentSubmission.create({ + amount: 100, + paymentStatus: 'successful', + submission: submission._id, + gstEnabled: false, + paymentIntentId: MOCK_PAYMENT_INTENT_ID, + email: MOCK_EMAIL, + targetAccountId: 'targetAccountId', + formId: formId, + pendingSubmissionId: submission._id, + status: MOCK_PAYMENT_STATUS, + }) + submission.paymentId = payment._id + await submission.save() + + // Act + const actualWebhookView = await submission.getWebhookView() + + // Assert + expect(actualWebhookView).toEqual({ + data: { + formId: expect.any(String), + submissionId: expect.any(String), + created: expect.any(Date), + encryptedContent: MOCK_ENCRYPTED_CONTENT, + verifiedContent: undefined, + attachmentDownloadUrls: {}, + version: 1, + paymentContent: { + amount: '1.00', + dateTime: '-', + payer: MOCK_EMAIL, + paymentIntent: MOCK_PAYMENT_INTENT_ID, + productService: '-', + status: MOCK_PAYMENT_STATUS, + transactionFee: '-', + type: 'payment_charge', + url: expect.stringContaining( + `api/v3/payments/${formId}/${payment._id}/invoice/download`, + ), + }, + }, + }) + }) it('should return non-null view with encryptedSubmission type when submission has no verified content', async () => { // Arrange const formId = new ObjectId() @@ -451,7 +642,7 @@ describe('Submission Model', () => { }) // Act - const actualWebhookView = submission.getWebhookView() + const actualWebhookView = await submission.getWebhookView() // Assert expect(actualWebhookView).toEqual({ @@ -463,6 +654,7 @@ describe('Submission Model', () => { verifiedContent: undefined, attachmentDownloadUrls: {}, version: 1, + paymentContent: {}, }, }) }) @@ -483,7 +675,7 @@ describe('Submission Model', () => { }) // Act - const actualWebhookView = submission.getWebhookView() + const actualWebhookView = await submission.getWebhookView() // Assert expect(actualWebhookView).toEqual({ @@ -495,6 +687,7 @@ describe('Submission Model', () => { encryptedContent: MOCK_ENCRYPTED_CONTENT, verifiedContent: MOCK_VERIFIED_CONTENT, version: 1, + paymentContent: {}, }, }) }) @@ -526,7 +719,7 @@ describe('Submission Model', () => { ).populate('form', 'webhook') // Act - const actualWebhookView = populatedSubmission!.getWebhookView() + const actualWebhookView = await populatedSubmission!.getWebhookView() // Assert expect(actualWebhookView).toEqual({ @@ -538,6 +731,7 @@ describe('Submission Model', () => { encryptedContent: MOCK_ENCRYPTED_CONTENT, verifiedContent: MOCK_VERIFIED_CONTENT, version: 1, + paymentContent: {}, }, }) }) @@ -559,7 +753,7 @@ describe('Submission Model', () => { }) // Act - const actualWebhookView = submission.getWebhookView() + const actualWebhookView = await submission.getWebhookView() // Assert expect(actualWebhookView).toBeNull() diff --git a/src/app/models/submission.server.model.ts b/src/app/models/submission.server.model.ts index a9c76bc658..e6df3bdac9 100644 --- a/src/app/models/submission.server.model.ts +++ b/src/app/models/submission.server.model.ts @@ -34,6 +34,7 @@ import { WebhookData, WebhookView, } from '../../types' +import { getPaymentWebhookEventObject } from '../modules/payments/payment.service.utils' import { createQueryWithDateParam } from '../utils/date' import { FORM_SCHEMA_ID } from './form.server.model' @@ -144,8 +145,8 @@ export const EmailSubmissionSchema = new Schema({ /** * Returns null as email submission does not have a webhook view */ -EmailSubmissionSchema.methods.getWebhookView = function (): null { - return null +EmailSubmissionSchema.methods.getWebhookView = function (): Promise { + return Promise.resolve(null) } const webhookResponseSchema = new Schema( @@ -209,9 +210,9 @@ export const EncryptSubmissionSchema = new Schema< * Returns an object which represents the encrypted submission * which will be posted to the webhook URL. */ -EncryptSubmissionSchema.methods.getWebhookView = function ( +EncryptSubmissionSchema.methods.getWebhookView = async function ( this: IEncryptedSubmissionSchema | IPopulatedWebhookSubmission, -): WebhookView { +): Promise { const formId = this.populated('form') ? String((this as IPopulatedWebhookSubmission).form._id) : String(this.form) @@ -219,6 +220,13 @@ EncryptSubmissionSchema.methods.getWebhookView = function ( this.attachmentMetadata ?? new Map(), ) + if (this.paymentId) { + await (this as IPopulatedWebhookSubmission).populate('paymentId') + } + const paymentContent = this.populated('paymentId') + ? getPaymentWebhookEventObject(this.paymentId) + : {} + const webhookData: WebhookData = { formId, submissionId: String(this._id), @@ -227,6 +235,7 @@ EncryptSubmissionSchema.methods.getWebhookView = function ( version: this.version, created: this.created, attachmentDownloadUrls: attachmentRecords, + paymentContent, } return { @@ -250,13 +259,19 @@ EncryptSubmissionSchema.statics.retrieveWebhookInfoById = function ( submissionId: string, ): Promise { return this.findById(submissionId) - .populate('form', 'webhook') + .populate([{ path: 'form', select: 'webhook' }, { path: 'paymentId' }]) .then((populatedSubmission: IPopulatedWebhookSubmission | null) => { if (!populatedSubmission) return null + const webhookView = populatedSubmission.getWebhookView() + return Promise.all([populatedSubmission, webhookView]) + }) + .then((arr) => { + if (!arr) return null + const [populatedSubmission, webhookView] = arr return { webhookUrl: populatedSubmission.form.webhook?.url ?? '', isRetryEnabled: !!populatedSubmission.form.webhook?.isRetryEnabled, - webhookView: populatedSubmission.getWebhookView(), + webhookView, } }) } diff --git a/src/app/modules/payments/payment.service.utils.ts b/src/app/modules/payments/payment.service.utils.ts new file mode 100644 index 0000000000..999b518af7 --- /dev/null +++ b/src/app/modules/payments/payment.service.utils.ts @@ -0,0 +1,37 @@ +import { PaymentType } from '../../../../shared/types' +import { centsToDollars } from '../../../../shared/utils/payments' +import { getPaymentInvoiceDownloadUrlPath } from '../../../../shared/utils/urls' +import config from '../../../app/config/config' +import { IPaymentSchema } from '../../../types' +import { PaymentWebhookEventObject } from '../webhook/webhook.types' + +export const getPaymentWebhookEventObject = ( + payment: IPaymentSchema, +): PaymentWebhookEventObject | object => { + // PaymentType.Fixed is deprecated, no need to send any additional fields + // We don't want admins to continue using this type of payment + if (payment.payment_fields_snapshot.payment_type === PaymentType.Fixed) { + return {} + } + + const paymentEventType = 'payment_charge' // currently only one type of payment + return { + type: paymentEventType, + status: payment.status, + payer: payment.email, + url: `${config.app.appUrl}/api/v3/${getPaymentInvoiceDownloadUrlPath( + payment.formId, + payment._id, + )}`, + paymentIntent: payment.paymentIntentId, + amount: centsToDollars(payment.amount), + productService: + payment.products + ?.map(({ data, quantity }) => `${data.name} x ${quantity}`) + .join(', ') || '-', + dateTime: payment.completedPayment?.paymentDate ?? '-', + transactionFee: payment.completedPayment?.transactionFee + ? centsToDollars(payment.completedPayment.transactionFee) + : '-', + } +} diff --git a/src/app/modules/webhook/webhook.service.ts b/src/app/modules/webhook/webhook.service.ts index 76e286bfd9..f74ab0355a 100644 --- a/src/app/modules/webhook/webhook.service.ts +++ b/src/app/modules/webhook/webhook.service.ts @@ -15,7 +15,7 @@ import formsgSdk from '../../config/formsg-sdk' import { createLoggerWithLabel } from '../../config/logger' import { getEncryptSubmissionModel } from '../../models/submission.server.model' import { transformMongoError } from '../../utils/handle-mongo-error' -import { PossibleDatabaseError } from '../core/core.errors' +import { DatabaseError, PossibleDatabaseError } from '../core/core.errors' import { SubmissionNotFoundError } from '../submission/submission.errors' import { WEBHOOK_MAX_CONTENT_LENGTH } from './webhook.constants' @@ -257,8 +257,12 @@ export const createInitialWebhookSender = | WebhookPushToQueueError > => { // Attempt to send webhook - return sendWebhook(submission.getWebhookView(), webhookUrl).andThen( - (webhookResponse) => { + + return ResultAsync.fromPromise( + submission.getWebhookView(), + () => new DatabaseError(), + ).andThen((webhookView) => + sendWebhook(webhookView, webhookUrl).andThen((webhookResponse) => { webhookStatsdClient.increment('sent', 1, 1, { responseCode: `${webhookResponse.response.status || null}`, webhookType: getWebhookType(webhookUrl), @@ -282,6 +286,6 @@ export const createInitialWebhookSender = ).asyncAndThen((queueMessage) => producer.sendMessage(queueMessage)) }, ) - }, + }), ) } diff --git a/src/app/modules/webhook/webhook.types.ts b/src/app/modules/webhook/webhook.types.ts index f6256b02e2..9375ee6009 100644 --- a/src/app/modules/webhook/webhook.types.ts +++ b/src/app/modules/webhook/webhook.types.ts @@ -53,3 +53,10 @@ export type RetryInterval = { base: number jitter: number } + +export type PaymentWebhookEventType = 'payment_charge' + +export type PaymentWebhookEventObject = { + type: PaymentWebhookEventType + [key: string]: unknown +} diff --git a/src/types/submission.ts b/src/types/submission.ts index 87d5a2db26..377f3d5323 100644 --- a/src/types/submission.ts +++ b/src/types/submission.ts @@ -1,5 +1,7 @@ import { Cursor as QueryCursor, Document, Model, QueryOptions } from 'mongoose' +import { PaymentWebhookEventObject } from 'src/app/modules/webhook/webhook.types' + import { EmailModeSubmissionBase, MultirespondentSubmissionBase, @@ -11,6 +13,7 @@ import { } from '../../shared/types/submission' import { IFormSchema } from './form' +import { IPaymentSchema } from './payment' export interface WebhookData { formId: string @@ -20,6 +23,7 @@ export interface WebhookData { version: IEncryptedSubmissionSchema['version'] created: IEncryptedSubmissionSchema['created'] attachmentDownloadUrls: Record + paymentContent?: PaymentWebhookEventObject | object } export interface WebhookView { @@ -43,11 +47,14 @@ export interface IPopulatedWebhookSubmission _id: IFormSchema['_id'] webhook: IFormSchema['webhook'] } + paymentId: IPaymentSchema } export interface ISubmissionSchema extends SubmissionBase, Document { // Allows for population and correct typing form: any + paymentId: any + created?: Date } @@ -65,15 +72,17 @@ export interface IEmailSubmissionSchema // Allows for population and correct typing form: any submissionType: SubmissionType.Email - getWebhookView(): null + getWebhookView(): Promise } export interface IEncryptedSubmissionSchema extends StorageModeSubmissionBase, ISubmissionSchema { // Allows for population and correct typing form: any + paymentId: any + submissionType: SubmissionType.Encrypt - getWebhookView(): WebhookView + getWebhookView(): Promise } export interface IMultirespondentSubmissionSchema extends MultirespondentSubmissionBase, @@ -81,7 +90,7 @@ export interface IMultirespondentSubmissionSchema // Allows for population and correct typing form: any submissionType: SubmissionType.Multirespondent - getWebhookView(): null + getWebhookView(): Promise } // When retrieving from database, the attachmentMetadata type becomes an object