From 3711eb6840179b6a9027ba8a04a465998258aad2 Mon Sep 17 00:00:00 2001 From: dragosp1011 Date: Tue, 30 Jun 2026 15:33:24 +0300 Subject: [PATCH 1/3] feat(stripe): handle refund webhook events --- ...d_transactions_payment_id_source_unique.js | 19 + .../backend/src/stripe-integration/service.ts | 260 +++++++- .../src/stripe-integration/validation.ts | 65 +- .../stripe-integration/controller.test.ts | 146 +++++ .../tests/stripe-integration/service.test.ts | 586 ++++++++++++++++-- 5 files changed, 1018 insertions(+), 58 deletions(-) create mode 100644 packages/wallet/backend/migrations/20260625220000_add_transactions_payment_id_source_unique.js diff --git a/packages/wallet/backend/migrations/20260625220000_add_transactions_payment_id_source_unique.js b/packages/wallet/backend/migrations/20260625220000_add_transactions_payment_id_source_unique.js new file mode 100644 index 000000000..02d0c772e --- /dev/null +++ b/packages/wallet/backend/migrations/20260625220000_add_transactions_payment_id_source_unique.js @@ -0,0 +1,19 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = function (knex) { + return knex.schema.alterTable('transactions', (table) => { + table.unique(['paymentId', 'source']) + }) +} + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function (knex) { + return knex.schema.alterTable('transactions', (table) => { + table.dropUnique(['paymentId', 'source']) + }) +} diff --git a/packages/wallet/backend/src/stripe-integration/service.ts b/packages/wallet/backend/src/stripe-integration/service.ts index 31975a06c..90bc0c2d0 100644 --- a/packages/wallet/backend/src/stripe-integration/service.ts +++ b/packages/wallet/backend/src/stripe-integration/service.ts @@ -3,16 +3,32 @@ import { Logger } from 'winston' import { GateHubClient } from '../gatehub/client' import { Env } from '../config/env' import { TransactionTypeEnum } from '../gatehub/consts' -import { StripeWebhookType } from './validation' +import { + ChargeRefundedWebhook, + PaymentIntentCanceledWebhook, + PaymentIntentFailedWebhook, + PaymentIntentSucceededWebhook, + RefundCreatedWebhook, + RefundFailedWebhook, + RefundUpdatedWebhook, + StripeRefundObject, + StripeWebhookType +} from './validation' import { WalletAddressService } from '../walletAddress/service' import { AccountService } from '../account/service' import { Transaction } from '../transaction/model' import { transformBalance, applyScale } from '../utils/helpers' +import { Account } from '../account/model' +import { UniqueViolationError } from 'objection' export enum EventType { payment_intent_canceled = 'payment_intent.canceled', payment_intent_payment_failed = 'payment_intent.payment_failed', - payment_intent_succeeded = 'payment_intent.succeeded' + payment_intent_succeeded = 'payment_intent.succeeded', + refund_created = 'refund.created', + refund_updated = 'refund.updated', + refund_failed = 'refund.failed', + charge_refunded = 'charge.refunded' } interface IStripeService { @@ -36,15 +52,39 @@ export class StripeService implements IStripeService { await this.handlePaymentIntentSucceeded(wh) break case EventType.payment_intent_payment_failed: - await this.handlePaymentIntentFailed(wh) + this.handlePaymentIntentFailed(wh) break case EventType.payment_intent_canceled: - await this.handlePaymentIntentCanceled(wh) + this.handlePaymentIntentCanceled(wh) + break + case EventType.refund_created: + await this.handleRefundCreated(wh) + break + case EventType.refund_updated: + await this.handleRefundUpdated(wh) + break + case EventType.refund_failed: + this.handleRefundFailed(wh) + break + case EventType.charge_refunded: + this.handleChargeRefunded(wh) break } } - private async handlePaymentIntentSucceeded(wh: StripeWebhookType) { + private refundDescription(paymentIntentId: string): string { + return `Stripe Refund (${paymentIntentId})` + } + + private logRefundAlreadyProcessed(refundId: string): void { + this.logger.info('Refund already processed', { + refund_id: refundId + }) + } + + private async handlePaymentIntentSucceeded( + wh: PaymentIntentSucceededWebhook + ) { const paymentIntent = wh.data.object const metadata = paymentIntent.metadata const receiving_address: string = metadata.receiving_address @@ -57,7 +97,11 @@ export class StripeService implements IStripeService { await this.walletAddressService.getByUrl(receiving_address) if (!walletAddress) { - throw new BadRequest('Wallet address not found') + this.logger.error('Wallet address not found for payment intent', { + payment_intent_id: paymentIntent.id, + receiving_address + }) + throw new BadRequest('Failed to create transaction') } const { gateHubWalletId } = @@ -84,13 +128,16 @@ export class StripeService implements IStripeService { source: 'Stripe' }) } catch (error) { - this.logger.error('Error creating gatehub transaction', { error }) + this.logger.error('Error creating gatehub transaction', { + payment_intent_id: paymentIntent.id, + receiving_address, + error + }) throw new BadRequest('Failed to create transaction') } } - private async handlePaymentIntentFailed(wh: StripeWebhookType) { - // No need to take action on the GateHub side as no funds were transferred + private handlePaymentIntentFailed(wh: PaymentIntentFailedWebhook) { const paymentIntent = wh.data.object const metadata = paymentIntent.metadata const receiving_address = metadata.receiving_address @@ -102,12 +149,203 @@ export class StripeService implements IStripeService { }) } - private async handlePaymentIntentCanceled(wh: StripeWebhookType) { + private handlePaymentIntentCanceled(wh: PaymentIntentCanceledWebhook) { const paymentIntent = wh.data.object - // No action needed on GateHub side as payment was canceled this.logger.info('Payment intent canceled', { payment_intent_id: paymentIntent.id, receiving_address: paymentIntent.metadata.receiving_address }) } + + private async handleRefundCreated(wh: RefundCreatedWebhook) { + const refund = wh.data.object + this.logger.info('Refund created', { + refund_id: refund.id, + payment_intent_id: refund.payment_intent, + amount: refund.amount, + currency: refund.currency, + status: refund.status + }) + + if (refund.status === 'succeeded') { + await this.processSucceededRefund(refund) + } + } + + private handleRefundFailed(wh: RefundFailedWebhook) { + const refund = wh.data.object + this.logger.warn('Refund failed', { + refund_id: refund.id, + payment_intent_id: refund.payment_intent, + amount: refund.amount, + currency: refund.currency, + failure_reason: refund.failure_reason + }) + } + + private handleChargeRefunded(wh: ChargeRefundedWebhook) { + const charge = wh.data.object + this.logger.info('Charge refunded', { + charge_id: charge.id, + payment_intent_id: charge.payment_intent, + amount_refunded: charge.amount_refunded, + refunded: charge.refunded + }) + } + + private async handleRefundUpdated(wh: RefundUpdatedWebhook) { + const refund = wh.data.object + + if (refund.status !== 'succeeded') { + this.logger.info('Refund updated', { + refund_id: refund.id, + payment_intent_id: refund.payment_intent, + status: refund.status + }) + return + } + + await this.processSucceededRefund(refund) + } + + private async processSucceededRefund(refund: StripeRefundObject) { + const existingRefund = await Transaction.query().findOne({ + paymentId: refund.id, + source: 'Stripe' + }) + + if (existingRefund) { + this.logRefundAlreadyProcessed(refund.id) + return + } + + const paymentIntentId = refund.payment_intent + const description = this.refundDescription(paymentIntentId) + + const originalTx = await Transaction.query().findOne({ + paymentId: paymentIntentId, + source: 'Stripe', + type: 'INCOMING' + }) + + if (!originalTx) { + this.logger.error('Original Stripe payment not found', { + refund_id: refund.id, + payment_intent_id: paymentIntentId + }) + return + } + + const scaledAmount = applyScale(refund.amount) + const refundValue = transformBalance(scaledAmount, 2) + const currency = refund.currency.toUpperCase() + + if (currency !== originalTx.assetCode) { + this.logger.error('Refund currency does not match original payment', { + refund_id: refund.id, + payment_intent_id: paymentIntentId, + refund_currency: currency, + original_currency: originalTx.assetCode + }) + return + } + + if (!originalTx.walletAddressId) { + this.logger.error('Original payment has no wallet address', { + refund_id: refund.id, + payment_intent_id: paymentIntentId, + original_transaction_id: originalTx.id + }) + return + } + + const account = await Account.query().findById(originalTx.accountId) + if (!account) { + this.logger.error('Account not found for original payment', { + refund_id: refund.id, + payment_intent_id: paymentIntentId, + account_id: originalTx.accountId + }) + return + } + + let gateHubWalletId: string + let gateHubUserId: string + try { + const walletAddress = await this.walletAddressService.getById({ + walletAddressId: originalTx.walletAddressId, + accountId: originalTx.accountId, + userId: account.userId + }) + const gateHubWallet = + await this.accountService.getGateHubWalletAddress(walletAddress) + gateHubWalletId = gateHubWallet.gateHubWalletId + gateHubUserId = gateHubWallet.gateHubUserId + } catch (error) { + this.logger.error( + 'Wallet address not found or inactive for original payment', + { + refund_id: refund.id, + payment_intent_id: paymentIntentId, + wallet_address_id: originalTx.walletAddressId, + error + } + ) + return + } + + const availableBalance = + await this.accountService.getAccountBalance(account) + const availableValue = transformBalance(availableBalance, 2) + + if (availableValue < refundValue) { + this.logger.error('Insufficient funds for refund reversal', { + refund_id: refund.id, + payment_intent_id: paymentIntentId, + required: scaledAmount, + available: availableBalance, + required_value: refundValue.toString(), + available_value: availableValue.toString() + }) + return + } + + try { + await this.gateHubClient.createTransaction( + { + amount: scaledAmount, + vault_uuid: this.gateHubClient.getVaultUuid(currency), + sending_address: gateHubWalletId, + receiving_address: this.env.GATEHUB_SETTLEMENT_WALLET_ADDRESS, + type: TransactionTypeEnum.HOSTED, + message: 'Stripe Refund' + }, + gateHubUserId + ) + + await Transaction.query().insert({ + walletAddressId: originalTx.walletAddressId, + accountId: originalTx.accountId, + paymentId: refund.id, + assetCode: currency, + value: refundValue, + type: 'OUTGOING', + status: 'COMPLETED', + description, + source: 'Stripe' + }) + } catch (error) { + if (error instanceof UniqueViolationError) { + this.logRefundAlreadyProcessed(refund.id) + return + } + + this.logger.error('Error reversing gatehub transaction for refund', { + error, + refund_id: refund.id, + payment_intent_id: paymentIntentId + }) + throw new Error('Failed to reverse transaction for refund') + } + } } diff --git a/packages/wallet/backend/src/stripe-integration/validation.ts b/packages/wallet/backend/src/stripe-integration/validation.ts index f0c0e1c17..1f1ea919d 100644 --- a/packages/wallet/backend/src/stripe-integration/validation.ts +++ b/packages/wallet/backend/src/stripe-integration/validation.ts @@ -39,10 +39,57 @@ const paymentIntentCanceledSchema = z.object({ }) }) +export const refundSchema = z.object({ + id: z.string(), + amount: z.number(), + currency: z.string(), + status: z.string(), + payment_intent: z.string().min(1), + charge: z.string().nullable().optional(), + failure_reason: z.string().nullable().optional() +}) + +const createRefundEventSchema = ( + type: + | typeof EventType.refund_created + | typeof EventType.refund_updated + | typeof EventType.refund_failed +) => + z.object({ + id: z.string({ required_error: 'id is required' }), + type: z.literal(type), + data: z.object({ + object: refundSchema + }) + }) + +const refundCreatedSchema = createRefundEventSchema(EventType.refund_created) +const refundUpdatedSchema = createRefundEventSchema(EventType.refund_updated) +const refundFailedSchema = createRefundEventSchema(EventType.refund_failed) + +const chargeSchema = z.object({ + id: z.string(), + payment_intent: z.string().nullable().optional(), + amount_refunded: z.number().optional(), + refunded: z.boolean().optional() +}) + +const chargeRefundedSchema = z.object({ + id: z.string({ required_error: 'id is required' }), + type: z.literal(EventType.charge_refunded), + data: z.object({ + object: chargeSchema + }) +}) + export const webhookSchema = z.discriminatedUnion('type', [ paymentIntentSucceededSchema, paymentIntentFailedSchema, - paymentIntentCanceledSchema + paymentIntentCanceledSchema, + refundCreatedSchema, + refundUpdatedSchema, + refundFailedSchema, + chargeRefundedSchema ]) export const webhookBodySchema = z.object({ @@ -50,3 +97,19 @@ export const webhookBodySchema = z.object({ }) export type StripeWebhookType = z.infer + +export type PaymentIntentSucceededWebhook = z.infer< + typeof paymentIntentSucceededSchema +> +export type PaymentIntentFailedWebhook = z.infer< + typeof paymentIntentFailedSchema +> +export type PaymentIntentCanceledWebhook = z.infer< + typeof paymentIntentCanceledSchema +> +export type RefundCreatedWebhook = z.infer +export type RefundUpdatedWebhook = z.infer +export type RefundFailedWebhook = z.infer +export type ChargeRefundedWebhook = z.infer +export type StripeRefundObject = z.infer +export type StripeChargeObject = z.infer diff --git a/packages/wallet/backend/tests/stripe-integration/controller.test.ts b/packages/wallet/backend/tests/stripe-integration/controller.test.ts index e5e5482f5..22d832259 100644 --- a/packages/wallet/backend/tests/stripe-integration/controller.test.ts +++ b/packages/wallet/backend/tests/stripe-integration/controller.test.ts @@ -257,5 +257,151 @@ describe('Stripe Controller', () => { expect(res.statusCode).toBe(400) expect(mockDeps.stripeService.onWebHook).not.toHaveBeenCalled() }) + + it('should verify and process refund.updated webhook successfully', async () => { + const mockDeps = createMockStripeControllerDeps() + + mockConstructEvent.mockReturnValue({}) + + req.body = Buffer.from( + JSON.stringify({ + id: 'webhookId123', + type: EventType.refund_updated, + data: { + object: { + id: 're_123456', + amount: 500, + currency: 'usd', + status: 'succeeded', + payment_intent: 'pi_123456', + charge: 'ch_123456' + } + } + }) + ) + + await stripeController.onWebHook(req, res, next) + + expect(mockDeps.stripeService.onWebHook).toHaveBeenCalledWith({ + id: 'webhookId123', + type: EventType.refund_updated, + data: { + object: { + id: 're_123456', + amount: 500, + currency: 'usd', + status: 'succeeded', + payment_intent: 'pi_123456', + charge: 'ch_123456' + } + } + }) + expect(res.statusCode).toBe(200) + }) + + it('should verify and process refund.created webhook successfully', async () => { + const mockDeps = createMockStripeControllerDeps() + + mockConstructEvent.mockReturnValue({}) + + req.body = Buffer.from( + JSON.stringify({ + id: 'webhookId123', + type: EventType.refund_created, + data: { + object: { + id: 're_123456', + amount: 500, + currency: 'usd', + status: 'pending', + payment_intent: 'pi_123456', + charge: 'ch_123456' + } + } + }) + ) + + await stripeController.onWebHook(req, res, next) + + expect(mockDeps.stripeService.onWebHook).toHaveBeenCalledWith({ + id: 'webhookId123', + type: EventType.refund_created, + data: { + object: { + id: 're_123456', + amount: 500, + currency: 'usd', + status: 'pending', + payment_intent: 'pi_123456', + charge: 'ch_123456' + } + } + }) + expect(res.statusCode).toBe(200) + }) + + it('should fail if refund webhook is missing required fields', async () => { + const mockDeps = createMockStripeControllerDeps() + + mockConstructEvent.mockReturnValue({}) + + req.body = Buffer.from( + JSON.stringify({ + id: 'webhookId123', + type: EventType.refund_updated, + data: { + object: { + id: 're_123456', + status: 'succeeded', + payment_intent: 'pi_123456' + } + } + }) + ) + + await stripeController.onWebHook(req, res, (err) => { + next() + errorHandler(err, req, res, next) + }) + + expect(mockConstructEvent).toHaveBeenCalled() + expect(next).toHaveBeenCalledTimes(1) + expect(mockDeps.logger.error).toHaveBeenCalled() + expect(res.statusCode).toBe(400) + expect(mockDeps.stripeService.onWebHook).not.toHaveBeenCalled() + }) + + it('should fail if refund webhook is missing payment_intent', async () => { + const mockDeps = createMockStripeControllerDeps() + + mockConstructEvent.mockReturnValue({}) + + req.body = Buffer.from( + JSON.stringify({ + id: 'webhookId123', + type: EventType.refund_updated, + data: { + object: { + id: 're_123456', + amount: 500, + currency: 'usd', + status: 'succeeded', + charge: 'ch_123456' + } + } + }) + ) + + await stripeController.onWebHook(req, res, (err) => { + next() + errorHandler(err, req, res, next) + }) + + expect(mockConstructEvent).toHaveBeenCalled() + expect(next).toHaveBeenCalledTimes(1) + expect(mockDeps.logger.error).toHaveBeenCalled() + expect(res.statusCode).toBe(400) + expect(mockDeps.stripeService.onWebHook).not.toHaveBeenCalled() + }) }) }) diff --git a/packages/wallet/backend/tests/stripe-integration/service.test.ts b/packages/wallet/backend/tests/stripe-integration/service.test.ts index 18d6d949a..80ea46052 100644 --- a/packages/wallet/backend/tests/stripe-integration/service.test.ts +++ b/packages/wallet/backend/tests/stripe-integration/service.test.ts @@ -14,6 +14,8 @@ import { Account } from '@/account/model' import { WalletAddress } from '@/walletAddress/model' import { loginUser } from '@/tests/utils' import { mockedListAssets } from '@/tests/mocks' +import { BadRequest } from '@shared/backend' +import { UniqueViolationError } from 'objection' describe('Stripe Service', (): void => { let bindings: AwilixContainer @@ -38,35 +40,115 @@ describe('Stripe Service', (): void => { } const mockWalletAddressService = { - getByUrl: jest.fn() + getByUrl: jest.fn(), + getById: jest.fn() } const mockAccountService = { - getGateHubWalletAddress: jest.fn() + getGateHubWalletAddress: jest.fn(), + getAccountBalance: jest.fn() } - const createMockWebhook = ( - type: EventType = EventType.payment_intent_succeeded, - overrides: Partial = {} - ): StripeWebhookType => ({ - id: faker.string.uuid(), - type, - data: { - object: { - id: 'pi_123456', - amount: 1000, - currency: 'usd', - metadata: { - receiving_address: 'wallet_address_123' - }, - last_payment_error: - type === EventType.payment_intent_payment_failed - ? 'Payment failed' - : null + const paymentIntentId = 'pi_123456' + + const createMockPaymentIntentWebhook = ( + type: + | EventType.payment_intent_succeeded + | EventType.payment_intent_payment_failed + | EventType.payment_intent_canceled = EventType.payment_intent_succeeded, + overrides: Record = {} + ): StripeWebhookType => + ({ + id: faker.string.uuid(), + type, + data: { + object: { + id: paymentIntentId, + amount: 1000, + currency: 'usd', + metadata: { + receiving_address: 'wallet_address_123' + }, + last_payment_error: + type === EventType.payment_intent_payment_failed + ? 'Payment failed' + : null + } + }, + ...overrides + }) as StripeWebhookType + + const createMockRefundWebhook = ( + type: + | EventType.refund_created + | EventType.refund_updated + | EventType.refund_failed, + overrides: { + refundId?: string + amount?: number + status?: string + currency?: string + paymentIntent?: string | null + failureReason?: string | null + webhookId?: string + } = {} + ): StripeWebhookType => { + const { + refundId = 're_123456', + amount = 500, + status = type === EventType.refund_updated ? 'succeeded' : 'pending', + currency = 'usd', + paymentIntent = paymentIntentId, + failureReason = type === EventType.refund_failed ? 'declined' : null, + webhookId = faker.string.uuid() + } = overrides + + return { + id: webhookId, + type, + data: { + object: { + id: refundId, + amount, + currency, + status, + payment_intent: paymentIntent, + charge: 'ch_123456', + failure_reason: failureReason + } } - }, - ...overrides - }) + } as StripeWebhookType + } + + const createMockChargeRefundedWebhook = (): StripeWebhookType => + ({ + id: faker.string.uuid(), + type: EventType.charge_refunded, + data: { + object: { + id: 'ch_123456', + payment_intent: paymentIntentId, + amount_refunded: 500, + refunded: false + } + } + }) as StripeWebhookType + + const insertOriginalStripePayment = async ( + value = 1000n + ): Promise => { + return Transaction.query().insert({ + walletAddressId: walletAddress.id, + accountId: account.id, + paymentId: paymentIntentId, + assetCode: 'USD', + value, + type: 'INCOMING', + status: 'COMPLETED', + description: 'Stripe Payment', + source: 'Stripe' + }) + } beforeAll(async (): Promise => { const testEnv = { ...env, USE_STRIPE: true } @@ -128,15 +210,19 @@ describe('Stripe Service', (): void => { mockGateHubClient.createTransaction.mockResolvedValue(undefined) mockWalletAddressService.getByUrl.mockResolvedValue(walletAddress) + mockWalletAddressService.getById.mockResolvedValue(walletAddress) + + mockAccountService.getAccountBalance.mockResolvedValue(100) mockAccountService.getGateHubWalletAddress.mockResolvedValue({ - gateHubWalletId: 'gatehub-wallet-123' + gateHubWalletId: 'gatehub-wallet-123', + gateHubUserId: 'test-gatehub-user' }) }) describe('onWebHook', (): void => { it('should handle payment_intent_succeeded event type', async (): Promise => { - const webhook = createMockWebhook() + const webhook = createMockPaymentIntentWebhook() await stripeService.onWebHook(webhook) @@ -154,8 +240,8 @@ describe('Stripe Service', (): void => { expect(transactions[0]).toMatchObject({ walletAddressId: walletAddress.id, accountId: account.id, - paymentId: webhook.data.object.id, - assetCode: webhook.data.object.currency.toUpperCase(), + paymentId: paymentIntentId, + assetCode: 'USD', type: 'INCOMING', status: 'COMPLETED', description: 'Stripe Payment', @@ -164,16 +250,18 @@ describe('Stripe Service', (): void => { }) it('should handle payment_intent_payment_failed event type', async (): Promise => { - const webhook = createMockWebhook(EventType.payment_intent_payment_failed) + const webhook = createMockPaymentIntentWebhook( + EventType.payment_intent_payment_failed + ) await stripeService.onWebHook(webhook) expect(mockLogger.warn).toHaveBeenCalledWith( 'Payment intent failed', expect.objectContaining({ - payment_intent_id: webhook.data.object.id, - receiving_address: webhook.data.object.metadata.receiving_address, - error: webhook.data.object.last_payment_error + payment_intent_id: paymentIntentId, + receiving_address: 'wallet_address_123', + error: 'Payment failed' }) ) expect(mockGateHubClient.createTransaction).not.toHaveBeenCalled() @@ -183,15 +271,17 @@ describe('Stripe Service', (): void => { }) it('should handle payment_intent_canceled event type', async (): Promise => { - const webhook = createMockWebhook(EventType.payment_intent_canceled) + const webhook = createMockPaymentIntentWebhook( + EventType.payment_intent_canceled + ) await stripeService.onWebHook(webhook) expect(mockLogger.info).toHaveBeenCalledWith( 'Payment intent canceled', expect.objectContaining({ - payment_intent_id: webhook.data.object.id, - receiving_address: webhook.data.object.metadata.receiving_address + payment_intent_id: paymentIntentId, + receiving_address: 'wallet_address_123' }) ) expect(mockGateHubClient.createTransaction).not.toHaveBeenCalled() @@ -201,7 +291,7 @@ describe('Stripe Service', (): void => { }) it('should log information about the received webhook', async (): Promise => { - const webhook = createMockWebhook() + const webhook = createMockPaymentIntentWebhook() await stripeService.onWebHook(webhook) @@ -218,7 +308,7 @@ describe('Stripe Service', (): void => { describe('handlePaymentIntentSucceeded', (): void => { it('should create transaction with correct parameters', async (): Promise => { - const webhook = createMockWebhook() + const webhook = createMockPaymentIntentWebhook() await Reflect.get(stripeService, 'handlePaymentIntentSucceeded').call( stripeService, @@ -239,8 +329,8 @@ describe('Stripe Service', (): void => { expect(transactions[0]).toMatchObject({ walletAddressId: walletAddress.id, accountId: account.id, - paymentId: webhook.data.object.id, - assetCode: webhook.data.object.currency.toUpperCase(), + paymentId: paymentIntentId, + assetCode: 'USD', type: 'INCOMING', status: 'COMPLETED', description: 'Stripe Payment', @@ -249,7 +339,7 @@ describe('Stripe Service', (): void => { }) it('should throw error when GateHub transaction creation fails', async (): Promise => { - const webhook = createMockWebhook() + const webhook = createMockPaymentIntentWebhook() mockGateHubClient.createTransaction.mockRejectedValueOnce( new Error('GateHub error') ) @@ -276,7 +366,9 @@ describe('Stripe Service', (): void => { describe('handlePaymentIntentFailed', (): void => { it('should log payment failure details', async (): Promise => { - const webhook = createMockWebhook(EventType.payment_intent_payment_failed) + const webhook = createMockPaymentIntentWebhook( + EventType.payment_intent_payment_failed + ) await Reflect.get(stripeService, 'handlePaymentIntentFailed').call( stripeService, @@ -286,9 +378,9 @@ describe('Stripe Service', (): void => { expect(mockLogger.warn).toHaveBeenCalledWith( 'Payment intent failed', expect.objectContaining({ - payment_intent_id: webhook.data.object.id, - receiving_address: webhook.data.object.metadata.receiving_address, - error: webhook.data.object.last_payment_error + payment_intent_id: paymentIntentId, + receiving_address: 'wallet_address_123', + error: 'Payment failed' }) ) @@ -299,7 +391,9 @@ describe('Stripe Service', (): void => { describe('handlePaymentIntentCanceled', (): void => { it('should log payment cancellation details', async (): Promise => { - const webhook = createMockWebhook(EventType.payment_intent_canceled) + const webhook = createMockPaymentIntentWebhook( + EventType.payment_intent_canceled + ) await Reflect.get(stripeService, 'handlePaymentIntentCanceled').call( stripeService, @@ -309,13 +403,413 @@ describe('Stripe Service', (): void => { expect(mockLogger.info).toHaveBeenCalledWith( 'Payment intent canceled', expect.objectContaining({ - payment_intent_id: webhook.data.object.id, - receiving_address: webhook.data.object.metadata.receiving_address + payment_intent_id: paymentIntentId, + receiving_address: 'wallet_address_123' + }) + ) + + const transactions = await Transaction.query() + expect(transactions).toHaveLength(0) + }) + }) + + describe('refund webhooks', (): void => { + it('should log refund.created without creating transactions when pending', async (): Promise => { + const webhook = createMockRefundWebhook(EventType.refund_created) + + await stripeService.onWebHook(webhook) + + expect(mockLogger.info).toHaveBeenCalledWith( + 'Refund created', + expect.objectContaining({ + refund_id: 're_123456', + payment_intent_id: paymentIntentId, + amount: 500, + currency: 'usd', + status: 'pending' }) ) + expect(mockGateHubClient.createTransaction).not.toHaveBeenCalled() const transactions = await Transaction.query() expect(transactions).toHaveLength(0) }) + + it('should process refund.created when status is succeeded', async (): Promise => { + await insertOriginalStripePayment() + const webhook = createMockRefundWebhook(EventType.refund_created, { + status: 'succeeded' + }) + + await stripeService.onWebHook(webhook) + + expect(mockLogger.info).toHaveBeenCalledWith( + 'Refund created', + expect.objectContaining({ status: 'succeeded' }) + ) + expect(mockGateHubClient.createTransaction).toHaveBeenCalledTimes(1) + + const refundTransactions = await Transaction.query().where( + 'paymentId', + 're_123456' + ) + expect(refundTransactions).toHaveLength(1) + }) + + it('should skip duplicate processing when refund.created and refund.updated both succeed', async (): Promise => { + await insertOriginalStripePayment() + const createdWebhook = createMockRefundWebhook(EventType.refund_created, { + status: 'succeeded' + }) + const updatedWebhook = createMockRefundWebhook(EventType.refund_updated, { + status: 'succeeded' + }) + + await stripeService.onWebHook(createdWebhook) + await stripeService.onWebHook(updatedWebhook) + + expect(mockGateHubClient.createTransaction).toHaveBeenCalledTimes(1) + expect(mockLogger.info).toHaveBeenCalledWith( + 'Refund already processed', + expect.objectContaining({ refund_id: 're_123456' }) + ) + }) + + it('should log charge.refunded without creating transactions', async (): Promise => { + const webhook = createMockChargeRefundedWebhook() + + await stripeService.onWebHook(webhook) + + expect(mockLogger.info).toHaveBeenCalledWith( + 'Charge refunded', + expect.objectContaining({ + charge_id: 'ch_123456', + payment_intent_id: paymentIntentId + }) + ) + expect(mockGateHubClient.createTransaction).not.toHaveBeenCalled() + }) + + it('should log refund.failed without creating transactions', async (): Promise => { + const webhook = createMockRefundWebhook(EventType.refund_failed) + + await stripeService.onWebHook(webhook) + + expect(mockLogger.warn).toHaveBeenCalledWith( + 'Refund failed', + expect.objectContaining({ + refund_id: 're_123456', + payment_intent_id: paymentIntentId, + failure_reason: 'declined' + }) + ) + expect(mockGateHubClient.createTransaction).not.toHaveBeenCalled() + }) + + it('should log refund.updated when status is not succeeded', async (): Promise => { + const webhook = createMockRefundWebhook(EventType.refund_updated, { + status: 'pending' + }) + + await stripeService.onWebHook(webhook) + + expect(mockLogger.info).toHaveBeenCalledWith( + 'Refund updated', + expect.objectContaining({ + refund_id: 're_123456', + status: 'pending' + }) + ) + expect(mockGateHubClient.createTransaction).not.toHaveBeenCalled() + }) + + it('should reverse gatehub transaction on refund.updated succeeded', async (): Promise => { + await insertOriginalStripePayment() + const webhook = createMockRefundWebhook(EventType.refund_updated) + + await stripeService.onWebHook(webhook) + + expect(mockAccountService.getAccountBalance).toHaveBeenCalledWith( + expect.objectContaining({ id: account.id }) + ) + expect(mockGateHubClient.createTransaction).toHaveBeenCalledWith( + { + amount: 5, + vault_uuid: 'vault-uuid-123', + sending_address: 'gatehub-wallet-123', + receiving_address: env.GATEHUB_SETTLEMENT_WALLET_ADDRESS, + type: TransactionTypeEnum.HOSTED, + message: 'Stripe Refund' + }, + 'test-gatehub-user' + ) + + const transactions = await Transaction.query().orderBy('createdAt', 'asc') + expect(transactions).toHaveLength(2) + expect(transactions[1]).toMatchObject({ + walletAddressId: walletAddress.id, + accountId: account.id, + paymentId: 're_123456', + assetCode: 'USD', + type: 'OUTGOING', + status: 'COMPLETED', + description: `Stripe Refund (${paymentIntentId})`, + source: 'Stripe' + }) + }) + + it('should skip processing when refund was already processed', async (): Promise => { + await insertOriginalStripePayment() + const webhook = createMockRefundWebhook(EventType.refund_updated) + + await stripeService.onWebHook(webhook) + await stripeService.onWebHook(webhook) + + expect(mockGateHubClient.createTransaction).toHaveBeenCalledTimes(1) + + const refundTransactions = await Transaction.query().where( + 'paymentId', + 're_123456' + ) + expect(refundTransactions).toHaveLength(1) + expect(mockLogger.info).toHaveBeenCalledWith( + 'Refund already processed', + expect.objectContaining({ refund_id: 're_123456' }) + ) + }) + + it('should support partial refunds', async (): Promise => { + await insertOriginalStripePayment() + const firstRefund = createMockRefundWebhook(EventType.refund_updated, { + refundId: 're_partial_1', + amount: 300 + }) + const secondRefund = createMockRefundWebhook(EventType.refund_updated, { + refundId: 're_partial_2', + amount: 200 + }) + + await stripeService.onWebHook(firstRefund) + await stripeService.onWebHook(secondRefund) + + expect(mockGateHubClient.createTransaction).toHaveBeenCalledTimes(2) + expect(mockGateHubClient.createTransaction).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ amount: 3 }), + 'test-gatehub-user' + ) + expect(mockGateHubClient.createTransaction).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ amount: 2 }), + 'test-gatehub-user' + ) + + const refundTransactions = await Transaction.query() + .where('type', 'OUTGOING') + .orderBy('createdAt', 'asc') + expect(refundTransactions).toHaveLength(2) + }) + + it('should stop without retry when original payment is not found', async (): Promise => { + const webhook = createMockRefundWebhook(EventType.refund_updated) + + await expect(stripeService.onWebHook(webhook)).resolves.toBeUndefined() + + expect(mockLogger.error).toHaveBeenCalledWith( + 'Original Stripe payment not found', + expect.objectContaining({ + refund_id: 're_123456', + payment_intent_id: paymentIntentId + }) + ) + expect(mockGateHubClient.createTransaction).not.toHaveBeenCalled() + expect(mockAccountService.getAccountBalance).not.toHaveBeenCalled() + }) + + it('should stop without retry when refund currency does not match original payment', async (): Promise => { + await insertOriginalStripePayment() + const webhook = createMockRefundWebhook(EventType.refund_updated, { + currency: 'eur' + }) + + await expect(stripeService.onWebHook(webhook)).resolves.toBeUndefined() + + expect(mockLogger.error).toHaveBeenCalledWith( + 'Refund currency does not match original payment', + expect.objectContaining({ + refund_id: 're_123456', + payment_intent_id: paymentIntentId + }) + ) + expect(mockAccountService.getAccountBalance).not.toHaveBeenCalled() + expect(mockGateHubClient.createTransaction).not.toHaveBeenCalled() + }) + + it('should stop without retry when wallet address is inactive', async (): Promise => { + await insertOriginalStripePayment() + mockWalletAddressService.getById.mockRejectedValueOnce( + new BadRequest('Not found') + ) + const webhook = createMockRefundWebhook(EventType.refund_updated) + + await expect(stripeService.onWebHook(webhook)).resolves.toBeUndefined() + + expect(mockLogger.error).toHaveBeenCalledWith( + 'Wallet address not found or inactive for original payment', + expect.objectContaining({ + refund_id: 're_123456', + payment_intent_id: paymentIntentId + }) + ) + expect(mockAccountService.getAccountBalance).not.toHaveBeenCalled() + expect(mockGateHubClient.createTransaction).not.toHaveBeenCalled() + }) + + it('should stop without retry when gatehub wallet address cannot be resolved', async (): Promise => { + await insertOriginalStripePayment() + mockAccountService.getGateHubWalletAddress.mockRejectedValueOnce( + new BadRequest('No account associated to the provided wallet address') + ) + const webhook = createMockRefundWebhook(EventType.refund_updated) + + await expect(stripeService.onWebHook(webhook)).resolves.toBeUndefined() + + expect(mockLogger.error).toHaveBeenCalledWith( + 'Wallet address not found or inactive for original payment', + expect.objectContaining({ + refund_id: 're_123456', + payment_intent_id: paymentIntentId + }) + ) + expect(mockAccountService.getAccountBalance).not.toHaveBeenCalled() + expect(mockGateHubClient.createTransaction).not.toHaveBeenCalled() + }) + + it('should stop without retry when user has insufficient balance for refund reversal', async (): Promise => { + await insertOriginalStripePayment() + mockAccountService.getAccountBalance.mockResolvedValueOnce(3) + const webhook = createMockRefundWebhook(EventType.refund_updated) + + await expect(stripeService.onWebHook(webhook)).resolves.toBeUndefined() + + expect(mockLogger.error).toHaveBeenCalledWith( + 'Insufficient funds for refund reversal', + expect.objectContaining({ + refund_id: 're_123456', + payment_intent_id: paymentIntentId, + required: 5, + available: 3 + }) + ) + expect(mockGateHubClient.createTransaction).not.toHaveBeenCalled() + + const refundTransactions = await Transaction.query().where( + 'paymentId', + 're_123456' + ) + expect(refundTransactions).toHaveLength(0) + }) + + it('should treat duplicate insert as already processed after gatehub reversal', async (): Promise => { + await insertOriginalStripePayment() + const webhook = createMockRefundWebhook(EventType.refund_updated) + + const { Transaction: TransactionModel } = jest.requireActual< + typeof import('@/transaction/model') + >('@/transaction/model') + const originalQuery = TransactionModel.query.bind(TransactionModel) + const querySpy = jest + .spyOn(Transaction, 'query') + .mockImplementation(() => { + const qb = originalQuery() + const originalInsert = qb.insert.bind(qb) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(qb as any).insert = (data: Partial) => { + if (data.paymentId === 're_123456' && data.type === 'OUTGOING') { + // db-errors constructor shape; objection types only expose string overload + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const error = new (UniqueViolationError as any)({ + nativeError: new Error('duplicate'), + client: 'postgres' + }) + return Promise.reject(error) + } + return originalInsert(data) + } + return qb + }) + + try { + await expect(stripeService.onWebHook(webhook)).resolves.toBeUndefined() + + expect(mockGateHubClient.createTransaction).toHaveBeenCalledTimes(1) + expect(mockLogger.info).toHaveBeenCalledWith( + 'Refund already processed', + expect.objectContaining({ refund_id: 're_123456' }) + ) + } finally { + querySpy.mockRestore() + } + }) + + it('should throw generic error when gatehub reversal fails', async (): Promise => { + await insertOriginalStripePayment() + mockGateHubClient.createTransaction.mockRejectedValueOnce( + new Error('Insufficient funds') + ) + const webhook = createMockRefundWebhook(EventType.refund_updated) + + await expect(stripeService.onWebHook(webhook)).rejects.toThrow( + 'Failed to reverse transaction for refund' + ) + + expect(mockLogger.error).toHaveBeenCalledWith( + 'Error reversing gatehub transaction for refund', + expect.objectContaining({ + refund_id: 're_123456', + payment_intent_id: paymentIntentId + }) + ) + + const refundTransactions = await Transaction.query().where( + 'paymentId', + 're_123456' + ) + expect(refundTransactions).toHaveLength(0) + }) + + it('should throw when db insert fails after gatehub reversal', async (): Promise => { + await insertOriginalStripePayment() + const webhook = createMockRefundWebhook(EventType.refund_updated) + + const { Transaction: TransactionModel } = jest.requireActual< + typeof import('@/transaction/model') + >('@/transaction/model') + const originalQuery = TransactionModel.query.bind(TransactionModel) + const querySpy = jest + .spyOn(Transaction, 'query') + .mockImplementation(() => { + const qb = originalQuery() + const originalInsert = qb.insert.bind(qb) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(qb as any).insert = (data: Partial) => { + if (data.paymentId === 're_123456' && data.type === 'OUTGOING') { + return Promise.reject(new Error('DB error')) + } + return originalInsert(data) + } + return qb + }) + + try { + await expect(stripeService.onWebHook(webhook)).rejects.toThrow( + 'Failed to reverse transaction for refund' + ) + + expect(mockGateHubClient.createTransaction).toHaveBeenCalledTimes(1) + } finally { + querySpy.mockRestore() + } + }) }) }) From 3fe5e94ac682b5a2f9e7c92c010cd07c7e0f0df4 Mon Sep 17 00:00:00 2001 From: dragosp1011 Date: Tue, 30 Jun 2026 16:13:11 +0300 Subject: [PATCH 2/3] fix: refactor process refund method --- .../backend/src/stripe-integration/service.ts | 110 +++++++++++++++--- 1 file changed, 97 insertions(+), 13 deletions(-) diff --git a/packages/wallet/backend/src/stripe-integration/service.ts b/packages/wallet/backend/src/stripe-integration/service.ts index 90bc0c2d0..2175635e7 100644 --- a/packages/wallet/backend/src/stripe-integration/service.ts +++ b/packages/wallet/backend/src/stripe-integration/service.ts @@ -21,6 +21,22 @@ import { transformBalance, applyScale } from '../utils/helpers' import { Account } from '../account/model' import { UniqueViolationError } from 'objection' +interface RefundValidationResult { + refund: StripeRefundObject + paymentIntentId: string + originalTx: Transaction + account: Account + scaledAmount: number + refundValue: bigint + currency: string + description: string +} + +interface RefundGateHubWalletDetails { + gateHubWalletId: string + gateHubUserId: string +} + export enum EventType { payment_intent_canceled = 'payment_intent.canceled', payment_intent_payment_failed = 'payment_intent.payment_failed', @@ -209,19 +225,45 @@ export class StripeService implements IStripeService { } private async processSucceededRefund(refund: StripeRefundObject) { + if (await this.isRefundAlreadyProcessed(refund.id)) { + return + } + + const validated = await this.validateRefund(refund) + if (!validated) { + return + } + + const gateHubWallet = await this.getRefundGateHubWallet(validated) + if (!gateHubWallet) { + return + } + + if (!(await this.hasSufficientBalanceForRefund(validated))) { + return + } + + await this.reverseAndRecordRefund(validated, gateHubWallet) + } + + private async isRefundAlreadyProcessed(refundId: string): Promise { const existingRefund = await Transaction.query().findOne({ - paymentId: refund.id, + paymentId: refundId, source: 'Stripe' }) if (existingRefund) { - this.logRefundAlreadyProcessed(refund.id) - return + this.logRefundAlreadyProcessed(refundId) + return true } - const paymentIntentId = refund.payment_intent - const description = this.refundDescription(paymentIntentId) + return false + } + private async validateRefund( + refund: StripeRefundObject + ): Promise { + const paymentIntentId = refund.payment_intent const originalTx = await Transaction.query().findOne({ paymentId: paymentIntentId, source: 'Stripe', @@ -269,18 +311,36 @@ export class StripeService implements IStripeService { return } - let gateHubWalletId: string - let gateHubUserId: string + return { + refund, + paymentIntentId, + originalTx, + account, + scaledAmount, + refundValue, + currency, + description: this.refundDescription(paymentIntentId) + } + } + + private async getRefundGateHubWallet( + validated: RefundValidationResult + ): Promise { + const { refund, paymentIntentId, originalTx, account } = validated + try { const walletAddress = await this.walletAddressService.getById({ - walletAddressId: originalTx.walletAddressId, + walletAddressId: originalTx.walletAddressId!, accountId: originalTx.accountId, userId: account.userId }) const gateHubWallet = await this.accountService.getGateHubWalletAddress(walletAddress) - gateHubWalletId = gateHubWallet.gateHubWalletId - gateHubUserId = gateHubWallet.gateHubUserId + + return { + gateHubWalletId: gateHubWallet.gateHubWalletId, + gateHubUserId: gateHubWallet.gateHubUserId + } } catch (error) { this.logger.error( 'Wallet address not found or inactive for original payment', @@ -293,6 +353,13 @@ export class StripeService implements IStripeService { ) return } + } + + private async hasSufficientBalanceForRefund( + validated: RefundValidationResult + ): Promise { + const { refund, paymentIntentId, account, refundValue, scaledAmount } = + validated const availableBalance = await this.accountService.getAccountBalance(account) @@ -307,20 +374,37 @@ export class StripeService implements IStripeService { required_value: refundValue.toString(), available_value: availableValue.toString() }) - return + return false } + return true + } + + private async reverseAndRecordRefund( + validated: RefundValidationResult, + gateHubWallet: RefundGateHubWalletDetails + ): Promise { + const { + refund, + paymentIntentId, + originalTx, + scaledAmount, + refundValue, + currency, + description + } = validated + try { await this.gateHubClient.createTransaction( { amount: scaledAmount, vault_uuid: this.gateHubClient.getVaultUuid(currency), - sending_address: gateHubWalletId, + sending_address: gateHubWallet.gateHubWalletId, receiving_address: this.env.GATEHUB_SETTLEMENT_WALLET_ADDRESS, type: TransactionTypeEnum.HOSTED, message: 'Stripe Refund' }, - gateHubUserId + gateHubWallet.gateHubUserId ) await Transaction.query().insert({ From dbdae6867f7339f1457f83d82843a5af8a3e50f9 Mon Sep 17 00:00:00 2001 From: dragosp1011 Date: Tue, 30 Jun 2026 16:29:59 +0300 Subject: [PATCH 3/3] test: refactor insert failure tests --- .../tests/stripe-integration/service.test.ts | 72 ++++++++----------- 1 file changed, 31 insertions(+), 41 deletions(-) diff --git a/packages/wallet/backend/tests/stripe-integration/service.test.ts b/packages/wallet/backend/tests/stripe-integration/service.test.ts index 80ea46052..707bba337 100644 --- a/packages/wallet/backend/tests/stripe-integration/service.test.ts +++ b/packages/wallet/backend/tests/stripe-integration/service.test.ts @@ -150,6 +150,26 @@ describe('Stripe Service', (): void => { }) } + const spyOnTransactionWithOutgoingRefundInsertFailure = ( + rejectInsert: () => Promise + ): jest.SpyInstance => { + const originalQuery = Transaction.query.bind(Transaction) + + return jest.spyOn(Transaction, 'query').mockImplementation(() => { + const qb = originalQuery() + const originalInsert = qb.insert.bind(qb) + + return Object.assign(qb, { + insert: (data: Partial) => { + if (data.paymentId === 're_123456' && data.type === 'OUTGOING') { + return rejectInsert() + } + return originalInsert(data) + } + }) + }) + } + beforeAll(async (): Promise => { const testEnv = { ...env, USE_STRIPE: true } bindings = await createContainer(testEnv) @@ -714,30 +734,15 @@ describe('Stripe Service', (): void => { await insertOriginalStripePayment() const webhook = createMockRefundWebhook(EventType.refund_updated) - const { Transaction: TransactionModel } = jest.requireActual< - typeof import('@/transaction/model') - >('@/transaction/model') - const originalQuery = TransactionModel.query.bind(TransactionModel) - const querySpy = jest - .spyOn(Transaction, 'query') - .mockImplementation(() => { - const qb = originalQuery() - const originalInsert = qb.insert.bind(qb) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ;(qb as any).insert = (data: Partial) => { - if (data.paymentId === 're_123456' && data.type === 'OUTGOING') { - // db-errors constructor shape; objection types only expose string overload - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const error = new (UniqueViolationError as any)({ - nativeError: new Error('duplicate'), - client: 'postgres' - }) - return Promise.reject(error) - } - return originalInsert(data) - } - return qb + const querySpy = spyOnTransactionWithOutgoingRefundInsertFailure(() => { + // db-errors constructor shape; objection types only expose string overload + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const error = new (UniqueViolationError as any)({ + nativeError: new Error('duplicate'), + client: 'postgres' }) + return Promise.reject(error) + }) try { await expect(stripeService.onWebHook(webhook)).resolves.toBeUndefined() @@ -782,24 +787,9 @@ describe('Stripe Service', (): void => { await insertOriginalStripePayment() const webhook = createMockRefundWebhook(EventType.refund_updated) - const { Transaction: TransactionModel } = jest.requireActual< - typeof import('@/transaction/model') - >('@/transaction/model') - const originalQuery = TransactionModel.query.bind(TransactionModel) - const querySpy = jest - .spyOn(Transaction, 'query') - .mockImplementation(() => { - const qb = originalQuery() - const originalInsert = qb.insert.bind(qb) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ;(qb as any).insert = (data: Partial) => { - if (data.paymentId === 're_123456' && data.type === 'OUTGOING') { - return Promise.reject(new Error('DB error')) - } - return originalInsert(data) - } - return qb - }) + const querySpy = spyOnTransactionWithOutgoingRefundInsertFailure(() => + Promise.reject(new Error('DB error')) + ) try { await expect(stripeService.onWebHook(webhook)).rejects.toThrow(