Skip to content

Commit f5a9a07

Browse files
zeppelin44njlie
authored andcommitted
feat: [RAF-1083][POS Service]: Add GQL Client for Rafiki BE calls for incoming payment operations (#3546)
1 parent 950b488 commit f5a9a07

File tree

11 files changed

+3432
-32
lines changed

11 files changed

+3432
-32
lines changed

packages/backend/codegen.yml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,19 @@ generates:
6161
defaultMapper: Partial<{T}>
6262
# By default it is T | null. But the service code uses typescript's optional types (`foo?: T`) which are `T | undefined`. This saves the trouble of converting them explicitly.
6363
inputMaybeValue: T | undefined
64+
scalars:
65+
UInt8: number
66+
UInt64: bigint
67+
../point-of-sale/src/graphql/generated/graphql.ts:
68+
plugins:
69+
- 'typescript'
70+
- 'typescript-resolvers'
71+
- 'typescript-operations'
72+
documents: ../point-of-sale/src/graphql/**/*.{ts,tsx,graphql}
73+
config:
74+
omitOperationSuffix: true
75+
defaultMapper: Partial<{T}>
76+
inputMaybeValue: T | undefined
6477
scalars:
6578
UInt8: number
6679
UInt64: bigint

packages/point-of-sale/jest.env.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
11
// Jest environment configuration for point-of-sale
22
process.env.NODE_ENV = 'test'
33
process.env.LOG_LEVEL = process.env.LOG_LEVEL || 'silent'
4+
5+
process.env.TENANT_ID = 'tenant_id'
6+
process.env.TENANT_SECRET = 'tenant_secret'
7+
process.env.TENANT_SIGNATURE_VERSION = 'tenant_signature_version'
8+
process.env.GRAPHQL_URL = 'http://127.0.0.1:3003/graphql'

packages/point-of-sale/package.json

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
"test:cov": "pnpm test -- --coverage",
1010
"test:sincemain": "pnpm test -- --changedSince=main",
1111
"test:sincemain:cov": "pnpm test:sincemain --coverage",
12+
"generate": "graphql-codegen --config codegen.yml",
1213
"knex": "knex",
1314
"dev": "ts-node-dev --inspect=0.0.0.0:9229 --respawn --transpile-only src/index.ts",
1415
"build": "pnpm build:deps && pnpm clean && tsc --build tsconfig.json",
@@ -25,6 +26,8 @@
2526
"@koa/cors": "^5.0.0",
2627
"@koa/router": "^12.0.2",
2728
"dotenv": "^16.4.7",
29+
"graphql": "^16.11.0",
30+
"json-canonicalize": "^1.0.6",
2831
"knex": "^3.1.0",
2932
"koa": "^3.0.0",
3033
"koa-bodyparser": "^4.4.1",
@@ -36,17 +39,24 @@
3639
"uuid": "^9.0.1"
3740
},
3841
"devDependencies": {
42+
"@apollo/client": "^3.11.8",
43+
"@graphql-codegen/cli": "5.0.4",
44+
"@graphql-codegen/introspection": "4.0.3",
45+
"@graphql-codegen/typescript": "4.1.3",
46+
"@graphql-codegen/typescript-operations": "^4.4.1",
47+
"@graphql-codegen/typescript-resolvers": "4.4.2",
3948
"@types/koa": "2.15.0",
4049
"@types/koa-bodyparser": "^4.3.12",
4150
"@types/koa__cors": "^5.0.0",
4251
"@types/koa__router": "^12.0.4",
52+
"@types/tmp": "^0.2.6",
4353
"@types/uuid": "^9.0.8",
44-
"nock": "14.0.0-beta.19",
54+
"cross-fetch": "^4.1.0",
4555
"jest-environment-node": "^29.7.0",
4656
"jest-openapi": "^0.14.2",
57+
"nock": "14.0.0-beta.19",
58+
"node-mocks-http": "^1.16.2",
4759
"testcontainers": "^10.16.0",
48-
"tmp": "^0.2.3",
49-
"@types/tmp": "^0.2.6",
50-
"node-mocks-http": "^1.16.2"
60+
"tmp": "^0.2.3"
5161
}
5262
}

packages/point-of-sale/src/config/app.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,5 +35,9 @@ export const Config = {
3535
port: envInt('PORT', 3008),
3636
trustProxy: envBool('TRUST_PROXY', false),
3737
enableManualMigrations: envBool('ENABLE_MANUAl_MIGRATIONS', false),
38-
dbSchema: undefined as string | undefined
38+
dbSchema: undefined as string | undefined,
39+
tenantId: envString('TENANT_ID'),
40+
tenantSecret: envString('TENANT_SECRET'),
41+
tenantSignatureVersion: envString('TENANT_SIGNATURE_VERSION'),
42+
graphqlUrl: envString('GRAPHQL_URL')
3943
}

packages/point-of-sale/src/graphql/generated/graphql.ts

