Skip to content

Commit 66b8511

Browse files
authored
feat: MUSD-737, MUSD-738 — Earnings section refresh (#29647)
## **Description** Two adjustments to the Money Home Earnings section. - **MUSD-737** replaces the `Lifetime earnings` and `Projected earnings` metrics on the Earnings card with forward-looking `Est. monthly earnings` and `Est. yearly earnings`. Values are computed from the user's Money Account balance and the current vault APY using the existing `calculateProjectedEarnings` utility (years = 1/12 and 1 respectively). The previous `+green` color treatment on the lifetime value is dropped — projections aren't past gains. - **MUSD-738** drops the `condensed` collapse path on `MoneyPotentialEarnings` so up to five convertible-token rows always render when the user has eligible tokens, regardless of Money Account transaction count. The `Earn on your crypto` single-CTA card no longer appears mid-state. `MoneyMusdTokenRow` and `MoneyActivityList` are unchanged. Locale strings: the `money.earnings.lifetime` / `money.earnings.projected` keys are renamed to `money.earnings.estimated_monthly` / `money.earnings.estimated_yearly` across all language files. The `money.earnings_tooltip.lifetime_*` / `projected_*` keys are renamed to `monthly_*` / `yearly_*` so the info-sheet copy mirrors the new section framing. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: - https://consensyssoftware.atlassian.net/browse/MUSD-737 - https://consensyssoftware.atlassian.net/browse/MUSD-738 ## **Manual testing steps** ```gherkin Feature: Money Home Earnings section refresh Scenario: user with a non-zero Money Account balance opens Money Home Given the Money Account feature flag is enabled And the user has a Money Account balance greater than $0 When the user navigates to Money Home Then the Earnings card shows "Est. monthly earnings" and "Est. yearly earnings" And both values reflect balance × APY scaled to the relevant period And no "Lifetime earnings" or "Projected earnings" labels are visible Scenario: user with at least one Money Account transaction sees convertible tokens Given the user has Money Account transaction count greater than 0 And the user has at least one convertible token with a positive fiat balance When the user scrolls to the Earnings area on Money Home Then up to five convertible-token rows are rendered And no "Earn on your crypto" / "View potential earnings" collapsed card is shown Scenario: user opens the Earnings info sheet Given Money Home is open When the user taps the info icon next to the Earnings section title Then the info sheet shows "Est. monthly earnings" and "Est. yearly earnings" headings And the body copy describes monthly and yearly projections ``` ## **Screenshots/Recordings** ### **Before** <!-- to be added --> ### **After** <!-- to be added --> ## **Pre-merge author checklist** - [ ] 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). - [ ] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] 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) - [ ] I've tested on Android - [ ] I've tested with a power user scenario - [ ] 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** > Low risk UI/UX change that adjusts earnings calculations/labels and removes a display mode; main risk is incorrect projections or regressions in Money Home navigation/press handlers. > > **Overview** > Updates Money Home’s Earnings card to show **estimated monthly** and **annual** earnings instead of lifetime/projected values, computing both from current balance and APY (with additional guards for invalid/zero inputs). > > Removes `MoneyPotentialEarnings` “condensed” mode so up to five convertible token rows always render when eligible tokens exist, and simplifies the earnings info sheet copy/strings to a single “estimated earnings” message. Tests were updated/expanded to cover the new metrics and key navigation/press behaviors (conversion, token row press, learn-more, and error logging). > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 175f4cd. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 5bfeb29 commit 66b8511

11 files changed

Lines changed: 286 additions & 304 deletions

File tree

app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.test.tsx

Lines changed: 156 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,11 @@ import useMoneyAccountBalance from '../../hooks/useMoneyAccountBalance';
2525
import { selectIsCardholder } from '../../../../../selectors/cardController';
2626
import { getDetectedGeolocation } from '../../../../../reducers/fiatOrders';
2727
import { moneyFormatFiat } from '../../utils/moneyFormatFiat';
28+
import { useMusdConversion } from '../../../Earn/hooks/useMusdConversion';
2829

2930
const mockGoBack = jest.fn();
3031
const mockNavigate = jest.fn();
32+
const mockInitiateCustomConversion = jest.fn();
3133
const mockMoneyFormatFiat = moneyFormatFiat as jest.MockedFunction<
3234
typeof moneyFormatFiat
3335
>;
@@ -72,9 +74,16 @@ jest.mock('../../hooks/useMoneyAccountBalance', () => ({
7274
}));
7375

7476
jest.mock('../../../Earn/hooks/useMusdConversion', () => ({
75-
useMusdConversion: () => ({
76-
initiateCustomConversion: jest.fn(),
77-
}),
77+
useMusdConversion: jest.fn(),
78+
}));
79+
80+
jest.mock('../../../../../core/NavigationService', () => ({
81+
__esModule: true,
82+
default: {
83+
navigation: {
84+
navigate: jest.fn(),
85+
},
86+
},
7887
}));
7988

8089
jest.mock('../../utils/moneyFormatFiat', () => ({
@@ -98,6 +107,8 @@ const mockUseMoneyAccountTransactions = jest.mocked(
98107
useMoneyAccountTransactions,
99108
);
100109

110+
const mockUseMusdConversion = jest.mocked(useMusdConversion);
111+
101112
const mockUseMoneyAccountBalance = jest.mocked(useMoneyAccountBalance);
102113

103114
jest.mock(
@@ -119,13 +130,22 @@ jest.mock('../../../../../component-library/components/Badges/Badge', () => ({
119130
}));
120131

121132
jest.mock('../../components/MoneyActivityItem/MoneyActivityItem', () => {
122-
const { View, Text } = jest.requireActual('react-native');
133+
const { TouchableOpacity, Text } = jest.requireActual('react-native');
123134
return {
124135
__esModule: true,
125-
default: ({ tx }: { tx: { id: string } }) => (
126-
<View testID={`money-activity-item-${tx.id}`}>
136+
default: ({
137+
tx,
138+
onPress,
139+
}: {
140+
tx: { id: string };
141+
onPress?: () => void;
142+
}) => (
143+
<TouchableOpacity
144+
testID={`money-activity-item-${tx.id}`}
145+
onPress={onPress}
146+
>
127147
<Text>{tx.id}</Text>
128-
</View>
148+
</TouchableOpacity>
129149
),
130150
};
131151
});
@@ -134,12 +154,21 @@ jest.mock('@react-native-masked-view/masked-view', () => 'MaskedView');
134154
jest.mock('../../../../UI/AssetOverview/Balance/Balance', () => ({
135155
NetworkBadgeSource: jest.fn(() => null),
136156
}));
157+
jest.mock('../../../../../util/Logger', () => ({
158+
__esModule: true,
159+
default: { error: jest.fn() },
160+
}));
137161

138162
describe('MoneyHomeView', () => {
139163
beforeEach(() => {
140164
jest.clearAllMocks();
141165
global.alert = jest.fn();
142166

167+
mockInitiateCustomConversion.mockResolvedValue(undefined);
168+
mockUseMusdConversion.mockReturnValue({
169+
initiateCustomConversion: mockInitiateCustomConversion,
170+
} as unknown as ReturnType<typeof useMusdConversion>);
171+
143172
mockSelectIsCardholder.mockReturnValue(false);
144173
mockGetDetectedGeolocation.mockReturnValue('US');
145174

@@ -339,22 +368,31 @@ describe('MoneyHomeView', () => {
339368

340369
expect(mockNavigate).toHaveBeenCalledWith(Routes.MONEY.MODALS.ROOT, {
341370
screen: Routes.MONEY.MODALS.EARNINGS_INFO_SHEET,
342-
params: { apy: 5 },
343371
});
344372
});
345373

346-
describe('projected earnings', () => {
347-
it('passes the formatted projected earnings to MoneyEarnings', () => {
374+
describe('monthly and yearly earnings', () => {
375+
it('passes the formatted monthly earnings to MoneyEarnings', () => {
348376
mockMoneyFormatFiat.mockReturnValue('$0.12');
349377

350378
const { getByTestId } = renderWithProvider(<MoneyHomeView />);
351379

352-
expect(
353-
getByTestId(MoneyEarningsTestIds.PROJECTED_VALUE),
354-
).toHaveTextContent('$0.12');
380+
expect(getByTestId(MoneyEarningsTestIds.MONTHLY_VALUE)).toHaveTextContent(
381+
'$0.12',
382+
);
383+
});
384+
385+
it('passes the formatted yearly earnings to MoneyEarnings', () => {
386+
mockMoneyFormatFiat.mockReturnValue('$0.12');
387+
388+
const { getByTestId } = renderWithProvider(<MoneyHomeView />);
389+
390+
expect(getByTestId(MoneyEarningsTestIds.YEARLY_VALUE)).toHaveTextContent(
391+
'$0.12',
392+
);
355393
});
356394

357-
it('displays the zero-formatted value for projected earnings when totalFiatRaw is absent', () => {
395+
it('displays the zero-formatted value for monthly earnings when totalFiatRaw is absent', () => {
358396
mockMoneyFormatFiat.mockReturnValue('$0.00');
359397
mockUseMoneyAccountBalance.mockReturnValue({
360398
totalFiatFormatted: undefined,
@@ -373,9 +411,9 @@ describe('MoneyHomeView', () => {
373411

374412
const { getByTestId } = renderWithProvider(<MoneyHomeView />);
375413

376-
expect(
377-
getByTestId(MoneyEarningsTestIds.PROJECTED_VALUE),
378-
).toHaveTextContent('$0.00');
414+
expect(getByTestId(MoneyEarningsTestIds.MONTHLY_VALUE)).toHaveTextContent(
415+
'$0.00',
416+
);
379417
});
380418
});
381419

@@ -599,6 +637,107 @@ describe('MoneyHomeView', () => {
599637
screen: Routes.MONEY.MODALS.ADD_MONEY_SHEET,
600638
});
601639
});
640+
641+
it('navigates to Asset details when the mUSD token row is pressed', () => {
642+
const NavigationService = jest.requireMock(
643+
'../../../../../core/NavigationService',
644+
).default;
645+
646+
const { getByTestId } = renderWithProvider(<MoneyHomeView />);
647+
648+
fireEvent.press(getByTestId(MoneyMusdTokenRowTestIds.CONTAINER));
649+
650+
expect(NavigationService.navigation.navigate).toHaveBeenCalledWith(
651+
'Asset',
652+
expect.objectContaining({ source: expect.any(String) }),
653+
);
654+
});
655+
656+
it('navigates to HowItWorks when its section header is pressed', () => {
657+
const { getByText } = renderWithProvider(<MoneyHomeView />);
658+
659+
fireEvent.press(getByText(strings('money.how_it_works.title')));
660+
661+
expect(mockNavigate).toHaveBeenCalledWith(Routes.MONEY.HOW_IT_WORKS);
662+
});
663+
664+
it('opens the Learn more URL when Learn more is pressed', () => {
665+
const { Linking } = jest.requireMock('react-native');
666+
const { getByTestId } = renderWithProvider(<MoneyHomeView />);
667+
668+
fireEvent.press(getByTestId(MoneyWhatYouGetTestIds.LEARN_MORE_BUTTON));
669+
670+
expect(Linking.openURL).toHaveBeenCalledWith(
671+
expect.stringContaining('http'),
672+
);
673+
});
674+
});
675+
676+
describe('filled state navigation handlers', () => {
677+
it('navigates to Potential Earnings when View all is pressed on potential earnings section', () => {
678+
const { getByTestId } = renderWithProvider(<MoneyHomeView />);
679+
680+
fireEvent.press(
681+
getByTestId(MoneyPotentialEarningsTestIds.VIEW_ALL_BUTTON),
682+
);
683+
684+
expect(mockNavigate).toHaveBeenCalledWith(
685+
Routes.MONEY.POTENTIAL_EARNINGS,
686+
);
687+
});
688+
689+
it('initiates a custom conversion when a token Convert button is pressed', async () => {
690+
const { getByText } = renderWithProvider(<MoneyHomeView />);
691+
692+
fireEvent.press(getByText(strings('money.potential_earnings.convert')));
693+
694+
expect(mockInitiateCustomConversion).toHaveBeenCalledWith(
695+
expect.objectContaining({
696+
preferredPaymentToken: expect.objectContaining({
697+
address: mockConversionTokens[0].address,
698+
}),
699+
navigationStack: Routes.MONEY.ROOT,
700+
}),
701+
);
702+
});
703+
704+
it('logs an error when initiateCustomConversion rejects', async () => {
705+
mockInitiateCustomConversion.mockRejectedValueOnce(
706+
new Error('network failure'),
707+
);
708+
const Logger = jest.requireMock('../../../../../util/Logger');
709+
710+
const { getByText } = renderWithProvider(<MoneyHomeView />);
711+
712+
fireEvent.press(getByText(strings('money.potential_earnings.convert')));
713+
714+
await Promise.resolve();
715+
716+
expect(Logger.default.error).toHaveBeenCalledWith(
717+
expect.any(Error),
718+
expect.objectContaining({
719+
message: expect.stringContaining('MoneyHomeView'),
720+
}),
721+
);
722+
});
723+
724+
it('triggers the under-construction alert when an activity item is pressed', () => {
725+
const { getByTestId } = renderWithProvider(<MoneyHomeView />);
726+
727+
fireEvent.press(getByTestId('money-activity-item-padded-0'));
728+
729+
expect(global.alert).toHaveBeenCalled();
730+
});
731+
});
732+
733+
describe('card upsell mode — Get Now handler', () => {
734+
it('navigates to Card root when the Get Now card row is pressed', () => {
735+
const { getByTestId } = renderWithProvider(<MoneyHomeView />);
736+
737+
fireEvent.press(getByTestId(MoneyMetaMaskCardTestIds.VIRTUAL_CARD_ROW));
738+
739+
expect(mockNavigate).toHaveBeenCalledWith(Routes.CARD.ROOT);
740+
});
602741
});
603742

604743
describe('Metal card geolocation gating', () => {

app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.tsx

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -86,11 +86,28 @@ const MoneyHomeView = () => {
8686
[currentCurrency],
8787
);
8888

89-
const projectedEarnings = useMemo(() => {
89+
const monthlyEarnings = useMemo(() => {
9090
if (!totalFiatRaw || !apyPercent) return formattedZero;
9191
const balance = new BigNumber(totalFiatRaw);
9292
if (balance.isZero() || balance.isNaN()) return formattedZero;
93-
const earnings = calculateProjectedEarnings(balance.toNumber(), apyPercent);
93+
const earnings = calculateProjectedEarnings(
94+
balance.toNumber(),
95+
apyPercent,
96+
1 / 12,
97+
);
98+
if (!Number.isFinite(earnings)) return formattedZero;
99+
return moneyFormatFiat(new BigNumber(earnings), currentCurrency);
100+
}, [totalFiatRaw, apyPercent, currentCurrency, formattedZero]);
101+
102+
const yearlyEarnings = useMemo(() => {
103+
if (!totalFiatRaw || !apyPercent) return formattedZero;
104+
const balance = new BigNumber(totalFiatRaw);
105+
if (balance.isZero() || balance.isNaN()) return formattedZero;
106+
const earnings = calculateProjectedEarnings(
107+
balance.toNumber(),
108+
apyPercent,
109+
1,
110+
);
94111
if (!Number.isFinite(earnings)) return formattedZero;
95112
return moneyFormatFiat(new BigNumber(earnings), currentCurrency);
96113
}, [totalFiatRaw, apyPercent, currentCurrency, formattedZero]);
@@ -141,9 +158,8 @@ const MoneyHomeView = () => {
141158
const handleEarningsInfoPress = useCallback(() => {
142159
navigation.navigate(Routes.MONEY.MODALS.ROOT, {
143160
screen: Routes.MONEY.MODALS.EARNINGS_INFO_SHEET,
144-
params: { apy: apyPercent },
145161
});
146-
}, [navigation, apyPercent]);
162+
}, [navigation]);
147163

148164
const handleMusdRowPress = useCallback(() => {
149165
NavigationService.navigation.navigate('Asset', {
@@ -248,8 +264,8 @@ const MoneyHomeView = () => {
248264
/>
249265
<Divider />
250266
<MoneyEarnings
251-
lifetimeEarnings={formattedZero}
252-
projectedEarnings={projectedEarnings}
267+
monthlyEarnings={monthlyEarnings}
268+
yearlyEarnings={yearlyEarnings}
253269
isLoading={vaultApyQuery.isLoading || isAggregatedBalanceLoading}
254270
onInfoPress={handleEarningsInfoPress}
255271
/>
@@ -285,7 +301,6 @@ const MoneyHomeView = () => {
285301
<MoneyPotentialEarnings
286302
tokens={conversionTokens}
287303
apy={apyPercent}
288-
condensed={isMilestone}
289304
onTokenPress={handleTokenConvertPress}
290305
onViewAllPress={handleEarnCryptoPress}
291306
onHeaderPress={handleEarnCryptoPress}

0 commit comments

Comments
 (0)