diff --git a/bruno/collections/Rafiki/Examples/Vailidating Wallet Address Ownership with Open Payments/folder.bru b/bruno/collections/Rafiki/Examples/Vailidating Wallet Address Ownership with Open Payments/folder.bru index 6520ef80ca..af11ab927d 100644 --- a/bruno/collections/Rafiki/Examples/Vailidating Wallet Address Ownership with Open Payments/folder.bru +++ b/bruno/collections/Rafiki/Examples/Vailidating Wallet Address Ownership with Open Payments/folder.bru @@ -1,4 +1,4 @@ meta { - name: Vailidating Wallet Address Ownership with Open Payments + name: Validating Wallet Address Ownership with Open Payments seq: 6 } diff --git a/bruno/collections/Rafiki/Open Payments APIs/Outgoing Payment Grant/Get Outgoing Payment Grant.bru b/bruno/collections/Rafiki/Open Payments APIs/Outgoing Payment Grant/Get Outgoing Payment Grant.bru new file mode 100644 index 0000000000..f4fd415556 --- /dev/null +++ b/bruno/collections/Rafiki/Open Payments APIs/Outgoing Payment Grant/Get Outgoing Payment Grant.bru @@ -0,0 +1,33 @@ +meta { + name: Get Outgoing Payment Grant + type: http + seq: 1 +} + +get { + url: {{senderOpenPaymentsHost}}/outgoing-payment-grant + body: json + auth: none +} + +headers { + Authorization: GNAP {{accessToken}} +} + +script:pre-request { + const scripts = require('./scripts'); + + scripts.addHostHeader(); + + await scripts.addSignatureHeaders(); +} + +tests { + test("Status code is 201", function() { + expect(res.getStatus()).to.equal(200); + }); +} + +docs { + Uses GNAP access token to determine the grant. +} diff --git a/open-payments-specifications b/open-payments-specifications index d0b86f6e5b..c1de33071c 160000 --- a/open-payments-specifications +++ b/open-payments-specifications @@ -1 +1 @@ -Subproject commit d0b86f6e5b391b044e9b6d0a74615a818d4ea787 +Subproject commit c1de33071c874f1c481d65f2220d791073a1cb6d diff --git a/packages/auth/src/access/utils.test.ts b/packages/auth/src/access/utils.test.ts index 1e2977c37c..ce96e0ce9d 100644 --- a/packages/auth/src/access/utils.test.ts +++ b/packages/auth/src/access/utils.test.ts @@ -296,6 +296,27 @@ describe('Access utilities', (): void => { ).toBe(false) }) + test('access comparison does not fail if no request identifier', async (): Promise => { + const grantAccessItemSuperAction = await Access.query(trx).insertAndFetch({ + grantId: grant.id, + type: AccessType.IncomingPayment, + actions: [AccessAction.ReadAll], + identifier + }) + + const requestAccessItem: AccessItem = { + type: 'incoming-payment', + actions: [AccessAction.ReadAll] + } + + expect( + compareRequestAndGrantAccessItems( + requestAccessItem, + toOpenPaymentsAccess(grantAccessItemSuperAction) + ) + ).toBe(true) + }) + test('access comparison fails if type mismatch', async (): Promise => { const grantAccessItemSuperAction = await Access.query(trx).insertAndFetch({ grantId: grant.id, diff --git a/packages/auth/src/access/utils.ts b/packages/auth/src/access/utils.ts index d0d3de5099..eea492e395 100644 --- a/packages/auth/src/access/utils.ts +++ b/packages/auth/src/access/utils.ts @@ -57,6 +57,7 @@ export function compareRequestAndGrantAccessItems( if ( grantAccessIdentifier && + requestAccessIdentifier && requestAccessIdentifier !== grantAccessIdentifier ) { return false diff --git a/packages/backend/migrations/20250409173617_add_outgoing_payment_grant_spent_amounts_table.js b/packages/backend/migrations/20250409173617_add_outgoing_payment_grant_spent_amounts_table.js new file mode 100644 index 0000000000..f1374e3d1e --- /dev/null +++ b/packages/backend/migrations/20250409173617_add_outgoing_payment_grant_spent_amounts_table.js @@ -0,0 +1,41 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = function (knex) { + return knex.schema + .createTable('outgoingPaymentGrantSpentAmounts', function (table) { + table.uuid('id').notNullable().primary() + table.string('grantId').notNullable() + table.foreign('grantId').references('outgoingPaymentGrants.id') + table.uuid('outgoingPaymentId').notNullable() + table.foreign('outgoingPaymentId').references('outgoingPayments.id') + table.integer('receiveAmountScale').notNullable() + table.string('receiveAmountCode').notNullable() + table.bigInteger('paymentReceiveAmountValue').notNullable() + table.bigInteger('intervalReceiveAmountValue').nullable() + table.bigInteger('grantTotalReceiveAmountValue').notNullable() + table.integer('debitAmountScale').notNullable() + table.string('debitAmountCode').notNullable() + table.bigInteger('paymentDebitAmountValue').notNullable() + table.bigInteger('intervalDebitAmountValue').nullable() + table.bigInteger('grantTotalDebitAmountValue').notNullable() + table.string('paymentState').notNullable() + table.timestamp('intervalStart').nullable() + table.timestamp('intervalEnd').nullable() + table.timestamp('createdAt').defaultTo(knex.fn.now()) + }) + .then(() => { + return knex.raw( + 'CREATE INDEX outgoingPaymentGrantSpentAmounts_grantId_createdAt_desc_idx ON "outgoingPaymentGrantSpentAmounts" ("grantId", "createdAt" DESC)' + ) + }) +} + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function (knex) { + return knex.schema.dropTableIfExists('outgoingPaymentGrantSpentAmounts') +} diff --git a/packages/backend/migrations/20250410162417_add_interval_op_grant_table.js b/packages/backend/migrations/20250410162417_add_interval_op_grant_table.js new file mode 100644 index 0000000000..dcd1bcb29b --- /dev/null +++ b/packages/backend/migrations/20250410162417_add_interval_op_grant_table.js @@ -0,0 +1,19 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = function (knex) { + return knex.schema.alterTable('outgoingPaymentGrants', function (table) { + table.string('interval').nullable() + }) +} + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function (knex) { + return knex.schema.alterTable('outgoingPaymentGrants', function (table) { + table.dropColumn('interval') + }) +} diff --git a/packages/backend/src/app.ts b/packages/backend/src/app.ts index d799d91ec4..1f3a6474bc 100644 --- a/packages/backend/src/app.ts +++ b/packages/backend/src/app.ts @@ -30,7 +30,8 @@ import { httpsigMiddleware, Grant, RequestAction, - authenticatedStatusMiddleware + authenticatedStatusMiddleware, + createOutgoingPaymentGrantTokenIntrospectionMiddleware } from './open_payments/auth/middleware' import { RatesService } from './rates/service' import { createSpspMiddleware } from './payment-method/ilp/spsp/middleware' @@ -134,13 +135,16 @@ export type AppRequest = Omit< params: Record } -export interface WalletAddressUrlContext extends AppContext { - walletAddressUrl: string +export interface IntrospectionContext extends AppContext { grant?: Grant client?: string accessAction?: AccessAction } +export interface WalletAddressUrlContext extends IntrospectionContext { + walletAddressUrl: string +} + export interface WalletAddressContext extends WalletAddressUrlContext { walletAddress: WalletAddress } @@ -715,6 +719,16 @@ export class App { outgoingPaymentRoutes.get ) + // GET /outgoing-payment-grant + // Get grant spent amounts (scoped to interval, if any) from grant + // with outgoing payment create access + router.get( + '/:tenantId/outgoing-payment-grant', + // Expects token used for outgoing payment payment creation + createOutgoingPaymentGrantTokenIntrospectionMiddleware(), + outgoingPaymentRoutes.getGrantSpentAmounts + ) + // GET /quotes/{id} // Read quote router.get( diff --git a/packages/backend/src/open_payments/auth/middleware.test.ts b/packages/backend/src/open_payments/auth/middleware.test.ts index 7038afb51a..87c74e3b6b 100644 --- a/packages/backend/src/open_payments/auth/middleware.test.ts +++ b/packages/backend/src/open_payments/auth/middleware.test.ts @@ -10,6 +10,7 @@ import { import { authenticatedStatusMiddleware, + createOutgoingPaymentGrantTokenIntrospectionMiddleware, createTokenIntrospectionMiddleware, httpsigMiddleware } from './middleware' @@ -20,16 +21,18 @@ import { AppServices, HttpSigContext, HttpSigWithAuthenticatedStatusContext, + IntrospectionContext, WalletAddressUrlContext } from '../../app' import { createTestApp, TestContainer } from '../../tests/app' import { createContext } from '../../tests/context' import { createWalletAddress } from '../../tests/walletAddress' import { setup } from '../wallet_address/model.test' -import { parseLimits } from '../payment/outgoing/limits' +import { Limits, parseLimits } from '../payment/outgoing/limits' import { AccessAction, AccessType } from '@interledger/open-payments' import { OpenPaymentsServerRouteError } from '../route-errors' import assert from 'assert' +import { Grant } from '../grant/model' const nock = (global as unknown as { nock: typeof import('nock') }).nock @@ -868,3 +871,499 @@ describe('HTTP Signature Middleware', (): void => { } }) }) + +describe('introspect', () => { + let ctx: IntrospectionContext + let mockTokenIntrospectionClient: { + introspect: jest.Mock + } + let mockConfig: { + authServerGrantUrl: string + } + + beforeEach(() => { + mockTokenIntrospectionClient = { + introspect: jest.fn() + } + mockConfig = { + authServerGrantUrl: 'https://auth.example.com' + } + + ctx = { + request: { + headers: { + authorization: 'GNAP test-token-123' + } + }, + container: { + use: jest.fn().mockImplementation((service: string) => { + if (service === 'tokenIntrospectionClient') { + return Promise.resolve(mockTokenIntrospectionClient) + } + if (service === 'config') { + return Promise.resolve(mockConfig) + } + return Promise.resolve(undefined) + }) + }, + set: jest.fn() + } as unknown as IntrospectionContext + }) + + describe('authorization header validation', () => { + it('should throw 401 if authorization header is missing', async () => { + ctx.request.headers.authorization = undefined + + const middleware = + createOutgoingPaymentGrantTokenIntrospectionMiddleware() + const next = jest.fn() + + await expect(middleware(ctx, next)).rejects.toThrow( + OpenPaymentsServerRouteError + ) + await expect(middleware(ctx, next)).rejects.toMatchObject({ + status: 401, + message: 'Missing or invalid authorization header value' + }) + expect(next).not.toHaveBeenCalled() + }) + + it('should throw 401 if authorization header does not have two parts', async () => { + ctx.request.headers.authorization = 'GNAP' + + const middleware = + createOutgoingPaymentGrantTokenIntrospectionMiddleware() + const next = jest.fn() + + await expect(middleware(ctx, next)).rejects.toThrow( + OpenPaymentsServerRouteError + ) + await expect(middleware(ctx, next)).rejects.toMatchObject({ + status: 401, + message: 'Missing or invalid authorization header value' + }) + expect(next).not.toHaveBeenCalled() + }) + + it('should throw 401 if authorization header does not start with GNAP', async () => { + ctx.request.headers.authorization = 'Bearer test-token-123' + + const middleware = + createOutgoingPaymentGrantTokenIntrospectionMiddleware() + const next = jest.fn() + + await expect(middleware(ctx, next)).rejects.toThrow( + OpenPaymentsServerRouteError + ) + await expect(middleware(ctx, next)).rejects.toMatchObject({ + status: 401, + message: 'Missing or invalid authorization header value' + }) + expect(next).not.toHaveBeenCalled() + }) + + it('should throw 401 if authorization header has too many parts', async () => { + ctx.request.headers.authorization = 'GNAP token extra' + + const middleware = + createOutgoingPaymentGrantTokenIntrospectionMiddleware() + const next = jest.fn() + + await expect(middleware(ctx, next)).rejects.toThrow( + OpenPaymentsServerRouteError + ) + await expect(middleware(ctx, next)).rejects.toMatchObject({ + status: 401, + message: 'Missing or invalid authorization header value' + }) + expect(next).not.toHaveBeenCalled() + }) + }) + + describe('token introspection client errors', () => { + it('should throw 401 if token introspection client throws', async () => { + mockTokenIntrospectionClient.introspect.mockRejectedValue( + new Error('Network error') + ) + + const middleware = + createOutgoingPaymentGrantTokenIntrospectionMiddleware() + const next = jest.fn() + + await expect(middleware(ctx, next)).rejects.toThrow( + OpenPaymentsServerRouteError + ) + await expect(middleware(ctx, next)).rejects.toMatchObject({ + status: 401, + message: 'Invalid Token' + }) + expect(next).not.toHaveBeenCalled() + }) + }) + + describe('token info validation', () => { + it('should throw 403 if token is inactive', async () => { + const inactiveTokenInfo: TokenInfo = { + active: false + } + mockTokenIntrospectionClient.introspect.mockResolvedValue( + inactiveTokenInfo + ) + + const middleware = + createOutgoingPaymentGrantTokenIntrospectionMiddleware() + const next = jest.fn() + + await expect(middleware(ctx, next)).rejects.toThrow( + OpenPaymentsServerRouteError + ) + await expect(middleware(ctx, next)).rejects.toMatchObject({ + status: 403, + message: 'Inactive Token' + }) + expect(next).not.toHaveBeenCalled() + }) + + it('should throw 403 if token has no access items', async () => { + const tokenInfoNoAccess: TokenInfo = { + active: true, + grant: 'grant-123', + client: 'https://client.example.com', + access: [] + } + mockTokenIntrospectionClient.introspect.mockResolvedValue( + tokenInfoNoAccess + ) + + const middleware = + createOutgoingPaymentGrantTokenIntrospectionMiddleware() + const next = jest.fn() + + await expect(middleware(ctx, next)).rejects.toThrow( + OpenPaymentsServerRouteError + ) + await expect(middleware(ctx, next)).rejects.toMatchObject({ + status: 403, + message: 'Insufficient Grant' + }) + expect(next).not.toHaveBeenCalled() + }) + + it('should throw 500 if token has more than one access item', async () => { + const tokenInfoMultipleAccess: TokenInfo = { + active: true, + grant: 'grant-123', + client: 'https://client.example.com', + access: [ + { + type: 'outgoing-payment', + actions: ['create'], + identifier: 'https://wallet.example.com/alice' + }, + { + type: 'incoming-payment', + actions: ['read'], + identifier: 'https://wallet.example.com/alice' + } + ] + } + mockTokenIntrospectionClient.introspect.mockResolvedValue( + tokenInfoMultipleAccess + ) + + const middleware = + createOutgoingPaymentGrantTokenIntrospectionMiddleware() + const next = jest.fn() + + await expect(middleware(ctx, next)).rejects.toThrow( + OpenPaymentsServerRouteError + ) + await expect(middleware(ctx, next)).rejects.toMatchObject({ + status: 500, + message: 'Unexpected number of access items' + }) + expect(next).not.toHaveBeenCalled() + }) + }) + + describe('successful introspection', () => { + it('should call introspect with correct access item parameters', async () => { + const tokenInfo: TokenInfo = { + active: true, + grant: 'grant-123', + client: 'https://client.example.com', + access: [ + { + type: 'outgoing-payment', + actions: ['create'], + identifier: 'https://wallet.example.com/alice' + } + ] + } + mockTokenIntrospectionClient.introspect.mockResolvedValue(tokenInfo) + + const middleware = + createOutgoingPaymentGrantTokenIntrospectionMiddleware() + const next = jest.fn() + + await middleware(ctx, next) + + expect(mockTokenIntrospectionClient.introspect).toHaveBeenCalledWith({ + access_token: 'test-token-123', + access: [ + { + type: AccessType.OutgoingPayment, + actions: [AccessAction.Create], + identifier: undefined + } + ] + }) + expect(next).toHaveBeenCalled() + }) + }) +}) + +describe('createOutgoingPaymentGrantTokenIntrospectionMiddleware', () => { + let ctx: IntrospectionContext + let mockTokenIntrospectionClient: { + introspect: jest.Mock + } + let mockConfig: { + authServerGrantUrl: string + } + + beforeEach(() => { + mockTokenIntrospectionClient = { + introspect: jest.fn() + } + mockConfig = { + authServerGrantUrl: 'https://auth.example.com' + } + + ctx = { + request: { + headers: { + authorization: 'GNAP test-token-123' + } + }, + container: { + use: jest.fn().mockImplementation((service: string) => { + if (service === 'tokenIntrospectionClient') { + return Promise.resolve(mockTokenIntrospectionClient) + } + if (service === 'config') { + return Promise.resolve(mockConfig) + } + return Promise.resolve(undefined) + }) + }, + set: jest.fn(), + grant: undefined as Grant | undefined + } as unknown as IntrospectionContext + }) + + describe('context population', () => { + it('should set grant on context with id and no limits when access has no limits', async () => { + const tokenInfo: TokenInfo = { + active: true, + grant: 'grant-123', + client: 'https://client.example.com', + access: [ + { + type: 'outgoing-payment', + actions: ['create'], + identifier: 'https://wallet.example.com/alice' + } + ] + } + mockTokenIntrospectionClient.introspect.mockResolvedValue(tokenInfo) + + const middleware = + createOutgoingPaymentGrantTokenIntrospectionMiddleware() + const next = jest.fn() + + await middleware(ctx, next) + + expect(ctx.grant).toEqual({ + id: 'grant-123', + limits: undefined + }) + expect(next).toHaveBeenCalled() + }) + + it('should set grant on context with id and parsed limits when access has limits', async () => { + const limits: Limits = { + receiver: 'https://receiver.example.com', + debitAmount: { + value: BigInt(1000), + assetCode: 'USD', + assetScale: 2 + } + } + const tokenInfo: TokenInfo = { + active: true, + grant: 'grant-456', + client: 'https://client.example.com', + access: [ + { + type: 'outgoing-payment', + actions: ['create'], + identifier: 'https://wallet.example.com/alice', + limits + } + ] + } + mockTokenIntrospectionClient.introspect.mockResolvedValue(tokenInfo) + + const middleware = + createOutgoingPaymentGrantTokenIntrospectionMiddleware() + const next = jest.fn() + + await middleware(ctx, next) + + expect(ctx.grant).toEqual({ + id: 'grant-456', + limits + }) + expect(next).toHaveBeenCalled() + }) + }) + + describe('WWW-Authenticate header', () => { + it('should set WWW-Authenticate header when OpenPaymentsServerRouteError is thrown', async () => { + ctx.request.headers.authorization = undefined + + const middleware = + createOutgoingPaymentGrantTokenIntrospectionMiddleware() + const next = jest.fn() + + await expect(middleware(ctx, next)).rejects.toThrow( + OpenPaymentsServerRouteError + ) + + expect(ctx.set).toHaveBeenCalledWith( + 'WWW-Authenticate', + 'GNAP as_uri=https://auth.example.com' + ) + }) + + it('should set WWW-Authenticate header on invalid token error', async () => { + mockTokenIntrospectionClient.introspect.mockRejectedValue( + new Error('Network error') + ) + + const middleware = + createOutgoingPaymentGrantTokenIntrospectionMiddleware() + const next = jest.fn() + + await expect(middleware(ctx, next)).rejects.toThrow( + OpenPaymentsServerRouteError + ) + + expect(ctx.set).toHaveBeenCalledWith( + 'WWW-Authenticate', + 'GNAP as_uri=https://auth.example.com' + ) + }) + + it('should set WWW-Authenticate header on inactive token error', async () => { + const inactiveTokenInfo: TokenInfo = { + active: false + } + mockTokenIntrospectionClient.introspect.mockResolvedValue( + inactiveTokenInfo + ) + + const middleware = + createOutgoingPaymentGrantTokenIntrospectionMiddleware() + const next = jest.fn() + + await expect(middleware(ctx, next)).rejects.toThrow( + OpenPaymentsServerRouteError + ) + + expect(ctx.set).toHaveBeenCalledWith( + 'WWW-Authenticate', + 'GNAP as_uri=https://auth.example.com' + ) + }) + + it('should set WWW-Authenticate header on insufficient grant error', async () => { + const tokenInfoNoAccess: TokenInfo = { + active: true, + grant: 'grant-123', + client: 'https://client.example.com', + access: [] + } + mockTokenIntrospectionClient.introspect.mockResolvedValue( + tokenInfoNoAccess + ) + + const middleware = + createOutgoingPaymentGrantTokenIntrospectionMiddleware() + const next = jest.fn() + + await expect(middleware(ctx, next)).rejects.toThrow( + OpenPaymentsServerRouteError + ) + + expect(ctx.set).toHaveBeenCalledWith( + 'WWW-Authenticate', + 'GNAP as_uri=https://auth.example.com' + ) + }) + }) + + describe('error propagation', () => { + it('should rethrow OpenPaymentsServerRouteError after setting WWW-Authenticate', async () => { + ctx.request.headers.authorization = 'Invalid' + + const middleware = + createOutgoingPaymentGrantTokenIntrospectionMiddleware() + const next = jest.fn() + + const error = await middleware(ctx, next).catch((e) => e) + + expect(error).toBeInstanceOf(OpenPaymentsServerRouteError) + expect(error.status).toBe(401) + }) + }) + + describe('next() invocation', () => { + it('should call next() on successful introspection', async () => { + const tokenInfo: TokenInfo = { + active: true, + grant: 'grant-123', + client: 'https://client.example.com', + access: [ + { + type: 'outgoing-payment', + actions: ['create'], + identifier: 'https://wallet.example.com/alice' + } + ] + } + mockTokenIntrospectionClient.introspect.mockResolvedValue(tokenInfo) + + const middleware = + createOutgoingPaymentGrantTokenIntrospectionMiddleware() + const next = jest.fn() + + await middleware(ctx, next) + + expect(next).toHaveBeenCalledTimes(1) + }) + + it('should not call next() when introspection fails', async () => { + ctx.request.headers.authorization = undefined + + const middleware = + createOutgoingPaymentGrantTokenIntrospectionMiddleware() + const next = jest.fn() + + await expect(middleware(ctx, next)).rejects.toThrow() + + expect(next).not.toHaveBeenCalled() + }) + }) +}) diff --git a/packages/backend/src/open_payments/auth/middleware.ts b/packages/backend/src/open_payments/auth/middleware.ts index 5d4ca69daa..a7f161ae15 100644 --- a/packages/backend/src/open_payments/auth/middleware.ts +++ b/packages/backend/src/open_payments/auth/middleware.ts @@ -7,6 +7,7 @@ import { Limits, parseLimits } from '../payment/outgoing/limits' import { HttpSigContext, HttpSigWithAuthenticatedStatusContext, + IntrospectionContext, WalletAddressUrlContext } from '../../app' import { @@ -84,46 +85,11 @@ export function createTokenIntrospectionMiddleware({ return } - const authSplit = ctx.request.headers.authorization?.split(' ') - if (authSplit?.length !== 2 || authSplit[0] !== 'GNAP') { - throw new OpenPaymentsServerRouteError( - 401, - 'Missing or invalid authorization header value' - ) - } - const token = authSplit[1] - const tokenIntrospectionClient = await ctx.container.use( - 'tokenIntrospectionClient' - ) - let tokenInfo: TokenInfo - try { - tokenInfo = await tokenIntrospectionClient.introspect({ - access_token: token, - access: [ - toOpenPaymentsAccess( - requestType, - requestAction, - ctx.walletAddressUrl - ) - ] - }) - } catch (err) { - throw new OpenPaymentsServerRouteError(401, 'Invalid Token') - } - if (!isActiveTokenInfo(tokenInfo)) { - throw new OpenPaymentsServerRouteError(403, 'Inactive Token') - } - - if (tokenInfo.access.length === 0) { - throw new OpenPaymentsServerRouteError(403, 'Insufficient Grant') - } - - if (tokenInfo.access.length !== 1) { - throw new OpenPaymentsServerRouteError( - 500, - 'Unexpected number of access items' - ) - } + const tokenInfo = await introspect(ctx, { + type: requestType, + action: requestAction, + identifier: ctx.walletAddressUrl + }) const access = tokenInfo.access[0] @@ -166,6 +132,87 @@ export function createTokenIntrospectionMiddleware({ } } +export function createOutgoingPaymentGrantTokenIntrospectionMiddleware() { + return async ( + ctx: IntrospectionContext, + next: () => Promise + ): Promise => { + const config = await ctx.container.use('config') + + try { + const tokenInfo = await introspect(ctx, { + type: AccessType.OutgoingPayment, + action: AccessAction.Create as RequestAction + }) + + const access = tokenInfo.access[0] + + ctx.grant = { + id: tokenInfo.grant, + limits: + 'limits' in access && access.limits + ? parseLimits(access.limits) + : undefined + } + } catch (err) { + if (err instanceof OpenPaymentsServerRouteError) { + ctx.set('WWW-Authenticate', `GNAP as_uri=${config.authServerGrantUrl}`) + } + throw err + } + + await next() + } +} + +async function introspect( + ctx: IntrospectionContext, + accessItem: { type: AccessType; action: RequestAction; identifier?: string } +) { + const authSplit = ctx.request.headers.authorization?.split(' ') + if (authSplit?.length !== 2 || authSplit[0] !== 'GNAP') { + throw new OpenPaymentsServerRouteError( + 401, + 'Missing or invalid authorization header value' + ) + } + const token = authSplit[1] + const tokenIntrospectionClient = await ctx.container.use( + 'tokenIntrospectionClient' + ) + let tokenInfo: TokenInfo + try { + tokenInfo = await tokenIntrospectionClient.introspect({ + access_token: token, + access: [ + toOpenPaymentsAccess( + accessItem.type, + accessItem.action, + accessItem.identifier + ) + ] + }) + } catch (err) { + throw new OpenPaymentsServerRouteError(401, 'Invalid Token') + } + if (!isActiveTokenInfo(tokenInfo)) { + throw new OpenPaymentsServerRouteError(403, 'Inactive Token') + } + + if (tokenInfo.access.length === 0) { + throw new OpenPaymentsServerRouteError(403, 'Insufficient Grant') + } + + if (tokenInfo.access.length !== 1) { + throw new OpenPaymentsServerRouteError( + 500, + 'Unexpected number of access items' + ) + } + + return tokenInfo +} + export const authenticatedStatusMiddleware = async ( ctx: HttpSigWithAuthenticatedStatusContext, next: () => Promise diff --git a/packages/backend/src/open_payments/payment/outgoing/errors.ts b/packages/backend/src/open_payments/payment/outgoing/errors.ts index b368843500..e495d7e953 100644 --- a/packages/backend/src/open_payments/payment/outgoing/errors.ts +++ b/packages/backend/src/open_payments/payment/outgoing/errors.ts @@ -18,6 +18,7 @@ export enum OutgoingPaymentError { InvalidAmount = 'InvalidAmount', NegativeReceiveAmount = 'NegativeReceiveAmount', InvalidReceiver = 'InvalidReceiver', + InvalidInterval = 'InvalidInterval', OnlyOneGrantAmountAllowed = 'OnlyOneGrantAmountAllowed' } @@ -52,6 +53,7 @@ export const errorToHTTPCode: { [OutgoingPaymentError.InvalidAmount]: 400, [OutgoingPaymentError.NegativeReceiveAmount]: 400, [OutgoingPaymentError.InvalidReceiver]: 400, + [OutgoingPaymentError.InvalidInterval]: 500, [OutgoingPaymentError.OnlyOneGrantAmountAllowed]: 500 } @@ -68,6 +70,7 @@ export const errorToCode: { [OutgoingPaymentError.InvalidAmount]: GraphQLErrorCode.BadUserInput, [OutgoingPaymentError.NegativeReceiveAmount]: GraphQLErrorCode.BadUserInput, [OutgoingPaymentError.InvalidReceiver]: GraphQLErrorCode.BadUserInput, + [OutgoingPaymentError.InvalidInterval]: GraphQLErrorCode.InternalServerError, [OutgoingPaymentError.OnlyOneGrantAmountAllowed]: GraphQLErrorCode.BadUserInput } @@ -85,6 +88,7 @@ export const errorToMessage: { [OutgoingPaymentError.InvalidAmount]: 'invalid amount', [OutgoingPaymentError.NegativeReceiveAmount]: 'negative receive amount', [OutgoingPaymentError.InvalidReceiver]: 'invalid receiver', + [OutgoingPaymentError.InvalidInterval]: 'invalid interval', [OutgoingPaymentError.OnlyOneGrantAmountAllowed]: 'only one of receiveAmount or debitAmount allowed' } diff --git a/packages/backend/src/open_payments/payment/outgoing/lifecycle.test.ts b/packages/backend/src/open_payments/payment/outgoing/lifecycle.test.ts new file mode 100644 index 0000000000..56029348f0 --- /dev/null +++ b/packages/backend/src/open_payments/payment/outgoing/lifecycle.test.ts @@ -0,0 +1,2327 @@ +import { IocContract } from '@adonisjs/fold' +import { AppServices } from '../../../app' +import { Config, IAppConfig } from '../../../config/app' +import { createTestApp, TestContainer } from '../../../tests/app' +import { initIocContainer } from '../../..' +import { createAsset } from '../../../tests/asset' +import { createWalletAddress } from '../../../tests/walletAddress' +import { createIncomingPayment } from '../../../tests/incomingPayment' +import { truncateTables } from '../../../tests/tableManager' +import { Grant } from '../../auth/middleware' +import { Knex } from 'knex' +import { Asset } from '../../../asset/model' +import { + OutgoingPayment, + OutgoingPaymentGrant, + OutgoingPaymentGrantSpentAmounts, + OutgoingPaymentState +} from './model' +import { createQuote } from '../../../tests/quote' +import { AccountingService, Transaction } from '../../../accounting/service' +import { OutgoingPaymentService } from './service' +import assert from 'assert' +import { isOutgoingPaymentError } from './errors' +import { PaymentMethodHandlerService } from '../../../payment-method/handler/service' +import { IncomingPayment } from '../incoming/model' +import { PaymentMethodHandlerError } from '../../../payment-method/handler/errors' +import { getInterval } from './limits' +import { TransferError } from '../../../accounting/errors' +import { withConfigOverride } from '../../../tests/helpers' +import { IncomingPaymentInitiationReason } from '../incoming/types' +import { v4 as uuid } from 'uuid' + +describe('Lifecycle', (): void => { + let deps: IocContract + let appContainer: TestContainer + let outgoingPaymentService: OutgoingPaymentService + let accountingService: AccountingService + let paymentMethodHandlerService: PaymentMethodHandlerService + let knex: Knex + let walletAddressId: string + let receiverWalletAddressId: string + let receiver: string + let incomingPayment: IncomingPayment + let asset: Asset + let config: IAppConfig + const tenantId = Config.operatorTenantId + const assetDetails = { + scale: 2, + code: 'USD' + } + + async function createAndFundGrantPayment( + debitAmountValue: bigint, + grant: Grant, + mockPayFactory?: ( + accountingService: AccountingService, + receiverWalletAddressId: string, + payment: OutgoingPayment + ) => jest.Mock + ) { + await OutgoingPaymentGrant.query(knex) + .insert({ + id: grant.id + }) + .onConflict('id') + .ignore() + + const quote = await createQuote(deps, { + tenantId, + walletAddressId, + receiver, + debitAmount: { + value: debitAmountValue, + assetCode: asset.code, + assetScale: asset.scale + }, + method: 'ilp', + exchangeRate: 1 + }) + + const payment = await outgoingPaymentService.create({ + tenantId, + walletAddressId, + quoteId: quote.id, + grant + }) + assert.ok(!isOutgoingPaymentError(payment)) + + const fundResult = await outgoingPaymentService.fund({ + tenantId, + id: payment.id, + amount: payment.debitAmount.value, + transferId: uuid() + }) + assert.ok(fundResult instanceof OutgoingPayment) + expect(fundResult.state).toBe(OutgoingPaymentState.Sending) + + if (mockPayFactory) { + const mockPay = mockPayFactory( + accountingService, + receiverWalletAddressId, + payment + ) + jest + .spyOn(paymentMethodHandlerService, 'pay') + .mockImplementationOnce(mockPay) + } + + return payment + } + + function mockPaySuccessFactory() { + return ( + accountingService: AccountingService, + receiverWalletAddressId: string, + payment: OutgoingPayment + ) => + jest.fn(async (_: unknown, args: { finalDebitAmount: bigint }) => { + const amount = args.finalDebitAmount + const transfer = await accountingService.createTransfer({ + sourceAccount: payment, + destinationAccount: await createIncomingPayment(deps, { + tenantId, + initiationReason: IncomingPaymentInitiationReason.OpenPayments, + walletAddressId: receiverWalletAddressId + }), + sourceAmount: amount, + destinationAmount: amount, + timeout: 0 + }) + assert.ok(transfer && typeof transfer === 'object') + await transfer.post() + return amount + }) + } + + function mockPayPartialFactory(partial: { debit: bigint; receive: bigint }) { + return ( + accountingService: AccountingService, + receiverWalletAddressId: string, + payment: OutgoingPayment + ) => + jest.fn(async () => { + const transfer: Transaction | TransferError = + await accountingService.createTransfer({ + sourceAccount: payment, + destinationAccount: await createIncomingPayment(deps, { + tenantId, + initiationReason: IncomingPaymentInitiationReason.OpenPayments, + walletAddressId: receiverWalletAddressId + }), + sourceAmount: partial.debit, + destinationAmount: partial.receive, + timeout: 0 + }) + assert.ok(transfer && typeof transfer === 'object') + await transfer.post() + return partial.receive + }) + } + + function mockPayErrorFactory(retryable: boolean = false) { + return () => + jest.fn(async () => { + throw new PaymentMethodHandlerError('Simulated failure', { + description: 'Payment failed', + retryable + }) + }) + } + + describe('Grant Spent Amounts', (): void => { + beforeAll(async (): Promise => { + deps = initIocContainer(Config) + appContainer = await createTestApp(deps) + outgoingPaymentService = await deps.use('outgoingPaymentService') + accountingService = await deps.use('accountingService') + paymentMethodHandlerService = await deps.use( + 'paymentMethodHandlerService' + ) + config = await deps.use('config') + knex = appContainer.knex + + jest.useFakeTimers() + }) + + beforeEach(async (): Promise => { + // Create sender wallet address + asset = await createAsset(deps, { assetOptions: assetDetails, tenantId }) + const senderWalletAddress = await createWalletAddress(deps, { + assetId: asset.id, + tenantId + }) + walletAddressId = senderWalletAddress.id + + // Create receiver wallet address and incoming payment + const receiverWalletAddress = await createWalletAddress(deps, { + assetId: asset.id, + tenantId + }) + receiverWalletAddressId = receiverWalletAddress.id + + incomingPayment = await createIncomingPayment(deps, { + tenantId, + initiationReason: IncomingPaymentInitiationReason.OpenPayments, + walletAddressId: receiverWalletAddressId + }) + const config = await deps.use('config') + receiver = incomingPayment.getUrl(config.openPaymentsUrl) + }) + + afterEach(async (): Promise => { + jest.restoreAllMocks() + await truncateTables(deps) + }) + + afterAll(async (): Promise => { + jest.useRealTimers() + await appContainer.shutdown() + }) + + describe('No Interval', (): void => { + test('Successful full payment should have null interval fields', async (): Promise => { + jest.setSystemTime(new Date('2025-01-02T00:00:00Z')) + + const grant = { + id: uuid(), + limits: { + debitAmount: { + value: 1000n, + assetCode: asset.code, + assetScale: asset.scale + } + } + } + const paymentAmount = 100n + + const payment = await createAndFundGrantPayment( + paymentAmount, + grant, + mockPaySuccessFactory() + ) + + const startSpentAmounts = await OutgoingPaymentGrantSpentAmounts.query( + knex + ) + .where({ outgoingPaymentId: payment.id }) + .first() + + assert(startSpentAmounts) + + expect(startSpentAmounts).toMatchObject({ + grantId: grant.id, + outgoingPaymentId: payment.id, + receiveAmountScale: assetDetails.scale, + receiveAmountCode: assetDetails.code, + paymentReceiveAmountValue: paymentAmount, + intervalReceiveAmountValue: null, + grantTotalReceiveAmountValue: paymentAmount, + debitAmountScale: assetDetails.scale, + debitAmountCode: assetDetails.code, + paymentDebitAmountValue: paymentAmount, + intervalDebitAmountValue: null, + grantTotalDebitAmountValue: paymentAmount, + paymentState: OutgoingPaymentState.Funding, + intervalStart: null, + intervalEnd: null + }) + + // advance time to ensure spents amounts created by processNext, if any, have + // later createdAt so that fetching latest is accurate + jest.advanceTimersByTime(500) + const processedPaymentId = await outgoingPaymentService.processNext() + expect(processedPaymentId).toBe(payment.id) + + const finalPayment = await outgoingPaymentService.get({ + id: payment.id + }) + expect(finalPayment?.state).toBe(OutgoingPaymentState.Completed) + + // There should not be a new spent amounts record + const endSpentAmounts = await OutgoingPaymentGrantSpentAmounts.query( + knex + ) + .where({ outgoingPaymentId: payment.id }) + .orderBy('createdAt', 'desc') + .first() + + assert(endSpentAmounts) + expect(endSpentAmounts).toEqual(startSpentAmounts) + }) + test('Retryable failure should not adjust spent amounts', async (): Promise => { + const grant = { + id: uuid(), + limits: { + debitAmount: { + value: 1000n, + assetCode: asset.code, + assetScale: asset.scale + } + } + } + const paymentAmount = 100n + + // Create payment that will fail with a retryable error + const payment = await createAndFundGrantPayment( + paymentAmount, + grant, + mockPayErrorFactory(true) + ) + + const startSpentAmounts = await OutgoingPaymentGrantSpentAmounts.query( + knex + ) + .where({ outgoingPaymentId: payment.id }) + .first() + + assert(startSpentAmounts) + + expect(startSpentAmounts).toMatchObject({ + grantId: grant.id, + outgoingPaymentId: payment.id, + receiveAmountScale: assetDetails.scale, + receiveAmountCode: assetDetails.code, + paymentReceiveAmountValue: paymentAmount, + intervalReceiveAmountValue: null, + grantTotalReceiveAmountValue: paymentAmount, + debitAmountScale: assetDetails.scale, + debitAmountCode: assetDetails.code, + paymentDebitAmountValue: paymentAmount, + intervalDebitAmountValue: null, + grantTotalDebitAmountValue: paymentAmount, + paymentState: OutgoingPaymentState.Funding, + intervalStart: null, + intervalEnd: null + }) + + jest.advanceTimersByTime(500) + const processedPaymentId = await outgoingPaymentService.processNext() + expect(processedPaymentId).toBe(payment.id) + + const finalPayment = await outgoingPaymentService.get({ + id: payment.id + }) + + // Payment should still be in Sending state (retryable, not failed) + // The latest spent amount record should be unchanged + expect(finalPayment?.state).toBe(OutgoingPaymentState.Sending) + + const endSpentAmounts = await OutgoingPaymentGrantSpentAmounts.query( + knex + ) + .where({ outgoingPaymentId: payment.id }) + .orderBy('createdAt', 'desc') + .first() + + assert(endSpentAmounts) + expect(endSpentAmounts).toEqual(startSpentAmounts) + + const allSpentAmounts = await OutgoingPaymentGrantSpentAmounts.query( + knex + ).where({ outgoingPaymentId: payment.id }) + + expect(allSpentAmounts.length).toBe(1) + }) + }) + describe('Inter-Interval', (): void => { + describe('Initial Payment', (): void => { + test('Successful full payment should not change grant spent amounts', async (): Promise => { + jest.setSystemTime(new Date('2025-01-02T00:00:00Z')) + const grant = { + id: uuid(), + limits: { + debitAmount: { + value: 1000n, + assetCode: asset.code, + assetScale: asset.scale + }, + interval: 'R/2025-01-01T00:00:00Z/P1M' + } + } + const paymentAmount = 100n + + const payment = await createAndFundGrantPayment( + paymentAmount, + grant, + mockPaySuccessFactory() + ) + + const startSpentAmounts = + await OutgoingPaymentGrantSpentAmounts.query(knex) + .where({ outgoingPaymentId: payment.id }) + .first() + + // Initital spent amount records should reflect outgoing payment amounts + const interval = getInterval(grant.limits.interval, new Date()) + assert(interval) + assert(interval.start) + assert(interval.end) + expect(startSpentAmounts).toMatchObject({ + grantId: grant.id, + outgoingPaymentId: payment.id, + receiveAmountScale: assetDetails.scale, + receiveAmountCode: assetDetails.code, + paymentReceiveAmountValue: paymentAmount, + intervalReceiveAmountValue: 100n, + grantTotalReceiveAmountValue: paymentAmount, + debitAmountScale: assetDetails.scale, + debitAmountCode: assetDetails.code, + paymentDebitAmountValue: paymentAmount, + intervalDebitAmountValue: 100n, + grantTotalDebitAmountValue: paymentAmount, + paymentState: OutgoingPaymentState.Funding, + intervalStart: interval.start.toJSDate(), + intervalEnd: interval.end.toJSDate() + }) + + // advance time to ensure spents amounts created by processNext, if any, have + // later createdAt so that fetching latest is accurate + jest.advanceTimersByTime(500) + const processedPaymentId = await outgoingPaymentService.processNext() + expect(processedPaymentId).toBe(payment.id) + + const finalPayment = await outgoingPaymentService.get({ + id: payment.id + }) + expect(finalPayment?.state).toBe(OutgoingPaymentState.Completed) + + // There should not be a new spent amounts record + const endSpentAmounts = await OutgoingPaymentGrantSpentAmounts.query( + knex + ) + .where({ outgoingPaymentId: payment.id }) + .orderBy('createdAt', 'desc') + .first() + assert(endSpentAmounts) + expect(endSpentAmounts).toEqual(startSpentAmounts) + }) + + test('Partial payment should add new, settled grant payment amount', async (): Promise => { + jest.setSystemTime(new Date('2025-01-02T00:00:00Z')) + const grant = { + id: uuid(), + limits: { + debitAmount: { + value: 1000n, + assetCode: asset.code, + assetScale: asset.scale + }, + interval: 'R/2025-01-01T00:00:00Z/P1M' + } + } + const paymentAmount = 100n + const settledAmount = 75n + + const payment = await createAndFundGrantPayment( + paymentAmount, + grant, + mockPayPartialFactory({ + debit: paymentAmount, // pay shouldn't have partial debit amount + receive: settledAmount + }) + ) + + const startSpentAmounts = + await OutgoingPaymentGrantSpentAmounts.query(knex) + .where({ outgoingPaymentId: payment.id }) + .first() + assert(startSpentAmounts) + + // Initital spent amount records should reflect full outgoing payment amounts + const interval = getInterval(grant.limits.interval, new Date()) + assert(interval) + assert(interval.start) + assert(interval.end) + expect(startSpentAmounts).toMatchObject({ + grantId: grant.id, + outgoingPaymentId: payment.id, + receiveAmountScale: assetDetails.scale, + receiveAmountCode: assetDetails.code, + paymentReceiveAmountValue: 100n, + intervalReceiveAmountValue: paymentAmount, + grantTotalReceiveAmountValue: 100n, + debitAmountScale: assetDetails.scale, + debitAmountCode: assetDetails.code, + paymentDebitAmountValue: paymentAmount, + intervalDebitAmountValue: paymentAmount, + grantTotalDebitAmountValue: paymentAmount, + paymentState: OutgoingPaymentState.Funding, + intervalStart: interval.start.toJSDate(), + intervalEnd: interval.end.toJSDate() + }) + + // advance time to ensure spents amounts created by processNext, if any, have + // later createdAt so that fetching latest is accurate + jest.advanceTimersByTime(500) + const processedPaymentId = await outgoingPaymentService.processNext() + expect(processedPaymentId).toBe(payment.id) + + const finalPayment = await outgoingPaymentService.get({ + id: payment.id + }) + expect(finalPayment?.state).toBe(OutgoingPaymentState.Completed) + + const endSpentAmounts = await OutgoingPaymentGrantSpentAmounts.query( + knex + ) + .where({ outgoingPaymentId: payment.id }) + .orderBy('createdAt', 'desc') + .first() + + assert(endSpentAmounts) + + // expect new spent amount record with the settled amounts + expect(endSpentAmounts.id).not.toBe(startSpentAmounts.id) + expect(endSpentAmounts).toMatchObject({ + grantId: grant.id, + outgoingPaymentId: payment.id, + receiveAmountScale: assetDetails.scale, + receiveAmountCode: assetDetails.code, + paymentReceiveAmountValue: settledAmount, + intervalReceiveAmountValue: settledAmount, + grantTotalReceiveAmountValue: settledAmount, + debitAmountScale: assetDetails.scale, + debitAmountCode: assetDetails.code, + paymentDebitAmountValue: paymentAmount, + intervalDebitAmountValue: paymentAmount, + grantTotalDebitAmountValue: paymentAmount, + paymentState: OutgoingPaymentState.Completed, + intervalStart: interval.start.toJSDate(), + intervalEnd: interval.end.toJSDate() + }) + }) + + test('Failed payment should revert latest amount', async (): Promise => { + jest.setSystemTime(new Date('2025-01-02T00:00:00Z')) + const grant = { + id: uuid(), + limits: { + debitAmount: { + value: 1000n, + assetCode: asset.code, + assetScale: asset.scale + }, + interval: 'R/2025-01-01T00:00:00Z/P1M' + } + } + const paymentAmount = 100n + const payment = await createAndFundGrantPayment( + paymentAmount, + grant, + mockPayErrorFactory() + ) + const startSpentAmounts = + await OutgoingPaymentGrantSpentAmounts.query(knex) + .where({ outgoingPaymentId: payment.id }) + .first() + + // Initital spent amount records should reflect outgoing payment amounts + const interval = getInterval(grant.limits.interval, new Date()) + assert(interval) + assert(interval.start) + assert(interval.end) + expect(startSpentAmounts).toMatchObject({ + grantId: grant.id, + outgoingPaymentId: payment.id, + receiveAmountScale: assetDetails.scale, + receiveAmountCode: assetDetails.code, + paymentReceiveAmountValue: 100n, + intervalReceiveAmountValue: paymentAmount, + grantTotalReceiveAmountValue: 100n, + debitAmountScale: assetDetails.scale, + debitAmountCode: assetDetails.code, + paymentDebitAmountValue: paymentAmount, + intervalDebitAmountValue: paymentAmount, + grantTotalDebitAmountValue: paymentAmount, + paymentState: OutgoingPaymentState.Funding, + intervalStart: interval.start.toJSDate(), + intervalEnd: interval.end.toJSDate() + }) + + // advance time to ensure spents amounts created by processNext, if any, have + // later createdAt so that fetching latest is accurate + jest.advanceTimersByTime(500) + const processedPaymentId = await outgoingPaymentService.processNext() + expect(processedPaymentId).toBe(payment.id) + + const latestGrantSpentAmounts = + await OutgoingPaymentGrantSpentAmounts.query(knex) + .where({ grantId: grant.id }) + .orderBy('createdAt', 'desc') + .first() + + assert(latestGrantSpentAmounts) + assert(startSpentAmounts) + expect(latestGrantSpentAmounts.id).not.toBe(startSpentAmounts.id) + + // Should have new spent amounts with payment factored out + expect(latestGrantSpentAmounts).toMatchObject({ + grantId: grant.id, + outgoingPaymentId: payment.id, + receiveAmountScale: assetDetails.scale, + receiveAmountCode: assetDetails.code, + paymentReceiveAmountValue: 0n, + intervalReceiveAmountValue: 0n, + grantTotalReceiveAmountValue: 0n, + debitAmountScale: assetDetails.scale, + debitAmountCode: assetDetails.code, + paymentDebitAmountValue: 0n, + intervalDebitAmountValue: 0n, + grantTotalDebitAmountValue: 0n, + paymentState: OutgoingPaymentState.Failed, + intervalStart: interval.start.toJSDate(), + intervalEnd: interval.end.toJSDate() + }) + }) + }) + describe('Successive Payment', (): void => { + test('Successful full payment should not change grant spent amounts', async (): Promise => { + jest.setSystemTime(new Date('2025-01-02T00:00:00Z')) + const grant = { + id: uuid(), + limits: { + debitAmount: { + value: 1000n, + assetCode: asset.code, + assetScale: asset.scale + }, + interval: 'R/2025-01-01T00:00:00Z/P1M' + } + } + const firstPaymentAmount = 100n + const secondPaymentAmount = 100n + + // Create and process first payment + await createAndFundGrantPayment( + firstPaymentAmount, + grant, + mockPaySuccessFactory() + ) + await outgoingPaymentService.processNext() + + // Create second payment + const secondPayment = await createAndFundGrantPayment( + secondPaymentAmount, + grant, + mockPaySuccessFactory() + ) + + const startSpentAmounts = + await OutgoingPaymentGrantSpentAmounts.query(knex) + .where({ outgoingPaymentId: secondPayment.id }) + .first() + + // Initital spent amount records should reflect full outgoing payment amounts + const interval = getInterval(grant.limits.interval, new Date()) + assert(interval) + assert(interval.start) + assert(interval.end) + expect(startSpentAmounts).toMatchObject({ + grantId: grant.id, + outgoingPaymentId: secondPayment.id, + receiveAmountScale: assetDetails.scale, + receiveAmountCode: assetDetails.code, + paymentReceiveAmountValue: secondPaymentAmount, + intervalReceiveAmountValue: + firstPaymentAmount + secondPaymentAmount, + grantTotalReceiveAmountValue: + firstPaymentAmount + secondPaymentAmount, + debitAmountScale: assetDetails.scale, + debitAmountCode: assetDetails.code, + paymentDebitAmountValue: secondPaymentAmount, + intervalDebitAmountValue: firstPaymentAmount + secondPaymentAmount, + grantTotalDebitAmountValue: + firstPaymentAmount + secondPaymentAmount, + paymentState: OutgoingPaymentState.Funding, + intervalStart: interval.start.toJSDate(), + intervalEnd: interval.end.toJSDate() + }) + + // advance time to ensure spents amounts created by processNext, if any, have + // later createdAt so that fetching latest is accurate + jest.advanceTimersByTime(500) + const processedPaymentId = await outgoingPaymentService.processNext() + expect(processedPaymentId).toBe(secondPayment.id) + + const finalPayment = await outgoingPaymentService.get({ + id: secondPayment.id + }) + expect(finalPayment?.state).toBe(OutgoingPaymentState.Completed) + + // There should not be a new spent amounts record from the worker + const endSpentAmounts = await OutgoingPaymentGrantSpentAmounts.query( + knex + ) + .where({ outgoingPaymentId: secondPayment.id }) + .orderBy('createdAt', 'desc') + .first() + assert(endSpentAmounts) + expect(endSpentAmounts).toEqual(startSpentAmounts) + }) + + test('Partial payment should add new, settled grant payment amount', async (): Promise => { + jest.setSystemTime(new Date('2025-01-02T00:00:00Z')) + const grant = { + id: uuid(), + limits: { + debitAmount: { + value: 1000n, + assetCode: asset.code, + assetScale: asset.scale + }, + interval: 'R/2025-01-01T00:00:00Z/P1M' + } + } + + // Create and process full payment + const paymentAmount = 100n + await createAndFundGrantPayment( + paymentAmount, + grant, + mockPaySuccessFactory() + ) + + await outgoingPaymentService.processNext() + + // advance time to ensure spents amounts created by outgoing payment create + // has later createdAt so that fetching latest is accurate + jest.advanceTimersByTime(500) + + // Create second payment with partially settled amount + const secondPaymentSettledAmount = 75n + const secondPayment = await createAndFundGrantPayment( + paymentAmount, + grant, + mockPayPartialFactory({ + debit: paymentAmount, // pay shouldn't have partial debit amount + receive: secondPaymentSettledAmount + }) + ) + + const startSpentAmounts = + await OutgoingPaymentGrantSpentAmounts.query(knex) + .where({ outgoingPaymentId: secondPayment.id }) + .first() + + assert(startSpentAmounts) + // Initital spent amount records should reflect full outgoing payment amounts + const interval = getInterval(grant.limits.interval, new Date()) + assert(interval) + assert(interval.start) + assert(interval.end) + expect(startSpentAmounts).toMatchObject({ + grantId: grant.id, + outgoingPaymentId: secondPayment.id, + receiveAmountScale: assetDetails.scale, + receiveAmountCode: assetDetails.code, + paymentReceiveAmountValue: paymentAmount, + intervalReceiveAmountValue: paymentAmount * 2n, + grantTotalReceiveAmountValue: paymentAmount * 2n, + debitAmountScale: assetDetails.scale, + debitAmountCode: assetDetails.code, + paymentDebitAmountValue: paymentAmount, + intervalDebitAmountValue: paymentAmount * 2n, + grantTotalDebitAmountValue: paymentAmount * 2n, + paymentState: OutgoingPaymentState.Funding, + intervalStart: interval.start.toJSDate(), + intervalEnd: interval.end.toJSDate() + }) + + // advance time to ensure spents amounts created by processNext, if any, have + // later createdAt so that fetching latest is accurate + jest.advanceTimersByTime(500) + const processedPaymentId = await outgoingPaymentService.processNext() + expect(processedPaymentId).toBe(secondPayment.id) + + const finalPayment = await outgoingPaymentService.get({ + id: secondPayment.id + }) + expect(finalPayment?.state).toBe(OutgoingPaymentState.Completed) + + const endSpentAmounts = await OutgoingPaymentGrantSpentAmounts.query( + knex + ) + .where({ outgoingPaymentId: secondPayment.id }) + .orderBy('createdAt', 'desc') + .first() + + assert(endSpentAmounts) + + // There should be a new spent amounts record from the worker with the settled amounts + expect(endSpentAmounts.id).not.toBe(startSpentAmounts.id) + expect(endSpentAmounts).toMatchObject({ + grantId: grant.id, + outgoingPaymentId: secondPayment.id, + receiveAmountScale: assetDetails.scale, + receiveAmountCode: assetDetails.code, + paymentReceiveAmountValue: secondPaymentSettledAmount, + intervalReceiveAmountValue: + paymentAmount + secondPaymentSettledAmount, + grantTotalReceiveAmountValue: + paymentAmount + secondPaymentSettledAmount, + debitAmountScale: assetDetails.scale, + debitAmountCode: assetDetails.code, + paymentDebitAmountValue: paymentAmount, + intervalDebitAmountValue: paymentAmount + paymentAmount, + grantTotalDebitAmountValue: paymentAmount + paymentAmount, + paymentState: OutgoingPaymentState.Completed, + intervalStart: interval.start.toJSDate(), + intervalEnd: interval.end.toJSDate() + }) + }) + + test('Failed payment should revert latest amount', async (): Promise => { + jest.setSystemTime(new Date('2025-01-02T00:00:00Z')) + const grant = { + id: uuid(), + limits: { + debitAmount: { + value: 1000n, + assetCode: asset.code, + assetScale: asset.scale + }, + interval: 'R/2025-01-01T00:00:00Z/P1M' + } + } + const paymentAmount = 100n + + // Create and process first successful payment + await createAndFundGrantPayment( + paymentAmount, + grant, + mockPaySuccessFactory() + ) + await outgoingPaymentService.processNext() + + // advance time to ensure spents amounts created by outgoing payment create + // has later createdAt so that fetching latest is accurate + jest.advanceTimersByTime(500) + + // Create second payment which will fail + const secondPayment = await createAndFundGrantPayment( + paymentAmount, + grant, + mockPayErrorFactory() + ) + + const startSpentAmounts = + await OutgoingPaymentGrantSpentAmounts.query(knex) + .where({ outgoingPaymentId: secondPayment.id }) + .first() + + const interval = getInterval(grant.limits.interval, new Date()) + assert(interval) + assert(interval.start) + assert(interval.end) + expect(startSpentAmounts).toMatchObject({ + grantId: grant.id, + outgoingPaymentId: secondPayment.id, + receiveAmountScale: assetDetails.scale, + receiveAmountCode: assetDetails.code, + paymentReceiveAmountValue: paymentAmount, + intervalReceiveAmountValue: paymentAmount * 2n, + grantTotalReceiveAmountValue: paymentAmount * 2n, + debitAmountScale: assetDetails.scale, + debitAmountCode: assetDetails.code, + paymentDebitAmountValue: paymentAmount, + intervalDebitAmountValue: paymentAmount * 2n, + grantTotalDebitAmountValue: paymentAmount * 2n, + paymentState: OutgoingPaymentState.Funding, + intervalStart: interval.start.toJSDate(), + intervalEnd: interval.end.toJSDate() + }) + + // advance time to ensure spents amounts created by processNext, if any, have + // later createdAt so that fetching latest is accurate + jest.advanceTimersByTime(500) + const processedPaymentId = await outgoingPaymentService.processNext() + expect(processedPaymentId).toBe(secondPayment.id) + + const finalPayment = await outgoingPaymentService.get({ + id: secondPayment.id + }) + expect(finalPayment?.state).toBe(OutgoingPaymentState.Failed) + + const latestGrantSpentAmounts = + await OutgoingPaymentGrantSpentAmounts.query(knex) + .where({ grantId: grant.id }) + .orderBy('createdAt', 'desc') + .first() + + // Should have new spent amounts with payment factored out + expect(latestGrantSpentAmounts).toMatchObject({ + grantId: grant.id, + outgoingPaymentId: secondPayment.id, + receiveAmountScale: assetDetails.scale, + receiveAmountCode: assetDetails.code, + paymentReceiveAmountValue: 0n, + intervalReceiveAmountValue: paymentAmount, + grantTotalReceiveAmountValue: paymentAmount, + debitAmountScale: assetDetails.scale, + debitAmountCode: assetDetails.code, + paymentDebitAmountValue: 0n, + intervalDebitAmountValue: paymentAmount, + grantTotalDebitAmountValue: paymentAmount, + paymentState: OutgoingPaymentState.Failed, + intervalStart: interval.start.toJSDate(), + intervalEnd: interval.end.toJSDate() + }) + }) + + describe('Payment Creation vs. Completion race condition', (): void => { + // Focuses on the scenario where grant payments are not created then processed before additional grant payments + // are created. For example, 2 payments for the same grant are created and then the first one is processed. + // Must verify the correct spent amounts are being used to determine if payment is partial, and that new, adjusted + // grant spent amount values are correct. + + test('Create, create, complete, complete', async (): Promise => { + jest.setSystemTime(new Date('2025-01-02T00:00:00Z')) + const grant = { + id: uuid(), + limits: { + debitAmount: { + value: 1000n, + assetCode: asset.code, + assetScale: asset.scale + }, + interval: 'R/2025-01-01T00:00:00Z/P1M' + } + } + + // Create 2 payments, which create 2 grant spent amounts. Do not process 1st before creating 2nd. + const firstPayment = await createAndFundGrantPayment(100n, grant) + jest.advanceTimersByTime(500) + + await createAndFundGrantPayment(200n, grant) + jest.advanceTimersByTime(500) + const spentAmounts = + await OutgoingPaymentGrantSpentAmounts.query(knex) + expect(spentAmounts.length).toBe(2) + + // Process next (1st payment) + jest + .spyOn(paymentMethodHandlerService, 'pay') + .mockImplementationOnce( + mockPaySuccessFactory()( + accountingService, + receiverWalletAddressId, + firstPayment + ) + ) + const id = await outgoingPaymentService.processNext() + jest.advanceTimersByTime(500) + expect(id).toBe(firstPayment.id) + + // Grant spent amounts should correspond to the first payment + // Should not detect a difference and insert a new spent amount. + const spentAmountsAfterProcessing = + await OutgoingPaymentGrantSpentAmounts.query(knex) + expect(spentAmountsAfterProcessing.length).toBe(2) + }) + + test('Create, create, complete (partial), complete (partial)', async (): Promise => { + // Create 2 payments, complete each partially, and ensure spent amounts remain correct at every step + jest.setSystemTime(new Date('2025-01-02T00:00:00Z')) + const grant = { + id: uuid(), + limits: { + debitAmount: { + value: 1000n, + assetCode: asset.code, + assetScale: asset.scale + }, + interval: 'R/2025-01-01T00:00:00Z/P1M' + } + } + + // Create 1 + const firstPaymentAmount = 10n + const firstPayment = await createAndFundGrantPayment( + firstPaymentAmount, + grant + ) + jest.advanceTimersByTime(500) + + const latestSpentAmounts = [ + await OutgoingPaymentGrantSpentAmounts.query(knex) + .where({ grantId: grant.id }) + .first() + ] + + let interval = getInterval( + grant.limits.interval, + firstPayment.createdAt + ) + assert(interval) + assert(interval.start) + assert(interval.end) + expect(latestSpentAmounts[0]).toMatchObject({ + grantId: grant.id, + outgoingPaymentId: firstPayment.id, + receiveAmountScale: assetDetails.scale, + receiveAmountCode: assetDetails.code, + paymentReceiveAmountValue: firstPaymentAmount, + intervalReceiveAmountValue: firstPaymentAmount, + grantTotalReceiveAmountValue: firstPaymentAmount, + debitAmountScale: assetDetails.scale, + debitAmountCode: assetDetails.code, + paymentDebitAmountValue: firstPaymentAmount, + intervalDebitAmountValue: firstPaymentAmount, + grantTotalDebitAmountValue: firstPaymentAmount, + paymentState: OutgoingPaymentState.Funding, + intervalStart: interval.start.toJSDate(), + intervalEnd: interval.end.toJSDate() + }) + + // Create 2 + const secondPaymentAmount = 20n + const secondPayment = await createAndFundGrantPayment( + secondPaymentAmount, + grant + ) + jest.advanceTimersByTime(500) + + latestSpentAmounts[1] = + await OutgoingPaymentGrantSpentAmounts.query(knex) + .where({ grantId: grant.id }) + .first() + + interval = getInterval( + grant.limits.interval, + firstPayment.createdAt + ) + assert(interval) + assert(interval.start) + assert(interval.end) + expect(latestSpentAmounts[1]).toMatchObject({ + grantId: grant.id, + outgoingPaymentId: secondPayment.id, + receiveAmountScale: assetDetails.scale, + receiveAmountCode: assetDetails.code, + paymentReceiveAmountValue: secondPaymentAmount, + intervalReceiveAmountValue: + firstPaymentAmount + secondPaymentAmount, + grantTotalReceiveAmountValue: + firstPaymentAmount + secondPaymentAmount, + debitAmountScale: assetDetails.scale, + debitAmountCode: assetDetails.code, + paymentDebitAmountValue: secondPaymentAmount, + intervalDebitAmountValue: + firstPaymentAmount + secondPaymentAmount, + grantTotalDebitAmountValue: + firstPaymentAmount + secondPaymentAmount, + paymentState: OutgoingPaymentState.Funding, + intervalStart: interval.start.toJSDate(), + intervalEnd: interval.end.toJSDate() + }) + + // Process first payment (partial) + const firstPaymentSettledAmount = 8n + jest + .spyOn(paymentMethodHandlerService, 'pay') + .mockImplementationOnce( + mockPayPartialFactory({ + debit: firstPaymentAmount, // pay shouldn't have partial debit amount + receive: firstPaymentSettledAmount + })(accountingService, receiverWalletAddressId, firstPayment) + ) + let id = await outgoingPaymentService.processNext() + jest.advanceTimersByTime(500) + expect(id).toBe(firstPayment.id) + + latestSpentAmounts[2] = + await OutgoingPaymentGrantSpentAmounts.query(knex) + .where({ grantId: grant.id }) + .first() + + interval = getInterval( + grant.limits.interval, + firstPayment.createdAt + ) + assert(interval) + assert(interval.start) + assert(interval.end) + expect(latestSpentAmounts[2]).toMatchObject({ + grantId: grant.id, + outgoingPaymentId: firstPayment.id, + receiveAmountScale: assetDetails.scale, + receiveAmountCode: assetDetails.code, + paymentReceiveAmountValue: firstPaymentSettledAmount, + intervalReceiveAmountValue: + secondPaymentAmount + firstPaymentSettledAmount, + grantTotalReceiveAmountValue: + secondPaymentAmount + firstPaymentSettledAmount, + debitAmountScale: assetDetails.scale, + debitAmountCode: assetDetails.code, + paymentDebitAmountValue: firstPaymentAmount, + intervalDebitAmountValue: + firstPaymentAmount + secondPaymentAmount, + grantTotalDebitAmountValue: + firstPaymentAmount + secondPaymentAmount, + paymentState: OutgoingPaymentState.Completed, + intervalStart: interval.start.toJSDate(), + intervalEnd: interval.end.toJSDate() + }) + + // Process 2nd payment (partial) + const secondPaymentSettledAmount = 15n + jest + .spyOn(paymentMethodHandlerService, 'pay') + .mockImplementationOnce( + mockPayPartialFactory({ + debit: secondPaymentAmount, // pay shouldn't have partial debit amount + receive: secondPaymentSettledAmount + })(accountingService, receiverWalletAddressId, secondPayment) + ) + id = await outgoingPaymentService.processNext() + jest.advanceTimersByTime(500) + expect(id).toBe(secondPayment.id) + + latestSpentAmounts[3] = + await OutgoingPaymentGrantSpentAmounts.query(knex) + .where({ grantId: grant.id }) + .first() + + interval = getInterval( + grant.limits.interval, + secondPayment.createdAt + ) + assert(interval) + assert(interval.start) + assert(interval.end) + assert(latestSpentAmounts[2]?.intervalReceiveAmountValue) + expect(latestSpentAmounts[3]).toMatchObject({ + grantId: grant.id, + outgoingPaymentId: secondPayment.id, + receiveAmountScale: assetDetails.scale, + receiveAmountCode: assetDetails.code, + paymentReceiveAmountValue: secondPaymentSettledAmount, + intervalReceiveAmountValue: + firstPaymentSettledAmount + secondPaymentSettledAmount, + grantTotalReceiveAmountValue: + firstPaymentSettledAmount + secondPaymentSettledAmount, + debitAmountScale: assetDetails.scale, + debitAmountCode: assetDetails.code, + paymentDebitAmountValue: secondPaymentAmount, + intervalDebitAmountValue: + firstPaymentAmount + secondPaymentAmount, + grantTotalDebitAmountValue: + firstPaymentAmount + secondPaymentAmount, + paymentState: OutgoingPaymentState.Completed, + intervalStart: interval.start.toJSDate(), + intervalEnd: interval.end.toJSDate() + }) + }) + + test('Create, create, complete (partial), create, complete (partial)', async (): Promise => { + // Create 2 payments, complete first partially, create another payment, complete 2nd, + // and ensure spent amounts remain correct at every step + jest.setSystemTime(new Date('2025-01-02T00:00:00Z')) + const grant = { + id: uuid(), + limits: { + debitAmount: { + value: 1000n, + assetCode: asset.code, + assetScale: asset.scale + }, + interval: 'R/2025-01-01T00:00:00Z/P1M' + } + } + + // Create 1 + const firstPaymentAmount = 10n + const firstPayment = await createAndFundGrantPayment( + firstPaymentAmount, + grant + ) + jest.advanceTimersByTime(500) + + const latestSpentAmounts = [ + await OutgoingPaymentGrantSpentAmounts.query(knex) + .where({ grantId: grant.id }) + .first() + ] + + let interval = getInterval( + grant.limits.interval, + firstPayment.createdAt + ) + assert(interval) + assert(interval.start) + assert(interval.end) + expect(latestSpentAmounts[0]).toMatchObject({ + grantId: grant.id, + outgoingPaymentId: firstPayment.id, + receiveAmountScale: assetDetails.scale, + receiveAmountCode: assetDetails.code, + paymentReceiveAmountValue: firstPaymentAmount, + intervalReceiveAmountValue: firstPaymentAmount, + grantTotalReceiveAmountValue: firstPaymentAmount, + debitAmountScale: assetDetails.scale, + debitAmountCode: assetDetails.code, + paymentDebitAmountValue: firstPaymentAmount, + intervalDebitAmountValue: firstPaymentAmount, + grantTotalDebitAmountValue: firstPaymentAmount, + paymentState: OutgoingPaymentState.Funding, + intervalStart: interval.start.toJSDate(), + intervalEnd: interval.end.toJSDate() + }) + + // Create 2 + const secondPaymentAmount = 20n + const secondPayment = await createAndFundGrantPayment( + secondPaymentAmount, + grant + ) + jest.advanceTimersByTime(500) + + latestSpentAmounts[1] = + await OutgoingPaymentGrantSpentAmounts.query(knex) + .where({ grantId: grant.id }) + .first() + + interval = getInterval( + grant.limits.interval, + firstPayment.createdAt + ) + assert(interval) + assert(interval.start) + assert(interval.end) + expect(latestSpentAmounts[1]).toMatchObject({ + grantId: grant.id, + outgoingPaymentId: secondPayment.id, + receiveAmountScale: assetDetails.scale, + receiveAmountCode: assetDetails.code, + paymentReceiveAmountValue: secondPaymentAmount, + intervalReceiveAmountValue: + firstPaymentAmount + secondPaymentAmount, + grantTotalReceiveAmountValue: + firstPaymentAmount + secondPaymentAmount, + debitAmountScale: assetDetails.scale, + debitAmountCode: assetDetails.code, + paymentDebitAmountValue: secondPaymentAmount, + intervalDebitAmountValue: + firstPaymentAmount + secondPaymentAmount, + grantTotalDebitAmountValue: + firstPaymentAmount + secondPaymentAmount, + paymentState: OutgoingPaymentState.Funding, + intervalStart: interval.start.toJSDate(), + intervalEnd: interval.end.toJSDate() + }) + + // Process first payment (partial) + const firstPaymentSettledAmount = 8n + jest + .spyOn(paymentMethodHandlerService, 'pay') + .mockImplementationOnce( + mockPayPartialFactory({ + debit: firstPaymentAmount, // pay shouldn't complete partially + receive: firstPaymentSettledAmount + })(accountingService, receiverWalletAddressId, firstPayment) + ) + let id = await outgoingPaymentService.processNext() + jest.advanceTimersByTime(500) + expect(id).toBe(firstPayment.id) + + latestSpentAmounts[2] = + await OutgoingPaymentGrantSpentAmounts.query(knex) + .where({ grantId: grant.id }) + .first() + + interval = getInterval( + grant.limits.interval, + secondPayment.createdAt + ) + assert(interval) + assert(interval.start) + assert(interval.end) + expect(latestSpentAmounts[2]).toMatchObject({ + grantId: grant.id, + outgoingPaymentId: firstPayment.id, + receiveAmountScale: assetDetails.scale, + receiveAmountCode: assetDetails.code, + paymentReceiveAmountValue: firstPaymentSettledAmount, + intervalReceiveAmountValue: + secondPaymentAmount + firstPaymentSettledAmount, + grantTotalReceiveAmountValue: + secondPaymentAmount + firstPaymentSettledAmount, + debitAmountScale: assetDetails.scale, + debitAmountCode: assetDetails.code, + paymentDebitAmountValue: firstPaymentAmount, + intervalDebitAmountValue: + firstPaymentAmount + secondPaymentAmount, + grantTotalDebitAmountValue: + firstPaymentAmount + secondPaymentAmount, + paymentState: OutgoingPaymentState.Completed, + intervalStart: interval.start.toJSDate(), + intervalEnd: interval.end.toJSDate() + }) + + // Create 3 + const thirdPaymentAmount = 30n + const thirdPayment = await createAndFundGrantPayment( + thirdPaymentAmount, + grant + ) + jest.advanceTimersByTime(500) + + latestSpentAmounts[3] = + await OutgoingPaymentGrantSpentAmounts.query(knex) + .where({ grantId: grant.id }) + .first() + + interval = getInterval( + grant.limits.interval, + thirdPayment.createdAt + ) + assert(interval) + assert(interval.start) + assert(interval.end) + assert(latestSpentAmounts[2]?.intervalReceiveAmountValue) + assert(latestSpentAmounts[2]?.intervalDebitAmountValue) + expect(latestSpentAmounts[3]).toMatchObject({ + grantId: grant.id, + outgoingPaymentId: thirdPayment.id, + receiveAmountScale: assetDetails.scale, + receiveAmountCode: assetDetails.code, + paymentReceiveAmountValue: thirdPaymentAmount, + intervalReceiveAmountValue: + latestSpentAmounts[2].intervalReceiveAmountValue + + thirdPaymentAmount, + grantTotalReceiveAmountValue: + latestSpentAmounts[2].grantTotalReceiveAmountValue + + thirdPaymentAmount, + debitAmountScale: assetDetails.scale, + debitAmountCode: assetDetails.code, + paymentDebitAmountValue: thirdPaymentAmount, + intervalDebitAmountValue: + latestSpentAmounts[2].intervalDebitAmountValue + + thirdPaymentAmount, + grantTotalDebitAmountValue: + latestSpentAmounts[2].grantTotalDebitAmountValue + + thirdPaymentAmount, + paymentState: OutgoingPaymentState.Funding, + intervalStart: interval.start.toJSDate(), + intervalEnd: interval.end.toJSDate() + }) + + // Process 2nd payment (partial) + const secondPaymentSettledAmount = 15n + jest + .spyOn(paymentMethodHandlerService, 'pay') + .mockImplementationOnce( + mockPayPartialFactory({ + debit: secondPaymentAmount, // pay shouldn't complete debit partially + receive: secondPaymentSettledAmount + })(accountingService, receiverWalletAddressId, secondPayment) + ) + id = await outgoingPaymentService.processNext() + jest.advanceTimersByTime(500) + expect(id).toBe(secondPayment.id) + + latestSpentAmounts[4] = + await OutgoingPaymentGrantSpentAmounts.query(knex) + .where({ grantId: grant.id }) + .first() + + interval = getInterval( + grant.limits.interval, + secondPayment.createdAt + ) + assert(interval) + assert(interval.start) + assert(interval.end) + assert(latestSpentAmounts[3]?.intervalReceiveAmountValue) + assert(latestSpentAmounts[3]?.intervalDebitAmountValue) + expect(latestSpentAmounts[4]).toMatchObject({ + grantId: grant.id, + outgoingPaymentId: secondPayment.id, + receiveAmountScale: assetDetails.scale, + receiveAmountCode: assetDetails.code, + paymentReceiveAmountValue: secondPaymentSettledAmount, + intervalReceiveAmountValue: + firstPaymentSettledAmount + + thirdPaymentAmount + + secondPaymentSettledAmount, + grantTotalReceiveAmountValue: + firstPaymentSettledAmount + + thirdPaymentAmount + + secondPaymentSettledAmount, + debitAmountScale: assetDetails.scale, + debitAmountCode: assetDetails.code, + paymentDebitAmountValue: secondPaymentAmount, + intervalDebitAmountValue: + firstPaymentAmount + secondPaymentAmount + thirdPaymentAmount, + grantTotalDebitAmountValue: + firstPaymentAmount + secondPaymentAmount + thirdPaymentAmount, + paymentState: OutgoingPaymentState.Completed, + intervalStart: interval.start.toJSDate(), + intervalEnd: interval.end.toJSDate() + }) + }) + + test('Create, create, fail, complete', async (): Promise => { + jest.setSystemTime(new Date('2025-01-02T00:00:00Z')) + const grant = { + id: uuid(), + limits: { + debitAmount: { + value: 1000n, + assetCode: asset.code, + assetScale: asset.scale + }, + interval: 'R/2025-01-01T00:00:00Z/P1M' + } + } + + // Create 1 + const firstPaymentAmount = 10n + const firstPayment = await createAndFundGrantPayment( + firstPaymentAmount, + grant + ) + jest.advanceTimersByTime(500) + + const latestSpentAmounts = [ + await OutgoingPaymentGrantSpentAmounts.query(knex) + .where({ grantId: grant.id }) + .first() + ] + + let interval = getInterval( + grant.limits.interval, + firstPayment.createdAt + ) + assert(interval) + assert(interval.start) + assert(interval.end) + expect(latestSpentAmounts[0]).toMatchObject({ + grantId: grant.id, + outgoingPaymentId: firstPayment.id, + receiveAmountScale: assetDetails.scale, + receiveAmountCode: assetDetails.code, + paymentReceiveAmountValue: firstPaymentAmount, + intervalReceiveAmountValue: firstPaymentAmount, + grantTotalReceiveAmountValue: firstPaymentAmount, + debitAmountScale: assetDetails.scale, + debitAmountCode: assetDetails.code, + paymentDebitAmountValue: firstPaymentAmount, + intervalDebitAmountValue: firstPaymentAmount, + grantTotalDebitAmountValue: firstPaymentAmount, + paymentState: OutgoingPaymentState.Funding, + intervalStart: interval.start.toJSDate(), + intervalEnd: interval.end.toJSDate() + }) + + // Create 2 + const secondPaymentAmount = 20n + const secondPayment = await createAndFundGrantPayment( + secondPaymentAmount, + grant + ) + jest.advanceTimersByTime(500) + + latestSpentAmounts[1] = + await OutgoingPaymentGrantSpentAmounts.query(knex) + .where({ grantId: grant.id }) + .first() + + interval = getInterval( + grant.limits.interval, + firstPayment.createdAt + ) + assert(interval) + assert(interval.start) + assert(interval.end) + expect(latestSpentAmounts[1]).toMatchObject({ + grantId: grant.id, + outgoingPaymentId: secondPayment.id, + receiveAmountScale: assetDetails.scale, + receiveAmountCode: assetDetails.code, + paymentReceiveAmountValue: secondPaymentAmount, + intervalReceiveAmountValue: + firstPaymentAmount + secondPaymentAmount, + grantTotalReceiveAmountValue: + firstPaymentAmount + secondPaymentAmount, + debitAmountScale: assetDetails.scale, + debitAmountCode: assetDetails.code, + paymentDebitAmountValue: secondPaymentAmount, + intervalDebitAmountValue: + firstPaymentAmount + secondPaymentAmount, + grantTotalDebitAmountValue: + firstPaymentAmount + secondPaymentAmount, + paymentState: OutgoingPaymentState.Funding, + intervalStart: interval.start.toJSDate(), + intervalEnd: interval.end.toJSDate() + }) + + // Process first payment (fail) + jest + .spyOn(paymentMethodHandlerService, 'pay') + .mockImplementationOnce(mockPayErrorFactory()()) + let id = await outgoingPaymentService.processNext() + jest.advanceTimersByTime(500) + expect(id).toBe(firstPayment.id) + + const failedPayment = await outgoingPaymentService.get({ + id: firstPayment.id + }) + expect(failedPayment?.state).toBe(OutgoingPaymentState.Failed) + + // Should have new record with first payment amounts removed + latestSpentAmounts[2] = + await OutgoingPaymentGrantSpentAmounts.query(knex) + .where({ grantId: grant.id }) + .first() + + interval = getInterval( + grant.limits.interval, + secondPayment.createdAt + ) + assert(interval) + assert(interval.start) + assert(interval.end) + // Latest spent amounts should now only reflect the second payment + expect(latestSpentAmounts[2]).toMatchObject({ + grantId: grant.id, + outgoingPaymentId: firstPayment.id, + receiveAmountScale: assetDetails.scale, + receiveAmountCode: assetDetails.code, + paymentReceiveAmountValue: 0n, + intervalReceiveAmountValue: secondPaymentAmount, + grantTotalReceiveAmountValue: secondPaymentAmount, + debitAmountScale: assetDetails.scale, + debitAmountCode: assetDetails.code, + paymentDebitAmountValue: 0n, + intervalDebitAmountValue: secondPaymentAmount, + grantTotalDebitAmountValue: secondPaymentAmount, + paymentState: OutgoingPaymentState.Failed, + intervalStart: interval.start.toJSDate(), + intervalEnd: interval.end.toJSDate() + }) + + // Process second payment (success) + jest + .spyOn(paymentMethodHandlerService, 'pay') + .mockImplementationOnce( + mockPaySuccessFactory()( + accountingService, + receiverWalletAddressId, + secondPayment + ) + ) + id = await outgoingPaymentService.processNext() + jest.advanceTimersByTime(500) + expect(id).toBe(secondPayment.id) + + const completedPayment = await outgoingPaymentService.get({ + id: secondPayment.id + }) + expect(completedPayment?.state).toBe(OutgoingPaymentState.Completed) + + latestSpentAmounts[3] = + await OutgoingPaymentGrantSpentAmounts.query(knex) + .where({ grantId: grant.id }) + .first() + + // Should not detect a difference and insert a new spent amount since payment completed fully + assert(latestSpentAmounts[2]) + expect(latestSpentAmounts[3]).toMatchObject(latestSpentAmounts[2]) + }) + }) + }) + }) + + describe('Cross-Interval', (): void => { + test('Payments across intervals should reset interval amounts but accumulate grant total', async (): Promise => { + const grant = { + id: uuid(), + limits: { + debitAmount: { + value: 1000n, + assetCode: asset.code, + assetScale: asset.scale + }, + interval: 'R/2025-01-01T00:00:00Z/P5D' + } + } + const paymentAmount = 100n + + // First interval - 2 payments + jest.setSystemTime(new Date('2025-01-02T00:00:00Z')) + await createAndFundGrantPayment( + paymentAmount, + grant, + mockPaySuccessFactory() + ) + await outgoingPaymentService.processNext() + + jest.advanceTimersByTime(500) + const secondPayment = await createAndFundGrantPayment( + paymentAmount, + grant, + mockPaySuccessFactory() + ) + await outgoingPaymentService.processNext() + + const secondSpentAmounts = await OutgoingPaymentGrantSpentAmounts.query( + knex + ) + .where({ outgoingPaymentId: secondPayment.id }) + .orderBy('createdAt', 'desc') + .first() + + assert(secondSpentAmounts) + expect(secondSpentAmounts).toMatchObject({ + paymentReceiveAmountValue: paymentAmount, + intervalReceiveAmountValue: paymentAmount * 2n, + grantTotalReceiveAmountValue: paymentAmount * 2n, + paymentDebitAmountValue: paymentAmount, + intervalDebitAmountValue: paymentAmount * 2n, + grantTotalDebitAmountValue: paymentAmount * 2n + }) + + // Second interval - 2 payments + jest.setSystemTime(new Date('2025-01-08T00:00:00Z')) + jest.advanceTimersByTime(500) + + await createAndFundGrantPayment( + paymentAmount, + grant, + mockPaySuccessFactory() + ) + await outgoingPaymentService.processNext() + + jest.advanceTimersByTime(500) + const fourthPayment = await createAndFundGrantPayment( + paymentAmount, + grant, + mockPaySuccessFactory() + ) + await outgoingPaymentService.processNext() + + const fourthSpentAmounts = await OutgoingPaymentGrantSpentAmounts.query( + knex + ) + .where({ outgoingPaymentId: fourthPayment.id }) + .orderBy('createdAt', 'desc') + .first() + + assert(fourthSpentAmounts) + const secondInterval = getInterval(grant.limits.interval, new Date()) + assert(secondInterval) + assert(secondInterval.start) + assert(secondInterval.end) + + // Interval amounts should only include second interval payments (3rd and 4th) + // Grant total should include all 4 payments + expect(fourthSpentAmounts).toMatchObject({ + paymentReceiveAmountValue: paymentAmount, + intervalReceiveAmountValue: paymentAmount * 2n, + grantTotalReceiveAmountValue: paymentAmount * 4n, + paymentDebitAmountValue: paymentAmount, + intervalDebitAmountValue: paymentAmount * 2n, + grantTotalDebitAmountValue: paymentAmount * 4n, + intervalStart: secondInterval.start.toJSDate(), + intervalEnd: secondInterval.end.toJSDate() + }) + + // Verify the interval boundaries are different between first and second + const firstInterval = getInterval( + grant.limits.interval, + new Date('2025-01-02T00:00:00Z') + ) + assert(firstInterval) + assert(firstInterval.start) + expect(fourthSpentAmounts.intervalStart).not.toEqual( + firstInterval.start.toJSDate() + ) + }) + test('Payment created at interval boundary should use creation-time interval', async (): Promise => { + const grant = { + id: uuid(), + limits: { + debitAmount: { + value: 1000n, + assetCode: asset.code, + assetScale: asset.scale + }, + interval: 'R/2025-01-01T00:00:00Z/P1M' + } + } + const paymentAmount = 100n + + // Create payment at the very end of January + jest.setSystemTime(new Date('2025-01-31T23:59:59Z')) + const payment = await createAndFundGrantPayment( + paymentAmount, + grant, + mockPaySuccessFactory() + ) + + const creationInterval = getInterval(grant.limits.interval, new Date()) + assert(creationInterval) + assert(creationInterval.start) + assert(creationInterval.end) + + const startSpentAmounts = await OutgoingPaymentGrantSpentAmounts.query( + knex + ) + .where({ outgoingPaymentId: payment.id }) + .orderBy('createdAt', 'desc') + .first() + assert(startSpentAmounts) + + // Process payment after interval boundary (in February) + jest.setSystemTime(new Date('2025-02-01T00:00:01Z')) + const processedPaymentId = await outgoingPaymentService.processNext() + expect(processedPaymentId).toBe(payment.id) + + const finishSpentAmounts = await OutgoingPaymentGrantSpentAmounts.query( + knex + ) + .where({ outgoingPaymentId: payment.id }) + .orderBy('createdAt', 'desc') + .first() + assert(finishSpentAmounts) + + // Should still be original spent amounts in January's interval + expect(finishSpentAmounts).toMatchObject(startSpentAmounts) + expect(finishSpentAmounts.intervalStart).toEqual( + creationInterval.start.toJSDate() + ) + expect(finishSpentAmounts.intervalEnd).toEqual( + creationInterval.end.toJSDate() + ) + }) + test('Partial payment at interval boundary should preserve creation-time interval in new record', async (): Promise => { + const grant = { + id: uuid(), + limits: { + debitAmount: { + value: 1000n, + assetCode: asset.code, + assetScale: asset.scale + }, + interval: 'R/2025-01-01T00:00:00Z/P1M' + } + } + const paymentAmount = 100n + const settledAmount = 75n + + // Create and process first payment fully in January + jest.setSystemTime(new Date('2025-01-15T12:00:00Z')) + await createAndFundGrantPayment( + paymentAmount, + grant, + mockPaySuccessFactory() + ) + await outgoingPaymentService.processNext() + + // Create second payment at the very end of January + jest.setSystemTime(new Date('2025-01-31T23:59:59Z')) + const payment = await createAndFundGrantPayment( + paymentAmount, + grant, + mockPayPartialFactory({ + debit: paymentAmount, // pay shouldnt complete debit partially + receive: settledAmount + }) + ) + + const creationInterval = getInterval(grant.limits.interval, new Date()) + assert(creationInterval) + assert(creationInterval.start) + assert(creationInterval.end) + + const startSpentAmounts = await OutgoingPaymentGrantSpentAmounts.query( + knex + ) + .where({ outgoingPaymentId: payment.id }) + .first() + assert(startSpentAmounts) + + // Initial record should have full payment amount in January's interval + // with cumulative amounts from first payment + expect(startSpentAmounts).toMatchObject({ + paymentReceiveAmountValue: paymentAmount, + intervalReceiveAmountValue: paymentAmount * 2n, + grantTotalReceiveAmountValue: paymentAmount * 2n, + paymentDebitAmountValue: paymentAmount, + intervalDebitAmountValue: paymentAmount * 2n, + grantTotalDebitAmountValue: paymentAmount * 2n, + intervalStart: creationInterval.start.toJSDate(), + intervalEnd: creationInterval.end.toJSDate() + }) + + // Process payment after interval boundary (in February) + jest.setSystemTime(new Date('2025-02-01T00:00:01Z')) + jest.advanceTimersByTime(500) + const processedPaymentId = await outgoingPaymentService.processNext() + expect(processedPaymentId).toBe(payment.id) + + const endSpentAmounts = await OutgoingPaymentGrantSpentAmounts.query( + knex + ) + .where({ outgoingPaymentId: payment.id }) + .orderBy('createdAt', 'desc') + .first() + assert(endSpentAmounts) + + // New record should be created with first payment + second payment + // in initial January interval + expect(endSpentAmounts.id).not.toBe(startSpentAmounts.id) + expect(endSpentAmounts).toMatchObject({ + paymentReceiveAmountValue: settledAmount, + intervalReceiveAmountValue: paymentAmount + settledAmount, + grantTotalReceiveAmountValue: paymentAmount + settledAmount, + paymentDebitAmountValue: paymentAmount, + intervalDebitAmountValue: paymentAmount + paymentAmount, + grantTotalDebitAmountValue: paymentAmount + paymentAmount, + intervalStart: creationInterval.start.toJSDate(), + intervalEnd: creationInterval.end.toJSDate() + }) + const februaryInterval = getInterval( + grant.limits.interval, + new Date('2025-02-01T00:00:01Z') + ) + assert(februaryInterval) + assert(februaryInterval.start) + expect(endSpentAmounts.intervalStart).not.toEqual( + februaryInterval.start.toJSDate() + ) + }) + test('Failed payment created in one interval but processed in next should revert spent amounts', async (): Promise => { + const grant = { + id: uuid(), + limits: { + debitAmount: { + value: 1000n, + assetCode: asset.code, + assetScale: asset.scale + }, + interval: 'R/2025-01-01T00:00:00Z/P1M' + } + } + const paymentAmount = 100n + + // Create and process first payment fully in January + jest.setSystemTime(new Date('2025-01-15T12:00:00Z')) + await createAndFundGrantPayment( + paymentAmount, + grant, + mockPaySuccessFactory() + ) + await outgoingPaymentService.processNext() + + const initialSpentAmounts = + await OutgoingPaymentGrantSpentAmounts.query(knex) + .where({ grantId: grant.id }) + .orderBy('createdAt', 'desc') + .first() + assert(initialSpentAmounts) + + // Create second payment at the very end of January + jest.setSystemTime(new Date('2025-01-31T23:59:59Z')) + const payment = await createAndFundGrantPayment( + paymentAmount, + grant, + mockPayErrorFactory() + ) + + const creationInterval = getInterval(grant.limits.interval, new Date()) + assert(creationInterval) + assert(creationInterval.start) + assert(creationInterval.end) + + const startSpentAmounts = await OutgoingPaymentGrantSpentAmounts.query( + knex + ) + .where({ outgoingPaymentId: payment.id }) + .first() + assert(startSpentAmounts) + + // Initial record should have full payment amount in January's interval + // with cumulative amounts from first payment + expect(startSpentAmounts).toMatchObject({ + paymentReceiveAmountValue: paymentAmount, + intervalReceiveAmountValue: paymentAmount * 2n, + grantTotalReceiveAmountValue: paymentAmount * 2n, + paymentDebitAmountValue: paymentAmount, + intervalDebitAmountValue: paymentAmount * 2n, + grantTotalDebitAmountValue: paymentAmount * 2n, + intervalStart: creationInterval.start.toJSDate(), + intervalEnd: creationInterval.end.toJSDate() + }) + + // Process payment after interval boundary (in February) + jest.setSystemTime(new Date('2025-02-01T00:00:01Z')) + jest.advanceTimersByTime(500) + const processedPaymentId = await outgoingPaymentService.processNext() + expect(processedPaymentId).toBe(payment.id) + + const finalPayment = await outgoingPaymentService.get({ + id: payment.id + }) + expect(finalPayment?.state).toBe(OutgoingPaymentState.Failed) + + const latestSpentAmounts = await OutgoingPaymentGrantSpentAmounts.query( + knex + ) + .where({ grantId: grant.id }) + .orderBy('createdAt', 'desc') + .first() + + assert(latestSpentAmounts) + + // Should have new spent amounts with payment factored out + expect(latestSpentAmounts).toMatchObject({ + grantId: grant.id, + outgoingPaymentId: payment.id, + receiveAmountScale: assetDetails.scale, + receiveAmountCode: assetDetails.code, + paymentReceiveAmountValue: 0n, + intervalReceiveAmountValue: paymentAmount, + grantTotalReceiveAmountValue: paymentAmount, + debitAmountScale: assetDetails.scale, + debitAmountCode: assetDetails.code, + paymentDebitAmountValue: 0n, + intervalDebitAmountValue: paymentAmount, + grantTotalDebitAmountValue: paymentAmount, + paymentState: OutgoingPaymentState.Failed, + intervalStart: creationInterval.start.toJSDate(), + intervalEnd: creationInterval.end.toJSDate() + }) + }) + + describe('Payment Creation vs. Completion race condition', (): void => { + test( + 'Payment Created in interval 1, payment 2 created in interval 1, payment 3 created in interval 2, payment 1 completes partially - should use correct interval amounts', + withConfigOverride( + () => config, + { + // adjust resource ages for long intervals + quoteLifespan: 2592000000, + incomingPaymentExpiryMaxMs: 2592000000 * 3, + // 0 slippages makes some results more predictable and easier to + // reason about for the purpose of testing. for example, + // secondPayment amount of 200n results in receiveAmount of 200n + // with 0 slippage, but 199n with default 0.01 + slippage: 0 + }, + async (): Promise => { + const grant = { + id: uuid(), + limits: { + debitAmount: { + value: 1000n, + assetCode: asset.code, + assetScale: asset.scale + }, + // 1 month repeating interval starting Jan 1 + interval: 'R/2025-01-01T00:00:00Z/P1M' + } + } + const firstPaymentAmount = 100n + const firstPaymentSettledAmount = 75n + const secondPaymentAmount = 200n + const thirdPaymentAmount = 300n + + // Create payment 1 in interval 1 (January) + jest.setSystemTime(new Date('2025-01-15T12:00:00Z')) + const firstPayment = await createAndFundGrantPayment( + firstPaymentAmount, + grant + ) + jest.advanceTimersByTime(500) + + const firstInterval = getInterval( + grant.limits.interval, + new Date() + ) + assert(firstInterval) + assert(firstInterval.start) + assert(firstInterval.end) + + const firstSpentAmounts = + await OutgoingPaymentGrantSpentAmounts.query(knex) + .where({ grantId: grant.id }) + .first() + assert(firstSpentAmounts) + + expect(firstSpentAmounts).toMatchObject({ + grantId: grant.id, + outgoingPaymentId: firstPayment.id, + paymentReceiveAmountValue: firstPaymentAmount, + intervalReceiveAmountValue: firstPaymentAmount, + grantTotalReceiveAmountValue: firstPaymentAmount, + paymentDebitAmountValue: firstPaymentAmount, + intervalDebitAmountValue: firstPaymentAmount, + grantTotalDebitAmountValue: firstPaymentAmount, + intervalStart: firstInterval.start.toJSDate(), + intervalEnd: firstInterval.end.toJSDate() + }) + + // Create payment 2 in interval 1 (still January) + jest.setSystemTime(new Date('2025-01-20T12:00:00Z')) + jest.advanceTimersByTime(500) + + const secondPayment = await createAndFundGrantPayment( + secondPaymentAmount, + grant + ) + jest.advanceTimersByTime(500) + + const secondSpentAmounts = + await OutgoingPaymentGrantSpentAmounts.query(knex) + .where({ grantId: grant.id }) + .first() + assert(secondSpentAmounts) + + // Payment 2 should accumulate with payment 1 in same interval + expect(secondSpentAmounts).toMatchObject({ + grantId: grant.id, + outgoingPaymentId: secondPayment.id, + paymentReceiveAmountValue: secondPaymentAmount, + intervalReceiveAmountValue: + firstPaymentAmount + secondPaymentAmount, + grantTotalReceiveAmountValue: + firstPaymentAmount + secondPaymentAmount, + paymentDebitAmountValue: secondPaymentAmount, + intervalDebitAmountValue: + firstPaymentAmount + secondPaymentAmount, + grantTotalDebitAmountValue: + firstPaymentAmount + secondPaymentAmount, + intervalStart: firstInterval.start.toJSDate(), + intervalEnd: firstInterval.end.toJSDate() + }) + + // Move to interval 2 (February) + jest.setSystemTime(new Date('2025-02-01T12:00:00Z')) + jest.advanceTimersByTime(500) + + // Create payment 3 in interval 2 + const thirdPayment = await createAndFundGrantPayment( + thirdPaymentAmount, + grant + ) + jest.advanceTimersByTime(500) + + const secondInterval = getInterval( + grant.limits.interval, + new Date() + ) + assert(secondInterval) + assert(secondInterval.start) + assert(secondInterval.end) + + const thirdSpentAmounts = + await OutgoingPaymentGrantSpentAmounts.query(knex) + .where({ grantId: grant.id }) + .first() + assert(thirdSpentAmounts) + + // Payment 3 should only show this payment in interval amounts (new interval) but accumulated grant totals + expect(thirdSpentAmounts).toMatchObject({ + grantId: grant.id, + outgoingPaymentId: thirdPayment.id, + paymentReceiveAmountValue: thirdPaymentAmount, + intervalReceiveAmountValue: thirdPaymentAmount, + grantTotalReceiveAmountValue: + firstPaymentAmount + secondPaymentAmount + thirdPaymentAmount, + paymentDebitAmountValue: thirdPaymentAmount, + intervalDebitAmountValue: thirdPaymentAmount, + grantTotalDebitAmountValue: + firstPaymentAmount + secondPaymentAmount + thirdPaymentAmount, + intervalStart: secondInterval.start.toJSDate(), + intervalEnd: secondInterval.end.toJSDate() + }) + + // Complete payment 1 partially within the time period of interval 2 + jest.advanceTimersByTime(500) + jest + .spyOn(paymentMethodHandlerService, 'pay') + .mockImplementationOnce( + mockPayPartialFactory({ + debit: firstPaymentAmount, // pay shouldn't complete partially + receive: firstPaymentSettledAmount + })(accountingService, receiverWalletAddressId, firstPayment) + ) + + const processedPaymentId = + await outgoingPaymentService.processNext() + expect(processedPaymentId).toBe(firstPayment.id) + + const completedPayment = await outgoingPaymentService.get({ + id: firstPayment.id + }) + expect(completedPayment?.state).toBe( + OutgoingPaymentState.Completed + ) + + // Get the new spent amounts for payment 1 + const updatedFirstSpentAmounts = + await OutgoingPaymentGrantSpentAmounts.query(knex) + .where({ grantId: grant.id }) + .first() + assert(updatedFirstSpentAmounts) + + // New record should be created + expect(updatedFirstSpentAmounts.id).not.toBe(firstSpentAmounts.id) + + // The updated amounts should: + // - Use payment 1's original interval (January) + // - Have payment 1's settled amount for payment amounts + // - Have only payment 1's settled amount for interval amounts (payment 2 is in different interval) + // - Have payment 1's settled amount + payment 2's full amount for grant totals + expect(updatedFirstSpentAmounts).toMatchObject({ + grantId: grant.id, + outgoingPaymentId: firstPayment.id, + paymentReceiveAmountValue: firstPaymentSettledAmount, + intervalReceiveAmountValue: + secondPaymentAmount + firstPaymentSettledAmount, + grantTotalReceiveAmountValue: + firstPaymentSettledAmount + + secondPaymentAmount + + thirdPaymentAmount, + paymentDebitAmountValue: firstPaymentAmount, + intervalDebitAmountValue: + firstPaymentAmount + secondPaymentAmount, + grantTotalDebitAmountValue: + firstPaymentAmount + secondPaymentAmount + thirdPaymentAmount, + intervalStart: firstInterval.start.toJSDate(), + intervalEnd: firstInterval.end.toJSDate() + }) + } + ) + ) + test( + 'Payment Created in interval 1, payment 2 created in interval 1, payment 3 created in interval 2, payment 1 fails - should use correct interval amounts', + withConfigOverride( + () => config, + { + quoteLifespan: 2592000000, + incomingPaymentExpiryMaxMs: 2592000000 * 3, + slippage: 0 + }, + async (): Promise => { + const grant = { + id: uuid(), + limits: { + debitAmount: { + value: 1000n, + assetCode: asset.code, + assetScale: asset.scale + }, + // 1 month repeating interval starting Jan 1 + interval: 'R/2025-01-01T00:00:00Z/P1M' + } + } + const firstPaymentAmount = 100n + const secondPaymentAmount = 200n + const thirdPaymentAmount = 300n + + // Create payment 1 in interval 1 (January) + jest.setSystemTime(new Date('2025-01-15T12:00:00Z')) + const firstPayment = await createAndFundGrantPayment( + firstPaymentAmount, + grant + ) + jest.advanceTimersByTime(500) + + const firstInterval = getInterval( + grant.limits.interval, + new Date() + ) + assert(firstInterval) + assert(firstInterval.start) + assert(firstInterval.end) + + const firstSpentAmounts = + await OutgoingPaymentGrantSpentAmounts.query(knex) + .where({ grantId: grant.id }) + .first() + assert(firstSpentAmounts) + + expect(firstSpentAmounts).toMatchObject({ + grantId: grant.id, + outgoingPaymentId: firstPayment.id, + paymentReceiveAmountValue: firstPaymentAmount, + intervalReceiveAmountValue: firstPaymentAmount, + grantTotalReceiveAmountValue: firstPaymentAmount, + paymentDebitAmountValue: firstPaymentAmount, + intervalDebitAmountValue: firstPaymentAmount, + grantTotalDebitAmountValue: firstPaymentAmount, + intervalStart: firstInterval.start.toJSDate(), + intervalEnd: firstInterval.end.toJSDate() + }) + + // Create payment 2 in interval 1 (still January) + jest.setSystemTime(new Date('2025-01-20T12:00:00Z')) + jest.advanceTimersByTime(500) + + const secondPayment = await createAndFundGrantPayment( + secondPaymentAmount, + grant + ) + jest.advanceTimersByTime(500) + + const secondSpentAmounts = + await OutgoingPaymentGrantSpentAmounts.query(knex) + .where({ grantId: grant.id }) + .first() + assert(secondSpentAmounts) + + // Payment 2 should accumulate with payment 1 in same interval + expect(secondSpentAmounts).toMatchObject({ + grantId: grant.id, + outgoingPaymentId: secondPayment.id, + paymentReceiveAmountValue: secondPaymentAmount, + intervalReceiveAmountValue: + firstPaymentAmount + secondPaymentAmount, + grantTotalReceiveAmountValue: + firstPaymentAmount + secondPaymentAmount, + paymentDebitAmountValue: secondPaymentAmount, + intervalDebitAmountValue: + firstPaymentAmount + secondPaymentAmount, + grantTotalDebitAmountValue: + firstPaymentAmount + secondPaymentAmount, + intervalStart: firstInterval.start.toJSDate(), + intervalEnd: firstInterval.end.toJSDate() + }) + + // Move to interval 2 (February) + jest.setSystemTime(new Date('2025-02-01T12:00:00Z')) + jest.advanceTimersByTime(500) + + // Create payment 3 in interval 2 + const thirdPayment = await createAndFundGrantPayment( + thirdPaymentAmount, + grant + ) + jest.advanceTimersByTime(500) + + const secondInterval = getInterval( + grant.limits.interval, + new Date() + ) + assert(secondInterval) + assert(secondInterval.start) + assert(secondInterval.end) + + const thirdSpentAmounts = + await OutgoingPaymentGrantSpentAmounts.query(knex) + .where({ grantId: grant.id }) + .first() + assert(thirdSpentAmounts) + + // Payment 3 should only show this payment in interval amounts (new interval) but accumulated grant totals + expect(thirdSpentAmounts).toMatchObject({ + grantId: grant.id, + outgoingPaymentId: thirdPayment.id, + paymentReceiveAmountValue: thirdPaymentAmount, + intervalReceiveAmountValue: thirdPaymentAmount, + grantTotalReceiveAmountValue: + firstPaymentAmount + secondPaymentAmount + thirdPaymentAmount, + paymentDebitAmountValue: thirdPaymentAmount, + intervalDebitAmountValue: thirdPaymentAmount, + grantTotalDebitAmountValue: + firstPaymentAmount + secondPaymentAmount + thirdPaymentAmount, + intervalStart: secondInterval.start.toJSDate(), + intervalEnd: secondInterval.end.toJSDate() + }) + + // Fail payment 1 within the time period of interval 2 + jest.advanceTimersByTime(500) + jest + .spyOn(paymentMethodHandlerService, 'pay') + .mockImplementationOnce(mockPayErrorFactory()()) + + const processedPaymentId = + await outgoingPaymentService.processNext() + expect(processedPaymentId).toBe(firstPayment.id) + + const failedPayment = await outgoingPaymentService.get({ + id: firstPayment.id + }) + expect(failedPayment?.state).toBe(OutgoingPaymentState.Failed) + + // Get latest spent amounts, which should be from the first payment's failure + const failedSpentAmounts = + await OutgoingPaymentGrantSpentAmounts.query(knex) + .where({ grantId: grant.id }) + .first() + assert(failedSpentAmounts) + + // New record should be created + expect(failedSpentAmounts.id).not.toBe(firstSpentAmounts.id) + + // The updated amounts should: + // - Use payment 1's original interval (January) + // - Have 0 for payment 1's payment amounts (failed) + // - Have 0 for interval amounts (payment 1 was the only one in interval 1) + // - Have only payment 2's amount for grant totals + expect(failedSpentAmounts).toMatchObject({ + grantId: grant.id, + outgoingPaymentId: firstPayment.id, + paymentReceiveAmountValue: 0n, + intervalReceiveAmountValue: secondPaymentAmount, + grantTotalReceiveAmountValue: + secondPaymentAmount + thirdPaymentAmount, + paymentDebitAmountValue: 0n, + intervalDebitAmountValue: secondPaymentAmount, + grantTotalDebitAmountValue: + secondPaymentAmount + thirdPaymentAmount, + paymentState: OutgoingPaymentState.Failed, + intervalStart: firstInterval.start.toJSDate(), + intervalEnd: firstInterval.end.toJSDate() + }) + } + ) + ) + }) + }) + }) +}) diff --git a/packages/backend/src/open_payments/payment/outgoing/lifecycle.ts b/packages/backend/src/open_payments/payment/outgoing/lifecycle.ts index 5616223647..34f865a0fa 100644 --- a/packages/backend/src/open_payments/payment/outgoing/lifecycle.ts +++ b/packages/backend/src/open_payments/payment/outgoing/lifecycle.ts @@ -5,7 +5,11 @@ import { OutgoingPaymentEvent, OutgoingPaymentEventType } from './model' -import { ServiceDependencies } from './service' +import { + revertGrantSpentAmounts, + ServiceDependencies, + updateGrantSpentAmounts +} from './service' import { Receiver } from '../../receiver/model' import { TransactionOrKnex } from 'objection' import { ValueType } from '@opentelemetry/api' @@ -86,6 +90,8 @@ export async function handleSending( description: 'Time to complete a payment', callName: 'PaymentMethodHandlerService:pay' }) + let amountDelivered + let finalDebitAmount if (receiver.isLocal) { if ( !payment.quote.debitAmountMinusFees || @@ -99,22 +105,29 @@ export async function handleSending( ) throw LifecycleError.BadState } - await deps.paymentMethodHandlerService.pay('LOCAL', { + finalDebitAmount = payment.quote.debitAmountMinusFees + amountDelivered = await deps.paymentMethodHandlerService.pay('LOCAL', { receiver, outgoingPayment: payment, - finalDebitAmount: payment.quote.debitAmountMinusFees, + finalDebitAmount, finalReceiveAmount: maxReceiveAmount }) } else { - await deps.paymentMethodHandlerService.pay('ILP', { + finalDebitAmount = maxDebitAmount + amountDelivered = await deps.paymentMethodHandlerService.pay('ILP', { receiver, outgoingPayment: payment, - finalDebitAmount: maxDebitAmount, + finalDebitAmount, finalReceiveAmount: maxReceiveAmount }) } stopTimer() + await updateGrantSpentAmounts(deps, payment, { + debit: finalDebitAmount, + receive: amountDelivered + }) + await Promise.all([ deps.telemetry.incrementCounter('transactions_total', 1, { description: 'Count of funded transactions' @@ -168,10 +181,17 @@ export async function handleFailed( const stopTimer = deps.telemetry.startTimer('handle_failed_ms', { callName: 'OutgoingPaymentLifecycle:handleFailed' }) + const failedAt = new Date() await payment.$query(deps.knex).patch({ state: OutgoingPaymentState.Failed, - error + error, + updatedAt: failedAt }) + + if (payment.grantId) { + await revertGrantSpentAmounts(deps, payment) + } + await sendWebhookEvent(deps, payment, OutgoingPaymentEventType.PaymentFailed) stopTimer() } diff --git a/packages/backend/src/open_payments/payment/outgoing/model.ts b/packages/backend/src/open_payments/payment/outgoing/model.ts index f05615a1d5..2fd737c21c 100644 --- a/packages/backend/src/open_payments/payment/outgoing/model.ts +++ b/packages/backend/src/open_payments/payment/outgoing/model.ts @@ -16,6 +16,7 @@ import { OutgoingPayment as OpenPaymentsOutgoingPayment, OutgoingPaymentWithSpentAmounts } from '@interledger/open-payments' +import { BaseModel } from '../../../shared/baseModel' import { Tenant } from '../../../tenants/model' import { OutgoingPaymentCardDetails, @@ -28,6 +29,75 @@ export class OutgoingPaymentGrant extends DbErrors(Model) { } public static readonly tableName = 'outgoingPaymentGrants' public id!: string + public interval?: string +} + +export type OutgoingPaymentGrantSpentAmountsInput = { + id?: string + grantId: string + outgoingPaymentId: string + receiveAmountScale: number + receiveAmountCode: string + paymentReceiveAmountValue: number | string | bigint + intervalReceiveAmountValue?: number | string | bigint | null + grantTotalReceiveAmountValue: number | string | bigint + debitAmountScale: number + debitAmountCode: string + paymentDebitAmountValue: number | string | bigint + intervalDebitAmountValue?: number | string | bigint | null + grantTotalDebitAmountValue: number | string | bigint + paymentState: string + intervalStart?: Date | string | null + intervalEnd?: Date | string | null + createdAt?: Date | string +} + +export class OutgoingPaymentGrantSpentAmounts extends BaseModel { + public static get modelPaths(): string[] { + return [__dirname] + } + public static readonly tableName = 'outgoingPaymentGrantSpentAmounts' + + public id!: string + public grantId!: string + public outgoingPaymentId!: string + public receiveAmountScale!: number + public receiveAmountCode!: string + public paymentReceiveAmountValue!: bigint + public intervalReceiveAmountValue!: bigint | null + public grantTotalReceiveAmountValue!: bigint + public debitAmountScale!: number + public debitAmountCode!: string + public paymentDebitAmountValue!: bigint + public intervalDebitAmountValue!: bigint | null + public grantTotalDebitAmountValue!: bigint + public paymentState!: string + public intervalStart!: Date | null + public intervalEnd!: Date | null + public createdAt!: Date + + public static create( + data: OutgoingPaymentGrantSpentAmountsInput + ): OutgoingPaymentGrantSpentAmounts { + return this.fromJson({ + ...data, + paymentReceiveAmountValue: BigInt(data.paymentReceiveAmountValue), + intervalReceiveAmountValue: + data.intervalReceiveAmountValue != null + ? BigInt(data.intervalReceiveAmountValue) + : null, + grantTotalReceiveAmountValue: BigInt(data.grantTotalReceiveAmountValue), + paymentDebitAmountValue: BigInt(data.paymentDebitAmountValue), + intervalDebitAmountValue: + data.intervalDebitAmountValue != null + ? BigInt(data.intervalDebitAmountValue) + : null, + grantTotalDebitAmountValue: BigInt(data.grantTotalDebitAmountValue), + intervalStart: data.intervalStart ? new Date(data.intervalStart) : null, + intervalEnd: data.intervalEnd ? new Date(data.intervalEnd) : null, + createdAt: data.createdAt ? new Date(data.createdAt) : new Date() + }) + } } export class OutgoingPayment diff --git a/packages/backend/src/open_payments/payment/outgoing/routes.test.ts b/packages/backend/src/open_payments/payment/outgoing/routes.test.ts index c1662b82b2..6af020cfa6 100644 --- a/packages/backend/src/open_payments/payment/outgoing/routes.test.ts +++ b/packages/backend/src/open_payments/payment/outgoing/routes.test.ts @@ -19,8 +19,8 @@ import { } from './service' import { errorToHTTPCode, errorToMessage, OutgoingPaymentError } from './errors' import { OutgoingPayment, OutgoingPaymentState } from './model' -import { OutgoingPaymentRoutes, CreateBody } from './routes' -import { serializeAmount } from '../../amount' +import { OutgoingPaymentRoutes, CreateBody, GrantContext } from './routes' +import { Amount, serializeAmount } from '../../amount' import { Grant } from '../../auth/middleware' import { WalletAddress } from '../../wallet_address/model' import { @@ -316,4 +316,96 @@ describe('Outgoing Payment Routes', (): void => { } ) }) + describe('getGrantSpentAmounts', (): void => { + const createContext = ( + grantId?: string, + limits?: { debitAmount: Amount; receiveAmount: Amount } + ): GrantContext => { + return { + grant: { + id: grantId, + limits + }, + body: undefined + } as GrantContext + } + + test('returns spent amounts when grant has debit and receive amounts', async (): Promise => { + const grantId = uuid() + const limits = { + debitAmount: { + value: BigInt(1000), + assetCode: walletAddress.asset.code, + assetScale: walletAddress.asset.scale + }, + receiveAmount: { + value: BigInt(900), + assetCode: 'USD', + assetScale: 2 + } + } + const ctx = createContext(grantId, limits) + + const mockSpentAmounts = { + spentDebitAmount: { + value: BigInt(500), + assetCode: walletAddress.asset.code, + assetScale: walletAddress.asset.scale + }, + spentReceiveAmount: { + value: BigInt(450), + assetCode: 'USD', + assetScale: 2 + } + } + + const getGrantSpentAmountsSpy = jest + .spyOn(outgoingPaymentService, 'getGrantSpentAmounts') + .mockResolvedValueOnce(mockSpentAmounts) + + await outgoingPaymentRoutes.getGrantSpentAmounts(ctx) + + expect(getGrantSpentAmountsSpy).toHaveBeenCalledWith({ + grantId, + limits + }) + expect(ctx.body).toEqual({ + spentDebitAmount: { + value: '500', + assetCode: walletAddress.asset.code, + assetScale: walletAddress.asset.scale + }, + spentReceiveAmount: { + value: '450', + assetCode: 'USD', + assetScale: 2 + } + }) + }) + + test('returns null for spent amounts when no amounts have been spent', async (): Promise => { + const grantId = uuid() + const ctx = createContext(grantId) + + const mockSpentAmounts = { + spentDebitAmount: null, + spentReceiveAmount: null + } + + const getGrantSpentAmountsSpy = jest + .spyOn(outgoingPaymentService, 'getGrantSpentAmounts') + .mockResolvedValueOnce(mockSpentAmounts) + + await outgoingPaymentRoutes.getGrantSpentAmounts(ctx) + + expect(getGrantSpentAmountsSpy).toHaveBeenCalledWith({ + grantId, + limits: undefined + }) + expect(ctx.body).toEqual({ + spentDebitAmount: null, + spentReceiveAmount: null + }) + }) + }) }) diff --git a/packages/backend/src/open_payments/payment/outgoing/routes.ts b/packages/backend/src/open_payments/payment/outgoing/routes.ts index fd0829a839..2ceec7164f 100644 --- a/packages/backend/src/open_payments/payment/outgoing/routes.ts +++ b/packages/backend/src/open_payments/payment/outgoing/routes.ts @@ -1,5 +1,10 @@ import { Logger } from 'pino' -import { ReadContext, CreateContext, ListContext } from '../../../app' +import { + ReadContext, + CreateContext, + ListContext, + IntrospectionContext +} from '../../../app' import { IAppConfig } from '../../../config/app' import { CreateOutgoingPaymentOptions, @@ -19,7 +24,8 @@ import { } from '@interledger/open-payments' import { WalletAddress } from '../../wallet_address/model' import { OpenPaymentsServerRouteError } from '../../route-errors' -import { AmountJSON, parseAmount } from '../../amount' +import { AmountJSON, parseAmount, serializeAmount } from '../../amount' +import { Limits } from './limits' interface ServiceDependencies { config: IAppConfig @@ -29,6 +35,7 @@ interface ServiceDependencies { export interface OutgoingPaymentRoutes { get(ctx: ReadContext): Promise + getGrantSpentAmounts(ctx: GrantContext): Promise create(ctx: CreateContext): Promise list(ctx: ListContext): Promise } @@ -42,6 +49,8 @@ export function createOutgoingPaymentRoutes( const deps = { ...deps_, logger } return { get: (ctx: ReadContext) => getOutgoingPayment(deps, ctx), + getGrantSpentAmounts: (ctx: GrantContext) => + getOutgoingPaymentGrantSpentAmounts(deps, ctx), create: (ctx: CreateContext) => createOutgoingPayment(deps, ctx), list: (ctx: ListContext) => listOutgoingPayments(deps, ctx) @@ -71,6 +80,29 @@ async function getOutgoingPayment( ctx.body = outgoingPaymentToBody(deps, ctx.walletAddress, outgoingPayment) } +export interface GrantContext extends IntrospectionContext { + grant: { id: string; limits?: Limits } +} + +async function getOutgoingPaymentGrantSpentAmounts( + deps: ServiceDependencies, + ctx: GrantContext +): Promise { + const spentAmounts = await deps.outgoingPaymentService.getGrantSpentAmounts({ + grantId: ctx.grant.id, + limits: ctx.grant.limits + }) + + ctx.body = { + spentDebitAmount: spentAmounts.spentDebitAmount + ? serializeAmount(spentAmounts.spentDebitAmount) + : null, + spentReceiveAmount: spentAmounts.spentReceiveAmount + ? serializeAmount(spentAmounts.spentReceiveAmount) + : null + } +} + type CreateBodyBase = { walletAddress: string metadata?: Record diff --git a/packages/backend/src/open_payments/payment/outgoing/service.test.ts b/packages/backend/src/open_payments/payment/outgoing/service.test.ts index 3d05340a77..2ece3667cd 100644 --- a/packages/backend/src/open_payments/payment/outgoing/service.test.ts +++ b/packages/backend/src/open_payments/payment/outgoing/service.test.ts @@ -38,7 +38,8 @@ import { OutgoingPaymentState, PaymentData, OutgoingPaymentEvent, - OutgoingPaymentEventType + OutgoingPaymentEventType, + OutgoingPaymentGrantSpentAmounts } from './model' import { RETRY_BACKOFF_SECONDS } from './worker' import { IncomingPayment, IncomingPaymentState } from '../incoming/model' @@ -180,6 +181,8 @@ describe('OutgoingPaymentService', (): void => { debitAmount: args.finalDebitAmount, receiveAmount: args.finalReceiveAmount }) + + return args.finalReceiveAmount }) } @@ -678,6 +681,154 @@ describe('OutgoingPaymentService', (): void => { } } ) + + describe('Grant Spent Amounts', () => { + beforeEach(async (): Promise => { + jest.useFakeTimers() + }) + afterEach(async (): Promise => { + jest.useRealTimers() + }) + + test('should not create spent amounts record when cancelling payment without grant', async (): Promise => { + const payment = await createOutgoingPayment(deps, { + tenantId, + walletAddressId, + client, + receiver, + debitAmount: { + value: BigInt(100), + assetCode: asset.code, + assetScale: asset.scale + }, + validDestination: false, + method: 'ilp' + }) + + const cancelResult = await outgoingPaymentService.cancel({ + tenantId, + id: payment.id + }) + + assert.ok(cancelResult instanceof OutgoingPayment) + expect(cancelResult.state).toBe(OutgoingPaymentState.Cancelled) + + // Verify no spent amounts records exist + const spentAmounts = await OutgoingPaymentGrantSpentAmounts.query( + knex + ).where({ outgoingPaymentId: payment.id }) + + expect(spentAmounts).toHaveLength(0) + }) + + test('should revert grant spent amounts with interval when cancelling payment', async (): Promise => { + jest.setSystemTime(new Date('2025-01-02T00:00:00Z')) + + const grant: Grant = { + id: uuid(), + limits: { + debitAmount: { + value: BigInt(1000), + assetCode: asset.code, + assetScale: asset.scale + }, + interval: 'R/2025-01-01T00:00:00Z/P1M' + } + } + await OutgoingPaymentGrant.query(knex).insertAndFetch({ + id: grant.id + }) + + const paymentAmount = BigInt(100) + + // Create first payment + const firstPayment = await createOutgoingPayment(deps, { + tenantId, + walletAddressId, + client, + receiver, + debitAmount: { + value: paymentAmount, + assetCode: asset.code, + assetScale: asset.scale + }, + grant, + validDestination: false, + method: 'ilp' + }) + + jest.advanceTimersByTime(500) + + // Create second payment + const secondPaymentAmount = BigInt(200) + const secondPayment = await createOutgoingPayment(deps, { + tenantId, + walletAddressId, + client, + receiver: `${Config.openPaymentsUrl}/incoming-payments/${uuid()}`, + debitAmount: { + value: secondPaymentAmount, + assetCode: asset.code, + assetScale: asset.scale + }, + grant, + validDestination: false, + method: 'ilp' + }) + + jest.advanceTimersByTime(500) + + // Verify spent amounts before cancellation + const beforeCancelSpentAmounts = + await OutgoingPaymentGrantSpentAmounts.query(knex) + .where({ grantId: grant.id }) + .orderBy('createdAt', 'desc') + .first() + + assert(beforeCancelSpentAmounts) + expect(beforeCancelSpentAmounts).toMatchObject({ + grantId: grant.id, + outgoingPaymentId: secondPayment.id, + paymentDebitAmountValue: secondPaymentAmount, + intervalDebitAmountValue: paymentAmount + secondPaymentAmount, + grantTotalDebitAmountValue: paymentAmount + secondPaymentAmount, + paymentState: OutgoingPaymentState.Funding + }) + + // Cancel the second payment + const cancelResult = await outgoingPaymentService.cancel({ + tenantId, + id: secondPayment.id, + reason: 'Testing interval cancellation' + }) + + assert.ok(cancelResult instanceof OutgoingPayment) + expect(cancelResult.state).toBe(OutgoingPaymentState.Cancelled) + + // Verify spent amounts were reverted correctly + const afterCancelSpentAmounts = + await OutgoingPaymentGrantSpentAmounts.query(knex) + .where({ grantId: grant.id }) + .orderBy('createdAt', 'desc') + .first() + + assert(afterCancelSpentAmounts) + expect(afterCancelSpentAmounts.id).not.toBe(beforeCancelSpentAmounts.id) + expect(afterCancelSpentAmounts).toMatchObject({ + grantId: grant.id, + outgoingPaymentId: secondPayment.id, + paymentDebitAmountValue: 0n, + paymentReceiveAmountValue: 0n, + intervalDebitAmountValue: firstPayment.debitAmount.value, + intervalReceiveAmountValue: firstPayment.receiveAmount.value, + grantTotalDebitAmountValue: firstPayment.debitAmount.value, + grantTotalReceiveAmountValue: firstPayment.receiveAmount.value, + paymentState: OutgoingPaymentState.Cancelled, + intervalStart: expect.any(Date), + intervalEnd: expect.any(Date) + }) + }) + }) }) describe('create', (): void => { @@ -717,6 +868,9 @@ describe('OutgoingPaymentService', (): void => { debitAmount, method: 'ilp' }) + await expect( + OutgoingPaymentGrantSpentAmounts.query(knex) + ).resolves.toEqual([]) }) test( @@ -804,12 +958,43 @@ describe('OutgoingPaymentService', (): void => { grant } + // Must account for interledger/pay off-by-one issue (even with 0 slippage/fees) + const adjustedReceiveAmountValue = debitAmount.value - 1n + for (let i = 0; i < 3; i++) { const payment = await outgoingPaymentService.create(options) assert.ok(!isOutgoingPaymentError(payment)) + expect(payment.grantSpentReceiveAmount?.value ?? 0n).toBe( - // Must account for interledger/pay off-by-one issue (even with 0 slippage/fees) - BigInt((debitAmount.value - BigInt(1)) * BigInt(i)) + adjustedReceiveAmountValue * BigInt(i) + ) + + const spentAmounts = await OutgoingPaymentGrantSpentAmounts.query( + knex + ) + .where({ outgoingPaymentId: payment.id }) + .first() + assert(spentAmounts) + + expect(spentAmounts).toEqual( + expect.objectContaining({ + grantId: grant.id, + outgoingPaymentId: payment.id, + debitAmountCode: debitAmount.assetCode, + debitAmountScale: debitAmount.assetScale, + paymentDebitAmountValue: debitAmount.value, + grantTotalDebitAmountValue: debitAmount.value * BigInt(i + 1), + receiveAmountCode: debitAmount.assetCode, + receiveAmountScale: debitAmount.assetScale, + paymentReceiveAmountValue: adjustedReceiveAmountValue, + grantTotalReceiveAmountValue: + adjustedReceiveAmountValue * BigInt(i + 1), + intervalDebitAmountValue: null, + intervalReceiveAmountValue: null, + intervalStart: null, + intervalEnd: null, + paymentState: 'FUNDING' + }) ) } } @@ -1244,28 +1429,39 @@ describe('OutgoingPaymentService', (): void => { ).resolves.toEqual(OutgoingPaymentError.InsufficientGrant) }) test.each` - limits | description - ${{ debitAmount: { assetCode: 'EUR', assetScale: asset.scale } }} | ${'debitAmount asset code'} - ${{ debitAmount: { assetCode: asset.code, assetScale: 2 } }} | ${'debitAmount asset scale'} - ${{ receiveAmount: { assetCode: 'EUR', assetScale: destinationAsset.scale } }} | ${'receiveAmount asset code'} - ${{ receiveAmount: { assetCode: destinationAsset.code, assetScale: 2 } }} | ${'receiveAmount asset scale'} + limits | withInterval | description + ${{ debitAmount: { assetCode: 'EUR', assetScale: asset.scale } }} | ${true} | ${'debitAmount asset code with interval'} + ${{ debitAmount: { assetCode: asset.code, assetScale: 2 } }} | ${true} | ${'debitAmount asset scale with interval'} + ${{ receiveAmount: { assetCode: 'EUR', assetScale: destinationAsset.scale } }} | ${true} | ${'receiveAmount asset code with interval'} + ${{ receiveAmount: { assetCode: destinationAsset.code, assetScale: 2 } }} | ${true} | ${'receiveAmount asset scale with interval'} + ${{ debitAmount: { assetCode: 'EUR', assetScale: asset.scale } }} | ${false} | ${'debitAmount asset code without interval'} + ${{ debitAmount: { assetCode: asset.code, assetScale: 2 } }} | ${false} | ${'debitAmount asset scale without interval'} + ${{ receiveAmount: { assetCode: 'EUR', assetScale: destinationAsset.scale } }} | ${false} | ${'receiveAmount asset code without interval'} + ${{ receiveAmount: { assetCode: destinationAsset.code, assetScale: 2 } }} | ${false} | ${'receiveAmount asset scale without interval'} `( 'fails if grant limits do not match payment - $description', - async ({ limits }): Promise => { + async ({ limits, withInterval }): Promise => { assert.ok(grant) - grant.limits = { ...limits, interval } + const grantLimits = { ...limits } + if (withInterval) { + grantLimits.interval = interval + } + grant.limits = grantLimits + await expect( outgoingPaymentService.create({ ...options, grant }) ).resolves.toEqual(OutgoingPaymentError.InsufficientGrant) } ) test.each` - debitAmount | description - ${true} | ${'debitAmount'} - ${false} | ${'receiveAmount'} + debitAmount | withInterval | description + ${true} | ${true} | ${'debitAmount with interval'} + ${false} | ${true} | ${'receiveAmount with interval'} + ${true} | ${false} | ${'debitAmount without interval'} + ${false} | ${false} | ${'receiveAmount without interval'} `( 'fails if grant limit $description is not enough for payment', - async ({ debitAmount }): Promise => { + async ({ debitAmount, withInterval }): Promise => { const amount = { value: BigInt(12), assetCode: debitAmount @@ -1276,29 +1472,33 @@ describe('OutgoingPaymentService', (): void => { : quote.receiveAmount.assetScale } assert.ok(grant) - grant.limits = debitAmount - ? { - debitAmount: amount, - interval - } - : { - receiveAmount: amount, - interval - } + const limits: Grant['limits'] = debitAmount + ? { debitAmount: amount } + : { receiveAmount: amount } + + if (withInterval) { + limits.interval = interval + } + + grant.limits = limits await expect( outgoingPaymentService.create({ ...options, grant }) ).resolves.toEqual(OutgoingPaymentError.InsufficientGrant) } ) test.each` - debitAmount | failed | description - ${true} | ${false} | ${'debitAmount'} - ${false} | ${false} | ${'receiveAmount'} - ${true} | ${true} | ${'debitAmount, failed first payment'} - ${false} | ${true} | ${'receiveAmount, failed first payment'} + debitAmount | failed | withInterval | description + ${true} | ${false} | ${true} | ${'debitAmount with interval'} + ${false} | ${false} | ${true} | ${'receiveAmount with interval'} + ${true} | ${true} | ${true} | ${'debitAmount, failed first payment with interval'} + ${false} | ${true} | ${true} | ${'receiveAmount, failed first payment with interval'} + ${true} | ${false} | ${false} | ${'debitAmount without interval'} + ${false} | ${false} | ${false} | ${'receiveAmount without interval'} + ${true} | ${true} | ${false} | ${'debitAmount, failed first payment without interval'} + ${false} | ${true} | ${false} | ${'receiveAmount, failed first payment without interval'} `( 'fails if limit was already used up - $description', - async ({ debitAmount, failed }): Promise => { + async ({ debitAmount, failed, withInterval }): Promise => { const grantAmount = { value: BigInt(200), assetCode: debitAmount @@ -1308,30 +1508,38 @@ describe('OutgoingPaymentService', (): void => { ? quote.asset.scale : quote.receiveAmount.assetScale } + assert.ok(grant) - grant.limits = { - debitAmount: debitAmount ? grantAmount : undefined, - receiveAmount: debitAmount ? undefined : grantAmount, - interval + + const grantLimits: Grant['limits'] = debitAmount + ? { debitAmount: grantAmount } + : { receiveAmount: grantAmount } + + if (withInterval) { + grantLimits.interval = interval } + + grant.limits = grantLimits + const paymentAmount = { ...grantAmount, value: BigInt(190) } + const firstPayment = await createOutgoingPayment(deps, { tenantId, walletAddressId, client, - receiver: `${ - Config.openPaymentsUrl - }/incoming-payments/${uuid()}`, + receiver: `${Config.openPaymentsUrl}/incoming-payments/${uuid()}`, debitAmount: debitAmount ? paymentAmount : undefined, receiveAmount: debitAmount ? undefined : paymentAmount, grant, validDestination: false, method: 'ilp' }) + assert.ok(firstPayment) + if (failed) { await firstPayment .$query(knex) @@ -1365,22 +1573,31 @@ describe('OutgoingPaymentService', (): void => { ) test.each` - debitAmount | competingPayment | failed | half | description - ${true} | ${false} | ${undefined} | ${false} | ${'debitAmount w/o competing payment'} - ${false} | ${false} | ${undefined} | ${false} | ${'receiveAmount w/o competing payment'} - ${true} | ${true} | ${false} | ${false} | ${'debitAmount w/ competing payment'} - ${false} | ${true} | ${false} | ${false} | ${'receiveAmount w/ competing payment'} - ${true} | ${true} | ${true} | ${false} | ${'debitAmount w/ failed competing payment'} - ${false} | ${true} | ${true} | ${false} | ${'receiveAmount w/ failed competing payment'} - ${true} | ${true} | ${true} | ${true} | ${'debitAmount w/ half-way failed competing payment'} - ${false} | ${true} | ${true} | ${true} | ${'receiveAmount half-way w/ failed competing payment'} + debitAmount | competingPayment | failed | half | withInterval | description + ${true} | ${false} | ${undefined} | ${false} | ${true} | ${'debitAmount w/o competing payment with interval'} + ${false} | ${false} | ${undefined} | ${false} | ${true} | ${'receiveAmount w/o competing payment with interval'} + ${true} | ${true} | ${false} | ${false} | ${true} | ${'debitAmount w/ competing payment with interval'} + ${false} | ${true} | ${false} | ${false} | ${true} | ${'receiveAmount w/ competing payment with interval'} + ${true} | ${true} | ${true} | ${false} | ${true} | ${'debitAmount w/ failed competing payment with interval'} + ${false} | ${true} | ${true} | ${false} | ${true} | ${'receiveAmount w/ failed competing payment with interval'} + ${true} | ${true} | ${true} | ${true} | ${true} | ${'debitAmount w/ half-way failed competing payment with interval'} + ${false} | ${true} | ${true} | ${true} | ${true} | ${'receiveAmount half-way w/ failed competing payment with interval'} + ${true} | ${false} | ${undefined} | ${false} | ${false} | ${'debitAmount w/o competing payment without interval'} + ${false} | ${false} | ${undefined} | ${false} | ${false} | ${'receiveAmount w/o competing payment without interval'} + ${true} | ${true} | ${false} | ${false} | ${false} | ${'debitAmount w/ competing payment without interval'} + ${false} | ${true} | ${false} | ${false} | ${false} | ${'receiveAmount w/ competing payment without interval'} + ${true} | ${true} | ${true} | ${false} | ${false} | ${'debitAmount w/ failed competing payment without interval'} + ${false} | ${true} | ${true} | ${false} | ${false} | ${'receiveAmount w/ failed competing payment without interval'} + ${true} | ${true} | ${true} | ${true} | ${false} | ${'debitAmount w/ half-way failed competing payment without interval'} + ${false} | ${true} | ${true} | ${true} | ${false} | ${'receiveAmount half-way w/ failed competing payment without interval'} `( 'succeeds if grant limit is enough for payment - $description', async ({ debitAmount, competingPayment, failed, - half + half, + withInterval }): Promise => { const grantAmount = { value: BigInt(1234567), @@ -1391,39 +1608,44 @@ describe('OutgoingPaymentService', (): void => { ? quote.asset.scale : quote.receiveAmount.assetScale } + assert.ok(grant) - grant.limits = debitAmount - ? { - debitAmount: grantAmount, - interval - } - : { - receiveAmount: grantAmount, - interval - } + + const limits: Grant['limits'] = debitAmount + ? { debitAmount: grantAmount } + : { receiveAmount: grantAmount } + + if (withInterval) { + limits.interval = interval + } + + grant.limits = limits + if (competingPayment) { const paymentAmount = { ...grantAmount, value: BigInt(7) } + const firstPayment = await createOutgoingPayment(deps, { tenantId, walletAddressId, client, - receiver: `${ - Config.openPaymentsUrl - }/incoming-payments/${uuid()}`, + receiver: `${Config.openPaymentsUrl}/incoming-payments/${uuid()}`, debitAmount: debitAmount ? paymentAmount : undefined, receiveAmount: debitAmount ? undefined : paymentAmount, grant, validDestination: false, method: 'ilp' }) + assert.ok(firstPayment) + if (failed) { await firstPayment .$query(knex) .patch({ state: OutgoingPaymentState.Failed }) + if (half) { jest .spyOn(accountingService, 'getTotalSent') @@ -1431,6 +1653,7 @@ describe('OutgoingPaymentService', (): void => { } } } + await expect( outgoingPaymentService.create({ ...options, grant }) ).resolves.toBeInstanceOf(OutgoingPayment) @@ -1440,6 +1663,381 @@ describe('OutgoingPaymentService', (): void => { } }) + describe('legacy grant spent amounts calculated from history of payments', (): void => { + let grant: Grant + let client: string + + beforeEach(async (): Promise => { + // setup existing grant + grant = { + id: uuid() + } + client = faker.internet.url({ appendSlash: false }) + await OutgoingPaymentGrant.query(knex).insertAndFetch({ + id: grant.id + }) + }) + + test('without interval', async (): Promise => { + // amount limit only, no interval + grant.limits = { + debitAmount: { + value: BigInt(1000), + assetCode: 'USD', + assetScale: 9 + } + } + + const legacyPayment1Amount = BigInt(100) + const legacyPayment2Amount = BigInt(150) + const newPaymentAmount = BigInt(200) + + await createOutgoingPayment(deps, { + tenantId, + walletAddressId, + client, + receiver: `${Config.openPaymentsUrl}/incoming-payments/${uuid()}`, + debitAmount: { + value: legacyPayment1Amount, + assetCode: 'USD', + assetScale: 9 + }, + grant, + validDestination: false, + method: 'ilp' + }) + await createOutgoingPayment(deps, { + tenantId, + walletAddressId, + client, + receiver: `${Config.openPaymentsUrl}/incoming-payments/${uuid()}`, + debitAmount: { + value: legacyPayment2Amount, + assetCode: 'USD', + assetScale: 9 + }, + grant, + validDestination: false, + method: 'ilp' + }) + + // remove spent amounts records to simulate a grant that existed before + // tracking spent amounts via OutgoingPaymentGrantSpentAmounts + await OutgoingPaymentGrantSpentAmounts.query(knex) + .where('grantId', grant.id) + .delete() + + const quote = await createQuote(deps, { + tenantId, + walletAddressId, + receiver, + debitAmount: { + value: newPaymentAmount, + assetCode: 'USD', + assetScale: 9 + }, + method: 'ilp' + }) + const payment = await outgoingPaymentService.create({ + tenantId, + walletAddressId, + client, + quoteId: quote.id, + grant + }) + assert.ok(!isOutgoingPaymentError(payment)) + + expect(payment.grantSpentDebitAmount?.value).toBe( + legacyPayment1Amount + legacyPayment2Amount + ) + const spentAmounts = await OutgoingPaymentGrantSpentAmounts.query(knex) + .where({ outgoingPaymentId: payment.id }) + .first() + assert(spentAmounts) + expect(spentAmounts).toEqual( + expect.objectContaining({ + grantId: grant.id, + outgoingPaymentId: payment.id, + grantTotalDebitAmountValue: + legacyPayment1Amount + legacyPayment2Amount + newPaymentAmount, + intervalDebitAmountValue: null, + intervalReceiveAmountValue: null, + intervalStart: null, + intervalEnd: null, + paymentState: 'FUNDING' + }) + ) + }) + + test('with interval', async (): Promise => { + const start = new Date(Date.now() - 5 * 24 * 60 * 60 * 1000) // 5 days ago + const interval = `R0/${start.toISOString()}/P1M` + + // with amount and interval limits + grant.limits = { + debitAmount: { + value: BigInt(1000), + assetCode: 'USD', + assetScale: 9 + }, + interval + } + + const legacyPaymentInIntervalAmount = BigInt(100) + const legacyPaymentBeforeIntervalAmount = BigInt(75) + const newPaymentAmount = BigInt(200) + + // legacy payment in interval + await createOutgoingPayment(deps, { + tenantId, + walletAddressId, + client, + receiver: `${Config.openPaymentsUrl}/incoming-payments/${uuid()}`, + debitAmount: { + value: legacyPaymentInIntervalAmount, + assetCode: 'USD', + assetScale: 9 + }, + grant, + validDestination: false, + method: 'ilp' + }) + const legacyPaymentBeforeInterval = await createOutgoingPayment(deps, { + tenantId, + walletAddressId, + client, + receiver: `${Config.openPaymentsUrl}/incoming-payments/${uuid()}`, + debitAmount: { + value: legacyPaymentBeforeIntervalAmount, + assetCode: 'USD', + assetScale: 9 + }, + grant, + validDestination: false, + method: 'ilp' + }) + + // manually set to be outside interval + const oldDate = new Date(start.getTime() - 50 * 24 * 60 * 60 * 1000) // 50 days before interval start + await legacyPaymentBeforeInterval + .$query(knex) + .patch({ createdAt: oldDate }) + + // remove spent amounts records to simulate a grant that existed before + // tracking spent amounts via OutgoingPaymentGrantSpentAmounts + await OutgoingPaymentGrantSpentAmounts.query(knex) + .where('grantId', grant.id) + .delete() + + const quote = await createQuote(deps, { + tenantId, + walletAddressId, + receiver, + debitAmount: { + value: newPaymentAmount, + assetCode: 'USD', + assetScale: 9 + }, + method: 'ilp' + }) + const payment = await outgoingPaymentService.create({ + tenantId, + walletAddressId, + client, + quoteId: quote.id, + grant + }) + + assert.ok(!isOutgoingPaymentError(payment)) + + // should not include out of interval payment + expect(payment.grantSpentDebitAmount?.value).toBe( + legacyPaymentInIntervalAmount + ) + + const spentAmounts = await OutgoingPaymentGrantSpentAmounts.query(knex) + .where({ outgoingPaymentId: payment.id }) + .first() + assert(spentAmounts) + expect(spentAmounts).toEqual( + expect.objectContaining({ + grantId: grant.id, + outgoingPaymentId: payment.id, + // all legacy payments and new payment + grantTotalDebitAmountValue: + legacyPaymentInIntervalAmount + + legacyPaymentBeforeIntervalAmount + + newPaymentAmount, + // all legacy payments and new payments in current interval + intervalDebitAmountValue: + legacyPaymentInIntervalAmount + newPaymentAmount, + intervalStart: expect.any(Date), + intervalEnd: expect.any(Date), + paymentState: 'FUNDING' + }) + ) + }) + + test('with failed payments - correctly handles partially sent amounts', async (): Promise => { + grant.limits = { + debitAmount: { + value: BigInt(1000), + assetCode: 'USD', + assetScale: 9 + } + } + + const successfulPaymentAmount = BigInt(100) + const failedPaymentRequestedAmount = BigInt(200) + const failedPaymentActualSentAmount = BigInt(150) + const newPaymentAmount = BigInt(50) + + // successful payment + await createOutgoingPayment(deps, { + tenantId, + walletAddressId, + client, + receiver: `${Config.openPaymentsUrl}/incoming-payments/${uuid()}`, + debitAmount: { + value: successfulPaymentAmount, + assetCode: 'USD', + assetScale: 9 + }, + grant, + validDestination: false, + method: 'ilp' + }) + + const failedPayment = await createOutgoingPayment(deps, { + tenantId, + walletAddressId, + client, + receiver: `${Config.openPaymentsUrl}/incoming-payments/${uuid()}`, + debitAmount: { + value: failedPaymentRequestedAmount, + assetCode: 'USD', + assetScale: 9 + }, + grant, + validDestination: false, + method: 'ilp' + }) + await failedPayment.$query(knex).patch({ + state: OutgoingPaymentState.Failed + }) + + // return partial amount for failed payment + const mockGetTotalSent = jest.spyOn(accountingService, 'getTotalSent') + mockGetTotalSent.mockResolvedValueOnce(failedPaymentActualSentAmount) + + // remove spent amounts records to simulate a grant that existed before + // tracking spent amounts via OutgoingPaymentGrantSpentAmounts + await OutgoingPaymentGrantSpentAmounts.query(knex) + .where('grantId', grant.id) + .delete() + + const quote = await createQuote(deps, { + tenantId, + walletAddressId, + receiver, + debitAmount: { + value: newPaymentAmount, + assetCode: 'USD', + assetScale: 9 + }, + method: 'ilp' + }) + + const payment = await outgoingPaymentService.create({ + tenantId, + walletAddressId, + client, + quoteId: quote.id, + grant + }) + + assert.ok(!isOutgoingPaymentError(payment)) + + const expectedTotalSpent = + successfulPaymentAmount + failedPaymentActualSentAmount + const expectedGrantTotal = expectedTotalSpent + newPaymentAmount + + expect(payment.grantSpentDebitAmount?.value).toBe(expectedTotalSpent) + + const spentAmounts = await OutgoingPaymentGrantSpentAmounts.query(knex) + .where({ outgoingPaymentId: payment.id }) + .first() + + assert(spentAmounts) + expect(spentAmounts.grantTotalDebitAmountValue).toBe(expectedGrantTotal) + expect(mockGetTotalSent).toHaveBeenCalledWith(failedPayment.id) + }) + + test('with failed payment that sent nothing - excludes from spent amounts', async (): Promise => { + grant.limits = { + debitAmount: { + value: BigInt(1000), + assetCode: 'USD', + assetScale: 9 + } + } + + const failedPayment = await createOutgoingPayment(deps, { + tenantId, + walletAddressId, + client, + receiver: `${Config.openPaymentsUrl}/incoming-payments/${uuid()}`, + debitAmount: { + value: BigInt(200), + assetCode: 'USD', + assetScale: 9 + }, + grant, + validDestination: false, + method: 'ilp' + }) + + await failedPayment.$query(knex).patch({ + state: OutgoingPaymentState.Failed + }) + + const mockGetTotalSent = jest.spyOn(accountingService, 'getTotalSent') + mockGetTotalSent.mockResolvedValueOnce(BigInt(0)) + + // remove spent amounts records to simulate a grant that existed before + // tracking spent amounts via OutgoingPaymentGrantSpentAmounts + await OutgoingPaymentGrantSpentAmounts.query(knex) + .where('grantId', grant.id) + .delete() + + const quote = await createQuote(deps, { + tenantId, + walletAddressId, + receiver, + debitAmount: { + value: BigInt(50), + assetCode: 'USD', + assetScale: 9 + }, + method: 'ilp' + }) + + const payment = await outgoingPaymentService.create({ + tenantId, + walletAddressId, + client, + quoteId: quote.id, + grant + }) + + assert.ok(!isOutgoingPaymentError(payment)) + + // failed payment should have sent nothing + expect(payment.grantSpentDebitAmount?.value).toBe(BigInt(0)) + expect(mockGetTotalSent).toHaveBeenCalledWith(failedPayment.id) + }) + }) + test( 'fails to create when both debitAmount and receiveAmount are set to grant limits', withConfigOverride( @@ -2342,4 +2940,375 @@ describe('OutgoingPaymentService', (): void => { ) ) }) + + describe('getGrantSpentAmounts', (): void => { + let grant: Grant + let debitAmountOptions: Amount + + beforeEach(async (): Promise => { + grant = { + id: uuid() + } + debitAmountOptions = { + value: BigInt(1000), + assetCode: asset.code, + assetScale: asset.scale + } + }) + + describe('without interval', (): void => { + test('handles non-existent grant', async (): Promise => { + const nonExistentGrantId = uuid() + + const result = await outgoingPaymentService.getGrantSpentAmounts({ + grantId: nonExistentGrantId + }) + + expect(result).toEqual({ + spentDebitAmount: null, + spentReceiveAmount: null + }) + }) + + test('returns null amounts when no spent amounts records exist', async (): Promise => { + const result = await outgoingPaymentService.getGrantSpentAmounts({ + grantId: grant.id + }) + + expect(result).toEqual({ + spentDebitAmount: null, + spentReceiveAmount: null + }) + }) + + test('uses legacy path and calculates amounts correctly when grant exists but no spent amounts records', async (): Promise => { + grant.limits = { + debitAmount: debitAmountOptions + } + await OutgoingPaymentGrant.query(knex).insert({ id: grant.id }) + + const payment1 = await createOutgoingPayment(deps, { + tenantId, + walletAddressId, + client, + receiver: `${Config.openPaymentsUrl}/incoming-payments/${uuid()}`, + debitAmount: { + value: BigInt(100), + assetCode: asset.code, + assetScale: asset.scale + }, + grant, + validDestination: false, + method: 'ilp' + }) + + const payment2 = await createOutgoingPayment(deps, { + tenantId, + walletAddressId, + client, + receiver: `${Config.openPaymentsUrl}/incoming-payments/${uuid()}`, + debitAmount: { + value: BigInt(150), + assetCode: asset.code, + assetScale: asset.scale + }, + grant, + validDestination: false, + method: 'ilp' + }) + + assert.ok(!isOutgoingPaymentError(payment1)) + assert.ok(!isOutgoingPaymentError(payment2)) + + // Remove spent amounts records to simulate a grant that existed before + // tracking spent amounts via OutgoingPaymentGrantSpentAmounts + await OutgoingPaymentGrantSpentAmounts.query(knex) + .where('grantId', grant.id) + .delete() + const spentAmountsBefore = await OutgoingPaymentGrantSpentAmounts.query( + knex + ) + .where('grantId', grant.id) + .first() + expect(spentAmountsBefore).toBeUndefined() + + const result = await outgoingPaymentService.getGrantSpentAmounts({ + grantId: grant.id, + limits: grant.limits + }) + + // should calculate from historical payments: 100 + 150 = 250 debit + expect(result.spentDebitAmount).not.toBeNull() + expect(result.spentReceiveAmount).not.toBeNull() + expect(result.spentDebitAmount?.value).toBe(BigInt(250)) + expect(result.spentDebitAmount?.assetCode).toBe(asset.code) + expect(result.spentDebitAmount?.assetScale).toBe(asset.scale) + expect(result.spentReceiveAmount?.value).toBe( + payment1.quote.receiveAmount.value + + payment2.quote.receiveAmount.value + ) + expect(result.spentReceiveAmount?.assetCode).toBe( + payment2.quote.receiveAmount.assetCode + ) + expect(result.spentReceiveAmount?.assetScale).toBe( + payment2.quote.receiveAmount.assetScale + ) + }) + + test('returns total grant amounts from latest record', async (): Promise => { + grant.limits = { + debitAmount: debitAmountOptions + } + await createOutgoingPayment(deps, { + tenantId, + walletAddressId, + client, + receiver: `${Config.openPaymentsUrl}/incoming-payments/${uuid()}`, + debitAmount: { + value: BigInt(100), + assetCode: asset.code, + assetScale: asset.scale + }, + grant, + validDestination: false, + method: 'ilp' + }) + + const payment2 = await createOutgoingPayment(deps, { + tenantId, + walletAddressId, + client, + receiver: `${Config.openPaymentsUrl}/incoming-payments/${uuid()}`, + debitAmount: { + value: BigInt(150), + assetCode: asset.code, + assetScale: asset.scale + }, + grant, + validDestination: false, + method: 'ilp' + }) + + const result = await outgoingPaymentService.getGrantSpentAmounts({ + grantId: grant.id, + limits: grant.limits + }) + + const latestSpentAmounts = await OutgoingPaymentGrantSpentAmounts.query( + knex + ) + .where({ outgoingPaymentId: payment2.id }) + .first() + + assert(latestSpentAmounts) + expect(result).toEqual({ + spentDebitAmount: { + value: latestSpentAmounts.grantTotalDebitAmountValue, + assetCode: latestSpentAmounts.debitAmountCode, + assetScale: latestSpentAmounts.debitAmountScale + }, + spentReceiveAmount: { + value: latestSpentAmounts.grantTotalReceiveAmountValue, + assetCode: latestSpentAmounts.receiveAmountCode, + assetScale: latestSpentAmounts.receiveAmountScale + } + }) + }) + }) + + describe('with interval', (): void => { + let interval: string + let intervalStart: Date + + beforeEach((): void => { + intervalStart = new Date(Date.now() - 5 * 24 * 60 * 60 * 1000) // 5 days ago + interval = `R0/${intervalStart.toISOString()}/P1M` + }) + + test('returns interval amounts from current interval', async (): Promise => { + grant.limits = { + debitAmount: debitAmountOptions, + interval + } + const payment = await createOutgoingPayment(deps, { + tenantId, + walletAddressId, + client, + receiver: `${Config.openPaymentsUrl}/incoming-payments/${uuid()}`, + debitAmount: { + value: BigInt(100), + assetCode: asset.code, + assetScale: asset.scale + }, + grant, + validDestination: false, + method: 'ilp' + }) + + const result = await outgoingPaymentService.getGrantSpentAmounts({ + grantId: grant.id, + limits: grant.limits + }) + + const spentAmounts = await OutgoingPaymentGrantSpentAmounts.query(knex) + .where({ outgoingPaymentId: payment.id }) + .first() + + assert(spentAmounts) + expect(result).toEqual({ + spentDebitAmount: { + value: spentAmounts.intervalDebitAmountValue ?? BigInt(0), + assetCode: spentAmounts.debitAmountCode, + assetScale: spentAmounts.debitAmountScale + }, + spentReceiveAmount: { + value: spentAmounts.intervalReceiveAmountValue ?? BigInt(0), + assetCode: spentAmounts.receiveAmountCode, + assetScale: spentAmounts.receiveAmountScale + } + }) + }) + + test('returns latest interval amounts when multiple payments in current interval', async (): Promise => { + grant.limits = { + debitAmount: debitAmountOptions, + interval + } + await createOutgoingPayment(deps, { + tenantId, + walletAddressId, + client, + receiver: `${Config.openPaymentsUrl}/incoming-payments/${uuid()}`, + debitAmount: { + value: BigInt(100), + assetCode: asset.code, + assetScale: asset.scale + }, + grant, + validDestination: false, + method: 'ilp' + }) + + await createOutgoingPayment(deps, { + tenantId, + walletAddressId, + client, + receiver: `${Config.openPaymentsUrl}/incoming-payments/${uuid()}`, + debitAmount: { + value: BigInt(150), + assetCode: asset.code, + assetScale: asset.scale + }, + grant, + validDestination: false, + method: 'ilp' + }) + + const result = await outgoingPaymentService.getGrantSpentAmounts({ + grantId: grant.id, + limits: grant.limits + }) + + const latestSpentAmounts = await OutgoingPaymentGrantSpentAmounts.query( + knex + ) + .where({ grantId: grant.id }) + .first() + assert(latestSpentAmounts) + + expect(result).toEqual({ + spentDebitAmount: { + value: latestSpentAmounts.intervalDebitAmountValue ?? BigInt(0), + assetCode: latestSpentAmounts.debitAmountCode, + assetScale: latestSpentAmounts.debitAmountScale + }, + spentReceiveAmount: { + value: latestSpentAmounts.intervalReceiveAmountValue ?? BigInt(0), + assetCode: latestSpentAmounts.receiveAmountCode, + assetScale: latestSpentAmounts.receiveAmountScale + } + }) + }) + + test('returns null when payments exist but outside current interval', async (): Promise => { + jest.useFakeTimers() + + try { + const startDate = new Date('2024-01-15T00:00:00Z') + jest.setSystemTime(startDate) + + // monthly repeating interval + const monthlyInterval = `R/${startDate.toISOString()}/P1M` + grant.limits = { + debitAmount: debitAmountOptions, + interval: monthlyInterval + } + + // payment in the first interval + const payment = await createOutgoingPayment(deps, { + tenantId, + walletAddressId, + client, + receiver: `${Config.openPaymentsUrl}/incoming-payments/${uuid()}`, + debitAmount: { + value: BigInt(100), + assetCode: asset.code, + assetScale: asset.scale + }, + grant, + validDestination: false, + method: 'ilp' + }) + + const latestSpentAmounts = + await OutgoingPaymentGrantSpentAmounts.query(knex) + .where({ grantId: grant.id }) + .first() + assert(latestSpentAmounts) + + // advance time by 35 days to move into the next interval + jest.advanceTimersByTime(35 * 24 * 60 * 60 * 1000) + + // should find no records for current interval + const result = await outgoingPaymentService.getGrantSpentAmounts({ + grantId: grant.id, + limits: { + debitAmount: debitAmountOptions, + interval: monthlyInterval + } + }) + + expect(result).toEqual({ + spentDebitAmount: { + value: 0n, + assetCode: asset.code, + assetScale: asset.scale + }, + spentReceiveAmount: { + value: 0n, + assetCode: payment.receiveAmount.assetCode, + assetScale: payment.receiveAmount.assetScale + } + }) + } finally { + jest.useRealTimers() + } + }) + + test('returns null when interval is invalid', async (): Promise => { + const result = await outgoingPaymentService.getGrantSpentAmounts({ + grantId: grant.id, + limits: { + debitAmount: debitAmountOptions, + interval: 'invalid-interval' + } + }) + + expect(result).toEqual({ + spentDebitAmount: null, + spentReceiveAmount: null + }) + }) + }) + }) }) diff --git a/packages/backend/src/open_payments/payment/outgoing/service.ts b/packages/backend/src/open_payments/payment/outgoing/service.ts index b0051faf62..af3eb68714 100644 --- a/packages/backend/src/open_payments/payment/outgoing/service.ts +++ b/packages/backend/src/open_payments/payment/outgoing/service.ts @@ -32,7 +32,7 @@ import { } from '../../wallet_address/service' import { sendWebhookEvent } from './lifecycle' import * as worker from './worker' -import { Interval } from 'luxon' +import { DateTime, Interval } from 'luxon' import { knex } from 'knex' import { AccountAlreadyExistsError } from '../../../accounting/errors' import { PaymentMethodHandlerService } from '../../../payment-method/handler/service' @@ -46,9 +46,18 @@ import { IAppConfig } from '../../../config/app' import { AssetService } from '../../../asset/service' import { Span, trace } from '@opentelemetry/api' import { FeeService } from '../../../fee/service' +import { OutgoingPaymentGrantSpentAmounts } from './model' +import { v4 as uuid } from 'uuid' import { OutgoingPaymentCardDetails } from './card/model' import { encryptDbData } from '../../../shared/utils' +const DEFAULT_GRANT_LOCK_TIMEOUT_MS = 5000 + +export interface GrantSpentAmounts { + spentDebitAmount: Amount | null + spentReceiveAmount: Amount | null +} + export interface OutgoingPaymentService extends WalletAddressSubresourceService { getPage(options?: GetPageOptions): Promise @@ -62,6 +71,10 @@ export interface OutgoingPaymentService options: FundOutgoingPaymentOptions ): Promise processNext(): Promise + getGrantSpentAmounts(options: { + grantId: string + limits?: Limits + }): Promise } export interface ServiceDependencies extends BaseService { @@ -91,7 +104,8 @@ export async function createOutgoingPaymentService( cancel: (options) => cancelOutgoingPayment(deps, options), fund: (options) => fundPayment(deps, options), processNext: () => worker.processPendingPayment(deps), - getWalletAddressPage: (options) => getWalletAddressPage(deps, options) + getWalletAddressPage: (options) => getWalletAddressPage(deps, options), + getGrantSpentAmounts: (options) => getGrantSpentAmounts(deps, options) } } @@ -272,6 +286,9 @@ async function cancelOutgoingPayment( payment.walletAddress = await deps.walletAddressService.get( payment.walletAddressId ) + if (payment.grantId) { + await revertGrantSpentAmounts({ ...deps, knex: trx }, payment) + } if (payment.initiatedBy === OutgoingPaymentInitiationReason.Card) { await sendWebhookEvent( @@ -376,21 +393,18 @@ async function createOutgoingPayment( } const payment = await OutgoingPayment.transaction(async (trx) => { + let existingGrant: OutgoingPaymentGrant | undefined + if (grantId) { - const stopTimerGrant = deps.telemetry.startTimer( - 'outgoing_payment_service_insertgrant_time_ms', - { - callName: 'OutgoingPaymentGrantModel:insert', - description: 'Time to insert grant in outgoing payment' - } - ) - await OutgoingPaymentGrant.query(trx) - .insert({ - id: grantId - }) - .onConflict('id') - .ignore() - stopTimerGrant() + existingGrant = + await OutgoingPaymentGrant.query(trx).findById(grantId) + + if (!existingGrant) { + await OutgoingPaymentGrant.query(trx) + .insert({ id: grantId }) + .onConflict('id') + .ignore() + } } const stopTimerInsertPayment = deps.telemetry.startTimer( 'outgoing_payment_service_insertpayment_time_ms', @@ -417,6 +431,7 @@ async function createOutgoingPayment( metadata: options.metadata, state: OutgoingPaymentState.Funding, grantId, + createdAt: new Date(), initiatedBy }) @@ -455,14 +470,19 @@ async function createOutgoingPayment( description: 'Time to validate a grant' } ) + const isValid = await validateGrantAndAddSpentAmountsToPayment( deps, - payment, - options.grant, - trx, - options.callback, - options.grantLockTimeoutMs + { + payment, + grant: options.grant, + trx, + callback: options.callback, + isExistingGrant: Boolean(existingGrant), + grantLockTimeoutMs: options.grantLockTimeoutMs + } ) + stopTimerValidateGrant() if (!isValid) { throw OutgoingPaymentError.InsufficientGrant @@ -551,20 +571,10 @@ function validateAccessLimits( } } -function validatePaymentInterval({ - limits, - payment -}: { - limits: PaymentLimits - payment: OutgoingPayment -}): boolean { - return ( - !limits.paymentInterval || - (limits.paymentInterval.start !== null && - limits.paymentInterval.start.toMillis() <= payment.createdAt.getTime() && - limits.paymentInterval.end !== null && - payment.createdAt.getTime() < limits.paymentInterval.end.toMillis()) - ) +export enum IntervalStatus { + Previous = 'previous', + Current = 'current', + Next = 'next' } function validateAmountAssets( @@ -588,19 +598,148 @@ function validateAmountAssets( ) } -interface PaymentLimits extends Limits { +export interface PaymentLimits extends Limits { paymentInterval?: Interval } -// "payment" is locked by the "deps.knex" transaction. +/** + * Legacy path: calculates spent amounts from historical payments. + * Used when a grant exists with outgoing payments made against it, but there are no + * spent amounts records yet (e.g., during migration/deployment). + */ +export async function calculateLegacyGrantSpentAmounts( + deps: ServiceDependencies, + args: { + grantId: string + trx: TransactionOrKnex + paymentLimits?: PaymentLimits + excludePaymentId?: string + } +): Promise<{ + grantTotalDebitAmountValue: bigint + grantTotalReceiveAmountValue: bigint + intervalDebitAmountValue: bigint | null + intervalReceiveAmountValue: bigint | null + latestPayment: { + id: string + debitAmountValue: bigint + debitAmountCode: string + debitAmountScale: number + receiveAmountValue: bigint + receiveAmountCode: string + receiveAmountScale: number + state: OutgoingPaymentState + } | null +}> { + const { grantId, trx, paymentLimits, excludePaymentId } = args + + const hasInterval = !!paymentLimits?.paymentInterval + + let grantTotalDebitAmountValue = 0n + let grantTotalReceiveAmountValue = 0n + let intervalDebitAmountValue: bigint | null = hasInterval ? 0n : null + let intervalReceiveAmountValue: bigint | null = hasInterval ? 0n : null + + const query = OutgoingPayment.query(trx) + .where({ grantId }) + .withGraphFetched('quote') + .orderBy('createdAt', 'asc') + + if (excludePaymentId) { + query.andWhereNot({ id: excludePaymentId }) + } + + const grantPayments = await query + + for (const grantPayment of grantPayments) { + const asset = await deps.assetService.get(grantPayment.quote.assetId) + if (asset) grantPayment.quote.asset = asset + + const addToInterval = + paymentLimits?.paymentInterval && + paymentLimits.paymentInterval.contains( + DateTime.fromJSDate(grantPayment.createdAt) + ) + + if (grantPayment.failed) { + const totalSent = validateSentAmount( + deps, + grantPayment, + await deps.accountingService.getTotalSent(grantPayment.id) + ) + + if (totalSent === BigInt(0)) { + continue + } + + grantTotalDebitAmountValue += totalSent + // Estimate delivered amount of failed payment + const estimatedReceived = + (grantPayment.receiveAmount.value * totalSent) / + grantPayment.debitAmount.value + grantTotalReceiveAmountValue += estimatedReceived + + if (addToInterval) { + intervalDebitAmountValue = (intervalDebitAmountValue ?? 0n) + totalSent + intervalReceiveAmountValue = + (intervalReceiveAmountValue ?? 0n) + estimatedReceived + } + } else { + grantTotalDebitAmountValue += grantPayment.debitAmount.value + grantTotalReceiveAmountValue += grantPayment.receiveAmount.value + + if (addToInterval) { + intervalDebitAmountValue = + (intervalDebitAmountValue ?? 0n) + grantPayment.debitAmount.value + intervalReceiveAmountValue = + (intervalReceiveAmountValue ?? 0n) + grantPayment.receiveAmount.value + } + } + } + + const latestPayment = + grantPayments.length > 0 ? grantPayments[grantPayments.length - 1] : null + + return { + grantTotalDebitAmountValue, + grantTotalReceiveAmountValue, + intervalDebitAmountValue, + intervalReceiveAmountValue, + latestPayment: latestPayment + ? { + id: latestPayment.id, + debitAmountValue: latestPayment.debitAmount.value, + debitAmountCode: latestPayment.debitAmount.assetCode, + debitAmountScale: latestPayment.debitAmount.assetScale, + receiveAmountValue: latestPayment.receiveAmount.value, + receiveAmountCode: latestPayment.receiveAmount.assetCode, + receiveAmountScale: latestPayment.receiveAmount.assetScale, + state: latestPayment.state + } + : null + } +} + async function validateGrantAndAddSpentAmountsToPayment( deps: ServiceDependencies, - payment: OutgoingPayment, - grant: Grant, - trx: TransactionOrKnex, - callback?: (f: unknown) => NodeJS.Timeout, - grantLockTimeoutMs: number = 5000 + args: { + payment: OutgoingPayment + grant: Grant + trx: TransactionOrKnex + callback?: (f: unknown) => NodeJS.Timeout + isExistingGrant: boolean + grantLockTimeoutMs?: number + } ): Promise { + const { + payment, + grant, + trx, + callback, + isExistingGrant, + grantLockTimeoutMs = DEFAULT_GRANT_LOCK_TIMEOUT_MS + } = args + if (!grant.limits) { return true } @@ -622,86 +761,202 @@ async function validateGrantAndAddSpentAmountsToPayment( return false } - await OutgoingPaymentGrant.query(trx || deps.knex) + // Lock the grant record + await OutgoingPaymentGrant.query(trx) .where('id', grant.id) .forNoKeyUpdate() .timeout(grantLockTimeoutMs) if (callback) await new Promise(callback) - const grantPayments = await OutgoingPayment.query(trx || deps.knex) - .where({ - grantId: grant.id + // Get the most recent spent amounts record + const latestSpentAmounts = await OutgoingPaymentGrantSpentAmounts.query(trx) + .where('grantId', grant.id) + .orderBy('createdAt', 'desc') + .limit(1) + .first() + + if ( + paymentLimits.paymentInterval && + (!paymentLimits.paymentInterval.start || !paymentLimits.paymentInterval.end) + ) { + deps.logger.error( + { + grantId: grant.id, + paymentId: payment.id, + intervalStart: paymentLimits.paymentInterval.start?.toJSDate(), + intervalEnd: paymentLimits.paymentInterval.end?.toJSDate() + }, + 'Payment interval missing start or end' + ) + throw OutgoingPaymentError.InvalidInterval + } + + // check if payment interval is before latest spent amounts interval + if ( + paymentLimits.paymentInterval && + latestSpentAmounts?.intervalStart && + latestSpentAmounts?.intervalEnd && + paymentLimits.paymentInterval.end && + paymentLimits.paymentInterval.end.toJSDate() <= + latestSpentAmounts.intervalStart + ) { + deps.logger.error( + { + grantId: grant.id, + paymentId: payment.id, + paymentIntervalStart: paymentLimits.paymentInterval.start?.toJSDate(), + paymentIntervalEnd: paymentLimits.paymentInterval.end.toJSDate(), + latestIntervalStart: latestSpentAmounts.intervalStart, + latestIntervalEnd: latestSpentAmounts.intervalEnd + }, + 'Payment interval is before latest spent amounts interval' + ) + throw OutgoingPaymentError.InvalidInterval + } + + const outgoingPaymentGrantSpentAmounts = + OutgoingPaymentGrantSpentAmounts.create({ + id: uuid(), + grantId: grant.id, + outgoingPaymentId: payment.id, + receiveAmountScale: payment.receiveAmount.assetScale, + receiveAmountCode: payment.receiveAmount.assetCode, + paymentReceiveAmountValue: payment.receiveAmount.value, + intervalReceiveAmountValue: null, + grantTotalReceiveAmountValue: 0n, + debitAmountScale: payment.debitAmount.assetScale, + debitAmountCode: payment.debitAmount.assetCode, + paymentDebitAmountValue: payment.debitAmount.value, + intervalDebitAmountValue: null, + grantTotalDebitAmountValue: 0n, + paymentState: payment.state, + intervalStart: paymentLimits.paymentInterval?.start?.toJSDate() || null, + intervalEnd: paymentLimits.paymentInterval?.end?.toJSDate() || null, + createdAt: new Date() }) - .andWhereNot({ - id: payment.id + + const hasInterval = !!paymentLimits.paymentInterval + + if (isExistingGrant && !latestSpentAmounts) { + // Legacy path: calculate spent amounts from historical payments + const legacyAmounts = await calculateLegacyGrantSpentAmounts(deps, { + grantId: grant.id, + trx, + paymentLimits, + excludePaymentId: payment.id }) - .withGraphFetched('quote') - if (grantPayments.length === 0) { - return true + outgoingPaymentGrantSpentAmounts.grantTotalDebitAmountValue = + legacyAmounts.grantTotalDebitAmountValue + outgoingPaymentGrantSpentAmounts.grantTotalReceiveAmountValue = + legacyAmounts.grantTotalReceiveAmountValue + outgoingPaymentGrantSpentAmounts.intervalDebitAmountValue = + legacyAmounts.intervalDebitAmountValue + outgoingPaymentGrantSpentAmounts.intervalReceiveAmountValue = + legacyAmounts.intervalReceiveAmountValue + } else { + // detect if we need to restart interval sum at 0 or continue from last + const isInIntervalAndFirstPayment = hasInterval + ? !latestSpentAmounts || + (latestSpentAmounts.intervalEnd && + paymentLimits.paymentInterval?.start && + latestSpentAmounts.intervalEnd <= + paymentLimits.paymentInterval.start.toJSDate()) + : false + + outgoingPaymentGrantSpentAmounts.grantTotalDebitAmountValue = + latestSpentAmounts?.grantTotalDebitAmountValue ?? 0n + outgoingPaymentGrantSpentAmounts.grantTotalReceiveAmountValue = + latestSpentAmounts?.grantTotalReceiveAmountValue ?? 0n + + if (hasInterval) { + outgoingPaymentGrantSpentAmounts.intervalDebitAmountValue = + isInIntervalAndFirstPayment + ? 0n + : latestSpentAmounts?.intervalDebitAmountValue ?? 0n + outgoingPaymentGrantSpentAmounts.intervalReceiveAmountValue = + isInIntervalAndFirstPayment + ? 0n + : latestSpentAmounts?.intervalReceiveAmountValue ?? 0n + } } - const amounts = { - sent: { - assetCode: payment.asset.code, - assetScale: payment.asset.scale, - value: BigInt(0) - }, - received: { - assetCode: payment.receiveAmount.assetCode, - assetScale: payment.receiveAmount.assetScale, - value: BigInt(0) + if (hasInterval) { + const debit = outgoingPaymentGrantSpentAmounts.intervalDebitAmountValue + const receive = outgoingPaymentGrantSpentAmounts.intervalReceiveAmountValue + + if (debit === null || receive === null) { + throw OutgoingPaymentError.InvalidInterval } - } - for (const grantPayment of grantPayments) { - const asset = await deps.assetService.get(grantPayment.quote.assetId) - if (asset) grantPayment.quote.asset = asset - if ( - validatePaymentInterval({ - limits: paymentLimits, - payment: grantPayment - }) - ) { - if (grantPayment.failed) { - const totalSent = validateSentAmount( - deps, - payment, - await deps.accountingService.getTotalSent(grantPayment.id) - ) + setGrantSpentAmounts(payment, debit, receive) - if (totalSent === BigInt(0)) { - continue - } - amounts.sent.value += totalSent - // Estimate delivered amount of failed payment - amounts.received.value += - (grantPayment.receiveAmount.value * totalSent) / - grantPayment.debitAmount.value - } else { - amounts.sent.value += grantPayment.debitAmount.value - amounts.received.value += grantPayment.receiveAmount.value - } + if (exceedsGrantLimits(payment, paymentLimits, debit, receive)) { + return false + } + + // add current payment to interval + outgoingPaymentGrantSpentAmounts.intervalDebitAmountValue = + debit + payment.debitAmount.value + outgoingPaymentGrantSpentAmounts.intervalReceiveAmountValue = + receive + payment.receiveAmount.value + } else { + const debit = outgoingPaymentGrantSpentAmounts.grantTotalDebitAmountValue + const receive = + outgoingPaymentGrantSpentAmounts.grantTotalReceiveAmountValue + + setGrantSpentAmounts(payment, debit, receive) + + if (exceedsGrantLimits(payment, paymentLimits, debit, receive)) { + return false } } - if ( - (paymentLimits.debitAmount && - paymentLimits.debitAmount.value - amounts.sent.value < - payment.debitAmount.value) || - (paymentLimits.receiveAmount && - paymentLimits.receiveAmount.value - amounts.received.value < - payment.receiveAmount.value) - ) { - payment.grantSpentDebitAmount = amounts.sent - payment.grantSpentReceiveAmount = amounts.received - return false - } - payment.grantSpentDebitAmount = amounts.sent - payment.grantSpentReceiveAmount = amounts.received + + // update totals + outgoingPaymentGrantSpentAmounts.grantTotalDebitAmountValue += + payment.debitAmount.value + outgoingPaymentGrantSpentAmounts.grantTotalReceiveAmountValue += + payment.receiveAmount.value + + await outgoingPaymentGrantSpentAmounts.$query(trx).insert() + return true } +function setGrantSpentAmounts( + payment: OutgoingPayment, + spentValue: bigint, + spentReceive: bigint +): void { + payment.grantSpentDebitAmount = { + value: spentValue, + assetCode: payment.debitAmount.assetCode, + assetScale: payment.debitAmount.assetScale + } + payment.grantSpentReceiveAmount = { + value: spentReceive, + assetCode: payment.receiveAmount.assetCode, + assetScale: payment.receiveAmount.assetScale + } +} + +function exceedsGrantLimits( + payment: OutgoingPayment, + limits: PaymentLimits, + spentValue: bigint, + spentReceive: bigint +): boolean { + return ( + (limits.debitAmount && + limits.debitAmount.value - spentValue < payment.debitAmount.value) || + (limits.receiveAmount && + limits.receiveAmount.value - spentReceive < + payment.receiveAmount.value) || + false + ) +} + export interface FundOutgoingPaymentOptions { id: string tenantId: string @@ -866,3 +1121,457 @@ function validateSentAmount( ) throw new Error(errorMessage) } + +/** + * Gets the latest spent amounts record by payment. + */ +async function getLatestPaymentSpentAmounts( + deps: ServiceDependencies, + id: string +): Promise { + return await OutgoingPaymentGrantSpentAmounts.query(deps.knex) + .where('outgoingPaymentId', id) + .orderBy('createdAt', 'desc') + .first() +} + +/** + * Gets the latest spent amounts records by grantId and payment interval if needed. + */ +async function getRemainingGrantSpentAmounts( + deps: ServiceDependencies, + grantId: string, + latestPaymentSpentAmounts: OutgoingPaymentGrantSpentAmounts +): Promise<{ + latestGrantSpentAmounts: OutgoingPaymentGrantSpentAmounts + latestIntervalSpentAmounts: OutgoingPaymentGrantSpentAmounts | null +} | null> { + const latestGrantSpentAmounts = await OutgoingPaymentGrantSpentAmounts.query( + deps.knex + ) + .where('grantId', grantId) + .orderBy('createdAt', 'desc') + .first() + + if (!latestGrantSpentAmounts) return null + + // For interval amounts, we need the latest record from this payment's interval + // (not necessarily the latest overall, nor this specific payment's spent amount record) + let latestIntervalSpentAmounts: OutgoingPaymentGrantSpentAmounts | null = null + + if ( + latestPaymentSpentAmounts.intervalStart && + latestPaymentSpentAmounts.intervalEnd + ) { + if ( + latestGrantSpentAmounts.intervalStart?.getTime() !== + latestPaymentSpentAmounts.intervalStart.getTime() || + latestGrantSpentAmounts.intervalEnd?.getTime() !== + latestPaymentSpentAmounts.intervalEnd.getTime() + ) { + latestIntervalSpentAmounts = + (await OutgoingPaymentGrantSpentAmounts.query(deps.knex) + .where('grantId', grantId) + .where('intervalStart', latestPaymentSpentAmounts.intervalStart) + .where('intervalEnd', latestPaymentSpentAmounts.intervalEnd) + .orderBy('createdAt', 'desc') + .first()) ?? null + } else { + latestIntervalSpentAmounts = latestGrantSpentAmounts + } + } + + return { latestGrantSpentAmounts, latestIntervalSpentAmounts } +} + +/** + * Calculates new interval amounts based on previous interval spent amounts. + */ +function calculateIntervalAmounts( + latestPaymentSpentAmounts: OutgoingPaymentGrantSpentAmounts, + latestIntervalSpentAmounts: OutgoingPaymentGrantSpentAmounts | null, + debitAmountDifference: bigint, + receiveAmountDifference: bigint +): { debit: bigint | null; receive: bigint | null } { + if ( + latestPaymentSpentAmounts.intervalStart === null || + latestPaymentSpentAmounts.intervalEnd === null || + !latestIntervalSpentAmounts + ) { + return { debit: null, receive: null } + } + + const newDebit = + (latestIntervalSpentAmounts.intervalDebitAmountValue ?? 0n) - + debitAmountDifference + const newReceive = + (latestIntervalSpentAmounts.intervalReceiveAmountValue ?? 0n) - + receiveAmountDifference + + return { + debit: newDebit < 0n ? 0n : newDebit, + receive: newReceive < 0n ? 0n : newReceive + } +} + +/** + * Compares the final settled amounts with the amounts on hold + * and inserts a new OutgoingPaymentGrantSpentAmount record if needed. + */ +export async function updateGrantSpentAmounts( + deps: ServiceDependencies, + payment: OutgoingPayment, + finalAmounts: { debit: bigint; receive: bigint } +) { + if (!payment.grantId) return + + const latestPaymentSpentAmounts = await getLatestPaymentSpentAmounts( + deps, + payment.id + ) + if (!latestPaymentSpentAmounts) { + deps.logger.error( + { payment, latestPaymentSpentAmounts }, + 'Could not find grant spent amounts for payment when updating spent amounts' + ) + deps.telemetry.incrementCounter( + 'grant_spent_amounts_missing_record_total', + 1, + { + callName: 'updateGrantSpentAmounts' + } + ) + return + } + + const reservedReceiveAmount = + latestPaymentSpentAmounts.paymentReceiveAmountValue + const receiveAmountDifference = reservedReceiveAmount - finalAmounts.receive + + if (receiveAmountDifference === 0n) return + + const records = await getRemainingGrantSpentAmounts( + deps, + payment.grantId, + latestPaymentSpentAmounts + ) + if (!records) { + deps.logger.error( + { payment, latestPaymentSpentAmounts }, + 'Could not find grant spent amounts for grant when updating spent amounts' + ) + deps.telemetry.incrementCounter( + 'grant_spent_amounts_missing_record_total', + 1, + { + callName: 'updateGrantSpentAmounts' + } + ) + return + } + + const { latestGrantSpentAmounts, latestIntervalSpentAmounts } = records + + const grantTotalReceiveDiff = + latestGrantSpentAmounts.grantTotalReceiveAmountValue - + receiveAmountDifference + const newGrantTotalReceiveAmountValue = + grantTotalReceiveDiff < 0n ? 0n : grantTotalReceiveDiff + + const { + debit: newIntervalDebitAmountValue, + receive: newIntervalReceiveAmountValue + } = calculateIntervalAmounts( + latestPaymentSpentAmounts, + latestIntervalSpentAmounts, + 0n, + receiveAmountDifference + ) + + await OutgoingPaymentGrantSpentAmounts.query(deps.knex).insert({ + id: uuid(), + grantId: latestPaymentSpentAmounts.grantId, + outgoingPaymentId: payment.id, + paymentDebitAmountValue: finalAmounts.debit, + debitAmountScale: latestPaymentSpentAmounts.debitAmountScale, + debitAmountCode: latestPaymentSpentAmounts.debitAmountCode, + intervalDebitAmountValue: newIntervalDebitAmountValue, + grantTotalDebitAmountValue: + latestGrantSpentAmounts.grantTotalDebitAmountValue, + paymentReceiveAmountValue: finalAmounts.receive, + receiveAmountScale: latestPaymentSpentAmounts.receiveAmountScale, + receiveAmountCode: latestPaymentSpentAmounts.receiveAmountCode, + intervalReceiveAmountValue: newIntervalReceiveAmountValue, + grantTotalReceiveAmountValue: newGrantTotalReceiveAmountValue, + intervalStart: latestPaymentSpentAmounts.intervalStart, + intervalEnd: latestPaymentSpentAmounts.intervalEnd, + createdAt: new Date(), + paymentState: OutgoingPaymentState.Completed + }) +} + +/** + * Gets the spent amounts for a grant. + * The spent amounts are scoped to current interval, if any, else they are + * for the total lifetime of the grant. + */ +async function getGrantSpentAmounts( + deps: ServiceDependencies, + options: { grantId: string; limits?: Limits } +): Promise { + const { grantId, limits } = options + const currentInterval = parseIntervalSafely(deps, limits?.interval) + if (limits?.interval && !currentInterval) { + return { spentDebitAmount: null, spentReceiveAmount: null } + } + + const latestRecord = await OutgoingPaymentGrantSpentAmounts.query(deps.knex) + .where('grantId', grantId) + .orderBy('createdAt', 'desc') + .first() + + if (latestRecord) { + // No interval requested - return totals + if (!currentInterval) { + return { + spentDebitAmount: { + value: latestRecord.grantTotalDebitAmountValue, + assetCode: latestRecord.debitAmountCode, + assetScale: latestRecord.debitAmountScale + }, + spentReceiveAmount: { + value: latestRecord.grantTotalReceiveAmountValue, + assetCode: latestRecord.receiveAmountCode, + assetScale: latestRecord.receiveAmountScale + } + } + } + + // Check if record matches current interval + if (latestRecord.intervalStart && latestRecord.intervalEnd) { + const recordInterval = Interval.fromDateTimes( + DateTime.fromJSDate(latestRecord.intervalStart), + DateTime.fromJSDate(latestRecord.intervalEnd) + ) + if (recordInterval.equals(currentInterval)) { + return { + spentDebitAmount: { + value: latestRecord.intervalDebitAmountValue ?? BigInt(0), + assetCode: latestRecord.debitAmountCode, + assetScale: latestRecord.debitAmountScale + }, + spentReceiveAmount: { + value: latestRecord.intervalReceiveAmountValue ?? BigInt(0), + assetCode: latestRecord.receiveAmountCode, + assetScale: latestRecord.receiveAmountScale + } + } + } + } else { + deps.logger.warn( + { + intervalStart: latestRecord.intervalStart, + intervalEnd: latestRecord.intervalEnd + }, + 'Grant spent amount interval missing start or end' + ) + } + + // New interval - return zeros + return { + spentDebitAmount: { + value: BigInt(0), + assetCode: latestRecord.debitAmountCode, + assetScale: latestRecord.debitAmountScale + }, + spentReceiveAmount: { + value: BigInt(0), + assetCode: latestRecord.receiveAmountCode, + assetScale: latestRecord.receiveAmountScale + } + } + } + + // No spent amounts record - check for legacy grant + const isExistingGrant = !!(await OutgoingPaymentGrant.query( + deps.knex + ).findById(grantId)) + if (!isExistingGrant) { + return { spentDebitAmount: null, spentReceiveAmount: null } + } + + // Legacy path + const paymentLimits = currentInterval + ? { ...limits, paymentInterval: currentInterval } + : undefined + + const legacy = await calculateLegacyGrantSpentAmounts(deps, { + grantId, + trx: deps.knex, + paymentLimits + }) + + if (!legacy.latestPayment) { + deps.logger.warn( + { grantId }, + 'Expected to find existing payments for grant' + ) + return { spentDebitAmount: null, spentReceiveAmount: null } + } + + // Insert the legacy spent amounts as a new record to migrate the data + await OutgoingPaymentGrantSpentAmounts.query(deps.knex).insert({ + id: uuid(), + grantId, + outgoingPaymentId: legacy.latestPayment.id, + paymentDebitAmountValue: legacy.latestPayment.debitAmountValue, + debitAmountScale: legacy.latestPayment.debitAmountScale, + debitAmountCode: legacy.latestPayment.debitAmountCode, + intervalDebitAmountValue: legacy.intervalDebitAmountValue, + grantTotalDebitAmountValue: legacy.grantTotalDebitAmountValue, + paymentReceiveAmountValue: legacy.latestPayment.receiveAmountValue, + receiveAmountScale: legacy.latestPayment.receiveAmountScale, + receiveAmountCode: legacy.latestPayment.receiveAmountCode, + intervalReceiveAmountValue: legacy.intervalReceiveAmountValue, + grantTotalReceiveAmountValue: legacy.grantTotalReceiveAmountValue, + intervalStart: currentInterval?.start?.toJSDate() ?? null, + intervalEnd: currentInterval?.end?.toJSDate() ?? null, + createdAt: new Date(), + paymentState: legacy.latestPayment.state + }) + + return { + spentDebitAmount: { + value: currentInterval + ? legacy.intervalDebitAmountValue ?? BigInt(0) + : legacy.grantTotalDebitAmountValue, + assetCode: legacy.latestPayment.debitAmountCode, + assetScale: legacy.latestPayment.debitAmountScale + }, + spentReceiveAmount: { + value: currentInterval + ? legacy.intervalReceiveAmountValue ?? BigInt(0) + : legacy.grantTotalReceiveAmountValue, + assetCode: legacy.latestPayment.receiveAmountCode, + assetScale: legacy.latestPayment.receiveAmountScale + } + } +} + +function parseIntervalSafely( + deps: ServiceDependencies, + intervalStr?: string +): Interval | null { + if (!intervalStr) return null + + try { + const interval = getInterval(intervalStr, new Date()) + if (!interval?.start || !interval?.end) { + deps.logger.warn( + { interval: intervalStr }, + 'Interval missing start or end' + ) + return null + } + return interval + } catch (err) { + deps.logger.warn({ err, interval: intervalStr }, 'Could not parse interval') + return null + } +} + +/** + * Reverts the grant spent amounts when a payment fails. + * Inserts a spent amount record with the reserved amounts adjusted out. + */ +export async function revertGrantSpentAmounts( + deps: ServiceDependencies, + payment: OutgoingPayment +): Promise { + if (!payment.grantId) return + + const latestPaymentSpentAmounts = await getLatestPaymentSpentAmounts( + deps, + payment.id + ) + if (!latestPaymentSpentAmounts) { + deps.logger.error( + { payment }, + 'Could not find grant spent amounts by payment when reverting spent amounts' + ) + deps.telemetry.incrementCounter( + 'grant_spent_amounts_missing_record_total', + 1, + { + callName: 'revertGrantSpentAmounts' + } + ) + return + } + + const records = await getRemainingGrantSpentAmounts( + deps, + payment.grantId, + latestPaymentSpentAmounts + ) + if (!records) { + deps.logger.error( + { payment, latestPaymentSpentAmounts }, + 'Could not find grant spent amounts for grant when reverting spent amounts' + ) + deps.telemetry.incrementCounter( + 'grant_spent_amounts_missing_record_total', + 1, + { + callName: 'revertGrantSpentAmounts' + } + ) + return + } + + const { latestGrantSpentAmounts, latestIntervalSpentAmounts } = records + + const reservedDebitAmount = latestPaymentSpentAmounts.paymentDebitAmountValue + const reservedReceiveAmount = + latestPaymentSpentAmounts.paymentReceiveAmountValue + + const grantTotalDebitDiff = + latestGrantSpentAmounts.grantTotalDebitAmountValue - reservedDebitAmount + const newGrantTotalDebitAmountValue = + grantTotalDebitDiff < 0n ? 0n : grantTotalDebitDiff + + const grantTotalReceiveDiff = + latestGrantSpentAmounts.grantTotalReceiveAmountValue - reservedReceiveAmount + const newGrantTotalReceiveAmountValue = + grantTotalReceiveDiff < 0n ? 0n : grantTotalReceiveDiff + + const { + debit: newIntervalDebitAmountValue, + receive: newIntervalReceiveAmountValue + } = calculateIntervalAmounts( + latestPaymentSpentAmounts, + latestIntervalSpentAmounts, + reservedDebitAmount, + reservedReceiveAmount + ) + + await OutgoingPaymentGrantSpentAmounts.query(deps.knex).insert({ + id: uuid(), + grantId: latestPaymentSpentAmounts.grantId, + outgoingPaymentId: payment.id, + paymentDebitAmountValue: BigInt(0), + debitAmountScale: latestPaymentSpentAmounts.debitAmountScale, + debitAmountCode: latestPaymentSpentAmounts.debitAmountCode, + intervalDebitAmountValue: newIntervalDebitAmountValue, + grantTotalDebitAmountValue: newGrantTotalDebitAmountValue, + paymentReceiveAmountValue: BigInt(0), + receiveAmountScale: latestPaymentSpentAmounts.receiveAmountScale, + receiveAmountCode: latestPaymentSpentAmounts.receiveAmountCode, + intervalReceiveAmountValue: newIntervalReceiveAmountValue, + grantTotalReceiveAmountValue: newGrantTotalReceiveAmountValue, + createdAt: new Date(), + paymentState: payment.state, + intervalStart: latestPaymentSpentAmounts.intervalStart, + intervalEnd: latestPaymentSpentAmounts.intervalEnd + }) +} diff --git a/packages/backend/src/payment-method/handler/service.ts b/packages/backend/src/payment-method/handler/service.ts index 281007bee9..7014b30fd1 100644 --- a/packages/backend/src/payment-method/handler/service.ts +++ b/packages/backend/src/payment-method/handler/service.ts @@ -35,7 +35,7 @@ export interface PaymentMethodService { quoteOptions: StartQuoteOptions, trx?: Transaction ): Promise - pay(payOptions: PayOptions): Promise + pay(payOptions: PayOptions): Promise } export type PaymentMethod = 'ILP' | 'LOCAL' @@ -46,7 +46,7 @@ export interface PaymentMethodHandlerService { quoteOptions: StartQuoteOptions, trx?: Transaction ): Promise - pay(method: PaymentMethod, payOptions: PayOptions): Promise + pay(method: PaymentMethod, payOptions: PayOptions): Promise } interface ServiceDependencies extends BaseService { diff --git a/packages/backend/src/payment-method/ilp/service.test.ts b/packages/backend/src/payment-method/ilp/service.test.ts index 47d89a50e0..26c446490e 100644 --- a/packages/backend/src/payment-method/ilp/service.test.ts +++ b/packages/backend/src/payment-method/ilp/service.test.ts @@ -824,7 +824,7 @@ describe('IlpPaymentService', (): void => { finalDebitAmount: 100n, finalReceiveAmount: 100n }) - ).resolves.toBeUndefined() + ).resolves.toBe(100n) await validateBalances(outgoingPayment, incomingPayment, { amountSent: 100n, @@ -875,7 +875,7 @@ describe('IlpPaymentService', (): void => { finalDebitAmount: 100n - 5n, finalReceiveAmount: 100n - 5n }) - ).resolves.toBeUndefined() + ).resolves.toBe(95n) await validateBalances(outgoingPayment, incomingPayment, { amountSent: 100n, diff --git a/packages/backend/src/payment-method/ilp/service.ts b/packages/backend/src/payment-method/ilp/service.ts index 86cec16173..d1d37eff94 100644 --- a/packages/backend/src/payment-method/ilp/service.ts +++ b/packages/backend/src/payment-method/ilp/service.ts @@ -284,7 +284,7 @@ async function getQuote( async function pay( deps: ServiceDependencies, options: PayOptions -): Promise { +): Promise { const { receiver, outgoingPayment, finalDebitAmount, finalReceiveAmount } = options @@ -364,6 +364,7 @@ async function pay( }, 'ILP payment completed' ) + return receipt.amountDelivered } catch (err) { const errorMessage = 'Received error during ILP pay' deps.logger.error( diff --git a/packages/backend/src/payment-method/local/service.test.ts b/packages/backend/src/payment-method/local/service.test.ts index a70da7b9a5..a977ad2429 100644 --- a/packages/backend/src/payment-method/local/service.test.ts +++ b/packages/backend/src/payment-method/local/service.test.ts @@ -496,7 +496,7 @@ describe('LocalPaymentService', (): void => { finalReceiveAmount: 100n }) - expect(payResponse).toBe(undefined) + expect(payResponse).toBe(100n) await validateBalances(outgoingPayment, incomingPayment, { amountSent: 100n, diff --git a/packages/backend/src/payment-method/local/service.ts b/packages/backend/src/payment-method/local/service.ts index 29daa328ae..d48d7b26a9 100644 --- a/packages/backend/src/payment-method/local/service.ts +++ b/packages/backend/src/payment-method/local/service.ts @@ -222,7 +222,7 @@ async function getQuote( async function pay( deps: ServiceDependencies, options: PayOptions -): Promise { +): Promise { const { outgoingPayment, receiver, finalReceiveAmount, finalDebitAmount } = options @@ -317,4 +317,6 @@ async function pay( retryable: false }) } + + return finalReceiveAmount } diff --git a/packages/token-introspection/src/openapi/specs/token-introspection.yaml b/packages/token-introspection/src/openapi/specs/token-introspection.yaml index 340e0af2a0..4b3c01e7a9 100644 --- a/packages/token-introspection/src/openapi/specs/token-introspection.yaml +++ b/packages/token-introspection/src/openapi/specs/token-introspection.yaml @@ -252,7 +252,6 @@ components: required: - type - actions - - identifier access-quote: title: access-quote type: object