Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: show transaction fee in individual response page #5942

Merged
merged 11 commits into from
Mar 23, 2023
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,16 @@ export const IndividualPaymentResponse = (submissionId: {
</Stack>
<Stack direction={{ base: 'column', md: 'row' }}>
<Text textStyle="subhead-1">Transaction fee:</Text>
{/* TODO: Change this to actual transaction fee once application fee object has been added */}
<Text>$0.06</Text>
<Text>
S$
{(paymentData.stripeTransactionFee / 100).toLocaleString(
'en-GB',
{
minimumFractionDigits: 2,
maximumFractionDigits: 2,
},
)}
</Text>
</Stack>
</Stack>
</>
Expand Down
6 changes: 6 additions & 0 deletions shared/types/payment.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
import Stripe from 'stripe'
import { DateString } from './generic'
import type { Opaque } from 'type-fest'

export enum PaymentStatus {
Failed = 'failed',
Pending = 'pending',
Succeeded = 'succeeded',
}

export type PaymentId = Opaque<string, 'PaymentId'>

export type Payment = {
_id: PaymentId
submissionId: string
amount: number
status: PaymentStatus
Expand All @@ -17,6 +21,8 @@ export type Payment = {
payoutId: string
payoutDate: Date
created: DateString
stripeTransactionFee: number
wanlingt marked this conversation as resolved.
Show resolved Hide resolved
receiptUrl: string
email: string
}

Expand Down
4 changes: 4 additions & 0 deletions src/app/models/payment.server.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ const compilePaymentModel = (db: Mongoose): IPaymentModel => {
payoutDate: {
type: Date,
},
stripeTransactionFee: {
type: Number,
},
receiptUrl: { type: String },
wanlingt marked this conversation as resolved.
Show resolved Hide resolved
email: {
type: String,
},
Expand Down
40 changes: 40 additions & 0 deletions src/app/modules/payments/payments.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,3 +80,43 @@ export const findPaymentBySubmissionId = (
return okAsync(payment)
})
}

export const updateReceiptUrlAndTransactionFee = (
paymentId: IPaymentSchema['_id'],
receiptUrl: string,
stripeTransactionFee: number,
): ResultAsync<IPaymentSchema, PaymentNotFoundError | DatabaseError> => {
// Retrieve payment object from database and
// Update payment's receipt url
return ResultAsync.fromPromise(
PaymentModel.findByIdAndUpdate(
paymentId,
{
$set: {
receiptUrl,
stripeTransactionFee,
updatedAt: new Date(),
wanlingt marked this conversation as resolved.
Show resolved Hide resolved
},
},
{ 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)
})
}
22 changes: 14 additions & 8 deletions src/app/modules/payments/stripe.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,13 +187,16 @@ export const checkPaymentReceiptStatus: ControllerHandler<{
},
})

return StripeService.getReceiptURL(formId, submissionId)
.map((receiptUrl) => {
return StripeService.getPaymentFromLatestSuccessfulCharge(
formId,
submissionId,
)
.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,
},
})

Expand Down Expand Up @@ -227,19 +230,22 @@ export const downloadPaymentReceipt: ControllerHandler<{
},
})

return StripeService.getReceiptURL(formId, submissionId)
.map((receiptUrl) => {
return StripeService.getPaymentFromLatestSuccessfulCharge(
formId,
submissionId,
)
.map((payment) => {
logger.info({
message: 'Received receipt url from Stripe webhook',
meta: {
action: 'downloadPaymentReceipt',
receiptUrl,
payment,
},
})
// retrieve receiptURL as html
return (
axios
.get<string>(receiptUrl)
.get<string>(payment.receiptUrl)
// convert to pdf and return
.then((receiptUrlResponse) => {
const html = receiptUrlResponse.data
Expand Down
21 changes: 21 additions & 0 deletions src/app/modules/payments/stripe.errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,34 @@ 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",
) {
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",
Expand Down
103 changes: 90 additions & 13 deletions src/app/modules/payments/stripe.service.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -18,12 +19,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)
Expand Down Expand Up @@ -172,11 +177,11 @@ export const validateAccount = (
)
}

export const getReceiptURL = (
export const getPaymentFromLatestSuccessfulCharge = (
formId: string,
submissionId: string,
): ResultAsync<
string,
Payment,
| FormNotFoundError
| SubmissionNotFoundError
| SubmissionAndFormMismatchError
Expand All @@ -201,7 +206,7 @@ export const getReceiptURL = (
logger.info({
message: 'Verified form submission exists',
meta: {
action: 'getReceiptURL',
action: 'getPaymentFromLatestSuccessfulCharge',
submission,
},
})
Expand All @@ -222,14 +227,21 @@ export const getReceiptURL = (
stripeAccount: form.payments?.target_account_id,
}),
(error) => {
logger.error({
message: 'Error retrieving paymentIntent object',
meta: {
action: 'getPaymentFromLatestSuccessfulCharge',
},
error,
})
return new StripeFetchError(String(error))
},
)
.andThen((paymentIntent) => {
logger.info({
message: 'Retrieved payment intent object from Stripe',
meta: {
action: 'getReceiptURL',
action: 'getPaymentFromLatestSuccessfulCharge',
paymentIntent,
},
})
Expand All @@ -238,7 +250,7 @@ export const getReceiptURL = (
message:
"Successfully retrieved payment intent's latest charge from Stripe",
meta: {
action: 'getReceiptURL',
action: 'getPaymentFromLatestSuccessfulCharge',
paymentIntent,
},
})
Expand All @@ -252,32 +264,97 @@ 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),
undefined,
{ stripeAccount },
),
(error) => {
logger.error({
message: 'Error retrieving latest charge object',
meta: {
action: 'getPaymentFromLatestSuccessfulCharge',
},
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',
action: 'getPaymentFromLatestSuccessfulCharge',
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: 'getPaymentFromLatestSuccessfulCharge',
},
error,
})
return new StripeFetchError(String(error))
},
)
// Step 7: Retrieve transaction fee associated with balance transaction object
// 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)
}
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.updateReceiptUrlAndTransactionFee(
paymentId,
charge.receipt_url,
stripeTransactionFee,
)
})
.andThen((payment) => {
if (!payment) {
return errAsync(new PaymentNotFoundError())
}
return okAsync(payment)
})
)
}),
)
}