Lines changed: 3103 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { gql } from '@apollo/client'
2+
3+
export const CREATE_INCOMING_PAYMENT = gql`
4+
mutation CreateIncomingPayment($input: CreateIncomingPaymentInput!) {
5+
createIncomingPayment(input: $input) {
6+
payment {
7+
id
8+
}
9+
}
10+
}
11+
`

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

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,22 +4,37 @@ import { Model } from 'objection'
44
import { Config } from './config/app'
55
import { App, AppServices } from './app'
66
import createLogger from 'pino'
7+
import {
8+
ApolloClient,
9+
ApolloLink,
10+
createHttpLink,
11+
InMemoryCache
12+
} from '@apollo/client'
13+
import { onError } from '@apollo/client/link/error'
14+
import { setContext } from '@apollo/client/link/context'
15+
import { print } from 'graphql'
16+
import { canonicalize } from 'json-canonicalize'
17+
import { createHmac } from 'crypto'
718
import { createMerchantService } from './merchant/service'
819
import { createPosDeviceService } from './merchant/devices/service'
920
import { createMerchantRoutes } from './merchant/routes'
21+
import { createPaymentService } from './payments/service'
1022
import { createPosDeviceRoutes } from './merchant/devices/routes'
1123

1224
export function initIocContainer(
1325
config: typeof Config
1426
): IocContract<AppServices> {
1527
const container: IocContract<AppServices> = new Ioc()
28+
1629
container.singleton('config', async () => config)
30+
1731
container.singleton('logger', async (deps: IocContract<AppServices>) => {
1832
const config = await deps.use('config')
1933
const logger = createLogger()
2034
logger.level = config.logLevel
2135
return logger
2236
})
37+
2338
container.singleton('knex', async (deps: IocContract<AppServices>) => {
2439
const logger = await deps.use('logger')
2540
const config = await deps.use('config')
@@ -76,6 +91,89 @@ export function initIocContainer(
7691
})
7792
})
7893

