From 12ead7212889a41f5dfa3f48678ed47eaf0c19a0 Mon Sep 17 00:00:00 2001 From: Vit Horacek Date: Thu, 12 Feb 2026 11:26:35 +0100 Subject: [PATCH 1/2] Fix fraud alert widget not rendering due to wrong data path The card fraud data (possibleFraud) is stored under card.nameValuePairs in Onyx, not under card.message. This mismatch caused the fraud alert widget to never render since card.message?.possibleFraud was always undefined. Additionally, isCardWithPotentialFraud now also checks for the presence of nameValuePairs.possibleFraud, since the card.fraud field may already be reset to 'none' while the fraud alert is still pending review. Co-authored-by: Cursor --- src/libs/CardUtils.ts | 6 +- src/pages/home/TimeSensitiveSection/index.tsx | 4 +- src/types/onyx/Card.ts | 13 +--- .../unit/hooks/useTimeSensitiveCards.test.ts | 27 ++++++++- tests/unit/selectors/CardTest.ts | 59 ++++++++++++++++--- tests/utils/collections/card.ts | 4 ++ 6 files changed, 90 insertions(+), 23 deletions(-) diff --git a/src/libs/CardUtils.ts b/src/libs/CardUtils.ts index 1550fb646f0f5..e667ee90662ed 100644 --- a/src/libs/CardUtils.ts +++ b/src/libs/CardUtils.ts @@ -939,10 +939,12 @@ function isCardPendingActivate(card?: Card) { /** * Check if a card has potential fraud that needs review. - * Returns true if the card has fraud type 'domain' or 'individual'. + * Returns true if the card has fraud type 'domain' or 'individual', + * or if possibleFraud data exists in nameValuePairs (the fraud field + * may already be reset to 'none' while the alert is still pending). */ function isCardWithPotentialFraud(card: Card): boolean { - return card.fraud === CONST.EXPENSIFY_CARD.FRAUD_TYPES.DOMAIN || card.fraud === CONST.EXPENSIFY_CARD.FRAUD_TYPES.INDIVIDUAL; + return card.fraud === CONST.EXPENSIFY_CARD.FRAUD_TYPES.DOMAIN || card.fraud === CONST.EXPENSIFY_CARD.FRAUD_TYPES.INDIVIDUAL || !!card.nameValuePairs?.possibleFraud; } function isCardPendingReplace(card?: Card) { diff --git a/src/pages/home/TimeSensitiveSection/index.tsx b/src/pages/home/TimeSensitiveSection/index.tsx index b37c7f4eddc27..e39a4842d7ed2 100644 --- a/src/pages/home/TimeSensitiveSection/index.tsx +++ b/src/pages/home/TimeSensitiveSection/index.tsx @@ -125,13 +125,13 @@ function TimeSensitiveSection() { {/* Priority 1: Card fraud alerts */} {shouldShowReviewCardFraud && cardsWithFraud.map((card) => { - if (!card.message?.possibleFraud) { + if (!card.nameValuePairs?.possibleFraud) { return null; } return ( ); })} diff --git a/src/types/onyx/Card.ts b/src/types/onyx/Card.ts index 189feb3fc25fa..073e2fb09880e 100644 --- a/src/types/onyx/Card.ts +++ b/src/types/onyx/Card.ts @@ -40,12 +40,6 @@ type PossibleFraudData = { fraudAlertReportActionID?: number; }; -/** Model of card message data */ -type CardMessage = { - /** Possible fraud information */ - possibleFraud?: PossibleFraudData; -}; - /** Model of Expensify card */ type Card = OnyxCommon.OnyxValueWithOfflineFeedback<{ /** Card ID number */ @@ -93,9 +87,6 @@ type Card = OnyxCommon.OnyxValueWithOfflineFeedback<{ /** Current fraud state of the card */ fraud: ValueOf; - /** Card message data containing possible fraud info and other metadata */ - message?: CardMessage; - /** Card name */ cardName?: string; @@ -201,6 +192,9 @@ type Card = OnyxCommon.OnyxValueWithOfflineFeedback<{ * null/undefined if card is not frozen */ frozen?: FrozenCardData | null; + + /** Possible fraud information */ + possibleFraud?: PossibleFraudData; }> & OnyxCommon.OnyxValueWithOfflineFeedback< /** Type of export card */ @@ -423,7 +417,6 @@ export type { AssignableCardsList, CardAssignmentData, UnassignedCard, - CardMessage, PossibleFraudData, FrozenCardData, }; diff --git a/tests/unit/hooks/useTimeSensitiveCards.test.ts b/tests/unit/hooks/useTimeSensitiveCards.test.ts index b1ae37e5665e1..96aa6f405bbd0 100644 --- a/tests/unit/hooks/useTimeSensitiveCards.test.ts +++ b/tests/unit/hooks/useTimeSensitiveCards.test.ts @@ -172,7 +172,11 @@ describe('useTimeSensitiveCards', () => { }); it('should identify cards with fraud and set shouldShowReviewCardFraud to true', async () => { - const cardWithFraud = createRandomExpensifyCard(1, {state: CONST.EXPENSIFY_CARD.STATE.OPEN, fraud: CONST.EXPENSIFY_CARD.FRAUD_TYPES.DOMAIN}); + const cardWithFraud = createRandomExpensifyCard(1, { + state: CONST.EXPENSIFY_CARD.STATE.OPEN, + fraud: CONST.EXPENSIFY_CARD.FRAUD_TYPES.DOMAIN, + possibleFraud: {triggerAmount: 5663, triggerMerchant: 'WAL-MART #2366', currency: 'USD', fraudAlertReportID: 123456}, + }); const cardList: CardList = {'1': cardWithFraud}; await Onyx.merge(ONYXKEYS.CARD_LIST, cardList); @@ -182,10 +186,11 @@ describe('useTimeSensitiveCards', () => { expect(result.current.cardsWithFraud).toHaveLength(1); expect(result.current.cardsWithFraud.at(0)?.cardID).toBe(1); + expect(result.current.cardsWithFraud.at(0)?.nameValuePairs?.possibleFraud?.triggerAmount).toBe(5663); expect(result.current.shouldShowReviewCardFraud).toBe(true); }); - it('should not show fraud review for cards with fraud type NONE', async () => { + it('should not show fraud review for cards with fraud type NONE and no possibleFraud data', async () => { const cardWithNoFraud = createRandomExpensifyCard(1, {state: CONST.EXPENSIFY_CARD.STATE.OPEN, fraud: CONST.EXPENSIFY_CARD.FRAUD_TYPES.NONE}); const cardList: CardList = {'1': cardWithNoFraud}; @@ -197,4 +202,22 @@ describe('useTimeSensitiveCards', () => { expect(result.current.cardsWithFraud).toHaveLength(0); expect(result.current.shouldShowReviewCardFraud).toBe(false); }); + + it('should show fraud review when fraud is NONE but possibleFraud data exists in nameValuePairs', async () => { + const cardWithPendingFraudAlert = createRandomExpensifyCard(1, { + state: CONST.EXPENSIFY_CARD.STATE.OPEN, + fraud: CONST.EXPENSIFY_CARD.FRAUD_TYPES.NONE, + possibleFraud: {triggerAmount: 5663, triggerMerchant: 'WAL-MART #2366', currency: 'USD', fraudAlertReportID: 5230242215684213}, + }); + const cardList: CardList = {'1': cardWithPendingFraudAlert}; + + await Onyx.merge(ONYXKEYS.CARD_LIST, cardList); + await waitForBatchedUpdates(); + + const {result} = renderHook(() => useTimeSensitiveCards()); + + expect(result.current.cardsWithFraud).toHaveLength(1); + expect(result.current.cardsWithFraud.at(0)?.cardID).toBe(1); + expect(result.current.shouldShowReviewCardFraud).toBe(true); + }); }); diff --git a/tests/unit/selectors/CardTest.ts b/tests/unit/selectors/CardTest.ts index 3bff645af2b93..b69aca649979e 100644 --- a/tests/unit/selectors/CardTest.ts +++ b/tests/unit/selectors/CardTest.ts @@ -376,7 +376,11 @@ describe('timeSensitiveCardsSelector', () => { }); it('identifies cards with domain fraud', () => { - const cardWithDomainFraud = createRandomExpensifyCard(1, {state: CONST.EXPENSIFY_CARD.STATE.OPEN, fraud: CONST.EXPENSIFY_CARD.FRAUD_TYPES.DOMAIN}); + const cardWithDomainFraud = createRandomExpensifyCard(1, { + state: CONST.EXPENSIFY_CARD.STATE.OPEN, + fraud: CONST.EXPENSIFY_CARD.FRAUD_TYPES.DOMAIN, + possibleFraud: {triggerAmount: 5000, triggerMerchant: 'Test Merchant', currency: 'USD', fraudAlertReportID: 123}, + }); const normalCard = createRandomExpensifyCard(2, {state: CONST.EXPENSIFY_CARD.STATE.OPEN, fraud: CONST.EXPENSIFY_CARD.FRAUD_TYPES.NONE}); const cardList: CardList = { @@ -389,10 +393,15 @@ describe('timeSensitiveCardsSelector', () => { expect(result.cardsWithFraud).toHaveLength(1); expect(result.cardsWithFraud.at(0)?.cardID).toBe(1); expect(result.cardsWithFraud.at(0)?.fraud).toBe(CONST.EXPENSIFY_CARD.FRAUD_TYPES.DOMAIN); + expect(result.cardsWithFraud.at(0)?.nameValuePairs?.possibleFraud?.triggerAmount).toBe(5000); }); it('identifies cards with individual fraud', () => { - const cardWithIndividualFraud = createRandomExpensifyCard(1, {state: CONST.EXPENSIFY_CARD.STATE.OPEN, fraud: CONST.EXPENSIFY_CARD.FRAUD_TYPES.INDIVIDUAL}); + const cardWithIndividualFraud = createRandomExpensifyCard(1, { + state: CONST.EXPENSIFY_CARD.STATE.OPEN, + fraud: CONST.EXPENSIFY_CARD.FRAUD_TYPES.INDIVIDUAL, + possibleFraud: {triggerAmount: 3000, triggerMerchant: 'Suspicious Shop', currency: 'USD', fraudAlertReportID: 456}, + }); const normalCard = createRandomExpensifyCard(2, {state: CONST.EXPENSIFY_CARD.STATE.OPEN, fraud: CONST.EXPENSIFY_CARD.FRAUD_TYPES.NONE}); const cardList: CardList = { @@ -405,13 +414,25 @@ describe('timeSensitiveCardsSelector', () => { expect(result.cardsWithFraud).toHaveLength(1); expect(result.cardsWithFraud.at(0)?.cardID).toBe(1); expect(result.cardsWithFraud.at(0)?.fraud).toBe(CONST.EXPENSIFY_CARD.FRAUD_TYPES.INDIVIDUAL); + expect(result.cardsWithFraud.at(0)?.nameValuePairs?.possibleFraud?.triggerMerchant).toBe('Suspicious Shop'); }); it('detects fraud on both physical and virtual Expensify cards', () => { - const physicalCardWithFraud = createRandomExpensifyCard(1, {state: CONST.EXPENSIFY_CARD.STATE.OPEN, fraud: CONST.EXPENSIFY_CARD.FRAUD_TYPES.DOMAIN}); + const physicalCardWithFraud = createRandomExpensifyCard(1, { + state: CONST.EXPENSIFY_CARD.STATE.OPEN, + fraud: CONST.EXPENSIFY_CARD.FRAUD_TYPES.DOMAIN, + possibleFraud: {triggerAmount: 5000, triggerMerchant: 'Store A', currency: 'USD', fraudAlertReportID: 111}, + }); const virtualCardWithFraud: Card = { - ...createRandomExpensifyCard(2, {state: CONST.EXPENSIFY_CARD.STATE.OPEN, fraud: CONST.EXPENSIFY_CARD.FRAUD_TYPES.INDIVIDUAL}), - nameValuePairs: {isVirtual: true} as Card['nameValuePairs'], + ...createRandomExpensifyCard(2, { + state: CONST.EXPENSIFY_CARD.STATE.OPEN, + fraud: CONST.EXPENSIFY_CARD.FRAUD_TYPES.INDIVIDUAL, + possibleFraud: {triggerAmount: 2000, triggerMerchant: 'Store B', currency: 'USD', fraudAlertReportID: 222}, + }), + nameValuePairs: { + isVirtual: true, + possibleFraud: {triggerAmount: 2000, triggerMerchant: 'Store B', currency: 'USD', fraudAlertReportID: 222}, + } as Card['nameValuePairs'], }; const cardList: CardList = { @@ -430,7 +451,11 @@ describe('timeSensitiveCardsSelector', () => { ...createRandomCompanyCard(1, {bank: 'vcf'}), fraud: CONST.EXPENSIFY_CARD.FRAUD_TYPES.DOMAIN, }; - const expensifyCardWithFraud = createRandomExpensifyCard(2, {state: CONST.EXPENSIFY_CARD.STATE.OPEN, fraud: CONST.EXPENSIFY_CARD.FRAUD_TYPES.DOMAIN}); + const expensifyCardWithFraud = createRandomExpensifyCard(2, { + state: CONST.EXPENSIFY_CARD.STATE.OPEN, + fraud: CONST.EXPENSIFY_CARD.FRAUD_TYPES.DOMAIN, + possibleFraud: {triggerAmount: 7500, triggerMerchant: 'Online Store', currency: 'USD', fraudAlertReportID: 789}, + }); const cardList: CardList = { '1': companyCardWithFraud, @@ -444,7 +469,7 @@ describe('timeSensitiveCardsSelector', () => { expect(result.cardsWithFraud.at(0)?.cardID).toBe(2); }); - it('does not include cards with fraud type NONE', () => { + it('does not include cards with fraud type NONE and no possibleFraud data', () => { const cardWithNoFraud = createRandomExpensifyCard(1, {state: CONST.EXPENSIFY_CARD.STATE.OPEN, fraud: CONST.EXPENSIFY_CARD.FRAUD_TYPES.NONE}); const cardList: CardList = { @@ -455,6 +480,26 @@ describe('timeSensitiveCardsSelector', () => { expect(result.cardsWithFraud).toHaveLength(0); }); + + it('includes cards with fraud type NONE when possibleFraud data exists in nameValuePairs', () => { + const cardWithPendingFraudAlert = createRandomExpensifyCard(1, { + state: CONST.EXPENSIFY_CARD.STATE.OPEN, + fraud: CONST.EXPENSIFY_CARD.FRAUD_TYPES.NONE, + possibleFraud: {triggerAmount: 5663, triggerMerchant: 'WAL-MART #2366', currency: 'USD', fraudAlertReportID: 5230242215684213}, + }); + const normalCard = createRandomExpensifyCard(2, {state: CONST.EXPENSIFY_CARD.STATE.OPEN, fraud: CONST.EXPENSIFY_CARD.FRAUD_TYPES.NONE}); + + const cardList: CardList = { + '1': cardWithPendingFraudAlert, + '2': normalCard, + }; + + const result = timeSensitiveCardsSelector(cardList); + + expect(result.cardsWithFraud).toHaveLength(1); + expect(result.cardsWithFraud.at(0)?.cardID).toBe(1); + expect(result.cardsWithFraud.at(0)?.nameValuePairs?.possibleFraud?.triggerAmount).toBe(5663); + }); }); describe('areAllExpensifyCardsShipped', () => { diff --git a/tests/utils/collections/card.ts b/tests/utils/collections/card.ts index c1aeab2d79881..65106859c14ff 100644 --- a/tests/utils/collections/card.ts +++ b/tests/utils/collections/card.ts @@ -3,6 +3,7 @@ import {format} from 'date-fns'; import type {ValueOf} from 'type-fest'; import CONST from '@src/CONST'; import type {Card} from '@src/types/onyx'; +import type {PossibleFraudData} from '@src/types/onyx/Card'; import type {CardFeedWithNumber} from '@src/types/onyx/CardFeeds'; export default function createRandomCard( @@ -14,6 +15,7 @@ export default function createRandomCard( fraud?: ValueOf; accountID?: number; domainName?: string; + possibleFraud?: PossibleFraudData; }, ): Card { const cardID = index > 0 ? index : randNumber(); @@ -62,6 +64,7 @@ export default function createRandomCard( scrapeMinDate: format(randPastDate(), CONST.DATE.FNS_DB_FORMAT_STRING), errors: {}, errorFields: {}, + ...(options?.possibleFraud ? {nameValuePairs: {possibleFraud: options.possibleFraud} as Card['nameValuePairs']} : {}), }; } @@ -76,6 +79,7 @@ function createRandomExpensifyCard( fraud?: ValueOf; accountID?: number; domainName?: string; + possibleFraud?: PossibleFraudData; }, ): Card { return createRandomCard(index, { From 3221b86e0552d0a377b74b4ff415597f15419884 Mon Sep 17 00:00:00 2001 From: Vit Horacek Date: Thu, 12 Feb 2026 13:13:48 +0100 Subject: [PATCH 2/2] Update isCardWithPotentialFraud check Co-authored-by: Cursor --- src/libs/CardUtils.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/libs/CardUtils.ts b/src/libs/CardUtils.ts index e667ee90662ed..bdd6a8cef0223 100644 --- a/src/libs/CardUtils.ts +++ b/src/libs/CardUtils.ts @@ -939,9 +939,7 @@ function isCardPendingActivate(card?: Card) { /** * Check if a card has potential fraud that needs review. - * Returns true if the card has fraud type 'domain' or 'individual', - * or if possibleFraud data exists in nameValuePairs (the fraud field - * may already be reset to 'none' while the alert is still pending). + * Returns true if the card has fraud type 'domain' or 'individual'. */ function isCardWithPotentialFraud(card: Card): boolean { return card.fraud === CONST.EXPENSIFY_CARD.FRAUD_TYPES.DOMAIN || card.fraud === CONST.EXPENSIFY_CARD.FRAUD_TYPES.INDIVIDUAL || !!card.nameValuePairs?.possibleFraud;