Skip to content

Commit 2def4fd

Browse files
authored
feat: add payment forms action to paysg integration (#870)
## Problem The PaySG team wants to add a new action to the existing PaySG integration. This PR implements this action. For background, the PaySG team is implementing an integration with FormSG so that payers can have a cohesive experience filling in a FormSG form and then being redirected to a payment page within the same browser session. This is currently not yet possible - the closest alternative is to use the createPayment action in the existing PaySG integration, but this creates a payment link that is sent out separately via email rather than being a redirect flow. Currently, users will need to use the "Custom HTTP request" action in order to implement it, as a workaround. However, the team wants to make this an action actually available on the UI. ## Solution The action is implemented in this PR, under the PaySG integration folder. ## Screenshots <img width="1512" alt="image" src="https://github.com/user-attachments/assets/9a732e3d-4e00-4297-9896-ba3fecfdb6ae" /> ## Tests For both PaySG staging and production - [x] Create a new payment form in PaySG. Set up the plumber pipe with relevant fields. Now, go to the PaySG payment link and ensure that the payment form works as intended. - [x] Ensure that the payment form still works, even if all the optional fields are missing.
1 parent dbed208 commit 2def4fd

File tree

4 files changed

+239
-2
lines changed

4 files changed

+239
-2
lines changed
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import type { IRawAction } from '@plumber/types'
2+
3+
import { ZodError } from 'zod'
4+
import { fromZodError } from 'zod-validation-error'
5+
6+
import StepError, { GenericSolution } from '@/errors/step'
7+
8+
import { requestSchema } from './schema'
9+
10+
const action: IRawAction = {
11+
name: 'Create payment form submission',
12+
key: 'createPaymentFormSubmission',
13+
description:
14+
'Create a new submission for a payment form, and initiate a payment request',
15+
arguments: [
16+
{
17+
label: 'Payment Form Link',
18+
description: 'This can be found on your PaySG payment services dashboard',
19+
key: 'formId',
20+
type: 'string' as const,
21+
required: true,
22+
placeholder: 'e.g. https://pay.gov.sg/forms/aBC123Def456HijK789LMn',
23+
},
24+
{
25+
label: 'FormSG Form ID',
26+
description: 'Input the variable corresponding to form ID here',
27+
key: 'formsg_form_id',
28+
type: 'string' as const,
29+
required: true,
30+
variables: true,
31+
},
32+
{
33+
label: 'FormSG Submission ID',
34+
key: 'formsg_submission_id',
35+
type: 'string' as const,
36+
required: true,
37+
variables: true,
38+
},
39+
{
40+
label: 'FormSG Reference Field Answer',
41+
key: 'nonce',
42+
type: 'string' as const,
43+
required: true,
44+
variables: true,
45+
},
46+
{
47+
label: 'Payer Name',
48+
key: 'payer_name',
49+
type: 'string' as const,
50+
required: true,
51+
variables: true,
52+
},
53+
{
54+
label: 'Payer Email',
55+
key: 'payer_email',
56+
type: 'string' as const,
57+
required: false,
58+
variables: true,
59+
},
60+
{
61+
label: 'Amount (in cents)',
62+
key: 'amount_in_cents',
63+
type: 'string' as const,
64+
required: true,
65+
variables: true,
66+
},
67+
{
68+
label: 'Description',
69+
key: 'description',
70+
type: 'string' as const,
71+
required: false,
72+
variables: true,
73+
},
74+
{
75+
label: 'Additional responses',
76+
description: 'These will be included in reports exported from PaySG',
77+
key: 'responses',
78+
type: 'multirow' as const,
79+
required: false,
80+
subFields: [
81+
{
82+
placeholder: 'Question',
83+
key: 'question',
84+
type: 'string' as const,
85+
required: true,
86+
variables: true,
87+
},
88+
{
89+
placeholder: 'Answer',
90+
key: 'answer',
91+
type: 'string' as const,
92+
required: true,
93+
variables: true,
94+
},
95+
],
96+
},
97+
],
98+
99+
async run($) {
100+
const paymentServiceId = $.auth.data.paymentServiceId as string
101+
102+
try {
103+
const { formId, ...body } = requestSchema.parse($.step.parameters)
104+
105+
await $.http.post(
106+
`/v1/payment-services/:paymentServiceId/forms/:formId/submissions`,
107+
body,
108+
{
109+
urlPathParams: {
110+
paymentServiceId,
111+
formId,
112+
},
113+
},
114+
)
115+
$.setActionItem({ raw: { success: true } })
116+
} catch (error) {
117+
if (error instanceof ZodError) {
118+
const firstError = fromZodError(error).details[0]
119+
120+
throw new StepError(
121+
`${firstError.message} under set up step`,
122+
GenericSolution.ReconfigureInvalidField,
123+
$.step.position,
124+
$.app.name,
125+
)
126+
}
127+
128+
throw error
129+
}
130+
},
131+
}
132+
133+
export default action
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import emailValidator from 'email-validator'
2+
import { z } from 'zod'
3+
4+
import { FORM_ID_LENGTH } from '@/apps/formsg/common/constants'
5+
6+
import { normalizeSpecialChars } from '../create-payment/normalize-special-chars'
7+
8+
const PAYMENT_FORM_LINK_REGEX = /^https:\/\/(staging.)?pay.gov.sg\/forms\/(.*)$/
9+
10+
export const requestSchema = z.object({
11+
formId: z
12+
.string({ invalid_type_error: 'Empty payment form link' })
13+
.trim()
14+
.min(1, { message: 'Specify a payment form link' })
15+
.transform((value, context) => {
16+
const match = value.match(PAYMENT_FORM_LINK_REGEX)
17+
18+
if (!match || !match[0]) {
19+
context.addIssue({
20+
code: z.ZodIssueCode.custom,
21+
message: 'Invalid payment form link',
22+
})
23+
return z.NEVER
24+
}
25+
26+
return match[2]
27+
}),
28+
formsg_form_id: z
29+
.string({ invalid_type_error: 'Empty FormSG form ID' })
30+
.trim()
31+
.length(FORM_ID_LENGTH, { message: 'Specify a valid FormSG form ID' }),
32+
formsg_submission_id: z
33+
.string({ invalid_type_error: 'Empty FormSG submission ID' })
34+
.trim()
35+
.min(1, { message: 'Specify a submission ID' }),
36+
nonce: z
37+
.string({ invalid_type_error: 'Empty FormSG reference field answer' })
38+
.trim()
39+
.min(1, { message: 'Specify a FormSG reference field answer' }),
40+
payer_name: z
41+
.string({ invalid_type_error: 'Empty payer name' })
42+
.trim()
43+
.min(1, { message: 'Empty payer name' })
44+
.max(255, { message: 'Payer name cannot be more than 255 characters' })
45+
.transform((value) => normalizeSpecialChars(value)),
46+
payer_email: z
47+
.string()
48+
.trim()
49+
.max(255, { message: 'Payer email cannot be more than 255 characters' })
50+
.transform((value, context) => {
51+
if (!value || value.length === 0) {
52+
return undefined
53+
}
54+
if (!emailValidator.validate(value)) {
55+
context.addIssue({
56+
code: z.ZodIssueCode.custom,
57+
message: 'Invalid payer email',
58+
})
59+
return z.NEVER
60+
}
61+
62+
return value
63+
})
64+
.optional(),
65+
amount_in_cents: z
66+
.string()
67+
.trim()
68+
.min(1, { message: 'Empty payment amount' })
69+
.pipe(
70+
z.coerce
71+
.number({
72+
invalid_type_error: 'Payment amount must be a number',
73+
})
74+
.int('Payment amount must be a round number')
75+
.min(50, { message: 'Payment amount must be larger than 50 cents' })
76+
.max(99999999, {
77+
message: 'Payment amount cannot be larger than $999999.99',
78+
}),
79+
),
80+
description: z
81+
.string()
82+
.trim()
83+
.max(500, { message: 'Description cannot be more than 500 characters' })
84+
.transform((value) => value || undefined)
85+
.optional(),
86+
responses: z.array(
87+
z.object({
88+
question: z
89+
.string({ invalid_type_error: 'Empty question' })
90+
.trim()
91+
.min(1, { message: 'Empty question' }),
92+
answer: z
93+
.string({ invalid_type_error: 'Empty answer' })
94+
.trim()
95+
.min(1, { message: 'Empty answer' }),
96+
}),
97+
),
98+
})

packages/backend/src/apps/paysg/actions/create-payment/schema.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ export const requestSchema = z
6767
.string()
6868
.trim()
6969
.min(1, { message: 'Empty description' })
70-
.max(500, { message: 'Payer email cannot be more than 500 characters' }),
70+
.max(500, { message: 'Description cannot be more than 500 characters' }),
7171
paymentAmountCents: z
7272
.string()
7373
.trim()
Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
import createPayment from './create-payment'
2+
import createPaymentFormSubmission from './create-payment-form-submission'
23
import getPayment from './get-payment'
34
import sendEmail from './send-email'
45

5-
export default [createPayment, getPayment, sendEmail]
6+
export default [
7+
createPayment,
8+
getPayment,
9+
sendEmail,
10+
createPaymentFormSubmission,
11+
]

0 commit comments

Comments
 (0)