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/actions/create-payment/index.ts b/packages/backend/src/apps/paysg/actions/create-payment/index.ts new file mode 100644 index 000000000..92ecf265d --- /dev/null +++ b/packages/backend/src/apps/paysg/actions/create-payment/index.ts @@ -0,0 +1,164 @@ +import type { IJSONArray, IJSONObject, IRawAction } from '@plumber/types' + +import getApiBaseUrl from '../../common/get-api-base-url' + +type CreatePaymentPayload = { + reference_id: string + payer_name: string + payer_address: string + payer_identifier: string + payer_email: string + description: string + amount_in_cents: number + metadata: Record + due_date?: string + return_url?: string +} + +function constructPayload(parameters: IJSONObject): CreatePaymentPayload { + const payload: CreatePaymentPayload = { + reference_id: parameters.referenceId as string, + payer_name: parameters.payerName as string, + payer_address: parameters.payerAddress as string, + payer_identifier: parameters.payerIdentifier as string, + payer_email: parameters.payerEmail as string, + description: parameters.description as string, + amount_in_cents: Number(parameters.paymentAmountCents), + metadata: Object.create(null), + } + + if (parameters.dueDate) { + payload['due_date'] = parameters.dueDate as string + } + + if (parameters.returnUrl) { + payload['return_url'] = parameters.returnUrl as string + } + + const metadata = parameters.metadata as IJSONArray | null + if (metadata?.length) { + for (const metadatum of metadata) { + const { key, value } = metadatum as { key: string; value: string } + payload['metadata'][key] = value + } + } + + return payload +} + +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 as DD-MM-YYYY (e.g. 31-DEC-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 + const payload = constructPayload($.step.parameters) + + const response = await $.http.post( + `/v1/payment-services/${paymentServiceId}/payments`, + payload, + { + baseURL: baseUrl, + headers: { + 'x-api-key': apiKey, + }, + }, + ) + + $.setActionItem({ raw: { ...response.data } }) + }, +} + +export default action 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/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..14071fd20 --- /dev/null +++ b/packages/backend/src/apps/paysg/auth/is-still-verified.ts @@ -0,0 +1,10 @@ +import type { IGlobalVariable } from '@plumber/types' + +import verifyCredentials from './verify-credentials' + +export default async function isStillVerified( + $: IGlobalVariable, +): Promise { + await verifyCredentials($) + 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..61f5924b4 --- /dev/null +++ b/packages/backend/src/apps/paysg/auth/verify-credentials.ts @@ -0,0 +1,9 @@ +import type { IGlobalVariable } from '@plumber/types' + +export default async function verifyCredentials( + $: IGlobalVariable, +): Promise { + if (!$.auth.data?.apiKey) { + throw new Error('Invalid PaySG API key') + } +} diff --git a/packages/backend/src/apps/paysg/common/get-api-base-url.ts b/packages/backend/src/apps/paysg/common/get-api-base-url.ts new file mode 100644 index 000000000..0dfb70594 --- /dev/null +++ b/packages/backend/src/apps/paysg/common/get-api-base-url.ts @@ -0,0 +1,17 @@ +const STAGING_PREFIX = 'paysg_stag_' +const STAGING_BASE_URL = 'https://api-staging.pay.gov.sg' + +const LIVE_PREFIX = 'paysg_live_' +const LIVE_BASE_URL = 'https://api.pay.gov.sg' + +export default function getApiBaseUrl(apiKey: string): string { + if (apiKey.startsWith(LIVE_PREFIX)) { + return LIVE_BASE_URL + } + + if (apiKey.startsWith(STAGING_PREFIX)) { + return STAGING_BASE_URL + } + + throw new Error('API key has unrecognized prefix!') +} diff --git a/packages/backend/src/apps/paysg/index.ts b/packages/backend/src/apps/paysg/index.ts new file mode 100644 index 000000000..c4c09d0d0 --- /dev/null +++ b/packages/backend/src/apps/paysg/index.ts @@ -0,0 +1,19 @@ +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.plumber.gov.sg/user-guides/actions/paysg', + supportsConnections: true, + baseUrl: '', + apiBaseUrl: '', + primaryColor: '000000', + auth, + actions, +} + +export default app