Skip to content

Commit

Permalink
PLU-166: Initial PaySG Integration (#345)
Browse files Browse the repository at this point in the history
  • Loading branch information
ogp-weeloong authored Dec 1, 2023
1 parent 12df74f commit d4b3c11
Show file tree
Hide file tree
Showing 13 changed files with 648 additions and 0 deletions.
2 changes: 2 additions & 0 deletions packages/backend/src/apps/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { IApp } from '@plumber/types'
import customApiApp from './custom-api'
import delayApp from './delay'
import formsgApp from './formsg'
import paysgApp from './paysg'
import postmanApp from './postman'
import schedulerApp from './scheduler'
import slackApp from './slack'
Expand All @@ -16,6 +17,7 @@ const apps: Record<string, IApp> = {
[customApiApp.key]: customApiApp,
[delayApp.key]: delayApp,
[formsgApp.key]: formsgApp,
[paysgApp.key]: paysgApp,
[postmanApp.key]: postmanApp,
[schedulerApp.key]: schedulerApp,
[slackApp.key]: slackApp,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import type { IGlobalVariable } from '@plumber/types'

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

import app from '../..'
import createPaymentAction from '../../actions/create-payment'
import { getApiBaseUrl } from '../../common/api'

const mocks = vi.hoisted(() => ({
httpPost: vi.fn(() => ({
data: {},
})),
}))

describe('create payment', () => {
let $: IGlobalVariable

beforeEach(() => {
$ = {
auth: {
set: vi.fn(),
data: {
apiKey: 'sample-api-key',
paymentServiceId: 'sample-payment-service-id',
},
},
step: {
id: 'herp-derp',
appKey: 'paysg',
position: 2,
parameters: {
// Pre-fill some required fields
referenceId: 'test-reference-id',
payerName: 'test-name',
payerAddress: 'test-address',
payerIdentifier: 'test-id',
payerEmail: '[email protected]',
description: 'test-description',
paymentAmountCents: '12345',
},
},
http: {
post: mocks.httpPost,
} as unknown as IGlobalVariable['http'],
setActionItem: vi.fn(),
app,
}

mocks.httpPost.mockResolvedValue({
data: {
id: 'payment-id',
payment_url: 'https://test2.local',
stripe_payment_intent_id: 'stripe-payment',
payment_qr_code_url: 'https://test3.local',
},
})
})

afterEach(() => {
vi.restoreAllMocks()
})

it.each(['paysg_stag_abcd'])(
'invokes the correct URL based on the API key',
async (apiKey) => {
$.auth.data.apiKey = apiKey
const expectedBaseUrl = getApiBaseUrl($.auth.data.apiKey)

await createPaymentAction.run($)

expect(mocks.httpPost).toHaveBeenCalledWith(
'/v1/payment-services/sample-payment-service-id/payments',
expect.anything(),
expect.objectContaining({
baseURL: expectedBaseUrl,
headers: {
'x-api-key': $.auth.data.apiKey,
},
}),
)
},
)

it('builds the payload correctly', async () => {
$.step.parameters.dueDate = '02 Jan 2023'
$.step.parameters.returnUrl = 'https://test.local'
$.step.parameters.metadata = [
{ key: 'test-key-1', value: 'test-value-1' },
{ key: 'test-key-2', value: 'test-value-2' },
]

$.auth.data.apiKey = 'paysg_stag_abcd'
await createPaymentAction.run($)

expect(mocks.httpPost).toHaveBeenCalledWith(
'/v1/payment-services/sample-payment-service-id/payments',
{
reference_id: 'test-reference-id',
payer_name: 'test-name',
payer_address: 'test-address',
payer_identifier: 'test-id',
payer_email: '[email protected]',
description: 'test-description',
amount_in_cents: 12345, // Converted to number
due_date: '02-JAN-2023',
return_url: 'https://test.local',
metadata: {
'test-key-1': 'test-value-1',
'test-key-2': 'test-value-2',
},
},
expect.anything(),
)
})
})
35 changes: 35 additions & 0 deletions packages/backend/src/apps/paysg/__tests__/common/api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest'

import {
getApiBaseUrl,
getEnvironmentFromApiKey,
PaySgEnvironment,
} from '../../common/api'

describe('API functions', () => {
describe('get environment', () => {
it('returns staging env if API key is for staging environment', () => {
expect(getEnvironmentFromApiKey('paysg_stag_1234')).toEqual(
PaySgEnvironment.Staging,
)
})

it('returns live env if API key is for live environment', () => {
expect(getEnvironmentFromApiKey('paysg_live_1234')).toEqual(
PaySgEnvironment.Live,
)
})
})

describe('get API base URL', () => {
it('returns staging URL if API key is for staging environment', () => {
const url = getApiBaseUrl('paysg_stag_1234')
expect(url.startsWith('https://api-staging.')).toEqual(true)
})

it('returns live URL if API key is for live environment', () => {
const url = getApiBaseUrl('paysg_live_1234')
expect(url.startsWith('https://api.')).toEqual(true)
})
})
})
146 changes: 146 additions & 0 deletions packages/backend/src/apps/paysg/actions/create-payment/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import type { IRawAction } from '@plumber/types'

import { ZodError } from 'zod'
import { fromZodError } from 'zod-validation-error'

import { VALIDATION_ERROR_SOLUTION } from '@/apps/postman/common/constants'
import { generateStepError } from '@/helpers/generate-step-error'

import { getApiBaseUrl } from '../../common/api'

import { requestSchema, responseSchema } from './schema'

const action: IRawAction = {
name: 'Create Payment',
key: 'createPayment',
description: 'Create a new PaySG payment',
arguments: [
{
label: 'Reference ID',
key: 'referenceId',
type: 'string' as const,
required: true,
variables: true,
},
{
label: 'Payer Name',
key: 'payerName',
type: 'string' as const,
required: true,
variables: true,
},
{
label: 'Payer Address',
key: 'payerAddress',
type: 'string' as const,
required: true,
variables: true,
},
{
label: 'Payer Identifier',
description: 'e.g. NRIC',
key: 'payerIdentifier',
type: 'string' as const,
required: true,
variables: true,
},
{
label: 'Payer Email',
key: 'payerEmail',
type: 'string' as const,
required: true,
variables: true,
},
{
label: 'Description',
key: 'description',
type: 'string' as const,
required: true,
variables: true,
},
{
label: 'Payment amount (in cents)',
key: 'paymentAmountCents',
type: 'string' as const,
required: true,
variables: true,
},
{
label: 'Due Date',
description:
'Must be formatted in "2-digit-day month 4-digit-year" format (e.g. 02 Nov 2023)',
key: 'dueDate',
type: 'string' as const,
required: false,
variables: true,
},
{
label: 'Return URL',
key: 'returnUrl',
type: 'string' as const,
required: false,
variables: true,
},
{
label: 'Metadata',
key: 'metadata',
type: 'multirow' as const,
required: false,
subFields: [
{
placeholder: 'Key',
key: 'key',
type: 'string' as const,
required: true,
variables: true,
},
{
placeholder: 'Value',
key: 'value',
type: 'string' as const,
required: true,
variables: true,
},
],
},
],

async run($) {
const apiKey = $.auth.data.apiKey as string
const baseUrl = getApiBaseUrl(apiKey)
const paymentServiceId = $.auth.data.paymentServiceId as string

try {
const payload = requestSchema.parse($.step.parameters)

const rawResponse = await $.http.post(
`/v1/payment-services/${paymentServiceId}/payments`,
payload,
{
baseURL: baseUrl,
headers: {
'x-api-key': apiKey,
},
},
)
const response = responseSchema.parse(rawResponse.data)

$.setActionItem({ raw: response })
} catch (error) {
if (error instanceof ZodError) {
const firstError = fromZodError(error).details[0]

throw generateStepError(
`${firstError.message} at "${firstError.path}"`,
VALIDATION_ERROR_SOLUTION,
$.step.position,
$.app.name,
)
}

throw error
}
},
}

export default action
Loading

0 comments on commit d4b3c11

Please sign in to comment.