diff --git a/localenv/mock-account-servicing-entity/app/lib/webhooks.server.ts b/localenv/mock-account-servicing-entity/app/lib/webhooks.server.ts index 05155d783c..f09993640b 100644 --- a/localenv/mock-account-servicing-entity/app/lib/webhooks.server.ts +++ b/localenv/mock-account-servicing-entity/app/lib/webhooks.server.ts @@ -119,7 +119,7 @@ export async function handleOutgoingPaymentCreated( input: { outgoingPaymentId: payment.id, idempotencyKey: uuid(), - dataToTransmit: 'sample kyc data' + dataToTransmit: acc.name } } }) diff --git a/localenv/mock-account-servicing-entity/generated/graphql.ts b/localenv/mock-account-servicing-entity/generated/graphql.ts index 0e1f200b88..6d7bf6ea69 100644 --- a/localenv/mock-account-servicing-entity/generated/graphql.ts +++ b/localenv/mock-account-servicing-entity/generated/graphql.ts @@ -529,7 +529,7 @@ export type DepositEventLiquidityInput = { }; export type DepositOutgoingPaymentLiquidityInput = { - /** Data to be encrypted and sent to the receiver. */ + /** Data to transmit to the recipient during payment. */ dataToTransmit?: InputMaybe; /** Unique key to ensure duplicate or retried requests are processed only once. For more information, refer to [idempotency](https://rafiki.dev/apis/graphql/admin-api-overview/#idempotency). */ idempotencyKey: Scalars['String']['input']; diff --git a/packages/backend/migrations/20251126191852_add_sender_data_to_outgoing_payment.js b/packages/backend/migrations/20251126191852_add_data_to_transmit_to_outgoing_payment.js similarity index 100% rename from packages/backend/migrations/20251126191852_add_sender_data_to_outgoing_payment.js rename to packages/backend/migrations/20251126191852_add_data_to_transmit_to_outgoing_payment.js diff --git a/packages/backend/src/graphql/generated/graphql.schema.json b/packages/backend/src/graphql/generated/graphql.schema.json index 99c810e922..0718478195 100644 --- a/packages/backend/src/graphql/generated/graphql.schema.json +++ b/packages/backend/src/graphql/generated/graphql.schema.json @@ -3135,7 +3135,7 @@ "inputFields": [ { "name": "dataToTransmit", - "description": "Data to be encrypted and sent to the receiver.", + "description": "Data to transmit to the recipient during payment.", "type": { "kind": "SCALAR", "name": "String", diff --git a/packages/backend/src/graphql/generated/graphql.ts b/packages/backend/src/graphql/generated/graphql.ts index 0e1f200b88..6d7bf6ea69 100644 --- a/packages/backend/src/graphql/generated/graphql.ts +++ b/packages/backend/src/graphql/generated/graphql.ts @@ -529,7 +529,7 @@ export type DepositEventLiquidityInput = { }; export type DepositOutgoingPaymentLiquidityInput = { - /** Data to be encrypted and sent to the receiver. */ + /** Data to transmit to the recipient during payment. */ dataToTransmit?: InputMaybe; /** Unique key to ensure duplicate or retried requests are processed only once. For more information, refer to [idempotency](https://rafiki.dev/apis/graphql/admin-api-overview/#idempotency). */ idempotencyKey: Scalars['String']['input']; diff --git a/packages/backend/src/graphql/resolvers/liquidity.test.ts b/packages/backend/src/graphql/resolvers/liquidity.test.ts index 544bbf97b7..cf9b5be59b 100644 --- a/packages/backend/src/graphql/resolvers/liquidity.test.ts +++ b/packages/backend/src/graphql/resolvers/liquidity.test.ts @@ -3528,19 +3528,11 @@ describe('Liquidity Resolvers', (): void => { assert.ok(outgoingPayment.debitAmount) await expect(depositSpy).toHaveBeenCalledWith({ id: eventId, - account: expect.any(OutgoingPayment), + account: expect.objectContaining({ + dataToTransmit + }), amount: outgoingPayment.debitAmount.value }) - await expect( - accountingService.getBalance(outgoingPayment.id) - ).resolves.toEqual(outgoingPayment.debitAmount.value) - await expect( - OutgoingPayment.query(knex).findById(outgoingPayment.id) - ).resolves.toEqual( - expect.objectContaining({ - dataToTransmit - }) - ) }) test("Can't deposit for non-existent outgoing payment id", async (): Promise => { diff --git a/packages/backend/src/graphql/schema.graphql b/packages/backend/src/graphql/schema.graphql index c12a0ced1e..dc933c0814 100644 --- a/packages/backend/src/graphql/schema.graphql +++ b/packages/backend/src/graphql/schema.graphql @@ -604,7 +604,7 @@ input DepositOutgoingPaymentLiquidityInput { outgoingPaymentId: String! "Unique key to ensure duplicate or retried requests are processed only once. For more information, refer to [idempotency](https://rafiki.dev/apis/graphql/admin-api-overview/#idempotency)." idempotencyKey: String! - "Data to be encrypted and sent to the receiver." + "Data to transmit to the recipient during payment." dataToTransmit: String } diff --git a/packages/backend/src/open_payments/payment/incoming/model.ts b/packages/backend/src/open_payments/payment/incoming/model.ts index 5ab807e446..7478a03d7a 100644 --- a/packages/backend/src/open_payments/payment/incoming/model.ts +++ b/packages/backend/src/open_payments/payment/incoming/model.ts @@ -19,7 +19,8 @@ import { IncomingPaymentInitiationReason } from './types' export enum IncomingPaymentEventType { IncomingPaymentCreated = 'incoming_payment.created', IncomingPaymentExpired = 'incoming_payment.expired', - IncomingPaymentCompleted = 'incoming_payment.completed' + IncomingPaymentCompleted = 'incoming_payment.completed', + IncomingPaymentPartialPaymentReceived = 'incoming_payment.partial_payment_received' } export enum IncomingPaymentState { diff --git a/packages/backend/src/open_payments/payment/incoming/service.test.ts b/packages/backend/src/open_payments/payment/incoming/service.test.ts index 94db3826e6..267070ba43 100644 --- a/packages/backend/src/open_payments/payment/incoming/service.test.ts +++ b/packages/backend/src/open_payments/payment/incoming/service.test.ts @@ -1,3 +1,4 @@ +import { createDecipheriv, randomBytes } from 'node:crypto' import assert from 'assert' import { faker } from '@faker-js/faker' import { Knex } from 'knex' @@ -1118,6 +1119,113 @@ describe('Incoming Payment Service', (): void => { }) }) + describe('processPartialPayment', (): void => { + let incomingPayment: IncomingPayment + + const dbEncryptionOverride: Partial = { + dbEncryptionSecret: randomBytes(32).toString('base64') + } + + beforeEach(async (): Promise => { + incomingPayment = await createIncomingPayment(deps, { + walletAddressId, + tenantId, + initiationReason: IncomingPaymentInitiationReason.Admin + }) + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + test( + 'can process partial payment for incoming payment', + withConfigOverride( + () => config, + dbEncryptionOverride, + async (): Promise => { + const dataToTransmit = JSON.stringify({ + data: faker.internet.email() + }) + const processedPayment = + await incomingPaymentService.processPartialPayment( + incomingPayment.id, + dataToTransmit + ) + assert.ok(!isIncomingPaymentError(processedPayment)) + + const webhookEvent = await IncomingPaymentEvent.query(knex) + .where({ + incomingPaymentId: processedPayment.id, + type: IncomingPaymentEventType.IncomingPaymentPartialPaymentReceived + }) + .withGraphFetched('webhooks') + .first() + assert.ok(webhookEvent) + assert.ok(webhookEvent.data.dataToTransmit) + + const webhookDataToTransmit = JSON.parse( + webhookEvent.data.dataToTransmit as string + ) + const decipher = createDecipheriv( + 'aes-256-gcm', + Uint8Array.from( + Buffer.from(config.dbEncryptionSecret as string, 'base64') + ), + webhookDataToTransmit.iv + ) + decipher.setAuthTag( + Uint8Array.from(Buffer.from(webhookDataToTransmit.tag, 'base64')) + ) + let decrypted = decipher.update( + webhookDataToTransmit.cipherText, + 'base64', + 'utf8' + ) + decrypted += decipher.final('utf8') + + expect(decrypted).toEqual(dataToTransmit) + expect(webhookEvent.webhooks).toHaveLength(1) + } + ) + ) + + test( + 'does not encrypt transmitted data without configured encryption secret', + withConfigOverride( + () => config, + { + ...dbEncryptionOverride, + dbEncryptionSecret: undefined + }, + async (): Promise => { + const dataToTransmit = JSON.stringify({ + data: faker.internet.email() + }) + + const processedPayment = + await incomingPaymentService.processPartialPayment( + incomingPayment.id, + dataToTransmit + ) + assert.ok(!isIncomingPaymentError(processedPayment)) + + const webhookEvent = await IncomingPaymentEvent.query(knex) + .where({ + incomingPaymentId: processedPayment.id, + type: IncomingPaymentEventType.IncomingPaymentPartialPaymentReceived + }) + .withGraphFetched('webhooks') + .first() + assert.ok(webhookEvent) + + expect(webhookEvent.data.dataToTransmit).toEqual(dataToTransmit) + expect(webhookEvent.webhooks).toHaveLength(1) + } + ) + ) + }) + describe('getPage', (): void => { let receiverWalletAddress: MockWalletAddress let assetId: string diff --git a/packages/backend/src/open_payments/payment/incoming/service.ts b/packages/backend/src/open_payments/payment/incoming/service.ts index 2d33a90093..d3b1a26622 100644 --- a/packages/backend/src/open_payments/payment/incoming/service.ts +++ b/packages/backend/src/open_payments/payment/incoming/service.ts @@ -17,7 +17,7 @@ import { import { Amount } from '../../amount' import { IncomingPaymentError } from './errors' import { IAppConfig } from '../../../config/app' -import { poll } from '../../../shared/utils' +import { encryptDbData, poll } from '../../../shared/utils' import { AssetService } from '../../../asset/service' import { finalizeWebhookRecipients } from '../../../webhook/service' import { Pagination, SortOrder } from '../../../shared/baseModel' @@ -78,6 +78,10 @@ export interface IncomingPaymentService update( options: UpdateOptions ): Promise + processPartialPayment( + id: string, + dataToTransmit?: string + ): Promise } export interface ServiceDependencies extends BaseService { @@ -107,7 +111,9 @@ export async function createIncomingPaymentService( getWalletAddressPage: (options) => getWalletAddressPage(deps, options), processNext: () => processNextIncomingPayment(deps), update: (options) => updateIncomingPayment(deps, options), - getPage: (options) => getPage(deps, options) + getPage: (options) => getPage(deps, options), + processPartialPayment: (id, dataToTransmit) => + processPartialPayment(deps, id, dataToTransmit) } } @@ -549,6 +555,43 @@ async function addReceivedAmount( return payment } +async function processPartialPayment( + deps: ServiceDependencies, + id: string, + dataToTransmit?: string +): Promise { + const { config, knex } = deps + + const incomingPayment = await IncomingPayment.query(knex) + .findById(id) + .withGraphFetched('asset') + if (!incomingPayment) return IncomingPaymentError.UnknownPayment + + await IncomingPaymentEvent.query(knex).insertGraph({ + incomingPaymentId: incomingPayment.id, + type: IncomingPaymentEventType.IncomingPaymentPartialPaymentReceived, + data: { + ...incomingPayment.toData(0n), + dataToTransmit: + dataToTransmit && config.dbEncryptionSecret + ? encryptDbData(dataToTransmit, config.dbEncryptionSecret) + : dataToTransmit + }, + tenantId: incomingPayment.tenantId, + webhooks: finalizeWebhookRecipients( + { + tenantIds: [incomingPayment.tenantId], + sendToPosService: + incomingPayment.initiatedBy === IncomingPaymentInitiationReason.Card + }, + deps.config, + deps.logger + ) + }) + + return incomingPayment +} + async function getPage( deps: ServiceDependencies, options?: GetPageOptions diff --git a/packages/card-service/src/graphql/generated/graphql.ts b/packages/card-service/src/graphql/generated/graphql.ts index d46a643062..ea46a8a372 100644 --- a/packages/card-service/src/graphql/generated/graphql.ts +++ b/packages/card-service/src/graphql/generated/graphql.ts @@ -529,7 +529,7 @@ export type DepositEventLiquidityInput = { }; export type DepositOutgoingPaymentLiquidityInput = { - /** Data to be encrypted and sent to the receiver. */ + /** Data to transmit to the recipient during payment. */ dataToTransmit?: InputMaybe; /** Unique key to ensure duplicate or retried requests are processed only once. For more information, refer to [idempotency](https://rafiki.dev/apis/graphql/admin-api-overview/#idempotency). */ idempotencyKey: Scalars['String']['input']; diff --git a/packages/frontend/app/generated/graphql.ts b/packages/frontend/app/generated/graphql.ts index c4fb7c03f5..568dac4d5e 100644 --- a/packages/frontend/app/generated/graphql.ts +++ b/packages/frontend/app/generated/graphql.ts @@ -529,7 +529,7 @@ export type DepositEventLiquidityInput = { }; export type DepositOutgoingPaymentLiquidityInput = { - /** Data to be encrypted and sent to the receiver. */ + /** Data to transmit to the recipient during payment. */ dataToTransmit?: InputMaybe; /** Unique key to ensure duplicate or retried requests are processed only once. For more information, refer to [idempotency](https://rafiki.dev/apis/graphql/admin-api-overview/#idempotency). */ idempotencyKey: Scalars['String']['input']; diff --git a/packages/mock-account-service-lib/src/generated/graphql.ts b/packages/mock-account-service-lib/src/generated/graphql.ts index 0e1f200b88..6d7bf6ea69 100644 --- a/packages/mock-account-service-lib/src/generated/graphql.ts +++ b/packages/mock-account-service-lib/src/generated/graphql.ts @@ -529,7 +529,7 @@ export type DepositEventLiquidityInput = { }; export type DepositOutgoingPaymentLiquidityInput = { - /** Data to be encrypted and sent to the receiver. */ + /** Data to transmit to the recipient during payment. */ dataToTransmit?: InputMaybe; /** Unique key to ensure duplicate or retried requests are processed only once. For more information, refer to [idempotency](https://rafiki.dev/apis/graphql/admin-api-overview/#idempotency). */ idempotencyKey: Scalars['String']['input']; diff --git a/packages/point-of-sale/src/graphql/generated/graphql.ts b/packages/point-of-sale/src/graphql/generated/graphql.ts index 8985783532..bdd41b93c1 100644 --- a/packages/point-of-sale/src/graphql/generated/graphql.ts +++ b/packages/point-of-sale/src/graphql/generated/graphql.ts @@ -529,7 +529,7 @@ export type DepositEventLiquidityInput = { }; export type DepositOutgoingPaymentLiquidityInput = { - /** Data to be encrypted and sent to the receiver. */ + /** Data to transmit to the recipient during payment. */ dataToTransmit?: InputMaybe; /** Unique key to ensure duplicate or retried requests are processed only once. For more information, refer to [idempotency](https://rafiki.dev/apis/graphql/admin-api-overview/#idempotency). */ idempotencyKey: Scalars['String']['input']; diff --git a/test/test-lib/src/generated/graphql.ts b/test/test-lib/src/generated/graphql.ts index 0e1f200b88..6d7bf6ea69 100644 --- a/test/test-lib/src/generated/graphql.ts +++ b/test/test-lib/src/generated/graphql.ts @@ -529,7 +529,7 @@ export type DepositEventLiquidityInput = { }; export type DepositOutgoingPaymentLiquidityInput = { - /** Data to be encrypted and sent to the receiver. */ + /** Data to transmit to the recipient during payment. */ dataToTransmit?: InputMaybe; /** Unique key to ensure duplicate or retried requests are processed only once. For more information, refer to [idempotency](https://rafiki.dev/apis/graphql/admin-api-overview/#idempotency). */ idempotencyKey: Scalars['String']['input'];