diff --git a/bruno/collections/Rafiki/POS Service APIs/Initiate Payment.bru b/bruno/collections/Rafiki/POS Service APIs/Initiate Payment.bru new file mode 100644 index 0000000000..9a13c092cd --- /dev/null +++ b/bruno/collections/Rafiki/POS Service APIs/Initiate Payment.bru @@ -0,0 +1,24 @@ +meta { + name: Initiate Payment + type: http + seq: 3 +} + +post { + url: http://localhost:4008/payment + body: json + auth: inherit +} + +body:json { + { + "card": { + "walletAddress": "http://cloud-nine-wallet-backend/accounts/gfranklin", + "trasactionCounter": 1, + "expiry": "2025-09-13T13:00:00Z" + }, + "signature": "signature", + "value": 1, + "merchantWalletAddress": "http://happy-life-bank-backend/accounts/pfry" + } +} diff --git a/localenv/cloud-nine-wallet/docker-compose.yml b/localenv/cloud-nine-wallet/docker-compose.yml index 47de97e5a9..dd3e66e7e9 100644 --- a/localenv/cloud-nine-wallet/docker-compose.yml +++ b/localenv/cloud-nine-wallet/docker-compose.yml @@ -11,6 +11,7 @@ services: - rafiki ports: - '3007:3007' + - '9234:9229' volumes: - type: bind source: ../../packages/card-service/src @@ -23,6 +24,10 @@ services: LOG_LEVEL: debug CARD_SERVICE_PORT: 3007 DATABASE_URL: postgresql://cloud_nine_wallet_card_service:cloud_nine_wallet_card_service@shared-database/cloud_nine_wallet_card_service + GRAPHQL_URL: http://cloud-nine-wallet-backend:3001/graphql + TENANT_ID: 438fa74a-fa7d-4317-9ced-dde32ece1787 + TENANT_SECRET: iyIgCprjb9uL8wFckR+pLEkJWMB7FJhgkvqhTQR/964= + TENANT_SIGNATURE_VERSION: 1 depends_on: - shared-database healthcheck: @@ -55,6 +60,10 @@ services: LOG_LEVEL: debug PORT: 3008 DATABASE_URL: postgresql://cloud_nine_wallet_point_of_sale:cloud_nine_wallet_point_of_sale@shared-database/cloud_nine_wallet_point_of_sale + TENANT_ID: 438fa74a-fa7d-4317-9ced-dde32ece1787 + TENANT_SECRET: iyIgCprjb9uL8wFckR+pLEkJWMB7FJhgkvqhTQR/964= + GRAPHQL_URL: http://cloud-nine-wallet-backend:3001/graphql + WEBHOOK_SIGNATURE_SECRET: webhook_secret depends_on: - shared-database healthcheck: diff --git a/localenv/happy-life-bank/docker-compose.yml b/localenv/happy-life-bank/docker-compose.yml index 61f4edd002..f23524a3d7 100644 --- a/localenv/happy-life-bank/docker-compose.yml +++ b/localenv/happy-life-bank/docker-compose.yml @@ -23,6 +23,10 @@ services: LOG_LEVEL: debug CARD_SERVICE_PORT: 4007 DATABASE_URL: postgresql://happy_life_bank_card_service:happy_life_bank_card_service@shared-database/happy_life_bank_card_service + GRAPHQL_URL: http://happy-life-bank-backend:3001/graphql + TENANT_ID: cf5fd7d3-1eb1-4041-8e43-ba45747e9e5d + TENANT_SECRET: iyIgCprjb9uL8wFckR+pLEkJWMB7FJhgkvqhTQR/964= + TENANT_SIGNATURE_VERSION: 1 depends_on: - shared-database - cloud-nine-wallet-card-service @@ -44,6 +48,7 @@ services: - rafiki ports: - '4008:4008' + - '9233:9229' volumes: - type: bind source: ../../packages/point-of-sale/src @@ -56,6 +61,10 @@ services: LOG_LEVEL: debug PORT: 4008 DATABASE_URL: postgresql://happy_life_bank_point_of_sale:happy_life_bank_point_of_sale@shared-database/happy_life_bank_point_of_sale + TENANT_ID: cf5fd7d3-1eb1-4041-8e43-ba45747e9e5d + TENANT_SECRET: iyIgCprjb9uL8wFckR+pLEkJWMB7FJhgkvqhTQR/964= + GRAPHQL_URL: http://happy-life-bank-backend:3001/graphql + WEBHOOK_SIGNATURE_SECRET: webhook_secret depends_on: - shared-database - cloud-nine-wallet-point-of-sale 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 index 50c99ea56f..10d0a4e5ef 100644 --- a/packages/point-of-sale/src/card-service-client/client.test.ts +++ b/packages/point-of-sale/src/card-service-client/client.test.ts @@ -9,6 +9,7 @@ import nock from 'nock' import { HttpStatusCode } from 'axios' import { initIocContainer } from '..' import { Config } from '../config/app' +import { faker } from '@faker-js/faker' describe('CardServiceClient', () => { const CARD_SERVICE_URL = 'http://card-service.com' @@ -38,9 +39,7 @@ describe('CardServiceClient', () => { date: new Date(), signature: '', card: { - walletAddress: { - cardService: CARD_SERVICE_URL - }, + walletAddress: faker.internet.url(), trasactionCounter: 1, expiry: new Date(new Date().getDate() + 1) }, @@ -61,13 +60,17 @@ describe('CardServiceClient', () => { nock(CARD_SERVICE_URL) .post('/payment') .reply(response.code, createPaymentResponse(response.result)) - expect(await client.sendPayment(options)).toBe(response.result) + expect(await client.sendPayment(CARD_SERVICE_URL, 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({ + await expect( + client.sendPayment(CARD_SERVICE_URL, options) + ).rejects.toMatchObject({ status: HttpStatusCode.NotFound, message: 'No payment information was received' }) @@ -77,7 +80,9 @@ describe('CardServiceClient', () => { nock(CARD_SERVICE_URL) .post('/payment') .reply(HttpStatusCode.ServiceUnavailable, 'Something went wrong') - await expect(client.sendPayment(options)).rejects.toMatchObject({ + await expect( + client.sendPayment(CARD_SERVICE_URL, 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 index ed8b316964..2894041070 100644 --- a/packages/point-of-sale/src/card-service-client/client.ts +++ b/packages/point-of-sale/src/card-service-client/client.ts @@ -11,9 +11,7 @@ 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 + walletAddress: string } export interface PaymentOptions { @@ -30,7 +28,7 @@ export interface PaymentOptions { } export interface CardServiceClient { - sendPayment(options: PaymentOptions): Promise + sendPayment(cardServiceUrl: string, options: PaymentOptions): Promise } interface ServiceDependencies extends BaseService { @@ -70,12 +68,14 @@ export async function createCardServiceClient({ axios } return { - sendPayment: (options) => sendPayment(deps, options) + sendPayment: (cardServiceUrl, options) => + sendPayment(deps, cardServiceUrl, options) } } async function sendPayment( deps: ServiceDependencies, + cardServiceUrl: string, options: PaymentOptions ): Promise { try { @@ -88,9 +88,8 @@ async function sendPayment( ...options, requestId: uuid() } - const cardServiceUrl = options.card.walletAddress.cardService const response = await deps.axios.post( - `${cardServiceUrl}/payment`, + `${cardServiceUrl + (cardServiceUrl.endsWith('/') ? 'payment' : '/payment')}`, requestBody, config ) @@ -103,6 +102,7 @@ async function sendPayment( } return payment.result } catch (error) { + deps.logger.debug(error) if (error instanceof CardServiceClientError) throw error if (error instanceof AxiosError) { diff --git a/packages/point-of-sale/src/payments/routes.test.ts b/packages/point-of-sale/src/payments/routes.test.ts index e9d867c72f..cffcc5e5e5 100644 --- a/packages/point-of-sale/src/payments/routes.test.ts +++ b/packages/point-of-sale/src/payments/routes.test.ts @@ -10,7 +10,6 @@ import { PaymentService } from './service' import { CardServiceClient, Result } from '../card-service-client/client' import { createContext } from '../tests/context' import { CardServiceClientError } from '../card-service-client/errors' -import { IncomingPaymentState } from '../graphql/generated/graphql' import { webhookWaitMap } from '../webhook-handlers/request-map' import { faker } from '@faker-js/faker' import { withConfigOverride } from '../tests/helpers' @@ -154,17 +153,11 @@ describe('Payment Routes', () => { .spyOn(paymentService, 'createIncomingPayment') .mockResolvedValueOnce({ id: 'incoming-payment-url', - url: faker.internet.url(), - createdAt: new Date().toString(), - walletAddressId: v4(), - expiresAt: new Date(Date.now() + 30000).toString(), - receivedAmount: { - assetCode: 'USD', - assetScale: 2, - value: BigInt(0) - }, - state: IncomingPaymentState.Pending + url: faker.internet.url() }) + jest + .spyOn(paymentService, 'getWalletAddressIdByUrl') + .mockResolvedValueOnce(faker.internet.url()) } }) }) diff --git a/packages/point-of-sale/src/payments/routes.ts b/packages/point-of-sale/src/payments/routes.ts index 0f1db5cea2..13221dc998 100644 --- a/packages/point-of-sale/src/payments/routes.ts +++ b/packages/point-of-sale/src/payments/routes.ts @@ -71,8 +71,16 @@ async function payment( assetScale: walletAddress.assetScale, value: body.value } + // TODO: in the future we need to find a way to make it work in local playground + const walletAddressUrl = body.merchantWalletAddress.replace( + /^http:/, + 'https:' + ) + const walletAddressId = + await deps.paymentService.getWalletAddressIdByUrl(walletAddressUrl) + const incomingPayment = await deps.paymentService.createIncomingPayment( - walletAddress.id, + walletAddressId, incomingAmount ) const deferred = new Deferred() @@ -81,17 +89,21 @@ async function payment( deferred, deps.config.webhookTimeoutMs ) - const result = await deps.cardServiceClient.sendPayment({ - merchantWalletAddress: body.merchantWalletAddress, - incomingPaymentUrl: incomingPayment.url, - date: new Date(), - signature: body.signature, - card: body.card, - incomingAmount: { - ...incomingAmount, - value: incomingAmount.value.toString() + + const result = await deps.cardServiceClient.sendPayment( + walletAddress.cardService, + { + merchantWalletAddress: body.merchantWalletAddress, + incomingPaymentUrl: incomingPayment.url, + date: new Date(), + signature: body.signature, + card: body.card, + incomingAmount: { + ...incomingAmount, + value: incomingAmount.value.toString() + } } - }) + ) if (result !== Result.APPROVED) throw new InvalidCardPaymentError(result) const event = await waitForIncomingPaymentEvent(deps.config, deferred) @@ -101,6 +113,7 @@ async function payment( ctx.body = result ctx.status = 200 } catch (err) { + deps.logger.debug(err) if (err instanceof IncomingPaymentEventTimeoutError) webhookWaitMap.delete(err.incomingPaymentId) const { body, status } = handlePaymentError(err) diff --git a/packages/point-of-sale/src/payments/service.test.ts b/packages/point-of-sale/src/payments/service.test.ts index 4e2619ad86..6c036ad302 100644 --- a/packages/point-of-sale/src/payments/service.test.ts +++ b/packages/point-of-sale/src/payments/service.test.ts @@ -43,9 +43,11 @@ describe('createPaymentService', () => { const expectedUrl = 'https://api.example.com/incoming-payments/abc123' mockApolloClient.mutate = jest.fn().mockResolvedValue({ data: { - payment: { - id: uuid, - url: expectedUrl + createIncomingPayment: { + payment: { + id: uuid, + url: expectedUrl + } } } }) @@ -68,20 +70,22 @@ describe('createPaymentService', () => { expect(mockApolloClient.mutate).toHaveBeenCalledWith( expect.objectContaining({ variables: expect.objectContaining({ - walletAddressId, - incomingAmount, - idempotencyKey: expect.any(String), - isCardPayment: true, - expiresAt + input: expect.objectContaining({ + expiresAt, + idempotencyKey: expect.any(String), + incomingAmount, + isCardPayment: true, + walletAddressId + }) }) }) ) }) it('should throw and log error if payment creation fails (no id)', async () => { - mockApolloClient.mutate = jest - .fn() - .mockResolvedValue({ data: { payment: undefined } }) + mockApolloClient.mutate = jest.fn().mockResolvedValue({ + data: { createIncomingPayment: { payment: undefined } } + }) const service = createPaymentService(deps) const walletAddressId = 'wallet-123' const incomingAmount: AmountInput = { @@ -159,3 +163,35 @@ describe('getWalletAddress', () => { ) }) }) + +describe('getWalletAddressByUrl', () => { + let service: PaymentService + const WALLET_ADDRESS_URL = 'https://api.example.com/wallet-address' + + beforeAll(() => { + service = createPaymentService(deps) + }) + + beforeEach(() => { + jest.clearAllMocks() + }) + + test('should obtain wallet address id successfully', async () => { + const id = uuid() + mockApolloClient.query = jest.fn().mockResolvedValue({ + data: { walletAddressByUrl: { id } } + }) + const walletAddressId = + await service.getWalletAddressIdByUrl(WALLET_ADDRESS_URL) + expect(walletAddressId).toBe(id) + }) + + test('should throw when no wallet address was found', async () => { + mockApolloClient.query = jest.fn().mockResolvedValue({ + data: { walletAddressByUrl: undefined } + }) + await expect( + service.getWalletAddressIdByUrl(WALLET_ADDRESS_URL) + ).rejects.toThrow('Wallet address not found') + }) +}) diff --git a/packages/point-of-sale/src/payments/service.ts b/packages/point-of-sale/src/payments/service.ts index 6713a3d602..f64d8f2bca 100644 --- a/packages/point-of-sale/src/payments/service.ts +++ b/packages/point-of-sale/src/payments/service.ts @@ -1,12 +1,13 @@ import { Logger } from 'pino' import { CREATE_INCOMING_PAYMENT } from '../graphql/mutations/createIncomingPayment' import { IAppConfig } from '../config/app' -import { ApolloClient, NormalizedCacheObject } from '@apollo/client' +import { ApolloClient, NormalizedCacheObject, gql } from '@apollo/client' import { AmountInput, - CreateIncomingPaymentInput, - IncomingPayment, - type Mutation + CreateIncomingPayment, + MutationCreateIncomingPaymentArgs, + Query, + QueryWalletAddressByUrlArgs } from '../graphql/generated/graphql' import { FnWithDeps } from '../shared/types' import { v4 } from 'uuid' @@ -34,12 +35,18 @@ export type WalletAddress = OpenPaymentsWalletAddress & { cardService: string } +export type CreatedIncomingPayment = { + id: string + url: string +} + export type PaymentService = { createIncomingPayment: ( walletAddressId: string, incomingAmount: AmountInput - ) => Promise + ) => Promise getWalletAddress: (walletAddressUrl: string) => Promise + getWalletAddressIdByUrl: (walletAddressUrl: string) => Promise } export function createPaymentService( @@ -59,7 +66,9 @@ export function createPaymentService( incomingAmount: AmountInput ) => createIncomingPayment(deps, walletAddressId, incomingAmount), getWalletAddress: (walletAddressUrl: string) => - getWalletAddress(deps, walletAddressUrl) + getWalletAddress(deps, walletAddressUrl), + getWalletAddressIdByUrl: (walletAddressUrl: string) => + getWalletAddressIdByUrl(deps, walletAddressUrl) } } @@ -72,20 +81,22 @@ const createIncomingPayment: FnWithDeps< Date.now() + deps.config.incomingPaymentExpiryMs ).toISOString() const { data } = await client.mutate< - Mutation['createIncomingPayment'], - CreateIncomingPaymentInput + CreateIncomingPayment, + MutationCreateIncomingPaymentArgs >({ mutation: CREATE_INCOMING_PAYMENT, variables: { - walletAddressId, - incomingAmount, - idempotencyKey: v4(), - isCardPayment: true, - expiresAt + input: { + walletAddressId, + incomingAmount, + idempotencyKey: v4(), + isCardPayment: true, + expiresAt + } } }) - const incomingPayment = data?.payment + const incomingPayment = data?.createIncomingPayment.payment if (!incomingPayment) { deps.logger.error( { walletAddressId }, @@ -105,7 +116,7 @@ async function getWalletAddress( ): Promise { const config: AxiosRequestConfig = { headers: { - 'Content-Type': 'application/json' + Accept: 'application/json' } } const { data: walletAddress } = await deps.axios.get< @@ -122,3 +133,26 @@ async function getWalletAddress( } return walletAddress as WalletAddress } + +async function getWalletAddressIdByUrl( + deps: ServiceDependencies, + walletAddressUrl: string +): Promise { + const client = deps.apolloClient + const { data } = await client.query({ + variables: { + url: walletAddressUrl + }, + query: gql` + query getWalletAddressByUrl($url: String!) { + walletAddressByUrl(url: $url) { + id + } + } + ` + }) + if (!data?.walletAddressByUrl) { + throw new Error('Wallet address not found') + } + return data.walletAddressByUrl.id +}