diff --git a/packages/wallet/backend/src/rhyza/service.ts b/packages/wallet/backend/src/rhyza/service.ts index 69612e855..f32ae628c 100644 --- a/packages/wallet/backend/src/rhyza/service.ts +++ b/packages/wallet/backend/src/rhyza/service.ts @@ -7,6 +7,7 @@ import { BadRequest } from '@shared/backend' import { AccountService } from '@/account/service' import { GateHubClient } from '@/gatehub/client' import { SocketService } from '@/socket/service' +import MessageType from '@/socket/messageType' import { Env } from '@/config/env' import { TransactionTypeEnum } from '@/gatehub/consts' import { RhyzaAdminClient } from './admin-client' @@ -15,7 +16,8 @@ import { Amount } from './types' export enum EventType { PaymentIntentCreated = 'payment_intent.created', - PaymentIntentCompleted = 'payment_intent.completed' + PaymentIntentCompleted = 'payment_intent.completed', + PaymentCompleted = 'payment.completed' } export interface WebHook { @@ -56,6 +58,9 @@ export class RhyzaService implements IRhyzaService { case EventType.PaymentIntentCompleted: await this.handlePaymentIntentCompleted(wh) break + case EventType.PaymentCompleted: + await this.handlePaymentCompleted(wh) + break } } @@ -115,6 +120,10 @@ export class RhyzaService implements IRhyzaService { amount = this.parseAmount(wh.data.receivedAmount as AmountJSON) } + if ([EventType.PaymentCompleted].includes(wh.type)) { + amount = this.parseAmount(wh.data.debitAmount as AmountJSON) + } + if (!amount) { throw new BadRequest('Unable to extract amount from webhook') } @@ -186,4 +195,57 @@ export class RhyzaService implements IRhyzaService { } into ${receiverWallet} ` ) } + + private async handlePaymentCompleted(wh: WebHook) { + const walletAddress = await this.getWalletAddress(wh) + const debitAmount = this.getAmountFromWebhook(wh) + + const { + gateHubWalletId: sendingWallet, + userId, + gateHubUserId + } = await this.accountService.getGateHubWalletAddress(walletAddress) + + if (!this.validateAmount(debitAmount, wh.type)) { + return + } + + await this.gateHubClient.createTransaction( + { + amount: this.amountToNumber(debitAmount), + vault_uuid: this.gateHubClient.getVaultUuid(debitAmount.assetCode), + sending_address: sendingWallet, + receiving_address: this.env.GATEHUB_SETTLEMENT_WALLET_ADDRESS, + type: TransactionTypeEnum.HOSTED, + message: 'Transfer' + }, + gateHubUserId + ) + + await this.transactionService.updateTransaction( + { paymentId: wh.data.id }, + { status: 'COMPLETED', value: BigInt(debitAmount.value) } + ) + + const isExchange = NodeCacheInstance.get(wh.data.id) + if (userId && !isExchange) { + const messageType = + wh.data.metadata?.type === 'instant' + ? MessageType.MONEY_SENT_SHOP + : MessageType.MONEY_SENT + await this.socketService.emitMoneySentByUserId( + userId.toString(), + debitAmount, + messageType + ) + } + + this.logger.info( + `Succesfully transfered ${this.amountToNumber( + debitAmount + )} from ${sendingWallet} to settlement account on ${ + EventType.PaymentCompleted + }` + ) + } } diff --git a/packages/wallet/backend/src/rhyza/validation.ts b/packages/wallet/backend/src/rhyza/validation.ts index e37f7e937..13a729a17 100644 --- a/packages/wallet/backend/src/rhyza/validation.ts +++ b/packages/wallet/backend/src/rhyza/validation.ts @@ -23,6 +23,27 @@ const paymentIntentSchema = z.object({ .optional() }) +const paymentSchema = z.object({ + id: z.string().uuid(), + walletAddressId: z.string().uuid(), + paymentIntentId: z.string().uuid().optional(), + state: z.enum([ + 'CREATED', + 'PROCESSING', + 'SENDING', + 'CONFIRMED', + 'REJECTED', + 'COMPLETED', + 'CANCELLED', + 'FAILED' + ]), + createdAt: z.string(), + debitAmount: amountSchema, + sentAmount: amountSchema, + receiveAmount: amountSchema, + metadata: z.record(z.unknown()).optional() +}) + export const paymentIntentWebhookSchema = z.object({ id: z.string({ required_error: 'id is required' }), type: z.enum([ @@ -32,8 +53,15 @@ export const paymentIntentWebhookSchema = z.object({ data: paymentIntentSchema }) +export const paymentWebhookSchema = z.object({ + id: z.string({ required_error: 'id is required' }), + type: z.enum([EventType.PaymentCompleted]), + data: paymentSchema +}) + export const webhookSchema = z.discriminatedUnion('type', [ - paymentIntentWebhookSchema + paymentIntentWebhookSchema, + paymentWebhookSchema ]) export const webhookBodySchema = z.object({ diff --git a/packages/wallet/backend/tests/mocks.ts b/packages/wallet/backend/tests/mocks.ts index 6f87589e5..f69a5dac3 100644 --- a/packages/wallet/backend/tests/mocks.ts +++ b/packages/wallet/backend/tests/mocks.ts @@ -406,6 +406,39 @@ export function mockPaymentIntentCompletedEvent( }) } +export function mockPaymentCompletedEvent( + overrides?: Partial +): RhyzaWebhookType { + return { + id: faker.string.uuid(), + type: RhyzaEventType.PaymentCompleted, + ...overrides, + data: { + id: faker.string.uuid(), + walletAddressId: faker.string.uuid(), + paymentIntentId: faker.string.uuid(), + state: 'COMPLETED', + createdAt: faker.date.recent().toISOString(), + debitAmount: { + value: '1000', + assetCode: 'USD', + assetScale: 2 + }, + sentAmount: { + value: '1000', + assetCode: 'USD', + assetScale: 2 + }, + receiveAmount: { + value: '1000', + assetCode: 'USD', + assetScale: 2 + }, + ...overrides?.data + } + } +} + export function mockIncomingPaymentRequest( overrides?: Partial ): IncomingPaymentCreated { diff --git a/packages/wallet/backend/tests/rhyza/service.test.ts b/packages/wallet/backend/tests/rhyza/service.test.ts index a88f40438..b6ba73ac9 100644 --- a/packages/wallet/backend/tests/rhyza/service.test.ts +++ b/packages/wallet/backend/tests/rhyza/service.test.ts @@ -7,7 +7,9 @@ import type { AccountService } from '@/account/service' import type { SocketService } from '@/socket/service' import type { Env } from '@/config/env' import type { RhyzaAdminClient } from '@/rhyza/admin-client' +import MessageType from '@/socket/messageType' import { + mockPaymentCompletedEvent, mockPaymentIntentCompletedEvent, mockPaymentIntentCreatedEvent } from '../mocks' @@ -27,7 +29,10 @@ describe('Rhyza Service', () => { findByIdWithoutValidation: jest.Mock getByUrl: jest.Mock } - let socketService: { emitMoneyReceivedByUserId: jest.Mock } + let socketService: { + emitMoneyReceivedByUserId: jest.Mock + emitMoneySentByUserId: jest.Mock + } let gateHubClient: { createTransaction: jest.Mock getVaultUuid: jest.Mock @@ -48,15 +53,19 @@ describe('Rhyza Service', () => { findByIdWithoutValidation: jest.fn().mockResolvedValue(walletAddress), getByUrl: jest.fn().mockResolvedValue(undefined) } - socketService = { emitMoneyReceivedByUserId: jest.fn() } + socketService = { + emitMoneyReceivedByUserId: jest.fn(), + emitMoneySentByUserId: jest.fn() + } gateHubClient = { createTransaction: jest.fn().mockResolvedValue(undefined), getVaultUuid: jest.fn().mockReturnValue('vault-id') } accountService = { getGateHubWalletAddress: jest.fn().mockResolvedValue({ - gateHubWalletId: 'receiver-wallet-id', - userId: 'user-id' + gateHubWalletId: 'user-wallet-id', + userId: 'user-id', + gateHubUserId: 'gatehub-user-id' }) } rhyzaAdminClient = { @@ -125,7 +134,7 @@ describe('Rhyza Service', () => { expect(gateHubClient.createTransaction).toHaveBeenCalledWith({ amount: 12.34, vault_uuid: 'vault-id', - receiving_address: 'receiver-wallet-id', + receiving_address: 'user-wallet-id', sending_address: 'settlement-wallet', type: expect.anything(), message: 'Transfer' @@ -220,5 +229,117 @@ describe('Rhyza Service', () => { } ) }) + + it('should settle a completed payment and notify its sender', async () => { + const paymentEvent = mockPaymentCompletedEvent({ + data: { + id: 'payment-id', + walletAddressId: walletAddress.id, + debitAmount: { + value: '1234', + assetCode: 'USD', + assetScale: 2 + } + } + }) + + await service.onWebHook(paymentEvent) + + expect( + walletAddressService.findByIdWithoutValidation + ).toHaveBeenCalledWith(walletAddress.id) + expect(gateHubClient.createTransaction).toHaveBeenCalledWith( + { + amount: 12.34, + vault_uuid: 'vault-id', + sending_address: 'user-wallet-id', + receiving_address: 'settlement-wallet', + type: expect.anything(), + message: 'Transfer' + }, + 'gatehub-user-id' + ) + expect(transactionService.updateTransaction).toHaveBeenCalledWith( + { paymentId: 'payment-id' }, + { status: 'COMPLETED', value: 1234n } + ) + expect(socketService.emitMoneySentByUserId).toHaveBeenCalledWith( + 'user-id', + { + value: '1234', + assetCode: 'USD', + assetScale: 2 + }, + MessageType.MONEY_SENT + ) + }) + + it('should notify with the shop message type for instant payments', async () => { + const paymentEvent = mockPaymentCompletedEvent({ + data: { + walletAddressId: walletAddress.id, + metadata: { type: 'instant' } + } + }) + + await service.onWebHook(paymentEvent) + + expect(socketService.emitMoneySentByUserId).toHaveBeenCalledWith( + 'user-id', + expect.anything(), + MessageType.MONEY_SENT_SHOP + ) + }) + + it('should skip settlement and completion for a zero debit amount', async () => { + const paymentEvent = mockPaymentCompletedEvent({ + data: { + walletAddressId: walletAddress.id, + debitAmount: { + value: '0', + assetCode: 'USD', + assetScale: 2 + } + } + }) + + await service.onWebHook(paymentEvent) + + expect(gateHubClient.createTransaction).not.toHaveBeenCalled() + expect(transactionService.updateTransaction).not.toHaveBeenCalled() + expect(socketService.emitMoneySentByUserId).not.toHaveBeenCalled() + }) + + it('should not complete a payment if the GateHub settlement fails', async () => { + const paymentEvent = mockPaymentCompletedEvent({ + data: { walletAddressId: walletAddress.id } + }) + gateHubClient.createTransaction.mockRejectedValue( + new Error('GateHub transfer failed') + ) + + await expect(service.onWebHook(paymentEvent)).rejects.toThrow( + 'GateHub transfer failed' + ) + + expect(transactionService.updateTransaction).not.toHaveBeenCalled() + expect(socketService.emitMoneySentByUserId).not.toHaveBeenCalled() + }) + + it('should not settle a payment for a wallet address without a GateHub account', async () => { + const paymentEvent = mockPaymentCompletedEvent({ + data: { walletAddressId: walletAddress.id } + }) + accountService.getGateHubWalletAddress.mockRejectedValue( + new Error('No account associated to the provided wallet address') + ) + + await expect(service.onWebHook(paymentEvent)).rejects.toThrow( + 'No account associated to the provided wallet address' + ) + + expect(gateHubClient.createTransaction).not.toHaveBeenCalled() + expect(transactionService.updateTransaction).not.toHaveBeenCalled() + }) }) }) diff --git a/packages/wallet/backend/tests/rhyza/validation.test.ts b/packages/wallet/backend/tests/rhyza/validation.test.ts new file mode 100644 index 000000000..846291283 --- /dev/null +++ b/packages/wallet/backend/tests/rhyza/validation.test.ts @@ -0,0 +1,46 @@ +import { webhookBodySchema } from '@/rhyza/validation' +import { + mockPaymentCompletedEvent, + mockPaymentIntentCompletedEvent +} from '../mocks' + +describe('Rhyza webhook validation', () => { + it('should accept a payment completed event and keep its metadata', () => { + const event = mockPaymentCompletedEvent({ + data: { metadata: { type: 'instant' } } + }) + + const result = webhookBodySchema.safeParse({ body: event }) + + expect(result.success).toBe(true) + expect(result.data?.body.data.metadata).toEqual({ type: 'instant' }) + }) + + it('should still accept a payment intent completed event', () => { + const result = webhookBodySchema.safeParse({ + body: mockPaymentIntentCompletedEvent() + }) + + expect(result.success).toBe(true) + }) + + it.each(['debitAmount', 'walletAddressId', 'state'])( + 'should reject a payment completed event without %s', + (field) => { + const event = mockPaymentCompletedEvent() + delete (event.data as Record)[field] + + const result = webhookBodySchema.safeParse({ body: event }) + + expect(result.success).toBe(false) + } + ) + + it('should reject an unsupported event type', () => { + const event = { ...mockPaymentCompletedEvent(), type: 'unknown.event' } + + const result = webhookBodySchema.safeParse({ body: event }) + + expect(result.success).toBe(false) + }) +})