diff --git a/packages/backend/src/apps/index.ts b/packages/backend/src/apps/index.ts index d0ee19f25..be445312b 100644 --- a/packages/backend/src/apps/index.ts +++ b/packages/backend/src/apps/index.ts @@ -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' @@ -16,6 +17,7 @@ const apps: Record = { [customApiApp.key]: customApiApp, [delayApp.key]: delayApp, [formsgApp.key]: formsgApp, + [paysgApp.key]: paysgApp, [postmanApp.key]: postmanApp, [schedulerApp.key]: schedulerApp, [slackApp.key]: slackApp, diff --git a/packages/backend/src/apps/paysg/__tests__/actions/create-payment.test.ts b/packages/backend/src/apps/paysg/__tests__/actions/create-payment.test.ts new file mode 100644 index 000000000..91a5a22ba --- /dev/null +++ b/packages/backend/src/apps/paysg/__tests__/actions/create-payment.test.ts @@ -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: 'test@email.local', + 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: 'test@email.local', + 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(), + ) + }) +}) diff --git a/packages/backend/src/apps/paysg/__tests__/common/api.test.ts b/packages/backend/src/apps/paysg/__tests__/common/api.test.ts new file mode 100644 index 000000000..f40eae3c4 --- /dev/null +++ b/packages/backend/src/apps/paysg/__tests__/common/api.test.ts @@ -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) + }) + }) +}) diff --git a/packages/backend/src/apps/paysg/actions/create-payment/index.ts b/packages/backend/src/apps/paysg/actions/create-payment/index.ts new file mode 100644 index 000000000..8c189bbd7 --- /dev/null +++ b/packages/backend/src/apps/paysg/actions/create-payment/index.ts @@ -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 diff --git a/packages/backend/src/apps/paysg/actions/create-payment/schema.ts b/packages/backend/src/apps/paysg/actions/create-payment/schema.ts new file mode 100644 index 000000000..e8f9a5dde --- /dev/null +++ b/packages/backend/src/apps/paysg/actions/create-payment/schema.ts @@ -0,0 +1,177 @@ +import emailValidator from 'email-validator' +import { z } from 'zod' + +import { zodParser as plumberDate } from '@/helpers/internal-date-format' + +export const requestSchema = z + .object({ + referenceId: z + .string() + .trim() + .min(1, { message: 'Empty reference ID' }) + .max(255, { message: 'Reference ID cannot be more than 255 characters' }), + payerName: z + .string() + .trim() + .min(1, { message: 'Empty payer name' }) + .max(255, { message: 'Payer name cannot be more than 255 characters' }), + payerAddress: z + .string() + .trim() + .min(1, { message: 'Empty payer address' }) + .max(255, { + message: 'Payer address cannot be more than 255 characters', + }), + payerIdentifier: z + .string() + .trim() + .min(1, { message: 'Empty payer identifier' }) + .max(10, { + message: 'Payer identifier cannot be more than 10 characters', + }), + payerEmail: z + .string() + .trim() + .min(1, { message: 'Empty payer email' }) + .max(255, { message: 'Payer email cannot be more than 255 characters' }) + .transform((value, context) => { + if (!emailValidator.validate(value)) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Invalid payer email', + }) + return z.NEVER + } + + return value + }), + description: z + .string() + .trim() + .min(1, { message: 'Empty description' }) + .max(500, { message: 'Payer email cannot be more than 500 characters' }), + paymentAmountCents: z + .string() + .trim() + .min(1, { message: 'Empty payment amount' }) + .pipe( + z.coerce + .number() + .int('Payment amount must be round number') + .min(50, { message: 'Payment amount must be larger than 50 cents' }) + .max(99999999, { + message: 'Payment amount cannot be larger than $999999.99', + }), + ), + + // + // Optional form fields below + // + metadata: z + .array( + z.object({ + // FIXME (ogp-weeloong): by default, we populate metadata with 1 empty + // row even if its optional, for UX reasons. For now, account for this + // case in code until we make the necessary UX changes to not need + // that 1 empty row. + key: z + .string() + .trim() + .max(40, { + message: 'metadata key cannot be more than 40 characters', + }) + .nullish(), + value: z + .string() + .trim() + .max(255, { + message: 'metadata value cannot be more than 255 characters', + }) + .nullish(), + }), + ) + .max(10, 'cannot have more than 10 metadata entries') + .transform((rawMetadata, context) => { + // Again.. to remove this when we fix the UX issue. + const metadata = rawMetadata.filter((metadatum) => !!metadatum.key) + + if (metadata.length === 0) { + return null + } + + const result: Record = Object.create(null) + for (const metadatum of metadata) { + if (!metadatum.value) { + // Null values are not allowed. This isn't needed once FIXME above + // is addressed. + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Metadata value cannot be empty', + }) + return z.NEVER + } + + result[metadatum.key] = metadatum.value + } + + return result + }) + .nullish(), + dueDate: z + // Must be a valid plumber date or empty string + .union([ + plumberDate, + z + .string() + .length(0) + .transform((_val) => null), + ]) + .nullish(), + returnUrl: z + // Must be valid https URL or empty string. + .union([ + z + .string() + .min(1) + .trim() + .min(1, { message: 'Empty return URL' }) // After trim + .url() + .startsWith('https://', 'Not a https URL'), + z + .string() + .length(0) + .transform((_val) => null), + ]) + .nullish(), + }) + // After parsing, convert to PaySG format. + .transform((data) => ({ + reference_id: data.referenceId, + payer_name: data.payerName, + payer_address: data.payerAddress, + payer_identifier: data.payerIdentifier, + payer_email: data.payerEmail, + description: data.description, + amount_in_cents: data.paymentAmountCents, + metadata: data.metadata ?? {}, // PaySG requires at least an empty metadata object. + due_date: data.dueDate?.toFormat('dd-MMM-yyyy')?.toUpperCase(), + return_url: data.returnUrl, + })) + +export const responseSchema = z + .object({ + // Allow nullish in case any part of the API changes... + id: z.string().nullish(), + payment_url: z.string().nullish(), + stripe_payment_intent_id: z.string().nullish(), + payment_qr_code_url: z.string().nullish(), + + // Rest of fields are not as relevant for our end users, so skip exposing + // them. We can expose them later if there is a use case. + }) + .transform((data) => ({ + id: data.id, + paymentUrl: data.payment_url, + stripePaymentIntentId: data.stripe_payment_intent_id, + paymentQrCodeUrl: data.payment_qr_code_url, + })) diff --git a/packages/backend/src/apps/paysg/actions/index.ts b/packages/backend/src/apps/paysg/actions/index.ts new file mode 100644 index 000000000..fb307e9b4 --- /dev/null +++ b/packages/backend/src/apps/paysg/actions/index.ts @@ -0,0 +1,3 @@ +import createPayment from './create-payment' + +export default [createPayment] diff --git a/packages/backend/src/apps/paysg/assets/favicon.svg b/packages/backend/src/apps/paysg/assets/favicon.svg new file mode 100644 index 000000000..2a698042a --- /dev/null +++ b/packages/backend/src/apps/paysg/assets/favicon.svg @@ -0,0 +1,2 @@ + + diff --git a/packages/backend/src/apps/paysg/auth/index.ts b/packages/backend/src/apps/paysg/auth/index.ts new file mode 100644 index 000000000..f17a18115 --- /dev/null +++ b/packages/backend/src/apps/paysg/auth/index.ts @@ -0,0 +1,34 @@ +import isStillVerified from './is-still-verified' +import verifyCredentials from './verify-credentials' + +export default { + fields: [ + { + key: 'screenName', + label: 'Label', + type: 'string' as const, + required: true, + readOnly: false, + }, + { + key: 'paymentServiceId', + label: 'Payment Service ID', + type: 'string' as const, + required: true, + readOnly: false, + clickToCopy: false, + }, + { + key: 'apiKey', + label: 'API key', + type: 'string' as const, + required: true, + readOnly: false, + clickToCopy: false, + autoComplete: 'off' as const, + }, + ], + + verifyCredentials, + isStillVerified, +} diff --git a/packages/backend/src/apps/paysg/auth/is-still-verified.ts b/packages/backend/src/apps/paysg/auth/is-still-verified.ts new file mode 100644 index 000000000..9449b2da0 --- /dev/null +++ b/packages/backend/src/apps/paysg/auth/is-still-verified.ts @@ -0,0 +1,19 @@ +import type { IGlobalVariable } from '@plumber/types' + +import { getEnvironmentFromApiKey } from '../common/api' + +export default async function isStillVerified( + $: IGlobalVariable, +): Promise { + const authData = $.auth.data + + if (!authData || !authData.apiKey) { + throw new Error('Invalid PaySG API key') + } + + // If we can get the env, we're good for now. + // TODO (ogp-weeloong): verify properly via paysg healthcheck api + getEnvironmentFromApiKey(authData.apiKey as string) + + return true +} diff --git a/packages/backend/src/apps/paysg/auth/verify-credentials.ts b/packages/backend/src/apps/paysg/auth/verify-credentials.ts new file mode 100644 index 000000000..477301f6b --- /dev/null +++ b/packages/backend/src/apps/paysg/auth/verify-credentials.ts @@ -0,0 +1,31 @@ +import type { IGlobalVariable } from '@plumber/types' + +import { getEnvironmentFromApiKey, PaySgEnvironment } from '../common/api' + +export default async function verifyCredentials( + $: IGlobalVariable, +): Promise { + const authData = $.auth.data + + if (!authData || !authData.apiKey) { + throw new Error('Invalid PaySG API key') + } + + const env = getEnvironmentFromApiKey($.auth.data?.apiKey as string) + let labelSuffix: string | null = null + switch (env) { + case PaySgEnvironment.Live: + labelSuffix = ' [LIVE]' + break + case PaySgEnvironment.Staging: + labelSuffix = ' [STAGING]' + break + } + + await $.auth.set({ + screenName: !($.auth.data.screenName as string).endsWith(labelSuffix) + ? `${authData.screenName}${labelSuffix}` + : undefined, + env, + }) +} diff --git a/packages/backend/src/apps/paysg/common/api.ts b/packages/backend/src/apps/paysg/common/api.ts new file mode 100644 index 000000000..9d6bcdc16 --- /dev/null +++ b/packages/backend/src/apps/paysg/common/api.ts @@ -0,0 +1,33 @@ +export enum PaySgEnvironment { + Staging = 'staging', + Live = 'live', +} + +const STAGING_ENV_API_KEY_PREFIX = 'paysg_stag_' +const STAGING_ENV_BASE_URL = 'https://api-staging.pay.gov.sg' + +const LIVE_ENV_API_KEY_PREFIX = 'paysg_live_' +const LIVE_ENV_BASE_URL = 'https://api.pay.gov.sg' + +export function getEnvironmentFromApiKey(apiKey: string): PaySgEnvironment { + if (apiKey.startsWith(LIVE_ENV_API_KEY_PREFIX)) { + return PaySgEnvironment.Live + } + + if (apiKey.startsWith(STAGING_ENV_API_KEY_PREFIX)) { + return PaySgEnvironment.Staging + } + + throw new Error('API key has unrecognized prefix!') +} + +export function getApiBaseUrl(apiKey: string): string { + const env = getEnvironmentFromApiKey(apiKey) + + switch (env) { + case PaySgEnvironment.Live: + return LIVE_ENV_BASE_URL + case PaySgEnvironment.Staging: + return STAGING_ENV_BASE_URL + } +} diff --git a/packages/backend/src/apps/paysg/index.ts b/packages/backend/src/apps/paysg/index.ts new file mode 100644 index 000000000..5d19adc41 --- /dev/null +++ b/packages/backend/src/apps/paysg/index.ts @@ -0,0 +1,20 @@ +import type { IApp } from '@plumber/types' + +import actions from './actions' +import auth from './auth' + +const app: IApp = { + name: 'PaySG', + key: 'paysg', + iconUrl: '{BASE_URL}/apps/paysg/assets/favicon.svg', + authDocUrl: + 'https://guide.pay.gov.sg/api/get-started-build-your-integration/get-started-with-paysgs-api', + supportsConnections: true, + baseUrl: '', + apiBaseUrl: '', + primaryColor: '000000', + auth, + actions, +} + +export default app diff --git a/packages/backend/src/helpers/internal-date-format.ts b/packages/backend/src/helpers/internal-date-format.ts new file mode 100644 index 000000000..a9255d110 --- /dev/null +++ b/packages/backend/src/helpers/internal-date-format.ts @@ -0,0 +1,31 @@ +import '@/config/app' // Force luxon locale settings, just in case. + +import { DateTime } from 'luxon' +import { z } from 'zod' + +/** + * This file contains things needed to help us conform to a common internal date + * format - dd MMM yyyy + */ + +export const LUXON_FORMAT_STRING = 'dd MMM yyyy' + +export const zodParser = z + .string() + .trim() + .min(1, { message: 'Empty date' }) + .transform((value, context) => { + const result = DateTime.fromFormat(value, LUXON_FORMAT_STRING) + + if (!result.isValid) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: + 'Date not in "2-digit-day month 4-digit-year" (e.g. 02 Nov 2023) format', + }) + + return z.NEVER + } + + return result + })