Skip to content

Commit fc3bf17

Browse files
authored
feat: added card service client to initiate a payment from pos service (#3535)
* Added card service client to initiate a payment
1 parent ac24b9b commit fc3bf17

File tree

6 files changed

+217
-0
lines changed

6 files changed

+217
-0
lines changed

packages/point-of-sale/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
"@apollo/server": "^4.11.2",
2525
"@koa/cors": "^5.0.0",
2626
"@koa/router": "^12.0.2",
27+
"axios": "1.8.2",
2728
"dotenv": "^16.4.7",
2829
"graphql": "^16.11.0",
2930
"json-canonicalize": "^1.0.6",
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import {
2+
CardServiceClient,
3+
PaymentOptions,
4+
PaymentResponse,
5+
Result,
6+
createCardServiceClient
7+
} from './client'
8+
import nock from 'nock'
9+
import { HttpStatusCode } from 'axios'
10+
import { initIocContainer } from '..'
11+
import { Config } from '../config/app'
12+
13+
describe('CardServiceClient', () => {
14+
const CARD_SERVICE_URL = 'http://card-service.com'
15+
let client: CardServiceClient
16+
17+
beforeEach(async () => {
18+
const deps = initIocContainer(Config)
19+
client = await createCardServiceClient({
20+
logger: await deps.use('logger'),
21+
axios: await deps.use('axios')
22+
})
23+
nock.cleanAll()
24+
})
25+
26+
afterEach(() => {
27+
expect(nock.isDone()).toBeTruthy()
28+
})
29+
30+
const createPaymentResponse = (result?: Result): PaymentResponse => ({
31+
requestId: 'requestId',
32+
result: result ?? Result.APPROVED
33+
})
34+
35+
const options: PaymentOptions = {
36+
incomingPaymentUrl: 'incomingPaymentUrl',
37+
merchantWalletAddress: '',
38+
date: new Date(),
39+
signature: '',
40+
card: {
41+
walletAddress: {
42+
cardService: CARD_SERVICE_URL
43+
},
44+
trasactionCounter: 1,
45+
expiry: new Date(new Date().getDate() + 1)
46+
}
47+
}
48+
49+
describe('returns the result', () => {
50+
it.each`
51+
result | code
52+
${Result.APPROVED} | ${HttpStatusCode.Ok}
53+
${Result.CARD_EXPIRED} | ${HttpStatusCode.Unauthorized}
54+
${Result.INVALID_SIGNATURE} | ${HttpStatusCode.Unauthorized}
55+
`('when the result is $result', async (response) => {
56+
nock(CARD_SERVICE_URL)
57+
.post('/payment')
58+
.reply(response.code, createPaymentResponse(response.result))
59+
expect(await client.sendPayment(options)).toBe(response.result)
60+
})
61+
})
62+
63+
test('throws when there is no payload data', async () => {
64+
nock(CARD_SERVICE_URL).post('/payment').reply(HttpStatusCode.Ok, undefined)
65+
await expect(client.sendPayment(options)).rejects.toMatchObject({
66+
status: HttpStatusCode.NotFound,
67+
message: 'No payment information was received'
68+
})
69+
})
70+
71+
test('throws when there is an issue with the request', async () => {
72+
nock(CARD_SERVICE_URL)
73+
.post('/payment')
74+
.reply(HttpStatusCode.ServiceUnavailable, 'Something went wrong')
75+
await expect(client.sendPayment(options)).rejects.toMatchObject({
76+
status: HttpStatusCode.ServiceUnavailable,
77+
message: 'Something went wrong'
78+
})
79+
})
80+
})
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import {
2+
AxiosError,
3+
AxiosInstance,
4+
AxiosRequestConfig,
5+
HttpStatusCode
6+
} from 'axios'
7+
import { CardServiceClientError } from './errors'
8+
import { v4 as uuid } from 'uuid'
9+
import { BaseService } from '../shared/baseService'
10+
11+
interface Card {
12+
trasactionCounter: number
13+
expiry: Date
14+
// TODO: replace with WalletAddress from payment service
15+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
16+
walletAddress: any
17+
}
18+
19+
export interface PaymentOptions {
20+
merchantWalletAddress: string
21+
incomingPaymentUrl: string
22+
date: Date
23+
signature: string
24+
card: Card
25+
}
26+
27+
export interface CardServiceClient {
28+
sendPayment(options: PaymentOptions): Promise<Result>
29+
}
30+
31+
interface ServiceDependencies extends BaseService {
32+
axios: AxiosInstance
33+
}
34+
interface PaymentOptionsWithReqId extends PaymentOptions {
35+
requestId: string
36+
}
37+
38+
export enum Result {
39+
APPROVED = 'approved',
40+
CARD_EXPIRED = 'card_expired',
41+
INVALID_SIGNATURE = 'invalid_signature'
42+
}
43+
44+
export type PaymentResponse = {
45+
requestId: string
46+
result: Result
47+
}
48+
49+
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types
50+
const isResult = (x: any): x is Result => Object.values(Result).includes(x)
51+
52+
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types
53+
const isPaymentResponse = (x: any): x is PaymentResponse =>
54+
typeof x.requestId === 'string' && isResult(x.result)
55+
56+
export async function createCardServiceClient({
57+
logger,
58+
axios
59+
}: ServiceDependencies): Promise<CardServiceClient> {
60+
const log = logger.child({
61+
service: 'PosDeviceService'
62+
})
63+
const deps: ServiceDependencies = {
64+
logger: log,
65+
axios
66+
}
67+
return {
68+
sendPayment: (options) => sendPayment(deps, options)
69+
}
70+
}
71+
72+
async function sendPayment(
73+
deps: ServiceDependencies,
74+
options: PaymentOptions
75+
): Promise<Result> {
76+
try {
77+
const config: AxiosRequestConfig = {
78+
headers: {
79+
'Content-Type': 'application/json'
80+
}
81+
}
82+
const requestBody: PaymentOptionsWithReqId = {
83+
...options,
84+
requestId: uuid()
85+
}
86+
const cardServiceUrl = options.card.walletAddress.cardService
87+
const response = await deps.axios.post<PaymentResponse>(
88+
`${cardServiceUrl}/payment`,
89+
requestBody,
90+
config
91+
)
92+
const payment = response.data
93+
if (!payment) {
94+
throw new CardServiceClientError(
95+
'No payment information was received',
96+
HttpStatusCode.NotFound
97+
)
98+
}
99+
return payment.result
100+
} catch (error) {
101+
if (error instanceof CardServiceClientError) throw error
102+
103+
if (error instanceof AxiosError) {
104+
if (isPaymentResponse(error.response?.data)) {
105+
return error.response.data.result
106+
}
107+
throw new CardServiceClientError(
108+
error.response?.data ?? 'Unknown Axios error',
109+
error.response?.status ?? HttpStatusCode.ServiceUnavailable
110+
)
111+
}
112+
113+
throw new CardServiceClientError(
114+
typeof error === 'string'
115+
? error
116+
: 'Something went wrong when calling the card service',
117+
HttpStatusCode.ServiceUnavailable
118+
)
119+
}
120+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { HttpStatusCode } from 'axios'
2+
3+
export class CardServiceClientError extends Error {
4+
constructor(
5+
message: string,
6+
public status: HttpStatusCode
7+
) {
8+
super(message)
9+
this.status = status
10+
}
11+
}

packages/point-of-sale/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,15 @@ import { createPosDeviceService } from './merchant/devices/service'
2020
import { createMerchantRoutes } from './merchant/routes'
2121
import { createPaymentService } from './payments/service'
2222
import { createPosDeviceRoutes } from './merchant/devices/routes'
23+
import axios from 'axios'
2324

2425
export function initIocContainer(
2526
config: typeof Config
2627
): IocContract<AppServices> {
2728
const container: IocContract<AppServices> = new Ioc()
2829

2930
container.singleton('config', async () => config)
31+
container.singleton('axios', async () => axios.create())
3032

3133
container.singleton('logger', async (deps: IocContract<AppServices>) => {
3234
const config = await deps.use('config')

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)