Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/point-of-sale/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions packages/point-of-sale/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import Koa, { DefaultState } from 'koa'
import Router from '@koa/router'
import bodyParser from 'koa-bodyparser'
import cors from '@koa/cors'
import { CardServiceClient } from './card-service-client/client'
import {
CreateMerchantContext,
DeleteMerchantContext,
Expand All @@ -27,6 +28,7 @@ export interface AppServices {
posDeviceRoutes: Promise<PosDeviceRoutes>
posDeviceService: Promise<PosDeviceService>
merchantService: Promise<MerchantService>
cardServiceClient: Promise<CardServiceClient>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: I don't think we need to add this to the AppServices interface if it's not going to be mounted to any routes

}

export type AppContainer = IocContract<AppServices>
Expand Down
80 changes: 80 additions & 0 deletions packages/point-of-sale/src/card-service-client/client.test.ts
Original file line number Diff line number Diff line change
@@ -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'
})
})
})
120 changes: 120 additions & 0 deletions packages/point-of-sale/src/card-service-client/client.ts
Original file line number Diff line number Diff line change
@@ -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<Result>
}

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<CardServiceClient> {
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<Result> {
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<PaymentResponse>(
`${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
)
}
}
11 changes: 11 additions & 0 deletions packages/point-of-sale/src/card-service-client/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { HttpStatusCode } from 'axios'

export class CardServiceClientError extends Error {
constructor(
message: string,
public status: HttpStatusCode
) {
super(message)
this.status = status
}
}
9 changes: 9 additions & 0 deletions packages/point-of-sale/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,16 @@ import { createPosDeviceService } from './merchant/devices/service'
import { createMerchantRoutes } from './merchant/routes'
import { createPaymentService } from './payments/service'
import { createPosDeviceRoutes } from './merchant/devices/routes'
import { createCardServiceClient } from './card-service-client/client'
import axios from 'axios'

export function initIocContainer(
config: typeof Config
): IocContract<AppServices> {
const container: IocContract<AppServices> = new Ioc()

container.singleton('config', async () => config)
container.singleton('axios', async () => axios.create())

container.singleton('logger', async (deps: IocContract<AppServices>) => {
const config = await deps.use('config')
Expand Down Expand Up @@ -194,6 +197,12 @@ export function initIocContainer(
})
)

container.singleton('cardServiceClient', async (deps) => {
return createCardServiceClient({
logger: await deps.use('logger'),
axios: await deps.use('axios')
})
})
return container
}

Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading