Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 62 additions & 10 deletions packages/wallet/backend/src/rhyza/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
}

Expand Down Expand Up @@ -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
)

Expand Down Expand Up @@ -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
)
}
}
2 changes: 1 addition & 1 deletion packages/wallet/backend/src/rhyza/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
})

Expand Down
18 changes: 18 additions & 0 deletions packages/wallet/backend/tests/mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -439,6 +439,24 @@ export function mockPaymentCompletedEvent(
}
}

export function mockPaymentFailedEvent(
overrides?: Partial<RhyzaWebHook>
): 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['body']>
): IncomingPaymentCreated {
Expand Down
102 changes: 102 additions & 0 deletions packages/wallet/backend/tests/rhyza/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type { RhyzaAdminClient } from '@/rhyza/admin-client'
import MessageType from '@/socket/messageType'
import {
mockPaymentCompletedEvent,
mockPaymentFailedEvent,
mockPaymentIntentCompletedEvent,
mockPaymentIntentCreatedEvent
} from '../mocks'
Expand Down Expand Up @@ -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 }
)
})
})
})
9 changes: 9 additions & 0 deletions packages/wallet/backend/tests/rhyza/validation.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { webhookBodySchema } from '@/rhyza/validation'
import {
mockPaymentCompletedEvent,
mockPaymentFailedEvent,
mockPaymentIntentCompletedEvent
} from '../mocks'

Expand All @@ -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()
Expand Down
Loading