diff --git a/README.md b/README.md index 7574b62..962a2b7 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,34 @@ To create a payment, you can try and run this command. $ curl -X POST 'http://localhost:3000/v3/payment/scheme_selection' ``` +### [POST] `/v3/payment/create` - Create Custom Payment +This endpoint has a default payment request body, so it behaves like `/v3/payment` if not request body is passed. +The core difference is that you can override the default body request to customise the payment creation. +The request below is used to create a payment in `EURs` with a `Preselected` provider and a `UserSelected` scheme. +Note that the property `type` of every block is required so the service knows what defaults to use. + +```json + { + "currency": "EUR", + "payment_method": { + "type": "bank_transfer", + "provider_selection": { + "type": "preselected", + "provider_id": "xs2a-sparkasse", + "scheme_selection": { + "type": "user_selected" + } + }, + "beneficiary": { + "type": "external_account", + "account_identifier": { + "type": "iban" + } + } + } + } +``` + ### [GET] `/v3/payment/{paymentId}` - Get Payment status Once the payment has been created, you can retrieve its status by using this command. diff --git a/src/controllers/paymentsController.ts b/src/controllers/paymentsController.ts new file mode 100644 index 0000000..a9e66dd --- /dev/null +++ b/src/controllers/paymentsController.ts @@ -0,0 +1,210 @@ +import { NextFunction, Request, Response } from 'express'; +import { + Beneficiary, + CreatePaymentRequest, + CreatePaymentRequestResponse, + ProviderSelection, + SchemeSelection +} from 'models/v3/payments-api/create_payment'; +import AuthenticationClient from 'clients/authentication-client'; +import PaymentsClient from 'clients/paymentv3-client'; +import config from 'config'; +import { HttpException } from 'middleware/errors'; +import { PaymentAccountIdentifier, ProviderSelectionFilter } from 'models/v3/payments-api/common'; + +export class PaymentsController { + private paymentClient = new PaymentsClient(new AuthenticationClient()); + + createPayment = async (req: Request>, res: Response, next: NextFunction) => { + const request = this.buildDefaultCreatePaymentRequest(req.body); + try { + const response = await this.paymentClient.initiatePayment(request); + res.status(200).send({ + localhost: this.buildHppUrl(response, 'http://localhost:3000'), + hpp_url: this.buildHppUrl(response), + request, + response + }); + } catch (error) { + next(error instanceof HttpException ? error : new HttpException(500, 'Failed to initiate payment.')); + } + }; + + private buildDefaultCreatePaymentRequest = (req: Partial): any => { + if (req?.payment_method != undefined && req?.payment_method?.type != 'bank_transfer') { + throw new Error('Unsuporrted payment method type. Only "bank_transfer" is supported.'); + } + + return { + amount_in_minor: 1, + currency: req?.currency ?? 'GBP', + user: { + name: 'John Doe', + phone: '+447514983456', + email: 'johndoe@gmail.com', + ...req?.user + }, + payment_method: { + type: 'bank_transfer', // Always bank transfer + provider_selection: this.overrideProviderSelection(req?.payment_method?.provider_selection), + beneficiary: { ...this.overrideBeneficiary(req?.payment_method?.beneficiary) } + } + }; + }; + + private buildHppUrl = (response: CreatePaymentRequestResponse, baseUrl?: string): string => { + return `${baseUrl ?? config.HPP_URI}/payments#payment_id=${response.id}&resource_token=${ + response.resource_token + }&return_uri=${config.REDIRECT_URI}`; + }; + + private overrideProviderSelection = (providerSelection?: Partial): ProviderSelection => { + if (providerSelection != undefined) { + if (providerSelection?.type == null) { + throw new Error('provider selection type is required'); + } + + if (providerSelection.type === 'user_selected') { + return { + type: 'user_selected', + ...this.overrideProviderSelectionFilter(providerSelection?.filter), + ...this.overrideSchemeSelection(providerSelection?.scheme_selection) + }; + } + + if (providerSelection.type === 'preselected') { + const result: ProviderSelection = { + type: 'preselected', + provider_id: providerSelection?.provider_id ?? 'ob-lloyds', + ...this.overrideSchemeSelection(providerSelection?.scheme_selection) + }; + + if (providerSelection?.scheme_selection == undefined) { + result.scheme_id = providerSelection?.scheme_id ?? 'faster_payments_service'; + } + + return result; + } + } + + return { + type: 'user_selected', + filter: { + release_channel: 'alpha' + } + }; + }; + + private overrideProviderSelectionFilter = ( + filter?: Partial + ): { filter?: ProviderSelectionFilter } => { + // Sending a property as null removes it from the request overriding the default values + if (filter === null) { + return {}; + } + + if (filter != undefined) { + return { + filter: { + ...filter, + release_channel: filter?.release_channel ?? 'alpha' + } + }; + } + + return { filter: { release_channel: 'alpha' } }; + }; + + private overrideSchemeSelection = ( + schemeSelection?: Partial + ): { scheme_selection?: SchemeSelection } => { + if (schemeSelection?.type == 'instant_only') { + return { + scheme_selection: { + ...schemeSelection, + type: 'instant_only' + } + }; + } + + if (schemeSelection?.type == 'instant_preferred') { + return { + scheme_selection: { + ...schemeSelection, + type: 'instant_preferred' + } + }; + } + + if (schemeSelection?.type == 'user_selected') { + return { + scheme_selection: { + type: 'user_selected' + } + }; + } + + return {}; + }; + + private overrideBeneficiary = (beneficiary?: Partial): Beneficiary => { + if (beneficiary != undefined) { + if (beneficiary?.type == null) { + throw new Error('beneficiary type is required'); + } + + if (beneficiary.type === 'external_account') { + return { + type: 'external_account', + reference: beneficiary?.reference ?? 'some reference', + account_holder_name: beneficiary?.account_holder_name ?? 'Merry Poppins', + account_identifier: { ...this.overrideAccountIdentifier(beneficiary?.account_identifier) } + }; + } + // Add merchant_account if needed + } + + // Default to external_account + return { + type: 'external_account', + reference: 'some reference', + account_holder_name: 'Merry Poppins', + account_identifier: { + type: 'sort_code_account_number', + account_number: '12345678', + sort_code: '112233' + } + }; + }; + + private overrideAccountIdentifier = ( + accountIdentifier?: Partial + ): PaymentAccountIdentifier => { + if (accountIdentifier != undefined) { + if (accountIdentifier.type == null) { + throw new Error('account identifier type is required'); + } + + if (accountIdentifier.type === 'sort_code_account_number') { + return { + type: 'sort_code_account_number', + account_number: accountIdentifier?.account_number ?? '12345678', + sort_code: accountIdentifier?.sort_code ?? '112233' + }; + } + + if (accountIdentifier.type === 'iban') { + return { + type: 'iban', + iban: accountIdentifier?.iban ?? 'GB33BUKB20201555555555' + }; + } + } + + return { + type: 'sort_code_account_number', + account_number: '12345678', + sort_code: '112233' + }; + }; +} diff --git a/src/controllers/paymentsV3.ts b/src/controllers/paymentsV3.ts index 793d42f..65db0c3 100644 --- a/src/controllers/paymentsV3.ts +++ b/src/controllers/paymentsV3.ts @@ -4,12 +4,8 @@ import AuthenticationClient from 'clients/authentication-client'; import PaymentsClient from 'clients/paymentv3-client'; import { HttpException } from 'middleware/errors'; import config from 'config'; -import { - CreatePaymentRequest, - ProviderFilter, - ProviderSelection, - SchemeSelection -} from 'models/v3/payments-api/create_payment'; +import { ProviderSelectionFilter } from 'models/v3/payments-api/common'; +import { CreatePaymentRequest, ProviderSelection, SchemeSelection } from 'models/v3/payments-api/create_payment'; /** * Controller for the PaymentsV3 API - Payments. @@ -112,7 +108,7 @@ export default class PaymentsV3Controller { private buildPaymentRequest(currency?: 'EUR'): CreatePaymentRequest { // Include all providers by default, // this is particulary useful when testing against embedded flow mock banks on Mobile (xs2a-volksbanken-de-sandbox) - const filter: ProviderFilter = { + const filter: ProviderSelectionFilter = { release_channel: 'alpha' }; @@ -156,7 +152,7 @@ export default class PaymentsV3Controller { // ob-monzo does not work with preselected private buildPaymentRequestWithUserSelectedScheme(): CreatePaymentRequest { const beneficiary = this.getBeneficiary('EUR'); - const filter: ProviderFilter = { + const filter: ProviderSelectionFilter = { release_channel: 'alpha' }; diff --git a/src/models/v3/payments-api/common.ts b/src/models/v3/payments-api/common.ts index a461f52..61520e1 100644 --- a/src/models/v3/payments-api/common.ts +++ b/src/models/v3/payments-api/common.ts @@ -43,6 +43,8 @@ export const providerFilterSchema = z }) .deepPartial(); +export type ProviderSelectionFilter = z.infer; + export const accountIdentifierScanSchema = z.object({ type: z.literal('sort_code_account_number'), sort_code: z.string(), @@ -59,6 +61,8 @@ export const paymentAccountIdentifierSchema = z.discriminatedUnion('type', [ accountIdentifierIbanSchema ]); +export type PaymentAccountIdentifier = z.infer; + export const remitterSchema = z.object({ account_holder_name: z.string(), account_identifier: paymentAccountIdentifierSchema diff --git a/src/models/v3/payments-api/create_payment.ts b/src/models/v3/payments-api/create_payment.ts index 826ec75..05b65a1 100644 --- a/src/models/v3/payments-api/create_payment.ts +++ b/src/models/v3/payments-api/create_payment.ts @@ -1,8 +1,6 @@ import z from 'zod'; import { currencyCodeSchema, paymentAccountIdentifierSchema, providerFilterSchema } from './common'; -export type ProviderFilter = z.infer; - const beneficiarySchema = z.object({ type: z.literal('external_account'), account_identifier: paymentAccountIdentifierSchema, @@ -10,6 +8,8 @@ const beneficiarySchema = z.object({ reference: z.string() }); +export type Beneficiary = z.infer; + const schemeSelectionIntantOnlySchema = z.object({ type: z.literal('instant_only'), allow_remitter_fee: z.boolean().optional() @@ -34,7 +34,7 @@ export type SchemeSelection = z.infer; const providerSelectionUserSelectedSchema = z.object({ type: z.literal('user_selected'), - filter: providerFilterSchema, + filter: providerFilterSchema.optional(), scheme_selection: schemeSelectionSchema.optional() }); diff --git a/src/routes/payments.ts b/src/routes/payments.ts index 33e6c64..e4a4b74 100644 --- a/src/routes/payments.ts +++ b/src/routes/payments.ts @@ -1,13 +1,16 @@ import PaymentsV3Controller from 'controllers/paymentsV3'; +import { PaymentsController } from 'controllers/paymentsController'; import { Router } from 'express'; const router = Router(); const v3Controller = new PaymentsV3Controller(); +const paymentsController = new PaymentsController(); router.post('/v3/payment', v3Controller.createPayment); router.post('/v3/payment/euro', v3Controller.createEuroPayment); router.post('/v3/payment/provider', v3Controller.createPaymentWithProvider()); router.post('/v3/payment/scheme_selection', v3Controller.createPaymentWithUserSelectedScheme()); router.get('/v3/payment/:id', v3Controller.getPayment); +router.post('/v3/payment/create', paymentsController.createPayment); export default router;