From 6d464327d98947637b1d0a4ffb01a57311a18039 Mon Sep 17 00:00:00 2001 From: wanlingt Date: Wed, 15 Mar 2023 14:47:52 +0800 Subject: [PATCH 1/9] feat: add stripeTransactionFee to payment model --- shared/types/payment.ts | 1 + src/app/models/payment.server.model.ts | 3 +++ 2 files changed, 4 insertions(+) diff --git a/shared/types/payment.ts b/shared/types/payment.ts index 86f415b7d3..9ec754e183 100644 --- a/shared/types/payment.ts +++ b/shared/types/payment.ts @@ -17,6 +17,7 @@ export type Payment = { payoutId: string payoutDate: Date created: DateString + stripeTransactionFee: number } export type PaymentReceiptStatusDto = { diff --git a/src/app/models/payment.server.model.ts b/src/app/models/payment.server.model.ts index 02c5feaf6d..71ca94f309 100644 --- a/src/app/models/payment.server.model.ts +++ b/src/app/models/payment.server.model.ts @@ -42,6 +42,9 @@ const compilePaymentModel = (db: Mongoose): IPaymentModel => { payoutDate: { type: Date, }, + stripeTransactionFee: { + type: Number, + }, }, { timestamps: { From ce10a3ab79b315ea4ac379e4ee84c424eb7baeef Mon Sep 17 00:00:00 2001 From: wanlingt Date: Wed, 15 Mar 2023 15:28:49 +0800 Subject: [PATCH 2/9] feat: add receiptUrl to payment model --- shared/types/payment.ts | 1 + src/app/models/payment.server.model.ts | 3 +++ 2 files changed, 4 insertions(+) diff --git a/shared/types/payment.ts b/shared/types/payment.ts index 9ec754e183..52893b3cb0 100644 --- a/shared/types/payment.ts +++ b/shared/types/payment.ts @@ -18,6 +18,7 @@ export type Payment = { payoutDate: Date created: DateString stripeTransactionFee: number + receiptUrl: string } export type PaymentReceiptStatusDto = { diff --git a/src/app/models/payment.server.model.ts b/src/app/models/payment.server.model.ts index 71ca94f309..23445753ef 100644 --- a/src/app/models/payment.server.model.ts +++ b/src/app/models/payment.server.model.ts @@ -45,6 +45,9 @@ const compilePaymentModel = (db: Mongoose): IPaymentModel => { stripeTransactionFee: { type: Number, }, + receiptUrl: { + type: String, + }, }, { timestamps: { From 1e47cdef61c740a1a1c5cfc1b49a933a40da6367 Mon Sep 17 00:00:00 2001 From: wanlingt Date: Sun, 19 Mar 2023 23:37:40 +0800 Subject: [PATCH 3/9] feat: add balance transaction retrieval to getReceiptUrl --- shared/types/payment.ts | 4 + src/app/modules/payments/payments.service.ts | 40 +++++++++ src/app/modules/payments/stripe.errors.ts | 21 +++++ src/app/modules/payments/stripe.service.ts | 88 ++++++++++++++++++-- 4 files changed, 146 insertions(+), 7 deletions(-) diff --git a/shared/types/payment.ts b/shared/types/payment.ts index 52893b3cb0..131d290abb 100644 --- a/shared/types/payment.ts +++ b/shared/types/payment.ts @@ -1,5 +1,6 @@ import Stripe from 'stripe' import { DateString } from './generic' +import type { Opaque } from 'type-fest' export enum PaymentStatus { Failed = 'failed', @@ -7,7 +8,10 @@ export enum PaymentStatus { Succeeded = 'succeeded', } +export type PaymentId = Opaque + export type Payment = { + _id: PaymentId submissionId: string amount: number status: PaymentStatus diff --git a/src/app/modules/payments/payments.service.ts b/src/app/modules/payments/payments.service.ts index a2d2613982..356b551b94 100644 --- a/src/app/modules/payments/payments.service.ts +++ b/src/app/modules/payments/payments.service.ts @@ -80,3 +80,43 @@ export const findPaymentBySubmissionId = ( return okAsync(payment) }) } + +export const updateReceiptUrl = ( + paymentId: IPaymentSchema['_id'], + receiptUrl: string, + stripeTransactionFee: number, +): ResultAsync => { + // Retrieve payment object from database and + // Update payment's receipt url + return ResultAsync.fromPromise( + PaymentModel.findByIdAndUpdate( + paymentId, + { + $set: { + receiptUrl, + stripeTransactionFee, + updatedAt: new Date(), + }, + }, + { new: true }, + ).exec(), + (error) => { + logger.error({ + message: + 'Database error when updating payment with receipt url and transaction fee', + meta: { + action: 'updateReceiptUrl', + paymentId, + }, + error, + }) + + return new DatabaseError() + }, + ).andThen((payment) => { + if (!payment) { + return errAsync(new PaymentNotFoundError()) + } + return okAsync(payment as IPaymentSchema) + }) +} diff --git a/src/app/modules/payments/stripe.errors.ts b/src/app/modules/payments/stripe.errors.ts index 20b567f3aa..b7df62fabd 100644 --- a/src/app/modules/payments/stripe.errors.ts +++ b/src/app/modules/payments/stripe.errors.ts @@ -20,6 +20,11 @@ export class SubmissionAndFormMismatchError extends ApplicationError { } } +export class SuccessfulChargeNotFoundError extends ApplicationError { + constructor(message = 'Successful charge not found from Stripe API') { + super(message) + } +} export class ChargeReceiptNotFoundError extends ApplicationError { constructor( message = "Charge object's receipt url not found from Stripe API", @@ -27,6 +32,22 @@ export class ChargeReceiptNotFoundError extends ApplicationError { super(message) } } + +export class StripeTransactionFeeNotFoundError extends ApplicationError { + constructor( + message = "Charge object's receipt url not found from Stripe API", + ) { + super(message) + } +} + +export class ChargeBalanceTransactionNotFoundError extends ApplicationError { + constructor( + message = "Charge object's balance transaction not found from Stripe API", + ) { + super(message) + } +} export class PaymentIntentLatestChargeNotFoundError extends ApplicationError { constructor( message = "Payment intent's latest charge not found from Stripe API", diff --git a/src/app/modules/payments/stripe.service.ts b/src/app/modules/payments/stripe.service.ts index 7968236b0f..7a04df1466 100644 --- a/src/app/modules/payments/stripe.service.ts +++ b/src/app/modules/payments/stripe.service.ts @@ -18,12 +18,16 @@ import * as SubmissionService from '../submission/submission.service' import * as PaymentService from './payments.service' import { getRedirectUri } from './payments.utils' import { + ChargeBalanceTransactionNotFoundError, ChargeReceiptNotFoundError, PaymentIntentLatestChargeNotFoundError, + PaymentNotFoundError, StripeAccountError, StripeAccountNotFoundError, StripeFetchError, + StripeTransactionFeeNotFoundError, SubmissionAndFormMismatchError, + SuccessfulChargeNotFoundError, } from './stripe.errors' const logger = createLoggerWithLabel(module) @@ -222,6 +226,13 @@ export const getReceiptURL = ( stripeAccount: form.payments?.target_account_id, }), (error) => { + logger.error({ + message: 'Error retrieving paymentIntent object', + meta: { + action: 'getReceiptURL', + }, + error, + }) return new StripeFetchError(String(error)) }, ) @@ -252,7 +263,7 @@ export const getReceiptURL = ( })), ) .andThen(({ paymentIntent, stripeAccount }) => - // Step 5: Retrieve charge object + // Step 5: Retrieve latest charge object ResultAsync.fromPromise( stripe.charges.retrieve( String(paymentIntent.latest_charge), @@ -260,24 +271,87 @@ export const getReceiptURL = ( { stripeAccount }, ), (error) => { + logger.error({ + message: 'Error retrieving latest charge object', + meta: { + action: 'getReceiptURL', + }, + error, + }) return new StripeFetchError(String(error)) }, - ), + ).map((charge) => ({ + charge, + paymentId: payment._id, + stripeAccount, + })), ) - .andThen((charge) => { - // Step 6: Retrieve receipt url + .andThen(({ charge, paymentId, stripeAccount }) => { + if (!charge || charge.status !== 'succeeded') { + return errAsync(new SuccessfulChargeNotFoundError()) + } // Note that for paynow expired payments in test mode, stripe returns receipt_url with 'null' string - if (!charge || !charge.receipt_url || charge.receipt_url === 'null') { + if (!charge.receipt_url || charge.receipt_url === 'null') { return errAsync(new ChargeReceiptNotFoundError()) } + if (!charge.balance_transaction) { + return errAsync(new ChargeBalanceTransactionNotFoundError()) + } logger.info({ - message: 'Retrieved charge object from Stripe', + message: 'Retrieved successful charge object from Stripe', meta: { action: 'getReceiptURL', charge, }, }) - return okAsync(String(charge.receipt_url)) + // Step 6: Retrieve balance transaction object + return ( + ResultAsync.fromPromise( + stripe.balanceTransactions.retrieve( + String(charge.balance_transaction), + undefined, + { stripeAccount }, + ), + (error) => { + logger.error({ + message: 'Error retrieving balance transaction object', + meta: { + action: 'getReceiptURL', + }, + error, + }) + return new StripeFetchError(String(error)) + }, + ) + // Step 7: Retrieve transaction fee associated with balance transaction object + // TODO: check how many elements in fee_details array + .andThen((balanceTransaction) => { + if (balanceTransaction.fee_details[0].type === 'stripe_fee') { + return okAsync(balanceTransaction.fee_details[0].amount) + } + return errAsync(new PaymentIntentLatestChargeNotFoundError()) + }) + // Step 8: Update payment object with receipt url and transaction fee + .andThen((stripeTransactionFee) => { + if (!charge.receipt_url || charge.receipt_url === 'null') { + return errAsync(new ChargeReceiptNotFoundError()) + } + if (!stripeTransactionFee) { + return errAsync(new StripeTransactionFeeNotFoundError()) + } + return PaymentService.updateReceiptUrl( + paymentId, + charge.receipt_url, + stripeTransactionFee, + ) + }) + .andThen((payment) => { + if (!payment) { + return errAsync(new PaymentNotFoundError()) + } + return okAsync(String(payment.receiptUrl)) + }) + ) }), ) } From dc682cda3b233a7b0308ece096e842db87d8a210 Mon Sep 17 00:00:00 2001 From: wanlingt Date: Mon, 20 Mar 2023 00:03:00 +0800 Subject: [PATCH 4/9] fix: return payment object in getReceiptUrl --- src/app/modules/payments/stripe.controller.ts | 12 ++++++------ src/app/modules/payments/stripe.service.ts | 5 +++-- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/app/modules/payments/stripe.controller.ts b/src/app/modules/payments/stripe.controller.ts index 43e20cb240..777f16c371 100644 --- a/src/app/modules/payments/stripe.controller.ts +++ b/src/app/modules/payments/stripe.controller.ts @@ -188,12 +188,12 @@ export const checkPaymentReceiptStatus: ControllerHandler<{ }) return StripeService.getReceiptURL(formId, submissionId) - .map((receiptUrl) => { + .map((payment) => { logger.info({ - message: 'Received receipt url from Stripe webhook', + message: 'Received payment object with receipt url from Stripe webhook', meta: { action: 'checkPaymentReceiptStatus', - receiptUrl, + payment, }, }) @@ -228,18 +228,18 @@ export const downloadPaymentReceipt: ControllerHandler<{ }) return StripeService.getReceiptURL(formId, submissionId) - .map((receiptUrl) => { + .map((payment) => { logger.info({ message: 'Received receipt url from Stripe webhook', meta: { action: 'downloadPaymentReceipt', - receiptUrl, + payment, }, }) // retrieve receiptURL as html return ( axios - .get(receiptUrl) + .get(payment.receiptUrl) // convert to pdf and return .then((receiptUrlResponse) => { const html = receiptUrlResponse.data diff --git a/src/app/modules/payments/stripe.service.ts b/src/app/modules/payments/stripe.service.ts index 7a04df1466..b774e765a4 100644 --- a/src/app/modules/payments/stripe.service.ts +++ b/src/app/modules/payments/stripe.service.ts @@ -1,6 +1,7 @@ import cuid from 'cuid' import mongoose from 'mongoose' import { errAsync, ok, okAsync, ResultAsync } from 'neverthrow' +import { Payment } from 'shared/types' import Stripe from 'stripe' import { MarkRequired } from 'ts-essentials' @@ -180,7 +181,7 @@ export const getReceiptURL = ( formId: string, submissionId: string, ): ResultAsync< - string, + Payment, | FormNotFoundError | SubmissionNotFoundError | SubmissionAndFormMismatchError @@ -349,7 +350,7 @@ export const getReceiptURL = ( if (!payment) { return errAsync(new PaymentNotFoundError()) } - return okAsync(String(payment.receiptUrl)) + return okAsync(payment) }) ) }), From 1481ba31aab5bf5dc8d7388d2c115e230832854c Mon Sep 17 00:00:00 2001 From: wanlingt Date: Mon, 20 Mar 2023 00:31:08 +0800 Subject: [PATCH 5/9] fix: rename getReceiptUrl to getPaymentFromLatestSuccessfulCharge --- src/app/modules/payments/stripe.controller.ts | 10 ++++++++-- src/app/modules/payments/stripe.service.ts | 16 ++++++++-------- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/src/app/modules/payments/stripe.controller.ts b/src/app/modules/payments/stripe.controller.ts index 777f16c371..964fc776fd 100644 --- a/src/app/modules/payments/stripe.controller.ts +++ b/src/app/modules/payments/stripe.controller.ts @@ -187,7 +187,10 @@ export const checkPaymentReceiptStatus: ControllerHandler<{ }, }) - return StripeService.getReceiptURL(formId, submissionId) + return StripeService.getPaymentFromLatestSuccessfulCharge( + formId, + submissionId, + ) .map((payment) => { logger.info({ message: 'Received payment object with receipt url from Stripe webhook', @@ -227,7 +230,10 @@ export const downloadPaymentReceipt: ControllerHandler<{ }, }) - return StripeService.getReceiptURL(formId, submissionId) + return StripeService.getPaymentFromLatestSuccessfulCharge( + formId, + submissionId, + ) .map((payment) => { logger.info({ message: 'Received receipt url from Stripe webhook', diff --git a/src/app/modules/payments/stripe.service.ts b/src/app/modules/payments/stripe.service.ts index b774e765a4..1bd817ed3e 100644 --- a/src/app/modules/payments/stripe.service.ts +++ b/src/app/modules/payments/stripe.service.ts @@ -177,7 +177,7 @@ export const validateAccount = ( ) } -export const getReceiptURL = ( +export const getPaymentFromLatestSuccessfulCharge = ( formId: string, submissionId: string, ): ResultAsync< @@ -206,7 +206,7 @@ export const getReceiptURL = ( logger.info({ message: 'Verified form submission exists', meta: { - action: 'getReceiptURL', + action: 'getPaymentFromLatestSuccessfulCharge', submission, }, }) @@ -230,7 +230,7 @@ export const getReceiptURL = ( logger.error({ message: 'Error retrieving paymentIntent object', meta: { - action: 'getReceiptURL', + action: 'getPaymentFromLatestSuccessfulCharge', }, error, }) @@ -241,7 +241,7 @@ export const getReceiptURL = ( logger.info({ message: 'Retrieved payment intent object from Stripe', meta: { - action: 'getReceiptURL', + action: 'getPaymentFromLatestSuccessfulCharge', paymentIntent, }, }) @@ -250,7 +250,7 @@ export const getReceiptURL = ( message: "Successfully retrieved payment intent's latest charge from Stripe", meta: { - action: 'getReceiptURL', + action: 'getPaymentFromLatestSuccessfulCharge', paymentIntent, }, }) @@ -275,7 +275,7 @@ export const getReceiptURL = ( logger.error({ message: 'Error retrieving latest charge object', meta: { - action: 'getReceiptURL', + action: 'getPaymentFromLatestSuccessfulCharge', }, error, }) @@ -301,7 +301,7 @@ export const getReceiptURL = ( logger.info({ message: 'Retrieved successful charge object from Stripe', meta: { - action: 'getReceiptURL', + action: 'getPaymentFromLatestSuccessfulCharge', charge, }, }) @@ -317,7 +317,7 @@ export const getReceiptURL = ( logger.error({ message: 'Error retrieving balance transaction object', meta: { - action: 'getReceiptURL', + action: 'getPaymentFromLatestSuccessfulCharge', }, error, }) From d8d1e99f27f710e47a9b7635495e8be64b546325 Mon Sep 17 00:00:00 2001 From: wanlingt Date: Mon, 20 Mar 2023 00:31:45 +0800 Subject: [PATCH 6/9] feat: add transaction fee to IndividualPaymentResponse --- .../IndividualPaymentResponse.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/frontend/src/features/admin-form/responses/IndividualResponsePage/IndividualPaymentResponse.tsx b/frontend/src/features/admin-form/responses/IndividualResponsePage/IndividualPaymentResponse.tsx index 6d618bd624..a10cc50844 100644 --- a/frontend/src/features/admin-form/responses/IndividualResponsePage/IndividualPaymentResponse.tsx +++ b/frontend/src/features/admin-form/responses/IndividualResponsePage/IndividualPaymentResponse.tsx @@ -65,8 +65,16 @@ export const IndividualPaymentResponse = (submissionId: { Transaction fee: - {/* TODO: Change this to actual transaction fee once application fee object has been added */} - $0.06 + + S$ + {(paymentData.stripeTransactionFee / 100).toLocaleString( + 'en-GB', + { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }, + )} + From 78fa781ae49ade1bf904793df60e8355c1571532 Mon Sep 17 00:00:00 2001 From: wanlingt Date: Mon, 20 Mar 2023 00:40:27 +0800 Subject: [PATCH 7/9] ref: rename updateReceiptUrl to updateReceiptUrlAndTransactionFee --- src/app/modules/payments/payments.service.ts | 2 +- src/app/modules/payments/stripe.service.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/modules/payments/payments.service.ts b/src/app/modules/payments/payments.service.ts index 356b551b94..c6286a99b0 100644 --- a/src/app/modules/payments/payments.service.ts +++ b/src/app/modules/payments/payments.service.ts @@ -81,7 +81,7 @@ export const findPaymentBySubmissionId = ( }) } -export const updateReceiptUrl = ( +export const updateReceiptUrlAndTransactionFee = ( paymentId: IPaymentSchema['_id'], receiptUrl: string, stripeTransactionFee: number, diff --git a/src/app/modules/payments/stripe.service.ts b/src/app/modules/payments/stripe.service.ts index 1bd817ed3e..34b21b5125 100644 --- a/src/app/modules/payments/stripe.service.ts +++ b/src/app/modules/payments/stripe.service.ts @@ -340,7 +340,7 @@ export const getPaymentFromLatestSuccessfulCharge = ( if (!stripeTransactionFee) { return errAsync(new StripeTransactionFeeNotFoundError()) } - return PaymentService.updateReceiptUrl( + return PaymentService.updateReceiptUrlAndTransactionFee( paymentId, charge.receipt_url, stripeTransactionFee, From a10472290316c57b96df664a67d7711b9852b728 Mon Sep 17 00:00:00 2001 From: wanlingt Date: Mon, 20 Mar 2023 09:02:06 +0800 Subject: [PATCH 8/9] ref: add comments for stripe transaction fee --- src/app/modules/payments/stripe.service.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/app/modules/payments/stripe.service.ts b/src/app/modules/payments/stripe.service.ts index 34b21b5125..e22e75a848 100644 --- a/src/app/modules/payments/stripe.service.ts +++ b/src/app/modules/payments/stripe.service.ts @@ -325,7 +325,9 @@ export const getPaymentFromLatestSuccessfulCharge = ( }, ) // Step 7: Retrieve transaction fee associated with balance transaction object - // TODO: check how many elements in fee_details array + // Assumption: Stripe fee is the only transaction fee for the MVP, so + // we use the first element of the array. (others are application_fee or tax) + // TODO: confirm with Girish how many elements there are in fee_details array .andThen((balanceTransaction) => { if (balanceTransaction.fee_details[0].type === 'stripe_fee') { return okAsync(balanceTransaction.fee_details[0].amount) From 073bdd55fcd76bf9fd8d4008cec21e733fee47d4 Mon Sep 17 00:00:00 2001 From: wanlingt Date: Thu, 23 Mar 2023 14:28:39 +0800 Subject: [PATCH 9/9] fix: rename and reformat payments model properties --- shared/types/payment.ts | 2 +- src/app/models/payment.server.model.ts | 4 +++- src/app/modules/payments/payments.service.ts | 1 - 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/shared/types/payment.ts b/shared/types/payment.ts index 456fffd1da..205cc0804a 100644 --- a/shared/types/payment.ts +++ b/shared/types/payment.ts @@ -21,7 +21,7 @@ export type Payment = { payoutId: string payoutDate: Date created: DateString - stripeTransactionFee: number + transactionFee: number receiptUrl: string email: string } diff --git a/src/app/models/payment.server.model.ts b/src/app/models/payment.server.model.ts index ed091f526a..2129048db0 100644 --- a/src/app/models/payment.server.model.ts +++ b/src/app/models/payment.server.model.ts @@ -45,7 +45,9 @@ const compilePaymentModel = (db: Mongoose): IPaymentModel => { stripeTransactionFee: { type: Number, }, - receiptUrl: { type: String }, + receiptUrl: { + type: String, + }, email: { type: String, }, diff --git a/src/app/modules/payments/payments.service.ts b/src/app/modules/payments/payments.service.ts index c6286a99b0..bff24d82e2 100644 --- a/src/app/modules/payments/payments.service.ts +++ b/src/app/modules/payments/payments.service.ts @@ -95,7 +95,6 @@ export const updateReceiptUrlAndTransactionFee = ( $set: { receiptUrl, stripeTransactionFee, - updatedAt: new Date(), }, }, { new: true },