Skip to content

Commit d08ec55

Browse files
feat(ramp): money-deposit headless funnel events cp-8.0.0 (#31716)
## **Description** Adds the click/screen funnel events on the MM Pay money-deposit screen for TRAM-3623, reusing existing `Ramps`-prefixed events, gated to `TransactionType.moneyAccountDeposit` so they do not fire for the perps/predict/withdraw/mUSD flows that share the screen. Stacked on the foundation PR (#31715) - see the merge order below. Funnel mapping: - Amount screen viewed = `Ramps Screen Viewed` - Amount committed = `Ramps Order Proposed` - Usable quote = `Ramps Order Selected` - Quote failure = `Ramps Quote Error` - Continue = `Ramps Continue Button Clicked` ## **Stack & merge order (TRAM-3623)** Three stacked client PRs + a paired schema PR. **Merge order:** 1. **#31715** (foundation, base `main`) - merge first; #31716 and #31764 depend on its shared types. 2. Then **#31716** (funnel) and **#31764** (tx-failed) - both stacked on the foundation branch and independent of each other, so merge in **either order**. When #31715 merges, GitHub retargets them to `main`; rebase onto `main`, then merge. Paired schema PR, must land alongside (it defines the `HEADLESS` / `ramp_surface` / `region` these events emit, else they fail validation): Consensys/segment-schema#621 _This PR is the funnel (step 2, sibling of #31764)._ ## **Changelog** CHANGELOG entry: null (Internal analytics funnel events only; no end-user-facing behavior change.) ## **Related issues** Refs: https://consensyssoftware.atlassian.net/browse/TRAM-3623 ## **Manual testing steps** ```gherkin Feature: Money-deposit headless funnel events Scenario: Funnel events fire on the MM Pay money-deposit screen Given a user on the MM Pay money "Add funds" (moneyAccountDeposit) screen When the user views the amount screen, enters an amount, receives a quote, and taps Continue Then Ramps Screen Viewed, Ramps Order Proposed, Ramps Order Selected and Ramps Continue Button Clicked fire And each carries ramp_type=HEADLESS and ramp_surface=money_account Scenario: Cross-flow isolation Given the perps / predict / withdraw / mUSD flows that share the screen When the user uses any of those flows Then none of the money-deposit funnel events fire ``` `yarn lint:tsc` clean; unit tests green, including cross-flow isolation. ## **Screenshots/Recordings** N/A - analytics funnel events only, no visual change. Event payloads verified via the analytics debugger; funnel recording tracked in TRAM-3623. ### **Before** N/A ### **After** After video: https://consensys.slack.com/archives/C0AK3NXRM7W/p1781627226380379 ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [x] I've tested on Android - [x] I've tested with a power user scenario - [x] I've instrumented key operations with Sentry traces for production performance metrics ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > Analytics-only instrumentation with no payment or transaction logic changes; events are gated to money-account deposit and covered by extensive tests. > > **Overview** > Adds **TRAM-3623** headless ramps funnel telemetry for the MM Pay **money-account deposit** amount screen, reusing existing `RAMPS_*` events with `ramp_type=HEADLESS`, `ramp_surface=money_account`, and region. > > Introduces ramps-owned **`useFiatFunnelMetrics`** (imperative + reactive emitters with dedupe) and a confirmations **`useFiatFunnelMetricsAdapter`** that maps tx type, fiat payment, alerts, and region—only **`moneyAccountDeposit`** gets a surface; perps/predict/withdraw/mUSD stay inert on the shared screen. **`custom-amount-info`** wires amount commit on successful Done and Continue on confirm; **`useFiatPaymentHighlightedActions`** fires **`RAMPS_PAYMENT_METHOD_SELECTOR_CLICKED`** once via **`useFiatPaymentSelectorMetrics`**. > > Unit and integration tests cover payloads, dedupe, cross-flow isolation, and no double emission. Depends on paired segment schema for new event properties. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 0ad4f82. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: saustrie-consensys <270766059+saustrie-consensys@users.noreply.github.com>
1 parent cbd2618 commit d08ec55

8 files changed

Lines changed: 1129 additions & 3 deletions

File tree

Lines changed: 337 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,337 @@
1+
import { renderHook, act } from '@testing-library/react-native';
2+
import { TransactionType } from '@metamask/transaction-controller';
3+
import {
4+
getFiatFunnelRampSurface,
5+
useFiatPaymentSelectorMetrics,
6+
useFiatFunnelMetrics,
7+
type FiatFunnelMetricsInput,
8+
} from './useFiatFunnelMetrics';
9+
import { RAMP_SURFACE } from '../Deposit/types/analytics';
10+
import type { Quote } from '../types';
11+
12+
const REGION = 'us-ca';
13+
const PM_ID = '/payments/debit-credit-card';
14+
15+
const mockTrackEvent = jest.fn();
16+
jest.mock('./useAnalytics', () => ({
17+
__esModule: true,
18+
default: () => mockTrackEvent,
19+
}));
20+
21+
interface RampsUserRegionMockReturn {
22+
userRegion: { regionCode: string } | null;
23+
setUserRegion: jest.Mock;
24+
}
25+
26+
const mockUseRampsUserRegion = jest.fn(
27+
(): RampsUserRegionMockReturn => ({
28+
userRegion: { regionCode: REGION },
29+
setUserRegion: jest.fn(),
30+
}),
31+
);
32+
jest.mock('./useRampsUserRegion', () => ({
33+
useRampsUserRegion: () => mockUseRampsUserRegion(),
34+
}));
35+
36+
const RAMPS_QUOTE_MOCK = {
37+
provider: '/providers/transak',
38+
quote: {
39+
amountIn: 100,
40+
amountOut: 0.05,
41+
paymentMethod: PM_ID,
42+
totalFees: 5,
43+
networkFee: 2,
44+
providerFee: 3,
45+
},
46+
} as unknown as Quote;
47+
48+
const EXPECTED_BASE = {
49+
ramp_type: 'HEADLESS',
50+
ramp_surface: RAMP_SURFACE.MONEY_ACCOUNT,
51+
region: REGION,
52+
};
53+
54+
const BASE_INPUT: FiatFunnelMetricsInput = {
55+
rampSurface: RAMP_SURFACE.MONEY_ACCOUNT,
56+
region: REGION,
57+
selectedPaymentMethodId: PM_ID,
58+
amountFiat: '100',
59+
assetId: 'eip155:1/slip44:60',
60+
};
61+
62+
/** First payload `trackEvent` was called with for `event`, or undefined. */
63+
function payloadFor(event: string): Record<string, unknown> | undefined {
64+
return mockTrackEvent.mock.calls.find(([type]) => type === event)?.[1];
65+
}
66+
67+
/** How many times `event` was emitted. */
68+
function emitCount(event: string): number {
69+
return mockTrackEvent.mock.calls.filter(([type]) => type === event).length;
70+
}
71+
72+
function renderFunnel(overrides: Partial<FiatFunnelMetricsInput> = {}) {
73+
return renderHook(
74+
({ input }: { input: FiatFunnelMetricsInput }) =>
75+
useFiatFunnelMetrics(input),
76+
{ initialProps: { input: { ...BASE_INPUT, ...overrides } } },
77+
);
78+
}
79+
80+
describe('useFiatFunnelMetrics', () => {
81+
beforeEach(() => {
82+
jest.clearAllMocks();
83+
mockUseRampsUserRegion.mockReturnValue({
84+
userRegion: { regionCode: REGION },
85+
setUserRegion: jest.fn(),
86+
});
87+
});
88+
89+
// Each emitted event keeps its full payload (proves no analytics regression).
90+
describe('money_account (HEADLESS) payloads', () => {
91+
it('emits the imperative events with their payloads', () => {
92+
const { result } = renderFunnel();
93+
act(() => {
94+
result.current.trackScreenViewed();
95+
result.current.trackAmountCommitted();
96+
result.current.trackPaymentSelectorOpened();
97+
result.current.trackContinue();
98+
});
99+
100+
expect(payloadFor('RAMPS_SCREEN_VIEWED')).toEqual({
101+
...EXPECTED_BASE,
102+
location: 'Amount Input',
103+
});
104+
expect(payloadFor('RAMPS_ORDER_PROPOSED')).toEqual({
105+
...EXPECTED_BASE,
106+
amount_source: 100,
107+
amount_destination: 0, // no quote yet at amount-commit
108+
payment_method_id: PM_ID,
109+
currency_destination: 'eip155:1/slip44:60',
110+
currency_source: 'USD',
111+
chain_id: 'eip155:1',
112+
is_authenticated: false,
113+
});
114+
expect(payloadFor('RAMPS_PAYMENT_METHOD_SELECTOR_CLICKED')).toEqual({
115+
...EXPECTED_BASE,
116+
location: 'Amount Input',
117+
current_payment_method: PM_ID,
118+
});
119+
expect(payloadFor('RAMPS_CONTINUE_BUTTON_CLICKED')).toEqual({
120+
...EXPECTED_BASE,
121+
amount_source: 100,
122+
payment_method_id: PM_ID,
123+
currency_destination: 'eip155:1/slip44:60',
124+
currency_source: 'USD',
125+
chain_id: 'eip155:1',
126+
});
127+
});
128+
129+
it('emits RAMPS_PAYMENT_METHOD_SELECTED reactively for a selected method', () => {
130+
renderFunnel();
131+
132+
expect(payloadFor('RAMPS_PAYMENT_METHOD_SELECTED')).toEqual({
133+
...EXPECTED_BASE,
134+
payment_method_id: PM_ID,
135+
is_authenticated: false,
136+
});
137+
});
138+
139+
it('emits RAMPS_ORDER_SELECTED reactively with the fee breakdown', () => {
140+
renderFunnel({ rampsQuote: RAMPS_QUOTE_MOCK });
141+
142+
expect(payloadFor('RAMPS_ORDER_SELECTED')).toEqual({
143+
...EXPECTED_BASE,
144+
amount_source: 100,
145+
amount_destination: 0.05,
146+
exchange_rate: 1900, // (100 - 5) / 0.05
147+
total_fee: 5,
148+
gas_fee: 2,
149+
processing_fee: 3,
150+
payment_method_id: PM_ID,
151+
currency_destination: 'eip155:1/slip44:60',
152+
currency_source: 'USD',
153+
chain_id: 'eip155:1',
154+
});
155+
});
156+
157+
it('emits RAMPS_QUOTE_ERROR reactively for an active quote error', () => {
158+
renderFunnel({
159+
quoteError: { key: 'x', message: 'No quotes available' },
160+
});
161+
162+
expect(payloadFor('RAMPS_QUOTE_ERROR')).toEqual({
163+
...EXPECTED_BASE,
164+
error_message: 'No quotes available',
165+
amount: 100,
166+
currency_source: 'USD',
167+
currency_destination: 'eip155:1/slip44:60',
168+
payment_method_id: PM_ID,
169+
});
170+
});
171+
});
172+
173+
it('does not emit RAMPS_ORDER_PROPOSED when the committed amount is zero', () => {
174+
const { result } = renderFunnel({ amountFiat: '0' });
175+
act(() => result.current.trackAmountCommitted());
176+
177+
expect(payloadFor('RAMPS_ORDER_PROPOSED')).toBeUndefined();
178+
});
179+
180+
it('falls back to empty chain_id and reads crypto-out from the quote', () => {
181+
const { result } = renderFunnel({
182+
rampsQuote: RAMPS_QUOTE_MOCK,
183+
assetId: 'not-a-caip',
184+
});
185+
act(() => result.current.trackAmountCommitted());
186+
187+
expect(payloadFor('RAMPS_ORDER_PROPOSED')).toEqual(
188+
expect.objectContaining({ chain_id: '', amount_destination: 0.05 }),
189+
);
190+
});
191+
192+
it('reports a zero exchange_rate when the quote has no crypto out', () => {
193+
renderFunnel({
194+
rampsQuote: {
195+
quote: { ...RAMPS_QUOTE_MOCK.quote, amountOut: 0 },
196+
} as unknown as Quote,
197+
});
198+
199+
expect(payloadFor('RAMPS_ORDER_SELECTED')).toEqual(
200+
expect.objectContaining({ exchange_rate: 0 }),
201+
);
202+
});
203+
204+
// Dedupe: re-render with a changed region (effects re-run) but the same
205+
// semantic key, to prove reactive events fire once per occurrence.
206+
it('does not re-emit RAMPS_SCREEN_VIEWED across calls', () => {
207+
const { result, rerender } = renderFunnel();
208+
act(() => result.current.trackScreenViewed());
209+
rerender({ input: { ...BASE_INPUT, region: 'us-ny' } });
210+
act(() => result.current.trackScreenViewed());
211+
212+
expect(emitCount('RAMPS_SCREEN_VIEWED')).toBe(1);
213+
});
214+
215+
it('does not re-emit the reactive quote/method events for unchanged values', () => {
216+
const { rerender } = renderFunnel({ rampsQuote: RAMPS_QUOTE_MOCK });
217+
rerender({
218+
input: { ...BASE_INPUT, rampsQuote: RAMPS_QUOTE_MOCK, region: 'us-ny' },
219+
});
220+
221+
expect(emitCount('RAMPS_ORDER_SELECTED')).toBe(1);
222+
expect(emitCount('RAMPS_PAYMENT_METHOD_SELECTED')).toBe(1);
223+
});
224+
225+
it('re-emits RAMPS_QUOTE_ERROR only after the error clears and recurs', () => {
226+
const quoteError = { key: 'x', message: 'No quotes' };
227+
const { rerender } = renderFunnel({ quoteError });
228+
rerender({ input: { ...BASE_INPUT, quoteError, region: 'us-ny' } });
229+
expect(emitCount('RAMPS_QUOTE_ERROR')).toBe(1);
230+
// Clears (resets the dedupe ref), then the same error recurs.
231+
rerender({ input: { ...BASE_INPUT, quoteError: undefined } });
232+
rerender({ input: { ...BASE_INPUT, quoteError } });
233+
expect(emitCount('RAMPS_QUOTE_ERROR')).toBe(2);
234+
});
235+
236+
// Generic in structure: inert without a surface, tags whatever surface given.
237+
it('emits nothing (reactive or imperative) when rampSurface is undefined', () => {
238+
const { result } = renderFunnel({
239+
rampSurface: undefined,
240+
rampsQuote: RAMPS_QUOTE_MOCK,
241+
quoteError: { key: 'x', message: 'No quotes available' },
242+
});
243+
244+
act(() => {
245+
result.current.trackScreenViewed();
246+
result.current.trackAmountCommitted();
247+
result.current.trackPaymentSelectorOpened();
248+
result.current.trackContinue();
249+
});
250+
251+
expect(mockTrackEvent).not.toHaveBeenCalled();
252+
});
253+
254+
it.each([RAMP_SURFACE.PERPS, RAMP_SURFACE.PREDICTION])(
255+
'tags events with the given (non-money) ramp surface: %s',
256+
(rampSurface) => {
257+
const { result } = renderFunnel({ rampSurface });
258+
act(() => result.current.trackContinue());
259+
260+
expect(payloadFor('RAMPS_CONTINUE_BUTTON_CLICKED')).toEqual(
261+
expect.objectContaining({ ramp_surface: rampSurface }),
262+
);
263+
},
264+
);
265+
});
266+
267+
describe('getFiatFunnelRampSurface', () => {
268+
it.each([
269+
[TransactionType.moneyAccountDeposit, RAMP_SURFACE.MONEY_ACCOUNT],
270+
[TransactionType.perpsDeposit, undefined],
271+
[TransactionType.predictDeposit, undefined],
272+
[TransactionType.moneyAccountWithdraw, undefined],
273+
[TransactionType.musdConversion, undefined],
274+
[undefined, undefined],
275+
])('maps %s to %s', (type, expected) => {
276+
expect(getFiatFunnelRampSurface(type)).toBe(expected);
277+
});
278+
});
279+
280+
describe('useFiatPaymentSelectorMetrics', () => {
281+
beforeEach(() => {
282+
jest.clearAllMocks();
283+
mockUseRampsUserRegion.mockReturnValue({
284+
userRegion: { regionCode: REGION },
285+
setUserRegion: jest.fn(),
286+
});
287+
});
288+
289+
it('emits the selector-opened payload exactly once', () => {
290+
const { rerender } = renderHook(
291+
({ currentPaymentMethodId }) =>
292+
useFiatPaymentSelectorMetrics({
293+
rampSurface: RAMP_SURFACE.MONEY_ACCOUNT,
294+
currentPaymentMethodId,
295+
}),
296+
{ initialProps: { currentPaymentMethodId: PM_ID } },
297+
);
298+
299+
rerender({ currentPaymentMethodId: '/payments/apple-pay' });
300+
301+
expect(mockTrackEvent).toHaveBeenCalledTimes(1);
302+
expect(payloadFor('RAMPS_PAYMENT_METHOD_SELECTOR_CLICKED')).toEqual({
303+
...EXPECTED_BASE,
304+
location: 'Amount Input',
305+
current_payment_method: PM_ID,
306+
});
307+
});
308+
309+
it('falls back to an empty region when unavailable', () => {
310+
mockUseRampsUserRegion.mockReturnValue({
311+
userRegion: null,
312+
setUserRegion: jest.fn(),
313+
});
314+
315+
renderHook(() =>
316+
useFiatPaymentSelectorMetrics({
317+
rampSurface: RAMP_SURFACE.MONEY_ACCOUNT,
318+
currentPaymentMethodId: PM_ID,
319+
}),
320+
);
321+
322+
expect(payloadFor('RAMPS_PAYMENT_METHOD_SELECTOR_CLICKED')).toEqual(
323+
expect.objectContaining({ region: '' }),
324+
);
325+
});
326+
327+
it('emits nothing without a surface', () => {
328+
renderHook(() =>
329+
useFiatPaymentSelectorMetrics({
330+
rampSurface: undefined,
331+
currentPaymentMethodId: PM_ID,
332+
}),
333+
);
334+
335+
expect(mockTrackEvent).not.toHaveBeenCalled();
336+
});
337+
});

0 commit comments

Comments
 (0)