diff --git a/open-payments-specifications b/open-payments-specifications new file mode 160000 index 0000000000..d0b86f6e5b --- /dev/null +++ b/open-payments-specifications @@ -0,0 +1 @@ +Subproject commit d0b86f6e5b391b044e9b6d0a74615a818d4ea787 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..1b72ab9fd6 --- /dev/null +++ b/packages/backend/src/open_payments/payment/outgoing/lifecycle.test.ts @@ -0,0 +1,1149 @@ +import { IocContract } from '@adonisjs/fold' +import { AppServices } from '../../../app' +import { Config } 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 { uuid } from '../../../payment-method/ilp/connector/ilp-routing/lib/utils' +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' + +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 + + 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, { + walletAddressId, + receiver, + debitAmount: { + value: debitAmountValue, + assetCode: asset.code, + assetScale: asset.scale + }, + method: 'ilp', + exchangeRate: 1 + }) + + const payment = await outgoingPaymentService.create({ + walletAddressId, + quoteId: quote.id, + grant + }) + assert.ok(!isOutgoingPaymentError(payment)) + + const fundResult = await outgoingPaymentService.fund({ + 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, { + walletAddressId: receiverWalletAddressId + }), + sourceAmount: amount, + destinationAmount: amount, + timeout: 0 + }) + assert.ok(transfer && typeof transfer === 'object') + await transfer.post() + return { debit: amount, receive: 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, { + walletAddressId: receiverWalletAddressId + }), + sourceAmount: partial.debit, + destinationAmount: partial.receive, + timeout: 0 + }) + assert.ok(transfer && typeof transfer === 'object') + await transfer.post() + return { debit: partial.debit, receive: partial.receive } + }) + } + + function mockPayErrorFactory() { + return () => + jest.fn(async () => { + throw new PaymentMethodHandlerError('Simulated failure', { + description: 'Payment failed', + retryable: false + }) + }) + } + + 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' + ) + knex = appContainer.knex + + jest.useFakeTimers() + }) + + beforeEach(async (): Promise => { + // Create sender wallet address + asset = await createAsset(deps, assetDetails) + const senderWalletAddress = await createWalletAddress(deps, { + assetId: asset.id + }) + walletAddressId = senderWalletAddress.id + + // Create receiver wallet address and incoming payment + const receiverWalletAddress = await createWalletAddress(deps, { + assetId: asset.id + }) + receiverWalletAddressId = receiverWalletAddress.id + + incomingPayment = await createIncomingPayment(deps, { + walletAddressId: receiverWalletAddressId + }) + const config = await deps.use('config') + receiver = incomingPayment.getUrl(config.openPaymentsUrl) + }) + + afterEach(async (): Promise => { + jest.restoreAllMocks() + await truncateTables(knex) + }) + + 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) + }) + }) + 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: settledAmount, + 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: settledAmount, + intervalDebitAmountValue: settledAmount, + grantTotalDebitAmountValue: settledAmount, + paymentState: OutgoingPaymentState.Funding, + intervalStart: interval.start.toJSDate(), + intervalEnd: interval.end.toJSDate() + }) + }) + + test('Failed payment should remove 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 endSpentAmounts = await OutgoingPaymentGrantSpentAmounts.query( + knex + ) + .where({ outgoingPaymentId: payment.id }) + .orderBy('createdAt', 'desc') + .first() + + expect(endSpentAmounts).toBe(undefined) + }) + }) + 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 paymentAmount = 100n + + // Create and process first payment + await createAndFundGrantPayment( + paymentAmount, + grant, + mockPaySuccessFactory() + ) + await outgoingPaymentService.processNext() + + // Create second payment + const secondPayment = await createAndFundGrantPayment( + paymentAmount, + 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: 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) + + // 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: secondPaymentSettledAmount, + 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: secondPaymentSettledAmount, + intervalDebitAmountValue: + paymentAmount + secondPaymentSettledAmount, + grantTotalDebitAmountValue: + paymentAmount + secondPaymentSettledAmount, + paymentState: OutgoingPaymentState.Funding, + intervalStart: interval.start.toJSDate(), + intervalEnd: interval.end.toJSDate() + }) + }) + + test('Failed payment should remove 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 + const firstPayment = 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) + + // Grant spent amounts for failed payment should be removed + const endSpentAmounts = await OutgoingPaymentGrantSpentAmounts.query( + knex + ) + .where({ outgoingPaymentId: secondPayment.id }) + .first() + expect(endSpentAmounts).toBe(undefined) + + const latestSpentAmounts = + await OutgoingPaymentGrantSpentAmounts.query(knex) + .where({ grantId: grant.id }) + .orderBy('createdAt', 'desc') + .first() + + assert(latestSpentAmounts) + expect(latestSpentAmounts.outgoingPaymentId).toBe(firstPayment.id) + }) + }) + }) + + 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: settledAmount, + 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: settledAmount, + intervalDebitAmountValue: paymentAmount + settledAmount, + grantTotalDebitAmountValue: paymentAmount + settledAmount, + 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 remove 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) + + // Grant spent amounts for failed payment should be removed + const endSpentAmounts = await OutgoingPaymentGrantSpentAmounts.query( + knex + ) + .where({ outgoingPaymentId: payment.id }) + .first() + expect(endSpentAmounts).toBe(undefined) + + // Only the first successful spent amounts should remain + const latestSpentAmounts = await OutgoingPaymentGrantSpentAmounts.query( + knex + ) + .where({ grantId: grant.id }) + .orderBy('createdAt', 'desc') + .first() + + assert(latestSpentAmounts) + expect(latestSpentAmounts).toMatchObject(initialSpentAmounts) + }) + }) + }) +}) diff --git a/packages/backend/src/open_payments/payment/outgoing/lifecycle.ts b/packages/backend/src/open_payments/payment/outgoing/lifecycle.ts index af1938d260..36b6b3b086 100644 --- a/packages/backend/src/open_payments/payment/outgoing/lifecycle.ts +++ b/packages/backend/src/open_payments/payment/outgoing/lifecycle.ts @@ -3,12 +3,15 @@ import { OutgoingPayment, OutgoingPaymentState, OutgoingPaymentEvent, - OutgoingPaymentEventType + OutgoingPaymentEventType, + OutgoingPaymentGrantSpentAmounts } from './model' import { ServiceDependencies } from './service' import { Receiver } from '../../receiver/model' import { TransactionOrKnex } from 'objection' import { ValueType } from '@opentelemetry/api' +import { v4 } from 'uuid' +import { SettledAmounts } from '../../../payment-method/handler/service' // "payment" is locked by the "deps.knex" transaction. export async function handleSending( @@ -85,6 +88,7 @@ export async function handleSending( description: 'Time to complete a payment', callName: 'PaymentMethodHandlerService:pay' }) + let settledAmounts: SettledAmounts if (receiver.isLocal) { if ( !payment.quote.debitAmountMinusFees || @@ -98,14 +102,14 @@ export async function handleSending( ) throw LifecycleError.BadState } - await deps.paymentMethodHandlerService.pay('LOCAL', { + settledAmounts = await deps.paymentMethodHandlerService.pay('LOCAL', { receiver, outgoingPayment: payment, finalDebitAmount: payment.quote.debitAmountMinusFees, finalReceiveAmount: maxReceiveAmount }) } else { - await deps.paymentMethodHandlerService.pay('ILP', { + settledAmounts = await deps.paymentMethodHandlerService.pay('ILP', { receiver, outgoingPayment: payment, finalDebitAmount: maxDebitAmount, @@ -114,6 +118,8 @@ export async function handleSending( } stopTimer() + await handleGrantSpentAmounts(deps, payment, settledAmounts) + await Promise.all([ deps.telemetry.incrementCounter('transactions_total', 1, { description: 'Count of funded transactions' @@ -158,6 +164,84 @@ function getAdjustedAmounts( return { maxDebitAmount, maxReceiveAmount } } +/** + * Compares the final settled amounts with the amounts on hold + * and inserts a new OutgoingPaymentGrantSpentAmount record if needed. + */ +async function handleGrantSpentAmounts( + deps: ServiceDependencies, + payment: OutgoingPayment, + settledAmounts: SettledAmounts +) { + if (!payment.grantId) return + + const latestSpentAmounts = await OutgoingPaymentGrantSpentAmounts.query( + deps.knex + ) + .where('grantId', payment.grantId) + .orderBy('createdAt', 'desc') + .first() + + // TODO: this shouldnt happen. should we error instead? + if (!latestSpentAmounts) { + deps.logger.warn( + { grantId: payment.grantId }, + 'No outgoingPaymentGrantSpentAmounts record found for grantId on payment failure' + ) + return + } + + const reservedDebitAmount = latestSpentAmounts.paymentDebitAmountValue + const reservedReceiveAmount = latestSpentAmounts.paymentReceiveAmountValue + const debitAmountDifference = reservedDebitAmount - settledAmounts.debit + const receiveAmountDifference = reservedReceiveAmount - settledAmounts.receive + + if (debitAmountDifference === 0n && receiveAmountDifference === 0n) return + + const newGrantTotalDebitAmountValue = + latestSpentAmounts.grantTotalDebitAmountValue - debitAmountDifference + const newIntervalDebitAmountValue = + latestSpentAmounts.intervalDebitAmountValue !== null + ? latestSpentAmounts.intervalDebitAmountValue - debitAmountDifference + : latestSpentAmounts.intervalDebitAmountValue + + const newGrantTotalReceiveAmountValue = + latestSpentAmounts.grantTotalReceiveAmountValue - receiveAmountDifference + const newIntervalReceiveAmountValue = + latestSpentAmounts.intervalReceiveAmountValue !== null + ? latestSpentAmounts.intervalReceiveAmountValue - receiveAmountDifference + : latestSpentAmounts.intervalReceiveAmountValue + + // TODO: handle case where these new values are negative? presumably that is an invalid state. + // In practice it may never happen but is theorhetically possible. + + await OutgoingPaymentGrantSpentAmounts.query(deps.knex).insert({ + ...latestSpentAmounts, + id: v4(), + paymentDebitAmountValue: settledAmounts.debit, + intervalDebitAmountValue: newIntervalDebitAmountValue, + grantTotalDebitAmountValue: newGrantTotalDebitAmountValue, + paymentReceiveAmountValue: settledAmounts.receive, + intervalReceiveAmountValue: newIntervalReceiveAmountValue, + grantTotalReceiveAmountValue: newGrantTotalReceiveAmountValue, + createdAt: new Date() + }) +} + +async function deleteGrantSpentAmounts( + deps: ServiceDependencies, + grantId: string +) { + // TODO: if keeping the delete, soft delete via deletedAt instead + const latestRecord = await OutgoingPaymentGrantSpentAmounts.query() + .where('grantId', grantId) + .orderBy('createdAt', 'desc') + .first() + if (latestRecord) { + await OutgoingPaymentGrantSpentAmounts.query().deleteById(latestRecord.id) + } +} + export async function handleFailed( deps: ServiceDependencies, payment: OutgoingPayment, @@ -166,10 +250,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) { + deleteGrantSpentAmounts(deps, payment.grantId) + } + await sendWebhookEvent(deps, payment, OutgoingPaymentEventType.PaymentFailed) stopTimer() } 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 98ec3b048b..46639c2a75 100644 --- a/packages/backend/src/open_payments/payment/outgoing/service.test.ts +++ b/packages/backend/src/open_payments/payment/outgoing/service.test.ts @@ -164,6 +164,8 @@ describe('OutgoingPaymentService', (): void => { debitAmount: args.finalDebitAmount, receiveAmount: args.finalReceiveAmount }) + + return args.finalReceiveAmount }) } diff --git a/packages/backend/src/open_payments/payment/outgoing/service.ts b/packages/backend/src/open_payments/payment/outgoing/service.ts index a5672b2a27..47bba87835 100644 --- a/packages/backend/src/open_payments/payment/outgoing/service.ts +++ b/packages/backend/src/open_payments/payment/outgoing/service.ts @@ -369,7 +369,8 @@ async function createOutgoingPayment( client: options.client, metadata: options.metadata, state: OutgoingPaymentState.Funding, - grantId + grantId, + createdAt: new Date() }) payment.walletAddress = walletAddress payment.quote = quote @@ -725,7 +726,7 @@ async function validateGrantAndAddSpentAmountsToPayment( ? !latestSpentAmounts || (latestSpentAmounts.intervalEnd && paymentLimits.paymentInterval?.start && - latestSpentAmounts.intervalEnd < + latestSpentAmounts.intervalEnd <= paymentLimits.paymentInterval.start.toJSDate()) : false diff --git a/packages/backend/src/payment-method/handler/service.ts b/packages/backend/src/payment-method/handler/service.ts index 281007bee9..de6c33f51b 100644 --- a/packages/backend/src/payment-method/handler/service.ts +++ b/packages/backend/src/payment-method/handler/service.ts @@ -30,12 +30,17 @@ export interface PayOptions { finalReceiveAmount: bigint } +export interface SettledAmounts { + debit: bigint + receive: bigint +} + export interface PaymentMethodService { getQuote( quoteOptions: StartQuoteOptions, trx?: Transaction ): Promise - pay(payOptions: PayOptions): Promise + pay(payOptions: PayOptions): Promise } export type PaymentMethod = 'ILP' | 'LOCAL' @@ -46,7 +51,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 b2f37b7025..13ae0d75a0 100644 --- a/packages/backend/src/payment-method/ilp/service.test.ts +++ b/packages/backend/src/payment-method/ilp/service.test.ts @@ -474,7 +474,7 @@ describe('IlpPaymentService', (): void => { jest.spyOn(Pay, 'startQuote').mockResolvedValueOnce({ maxSourceAmount: 10n, highEstimatedExchangeRate: Pay.Ratio.from(0.099) - } as Pay.Quote) + } as unknown as Pay.Quote) expect.assertions(5) try { @@ -680,7 +680,7 @@ describe('IlpPaymentService', (): void => { finalDebitAmount: 100n, finalReceiveAmount: 100n }) - ).resolves.toBeUndefined() + ).resolves.toBe(100n) await validateBalances(outgoingPayment, incomingPayment, { amountSent: 100n, @@ -730,7 +730,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 017ab8bdf2..8a85804afa 100644 --- a/packages/backend/src/payment-method/ilp/service.ts +++ b/packages/backend/src/payment-method/ilp/service.ts @@ -254,7 +254,7 @@ async function getQuote( async function pay( deps: ServiceDependencies, options: PayOptions -): Promise { +): Promise { const { receiver, outgoingPayment, finalDebitAmount, finalReceiveAmount } = options @@ -327,6 +327,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 0ddf674709..633825521b 100644 --- a/packages/backend/src/payment-method/local/service.test.ts +++ b/packages/backend/src/payment-method/local/service.test.ts @@ -421,7 +421,7 @@ describe('LocalPaymentService', (): void => { finalReceiveAmount: 100n }) - expect(payResponse).toBe(undefined) + expect(payResponse).toMatchObject({ receive: 100n, debit: 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 a2d4136a3b..8349d6a4f3 100644 --- a/packages/backend/src/payment-method/local/service.ts +++ b/packages/backend/src/payment-method/local/service.ts @@ -3,7 +3,8 @@ import { PaymentQuote, PaymentMethodService, StartQuoteOptions, - PayOptions + PayOptions, + SettledAmounts } from '../handler/service' import { ConvertError, @@ -205,7 +206,7 @@ async function getQuote( async function pay( deps: ServiceDependencies, options: PayOptions -): Promise { +): Promise { const { outgoingPayment, receiver, finalReceiveAmount, finalDebitAmount } = options @@ -300,4 +301,6 @@ async function pay( retryable: false }) } + + return { receive: finalReceiveAmount, debit: finalDebitAmount } }