diff --git a/bruno/collections/Rafiki/Rafiki Admin APIs/Approve Incoming Payment.bru b/bruno/collections/Rafiki/Rafiki Admin APIs/Approve Incoming Payment.bru new file mode 100644 index 0000000000..d13c7f02cf --- /dev/null +++ b/bruno/collections/Rafiki/Rafiki Admin APIs/Approve Incoming Payment.bru @@ -0,0 +1,35 @@ +meta { + name: Approve Incoming Payment + type: graphql + seq: 47 +} + +post { + url: {{PeerGraphqlHost}}/graphql + body: graphql + auth: none +} + +body:graphql { + mutation ApproveIncomingPayment($input: ApproveIncomingPaymentInput!) { + approveIncomingPayment(input:$input) { + payment { + id + } + } + } +} + +body:graphql:vars { + { + "input": { + "id": "{{incomingPaymentId}}" + } + } +} + +script:pre-request { + const scripts = require('./scripts'); + + scripts.addApiSignatureHeader(); +} diff --git a/bruno/collections/Rafiki/Rafiki Admin APIs/Cancel Incoming Payment.bru b/bruno/collections/Rafiki/Rafiki Admin APIs/Cancel Incoming Payment.bru new file mode 100644 index 0000000000..775e508187 --- /dev/null +++ b/bruno/collections/Rafiki/Rafiki Admin APIs/Cancel Incoming Payment.bru @@ -0,0 +1,36 @@ +meta { + name: Cancel Incoming Payment + type: graphql + seq: 48 +} + +post { + url: {{PeerGraphqlHost}}/graphql + body: graphql + auth: none +} + +body:graphql { + mutation CancelIncomingPayment($input: CancelIncomingPaymentInput!) { + cancelIncomingPayment(input: $input) { + payment { + id + } + } + } + +} + +body:graphql:vars { + { + "input": { + "id": "{{incomingPaymentId}}" + } + } +} + +script:pre-request { + const scripts = require('./scripts'); + + scripts.addApiSignatureHeader(); +} diff --git a/localenv/mock-account-servicing-entity/generated/graphql.ts b/localenv/mock-account-servicing-entity/generated/graphql.ts index 55a68afa65..1acc448110 100644 --- a/localenv/mock-account-servicing-entity/generated/graphql.ts +++ b/localenv/mock-account-servicing-entity/generated/graphql.ts @@ -77,6 +77,16 @@ export type AmountInput = { value: Scalars['UInt64']['input']; }; +export type ApproveIncomingPaymentInput = { + /** Unique identifier of the incoming payment to be approved. Note: Incoming Payment must be PENDING. */ + id: Scalars['ID']['input']; +}; + +export type ApproveIncomingPaymentResponse = { + __typename?: 'ApproveIncomingPaymentResponse'; + payment?: Maybe; +}; + export type Asset = Model & { __typename?: 'Asset'; /** [ISO 4217 currency code](https://en.wikipedia.org/wiki/ISO_4217), e.g. `USD` */ @@ -135,6 +145,16 @@ export type BasePayment = { walletAddressId: Scalars['ID']['output']; }; +export type CancelIncomingPaymentInput = { + /** Unique identifier of the incoming payment to be cancelled. Note: Incoming Payment must be PENDING. */ + id: Scalars['ID']['input']; +}; + +export type CancelIncomingPaymentResponse = { + __typename?: 'CancelIncomingPaymentResponse'; + payment?: Maybe; +}; + export type CancelOutgoingPaymentInput = { /** Outgoing payment id */ id: Scalars['ID']['input']; @@ -607,6 +627,10 @@ export type Model = { export type Mutation = { __typename?: 'Mutation'; + /** Approves the incoming payment if the incoming payment is in the PENDING state */ + approveIncomingPayment: ApproveIncomingPaymentResponse; + /** Cancel the incoming payment if the incoming payment is in the PENDING state */ + cancelIncomingPayment: CancelIncomingPaymentResponse; /** Cancel Outgoing Payment */ cancelOutgoingPayment: OutgoingPaymentResponse; /** Create an asset */ @@ -678,6 +702,16 @@ export type Mutation = { }; +export type MutationApproveIncomingPaymentArgs = { + input: ApproveIncomingPaymentInput; +}; + + +export type MutationCancelIncomingPaymentArgs = { + input: CancelIncomingPaymentInput; +}; + + export type MutationCancelOutgoingPaymentArgs = { input: CancelOutgoingPaymentInput; }; @@ -1523,12 +1557,16 @@ export type ResolversTypes = { Alg: ResolverTypeWrapper>; Amount: ResolverTypeWrapper>; AmountInput: ResolverTypeWrapper>; + ApproveIncomingPaymentInput: ResolverTypeWrapper>; + ApproveIncomingPaymentResponse: ResolverTypeWrapper>; Asset: ResolverTypeWrapper>; AssetEdge: ResolverTypeWrapper>; AssetMutationResponse: ResolverTypeWrapper>; AssetsConnection: ResolverTypeWrapper>; BasePayment: ResolverTypeWrapper['BasePayment']>; Boolean: ResolverTypeWrapper>; + CancelIncomingPaymentInput: ResolverTypeWrapper>; + CancelIncomingPaymentResponse: ResolverTypeWrapper>; CancelOutgoingPaymentInput: ResolverTypeWrapper>; CreateAssetInput: ResolverTypeWrapper>; CreateAssetLiquidityWithdrawalInput: ResolverTypeWrapper>; @@ -1648,12 +1686,16 @@ export type ResolversParentTypes = { AdditionalPropertyInput: Partial; Amount: Partial; AmountInput: Partial; + ApproveIncomingPaymentInput: Partial; + ApproveIncomingPaymentResponse: Partial; Asset: Partial; AssetEdge: Partial; AssetMutationResponse: Partial; AssetsConnection: Partial; BasePayment: ResolversInterfaceTypes['BasePayment']; Boolean: Partial; + CancelIncomingPaymentInput: Partial; + CancelIncomingPaymentResponse: Partial; CancelOutgoingPaymentInput: Partial; CreateAssetInput: Partial; CreateAssetLiquidityWithdrawalInput: Partial; @@ -1786,6 +1828,11 @@ export type AmountResolvers; }; +export type ApproveIncomingPaymentResponseResolvers = { + payment?: Resolver, ParentType, ContextType>; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type AssetResolvers = { code?: Resolver; createdAt?: Resolver; @@ -1826,6 +1873,11 @@ export type BasePaymentResolvers; }; +export type CancelIncomingPaymentResponseResolvers = { + payment?: Resolver, ParentType, ContextType>; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type CreateOrUpdatePeerByUrlMutationResponseResolvers = { peer?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; @@ -1950,6 +2002,8 @@ export type ModelResolvers = { + approveIncomingPayment?: Resolver>; + cancelIncomingPayment?: Resolver>; cancelOutgoingPayment?: Resolver>; createAsset?: Resolver>; createAssetLiquidityWithdrawal?: Resolver, ParentType, ContextType, RequireFields>; @@ -2253,11 +2307,13 @@ export type Resolvers = { AccountingTransferConnection?: AccountingTransferConnectionResolvers; AdditionalProperty?: AdditionalPropertyResolvers; Amount?: AmountResolvers; + ApproveIncomingPaymentResponse?: ApproveIncomingPaymentResponseResolvers; Asset?: AssetResolvers; AssetEdge?: AssetEdgeResolvers; AssetMutationResponse?: AssetMutationResponseResolvers; AssetsConnection?: AssetsConnectionResolvers; BasePayment?: BasePaymentResolvers; + CancelIncomingPaymentResponse?: CancelIncomingPaymentResponseResolvers; CreateOrUpdatePeerByUrlMutationResponse?: CreateOrUpdatePeerByUrlMutationResponseResolvers; CreatePeerMutationResponse?: CreatePeerMutationResponseResolvers; CreateReceiverResponse?: CreateReceiverResponseResolvers; diff --git a/packages/backend/migrations/20240729210134_incoming_payment_cancel_approved_timestamp.js b/packages/backend/migrations/20240729210134_incoming_payment_cancel_approved_timestamp.js new file mode 100644 index 0000000000..248eaebb46 --- /dev/null +++ b/packages/backend/migrations/20240729210134_incoming_payment_cancel_approved_timestamp.js @@ -0,0 +1,21 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = function (knex) { + return knex.schema.alterTable('incomingPayments', (table) => { + table.timestamp('approvedAt').nullable() + table.timestamp('cancelledAt').nullable() + }) +} + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function (knex) { + return knex.schema.alterTable('incomingPayments', (table) => { + table.dropColumn('approvedAt') + table.dropColumn('cancelledAt') + }) +} diff --git a/packages/backend/migrations/20240820101201_add_deleted_at_grants.js b/packages/backend/migrations/20240820101201_add_deleted_at_grants.js new file mode 100644 index 0000000000..7af33288ea --- /dev/null +++ b/packages/backend/migrations/20240820101201_add_deleted_at_grants.js @@ -0,0 +1,21 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = function (knex) { + return knex.schema.alterTable('grants', (table) => { + table.dropUnique(['authServerId', 'accessType', 'accessActions']) + table.timestamp('deletedAt').nullable() + }) +} + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function (knex) { + return knex.schema.alterTable('grants', (table) => { + table.unique(['authServerId', 'accessType', 'accessActions']) + table.dropColumn('deletedAt') + }) +} diff --git a/packages/backend/src/app.ts b/packages/backend/src/app.ts index 4614e99380..c56544f38f 100644 --- a/packages/backend/src/app.ts +++ b/packages/backend/src/app.ts @@ -98,6 +98,8 @@ import { } from './open_payments/wallet_address/middleware' import { LoggingPlugin } from './graphql/plugin' +import { GrantService } from './open_payments/grant/service' +import { AuthServerService } from './open_payments/authServer/service' export interface AppContextData { logger: Logger container: AppContainer @@ -232,6 +234,8 @@ export interface AppServices { incomingPaymentService: Promise remoteIncomingPaymentService: Promise receiverService: Promise + grantService: Promise + authServerService: Promise streamServer: Promise webhookService: Promise quoteService: Promise diff --git a/packages/backend/src/config/app.ts b/packages/backend/src/config/app.ts index a2a77684c8..29a3c7070a 100644 --- a/packages/backend/src/config/app.ts +++ b/packages/backend/src/config/app.ts @@ -132,6 +132,18 @@ export const Config = { incomingPaymentWorkers: envInt('INCOMING_PAYMENT_WORKERS', 1), incomingPaymentWorkerIdle: envInt('INCOMING_PAYMENT_WORKER_IDLE', 200), // milliseconds + pollIncomingPaymentCreatedWebhook: envBool( + 'POLL_INCOMING_PAYMENT_CREATED_WEBHOOK', + false + ), + incomingPaymentCreatedPollTimeout: envInt( + 'INCOMING_PAYMENT_CREATED_POLL_TIMEOUT_MS', + 10000 + ), // milliseconds + incomingPaymentCreatedPollFrequency: envInt( + 'INCOMING_PAYMENT_CREATED_POLL_FREQUENCY_MS', + 1000 + ), // milliseconds webhookWorkers: envInt('WEBHOOK_WORKERS', 1), webhookWorkerIdle: envInt('WEBHOOK_WORKER_IDLE', 200), // milliseconds diff --git a/packages/backend/src/graphql/errors/index.ts b/packages/backend/src/graphql/errors/index.ts index e19601440a..b9e8530317 100644 --- a/packages/backend/src/graphql/errors/index.ts +++ b/packages/backend/src/graphql/errors/index.ts @@ -5,5 +5,6 @@ export enum GraphQLErrorCode { Inactive = 'INACTIVE', InternalServerError = 'INTERNAL_SERVER_ERROR', NotFound = 'NOT_FOUND', - Conflict = 'CONFLICT' + Conflict = 'CONFLICT', + Timeout = 'TIMEOUT' } diff --git a/packages/backend/src/graphql/generated/graphql.schema.json b/packages/backend/src/graphql/generated/graphql.schema.json index 58c79a704c..afae163e2e 100644 --- a/packages/backend/src/graphql/generated/graphql.schema.json +++ b/packages/backend/src/graphql/generated/graphql.schema.json @@ -449,6 +449,56 @@ "enumValues": null, "possibleTypes": null }, + { + "kind": "INPUT_OBJECT", + "name": "ApproveIncomingPaymentInput", + "description": null, + "fields": null, + "inputFields": [ + { + "name": "id", + "description": "Unique identifier of the incoming payment to be approved. Note: Incoming Payment must be PENDING.", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "ApproveIncomingPaymentResponse", + "description": null, + "fields": [ + { + "name": "payment", + "description": null, + "args": [], + "type": { + "kind": "OBJECT", + "name": "IncomingPayment", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, { "kind": "OBJECT", "name": "Asset", @@ -889,6 +939,56 @@ "enumValues": null, "possibleTypes": null }, + { + "kind": "INPUT_OBJECT", + "name": "CancelIncomingPaymentInput", + "description": null, + "fields": null, + "inputFields": [ + { + "name": "id", + "description": "Unique identifier of the incoming payment to be cancelled. Note: Incoming Payment must be PENDING.", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "CancelIncomingPaymentResponse", + "description": null, + "fields": [ + { + "name": "payment", + "description": null, + "args": [], + "type": { + "kind": "OBJECT", + "name": "IncomingPayment", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, { "kind": "INPUT_OBJECT", "name": "CancelOutgoingPaymentInput", @@ -3882,6 +3982,72 @@ "name": "Mutation", "description": null, "fields": [ + { + "name": "approveIncomingPayment", + "description": "Approves the incoming payment if the incoming payment is in the PENDING state", + "args": [ + { + "name": "input", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "ApproveIncomingPaymentInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "ApproveIncomingPaymentResponse", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "cancelIncomingPayment", + "description": "Cancel the incoming payment if the incoming payment is in the PENDING state", + "args": [ + { + "name": "input", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "CancelIncomingPaymentInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "CancelIncomingPaymentResponse", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "cancelOutgoingPayment", "description": "Cancel Outgoing Payment", diff --git a/packages/backend/src/graphql/generated/graphql.ts b/packages/backend/src/graphql/generated/graphql.ts index 55a68afa65..1acc448110 100644 --- a/packages/backend/src/graphql/generated/graphql.ts +++ b/packages/backend/src/graphql/generated/graphql.ts @@ -77,6 +77,16 @@ export type AmountInput = { value: Scalars['UInt64']['input']; }; +export type ApproveIncomingPaymentInput = { + /** Unique identifier of the incoming payment to be approved. Note: Incoming Payment must be PENDING. */ + id: Scalars['ID']['input']; +}; + +export type ApproveIncomingPaymentResponse = { + __typename?: 'ApproveIncomingPaymentResponse'; + payment?: Maybe; +}; + export type Asset = Model & { __typename?: 'Asset'; /** [ISO 4217 currency code](https://en.wikipedia.org/wiki/ISO_4217), e.g. `USD` */ @@ -135,6 +145,16 @@ export type BasePayment = { walletAddressId: Scalars['ID']['output']; }; +export type CancelIncomingPaymentInput = { + /** Unique identifier of the incoming payment to be cancelled. Note: Incoming Payment must be PENDING. */ + id: Scalars['ID']['input']; +}; + +export type CancelIncomingPaymentResponse = { + __typename?: 'CancelIncomingPaymentResponse'; + payment?: Maybe; +}; + export type CancelOutgoingPaymentInput = { /** Outgoing payment id */ id: Scalars['ID']['input']; @@ -607,6 +627,10 @@ export type Model = { export type Mutation = { __typename?: 'Mutation'; + /** Approves the incoming payment if the incoming payment is in the PENDING state */ + approveIncomingPayment: ApproveIncomingPaymentResponse; + /** Cancel the incoming payment if the incoming payment is in the PENDING state */ + cancelIncomingPayment: CancelIncomingPaymentResponse; /** Cancel Outgoing Payment */ cancelOutgoingPayment: OutgoingPaymentResponse; /** Create an asset */ @@ -678,6 +702,16 @@ export type Mutation = { }; +export type MutationApproveIncomingPaymentArgs = { + input: ApproveIncomingPaymentInput; +}; + + +export type MutationCancelIncomingPaymentArgs = { + input: CancelIncomingPaymentInput; +}; + + export type MutationCancelOutgoingPaymentArgs = { input: CancelOutgoingPaymentInput; }; @@ -1523,12 +1557,16 @@ export type ResolversTypes = { Alg: ResolverTypeWrapper>; Amount: ResolverTypeWrapper>; AmountInput: ResolverTypeWrapper>; + ApproveIncomingPaymentInput: ResolverTypeWrapper>; + ApproveIncomingPaymentResponse: ResolverTypeWrapper>; Asset: ResolverTypeWrapper>; AssetEdge: ResolverTypeWrapper>; AssetMutationResponse: ResolverTypeWrapper>; AssetsConnection: ResolverTypeWrapper>; BasePayment: ResolverTypeWrapper['BasePayment']>; Boolean: ResolverTypeWrapper>; + CancelIncomingPaymentInput: ResolverTypeWrapper>; + CancelIncomingPaymentResponse: ResolverTypeWrapper>; CancelOutgoingPaymentInput: ResolverTypeWrapper>; CreateAssetInput: ResolverTypeWrapper>; CreateAssetLiquidityWithdrawalInput: ResolverTypeWrapper>; @@ -1648,12 +1686,16 @@ export type ResolversParentTypes = { AdditionalPropertyInput: Partial; Amount: Partial; AmountInput: Partial; + ApproveIncomingPaymentInput: Partial; + ApproveIncomingPaymentResponse: Partial; Asset: Partial; AssetEdge: Partial; AssetMutationResponse: Partial; AssetsConnection: Partial; BasePayment: ResolversInterfaceTypes['BasePayment']; Boolean: Partial; + CancelIncomingPaymentInput: Partial; + CancelIncomingPaymentResponse: Partial; CancelOutgoingPaymentInput: Partial; CreateAssetInput: Partial; CreateAssetLiquidityWithdrawalInput: Partial; @@ -1786,6 +1828,11 @@ export type AmountResolvers; }; +export type ApproveIncomingPaymentResponseResolvers = { + payment?: Resolver, ParentType, ContextType>; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type AssetResolvers = { code?: Resolver; createdAt?: Resolver; @@ -1826,6 +1873,11 @@ export type BasePaymentResolvers; }; +export type CancelIncomingPaymentResponseResolvers = { + payment?: Resolver, ParentType, ContextType>; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type CreateOrUpdatePeerByUrlMutationResponseResolvers = { peer?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; @@ -1950,6 +2002,8 @@ export type ModelResolvers = { + approveIncomingPayment?: Resolver>; + cancelIncomingPayment?: Resolver>; cancelOutgoingPayment?: Resolver>; createAsset?: Resolver>; createAssetLiquidityWithdrawal?: Resolver, ParentType, ContextType, RequireFields>; @@ -2253,11 +2307,13 @@ export type Resolvers = { AccountingTransferConnection?: AccountingTransferConnectionResolvers; AdditionalProperty?: AdditionalPropertyResolvers; Amount?: AmountResolvers; + ApproveIncomingPaymentResponse?: ApproveIncomingPaymentResponseResolvers; Asset?: AssetResolvers; AssetEdge?: AssetEdgeResolvers; AssetMutationResponse?: AssetMutationResponseResolvers; AssetsConnection?: AssetsConnectionResolvers; BasePayment?: BasePaymentResolvers; + CancelIncomingPaymentResponse?: CancelIncomingPaymentResponseResolvers; CreateOrUpdatePeerByUrlMutationResponse?: CreateOrUpdatePeerByUrlMutationResponseResolvers; CreatePeerMutationResponse?: CreatePeerMutationResponseResolvers; CreateReceiverResponse?: CreateReceiverResponseResolvers; diff --git a/packages/backend/src/graphql/resolvers/incoming_payment.ts b/packages/backend/src/graphql/resolvers/incoming_payment.ts index cde669d166..0931c553f2 100644 --- a/packages/backend/src/graphql/resolvers/incoming_payment.ts +++ b/packages/backend/src/graphql/resolvers/incoming_payment.ts @@ -108,6 +108,60 @@ export const createIncomingPayment: MutationResolvers['createInco } } +export const approveIncomingPayment: MutationResolvers['approveIncomingPayment'] = + async ( + parent, + args, + ctx + ): Promise => { + const incomingPaymentService = await ctx.container.use( + 'incomingPaymentService' + ) + + const incomingPaymentOrError = await incomingPaymentService.approve( + args.input.id + ) + + if (isIncomingPaymentError(incomingPaymentOrError)) { + throw new GraphQLError(errorToMessage[incomingPaymentOrError], { + extensions: { + code: errorToCode[incomingPaymentOrError] + } + }) + } + + return { + payment: paymentToGraphql(incomingPaymentOrError) + } + } + +export const cancelIncomingPayment: MutationResolvers['cancelIncomingPayment'] = + async ( + parent, + args, + ctx + ): Promise => { + const incomingPaymentService = await ctx.container.use( + 'incomingPaymentService' + ) + + const incomingPaymentOrError = await incomingPaymentService.cancel( + args.input.id + ) + + if (isIncomingPaymentError(incomingPaymentOrError)) { + throw new GraphQLError(errorToMessage[incomingPaymentOrError], { + extensions: { + code: errorToCode[incomingPaymentOrError] + } + }) + } + + return { + payment: paymentToGraphql(incomingPaymentOrError) + } + } + export function paymentToGraphql( payment: IncomingPayment ): SchemaIncomingPayment { diff --git a/packages/backend/src/graphql/resolvers/index.ts b/packages/backend/src/graphql/resolvers/index.ts index c4f5c6f5ec..1cbfd9285e 100644 --- a/packages/backend/src/graphql/resolvers/index.ts +++ b/packages/backend/src/graphql/resolvers/index.ts @@ -19,7 +19,9 @@ import { import { getWalletAddressIncomingPayments, createIncomingPayment, - getIncomingPayment + getIncomingPayment, + approveIncomingPayment, + cancelIncomingPayment } from './incoming_payment' import { getQuote, createQuote, getWalletAddressQuotes } from './quote' import { @@ -124,6 +126,8 @@ export const resolvers: Resolvers = { createOutgoingPaymentFromIncomingPayment, cancelOutgoingPayment, createIncomingPayment, + approveIncomingPayment, + cancelIncomingPayment, createReceiver, createPeer: createPeer, createOrUpdatePeerByUrl: createOrUpdatePeerByUrl, diff --git a/packages/backend/src/graphql/resolvers/receiver.test.ts b/packages/backend/src/graphql/resolvers/receiver.test.ts index 2ee01cb326..caa2cb32ef 100644 --- a/packages/backend/src/graphql/resolvers/receiver.test.ts +++ b/packages/backend/src/graphql/resolvers/receiver.test.ts @@ -60,7 +60,8 @@ describe('Receiver Resolver', (): void => { incomingAmount: incomingAmount ? serializeAmount(incomingAmount) : undefined - }) + }), + false ) const createSpy = jest @@ -253,7 +254,8 @@ describe('Receiver Resolver', (): void => { id: `${walletAddress.id}/incoming-payments/${uuid()}`, walletAddress: walletAddress.id, incomingAmount: amount ? serializeAmount(amount) : undefined - }) + }), + false ) test('returns receiver', async (): Promise => { diff --git a/packages/backend/src/graphql/schema.graphql b/packages/backend/src/graphql/schema.graphql index ed137ba407..ddf249f8fd 100644 --- a/packages/backend/src/graphql/schema.graphql +++ b/packages/backend/src/graphql/schema.graphql @@ -244,6 +244,16 @@ type Mutation { "Set the fee on an asset" setFee(input: SetFeeInput!): SetFeeResponse! + + "Approves the incoming payment if the incoming payment is in the PENDING state" + approveIncomingPayment( + input: ApproveIncomingPaymentInput! + ): ApproveIncomingPaymentResponse! + + "Cancel the incoming payment if the incoming payment is in the PENDING state" + cancelIncomingPayment( + input: CancelIncomingPaymentInput! + ): CancelIncomingPaymentResponse! } type PageInfo { @@ -267,6 +277,16 @@ type AssetEdge { cursor: String! } +input ApproveIncomingPaymentInput { + "Unique identifier of the incoming payment to be approved. Note: Incoming Payment must be PENDING." + id: ID! +} + +input CancelIncomingPaymentInput { + "Unique identifier of the incoming payment to be cancelled. Note: Incoming Payment must be PENDING." + id: ID! +} + input CreateAssetInput { "[ISO 4217 currency code](https://en.wikipedia.org/wiki/ISO_4217), e.g. `USD`" code: String! @@ -1286,6 +1306,14 @@ type SetFeeResponse { fee: Fee } +type ApproveIncomingPaymentResponse { + payment: IncomingPayment +} + +type CancelIncomingPaymentResponse { + payment: IncomingPayment +} + scalar UInt8 scalar UInt64 scalar JSONObject diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index bd72bba1fa..e438e8ba81 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -256,6 +256,7 @@ export function initIocContainer( container.singleton('grantService', async (deps) => { return await createGrantService({ authServerService: await deps.use('authServerService'), + openPaymentsClient: await deps.use('openPaymentsClient'), logger: await deps.use('logger'), knex: await deps.use('knex') }) diff --git a/packages/backend/src/open_payments/grant/errors.ts b/packages/backend/src/open_payments/grant/errors.ts new file mode 100644 index 0000000000..b12ec922f3 --- /dev/null +++ b/packages/backend/src/open_payments/grant/errors.ts @@ -0,0 +1,8 @@ +export enum GrantError { + GrantRequiresInteraction = 'GrantRequiresInteraction', + InvalidGrantRequest = 'InvalidGrantRequest' +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types +export const isGrantError = (o: any): o is GrantError => + Object.values(GrantError).includes(o) diff --git a/packages/backend/src/open_payments/grant/model.ts b/packages/backend/src/open_payments/grant/model.ts index 6ccffd0949..ed2c77daa7 100644 --- a/packages/backend/src/open_payments/grant/model.ts +++ b/packages/backend/src/open_payments/grant/model.ts @@ -33,6 +33,7 @@ export class Grant extends BaseModel { public accessType!: AccessType public accessActions!: AccessAction[] public expiresAt?: Date | null + public deletedAt?: Date public get expired(): boolean { return !!this.expiresAt && this.expiresAt <= new Date() diff --git a/packages/backend/src/open_payments/grant/service.test.ts b/packages/backend/src/open_payments/grant/service.test.ts index d3eaa8c55a..3e70c936ac 100644 --- a/packages/backend/src/open_payments/grant/service.test.ts +++ b/packages/backend/src/open_payments/grant/service.test.ts @@ -1,32 +1,50 @@ import { IocContract } from '@adonisjs/fold' import { faker } from '@faker-js/faker' import { Knex } from 'knex' - +import assert from 'assert' import { Grant } from './model' -import { CreateOptions, GrantService } from './service' +import { + CreateOptions, + GrantService, + ServiceDependencies, + getExistingGrant +} from './service' import { AuthServer } from '../authServer/model' import { initIocContainer } from '../..' import { AppServices } from '../../app' import { Config } from '../../config/app' import { createTestApp, TestContainer } from '../../tests/app' import { truncateTables } from '../../tests/tableManager' -import { AccessType, AccessAction } from '@interledger/open-payments' +import { + AccessType, + AccessAction, + AuthenticatedClient, + mockGrant, + mockAccessToken, + mockPendingGrant +} from '@interledger/open-payments' import { v4 as uuid } from 'uuid' +import { GrantError, isGrantError } from './errors' +import { AuthServerService } from '../authServer/service' describe('Grant Service', (): void => { let deps: IocContract let appContainer: TestContainer let grantService: GrantService + let openPaymentsClient: AuthenticatedClient + let authServerService: AuthServerService let knex: Knex beforeAll(async (): Promise => { deps = await initIocContainer(Config) appContainer = await createTestApp(deps) + grantService = await deps.use('grantService') + openPaymentsClient = await deps.use('openPaymentsClient') + authServerService = await deps.use('authServerService') knex = appContainer.knex }) beforeEach(async (): Promise => { - grantService = await deps.use('grantService') jest.useFakeTimers() jest.setSystemTime(Date.now()) }) @@ -201,4 +219,455 @@ describe('Grant Service', (): void => { } ) }) + + describe('getOrCreate', (): void => { + let authServer: AuthServer + + beforeEach(async (): Promise => { + const authServerService = await deps.use('authServerService') + const url = faker.internet.url({ appendSlash: false }) + authServer = await authServerService.getOrCreate(url) + }) + + test('gets existing grant', async () => { + const existingGrant = await Grant.query(knex).insertAndFetch({ + authServerId: authServer.id, + accessToken: uuid(), + managementId: uuid(), + accessType: AccessType.IncomingPayment, + accessActions: [AccessAction.ReadAll] + }) + + const openPaymentsGrantRequestSpy = jest.spyOn( + openPaymentsClient.grant, + 'request' + ) + + const options = { + authServer: authServer.url, + accessType: AccessType.IncomingPayment, + accessActions: [AccessAction.ReadAll] + } + + const grant = await grantService.getOrCreate(options) + + assert(!isGrantError(grant)) + expect(grant.id).toBe(existingGrant.id) + expect(openPaymentsGrantRequestSpy).not.toHaveBeenCalled() + }) + + test('updates expired grant (by rotating existing token)', async () => { + const existingGrant = await Grant.query(knex).insertAndFetch({ + authServerId: authServer.id, + accessToken: uuid(), + managementId: uuid(), + accessType: AccessType.IncomingPayment, + accessActions: [ + AccessAction.ReadAll, + AccessAction.Create, + AccessAction.Complete + ], + expiresAt: new Date(Date.now() - 1000) + }) + + const openPaymentsGrantRequestSpy = jest.spyOn( + openPaymentsClient.grant, + 'request' + ) + + const rotatedAccessToken = mockAccessToken() + const managementId = uuid() + rotatedAccessToken.access_token.manage = `${faker.internet.url()}token/${managementId}` + + const openPaymentsTokenRotationSpy = jest + .spyOn(openPaymentsClient.token, 'rotate') + .mockResolvedValueOnce(rotatedAccessToken) + + const options = { + authServer: authServer.url, + accessType: AccessType.IncomingPayment, + accessActions: [AccessAction.Create, AccessAction.ReadAll] + } + + const grant = await grantService.getOrCreate(options) + + assert(!isGrantError(grant)) + expect(grant).toMatchObject({ + id: existingGrant.id, + authServerId: existingGrant.authServerId, + accessToken: rotatedAccessToken.access_token.value, + expiresAt: new Date( + Date.now() + rotatedAccessToken.access_token.expires_in! * 1000 + ), + managementId + }) + expect(openPaymentsGrantRequestSpy).not.toHaveBeenCalled() + expect(openPaymentsTokenRotationSpy).toHaveBeenCalledWith({ + url: existingGrant.getManagementUrl(authServer.url), + accessToken: existingGrant.accessToken + }) + }) + + test('creates new grant when no prior existing grant', async () => { + const managementId = uuid() + const newOpenPaymentsGrant = mockGrant() + newOpenPaymentsGrant.access_token.manage = `${faker.internet.url()}token/${managementId}` + const openPaymentsGrantRequestSpy = jest + .spyOn(openPaymentsClient.grant, 'request') + .mockResolvedValueOnce({ + ...newOpenPaymentsGrant + }) + + const options = { + authServer: authServer.url, + accessType: AccessType.IncomingPayment, + accessActions: [AccessAction.Create, AccessAction.Read] + } + + const authServerServiceGetOrCreateSoy = jest.spyOn( + authServerService, + 'getOrCreate' + ) + + const grant = await grantService.getOrCreate(options) + + assert(!isGrantError(grant)) + expect(grant).toMatchObject({ + authServerId: authServer.id, + accessType: options.accessType, + accessActions: options.accessActions, + accessToken: newOpenPaymentsGrant.access_token.value, + expiresAt: new Date( + Date.now() + newOpenPaymentsGrant.access_token.expires_in! * 1000 + ), + managementId + }) + expect(openPaymentsGrantRequestSpy).toHaveBeenCalledWith( + { url: options.authServer }, + { + access_token: { + access: [ + { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + type: options.accessType as any, + actions: options.accessActions + } + ] + }, + interact: { + start: ['redirect'] + } + } + ) + expect(authServerServiceGetOrCreateSoy).toHaveBeenCalled() + }) + + test('creates new grant with additional subset actions', async () => { + const newOpenPaymentsGrant = mockGrant() + const openPaymentsGrantRequestSpy = jest + .spyOn(openPaymentsClient.grant, 'request') + .mockResolvedValueOnce({ + ...newOpenPaymentsGrant + }) + + const options = { + authServer: authServer.url, + accessType: AccessType.IncomingPayment, + accessActions: [ + AccessAction.Create, + AccessAction.ReadAll, + AccessAction.ListAll + ] + } + + const authServerServiceGetOrCreateSoy = jest.spyOn( + authServerService, + 'getOrCreate' + ) + + const grant = await grantService.getOrCreate(options) + + assert(!isGrantError(grant)) + expect(grant.accessActions.sort()).toEqual( + [ + AccessAction.Create, + AccessAction.ReadAll, + AccessAction.ListAll, + AccessAction.List, + AccessAction.Read + ].sort() + ) + expect(openPaymentsGrantRequestSpy).toHaveBeenCalledWith( + { url: options.authServer }, + { + access_token: { + access: [ + { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + type: options.accessType as any, + actions: options.accessActions + } + ] + }, + interact: { + start: ['redirect'] + } + } + ) + expect(authServerServiceGetOrCreateSoy).toHaveBeenCalled() + }) + + test('creates new grant and deletes old one after being unable to rotate existing token', async () => { + const existingGrant = await Grant.query(knex).insertAndFetch({ + authServerId: authServer.id, + accessToken: uuid(), + managementId: uuid(), + accessType: AccessType.IncomingPayment, + accessActions: [ + AccessAction.Read, + AccessAction.Create, + AccessAction.Complete + ], + expiresAt: new Date(Date.now() - 1000) + }) + + const managementId = uuid() + const newOpenPaymentsGrant = mockGrant() + newOpenPaymentsGrant.access_token.manage = `${faker.internet.url()}token/${managementId}` + const openPaymentsGrantRequestSpy = jest + .spyOn(openPaymentsClient.grant, 'request') + .mockResolvedValueOnce(newOpenPaymentsGrant) + + const openPaymentsTokenRotationSpy = jest + .spyOn(openPaymentsClient.token, 'rotate') + .mockImplementationOnce(() => { + throw new Error('Could not rotate token') + }) + + const options = { + authServer: authServer.url, + accessType: AccessType.IncomingPayment, + accessActions: [AccessAction.Create, AccessAction.Read] + } + + const grant = await grantService.getOrCreate(options) + + assert(!isGrantError(grant)) + expect(grant.id).not.toBe(existingGrant.id) + expect(grant).toMatchObject({ + accessType: options.accessType, + accessActions: options.accessActions, + authServerId: authServer.id, + accessToken: newOpenPaymentsGrant.access_token.value, + expiresAt: new Date( + Date.now() + newOpenPaymentsGrant.access_token.expires_in! * 1000 + ), + managementId + }) + expect(openPaymentsTokenRotationSpy).toHaveBeenCalled() + expect(openPaymentsGrantRequestSpy).toHaveBeenCalled() + + const originalGrant = await Grant.query(knex).findById(existingGrant.id) + expect(originalGrant?.deletedAt).toBeDefined() + }) + + test('returns error if Open Payments grant request fails', async () => { + const openPaymentsGrantRequestSpy = jest + .spyOn(openPaymentsClient.grant, 'request') + .mockImplementationOnce(() => { + throw new Error('Could not request grant') + }) + + const options = { + authServer: authServer.url, + accessType: AccessType.IncomingPayment, + accessActions: [AccessAction.Create, AccessAction.ReadAll] + } + + const error = await grantService.getOrCreate(options) + + expect(error).toBe(GrantError.InvalidGrantRequest) + expect(openPaymentsGrantRequestSpy).toHaveBeenCalled() + }) + + test('returns error if Open Payments grant request returns a pending grant', async () => { + const openPaymentsGrantRequestSpy = jest + .spyOn(openPaymentsClient.grant, 'request') + .mockResolvedValueOnce(mockPendingGrant()) + + const options = { + authServer: authServer.url, + accessType: AccessType.IncomingPayment, + accessActions: [AccessAction.Create, AccessAction.ReadAll] + } + + const error = await grantService.getOrCreate(options) + + expect(error).toBe(GrantError.GrantRequiresInteraction) + expect(openPaymentsGrantRequestSpy).toHaveBeenCalled() + }) + }) + + describe('getExistingGrant', (): void => { + let authServer: AuthServer + + beforeEach(async (): Promise => { + const authServerService = await deps.use('authServerService') + const url = faker.internet.url({ appendSlash: false }) + authServer = await authServerService.getOrCreate(url) + }) + + test('gets existing grant (identical match)', async () => { + const existingGrant = await Grant.query(knex).insertAndFetch({ + authServerId: authServer.id, + accessToken: uuid(), + managementId: uuid(), + accessType: AccessType.IncomingPayment, + accessActions: [AccessAction.ReadAll] + }) + + const options = { + authServer: authServer.url, + accessType: AccessType.IncomingPayment, + accessActions: [AccessAction.ReadAll] + } + + await expect( + getExistingGrant({ knex } as ServiceDependencies, options) + ).resolves.toEqual({ ...existingGrant, authServer }) + }) + + test('gets existing grant (requested actions are a subset of saved actions)', async () => { + const existingGrant = await Grant.query(knex).insertAndFetch({ + authServerId: authServer.id, + accessToken: uuid(), + managementId: uuid(), + accessType: AccessType.IncomingPayment, + accessActions: [ + AccessAction.Complete, + AccessAction.Create, + AccessAction.ReadAll + ] + }) + + const options = { + authServer: authServer.url, + accessType: AccessType.IncomingPayment, + accessActions: [AccessAction.ReadAll, AccessAction.Create] + } + + await expect( + getExistingGrant({ knex } as ServiceDependencies, options) + ).resolves.toEqual({ ...existingGrant, authServer }) + }) + + test('ignores deleted grants', async () => { + await Grant.query(knex).insertAndFetch({ + authServerId: authServer.id, + accessToken: uuid(), + managementId: uuid(), + accessType: AccessType.IncomingPayment, + accessActions: [AccessAction.ReadAll], + deletedAt: new Date() + }) + + const options = { + authServer: authServer.url, + accessType: AccessType.IncomingPayment, + accessActions: [AccessAction.ReadAll] + } + + await expect( + getExistingGrant({ knex } as ServiceDependencies, options) + ).resolves.toBeUndefined() + }) + + test('ignores different accessType', async () => { + await Grant.query(knex).insertAndFetch({ + authServerId: authServer.id, + accessToken: uuid(), + managementId: uuid(), + accessType: AccessType.IncomingPayment, + accessActions: [AccessAction.ReadAll] + }) + + const options = { + authServer: authServer.url, + accessType: AccessType.OutgoingPayment, + accessActions: [AccessAction.ReadAll] + } + + await expect( + getExistingGrant({ knex } as ServiceDependencies, options) + ).resolves.toBeUndefined() + }) + + test('ignores different auth server url', async () => { + await Grant.query(knex).insertAndFetch({ + authServerId: authServer.id, + accessToken: uuid(), + managementId: uuid(), + accessType: AccessType.IncomingPayment, + accessActions: [AccessAction.ReadAll] + }) + + const options = { + authServer: uuid(), + accessType: AccessType.IncomingPayment, + accessActions: [AccessAction.ReadAll] + } + + await expect( + getExistingGrant({ knex } as ServiceDependencies, options) + ).resolves.toBeUndefined() + }) + + test('ignores insufficient accessActions', async () => { + await Grant.query(knex).insertAndFetch({ + authServerId: authServer.id, + accessToken: uuid(), + managementId: uuid(), + accessType: AccessType.IncomingPayment, + accessActions: [AccessAction.ReadAll] + }) + + const options = { + authServer: authServer.id, + accessType: AccessType.IncomingPayment, + accessActions: [AccessAction.ReadAll, AccessAction.Create] + } + + await expect( + getExistingGrant({ knex } as ServiceDependencies, options) + ).resolves.toBeUndefined() + }) + }) + + describe('delete', (): void => { + let authServer: AuthServer + + beforeEach(async (): Promise => { + const authServerService = await deps.use('authServerService') + const url = faker.internet.url({ appendSlash: false }) + authServer = await authServerService.getOrCreate(url) + }) + + test('deletes grant', async () => { + const existingGrant = await Grant.query(knex).insertAndFetch({ + authServerId: authServer.id, + accessToken: uuid(), + managementId: uuid(), + accessType: AccessType.IncomingPayment, + accessActions: [AccessAction.ReadAll] + }) + + const now = new Date() + jest.setSystemTime(now) + + const grant = await grantService.delete(existingGrant.id) + + expect(grant.id).toBe(existingGrant.id) + expect(grant.deletedAt).toEqual(now) + }) + }) }) diff --git a/packages/backend/src/open_payments/grant/service.ts b/packages/backend/src/open_payments/grant/service.ts index ea8b68f106..b6ef161e80 100644 --- a/packages/backend/src/open_payments/grant/service.ts +++ b/packages/backend/src/open_payments/grant/service.ts @@ -1,16 +1,26 @@ import { Grant } from './model' import { AuthServerService } from '../authServer/service' import { BaseService } from '../../shared/baseService' -import { AccessAction, AccessType } from '@interledger/open-payments' +import { + AccessAction, + AccessToken, + AccessType, + AuthenticatedClient, + isPendingGrant +} from '@interledger/open-payments' +import { GrantError } from './errors' export interface GrantService { create(options: CreateOptions): Promise get(options: GrantOptions): Promise update(grant: Grant, options: UpdateOptions): Promise + getOrCreate(options: GrantOptions): Promise + delete(id: string): Promise } export interface ServiceDependencies extends BaseService { authServerService: AuthServerService + openPaymentsClient: AuthenticatedClient } export async function createGrantService( @@ -26,7 +36,9 @@ export async function createGrantService( return { get: (options) => getGrant(deps, options), create: (options) => createGrant(deps, options), - update: (grant, options) => updateGrant(deps, grant, options) + update: (grant, options) => updateGrant(deps, grant, options), + getOrCreate: (options) => getOrCreateGrant(deps, options), + delete: (id) => deleteGrant(deps, id) } } @@ -89,6 +101,164 @@ async function updateGrant( .withGraphFetched('authServer') } +async function getOrCreateGrant( + deps: ServiceDependencies, + options: GrantOptions +): Promise { + const existingGrant = await getExistingGrant(deps, options) + + if (!existingGrant) { + return requestNewGrant(deps, options) + } + + if (existingGrant.expired) { + return ( + (await rotateTokenAndUpdateGrant(deps, existingGrant)) ?? + (await requestNewGrant(deps, options)) + ) + } + + return existingGrant +} + +export async function getExistingGrant( + deps: ServiceDependencies, + options: GrantOptions +): Promise { + return await Grant.query(deps.knex) + .findOne({ + accessType: options.accessType + }) + .whereNull('deletedAt') + .andWhere('authServer.url', options.authServer) + // all options.accessActions are a subset of saved accessActions + // e.g. if [ReadAll, Create] is saved, requesting just [Create] would still match + .andWhere('accessActions', '@>', options.accessActions) + .withGraphJoined('authServer') +} + +async function requestNewGrant( + deps: ServiceDependencies, + options: GrantOptions +): Promise { + let openPaymentsGrant + try { + openPaymentsGrant = await deps.openPaymentsClient.grant.request( + { url: options.authServer }, + { + access_token: { + access: [ + { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + type: options.accessType as any, + actions: options.accessActions + } + ] + }, + interact: { + start: ['redirect'] + } + } + ) + } catch (err) { + deps.logger.error( + { err, options }, + 'Received error requesting Open Payments grant' + ) + return GrantError.InvalidGrantRequest + } + + if (isPendingGrant(openPaymentsGrant)) { + deps.logger.error({ ...options }, 'Requested grant requires interaction') + return GrantError.GrantRequiresInteraction + } + + const { id: authServerId } = await deps.authServerService.getOrCreate( + options.authServer + ) + + return Grant.query(deps.knex) + .insertAndFetch({ + accessType: options.accessType, + accessActions: addSubsetActions(options.accessActions), + accessToken: openPaymentsGrant.access_token.value, + managementId: retrieveManagementId(openPaymentsGrant.access_token.manage), + authServerId, + expiresAt: openPaymentsGrant.access_token.expires_in + ? new Date( + Date.now() + openPaymentsGrant.access_token.expires_in * 1000 + ) + : undefined + }) + .withGraphFetched('authServer') +} + +async function rotateTokenAndUpdateGrant( + deps: ServiceDependencies, + grant: Grant +): Promise { + if (!grant.authServer) { + deps.logger.error( + { grantId: grant.id }, + 'Could not get auth server from grant during token rotation' + ) + return undefined + } + + let rotatedToken: AccessToken + + try { + rotatedToken = await deps.openPaymentsClient.token.rotate({ + url: grant.getManagementUrl(grant.authServer.url), + accessToken: grant.accessToken + }) + } catch (err) { + deps.logger.warn( + { err, authServerUrl: grant.authServer.url }, + 'Grant token rotation failed' + ) + await deleteGrant(deps, grant.id) + return undefined + } + + return updateGrant(deps, grant, { + accessToken: rotatedToken.access_token.value, + managementUrl: rotatedToken.access_token.manage, + expiresIn: rotatedToken.access_token.expires_in + }) +} + +async function deleteGrant( + deps: ServiceDependencies, + grantId: string +): Promise { + return Grant.query(deps.knex).updateAndFetchById(grantId, { + deletedAt: new Date() + }) +} + +function addSubsetActions(accessActions: AccessAction[]): AccessAction[] { + const newAccessActions = [...accessActions] + + // Read is a subset action of ReadAll + if ( + accessActions.includes(AccessAction.ReadAll) && + !accessActions.includes(AccessAction.Read) + ) { + newAccessActions.push(AccessAction.Read) + } + + // List is a subset action of ListAll + if ( + accessActions.includes(AccessAction.ListAll) && + !accessActions.includes(AccessAction.List) + ) { + newAccessActions.push(AccessAction.List) + } + + return newAccessActions +} + function retrieveManagementId(managementUrl: string): string { const managementUrlParts = managementUrl.split('/') const managementId = managementUrlParts.pop() || managementUrlParts.pop() // handle trailing slash diff --git a/packages/backend/src/open_payments/payment/incoming/errors.ts b/packages/backend/src/open_payments/payment/incoming/errors.ts index 6d6ea90bbc..96a90f8d13 100644 --- a/packages/backend/src/open_payments/payment/incoming/errors.ts +++ b/packages/backend/src/open_payments/payment/incoming/errors.ts @@ -7,7 +7,9 @@ export enum IncomingPaymentError { InvalidState = 'InvalidState', InvalidExpiry = 'InvalidExpiry', WrongState = 'WrongState', - InactiveWalletAddress = 'InactiveWalletAddress' + InactiveWalletAddress = 'InactiveWalletAddress', + ActionNotPerformed = 'ActionNotPerformed', + AlreadyActioned = 'AlreadyActioned' } // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types @@ -23,7 +25,9 @@ export const errorToHTTPCode: { [IncomingPaymentError.InvalidState]: 400, [IncomingPaymentError.InvalidExpiry]: 400, [IncomingPaymentError.WrongState]: 409, - [IncomingPaymentError.InactiveWalletAddress]: 400 + [IncomingPaymentError.InactiveWalletAddress]: 400, + [IncomingPaymentError.ActionNotPerformed]: 403, + [IncomingPaymentError.AlreadyActioned]: 403 } export const errorToCode: { @@ -35,7 +39,9 @@ export const errorToCode: { [IncomingPaymentError.InvalidState]: GraphQLErrorCode.BadUserInput, [IncomingPaymentError.InvalidExpiry]: GraphQLErrorCode.BadUserInput, [IncomingPaymentError.WrongState]: GraphQLErrorCode.Conflict, - [IncomingPaymentError.InactiveWalletAddress]: GraphQLErrorCode.Inactive + [IncomingPaymentError.InactiveWalletAddress]: GraphQLErrorCode.Inactive, + [IncomingPaymentError.ActionNotPerformed]: GraphQLErrorCode.Timeout, + [IncomingPaymentError.AlreadyActioned]: GraphQLErrorCode.Forbidden } export const errorToMessage: { @@ -47,5 +53,7 @@ export const errorToMessage: { [IncomingPaymentError.InvalidState]: 'invalid state', [IncomingPaymentError.InvalidExpiry]: 'invalid expiresAt', [IncomingPaymentError.WrongState]: 'wrong state', - [IncomingPaymentError.InactiveWalletAddress]: 'inactive wallet address' + [IncomingPaymentError.InactiveWalletAddress]: 'inactive wallet address', + [IncomingPaymentError.ActionNotPerformed]: 'action not performed', + [IncomingPaymentError.AlreadyActioned]: 'action already performed' } diff --git a/packages/backend/src/open_payments/payment/incoming/model.ts b/packages/backend/src/open_payments/payment/incoming/model.ts index 26c854fbae..b22f086af7 100644 --- a/packages/backend/src/open_payments/payment/incoming/model.ts +++ b/packages/backend/src/open_payments/payment/incoming/model.ts @@ -46,6 +46,8 @@ export interface IncomingPaymentResponse { receivedAmount: AmountJSON completed: boolean metadata?: Record + approvedAt?: string + cancelledAt?: string } export type IncomingPaymentData = IncomingPaymentResponse & @@ -100,6 +102,8 @@ export class IncomingPayment public metadata?: Record public processAt!: Date | null + public approvedAt?: Date | null + public cancelledAt?: Date | null public readonly assetId!: string public asset!: Asset @@ -206,6 +210,12 @@ export class IncomingPayment if (this.metadata) { data.metadata = this.metadata } + if (this.approvedAt) { + data.approvedAt = new Date(this.approvedAt).toISOString() + } + if (this.cancelledAt) { + data.cancelledAt = new Date(this.cancelledAt).toISOString() + } return data } diff --git a/packages/backend/src/open_payments/payment/incoming/service.test.ts b/packages/backend/src/open_payments/payment/incoming/service.test.ts index 710056ba40..5b2e98df1e 100644 --- a/packages/backend/src/open_payments/payment/incoming/service.test.ts +++ b/packages/backend/src/open_payments/payment/incoming/service.test.ts @@ -25,6 +25,8 @@ import { IncomingPaymentError, isIncomingPaymentError } from './errors' import { Amount } from '../../amount' import { getTests } from '../../wallet_address/model.test' import { WalletAddress } from '../../wallet_address/model' +import { withConfigOverride } from '../../../tests/helpers' +import { sleep } from '../../../shared/utils' describe('Incoming Payment Service', (): void => { let deps: IocContract @@ -62,6 +64,224 @@ describe('Incoming Payment Service', (): void => { await appContainer.shutdown() }) + describe('Actionable IncomingPayment', (): void => { + function actionableIncomingPaymentConfigOverride() { + return { + pollIncomingPaymentCreatedWebhook: true, + incomingPaymentCreatedPollFrequency: 1, + incomingPaymentCreatedPollTimeout: 100 + } + } + async function patchIncomingPaymentHelper(options: { + approvedAt?: Date + cancelledAt?: Date + }) { + await sleep(50) + const incomingPaymentEvent = await IncomingPaymentEvent.query( + knex + ).findOne({ + type: IncomingPaymentEventType.IncomingPaymentCreated + }) + assert.ok(!!incomingPaymentEvent) + await IncomingPayment.query(knex) + .findById(incomingPaymentEvent.incomingPaymentId as string) + .patch(options) + + return incomingPaymentService.get({ + id: incomingPaymentEvent.incomingPaymentId as string + }) + } + + function createIncomingPaymentHelper(): Promise< + IncomingPayment | IncomingPaymentError + > { + const options = { + client: faker.internet.url({ appendSlash: false }), + incomingAmount: true, + expiresAt: new Date(Date.now() + 30_000) + } + + return incomingPaymentService.create({ + walletAddressId, + ...options, + incomingAmount: undefined + }) + } + + test( + 'should return cancelled incoming payment', + withConfigOverride( + () => config, + actionableIncomingPaymentConfigOverride(), + async (): Promise => { + const options = { cancelledAt: new Date(Date.now() - 1) } + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [incomingPayment, _] = await Promise.all([ + createIncomingPaymentHelper(), + patchIncomingPaymentHelper(options) + ]) + + assert.ok(isIncomingPaymentError(incomingPayment)) + expect(incomingPayment).toBe(IncomingPaymentError.ActionNotPerformed) + } + ) + ) + + test( + 'should return approved incoming payment', + withConfigOverride( + () => config, + actionableIncomingPaymentConfigOverride(), + async (): Promise => { + const options = { approvedAt: new Date(Date.now() - 1) } + const [incomingPayment, approvedIncomingPayment] = await Promise.all([ + createIncomingPaymentHelper(), + patchIncomingPaymentHelper(options) + ]) + + assert.ok(!isIncomingPaymentError(incomingPayment)) + expect(incomingPayment.id).toEqual(approvedIncomingPayment?.id) + expect(incomingPayment.approvedAt).toEqual(options.approvedAt) + expect(!incomingPayment.cancelledAt).toBeTruthy() + } + ) + ) + test( + 'should return ActionNotPerformed Error if no action taken', + withConfigOverride( + () => config, + actionableIncomingPaymentConfigOverride(), + async (): Promise => { + await expect( + IncomingPaymentEvent.query(knex).where({ + type: IncomingPaymentEventType.IncomingPaymentCreated + }) + ).resolves.toHaveLength(0) + + const incomingPayment = await createIncomingPaymentHelper() + + assert.ok(isIncomingPaymentError(incomingPayment)) + expect(incomingPayment).toBe(IncomingPaymentError.ActionNotPerformed) + } + ) + ) + + describe('approveIncomingPayment', (): void => { + it('should return UnknownPayment error if payment does not exist', async (): Promise => { + expect(incomingPaymentService.approve(uuid())).resolves.toBe( + IncomingPaymentError.UnknownPayment + ) + }) + + it('should not approve already cancelled incoming payment', async (): Promise => { + const incomingPayment = await createIncomingPaymentHelper() + assert.ok(!isIncomingPaymentError(incomingPayment)) + + await IncomingPayment.query(knex) + .findOne({ id: incomingPayment.id }) + .patch({ cancelledAt: new Date() }) + + const response = await incomingPaymentService.approve( + incomingPayment.id + ) + expect(response).toBe(IncomingPaymentError.AlreadyActioned) + }) + + it('should not update approvedAt field of already approved incoming payment', async (): Promise => { + const approvedAt = new Date() + const incomingPayment = await createIncomingPaymentHelper() + assert.ok(!isIncomingPaymentError(incomingPayment)) + + await IncomingPayment.query(knex) + .findOne({ id: incomingPayment.id }) + .patch({ approvedAt }) + + const approvedPayment = await incomingPaymentService.approve( + incomingPayment.id + ) + assert.ok(!isIncomingPaymentError(approvedPayment)) + + expect(approvedPayment.approvedAt?.toISOString()).toBe( + approvedAt.toISOString() + ) + }) + + it('should approve incoming payment', async (): Promise => { + const incomingPayment = await createIncomingPaymentHelper() + assert.ok(!isIncomingPaymentError(incomingPayment)) + + await IncomingPayment.query(knex) + .findOne({ id: incomingPayment.id }) + .patch({ state: IncomingPaymentState.Pending }) + + const approvedIncomingPayment = await incomingPaymentService.approve( + incomingPayment.id + ) + assert.ok(!isIncomingPaymentError(approvedIncomingPayment)) + expect(approvedIncomingPayment.id).toBe(incomingPayment.id) + expect(approvedIncomingPayment.approvedAt).toBeDefined() + expect(!approvedIncomingPayment.cancelledAt).toBeTruthy() + expect(approvedIncomingPayment.cancelledAt).toBeFalsy() + }) + }) + + describe('cancelIncomingPayment', (): void => { + it('should return UnknownPayment error if payment does not exist', async (): Promise => { + expect(incomingPaymentService.cancel(uuid())).resolves.toBe( + IncomingPaymentError.UnknownPayment + ) + }) + + it('should not cancel already approved incoming payment', async (): Promise => { + const incomingPayment = await createIncomingPaymentHelper() + assert.ok(!isIncomingPaymentError(incomingPayment)) + + await IncomingPayment.query(knex) + .findOne({ id: incomingPayment.id }) + .patch({ approvedAt: new Date() }) + + const response = await incomingPaymentService.cancel(incomingPayment.id) + expect(response).toBe(IncomingPaymentError.AlreadyActioned) + }) + + it('should not update cancelledAt field of already cancelled incoming payment', async (): Promise => { + const cancelledAt = new Date() + const incomingPayment = await createIncomingPaymentHelper() + assert.ok(!isIncomingPaymentError(incomingPayment)) + + await IncomingPayment.query(knex) + .findOne({ id: incomingPayment.id }) + .patch({ cancelledAt }) + + const cancelledPayment = await incomingPaymentService.cancel( + incomingPayment.id + ) + assert.ok(!isIncomingPaymentError(cancelledPayment)) + + expect(cancelledPayment.cancelledAt?.toISOString()).toBe( + cancelledAt.toISOString() + ) + }) + + it('should cancel incoming payment', async (): Promise => { + const incomingPayment = await createIncomingPaymentHelper() + assert.ok(!isIncomingPaymentError(incomingPayment)) + + await IncomingPayment.query(knex) + .findOne({ id: incomingPayment.id }) + .patch({ state: IncomingPaymentState.Pending }) + + const canceledIncomingPayment = await incomingPaymentService.cancel( + incomingPayment.id + ) + assert.ok(!isIncomingPaymentError(canceledIncomingPayment)) + expect(canceledIncomingPayment.id).toBe(incomingPayment.id) + expect(canceledIncomingPayment.cancelledAt).toBeDefined() + expect(!canceledIncomingPayment.approvedAt).toBeTruthy() + }) + }) + }) + describe('Create IncomingPayment', (): void => { let amount: Amount diff --git a/packages/backend/src/open_payments/payment/incoming/service.ts b/packages/backend/src/open_payments/payment/incoming/service.ts index afad128bbe..6d7bd48e0a 100644 --- a/packages/backend/src/open_payments/payment/incoming/service.ts +++ b/packages/backend/src/open_payments/payment/incoming/service.ts @@ -17,6 +17,7 @@ import { import { Amount } from '../../amount' import { IncomingPaymentError } from './errors' import { IAppConfig } from '../../../config/app' +import { poll } from '../../../shared/utils' export const POSITIVE_SLIPPAGE = BigInt(1) // First retry waits 10 seconds @@ -38,6 +39,8 @@ export interface IncomingPaymentService options: CreateIncomingPaymentOptions, trx?: Knex.Transaction ): Promise + approve(id: string): Promise + cancel(id: string): Promise complete(id: string): Promise processNext(): Promise } @@ -62,6 +65,8 @@ export async function createIncomingPaymentService( return { get: (options) => getIncomingPayment(deps, options), create: (options, trx) => createIncomingPayment(deps, options, trx), + approve: (id) => approveIncomingPayment(deps, id), + cancel: (id) => cancelIncomingPayment(deps, id), complete: (id) => completeIncomingPayment(deps, id), getWalletAddressPage: (options) => getWalletAddressPage(deps, options), processNext: () => processNextIncomingPayment(deps) @@ -116,7 +121,8 @@ async function createIncomingPayment( return IncomingPaymentError.InvalidAmount } } - const incomingPayment = await IncomingPayment.query(trx || deps.knex) + + let incomingPayment = await IncomingPayment.query(trx || deps.knex) .insertAndFetch({ walletAddressId: walletAddressId, client, @@ -135,7 +141,55 @@ async function createIncomingPayment( data: incomingPayment.toData(0n) }) - return await addReceivedAmount(deps, incomingPayment, BigInt(0)) + incomingPayment = await addReceivedAmount(deps, incomingPayment, BigInt(0)) + if (!deps.config.pollIncomingPaymentCreatedWebhook) { + return incomingPayment + } + + try { + const response = await poll({ + request: async () => + getApprovedOrCanceledIncomingPayment(deps, { id: incomingPayment.id }), + pollingFrequencyMs: deps.config.incomingPaymentCreatedPollFrequency, + timeoutMs: deps.config.incomingPaymentCreatedPollTimeout + }) + + if (response?.cancelledAt) { + deps.logger.info( + { + cancelledAt: response.cancelledAt.toISOString(), + paymentId: incomingPayment.id + }, + 'Incoming payment was cancelled' + ) + return IncomingPaymentError.ActionNotPerformed + } + + if (response?.approvedAt) return response + deps.logger.error( + { response }, + 'Got response, but incoming payment is not approved or cancelled' + ) + return IncomingPaymentError.ActionNotPerformed + } catch (err) { + const errorMessage = 'Got error / timeout while polling incoming payment' + deps.logger.error( + { errorMessage: err instanceof Error && err.message }, + errorMessage + ) + return IncomingPaymentError.ActionNotPerformed + } +} + +async function getApprovedOrCanceledIncomingPayment( + deps: ServiceDependencies, + options: GetOptions +) { + return IncomingPayment.query(deps.knex) + .get(options) + .withGraphFetched('[asset, walletAddress]') + .whereNotNull('approvedAt') + .orWhereNotNull('cancelledAt') } // Fetch (and lock) an incoming payment for work. @@ -278,6 +332,70 @@ async function getWalletAddressPage( }) } +async function approveIncomingPayment( + deps: ServiceDependencies, + id: string +): Promise { + return deps.knex.transaction(async (trx) => { + const payment = await IncomingPayment.query(trx) + .findById(id) + .forUpdate() + .withGraphFetched('[asset, walletAddress]') + + if (!payment) return IncomingPaymentError.UnknownPayment + if (payment.state !== IncomingPaymentState.Pending) + return IncomingPaymentError.WrongState + + if (payment.cancelledAt) { + deps.logger.info({ + errorMessage: 'Cannot approve already cancelled incoming payment', + paymentId: payment.id + }) + return IncomingPaymentError.AlreadyActioned + } + + if (!payment.approvedAt) { + await payment.$query(trx).patch({ + approvedAt: new Date(Date.now()) + }) + } + + return await addReceivedAmount(deps, payment) + }) +} + +async function cancelIncomingPayment( + deps: ServiceDependencies, + id: string +): Promise { + return deps.knex.transaction(async (trx) => { + const payment = await IncomingPayment.query(trx) + .findById(id) + .forUpdate() + .withGraphFetched('[asset, walletAddress]') + + if (!payment) return IncomingPaymentError.UnknownPayment + if (payment.state !== IncomingPaymentState.Pending) + return IncomingPaymentError.WrongState + + if (payment.approvedAt) { + deps.logger.info({ + errorMessage: 'Cannot cancel already approved incoming payment', + paymentId: payment.id + }) + return IncomingPaymentError.AlreadyActioned + } + + if (!payment.cancelledAt) { + await payment.$query(trx).patch({ + cancelledAt: new Date(Date.now()) + }) + } + + return await addReceivedAmount(deps, payment) + }) +} + async function completeIncomingPayment( deps: ServiceDependencies, id: string diff --git a/packages/backend/src/open_payments/receiver/model.test.ts b/packages/backend/src/open_payments/receiver/model.test.ts index c062e1370c..0255c615f0 100644 --- a/packages/backend/src/open_payments/receiver/model.test.ts +++ b/packages/backend/src/open_payments/receiver/model.test.ts @@ -42,6 +42,7 @@ describe('Receiver Model', (): void => { const incomingPayment = await createIncomingPayment(deps, { walletAddressId: walletAddress.id }) + const isLocal = true const streamCredentials = streamCredentialsService.get(incomingPayment) assert(streamCredentials) @@ -50,7 +51,8 @@ describe('Receiver Model', (): void => { incomingPayment.toOpenPaymentsTypeWithMethods( walletAddress, streamCredentials - ) + ), + isLocal ) expect(receiver).toEqual({ @@ -74,7 +76,8 @@ describe('Receiver Model', (): void => { sharedSecret: base64url(streamCredentials.sharedSecret) } ] - } + }, + isLocal }) }) @@ -96,7 +99,7 @@ describe('Receiver Model', (): void => { streamCredentials ) - expect(() => new Receiver(openPaymentsIncomingPayment)).toThrow( + expect(() => new Receiver(openPaymentsIncomingPayment, false)).toThrow( 'Cannot create receiver from completed incoming payment' ) }) @@ -116,7 +119,7 @@ describe('Receiver Model', (): void => { streamCredentials ) - expect(() => new Receiver(openPaymentsIncomingPayment)).toThrow( + expect(() => new Receiver(openPaymentsIncomingPayment, false)).toThrow( 'Cannot create receiver from expired incoming payment' ) }) @@ -137,7 +140,7 @@ describe('Receiver Model', (): void => { streamCredentials ) - expect(() => new Receiver(openPaymentsIncomingPayment)).toThrow( + expect(() => new Receiver(openPaymentsIncomingPayment, false)).toThrow( 'Invalid ILP address on ilp payment method' ) }) diff --git a/packages/backend/src/open_payments/receiver/model.ts b/packages/backend/src/open_payments/receiver/model.ts index f8b226d066..30156ee847 100644 --- a/packages/backend/src/open_payments/receiver/model.ts +++ b/packages/backend/src/open_payments/receiver/model.ts @@ -29,8 +29,12 @@ export class Receiver { public readonly assetCode: string public readonly assetScale: number public readonly incomingPayment: ReceiverIncomingPayment + public readonly isLocal: boolean - constructor(incomingPayment: OpenPaymentsIncomingPaymentWithPaymentMethod) { + constructor( + incomingPayment: OpenPaymentsIncomingPaymentWithPaymentMethod, + isLocal: boolean + ) { if (incomingPayment.completed) { throw new Error('Cannot create receiver from completed incoming payment') } @@ -76,6 +80,7 @@ export class Receiver { createdAt: new Date(incomingPayment.createdAt), updatedAt: new Date(incomingPayment.updatedAt) } + this.isLocal = isLocal } public get asset(): AssetOptions { diff --git a/packages/backend/src/open_payments/receiver/service.test.ts b/packages/backend/src/open_payments/receiver/service.test.ts index 088d6812d0..7c0c32b8c1 100644 --- a/packages/backend/src/open_payments/receiver/service.test.ts +++ b/packages/backend/src/open_payments/receiver/service.test.ts @@ -114,7 +114,8 @@ describe('Receiver Service', (): void => { sharedSecret: expect.any(String) } ] - } + }, + isLocal: true }) }) @@ -260,7 +261,8 @@ describe('Receiver Service', (): void => { sharedSecret: expect.any(String) } ] - } + }, + isLocal: false }) if (!existingGrant) { expect(clientRequestGrantSpy).toHaveBeenCalledWith( @@ -480,7 +482,8 @@ describe('Receiver Service', (): void => { sharedSecret: expect.any(String) } ] - } + }, + isLocal: false }) expect(remoteIncomingPaymentServiceSpy).toHaveBeenCalledWith({ @@ -573,7 +576,8 @@ describe('Receiver Service', (): void => { sharedSecret: expect.any(String) } ] - } + }, + isLocal: true }) expect(incomingPaymentCreateSpy).toHaveBeenCalledWith({ diff --git a/packages/backend/src/open_payments/receiver/service.ts b/packages/backend/src/open_payments/receiver/service.ts index 8f3ab7c28f..fe2cd8ca4d 100644 --- a/packages/backend/src/open_payments/receiver/service.ts +++ b/packages/backend/src/open_payments/receiver/service.ts @@ -74,8 +74,9 @@ async function createReceiver( const localWalletAddress = await deps.walletAddressService.getByUrl( args.walletAddressUrl ) + const isLocal = !!localWalletAddress - const incomingPaymentOrError = localWalletAddress + const incomingPaymentOrError = isLocal ? await createLocalIncomingPayment(deps, args, localWalletAddress) : await deps.remoteIncomingPaymentService.create(args) @@ -84,7 +85,7 @@ async function createReceiver( } try { - return new Receiver(incomingPaymentOrError) + return new Receiver(incomingPaymentOrError, isLocal) } catch (error) { const errorMessage = 'Could not create receiver from incoming payment' deps.logger.error( @@ -147,10 +148,27 @@ async function getReceiver( deps: ServiceDependencies, url: string ): Promise { - const incomingPayment = await getIncomingPayment(deps, url) - if (incomingPayment) { - return new Receiver(incomingPayment) + try { + const localIncomingPayment = await getLocalIncomingPayment({ + deps, + url + }) + if (localIncomingPayment) { + return new Receiver(localIncomingPayment, true) + } + + const remoteIncomingPayment = await getRemoteIncomingPayment(deps, url) + if (remoteIncomingPayment) { + return new Receiver(remoteIncomingPayment, false) + } + } catch (error) { + deps.logger.error( + { errorMessage: error instanceof Error && error.message }, + 'Could not get incoming payment' + ) } + + return undefined } function parseIncomingPaymentUrl( @@ -167,49 +185,33 @@ function parseIncomingPaymentUrl( } } -async function getIncomingPayment( +async function getRemoteIncomingPayment( deps: ServiceDependencies, url: string ): Promise { - try { - const urlParseResult = parseIncomingPaymentUrl(url) - if (!urlParseResult) { - return undefined - } - - const localIncomingPayment = await getLocalIncomingPayment({ - deps, - id: urlParseResult.id + const grant = await getIncomingPaymentGrant(deps, url) + if (!grant) { + throw new Error('Could not find grant') + } else { + return await deps.openPaymentsClient.incomingPayment.get({ + url, + accessToken: grant.accessToken }) - if (localIncomingPayment) { - return localIncomingPayment - } - - const grant = await getIncomingPaymentGrant(deps, url) - if (!grant) { - throw new Error('Could not find grant') - } else { - return await deps.openPaymentsClient.incomingPayment.get({ - url, - accessToken: grant.accessToken - }) - } - } catch (error) { - deps.logger.error( - { errorMessage: error instanceof Error && error.message }, - 'Could not get incoming payment' - ) - return undefined } } async function getLocalIncomingPayment({ deps, - id + url }: { deps: ServiceDependencies - id: string + url: string }): Promise { + const { id } = parseIncomingPaymentUrl(url) ?? {} + if (!id) { + return undefined + } + const incomingPayment = await deps.incomingPaymentService.get({ id }) diff --git a/packages/backend/src/tests/outgoingPayment.ts b/packages/backend/src/tests/outgoingPayment.ts index f28b38e694..451f33e6bb 100644 --- a/packages/backend/src/tests/outgoingPayment.ts +++ b/packages/backend/src/tests/outgoingPayment.ts @@ -56,7 +56,8 @@ export async function createOutgoingPayment( incomingPayment.toOpenPaymentsTypeWithMethods( walletAddress, streamCredentials - ) + ), + false ) ) } @@ -124,7 +125,8 @@ export async function createOutgoingPaymentWithReceiver( incomingPayment.toOpenPaymentsTypeWithMethods( args.receivingWalletAddress, streamCredentials - ) + ), + false ) const outgoingPayment = await createOutgoingPayment(deps, { diff --git a/packages/backend/src/tests/receiver.ts b/packages/backend/src/tests/receiver.ts index 3cd6b7a8c8..218051e70d 100644 --- a/packages/backend/src/tests/receiver.ts +++ b/packages/backend/src/tests/receiver.ts @@ -22,6 +22,7 @@ export async function createReceiver( incomingPayment.toOpenPaymentsTypeWithMethods( walletAddress, streamCredentialsService.get(incomingPayment)! - ) + ), + false ) } diff --git a/packages/frontend/app/generated/graphql.ts b/packages/frontend/app/generated/graphql.ts index db27546a81..e8e28e3baa 100644 --- a/packages/frontend/app/generated/graphql.ts +++ b/packages/frontend/app/generated/graphql.ts @@ -77,6 +77,16 @@ export type AmountInput = { value: Scalars['UInt64']['input']; }; +export type ApproveIncomingPaymentInput = { + /** Unique identifier of the incoming payment to be approved. Note: Incoming Payment must be PENDING. */ + id: Scalars['ID']['input']; +}; + +export type ApproveIncomingPaymentResponse = { + __typename?: 'ApproveIncomingPaymentResponse'; + payment?: Maybe; +}; + export type Asset = Model & { __typename?: 'Asset'; /** [ISO 4217 currency code](https://en.wikipedia.org/wiki/ISO_4217), e.g. `USD` */ @@ -135,6 +145,16 @@ export type BasePayment = { walletAddressId: Scalars['ID']['output']; }; +export type CancelIncomingPaymentInput = { + /** Unique identifier of the incoming payment to be cancelled. Note: Incoming Payment must be PENDING. */ + id: Scalars['ID']['input']; +}; + +export type CancelIncomingPaymentResponse = { + __typename?: 'CancelIncomingPaymentResponse'; + payment?: Maybe; +}; + export type CancelOutgoingPaymentInput = { /** Outgoing payment id */ id: Scalars['ID']['input']; @@ -607,6 +627,10 @@ export type Model = { export type Mutation = { __typename?: 'Mutation'; + /** Approves the incoming payment if the incoming payment is in the PENDING state */ + approveIncomingPayment: ApproveIncomingPaymentResponse; + /** Cancel the incoming payment if the incoming payment is in the PENDING state */ + cancelIncomingPayment: CancelIncomingPaymentResponse; /** Cancel Outgoing Payment */ cancelOutgoingPayment: OutgoingPaymentResponse; /** Create an asset */ @@ -678,6 +702,16 @@ export type Mutation = { }; +export type MutationApproveIncomingPaymentArgs = { + input: ApproveIncomingPaymentInput; +}; + + +export type MutationCancelIncomingPaymentArgs = { + input: CancelIncomingPaymentInput; +}; + + export type MutationCancelOutgoingPaymentArgs = { input: CancelOutgoingPaymentInput; }; @@ -1523,12 +1557,16 @@ export type ResolversTypes = { Alg: ResolverTypeWrapper>; Amount: ResolverTypeWrapper>; AmountInput: ResolverTypeWrapper>; + ApproveIncomingPaymentInput: ResolverTypeWrapper>; + ApproveIncomingPaymentResponse: ResolverTypeWrapper>; Asset: ResolverTypeWrapper>; AssetEdge: ResolverTypeWrapper>; AssetMutationResponse: ResolverTypeWrapper>; AssetsConnection: ResolverTypeWrapper>; BasePayment: ResolverTypeWrapper['BasePayment']>; Boolean: ResolverTypeWrapper>; + CancelIncomingPaymentInput: ResolverTypeWrapper>; + CancelIncomingPaymentResponse: ResolverTypeWrapper>; CancelOutgoingPaymentInput: ResolverTypeWrapper>; CreateAssetInput: ResolverTypeWrapper>; CreateAssetLiquidityWithdrawalInput: ResolverTypeWrapper>; @@ -1648,12 +1686,16 @@ export type ResolversParentTypes = { AdditionalPropertyInput: Partial; Amount: Partial; AmountInput: Partial; + ApproveIncomingPaymentInput: Partial; + ApproveIncomingPaymentResponse: Partial; Asset: Partial; AssetEdge: Partial; AssetMutationResponse: Partial; AssetsConnection: Partial; BasePayment: ResolversInterfaceTypes['BasePayment']; Boolean: Partial; + CancelIncomingPaymentInput: Partial; + CancelIncomingPaymentResponse: Partial; CancelOutgoingPaymentInput: Partial; CreateAssetInput: Partial; CreateAssetLiquidityWithdrawalInput: Partial; @@ -1786,6 +1828,11 @@ export type AmountResolvers; }; +export type ApproveIncomingPaymentResponseResolvers = { + payment?: Resolver, ParentType, ContextType>; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type AssetResolvers = { code?: Resolver; createdAt?: Resolver; @@ -1826,6 +1873,11 @@ export type BasePaymentResolvers; }; +export type CancelIncomingPaymentResponseResolvers = { + payment?: Resolver, ParentType, ContextType>; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type CreateOrUpdatePeerByUrlMutationResponseResolvers = { peer?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; @@ -1950,6 +2002,8 @@ export type ModelResolvers = { + approveIncomingPayment?: Resolver>; + cancelIncomingPayment?: Resolver>; cancelOutgoingPayment?: Resolver>; createAsset?: Resolver>; createAssetLiquidityWithdrawal?: Resolver, ParentType, ContextType, RequireFields>; @@ -2253,11 +2307,13 @@ export type Resolvers = { AccountingTransferConnection?: AccountingTransferConnectionResolvers; AdditionalProperty?: AdditionalPropertyResolvers; Amount?: AmountResolvers; + ApproveIncomingPaymentResponse?: ApproveIncomingPaymentResponseResolvers; Asset?: AssetResolvers; AssetEdge?: AssetEdgeResolvers; AssetMutationResponse?: AssetMutationResponseResolvers; AssetsConnection?: AssetsConnectionResolvers; BasePayment?: BasePaymentResolvers; + CancelIncomingPaymentResponse?: CancelIncomingPaymentResponseResolvers; CreateOrUpdatePeerByUrlMutationResponse?: CreateOrUpdatePeerByUrlMutationResponseResolvers; CreatePeerMutationResponse?: CreatePeerMutationResponseResolvers; CreateReceiverResponse?: CreateReceiverResponseResolvers; diff --git a/packages/mock-account-service-lib/src/generated/graphql.ts b/packages/mock-account-service-lib/src/generated/graphql.ts index 55a68afa65..1acc448110 100644 --- a/packages/mock-account-service-lib/src/generated/graphql.ts +++ b/packages/mock-account-service-lib/src/generated/graphql.ts @@ -77,6 +77,16 @@ export type AmountInput = { value: Scalars['UInt64']['input']; }; +export type ApproveIncomingPaymentInput = { + /** Unique identifier of the incoming payment to be approved. Note: Incoming Payment must be PENDING. */ + id: Scalars['ID']['input']; +}; + +export type ApproveIncomingPaymentResponse = { + __typename?: 'ApproveIncomingPaymentResponse'; + payment?: Maybe; +}; + export type Asset = Model & { __typename?: 'Asset'; /** [ISO 4217 currency code](https://en.wikipedia.org/wiki/ISO_4217), e.g. `USD` */ @@ -135,6 +145,16 @@ export type BasePayment = { walletAddressId: Scalars['ID']['output']; }; +export type CancelIncomingPaymentInput = { + /** Unique identifier of the incoming payment to be cancelled. Note: Incoming Payment must be PENDING. */ + id: Scalars['ID']['input']; +}; + +export type CancelIncomingPaymentResponse = { + __typename?: 'CancelIncomingPaymentResponse'; + payment?: Maybe; +}; + export type CancelOutgoingPaymentInput = { /** Outgoing payment id */ id: Scalars['ID']['input']; @@ -607,6 +627,10 @@ export type Model = { export type Mutation = { __typename?: 'Mutation'; + /** Approves the incoming payment if the incoming payment is in the PENDING state */ + approveIncomingPayment: ApproveIncomingPaymentResponse; + /** Cancel the incoming payment if the incoming payment is in the PENDING state */ + cancelIncomingPayment: CancelIncomingPaymentResponse; /** Cancel Outgoing Payment */ cancelOutgoingPayment: OutgoingPaymentResponse; /** Create an asset */ @@ -678,6 +702,16 @@ export type Mutation = { }; +export type MutationApproveIncomingPaymentArgs = { + input: ApproveIncomingPaymentInput; +}; + + +export type MutationCancelIncomingPaymentArgs = { + input: CancelIncomingPaymentInput; +}; + + export type MutationCancelOutgoingPaymentArgs = { input: CancelOutgoingPaymentInput; }; @@ -1523,12 +1557,16 @@ export type ResolversTypes = { Alg: ResolverTypeWrapper>; Amount: ResolverTypeWrapper>; AmountInput: ResolverTypeWrapper>; + ApproveIncomingPaymentInput: ResolverTypeWrapper>; + ApproveIncomingPaymentResponse: ResolverTypeWrapper>; Asset: ResolverTypeWrapper>; AssetEdge: ResolverTypeWrapper>; AssetMutationResponse: ResolverTypeWrapper>; AssetsConnection: ResolverTypeWrapper>; BasePayment: ResolverTypeWrapper['BasePayment']>; Boolean: ResolverTypeWrapper>; + CancelIncomingPaymentInput: ResolverTypeWrapper>; + CancelIncomingPaymentResponse: ResolverTypeWrapper>; CancelOutgoingPaymentInput: ResolverTypeWrapper>; CreateAssetInput: ResolverTypeWrapper>; CreateAssetLiquidityWithdrawalInput: ResolverTypeWrapper>; @@ -1648,12 +1686,16 @@ export type ResolversParentTypes = { AdditionalPropertyInput: Partial; Amount: Partial; AmountInput: Partial; + ApproveIncomingPaymentInput: Partial; + ApproveIncomingPaymentResponse: Partial; Asset: Partial; AssetEdge: Partial; AssetMutationResponse: Partial; AssetsConnection: Partial; BasePayment: ResolversInterfaceTypes['BasePayment']; Boolean: Partial; + CancelIncomingPaymentInput: Partial; + CancelIncomingPaymentResponse: Partial; CancelOutgoingPaymentInput: Partial; CreateAssetInput: Partial; CreateAssetLiquidityWithdrawalInput: Partial; @@ -1786,6 +1828,11 @@ export type AmountResolvers; }; +export type ApproveIncomingPaymentResponseResolvers = { + payment?: Resolver, ParentType, ContextType>; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type AssetResolvers = { code?: Resolver; createdAt?: Resolver; @@ -1826,6 +1873,11 @@ export type BasePaymentResolvers; }; +export type CancelIncomingPaymentResponseResolvers = { + payment?: Resolver, ParentType, ContextType>; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type CreateOrUpdatePeerByUrlMutationResponseResolvers = { peer?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; @@ -1950,6 +2002,8 @@ export type ModelResolvers = { + approveIncomingPayment?: Resolver>; + cancelIncomingPayment?: Resolver>; cancelOutgoingPayment?: Resolver>; createAsset?: Resolver>; createAssetLiquidityWithdrawal?: Resolver, ParentType, ContextType, RequireFields>; @@ -2253,11 +2307,13 @@ export type Resolvers = { AccountingTransferConnection?: AccountingTransferConnectionResolvers; AdditionalProperty?: AdditionalPropertyResolvers; Amount?: AmountResolvers; + ApproveIncomingPaymentResponse?: ApproveIncomingPaymentResponseResolvers; Asset?: AssetResolvers; AssetEdge?: AssetEdgeResolvers; AssetMutationResponse?: AssetMutationResponseResolvers; AssetsConnection?: AssetsConnectionResolvers; BasePayment?: BasePaymentResolvers; + CancelIncomingPaymentResponse?: CancelIncomingPaymentResponseResolvers; CreateOrUpdatePeerByUrlMutationResponse?: CreateOrUpdatePeerByUrlMutationResponseResolvers; CreatePeerMutationResponse?: CreatePeerMutationResponseResolvers; CreateReceiverResponse?: CreateReceiverResponseResolvers; diff --git a/test/integration/lib/generated/graphql.ts b/test/integration/lib/generated/graphql.ts index 55a68afa65..1acc448110 100644 --- a/test/integration/lib/generated/graphql.ts +++ b/test/integration/lib/generated/graphql.ts @@ -77,6 +77,16 @@ export type AmountInput = { value: Scalars['UInt64']['input']; }; +export type ApproveIncomingPaymentInput = { + /** Unique identifier of the incoming payment to be approved. Note: Incoming Payment must be PENDING. */ + id: Scalars['ID']['input']; +}; + +export type ApproveIncomingPaymentResponse = { + __typename?: 'ApproveIncomingPaymentResponse'; + payment?: Maybe; +}; + export type Asset = Model & { __typename?: 'Asset'; /** [ISO 4217 currency code](https://en.wikipedia.org/wiki/ISO_4217), e.g. `USD` */ @@ -135,6 +145,16 @@ export type BasePayment = { walletAddressId: Scalars['ID']['output']; }; +export type CancelIncomingPaymentInput = { + /** Unique identifier of the incoming payment to be cancelled. Note: Incoming Payment must be PENDING. */ + id: Scalars['ID']['input']; +}; + +export type CancelIncomingPaymentResponse = { + __typename?: 'CancelIncomingPaymentResponse'; + payment?: Maybe; +}; + export type CancelOutgoingPaymentInput = { /** Outgoing payment id */ id: Scalars['ID']['input']; @@ -607,6 +627,10 @@ export type Model = { export type Mutation = { __typename?: 'Mutation'; + /** Approves the incoming payment if the incoming payment is in the PENDING state */ + approveIncomingPayment: ApproveIncomingPaymentResponse; + /** Cancel the incoming payment if the incoming payment is in the PENDING state */ + cancelIncomingPayment: CancelIncomingPaymentResponse; /** Cancel Outgoing Payment */ cancelOutgoingPayment: OutgoingPaymentResponse; /** Create an asset */ @@ -678,6 +702,16 @@ export type Mutation = { }; +export type MutationApproveIncomingPaymentArgs = { + input: ApproveIncomingPaymentInput; +}; + + +export type MutationCancelIncomingPaymentArgs = { + input: CancelIncomingPaymentInput; +}; + + export type MutationCancelOutgoingPaymentArgs = { input: CancelOutgoingPaymentInput; }; @@ -1523,12 +1557,16 @@ export type ResolversTypes = { Alg: ResolverTypeWrapper>; Amount: ResolverTypeWrapper>; AmountInput: ResolverTypeWrapper>; + ApproveIncomingPaymentInput: ResolverTypeWrapper>; + ApproveIncomingPaymentResponse: ResolverTypeWrapper>; Asset: ResolverTypeWrapper>; AssetEdge: ResolverTypeWrapper>; AssetMutationResponse: ResolverTypeWrapper>; AssetsConnection: ResolverTypeWrapper>; BasePayment: ResolverTypeWrapper['BasePayment']>; Boolean: ResolverTypeWrapper>; + CancelIncomingPaymentInput: ResolverTypeWrapper>; + CancelIncomingPaymentResponse: ResolverTypeWrapper>; CancelOutgoingPaymentInput: ResolverTypeWrapper>; CreateAssetInput: ResolverTypeWrapper>; CreateAssetLiquidityWithdrawalInput: ResolverTypeWrapper>; @@ -1648,12 +1686,16 @@ export type ResolversParentTypes = { AdditionalPropertyInput: Partial; Amount: Partial; AmountInput: Partial; + ApproveIncomingPaymentInput: Partial; + ApproveIncomingPaymentResponse: Partial; Asset: Partial; AssetEdge: Partial; AssetMutationResponse: Partial; AssetsConnection: Partial; BasePayment: ResolversInterfaceTypes['BasePayment']; Boolean: Partial; + CancelIncomingPaymentInput: Partial; + CancelIncomingPaymentResponse: Partial; CancelOutgoingPaymentInput: Partial; CreateAssetInput: Partial; CreateAssetLiquidityWithdrawalInput: Partial; @@ -1786,6 +1828,11 @@ export type AmountResolvers; }; +export type ApproveIncomingPaymentResponseResolvers = { + payment?: Resolver, ParentType, ContextType>; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type AssetResolvers = { code?: Resolver; createdAt?: Resolver; @@ -1826,6 +1873,11 @@ export type BasePaymentResolvers; }; +export type CancelIncomingPaymentResponseResolvers = { + payment?: Resolver, ParentType, ContextType>; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type CreateOrUpdatePeerByUrlMutationResponseResolvers = { peer?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; @@ -1950,6 +2002,8 @@ export type ModelResolvers = { + approveIncomingPayment?: Resolver>; + cancelIncomingPayment?: Resolver>; cancelOutgoingPayment?: Resolver>; createAsset?: Resolver>; createAssetLiquidityWithdrawal?: Resolver, ParentType, ContextType, RequireFields>; @@ -2253,11 +2307,13 @@ export type Resolvers = { AccountingTransferConnection?: AccountingTransferConnectionResolvers; AdditionalProperty?: AdditionalPropertyResolvers; Amount?: AmountResolvers; + ApproveIncomingPaymentResponse?: ApproveIncomingPaymentResponseResolvers; Asset?: AssetResolvers; AssetEdge?: AssetEdgeResolvers; AssetMutationResponse?: AssetMutationResponseResolvers; AssetsConnection?: AssetsConnectionResolvers; BasePayment?: BasePaymentResolvers; + CancelIncomingPaymentResponse?: CancelIncomingPaymentResponseResolvers; CreateOrUpdatePeerByUrlMutationResponse?: CreateOrUpdatePeerByUrlMutationResponseResolvers; CreatePeerMutationResponse?: CreatePeerMutationResponseResolvers; CreateReceiverResponse?: CreateReceiverResponseResolvers;