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..2175635e7 100644 --- a/packages/wallet/backend/src/stripe-integration/service.ts +++ b/packages/wallet/backend/src/stripe-integration/service.ts @@ -3,16 +3,48 @@ 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' + +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', - 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 +68,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 +113,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 +144,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 +165,271 @@ 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) { + 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: refundId, + source: 'Stripe' + }) + + if (existingRefund) { + this.logRefundAlreadyProcessed(refundId) + return true + } + + return false + } + + private async validateRefund( + refund: StripeRefundObject + ): Promise { + const paymentIntentId = refund.payment_intent + 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 + } + + 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!, + accountId: originalTx.accountId, + userId: account.userId + }) + const gateHubWallet = + await this.accountService.getGateHubWalletAddress(walletAddress) + + return { + 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 + } + } + + private async hasSufficientBalanceForRefund( + validated: RefundValidationResult + ): Promise { + const { refund, paymentIntentId, account, refundValue, scaledAmount } = + validated + + 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 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: gateHubWallet.gateHubWalletId, + receiving_address: this.env.GATEHUB_SETTLEMENT_WALLET_ADDRESS, + type: TransactionTypeEnum.HOSTED, + message: 'Stripe Refund' + }, + gateHubWallet.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..707bba337 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,135 @@ 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' + }) + } + + 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 } @@ -128,15 +230,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 +260,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 +270,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 +291,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 +311,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 +328,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 +349,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 +359,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 +386,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 +398,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 +411,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 +423,383 @@ 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 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() + + 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 querySpy = spyOnTransactionWithOutgoingRefundInsertFailure(() => + Promise.reject(new Error('DB error')) + ) + + try { + await expect(stripeService.onWebHook(webhook)).rejects.toThrow( + 'Failed to reverse transaction for refund' + ) + + expect(mockGateHubClient.createTransaction).toHaveBeenCalledTimes(1) + } finally { + querySpy.mockRestore() + } + }) }) })