diff --git a/app/components/UI/Ramp/Deposit/Views/BankDetails/BankDetails.tsx b/app/components/UI/Ramp/Deposit/Views/BankDetails/BankDetails.tsx index 83499bc7f716..85dbcb82d6a5 100644 --- a/app/components/UI/Ramp/Deposit/Views/BankDetails/BankDetails.tsx +++ b/app/components/UI/Ramp/Deposit/Views/BankDetails/BankDetails.tsx @@ -35,7 +35,10 @@ import { FIAT_ORDER_STATES } from '../../../../../../constants/on-ramp'; import { processFiatOrder } from '../../../index'; import { useTheme } from '../../../../../../util/theme'; import { RootState } from '../../../../../../reducers'; -import { hasDepositOrderField } from '../../utils'; +import { + getCryptoCurrencyFromTransakId, + hasDepositOrderField, +} from '../../utils'; import { useDepositSDK } from '../../sdk'; import Button, { ButtonSize, @@ -44,6 +47,7 @@ import Button, { import { SUPPORTED_PAYMENT_METHODS } from '../../constants'; import { DepositOrder } from '@consensys/native-ramps-sdk'; import PrivacySection from '../../components/PrivacySection'; +import useAnalytics from '../../../hooks/useAnalytics'; export interface BankDetailsParams { orderId: string; @@ -59,7 +63,8 @@ const BankDetails = () => { const { colors } = useTheme(); const dispatch = useDispatch(); const dispatchThunk = useThunkDispatch(); - const { sdk } = useDepositSDK(); + const { sdk, selectedWalletAddress, selectedRegion } = useDepositSDK(); + const trackEvent = useAnalytics(); const { orderId, shouldUpdate = true } = useParams(); const order = useSelector((state: RootState) => getOrderById(state, orderId)); @@ -210,6 +215,25 @@ const BankDetails = () => { return; } + const cryptoCurrency = getCryptoCurrencyFromTransakId( + order.data.cryptoCurrency, + ); + + trackEvent('RAMPS_TRANSACTION_CONFIRMED', { + ramp_type: 'DEPOSIT', + amount_source: Number(order.data.fiatAmount), + amount_destination: Number(order.cryptoAmount), + exchange_rate: Number(order.data.exchangeRate), + gas_fee: 0, //Number(order.data.gasFee), + processing_fee: 0, //Number(order.data.processingFee), + total_fee: Number(order.data.totalFeesFiat), + payment_method_id: order.data.paymentMethod, + country: selectedRegion?.isoCode || '', + chain_id: cryptoCurrency?.chainId || '', + currency_destination: selectedWalletAddress || order.data.walletAddress, + currency_source: order.data.fiatCurrency, + }); + await confirmPayment(order.id, paymentOptionId); await handleOnRefresh(); @@ -222,7 +246,15 @@ const BankDetails = () => { } catch (fetchError) { console.error(fetchError); } - }, [navigation, confirmPayment, handleOnRefresh, order]); + }, [ + navigation, + confirmPayment, + handleOnRefresh, + order, + selectedRegion?.isoCode, + selectedWalletAddress, + trackEvent, + ]); const handleCancelOrder = useCallback(async () => { try { diff --git a/app/components/UI/Ramp/Deposit/Views/OrderProcessing/OrderProcessing.test.tsx b/app/components/UI/Ramp/Deposit/Views/OrderProcessing/OrderProcessing.test.tsx index a8df1bd1878a..ae48842c87d0 100644 --- a/app/components/UI/Ramp/Deposit/Views/OrderProcessing/OrderProcessing.test.tsx +++ b/app/components/UI/Ramp/Deposit/Views/OrderProcessing/OrderProcessing.test.tsx @@ -12,6 +12,7 @@ const mockSetOptions = jest.fn(); const mockLinkingOpenURL = jest.fn(); const mockUseDepositSDK = jest.fn(); const mockCancelOrder = jest.fn(); +const mockTrackEvent = jest.fn(); jest.mock('@react-navigation/native', () => { const actualNav = jest.requireActual('@react-navigation/native'); @@ -58,6 +59,8 @@ jest.mock('../../hooks/useDepositSdkMethod', () => ({ }), })); +jest.mock('../../../hooks/useAnalytics', () => () => mockTrackEvent); + describe('OrderProcessing Component', () => { const mockOrder = { id: 'test-order-id', @@ -71,15 +74,37 @@ describe('OrderProcessing Component', () => { data: { cryptoCurrency: 'USDC', providerOrderLink: 'https://transak.com/order/123', + fiatAmount: '100', + exchangeRate: '2000', + totalFeesFiat: '2.50', + networkFees: '2.50', + partnerFees: '2.50', + paymentMethod: 'credit_debit_card', + walletAddress: '0x1234567890123456789012345678901234567890', + fiatCurrency: 'USD', }, }; + const mockSelectedRegion = { + isoCode: 'US', + flag: '🇺🇸', + name: 'United States', + currency: 'USD', + supported: true, + }; + + const mockSelectedWalletAddress = + '0x1234567890123456789012345678901234567890'; + beforeEach(() => { jest.clearAllMocks(); (getOrderById as jest.Mock).mockReturnValue(mockOrder); mockUseDepositSDK.mockReturnValue({ isAuthenticated: false, + selectedRegion: mockSelectedRegion, + selectedWalletAddress: mockSelectedWalletAddress, }); + mockTrackEvent.mockClear(); }); it('renders success state correctly', () => { @@ -180,4 +205,210 @@ describe('OrderProcessing Component', () => { expect(mockNavigate).toHaveBeenCalledWith(Routes.DEPOSIT.BUILD_QUOTE); }); + + describe('Analytics Event Tracking', () => { + describe('RAMPS_TRANSACTION_COMPLETED tracking', () => { + it('tracks RAMPS_TRANSACTION_COMPLETED event when order state is COMPLETED', () => { + renderWithProvider(, { + state: { + engine: { + backgroundState, + }, + }, + }); + + expect(mockTrackEvent).toHaveBeenCalledWith( + 'RAMPS_TRANSACTION_COMPLETED', + { + ramp_type: 'DEPOSIT', + amount_source: 100, + amount_destination: 0.05, + exchange_rate: 2000, + gas_fee: 2.5, + processing_fee: 2.5, + total_fee: 2.5, + payment_method_id: 'credit_debit_card', + country: 'US', + chain_id: 'eip155:1', + currency_destination: mockSelectedWalletAddress, + currency_source: 'USD', + }, + ); + }); + + it('tracks RAMPS_TRANSACTION_COMPLETED with order wallet address when selectedWalletAddress is not available', () => { + mockUseDepositSDK.mockReturnValueOnce({ + isAuthenticated: false, + selectedRegion: mockSelectedRegion, + selectedWalletAddress: null, + }); + + renderWithProvider(, { + state: { + engine: { + backgroundState, + }, + }, + }); + + expect(mockTrackEvent).toHaveBeenCalledWith( + 'RAMPS_TRANSACTION_COMPLETED', + { + ramp_type: 'DEPOSIT', + amount_source: 100, + amount_destination: 0.05, + exchange_rate: 2000, + gas_fee: 2.5, + processing_fee: 2.5, + total_fee: 2.5, + payment_method_id: 'credit_debit_card', + country: 'US', + chain_id: 'eip155:1', + currency_destination: '0x1234567890123456789012345678901234567890', + currency_source: 'USD', + }, + ); + }); + + it('tracks RAMPS_TRANSACTION_COMPLETED with correct number conversions for all numeric fields', () => { + const orderWithStringNumbers = { + ...mockOrder, + data: { + ...mockOrder.data, + fiatAmount: '250.75', + exchangeRate: '1850.25', + totalFeesFiat: '5.99', + networkFees: '5.99', + partnerFees: '5.99', + }, + cryptoAmount: '0.135', + }; + (getOrderById as jest.Mock).mockReturnValue(orderWithStringNumbers); + + renderWithProvider(, { + state: { + engine: { + backgroundState, + }, + }, + }); + + expect(mockTrackEvent).toHaveBeenCalledWith( + 'RAMPS_TRANSACTION_COMPLETED', + { + ramp_type: 'DEPOSIT', + amount_source: 250.75, + amount_destination: 0.135, + exchange_rate: 1850.25, + gas_fee: 5.99, + processing_fee: 5.99, + total_fee: 5.99, + payment_method_id: 'credit_debit_card', + country: 'US', + chain_id: 'eip155:1', + currency_destination: mockSelectedWalletAddress, + currency_source: 'USD', + }, + ); + }); + }); + + describe('RAMPS_TRANSACTION_FAILED tracking', () => { + it('tracks RAMPS_TRANSACTION_FAILED event when order state is FAILED', () => { + const failedOrder = { ...mockOrder, state: FIAT_ORDER_STATES.FAILED }; + (getOrderById as jest.Mock).mockReturnValue(failedOrder); + + renderWithProvider(, { + state: { + engine: { + backgroundState, + }, + }, + }); + + expect(mockTrackEvent).toHaveBeenCalledWith( + 'RAMPS_TRANSACTION_FAILED', + { + ramp_type: 'DEPOSIT', + amount_source: 100, + amount_destination: 0.05, + exchange_rate: 2000, + gas_fee: 2.5, + processing_fee: 2.5, + total_fee: 2.5, + payment_method_id: 'credit_debit_card', + country: 'US', + chain_id: 'eip155:1', + currency_destination: mockSelectedWalletAddress, + currency_source: 'USD', + error_message: 'transaction_failed', + }, + ); + }); + }); + + describe('No analytics tracking scenarios', () => { + it('does not track analytics events for PENDING state', () => { + const pendingOrder = { ...mockOrder, state: FIAT_ORDER_STATES.PENDING }; + (getOrderById as jest.Mock).mockReturnValue(pendingOrder); + + renderWithProvider(, { + state: { + engine: { + backgroundState, + }, + }, + }); + + expect(mockTrackEvent).not.toHaveBeenCalled(); + }); + + it('does not track analytics events for CREATED state', () => { + const createdOrder = { ...mockOrder, state: FIAT_ORDER_STATES.CREATED }; + (getOrderById as jest.Mock).mockReturnValue(createdOrder); + + renderWithProvider(, { + state: { + engine: { + backgroundState, + }, + }, + }); + + expect(mockTrackEvent).not.toHaveBeenCalled(); + }); + + it('does not track analytics events for CANCELLED state', () => { + const cancelledOrder = { + ...mockOrder, + state: FIAT_ORDER_STATES.CANCELLED, + }; + (getOrderById as jest.Mock).mockReturnValue(cancelledOrder); + + renderWithProvider(, { + state: { + engine: { + backgroundState, + }, + }, + }); + + expect(mockTrackEvent).not.toHaveBeenCalled(); + }); + + it('does not track analytics events when order is null', () => { + (getOrderById as jest.Mock).mockReturnValue(null); + + renderWithProvider(, { + state: { + engine: { + backgroundState, + }, + }, + }); + + expect(mockTrackEvent).not.toHaveBeenCalled(); + }); + }); + }); }); diff --git a/app/components/UI/Ramp/Deposit/Views/OrderProcessing/OrderProcessing.tsx b/app/components/UI/Ramp/Deposit/Views/OrderProcessing/OrderProcessing.tsx index 7a99e4f51dad..6e7a1305e890 100644 --- a/app/components/UI/Ramp/Deposit/Views/OrderProcessing/OrderProcessing.tsx +++ b/app/components/UI/Ramp/Deposit/Views/OrderProcessing/OrderProcessing.tsx @@ -24,6 +24,14 @@ import Button, { ButtonSize, ButtonVariants, } from '../../../../../../component-library/components/Buttons/Button'; +import useAnalytics from '../../../hooks/useAnalytics'; +import { useDepositSDK } from '../../sdk'; +import { + getCryptoCurrencyFromTransakId, + hasDepositOrderField, +} from '../../utils'; +import { DepositOrder } from '@consensys/native-ramps-sdk'; + export interface OrderProcessingParams { orderId: string; } @@ -38,6 +46,8 @@ const OrderProcessing = () => { const { styles, theme } = useStyles(styleSheet, {}); const { orderId } = useParams(); const order = useSelector((state: RootState) => getOrderById(state, orderId)); + const trackEvent = useAnalytics(); + const { selectedWalletAddress, selectedRegion } = useDepositSDK(); const handleMainAction = useCallback(() => { if ( @@ -75,6 +85,66 @@ const OrderProcessing = () => { } }, [order?.state, navigation, orderId]); + useEffect(() => { + if (!order) return; + + const isCompleted = order.state === FIAT_ORDER_STATES.COMPLETED; + const isFailed = order.state === FIAT_ORDER_STATES.FAILED; + + if (isCompleted || isFailed) { + if (hasDepositOrderField(order.data, 'cryptoCurrency')) { + const cryptoCurrency = getCryptoCurrencyFromTransakId( + (order.data as DepositOrder).cryptoCurrency, + ); + + const baseAnalyticsData = { + ramp_type: 'DEPOSIT' as const, + amount_source: Number(order.data.fiatAmount), + amount_destination: Number(order.cryptoAmount), + exchange_rate: Number(order.data.exchangeRate), + payment_method_id: order.data.paymentMethod, + country: selectedRegion?.isoCode || '', + chain_id: cryptoCurrency?.chainId || '', + currency_destination: + selectedWalletAddress || order.data.walletAddress, + currency_source: order.data.fiatCurrency, + }; + + if (isCompleted) { + trackEvent('RAMPS_TRANSACTION_COMPLETED', { + ...baseAnalyticsData, + gas_fee: order.data.networkFees + ? Number(order.data.networkFees) + : 0, + processing_fee: order.data.partnerFees + ? Number(order.data.partnerFees) + : 0, + total_fee: Number(order.data.totalFeesFiat), + }); + } else if (isFailed) { + trackEvent('RAMPS_TRANSACTION_FAILED', { + ...baseAnalyticsData, + gas_fee: order.data.networkFees + ? Number(order.data.networkFees) + : 0, + processing_fee: order.data.partnerFees + ? Number(order.data.partnerFees) + : 0, + total_fee: Number(order.data.totalFeesFiat), + error_message: order.data.statusDescription || 'transaction_failed', + }); + } + } + } + }, [ + order, + navigation, + orderId, + trackEvent, + selectedWalletAddress, + selectedRegion, + ]); + if (!order) { return ( diff --git a/app/components/UI/Ramp/Deposit/hooks/useDepositRouting.test.ts b/app/components/UI/Ramp/Deposit/hooks/useDepositRouting.test.ts index 47ebc719ddf3..fceb73731c08 100644 --- a/app/components/UI/Ramp/Deposit/hooks/useDepositRouting.test.ts +++ b/app/components/UI/Ramp/Deposit/hooks/useDepositRouting.test.ts @@ -38,12 +38,19 @@ let mockGetOrder = jest.fn().mockResolvedValue({ id: 'order-id', walletAddress: '0x123', cryptoCurrency: 'USDC', - network: 'ethereum', + network: 'eip155:1', + fiatAmount: '100', + cryptoAmount: '0.05', + exchangeRate: '2000', + totalFeesFiat: '2.50', + networkFees: '2.50', + partnerFees: '2.50', + paymentMethod: 'credit_debit_card', + fiatCurrency: 'USD', }); const mockNavigate = jest.fn(); const mockDispatch = jest.fn(); - const mockTrackEvent = jest.fn(); jest.mock('../../hooks/useAnalytics', () => () => mockTrackEvent); @@ -108,10 +115,11 @@ jest.mock('./useDepositSdkMethod', () => ({ })); const mockClearAuthToken = jest.fn(); +const mockSelectedRegion = { isoCode: 'US' }; jest.mock('../sdk', () => ({ useDepositSDK: jest.fn(() => ({ - selectedRegion: { isoCode: 'US' }, + selectedRegion: mockSelectedRegion, clearAuthToken: mockClearAuthToken, selectedWalletAddress: '0x123', })), @@ -135,6 +143,8 @@ jest.mock('../orderProcessor', () => ({ depositOrderToFiatOrder: jest.fn((order) => order), })); +jest.mock('../../hooks/useAnalytics', () => () => mockTrackEvent); + const mockUseHandleNewOrder = jest.mocked(useHandleNewOrder); describe('useDepositRouting', () => { @@ -162,6 +172,8 @@ describe('useDepositRouting', () => { walletAddress: '0x123', cryptoCurrency: 'USDC', network: 'ethereum', + networkFees: '5.99', + partnerFees: '5.99', }); mockUseHandleNewOrder.mockReturnValue( @@ -692,6 +704,102 @@ describe('useDepositRouting', () => { }); }); + it('tracks RAMPS_TRANSACTION_CONFIRMED event when order is processed successfully', async () => { + const mockParams = { + cryptoCurrencyChainId: 'eip155:1', + paymentMethodId: 'credit_debit_card', + }; + const mockHandleNewOrder = jest.fn().mockResolvedValue(undefined); + mockUseHandleNewOrder.mockReturnValue(mockHandleNewOrder); + + const testOrder = { + id: 'order-id', + walletAddress: '0x123', + cryptoCurrency: 'USDC', + network: 'ethereum', + fiatAmount: '100', + cryptoAmount: '0.05', + exchangeRate: '2000', + totalFeesFiat: '2.50', + networkFees: '0', + partnerFees: '0', + paymentMethod: 'credit_debit_card', + fiatCurrency: 'USD', + }; + mockGetOrder.mockResolvedValue(testOrder); + + const { result } = renderHook(() => useDepositRouting(mockParams)); + + const mockQuote = {} as BuyQuote; + await result.current.handleApprovedKycFlow(mockQuote); + + const navigateCall = mockNavigate.mock.calls.find( + (call) => + call[0] === 'DepositModals' && + call[1]?.params?.handleNavigationStateChange, + ); + const handler = navigateCall?.[1]?.params?.handleNavigationStateChange; + + mockTrackEvent.mockClear(); + + await handler({ + url: 'https://metamask.io/success?orderId=test-order-id', + }); + + expect(mockTrackEvent).toHaveBeenCalledWith( + 'RAMPS_TRANSACTION_CONFIRMED', + { + ramp_type: 'DEPOSIT', + amount_source: 100, + amount_destination: 0.05, + exchange_rate: 2000, + gas_fee: 0, + processing_fee: 0, + total_fee: 2.5, + payment_method_id: 'credit_debit_card', + country: 'US', + chain_id: 'eip155:1', + currency_destination: '0x123', + currency_source: 'USD', + }, + ); + }); + + it('does not track analytics when order processing fails', async () => { + const mockParams = { + cryptoCurrencyChainId: 'eip155:1', + paymentMethodId: 'credit_debit_card', + }; + const mockHandleNewOrder = jest + .fn() + .mockRejectedValue(new Error('Processing failed')); + mockUseHandleNewOrder.mockReturnValue(mockHandleNewOrder); + + const { result } = renderHook(() => useDepositRouting(mockParams)); + + const mockQuote = {} as BuyQuote; + await result.current.handleApprovedKycFlow(mockQuote); + + const navigateCall = mockNavigate.mock.calls.find( + (call) => + call[0] === 'DepositModals' && + call[1]?.params?.handleNavigationStateChange, + ); + const handler = navigateCall?.[1]?.params?.handleNavigationStateChange; + + mockTrackEvent.mockClear(); + + await handler({ + url: 'https://metamask.io/success?orderId=test-order-id', + }); + + expect(mockTrackEvent).not.toHaveBeenCalled(); + expect(mockNavigate).not.toHaveBeenCalledWith( + 'OrderProcessing', + expect.any(Object), + ); + }); + it('does nothing when URL does not start with metamask.io', async () => { const mockParams = { cryptoCurrencyChainId: 'eip155:1', @@ -718,6 +826,7 @@ describe('useDepositRouting', () => { expect(mockGetOrder).not.toHaveBeenCalled(); expect(mockNavigate).not.toHaveBeenCalled(); + expect(mockTrackEvent).not.toHaveBeenCalled(); }); it('does nothing when metamask.io URL has no orderId', async () => { @@ -744,6 +853,7 @@ describe('useDepositRouting', () => { expect(mockGetOrder).not.toHaveBeenCalled(); expect(mockNavigate).not.toHaveBeenCalled(); + expect(mockTrackEvent).not.toHaveBeenCalled(); }); it('handles error when getOrder fails', async () => { @@ -774,6 +884,7 @@ describe('useDepositRouting', () => { expect(mockGetOrder).toHaveBeenCalledWith('test-order-id', '0x123'); expect(mockNavigate).not.toHaveBeenCalled(); + expect(mockTrackEvent).not.toHaveBeenCalled(); }); it('handles error when getOrder returns null', async () => { @@ -804,6 +915,7 @@ describe('useDepositRouting', () => { expect(mockGetOrder).toHaveBeenCalledWith('test-order-id', '0x123'); expect(mockNavigate).not.toHaveBeenCalled(); + expect(mockTrackEvent).not.toHaveBeenCalled(); }); }); diff --git a/app/components/UI/Ramp/Deposit/hooks/useDepositRouting.ts b/app/components/UI/Ramp/Deposit/hooks/useDepositRouting.ts index fe4c93c28283..0e9f29b54e61 100644 --- a/app/components/UI/Ramp/Deposit/hooks/useDepositRouting.ts +++ b/app/components/UI/Ramp/Deposit/hooks/useDepositRouting.ts @@ -231,6 +231,24 @@ export const useDepositRouting = ({ await handleNewOrder(processedOrder); + trackEvent('RAMPS_TRANSACTION_CONFIRMED', { + ramp_type: 'DEPOSIT', + amount_source: Number(order.fiatAmount), + amount_destination: Number(order.cryptoAmount), + exchange_rate: Number(order.exchangeRate), + gas_fee: order.networkFees ? Number(order.networkFees) : 0, + processing_fee: order.partnerFees + ? Number(order.partnerFees) + : 0, + total_fee: Number(order.totalFeesFiat), + payment_method_id: order.paymentMethod, + country: selectedRegion?.isoCode || '', + chain_id: cryptoCurrency?.chainId || '', + currency_destination: + selectedWalletAddress || order.walletAddress, + currency_source: order.fiatCurrency, + }); + navigateToOrderProcessingCallback({ orderId: order.id, }); @@ -252,6 +270,8 @@ export const useDepositRouting = ({ selectedWalletAddress, handleNewOrder, navigateToOrderProcessingCallback, + selectedRegion?.isoCode, + trackEvent, ], ); diff --git a/package.json b/package.json index c08052c311e1..cfb805e5d0c6 100644 --- a/package.json +++ b/package.json @@ -170,7 +170,7 @@ }, "dependencies": { "@config-plugins/detox": "^9.0.0", - "@consensys/native-ramps-sdk": "1.0.12", + "@consensys/native-ramps-sdk": "^1.1.0", "@consensys/on-ramp-sdk": "2.1.10", "@craftzdog/react-native-buffer": "^6.1.0", "@deeeed/hyperliquid-node20": "^0.23.1-node20.1", diff --git a/yarn.lock b/yarn.lock index fd3ca0fa26b9..96060f969f4d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1444,10 +1444,10 @@ dependencies: expo-build-properties "^0.13.1" -"@consensys/native-ramps-sdk@1.0.12": - version "1.0.12" - resolved "https://registry.yarnpkg.com/@consensys/native-ramps-sdk/-/native-ramps-sdk-1.0.12.tgz#eb2fcb14a049e8582879fc7582456c62a596cbb1" - integrity sha512-suUsmWKDqRIRx4GYe83i+ZhN/gQfW5XH2+QVmai9pUdU4dLz8U91JRn4takM1DzjPT0EKpa5qrJaDjHnrUtO3A== +"@consensys/native-ramps-sdk@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@consensys/native-ramps-sdk/-/native-ramps-sdk-1.1.0.tgz#e10ba42766aca2ff6bddd8df02aa806d03d28a77" + integrity sha512-9m40XfXm9MtzrT7eR/WtbFdfRrn8Xab1K/gtgU0jSDpl3WjM6/SRngAsl770IAJqMVxFEI7UAH/dAx1f0wl/Vw== dependencies: async "^3.2.3" axios "^1.8.3"