diff --git a/packages/wallet/backend/src/rhyza/service.ts b/packages/wallet/backend/src/rhyza/service.ts index f32ae628c..a8e931351 100644 --- a/packages/wallet/backend/src/rhyza/service.ts +++ b/packages/wallet/backend/src/rhyza/service.ts @@ -17,7 +17,8 @@ import { Amount } from './types' export enum EventType { PaymentIntentCreated = 'payment_intent.created', PaymentIntentCompleted = 'payment_intent.completed', - PaymentCompleted = 'payment.completed' + PaymentCompleted = 'payment.completed', + PaymentFailed = 'payment.failed' } export interface WebHook { @@ -61,6 +62,9 @@ export class RhyzaService implements IRhyzaService { case EventType.PaymentCompleted: await this.handlePaymentCompleted(wh) break + case EventType.PaymentFailed: + await this.handlePaymentFailed(wh) + break } } @@ -210,15 +214,9 @@ export class RhyzaService implements IRhyzaService { 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' - }, + await this.settleToSettlementWallet( + debitAmount, + sendingWallet, gateHubUserId ) @@ -248,4 +246,58 @@ export class RhyzaService implements IRhyzaService { }` ) } + + private async handlePaymentFailed(wh: WebHook) { + const sentAmount = this.parseAmount(wh.data.sentAmount as AmountJSON) + + if (BigInt(sentAmount.value) <= 0n) { + await this.transactionService.updateTransaction( + { paymentId: wh.data.id }, + { status: 'FAILED', value: 0n } + ) + return + } + + const walletAddress = await this.getWalletAddress(wh) + const { gateHubWalletId: sendingWallet, gateHubUserId } = + await this.accountService.getGateHubWalletAddress(walletAddress) + + // A partially sent payment still settles what actually left the wallet + await this.settleToSettlementWallet( + sentAmount, + sendingWallet, + gateHubUserId + ) + + await this.transactionService.updateTransaction( + { paymentId: wh.data.id }, + { status: 'COMPLETED', value: BigInt(sentAmount.value) } + ) + + this.logger.info( + `Succesfully transfered ${this.amountToNumber( + sentAmount + )} from ${sendingWallet} to settlement account on ${ + EventType.PaymentFailed + }` + ) + } + + private async settleToSettlementWallet( + amount: Amount, + sendingWallet: string, + gateHubUserId: string + ) { + await this.gateHubClient.createTransaction( + { + amount: this.amountToNumber(amount), + vault_uuid: this.gateHubClient.getVaultUuid(amount.assetCode), + sending_address: sendingWallet, + receiving_address: this.env.GATEHUB_SETTLEMENT_WALLET_ADDRESS, + type: TransactionTypeEnum.HOSTED, + message: 'Transfer' + }, + gateHubUserId + ) + } } diff --git a/packages/wallet/backend/src/rhyza/validation.ts b/packages/wallet/backend/src/rhyza/validation.ts index 13a729a17..a80522632 100644 --- a/packages/wallet/backend/src/rhyza/validation.ts +++ b/packages/wallet/backend/src/rhyza/validation.ts @@ -55,7 +55,7 @@ export const paymentIntentWebhookSchema = z.object({ export const paymentWebhookSchema = z.object({ id: z.string({ required_error: 'id is required' }), - type: z.enum([EventType.PaymentCompleted]), + type: z.enum([EventType.PaymentCompleted, EventType.PaymentFailed]), data: paymentSchema }) diff --git a/packages/wallet/backend/tests/mocks.ts b/packages/wallet/backend/tests/mocks.ts index f69a5dac3..fdd7232ee 100644 --- a/packages/wallet/backend/tests/mocks.ts +++ b/packages/wallet/backend/tests/mocks.ts @@ -439,6 +439,24 @@ export function mockPaymentCompletedEvent( } } +export function mockPaymentFailedEvent( + overrides?: Partial +): RhyzaWebhookType { + return mockPaymentCompletedEvent({ + type: RhyzaEventType.PaymentFailed, + ...overrides, + data: { + state: 'FAILED', + sentAmount: { + value: '0', + 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 b6ba73ac9..3e2f10c94 100644 --- a/packages/wallet/backend/tests/rhyza/service.test.ts +++ b/packages/wallet/backend/tests/rhyza/service.test.ts @@ -10,6 +10,7 @@ import type { RhyzaAdminClient } from '@/rhyza/admin-client' import MessageType from '@/socket/messageType' import { mockPaymentCompletedEvent, + mockPaymentFailedEvent, mockPaymentIntentCompletedEvent, mockPaymentIntentCreatedEvent } from '../mocks' @@ -341,5 +342,106 @@ describe('Rhyza Service', () => { expect(gateHubClient.createTransaction).not.toHaveBeenCalled() expect(transactionService.updateTransaction).not.toHaveBeenCalled() }) + + it('should mark a failed payment with nothing sent as failed', async () => { + const paymentEvent = mockPaymentFailedEvent({ + data: { id: 'payment-id', walletAddressId: walletAddress.id } + }) + + await service.onWebHook(paymentEvent) + + expect(transactionService.updateTransaction).toHaveBeenCalledTimes(1) + expect(transactionService.updateTransaction).toHaveBeenCalledWith( + { paymentId: 'payment-id' }, + { status: 'FAILED', value: 0n } + ) + expect(gateHubClient.createTransaction).not.toHaveBeenCalled() + expect(socketService.emitMoneySentByUserId).not.toHaveBeenCalled() + }) + + it('should settle the sent amount of a partially sent failed payment', async () => { + const paymentEvent = mockPaymentFailedEvent({ + data: { + id: 'payment-id', + walletAddressId: walletAddress.id, + sentAmount: { + value: '500', + assetCode: 'USD', + assetScale: 2 + } + } + }) + + await service.onWebHook(paymentEvent) + + expect(gateHubClient.createTransaction).toHaveBeenCalledWith( + { + amount: 5, + vault_uuid: 'vault-id', + sending_address: 'user-wallet-id', + receiving_address: 'settlement-wallet', + type: expect.anything(), + message: 'Transfer' + }, + 'gatehub-user-id' + ) + expect(transactionService.updateTransaction).toHaveBeenCalledTimes(1) + expect(transactionService.updateTransaction).toHaveBeenCalledWith( + { paymentId: 'payment-id' }, + { status: 'COMPLETED', value: 500n } + ) + }) + + it('should not update a partially sent failed payment if the GateHub settlement fails', async () => { + const paymentEvent = mockPaymentFailedEvent({ + data: { + walletAddressId: walletAddress.id, + sentAmount: { + value: '500', + assetCode: 'USD', + assetScale: 2 + } + } + }) + gateHubClient.createTransaction.mockRejectedValue( + new Error('GateHub transfer failed') + ) + + await expect(service.onWebHook(paymentEvent)).rejects.toThrow( + 'GateHub transfer failed' + ) + + expect(transactionService.updateTransaction).not.toHaveBeenCalled() + }) + + it('should settle a partially sent failed payment regardless of its debit amount', async () => { + const paymentEvent = mockPaymentFailedEvent({ + data: { + id: 'payment-id', + walletAddressId: walletAddress.id, + debitAmount: { + value: '0', + assetCode: 'USD', + assetScale: 2 + }, + sentAmount: { + value: '500', + assetCode: 'USD', + assetScale: 2 + } + } + }) + + await service.onWebHook(paymentEvent) + + expect(gateHubClient.createTransaction).toHaveBeenCalledWith( + expect.objectContaining({ amount: 5 }), + 'gatehub-user-id' + ) + expect(transactionService.updateTransaction).toHaveBeenCalledWith( + { paymentId: 'payment-id' }, + { status: 'COMPLETED', value: 500n } + ) + }) }) }) diff --git a/packages/wallet/backend/tests/rhyza/validation.test.ts b/packages/wallet/backend/tests/rhyza/validation.test.ts index 846291283..01528f0e8 100644 --- a/packages/wallet/backend/tests/rhyza/validation.test.ts +++ b/packages/wallet/backend/tests/rhyza/validation.test.ts @@ -1,6 +1,7 @@ import { webhookBodySchema } from '@/rhyza/validation' import { mockPaymentCompletedEvent, + mockPaymentFailedEvent, mockPaymentIntentCompletedEvent } from '../mocks' @@ -16,6 +17,14 @@ describe('Rhyza webhook validation', () => { expect(result.data?.body.data.metadata).toEqual({ type: 'instant' }) }) + it('should accept a payment failed event', () => { + const result = webhookBodySchema.safeParse({ + body: mockPaymentFailedEvent() + }) + + expect(result.success).toBe(true) + }) + it('should still accept a payment intent completed event', () => { const result = webhookBodySchema.safeParse({ body: mockPaymentIntentCompletedEvent()