diff --git a/packages/point-of-sale/package.json b/packages/point-of-sale/package.json index 395f73cf69..73bd43bb56 100644 --- a/packages/point-of-sale/package.json +++ b/packages/point-of-sale/package.json @@ -24,6 +24,7 @@ "@apollo/server": "^4.11.2", "@koa/cors": "^5.0.0", "@koa/router": "^12.0.2", + "axios": "1.8.2", "dotenv": "^16.4.7", "graphql": "^16.11.0", "json-canonicalize": "^1.0.6", diff --git a/packages/point-of-sale/src/card-service-client/client.test.ts b/packages/point-of-sale/src/card-service-client/client.test.ts new file mode 100644 index 0000000000..16d8644969 --- /dev/null +++ b/packages/point-of-sale/src/card-service-client/client.test.ts @@ -0,0 +1,80 @@ +import { + CardServiceClient, + PaymentOptions, + PaymentResponse, + Result, + createCardServiceClient +} from './client' +import nock from 'nock' +import { HttpStatusCode } from 'axios' +import { initIocContainer } from '..' +import { Config } from '../config/app' + +describe('CardServiceClient', () => { + const CARD_SERVICE_URL = 'http://card-service.com' + let client: CardServiceClient + + beforeEach(async () => { + const deps = initIocContainer(Config) + client = await createCardServiceClient({ + logger: await deps.use('logger'), + axios: await deps.use('axios') + }) + nock.cleanAll() + }) + + afterEach(() => { + expect(nock.isDone()).toBeTruthy() + }) + + const createPaymentResponse = (result?: Result): PaymentResponse => ({ + requestId: 'requestId', + result: result ?? Result.APPROVED + }) + + const options: PaymentOptions = { + incomingPaymentUrl: 'incomingPaymentUrl', + merchantWalletAddress: '', + date: new Date(), + signature: '', + card: { + walletAddress: { + cardService: CARD_SERVICE_URL + }, + trasactionCounter: 1, + expiry: new Date(new Date().getDate() + 1) + } + } + + describe('returns the result', () => { + it.each` + result | code + ${Result.APPROVED} | ${HttpStatusCode.Ok} + ${Result.CARD_EXPIRED} | ${HttpStatusCode.Unauthorized} + ${Result.INVALID_SIGNATURE} | ${HttpStatusCode.Unauthorized} + `('when the result is $result', async (response) => { + nock(CARD_SERVICE_URL) + .post('/payment') + .reply(response.code, createPaymentResponse(response.result)) + expect(await client.sendPayment(options)).toBe(response.result) + }) + }) + + test('throws when there is no payload data', async () => { + nock(CARD_SERVICE_URL).post('/payment').reply(HttpStatusCode.Ok, undefined) + await expect(client.sendPayment(options)).rejects.toMatchObject({ + status: HttpStatusCode.NotFound, + message: 'No payment information was received' + }) + }) + + test('throws when there is an issue with the request', async () => { + nock(CARD_SERVICE_URL) + .post('/payment') + .reply(HttpStatusCode.ServiceUnavailable, 'Something went wrong') + await expect(client.sendPayment(options)).rejects.toMatchObject({ + status: HttpStatusCode.ServiceUnavailable, + message: 'Something went wrong' + }) + }) +}) diff --git a/packages/point-of-sale/src/card-service-client/client.ts b/packages/point-of-sale/src/card-service-client/client.ts new file mode 100644 index 0000000000..8933afa874 --- /dev/null +++ b/packages/point-of-sale/src/card-service-client/client.ts @@ -0,0 +1,120 @@ +import { + AxiosError, + AxiosInstance, + AxiosRequestConfig, + HttpStatusCode +} from 'axios' +import { CardServiceClientError } from './errors' +import { v4 as uuid } from 'uuid' +import { BaseService } from '../shared/baseService' + +interface Card { + trasactionCounter: number + expiry: Date + // TODO: replace with WalletAddress from payment service + // eslint-disable-next-line @typescript-eslint/no-explicit-any + walletAddress: any +} + +export interface PaymentOptions { + merchantWalletAddress: string + incomingPaymentUrl: string + date: Date + signature: string + card: Card +} + +export interface CardServiceClient { + sendPayment(options: PaymentOptions): Promise +} + +interface ServiceDependencies extends BaseService { + axios: AxiosInstance +} +interface PaymentOptionsWithReqId extends PaymentOptions { + requestId: string +} + +export enum Result { + APPROVED = 'approved', + CARD_EXPIRED = 'card_expired', + INVALID_SIGNATURE = 'invalid_signature' +} + +export type PaymentResponse = { + requestId: string + result: Result +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types +const isResult = (x: any): x is Result => Object.values(Result).includes(x) + +// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types +const isPaymentResponse = (x: any): x is PaymentResponse => + typeof x.requestId === 'string' && isResult(x.result) + +export async function createCardServiceClient({ + logger, + axios +}: ServiceDependencies): Promise { + const log = logger.child({ + service: 'PosDeviceService' + }) + const deps: ServiceDependencies = { + logger: log, + axios + } + return { + sendPayment: (options) => sendPayment(deps, options) + } +} + +async function sendPayment( + deps: ServiceDependencies, + options: PaymentOptions +): Promise { + try { + const config: AxiosRequestConfig = { + headers: { + 'Content-Type': 'application/json' + } + } + const requestBody: PaymentOptionsWithReqId = { + ...options, + requestId: uuid() + } + const cardServiceUrl = options.card.walletAddress.cardService + const response = await deps.axios.post( + `${cardServiceUrl}/payment`, + requestBody, + config + ) + const payment = response.data + if (!payment) { + throw new CardServiceClientError( + 'No payment information was received', + HttpStatusCode.NotFound + ) + } + return payment.result + } catch (error) { + if (error instanceof CardServiceClientError) throw error + + if (error instanceof AxiosError) { + if (isPaymentResponse(error.response?.data)) { + return error.response.data.result + } + throw new CardServiceClientError( + error.response?.data ?? 'Unknown Axios error', + error.response?.status ?? HttpStatusCode.ServiceUnavailable + ) + } + + throw new CardServiceClientError( + typeof error === 'string' + ? error + : 'Something went wrong when calling the card service', + HttpStatusCode.ServiceUnavailable + ) + } +} diff --git a/packages/point-of-sale/src/card-service-client/errors.ts b/packages/point-of-sale/src/card-service-client/errors.ts new file mode 100644 index 0000000000..0d008c646f --- /dev/null +++ b/packages/point-of-sale/src/card-service-client/errors.ts @@ -0,0 +1,11 @@ +import { HttpStatusCode } from 'axios' + +export class CardServiceClientError extends Error { + constructor( + message: string, + public status: HttpStatusCode + ) { + super(message) + this.status = status + } +} diff --git a/packages/point-of-sale/src/index.ts b/packages/point-of-sale/src/index.ts index da27d073e1..c8acf0f802 100644 --- a/packages/point-of-sale/src/index.ts +++ b/packages/point-of-sale/src/index.ts @@ -20,6 +20,7 @@ import { createPosDeviceService } from './merchant/devices/service' import { createMerchantRoutes } from './merchant/routes' import { createPaymentService } from './payments/service' import { createPosDeviceRoutes } from './merchant/devices/routes' +import axios from 'axios' export function initIocContainer( config: typeof Config @@ -27,6 +28,7 @@ export function initIocContainer( const container: IocContract = new Ioc() container.singleton('config', async () => config) + container.singleton('axios', async () => axios.create()) container.singleton('logger', async (deps: IocContract) => { const config = await deps.use('config') diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 826307e00d..c9b9269f4e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -805,6 +805,9 @@ importers: '@koa/router': specifier: ^12.0.2 version: 12.0.2 + axios: + specifier: 1.8.2 + version: 1.8.2(debug@4.3.2) dotenv: specifier: ^16.4.7 version: 16.4.7