diff --git a/packages/frontend/app/generated/graphql.ts b/packages/frontend/app/generated/graphql.ts index cb661f9c18..ded2783b8e 100644 --- a/packages/frontend/app/generated/graphql.ts +++ b/packages/frontend/app/generated/graphql.ts @@ -3063,7 +3063,7 @@ export type ListTenantsQueryVariables = Exact<{ }>; -export type ListTenantsQuery = { __typename?: 'Query', tenants: { __typename?: 'TenantsConnection', edges: Array<{ __typename?: 'TenantEdge', node: { __typename?: 'Tenant', id: string, email?: string | null, apiSecret: string, idpConsentUrl?: string | null, idpSecret?: string | null, publicName?: string | null, createdAt: string, deletedAt?: string | null, settings: Array<{ __typename?: 'TenantSetting', key: string, value: string }> } }>, pageInfo: { __typename?: 'PageInfo', startCursor?: string | null, endCursor?: string | null, hasNextPage: boolean, hasPreviousPage: boolean } } }; +export type ListTenantsQuery = { __typename?: 'Query', tenants: { __typename?: 'TenantsConnection', edges: Array<{ __typename?: 'TenantEdge', node: { __typename?: 'Tenant', id: string, email?: string | null, apiSecret: string, idpConsentUrl?: string | null, idpSecret?: string | null, publicName?: string | null, createdAt: string, deletedAt?: string | null, settings: Array<{ __typename?: 'TenantSetting', key: TenantSettingKey, value: string }> } }>, pageInfo: { __typename?: 'PageInfo', startCursor?: string | null, endCursor?: string | null, hasNextPage: boolean, hasPreviousPage: boolean } } }; export type CreateTenantMutationVariables = Exact<{ input: CreateTenantInput; @@ -3091,7 +3091,7 @@ export type GetTenantQueryVariables = Exact<{ }>; -export type GetTenantQuery = { __typename?: 'Query', tenant: { __typename?: 'Tenant', id: string, email?: string | null, apiSecret: string, idpConsentUrl?: string | null, idpSecret?: string | null, publicName?: string | null, createdAt: string, deletedAt?: string | null, settings: Array<{ __typename?: 'TenantSetting', key: string, value: string }> } }; +export type GetTenantQuery = { __typename?: 'Query', tenant: { __typename?: 'Tenant', id: string, email?: string | null, apiSecret: string, idpConsentUrl?: string | null, idpSecret?: string | null, publicName?: string | null, createdAt: string, deletedAt?: string | null, settings: Array<{ __typename?: 'TenantSetting', key: TenantSettingKey, value: string }> } }; export type GetWalletAddressQueryVariables = Exact<{ id: Scalars['String']['input']; diff --git a/packages/point-of-sale/src/app.ts b/packages/point-of-sale/src/app.ts index 6e39d9ecb1..5cce04b5f7 100644 --- a/packages/point-of-sale/src/app.ts +++ b/packages/point-of-sale/src/app.ts @@ -18,6 +18,7 @@ import { } from './merchant/devices/routes' import { PosDeviceService } from './merchant/devices/service' import { MerchantService } from './merchant/service' +import { PaymentContext, PaymentRoutes } from './payments/routes' export interface AppServices { logger: Promise @@ -27,6 +28,7 @@ export interface AppServices { posDeviceRoutes: Promise posDeviceService: Promise merchantService: Promise + paymentRoutes: Promise } export type AppContainer = IocContract @@ -71,6 +73,7 @@ export class App { const merchantRoutes = await this.container.use('merchantRoutes') const posDeviceRoutes = await this.container.use('posDeviceRoutes') + const paymentRoutes = await this.container.use('paymentRoutes') // POST /merchants // Create merchant @@ -93,6 +96,10 @@ export class App { posDeviceRoutes.register ) + // POST /payment + // Initiate a payment + router.post('/payment', paymentRoutes.payment) + koa.use(cors()) koa.use(router.routes()) diff --git a/packages/point-of-sale/src/graphql/generated/graphql.ts b/packages/point-of-sale/src/graphql/generated/graphql.ts index 4b8d3a754b..083be05076 100644 --- a/packages/point-of-sale/src/graphql/generated/graphql.ts +++ b/packages/point-of-sale/src/graphql/generated/graphql.ts @@ -191,6 +191,13 @@ export type CancelOutgoingPaymentInput = { reason?: InputMaybe; }; +export type CardDetailsInput = { + /** Expire date */ + expiry: Scalars['String']['input']; + /** Signature */ + signature: Scalars['String']['input']; +}; + export type CreateAssetInput = { /** Should be an ISO 4217 currency code whenever possible, e.g. `USD`. For more information, refer to [assets](https://rafiki.dev/overview/concepts/accounting/#assets). */ code: Scalars['String']['input']; @@ -278,6 +285,8 @@ export type CreateOutgoingPaymentFromIncomingPaymentInput = { }; export type CreateOutgoingPaymentInput = { + /** Used for the card service to provide the card expiry and signature */ + cardDetails?: InputMaybe; /** Unique key to ensure duplicate or retried requests are processed only once. For more information, refer to [idempotency](https://rafiki.dev/apis/graphql/admin-api-overview/#idempotency). */ idempotencyKey?: InputMaybe; /** Additional metadata associated with the outgoing payment. */ @@ -1006,6 +1015,8 @@ export type MutationWithdrawEventLiquidityArgs = { export type OutgoingPayment = BasePayment & Model & { __typename?: 'OutgoingPayment'; + /** Used for the card service to provide the card expiry and signature */ + cardDetails?: Maybe; /** Information about the wallet address of the Open Payments client that created the outgoing payment. */ client?: Maybe; /** The date and time that the outgoing payment was created. */ @@ -1040,6 +1051,16 @@ export type OutgoingPayment = BasePayment & Model & { walletAddressId: Scalars['ID']['output']; }; +export type OutgoingPaymentCardDetails = Model & { + __typename?: 'OutgoingPaymentCardDetails'; + createdAt: Scalars['String']['output']; + expiry: Scalars['String']['output']; + id: Scalars['ID']['output']; + outgoingPaymentId: Scalars['ID']['output']; + signature: Scalars['String']['output']; + updatedAt: Scalars['String']['output']; +}; + export type OutgoingPaymentConnection = { __typename?: 'OutgoingPaymentConnection'; /** A list of outgoing payment edges, containing outgoing payment nodes and cursors for pagination. */ @@ -1511,18 +1532,27 @@ export type TenantMutationResponse = { export type TenantSetting = { __typename?: 'TenantSetting'; /** Key for this setting. */ - key: Scalars['String']['output']; + key: TenantSettingKey; /** Value of a setting for this key. */ value: Scalars['String']['output']; }; export type TenantSettingInput = { /** Key for this setting. */ - key: Scalars['String']['input']; + key: TenantSettingKey; /** Value of a setting for this key. */ value: Scalars['String']['input']; }; +export enum TenantSettingKey { + ExchangeRatesUrl = 'EXCHANGE_RATES_URL', + IlpAddress = 'ILP_ADDRESS', + WalletAddressUrl = 'WALLET_ADDRESS_URL', + WebhookMaxRetry = 'WEBHOOK_MAX_RETRY', + WebhookTimeout = 'WEBHOOK_TIMEOUT', + WebhookUrl = 'WEBHOOK_URL' +} + export type TenantsConnection = { __typename?: 'TenantsConnection'; /** A list of edges representing tenants and cursors for pagination. */ @@ -1898,7 +1928,7 @@ export type DirectiveResolverFn> = { BasePayment: ( Partial ) | ( Partial ) | ( Partial ); - Model: ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ); + Model: ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ); }; /** Mapping between all available schema types and the resolvers types */ @@ -1921,6 +1951,7 @@ export type ResolversTypes = { CancelIncomingPaymentInput: ResolverTypeWrapper>; CancelIncomingPaymentResponse: ResolverTypeWrapper>; CancelOutgoingPaymentInput: ResolverTypeWrapper>; + CardDetailsInput: ResolverTypeWrapper>; CreateAssetInput: ResolverTypeWrapper>; CreateAssetLiquidityWithdrawalInput: ResolverTypeWrapper>; CreateIncomingPaymentInput: ResolverTypeWrapper>; @@ -1982,6 +2013,7 @@ export type ResolversTypes = { Model: ResolverTypeWrapper['Model']>; Mutation: ResolverTypeWrapper<{}>; OutgoingPayment: ResolverTypeWrapper>; + OutgoingPaymentCardDetails: ResolverTypeWrapper>; OutgoingPaymentConnection: ResolverTypeWrapper>; OutgoingPaymentEdge: ResolverTypeWrapper>; OutgoingPaymentFilter: ResolverTypeWrapper>; @@ -2014,6 +2046,7 @@ export type ResolversTypes = { TenantMutationResponse: ResolverTypeWrapper>; TenantSetting: ResolverTypeWrapper>; TenantSettingInput: ResolverTypeWrapper>; + TenantSettingKey: ResolverTypeWrapper>; TenantsConnection: ResolverTypeWrapper>; TransferState: ResolverTypeWrapper>; TransferType: ResolverTypeWrapper>; @@ -2065,6 +2098,7 @@ export type ResolversParentTypes = { CancelIncomingPaymentInput: Partial; CancelIncomingPaymentResponse: Partial; CancelOutgoingPaymentInput: Partial; + CardDetailsInput: Partial; CreateAssetInput: Partial; CreateAssetLiquidityWithdrawalInput: Partial; CreateIncomingPaymentInput: Partial; @@ -2121,6 +2155,7 @@ export type ResolversParentTypes = { Model: ResolversInterfaceTypes['Model']; Mutation: {}; OutgoingPayment: Partial; + OutgoingPaymentCardDetails: Partial; OutgoingPaymentConnection: Partial; OutgoingPaymentEdge: Partial; OutgoingPaymentFilter: Partial; @@ -2392,7 +2427,7 @@ export type LiquidityMutationResponseResolvers = { - __resolveType: TypeResolveFn<'AccountingTransfer' | 'Asset' | 'Fee' | 'IncomingPayment' | 'OutgoingPayment' | 'Payment' | 'Peer' | 'Tenant' | 'WalletAddress' | 'WalletAddressKey' | 'WebhookEvent', ParentType, ContextType>; + __resolveType: TypeResolveFn<'AccountingTransfer' | 'Asset' | 'Fee' | 'IncomingPayment' | 'OutgoingPayment' | 'OutgoingPaymentCardDetails' | 'Payment' | 'Peer' | 'Tenant' | 'WalletAddress' | 'WalletAddressKey' | 'WebhookEvent', ParentType, ContextType>; createdAt?: Resolver; id?: Resolver; }; @@ -2439,6 +2474,7 @@ export type MutationResolvers = { + cardDetails?: Resolver, ParentType, ContextType>; client?: Resolver, ParentType, ContextType>; createdAt?: Resolver; debitAmount?: Resolver; @@ -2458,6 +2494,16 @@ export type OutgoingPaymentResolvers; }; +export type OutgoingPaymentCardDetailsResolvers = { + createdAt?: Resolver; + expiry?: Resolver; + id?: Resolver; + outgoingPaymentId?: Resolver; + signature?: Resolver; + updatedAt?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type OutgoingPaymentConnectionResolvers = { edges?: Resolver, ParentType, ContextType>; pageInfo?: Resolver; @@ -2633,7 +2679,7 @@ export type TenantMutationResponseResolvers = { - key?: Resolver; + key?: Resolver; value?: Resolver; __isTypeOf?: IsTypeOfResolverFn; }; @@ -2792,6 +2838,7 @@ export type Resolvers = { Model?: ModelResolvers; Mutation?: MutationResolvers; OutgoingPayment?: OutgoingPaymentResolvers; + OutgoingPaymentCardDetails?: OutgoingPaymentCardDetailsResolvers; OutgoingPaymentConnection?: OutgoingPaymentConnectionResolvers; OutgoingPaymentEdge?: OutgoingPaymentEdgeResolvers; OutgoingPaymentResponse?: OutgoingPaymentResponseResolvers; @@ -3016,7 +3063,7 @@ export type ListTenantsQueryVariables = Exact<{ }>; -export type ListTenantsQuery = { __typename?: 'Query', tenants: { __typename?: 'TenantsConnection', edges: Array<{ __typename?: 'TenantEdge', node: { __typename?: 'Tenant', id: string, email?: string | null, apiSecret: string, idpConsentUrl?: string | null, idpSecret?: string | null, publicName?: string | null, createdAt: string, deletedAt?: string | null } }>, pageInfo: { __typename?: 'PageInfo', startCursor?: string | null, endCursor?: string | null, hasNextPage: boolean, hasPreviousPage: boolean } } }; +export type ListTenantsQuery = { __typename?: 'Query', tenants: { __typename?: 'TenantsConnection', edges: Array<{ __typename?: 'TenantEdge', node: { __typename?: 'Tenant', id: string, email?: string | null, apiSecret: string, idpConsentUrl?: string | null, idpSecret?: string | null, publicName?: string | null, createdAt: string, deletedAt?: string | null, settings: Array<{ __typename?: 'TenantSetting', key: TenantSettingKey, value: string }> } }>, pageInfo: { __typename?: 'PageInfo', startCursor?: string | null, endCursor?: string | null, hasNextPage: boolean, hasPreviousPage: boolean } } }; export type CreateTenantMutationVariables = Exact<{ input: CreateTenantInput; @@ -3044,7 +3091,7 @@ export type GetTenantQueryVariables = Exact<{ }>; -export type GetTenantQuery = { __typename?: 'Query', tenant: { __typename?: 'Tenant', id: string, email?: string | null, apiSecret: string, idpConsentUrl?: string | null, idpSecret?: string | null, publicName?: string | null, createdAt: string, deletedAt?: string | null } }; +export type GetTenantQuery = { __typename?: 'Query', tenant: { __typename?: 'Tenant', id: string, email?: string | null, apiSecret: string, idpConsentUrl?: string | null, idpSecret?: string | null, publicName?: string | null, createdAt: string, deletedAt?: string | null, settings: Array<{ __typename?: 'TenantSetting', key: TenantSettingKey, value: string }> } }; export type GetWalletAddressQueryVariables = Exact<{ id: Scalars['String']['input']; diff --git a/packages/point-of-sale/src/index.ts b/packages/point-of-sale/src/index.ts index 36b2a092e4..040e437039 100644 --- a/packages/point-of-sale/src/index.ts +++ b/packages/point-of-sale/src/index.ts @@ -20,7 +20,9 @@ import { createPosDeviceService } from './merchant/devices/service' import { createMerchantRoutes } from './merchant/routes' import { createPaymentService } from './payments/service' import { createPosDeviceRoutes } from './merchant/devices/routes' +import { createPaymentRoutes } from './payments/routes' import axios from 'axios' +import { createCardServiceClient } from './card-service-client/client' export function initIocContainer( config: typeof Config @@ -183,13 +185,17 @@ export function initIocContainer( async (deps: IocContract) => { const logger = await deps.use('logger') const knex = await deps.use('knex') - return await createPosDeviceService({ - logger, - knex - }) + return await createPosDeviceService({ logger, knex }) } ) + container.singleton('cardServiceClient', async (deps) => { + return createCardServiceClient({ + logger: await deps.use('logger'), + axios: await deps.use('axios') + }) + }) + container.singleton('posDeviceRoutes', async (deps) => createPosDeviceRoutes({ logger: await deps.use('logger'), @@ -197,6 +203,14 @@ export function initIocContainer( }) ) + container.singleton('paymentRoutes', async (deps) => { + return createPaymentRoutes({ + logger: await deps.use('logger'), + paymentService: await deps.use('paymentClient'), + cardServiceClient: await deps.use('cardServiceClient') + }) + }) + return container } diff --git a/packages/point-of-sale/src/payments/routes.test.ts b/packages/point-of-sale/src/payments/routes.test.ts new file mode 100644 index 0000000000..afbe232d4c --- /dev/null +++ b/packages/point-of-sale/src/payments/routes.test.ts @@ -0,0 +1,128 @@ +import { IocContract } from '@adonisjs/fold' +import { initIocContainer } from '..' +import { AppServices } from '../app' +import { Config } from '../config/app' +import { TestContainer, createTestApp } from '../tests/app' +import { PaymentContext, PaymentRoutes } from './routes' +import { truncateTables } from '../tests/tableManager' +import { PaymentService } from './service' +import { CardServiceClient, Result } from '../card-service-client/client' +import { createContext } from '../tests/context' +import { CardServiceClientError } from '../card-service-client/errors' + +describe('Payment Routes', () => { + let deps: IocContract + let appContainer: TestContainer + let paymentRoutes: PaymentRoutes + let paymentService: PaymentService + let cardServiceClient: CardServiceClient + + beforeAll(async () => { + deps = initIocContainer(Config) + appContainer = await createTestApp(deps) + paymentService = await deps.use('paymentClient') + cardServiceClient = await deps.use('cardServiceClient') + paymentRoutes = await deps.use('paymentRoutes') + }) + + beforeEach(() => { + jest.clearAllMocks() + }) + + afterEach(async (): Promise => { + await truncateTables(deps) + }) + + afterAll(async (): Promise => { + await appContainer.shutdown() + }) + + describe('payment', () => { + test('returns 200 with result approved', async () => { + const ctx = createPaymentContext() + mockPaymentService() + jest + .spyOn(cardServiceClient, 'sendPayment') + .mockResolvedValueOnce(Result.APPROVED) + + await paymentRoutes.payment(ctx) + expect(ctx.response.body).toBe(Result.APPROVED) + expect(ctx.status).toBe(200) + }) + + test('returns 401 with result card_expired or invalid_signature', async () => { + const ctx = createPaymentContext() + mockPaymentService() + jest + .spyOn(cardServiceClient, 'sendPayment') + .mockResolvedValueOnce(Result.CARD_EXPIRED) + + await paymentRoutes.payment(ctx) + expect(ctx.response.body).toBe(Result.CARD_EXPIRED) + expect(ctx.status).toBe(401) + }) + + test('returns cardService error code when thrown', async () => { + const ctx = createPaymentContext() + mockPaymentService() + jest + .spyOn(cardServiceClient, 'sendPayment') + .mockRejectedValue(new CardServiceClientError('Some error', 404)) + await paymentRoutes.payment(ctx) + expect(ctx.response.body).toBe('Some error') + expect(ctx.status).toBe(404) + }) + + test('returns 400 when there is a paymentService error', async () => { + const ctx = createPaymentContext() + jest + .spyOn(paymentService, 'getWalletAddress') + .mockRejectedValueOnce(new Error('Wallet address error')) + await paymentRoutes.payment(ctx) + expect(ctx.response.body).toBe('Wallet address error') + expect(ctx.status).toBe(400) + }) + + test('returns 500 when an unknown error is thrown', async () => { + const ctx = createPaymentContext() + jest + .spyOn(paymentService, 'getWalletAddress') + .mockRejectedValueOnce('Unknown error') + await paymentRoutes.payment(ctx) + expect(ctx.response.body).toBe('Unknown error') + expect(ctx.status).toBe(500) + }) + + function mockPaymentService() { + jest.spyOn(paymentService, 'getWalletAddress').mockResolvedValueOnce({ + id: 'id', + assetCode: 'USD', + assetScale: 1, + authServer: 'authServer', + resourceServer: 'resourceServer', + cardService: 'cardService' + }) + jest + .spyOn(paymentService, 'createIncomingPayment') + .mockResolvedValueOnce('incoming-payment-url') + } + }) +}) + +function createPaymentContext() { + return createContext({ + headers: { Accept: 'application/json' }, + method: 'POST', + url: `/payment`, + body: { + card: { + walletAddress: 'wallet-address', + trasactionCounter: 0, + expiry: new Date(new Date().getDate() + 1) + }, + signature: 'signature', + value: 100, + merchantWalletAddress: 'merchant-wallet-address' + } + }) +} diff --git a/packages/point-of-sale/src/payments/routes.ts b/packages/point-of-sale/src/payments/routes.ts new file mode 100644 index 0000000000..66c84d87dc --- /dev/null +++ b/packages/point-of-sale/src/payments/routes.ts @@ -0,0 +1,93 @@ +import { AppContext } from '../app' +import { CardServiceClient, Result } from '../card-service-client/client' +import { AmountInput } from '../graphql/generated/graphql' +import { BaseService } from '../shared/baseService' +import { PaymentService } from './service' +import { CardServiceClientError } from '../card-service-client/errors' + +interface ServiceDependencies extends BaseService { + paymentService: PaymentService + cardServiceClient: CardServiceClient +} + +export type PaymentBody = { + card: { + walletAddress: string + trasactionCounter: number + expiry: Date + } + signature: string + value: bigint + merchantWalletAddress: string +} +type PaymentRequest = Exclude & { + body: PaymentBody +} + +export type PaymentContext = Exclude & { + request: PaymentRequest +} + +export interface PaymentRoutes { + payment(ctx: PaymentContext): Promise +} + +export function createPaymentRoutes(deps_: ServiceDependencies): PaymentRoutes { + const log = deps_.logger.child({ + service: 'PaymentRoutes' + }) + + const deps = { + ...deps_, + logger: log + } + + return { + payment: (ctx: PaymentContext) => payment(deps, ctx) + } +} + +async function payment( + deps: ServiceDependencies, + ctx: PaymentContext +): Promise { + const body = ctx.request.body + try { + const walletAddress = await deps.paymentService.getWalletAddress( + body.card.walletAddress + ) + const incomingAmount: AmountInput = { + assetCode: walletAddress.assetCode, + assetScale: walletAddress.assetScale, + value: body.value + } + const incomingPaymentUrl = await deps.paymentService.createIncomingPayment( + walletAddress.id, + incomingAmount + ) + const result = await deps.cardServiceClient.sendPayment({ + merchantWalletAddress: body.merchantWalletAddress, + incomingPaymentUrl, + date: new Date(), + signature: body.signature, + card: body.card + }) + + ctx.body = result + ctx.status = result === Result.APPROVED ? 200 : 401 + } catch (err) { + const { body, status } = handlePaymentError(err) + ctx.body = body + ctx.status = status + } +} + +function handlePaymentError(err: unknown) { + if (err instanceof CardServiceClientError) { + return { body: err.message, status: err.status } + } + if (err instanceof Error) { + return { body: err.message, status: 400 } + } + return { body: err, status: 500 } +}