-
Notifications
You must be signed in to change notification settings - Fork 5
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
PLU-166: Initial PaySG Integration (#345)
- Loading branch information
1 parent
12df74f
commit d4b3c11
Showing
13 changed files
with
648 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
115 changes: 115 additions & 0 deletions
115
packages/backend/src/apps/paysg/__tests__/actions/create-payment.test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
35
packages/backend/src/apps/paysg/__tests__/common/api.test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
146
packages/backend/src/apps/paysg/actions/create-payment/index.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
Oops, something went wrong.