94+
container.singleton('apolloClient', async (deps) => {
95+
const [logger, config] = await Promise.all([
96+
deps.use('logger'),
97+
deps.use('config')
98+
])
99+
100+
const httpLink = createHttpLink({
101+
uri: config.graphqlUrl
102+
})
103+
104+
const errorLink = onError(({ graphQLErrors }) => {
105+
if (graphQLErrors) {
106+
logger.error(graphQLErrors)
107+
graphQLErrors.map(({ extensions }) => {
108+
if (extensions && extensions.code === 'UNAUTHENTICATED') {
109+
logger.error('UNAUTHENTICATED')
110+
}
111+
112+
if (extensions && extensions.code === 'FORBIDDEN') {
113+
logger.error('FORBIDDEN')
114+
}
115+
})
116+
}
117+
})
118+
119+
const authLink = setContext((request, { headers }) => {
120+
if (!config.tenantSecret || !config.tenantSignatureVersion)
121+
return { headers }
122+
const timestamp = Date.now()
123+
const version = config.tenantSignatureVersion
124+
125+
const { query, variables, operationName } = request
126+
const formattedRequest = {
127+
variables,
128+
operationName,
129+
query: print(query)
130+
}
131+
132+
const payload = `${timestamp}.${canonicalize(formattedRequest)}`
133+
const hmac = createHmac('sha256', config.tenantSecret)
134+
hmac.update(payload)
135+
const digest = hmac.digest('hex')
136+
137+
const link = {
138+
headers: {
139+
...headers,
140+
'tenant-id': `${config.tenantId}`,
141+
signature: `t=${timestamp}, v${version}=${digest}`
142+
}
143+
}
144+
145+
return link
146+
})
147+
148+
const link = ApolloLink.from([errorLink, authLink, httpLink])
149+
150+
const client = new ApolloClient({
151+
cache: new InMemoryCache({}),
152+
link: link,
153+
defaultOptions: {
154+
query: {
155+
fetchPolicy: 'no-cache'
156+
},
157+
mutate: {
158+
fetchPolicy: 'no-cache'
159+
},
160+
watchQuery: {
161+
fetchPolicy: 'no-cache'
162+
}
163+
}
164+
})
165+
166+
return client
167+
})
168+
169+
container.singleton('paymentClient', async (deps) => {
170+
return createPaymentService({
171+
apolloClient: await deps.use('apolloClient'),
172+
logger: await deps.use('logger'),
173+
config: await deps.use('config')
174+
})
175+
})
176+
79177
container.singleton(
80178
'posDeviceService',
81179
async (deps: IocContract<AppServices>) => {
@@ -94,6 +192,7 @@ export function initIocContainer(
94192
posDeviceService: await deps.use('posDeviceService')
95193
})
96194
)
195+
97196
return container
98197
}
99198

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { createPaymentService } from './service'
2+
import { ApolloClient, NormalizedCacheObject } from '@apollo/client'
3+
import { Logger } from 'pino'
4+
import { AmountInput } from '../graphql/generated/graphql'
5+
import { IAppConfig } from '../config/app'
6+
7+
const mockLogger = {
8+
child: jest.fn().mockReturnThis(),
9+
error: jest.fn()
10+
} as unknown as Logger
11+
12+
const mockConfig = {}
13+
14+
const mockApolloClient = {
15+
mutate: jest.fn()
16+
} as unknown as ApolloClient<NormalizedCacheObject>
17+
18+
const deps = {
19+
logger: mockLogger,
20+
config: mockConfig as IAppConfig,
21+
apolloClient: mockApolloClient
22+
}
23+
24+
describe('createPaymentService', () => {
25+
beforeEach(() => {
26+
jest.clearAllMocks()
27+
})
28+
29+
it('should create an incoming payment and return the incoming payment url (id)', async () => {
30+
const expectedUrl = 'https://api.example.com/incoming-payments/abc123'
31+
mockApolloClient.mutate = jest.fn().mockResolvedValue({
32+
data: {
33+
payment: {
34+
id: expectedUrl
35+
}
36+
}
37+
})
38+
const service = createPaymentService(deps)
39+
const walletAddressId = 'wallet-123'
40+
const incomingAmount: AmountInput = {
41+
value: 1000n,
42+
assetCode: 'USD',
43+
assetScale: 2
44+
}
45+
const result = await service.createIncomingPayment(
46+
walletAddressId,
47+
incomingAmount
48+
)
49+
expect(result).toBe(expectedUrl)
50+
expect(mockApolloClient.mutate).toHaveBeenCalled()
51+
})
52+
53+
it('should throw and log error if payment creation fails (no id)', async () => {
54+
mockApolloClient.mutate = jest
55+
.fn()
56+
.mockResolvedValue({ data: { payment: { id: undefined } } })
57+
const service = createPaymentService(deps)
58+
const walletAddressId = 'wallet-123'
59+
const incomingAmount: AmountInput = {
60+
value: 1000n,
61+
assetCode: 'USD',
62+
assetScale: 2
63+
}
64+
await expect(
65+
service.createIncomingPayment(walletAddressId, incomingAmount)
66+
).rejects.toThrow(/Failed to create incoming payment/)
67+
expect(mockLogger.error).toHaveBeenCalledWith(
68+
{ walletAddressId },
69+
'Failed to create incoming payment for given walletAddressId'
70+
)
71+
})
72+
})
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { Logger } from 'pino'
2+
import { CREATE_INCOMING_PAYMENT } from '../graphql/mutations/createIncomingPayment'
3+
import { IAppConfig } from '../config/app'
4+
import { ApolloClient, NormalizedCacheObject } from '@apollo/client'
5+
import {
6+
AmountInput,
7+
CreateIncomingPaymentInput,
8+
type Mutation
9+
} from '../graphql/generated/graphql'
10+
import { FnWithDeps } from '../shared/types'
11+
import { v4 } from 'uuid'
12+
13+
type ServiceDependencies = {
14+
logger: Logger
15+
config: IAppConfig
16+
apolloClient: ApolloClient<NormalizedCacheObject>
17+
}
18+
19+
type PaymentService = {
20+
createIncomingPayment: (
21+
walletAddressId: string,
22+
incomingAmount: AmountInput
23+
) => Promise<string>
24+
}
25+
26+
export function createPaymentService(
27+
deps_: ServiceDependencies
28+
): PaymentService {
29+
const logger = deps_.logger.child({
30+
service: 'PaymentService'
31+
})
32+
const deps = {
33+
...deps_,
34+
logger
35+
}
36+
37+
return {
38+
createIncomingPayment: (
39+
walletAddressId: string,
40+
incomingAmount: AmountInput
41+
) => createIncomingPayment(deps, walletAddressId, incomingAmount)
42+
}
43+
}
44+
45+
const createIncomingPayment: FnWithDeps<
46+
ServiceDependencies,
47+
PaymentService['createIncomingPayment']
48+
> = async (deps, walletAddressId, incomingAmount) => {
49+
const client = deps.apolloClient
50+
const { data } = await client.mutate<
51+
Mutation['createIncomingPayment'],
52+
CreateIncomingPaymentInput
53+
>({
54+
mutation: CREATE_INCOMING_PAYMENT,
55+
variables: {
56+
walletAddressId,
57+
incomingAmount,
58+
idempotencyKey: v4()
59+
}
60+
})
61+
62+
const incomingPaymentUrl = data?.payment?.id
63+
if (!incomingPaymentUrl) {
64+
deps.logger.error(
65+
{ walletAddressId },
66+
'Failed to create incoming payment for given walletAddressId'
67+
)
68+
throw new Error(
69+
`Failed to create incoming payment for given walletAddressId ${walletAddressId}`
70+
)
71+
}
72+
73+
return incomingPaymentUrl
74+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export type FnWithDeps<Deps, Fn> = Fn extends (...args: infer Args) => infer R
2+
? (deps: Deps, ...args: Args) => R
3+
: never

0 commit comments

Comments
 (0)