diff --git a/src/components/CardFeedIcon.tsx b/src/components/CardFeedIcon.tsx index 080f3d9f34f3..2ebe55927163 100644 --- a/src/components/CardFeedIcon.tsx +++ b/src/components/CardFeedIcon.tsx @@ -4,13 +4,14 @@ import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useThemeIllustrations from '@hooks/useThemeIllustrations'; import {getCardFeedIcon, getPlaidInstitutionIconUrl, getPlaidInstitutionId} from '@libs/CardUtils'; import type {CardFeedWithDomainID} from '@src/types/onyx'; +import type {CardFeedWithNumber} from '@src/types/onyx/CardFeeds'; import type {IconProps} from './Icon'; import Icon from './Icon'; import PlaidCardFeedIcon from './PlaidCardFeedIcon'; type CardFeedIconProps = { isExpensifyCardFeed?: boolean; - selectedFeed?: CardFeedWithDomainID | undefined; + selectedFeed?: CardFeedWithDomainID | CardFeedWithNumber | undefined; iconProps?: Partial; useSkeletonLoader?: boolean; }; diff --git a/src/libs/CardUtils.ts b/src/libs/CardUtils.ts index 51d4a0826393..efc7da4a5da9 100644 --- a/src/libs/CardUtils.ts +++ b/src/libs/CardUtils.ts @@ -41,6 +41,7 @@ import type { CompanyFeeds, NonConnectableBankName, } from '@src/types/onyx/CardFeeds'; +import type {CardFeedErrors} from '@src/types/onyx/DerivedValues'; import type {SelectedTimezone} from '@src/types/onyx/PersonalDetails'; import type {Connections} from '@src/types/onyx/Policy'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; @@ -1730,6 +1731,39 @@ function getDisplayableExpensifyCards(cardList: CardList | undefined): Card[] { }); } +/** + * Active, non-Expensify, non-cash cards (employer feed or personal Plaid) that are not flagged + * as broken at the card- or feed-level, sorted by cardID ascending. + * + * No `domainName` dedupe: third-party cards don't share the Expensify "one domain ⇒ one + * physical+virtual pair" invariant, so deduping would silently collapse distinct cards. + * + * `cardFeedErrors` maps are `Record` (presence = broken), so filter with + * truthy/falsy — `=== true` would always be false and let broken cards through. + */ +function getDisplayableThirdPartyCards(cardList: CardList | undefined, cardFeedErrors: Pick): Card[] { + if (!cardList) { + return []; + } + + const {cardsWithBrokenFeedConnection, personalCardsWithBrokenConnection} = cardFeedErrors; + const cards = Object.values(cardList).filter( + (card) => + CONST.EXPENSIFY_CARD.ACTIVE_STATES.includes(card.state ?? 0) && + !isExpensifyCard(card) && + (!!card.domainName || isPersonalCard(card)) && + card.cardName !== CONST.COMPANY_CARDS.CARD_NAME.CASH && + !isCardConnectionBroken(card) && + !cardsWithBrokenFeedConnection[card.cardID] && + !personalCardsWithBrokenConnection[card.cardID], + ); + + // Stable sort by `getAssignedCardSortKey` (constant `2` for all non-Expensify cards), + // so the result preserves the cardID-ascending order produced by `Object.values` over + // integer-indexed keys. + return lodashSortBy(cards, getAssignedCardSortKey); +} + function getCardCurrency(card?: OnyxEntry, cardSettings?: OnyxEntry): string { // If currency is set on the card itself, use it. if (card?.nameValuePairs?.currency) { @@ -1948,6 +1982,7 @@ export { isCardInactive, isCardWithPotentialFraud, getDisplayableExpensifyCards, + getDisplayableThirdPartyCards, isExpiredCard, getCardCurrency, getSelectedCardsSharedCurrency, diff --git a/src/pages/home/YourSpendSection/CardRow.tsx b/src/pages/home/YourSpendSection/CardRow.tsx index 043c6e5e116b..1d5e39fa62e6 100644 --- a/src/pages/home/YourSpendSection/CardRow.tsx +++ b/src/pages/home/YourSpendSection/CardRow.tsx @@ -10,11 +10,13 @@ import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; +import {getCardFeedWithDomainID} from '@libs/CardUtils'; import Navigation from '@libs/Navigation/Navigation'; import variables from '@styles/variables'; import ROUTES from '@src/ROUTES'; import RemainingLimitCircle from './RemainingLimitCircle'; import type {useYourSpendData} from './useYourSpendData'; +import {YOUR_SPEND_CARD_KIND} from './useYourSpendData'; type CardRowProps = { cardRow: ReturnType['cardRows'][number]; @@ -32,6 +34,41 @@ function CardRow({cardRow, wrapperStyle}: CardRowProps) { const cardTotal = cardRow.total !== undefined ? convertToDisplayString(cardRow.total, cardRow.currency) : undefined; + // Pick the artwork branch up front so the JSX below stays readable. + // - Expensify Card: keep the existing illustrated feed icon. + // - Third-party with `fundID`: employer-feed company card → `feed|domainID` key. + // - Third-party without `fundID`: personal Plaid card → pass the bare `bank` + // (`plaid.ins_…`) directly. `CardFeedIcon` resolves the Plaid institution icon + // internally via `getPlaidInstitutionId(selectedFeed)`. + const iconProps = { + width: variables.cardIconWidth, + height: variables.cardIconHeight, + additionalStyles: [styles.overflowHidden, styles.br1], + }; + let leftIcon: React.ReactElement; + if (cardRow.kind === YOUR_SPEND_CARD_KIND.EXPENSIFY) { + leftIcon = ( + + ); + } else if (cardRow.fundID !== undefined) { + leftIcon = ( + + ); + } else { + leftIcon = ( + + ); + } + return ( } - leftComponent={ - - - - } + leftComponent={{leftIcon}} wrapperStyle={wrapperStyle} hasSubMenuItems shouldCheckActionAllowedOnPress={false} diff --git a/src/pages/home/YourSpendSection/SpendSummaryRow.tsx b/src/pages/home/YourSpendSection/SpendSummaryRow.tsx index 10d448e71e29..823427927534 100644 --- a/src/pages/home/YourSpendSection/SpendSummaryRow.tsx +++ b/src/pages/home/YourSpendSection/SpendSummaryRow.tsx @@ -11,7 +11,7 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import variables from '@styles/variables'; import type IconAsset from '@src/types/utils/IconAsset'; -import YOUR_SPEND_ROW_STATE from './const'; +import {YOUR_SPEND_ROW_STATE} from './const'; import type {useYourSpendData} from './useYourSpendData'; // Skeleton geometry mirrors `ForYouSection/ForYouSkeleton.tsx` so the home page diff --git a/src/pages/home/YourSpendSection/const.ts b/src/pages/home/YourSpendSection/const.ts index 0c925e3a35fa..1ccf87b326fd 100644 --- a/src/pages/home/YourSpendSection/const.ts +++ b/src/pages/home/YourSpendSection/const.ts @@ -5,4 +5,14 @@ const YOUR_SPEND_ROW_STATE = { HIDDEN_EMPTY: 'hiddenEmpty', } as const; -export default YOUR_SPEND_ROW_STATE; +/** + * Discriminator for a card row's artwork branch: + * - `EXPENSIFY` keeps the existing Expensify Card icon. + * - `THIRD_PARTY` switches to bank artwork (employer feed) or a Plaid institution icon. + */ +const YOUR_SPEND_CARD_KIND = { + EXPENSIFY: 'expensify', + THIRD_PARTY: 'thirdParty', +} as const; + +export {YOUR_SPEND_ROW_STATE, YOUR_SPEND_CARD_KIND}; diff --git a/src/pages/home/YourSpendSection/useYourSpendData.ts b/src/pages/home/YourSpendSection/useYourSpendData.ts index 3db549581f12..fc47dbff4e15 100644 --- a/src/pages/home/YourSpendSection/useYourSpendData.ts +++ b/src/pages/home/YourSpendSection/useYourSpendData.ts @@ -2,20 +2,23 @@ import {useIsFocused} from '@react-navigation/native'; import {useEffect, useEffectEvent, useMemo, useState} from 'react'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; +import useCardFeedErrors from '@hooks/useCardFeedErrors'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import {search} from '@libs/actions/Search'; -import {getDisplayableExpensifyCards} from '@libs/CardUtils'; +import {getDisplayableExpensifyCards, getDisplayableThirdPartyCards, lastFourNumbersFromCardName} from '@libs/CardUtils'; import {arePaymentsEnabled, hasApprovalFlow, isPaidGroupPolicy} from '@libs/PolicyUtils'; import {buildSearchQueryJSON} from '@libs/SearchQueryUtils'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Policy} from '@src/types/onyx'; +import type {Card, Policy} from '@src/types/onyx'; +import type {CardFeedWithNumber} from '@src/types/onyx/CardFeeds'; import type SearchResults from '@src/types/onyx/SearchResults'; -import YOUR_SPEND_ROW_STATE from './const'; +import {YOUR_SPEND_CARD_KIND, YOUR_SPEND_ROW_STATE} from './const'; import {buildAwaitingApprovalQuery, buildRecentCardTransactionsQuery, buildRepaidLast30DaysQuery} from './queries'; type YourSpendRowState = ValueOf; +type YourSpendCardKind = ValueOf; type GetYourSpendRowStateParams = { isApplicable: boolean; @@ -29,12 +32,20 @@ type YourSpendCardRow = { query: string; total: number | undefined; currency: string | undefined; - // Fraction (0–1) of the card's unapproved expense limit that has been spent. - // `undefined` when the card has no limit configured, in which case the consumer - // should not render the remaining-limit indicator. + + // Fraction (0–1) of the card's unapproved expense limit. `undefined` when no + // limit is configured or for third-party rows; suppresses the limit indicator. spentFraction: number | undefined; + + kind: YourSpendCardKind; + bank: CardFeedWithNumber; + + // Set for employer-feed third-party cards; `undefined` for personal Plaid cards. + fundID: string | undefined; }; +type TaggedCard = {card: Card; kind: YourSpendCardKind}; + type YourSpendApplicability = { isApprovalApplicable: boolean; isPaymentApplicable: boolean; @@ -96,24 +107,37 @@ function useYourSpendData(): UseYourSpendDataReturn { const {isApprovalApplicable, isPaymentApplicable} = getYourSpendApplicability(policies); - // Memo anchor. The compiler does not auto-cache this call, so without the - // `useMemo` every downstream value derived from `displayableCards` would - // get a new identity each render and defeat the compiler's downstream caches. - const displayableCards = useMemo(() => getDisplayableExpensifyCards(cardList), [cardList]); + // Destructure here so downstream memos depend only on the sub-records, not on + // the parent value that's rebuilt on every CARD_FEED_ERRORS tick. + const {cardsWithBrokenFeedConnection, personalCardsWithBrokenConnection} = useCardFeedErrors(); + + // Memo anchor: the compiler does not auto-cache these calls, so downstream + // memos would invalidate every render without it. + const expensifyCards = useMemo(() => getDisplayableExpensifyCards(cardList), [cardList]); + const thirdPartyCards = useMemo( + () => getDisplayableThirdPartyCards(cardList, {cardsWithBrokenFeedConnection, personalCardsWithBrokenConnection}), + [cardList, cardsWithBrokenFeedConnection, personalCardsWithBrokenConnection], + ); + + // Ordering invariant: Expensify Card rows first, then third-party rows. + const displayableCards = useMemo( + () => [ + ...expensifyCards.map((card): TaggedCard => ({card, kind: YOUR_SPEND_CARD_KIND.EXPENSIFY})), + ...thirdPartyCards.map((card): TaggedCard => ({card, kind: YOUR_SPEND_CARD_KIND.THIRD_PARTY})), + ], + [expensifyCards, thirdPartyCards], + ); - // Stable signature of the displayable card IDs. Used as a dependency for the - // search-firing effect so it re-runs when cards finish loading after first - // focus, without re-firing on unrelated cardList updates. + // Stable signature for the search-firing effect — re-fires on card-set changes + // but not on unrelated `cardList` mutations. const displayableCardIDsKey = displayableCards - .map((card) => card.cardID) + .map(({card}) => card.cardID) .sort((a, b) => a - b) .join(','); - // Precompute the query string and parsed JSON per card. Another memo anchor: - // downstream caches key off this object's identity. const cardQueryByCardID = useMemo( () => - displayableCards.reduce}>>((acc, card) => { + displayableCards.reduce}>>((acc, {card}) => { const query = buildRecentCardTransactionsQuery(accountID, card.cardID); acc[card.cardID] = {query, queryJSON: buildSearchQueryJSON(query)}; return acc; @@ -121,9 +145,6 @@ function useYourSpendData(): UseYourSpendDataReturn { [displayableCards, accountID], ); - // Narrow the snapshot subscription to our displayable cards and project to just - // {count, total, currency}, so `useOnyx`'s deep-equal on selector output is - // O(1) per card and unrelated snapshot mutations don't re-render us. const cardSnapshotKeys = useMemo( () => Object.values(cardQueryByCardID) @@ -139,6 +160,8 @@ function useYourSpendData(): UseYourSpendDataReturn { currency: string | undefined; }; + // Project snapshots down to {count, total, currency} so unrelated snapshot + // mutations don't re-render us (useOnyx deep-equals selector output). const cardSnapshotsSelector = (snapshots: OnyxCollection | undefined): Record | undefined => { if (!snapshots || cardSnapshotKeys.length === 0) { return undefined; @@ -152,14 +175,12 @@ function useYourSpendData(): UseYourSpendDataReturn { }; const [cardSnapshots] = useOnyx(ONYXKEYS.COLLECTION.SNAPSHOT, {selector: cardSnapshotsSelector}); - // Per-card equivalent of the approval/payment cache below; see that comment - // for the full mechanic. Cached by cardID so each row keeps its last READY - // totals when the shared snapshot is wiped by the Search screen. + // Per-card READY totals cache; see the approval/payment cache below for the mechanic. const [cachedCardTotals, setCachedCardTotals] = useState>({}); const cardCacheUpdates: Record = {}; let hasCardCacheUpdates = false; - for (const card of displayableCards) { + for (const {card} of displayableCards) { const entry = cardQueryByCardID[card.cardID]; const hash = entry?.queryJSON?.hash; const snapshotKey = hash !== undefined ? `${ONYXKEYS.COLLECTION.SNAPSHOT}${hash}` : undefined; @@ -177,11 +198,9 @@ function useYourSpendData(): UseYourSpendDataReturn { setCachedCardTotals((prev) => ({...prev, ...cardCacheUpdates})); } - // `useMemo` to keep a stable identity for the consumer list; the compiler - // does not extract this reduce on its own. const cardRows: YourSpendCardRow[] = useMemo( () => - displayableCards.reduce((acc, card) => { + displayableCards.reduce((acc, {card, kind}) => { const entry = cardQueryByCardID[card.cardID]; if (!entry) { return acc; @@ -190,9 +209,7 @@ function useYourSpendData(): UseYourSpendDataReturn { const snapshotKey = hash !== undefined ? `${ONYXKEYS.COLLECTION.SNAPSHOT}${hash}` : undefined; const snapshot = snapshotKey ? cardSnapshots?.[snapshotKey] : undefined; - // Snapshot loaded but its count was wiped by the Search screen — fall back - // to cached READY totals. "Snapshot never loaded" and "count === 0" both - // leave the row hidden. + // Snapshot loaded but count wiped by the Search screen — fall back to cached READY totals. const countIsMissing = snapshot !== undefined && (snapshot.count === undefined || snapshot.count === null); const cached = cachedCardTotals[card.cardID]; const shouldUseCached = countIsMissing && cached !== undefined; @@ -204,15 +221,29 @@ function useYourSpendData(): UseYourSpendDataReturn { const total = snapshot?.count ? snapshot.total : cached?.total; const currency = snapshot?.count ? snapshot.currency : cached?.currency; - const unapprovedExpenseLimit = card.nameValuePairs?.unapprovedExpenseLimit; - const spentFraction = unapprovedExpenseLimit ? 1 - (card.availableSpend ?? 0) / unapprovedExpenseLimit : undefined; + // Fallback for third-party cards with empty `lastFourPAN` and digits in `cardName` + // (e.g. "CREDIT CARD...1234"; no-space names fall through to ""). Ternary so + // empty-string `lastFourPAN` also falls through. + const lastFour = card.lastFourPAN ? card.lastFourPAN : lastFourNumbersFromCardName(card.cardName); + if (!lastFour) { + return acc; + } + + let spentFraction: number | undefined; + if (kind === YOUR_SPEND_CARD_KIND.EXPENSIFY) { + const unapprovedExpenseLimit = card.nameValuePairs?.unapprovedExpenseLimit; + spentFraction = unapprovedExpenseLimit ? 1 - (card.availableSpend ?? 0) / unapprovedExpenseLimit : undefined; + } acc.push({ cardID: card.cardID, - lastFour: card.lastFourPAN ?? '', + lastFour, query: entry.query, total, currency, spentFraction, + kind, + bank: card.bank, + fundID: card.fundID, }); return acc; }, []), @@ -225,12 +256,10 @@ function useYourSpendData(): UseYourSpendDataReturn { const approvalTotalsRaw: YourSpendRowTotals = {total: approvalSearchResults?.search.total, currency: approvalSearchResults?.search.currency}; const paymentTotalsRaw: YourSpendRowTotals = {total: paymentSearchResults?.search.total, currency: paymentSearchResults?.search.currency}; - // The Search screen reuses the same snapshot key and calls `search()` with - // `shouldCalculateTotals: false`, wiping `count/total/currency` on the shared - // snapshot and briefly flipping this row to HIDDEN_EMPTY between navigation - // and the home re-fetch. Cache the last READY totals and reuse them when the - // snapshot is loaded but its count has been wiped. A genuine `count === 0` - // is still treated as empty. + // The Search screen calls `search()` with `shouldCalculateTotals: false` on the + // same snapshot key, wiping count/total/currency and briefly flipping the row + // to HIDDEN_EMPTY. Cache the last READY totals and reuse them when the snapshot + // is loaded but count is missing; a genuine `count === 0` is still empty. const [cachedApprovalReady, setCachedApprovalReady] = useState(null); const [cachedPaymentReady, setCachedPaymentReady] = useState(null); @@ -261,15 +290,14 @@ function useYourSpendData(): UseYourSpendDataReturn { const approvalTotals: YourSpendRowTotals = shouldUseCachedApproval && cachedApprovalReady ? cachedApprovalReady : approvalTotalsRaw; const paymentTotals: YourSpendRowTotals = shouldUseCachedPayment && cachedPaymentReady ? cachedPaymentReady : paymentTotalsRaw; - // Stable key that changes whenever approval/payment applicability flips, so - // the search-firing effect re-runs. + // Re-fires the search effect when approval/payment applicability flips. const applicabilityKey = `${isApprovalApplicable ? 1 : 0}${isPaymentApplicable ? 1 : 0}`; const fireSearches = useEffectEvent(() => { if (isOffline) { return; } - for (const card of displayableCards) { + for (const {card} of displayableCards) { const cardQueryJSON = cardQueryByCardID[card.cardID]?.queryJSON; if (!cardQueryJSON) { continue; @@ -326,5 +354,5 @@ function useYourSpendData(): UseYourSpendDataReturn { }; } -export {YOUR_SPEND_ROW_STATE, getYourSpendApplicability, getYourSpendRowState, useYourSpendData}; -export type {GetYourSpendRowStateParams, UseYourSpendDataReturn, YourSpendApplicability, YourSpendCardRow, YourSpendRowState, YourSpendRowTotals}; +export {YOUR_SPEND_CARD_KIND, YOUR_SPEND_ROW_STATE, getYourSpendApplicability, getYourSpendRowState, useYourSpendData}; +export type {GetYourSpendRowStateParams, UseYourSpendDataReturn, YourSpendApplicability, YourSpendCardKind, YourSpendCardRow, YourSpendRowState, YourSpendRowTotals}; diff --git a/tests/unit/CardUtilsTest.ts b/tests/unit/CardUtilsTest.ts index a00507e9c1df..8c0a18a12a85 100644 --- a/tests/unit/CardUtilsTest.ts +++ b/tests/unit/CardUtilsTest.ts @@ -41,6 +41,7 @@ import { getCustomOrFormattedFeedName, getDefaultExpensifyCardLimitType, getDisplayableExpensifyCards, + getDisplayableThirdPartyCards, getEligibleBankAccountsForCard, getEligibleBankAccountsForUkEuCard, getFeedConnectionBrokenCard, @@ -1580,6 +1581,16 @@ describe('CardUtils', () => { const lastFour = lastFourNumbersFromCardName('Business Card Cash - Business'); expect(lastFour).toBe(''); }); + + it('Should return last 4 numbers for an ellipsis name with a space (e.g. "CREDIT CARD...1234")', () => { + const lastFour = lastFourNumbersFromCardName('CREDIT CARD...1234'); + expect(lastFour).toBe('1234'); + }); + + it('Should return empty string for an ellipsis name without a space (e.g. "SomeCardName...1234")', () => { + const lastFour = lastFourNumbersFromCardName('SomeCardName...1234'); + expect(lastFour).toBe(''); + }); }); describe('maskCardNumber', () => { @@ -2850,6 +2861,170 @@ describe('CardUtils', () => { }); }); + describe('getDisplayableThirdPartyCards', () => { + const emptyCardFeedErrors = { + cardsWithBrokenFeedConnection: {}, + personalCardsWithBrokenConnection: {}, + }; + + function makeCompanyCard(overrides: Partial & {cardID: number}): Card { + return { + accountID: 1, + bank: CONST.COMPANY_CARD.FEED_BANK_NAME.VISA, + cardName: '480801XXXXXX2554', + domainName: 'expensify-policy123.exfy', + fraud: 'none', + fundID: '767578', + lastFourPAN: '2554', + lastScrape: '', + lastUpdated: '', + state: CONST.EXPENSIFY_CARD.STATE.OPEN, + ...overrides, + }; + } + + function makePersonalPlaidCard(overrides: Partial & {cardID: number}): Card { + return { + accountID: 1, + bank: 'plaid.ins_109508' as CompanyCardFeed, + cardName: 'Chase Checking', + domainName: '', + fraud: 'none', + lastFourPAN: '4321', + lastScrape: '', + lastUpdated: '', + state: CONST.EXPENSIFY_CARD.STATE.OPEN, + ...overrides, + }; + } + + it('returns [] when cardList is undefined', () => { + expect(getDisplayableThirdPartyCards(undefined, emptyCardFeedErrors)).toEqual([]); + }); + + it('returns [] when cardList contains only Expensify Cards', () => { + const cardList = { + 1: { + accountID: 1, + bank: CONST.EXPENSIFY_CARD.BANK, + cardID: 1, + cardName: 'Expensify Card', + domainName: 'expensify-policy1.exfy', + fraud: 'none', + fundID: '111', + lastFourPAN: '1234', + lastScrape: '', + lastUpdated: '', + state: CONST.EXPENSIFY_CARD.STATE.OPEN, + }, + } as unknown as CardList; + expect(getDisplayableThirdPartyCards(cardList, emptyCardFeedErrors)).toEqual([]); + }); + + it('includes an active company card (has domainName, fundID, not Expensify)', () => { + const cardList = { + 10: makeCompanyCard({cardID: 10}), + } as unknown as CardList; + const result = getDisplayableThirdPartyCards(cardList, emptyCardFeedErrors); + expect(result).toHaveLength(1); + expect(result.at(0)?.cardID).toBe(10); + }); + + it('includes an active personal Plaid card (isPersonalCard returns true)', () => { + const cardList = { + 20: makePersonalPlaidCard({cardID: 20}), + } as unknown as CardList; + const result = getDisplayableThirdPartyCards(cardList, emptyCardFeedErrors); + expect(result).toHaveLength(1); + expect(result.at(0)?.cardID).toBe(20); + }); + + it('excludes Expensify Cards', () => { + const cardList = { + 1: { + accountID: 1, + bank: CONST.EXPENSIFY_CARD.BANK, + cardID: 1, + cardName: 'Expensify Card', + domainName: 'expensify-policy1.exfy', + fraud: 'none', + fundID: '111', + lastFourPAN: '1234', + lastScrape: '', + lastUpdated: '', + state: CONST.EXPENSIFY_CARD.STATE.OPEN, + }, + 2: makeCompanyCard({cardID: 2}), + } as unknown as CardList; + const result = getDisplayableThirdPartyCards(cardList, emptyCardFeedErrors); + expect(result).toHaveLength(1); + expect(result.at(0)?.cardID).toBe(2); + }); + + it('excludes the cash card (cardName === CASH)', () => { + const cardList = { + 30: makeCompanyCard({cardID: 30, cardName: CONST.COMPANY_CARDS.CARD_NAME.CASH}), + } as unknown as CardList; + expect(getDisplayableThirdPartyCards(cardList, emptyCardFeedErrors)).toEqual([]); + }); + + it('excludes cards not in ACTIVE_STATES', () => { + const cardList = { + 40: makeCompanyCard({cardID: 40, state: CONST.EXPENSIFY_CARD.STATE.CLOSED}), + } as unknown as CardList; + expect(getDisplayableThirdPartyCards(cardList, emptyCardFeedErrors)).toEqual([]); + }); + + it('excludes a card for which isCardConnectionBroken(card) === true', () => { + const cardList = { + 50: makeCompanyCard({cardID: 50, lastScrapeResult: 403}), + } as unknown as CardList; + expect(getDisplayableThirdPartyCards(cardList, emptyCardFeedErrors)).toEqual([]); + }); + + it('excludes a company card listed in cardFeedErrors.cardsWithBrokenFeedConnection', () => { + const card = makeCompanyCard({cardID: 60}); + const cardList = {60: card} as unknown as CardList; + const result = getDisplayableThirdPartyCards(cardList, { + cardsWithBrokenFeedConnection: {60: card}, + personalCardsWithBrokenConnection: {}, + }); + expect(result).toEqual([]); + }); + + it('excludes a personal card listed in cardFeedErrors.personalCardsWithBrokenConnection', () => { + const card = makePersonalPlaidCard({cardID: 70}); + const cardList = {70: card} as unknown as CardList; + const result = getDisplayableThirdPartyCards(cardList, { + cardsWithBrokenFeedConnection: {}, + personalCardsWithBrokenConnection: {70: card}, + }); + expect(result).toEqual([]); + }); + + it('returns third-party cards in cardID-ascending order regardless of input ordering (Q3)', () => { + // CardList keyed by integer-indexed string keys is iterated in ascending numeric order + // by Object.values per ES2015. lodashSortBy with a constant sort key (2) preserves that order. + const cardList = { + 300: makeCompanyCard({cardID: 300, lastFourPAN: '0001'}), + 100: makeCompanyCard({cardID: 100, lastFourPAN: '0002', domainName: 'feed-a.exfy'}), + 200: makeCompanyCard({cardID: 200, lastFourPAN: '0003', domainName: 'feed-b.exfy'}), + } as unknown as CardList; + const result = getDisplayableThirdPartyCards(cardList, emptyCardFeedErrors); + expect(result.map((c) => c.cardID)).toEqual([100, 200, 300]); + }); + + it('does not dedupe: two distinct third-party cards sharing the same domainName both appear (R-3 lock-in)', () => { + const cardList = { + 400: makeCompanyCard({cardID: 400, lastFourPAN: '1111', domainName: 'same-domain.exfy'}), + 401: makeCompanyCard({cardID: 401, lastFourPAN: '2222', domainName: 'same-domain.exfy', fundID: '767579'}), + } as unknown as CardList; + const result = getDisplayableThirdPartyCards(cardList, emptyCardFeedErrors); + expect(result).toHaveLength(2); + expect(result.map((c) => c.cardID).sort((a, b) => a - b)).toEqual([400, 401]); + }); + }); + describe('PersonalCard (isPersonalCard)', () => { it('should return true when card has no fundID or fundID is "0"', () => { const cardWithNoFundID: Card = { diff --git a/tests/unit/HomePage/YourSpendSection/YourSpendSectionTest.tsx b/tests/unit/HomePage/YourSpendSection/YourSpendSectionTest.tsx index 582463adbbfa..5b79c1fe9d2d 100644 --- a/tests/unit/HomePage/YourSpendSection/YourSpendSectionTest.tsx +++ b/tests/unit/HomePage/YourSpendSection/YourSpendSectionTest.tsx @@ -1,9 +1,9 @@ import type * as NativeNavigation from '@react-navigation/native'; -import {fireEvent, render, screen} from '@testing-library/react-native'; +import {fireEvent, render, screen, within} from '@testing-library/react-native'; import type {ReactNode} from 'react'; import React from 'react'; // eslint-disable-next-line no-restricted-imports -import type {Pressable as RNPressable, Text as RNText} from 'react-native'; +import type {Pressable as RNPressable, Text as RNText, View as RNView} from 'react-native'; import type {ValueOf} from 'type-fest'; import type * as CardUtils from '@libs/CardUtils'; import Navigation from '@libs/Navigation/Navigation'; @@ -11,6 +11,7 @@ import YourSpendSection from '@pages/home/YourSpendSection'; import type * as UseYourSpendDataModule from '@pages/home/YourSpendSection/useYourSpendData'; import {useYourSpendData, YOUR_SPEND_ROW_STATE} from '@pages/home/YourSpendSection/useYourSpendData'; import ROUTES from '@src/ROUTES'; +import type {CardFeedWithNumber} from '@src/types/onyx/CardFeeds'; jest.mock('@libs/Navigation/Navigation', () => ({ navigate: jest.fn(), @@ -69,10 +70,34 @@ jest.mock('@libs/CardUtils', () => { {cardID: 1, lastFourPAN: '1234', nameValuePairs: {}}, {cardID: 2, lastFourPAN: '5678', nameValuePairs: {}}, ]), + getDisplayableThirdPartyCards: jest.fn(() => []), getCardDescription: jest.fn(() => 'Card description'), }; }); +// Mock `CardFeedIcon` so tests can introspect which branch the row picked. +// `isExpensifyCardFeed` is exposed as a testID suffix; `selectedFeed` is rendered +// as a separate testID for the third-party branches. +jest.mock('@components/CardFeedIcon', () => { + const {View} = jest.requireActual<{View: typeof RNView}>('react-native'); + function MockCardFeedIcon({isExpensifyCardFeed, selectedFeed}: {isExpensifyCardFeed?: boolean; selectedFeed?: string}) { + if (isExpensifyCardFeed) { + return ; + } + return ; + } + return MockCardFeedIcon; +}); + +// Mock `RemainingLimitCircle` so tests can assert its absence on third-party rows. +jest.mock('@pages/home/YourSpendSection/RemainingLimitCircle', () => { + const {View} = jest.requireActual<{View: typeof RNView}>('react-native'); + function MockRemainingLimitCircle() { + return ; + } + return MockRemainingLimitCircle; +}); + // `MenuItemWithTopDescription` requires a ScreenWrapper transition context which isn't set up in this // unit test. Replace it with a Pressable passthrough that still mounts left/right/title/description // content and forwards `onPress` so row-level navigation can be exercised. @@ -122,12 +147,24 @@ jest.mock('@pages/home/YourSpendSection/useYourSpendData', () => { }; }); +type MockCardRow = { + cardID: number; + query: string; + lastFour: string; + total: number | undefined; + currency: string | undefined; + spentFraction?: number | undefined; + kind?: 'expensify' | 'thirdParty'; + bank?: CardFeedWithNumber; + fundID?: string | undefined; +}; + type MockHookData = { approvalRowState: ValueOf; approvalTotals: {total: number | undefined; currency: string | undefined}; paymentRowState: ValueOf; paymentTotals: {total: number | undefined; currency: string | undefined}; - cardRows: Array<{cardID: number; query: string; lastFour: string; total: number | undefined; currency: string | undefined}>; + cardRows: MockCardRow[]; awaitingApprovalQuery: string; repaidLast30DaysQuery: string; }; @@ -217,3 +254,155 @@ describe('YourSpendSection', () => { expect(toJSON()).toBeNull(); }); }); + +describe('YourSpendSection — third-party rows', () => { + const THIRD_PARTY_CARD_ID = 901; + const THIRD_PARTY_BANK = 'vcf' as CardFeedWithNumber; + const THIRD_PARTY_QUERY = `type:expense from:1 cardID:${THIRD_PARTY_CARD_ID}`; + + function thirdPartyRow(overrides: Partial = {}): MockCardRow { + return { + cardID: THIRD_PARTY_CARD_ID, + query: THIRD_PARTY_QUERY, + lastFour: '9876', + total: 12300, + currency: 'USD', + spentFraction: undefined, + kind: 'thirdParty', + bank: THIRD_PARTY_BANK, + fundID: '767578', + ...overrides, + }; + } + + function expensifyRow(overrides: Partial = {}): MockCardRow { + return { + cardID: 1, + query: 'type:expense from:1 cardID:1', + lastFour: '1234', + total: 5000, + currency: 'USD', + spentFraction: 0.4, + kind: 'expensify', + bank: 'Expensify Card' as CardFeedWithNumber, + fundID: '999', + ...overrides, + }; + } + + it('renders a third-party card row by testID', () => { + mockHook({cardRows: [thirdPartyRow()]}); + render(); + expect(screen.getByTestId(`your-spend-card-row-${THIRD_PARTY_CARD_ID}`)).toBeOnTheScreen(); + }); + + it("does not render the Expensify-card feed icon inside a third-party row's leftComponent", () => { + mockHook({cardRows: [thirdPartyRow()]}); + render(); + const row = screen.getByTestId(`your-spend-card-row-${THIRD_PARTY_CARD_ID}`); + expect(within(row).queryByTestId('card-feed-icon-expensify')).toBeNull(); + }); + + it('does not render the RemainingLimitCircle for a third-party row (R-5)', () => { + mockHook({cardRows: [thirdPartyRow({spentFraction: undefined})]}); + render(); + const row = screen.getByTestId(`your-spend-card-row-${THIRD_PARTY_CARD_ID}`); + expect(within(row).queryByTestId('remaining-limit-circle')).toBeNull(); + }); + + it('navigates to SEARCH_ROOT with the third-party row query when tapped (R-8)', () => { + (Navigation.navigate as jest.Mock).mockClear(); + mockHook({cardRows: [thirdPartyRow()]}); + render(); + const row = screen.getByTestId(`your-spend-card-row-${THIRD_PARTY_CARD_ID}`); + // The mock MenuItem renders as a Pressable wrapping a description Text. fireEvent.press + // requires a Pressable target, so we press the description text inside the row. + const description = within(row).getByText('homePage.yourSpend.recentTransactions'); + fireEvent.press(description); + expect(Navigation.navigate).toHaveBeenLastCalledWith(ROUTES.SEARCH_ROOT.getRoute({query: THIRD_PARTY_QUERY})); + }); + + it('renders no skeleton inside the third-party card row (R-6)', () => { + mockHook({cardRows: [thirdPartyRow()]}); + render(); + const row = screen.getByTestId(`your-spend-card-row-${THIRD_PARTY_CARD_ID}`); + // The Your spend section only renders skeletons for the approval/payment summary rows. + // Third-party card rows must never carry a skeleton testID — assert the absence here. + expect(within(row).queryByTestId('your-spend-approval-skeleton')).toBeNull(); + expect(within(row).queryByTestId('your-spend-payment-skeleton')).toBeNull(); + }); + + it('PRD Example 4: renders only the third-party rows when approval and payment are HIDDEN and there is no Expensify Card row', () => { + const tpRow1 = thirdPartyRow(); + const tpRow2 = thirdPartyRow({cardID: 902, query: 'type:expense from:1 cardID:902', lastFour: '5555'}); + mockHook({ + approvalRowState: YOUR_SPEND_ROW_STATE.HIDDEN, + paymentRowState: YOUR_SPEND_ROW_STATE.HIDDEN, + cardRows: [tpRow1, tpRow2], + }); + render(); + expect(screen.getByTestId('your-spend-section')).toBeOnTheScreen(); + expect(screen.queryByTestId('your-spend-approval-row')).toBeNull(); + expect(screen.queryByTestId('your-spend-payment-row')).toBeNull(); + expect(screen.getByTestId(`your-spend-card-row-${tpRow1.cardID}`)).toBeOnTheScreen(); + expect(screen.getByTestId(`your-spend-card-row-${tpRow2.cardID}`)).toBeOnTheScreen(); + }); + + it('R-9: section is hidden when there are zero card rows and approval/payment are HIDDEN', () => { + mockHook({ + approvalRowState: YOUR_SPEND_ROW_STATE.HIDDEN, + paymentRowState: YOUR_SPEND_ROW_STATE.HIDDEN, + cardRows: [], + }); + const {toJSON} = render(); + expect(toJSON()).toBeNull(); + expect(screen.queryByTestId('your-spend-section')).toBeNull(); + }); + + it('caps visible card rows at 5 and renders the See N more toggle when more cards exist (R-7)', () => { + // 6 mixed rows (1 Expensify + 5 third-party). With approval/payment HIDDEN the + // visible cap is SECTION_VISIBLE_LIMIT (5), so 1 row is hidden and a See more + // toggle is rendered. Translate is mocked to identity so the toggle label + // surfaces as the i18n key `homePage.seeMore`. + const rows: MockCardRow[] = [ + expensifyRow({cardID: 1001, lastFour: '0001', query: 'type:expense from:1 cardID:1001'}), + thirdPartyRow({cardID: 2001, lastFour: '0002', query: 'type:expense from:1 cardID:2001'}), + thirdPartyRow({cardID: 2002, lastFour: '0003', query: 'type:expense from:1 cardID:2002'}), + thirdPartyRow({cardID: 2003, lastFour: '0004', query: 'type:expense from:1 cardID:2003'}), + thirdPartyRow({cardID: 2004, lastFour: '0005', query: 'type:expense from:1 cardID:2004'}), + thirdPartyRow({cardID: 2005, lastFour: '0006', query: 'type:expense from:1 cardID:2005'}), + ]; + mockHook({ + approvalRowState: YOUR_SPEND_ROW_STATE.HIDDEN, + paymentRowState: YOUR_SPEND_ROW_STATE.HIDDEN, + cardRows: rows, + }); + render(); + expect(screen.getByTestId('your-spend-card-row-1001')).toBeOnTheScreen(); + expect(screen.getByTestId('your-spend-card-row-2001')).toBeOnTheScreen(); + expect(screen.getByTestId('your-spend-card-row-2002')).toBeOnTheScreen(); + expect(screen.getByTestId('your-spend-card-row-2003')).toBeOnTheScreen(); + expect(screen.getByTestId('your-spend-card-row-2004')).toBeOnTheScreen(); + // The 6th row is hidden (only 5 visible) until the toggle is pressed. + expect(screen.queryByTestId('your-spend-card-row-2005')).toBeNull(); + // The See N more toggle is present. + expect(screen.getByText('homePage.seeMore')).toBeOnTheScreen(); + }); + + it('R-4 (full ordering): approval -> payment -> expensify card -> third-party card', () => { + const expRow = expensifyRow(); + const tpRow = thirdPartyRow(); + mockHook({ + approvalRowState: YOUR_SPEND_ROW_STATE.READY, + paymentRowState: YOUR_SPEND_ROW_STATE.READY, + cardRows: [expRow, tpRow], + }); + render(); + const section = screen.getByTestId('your-spend-section'); + // Match approval-row, payment-row, and any card-row-* element under the section, + // then assert their relative order in the rendered tree. + const allRows = within(section).getAllByTestId(/^your-spend-(approval-row|payment-row|card-row-\d+)$/); + const collectedTestIDs = allRows.map((el) => (el.props as {testID: string}).testID); + expect(collectedTestIDs).toEqual(['your-spend-approval-row', 'your-spend-payment-row', `your-spend-card-row-${expRow.cardID}`, `your-spend-card-row-${tpRow.cardID}`]); + }); +}); diff --git a/tests/unit/HomePage/YourSpendSection/useYourSpendDataTest.ts b/tests/unit/HomePage/YourSpendSection/useYourSpendDataTest.ts index ee6a478a1747..58e1c928a024 100644 --- a/tests/unit/HomePage/YourSpendSection/useYourSpendDataTest.ts +++ b/tests/unit/HomePage/YourSpendSection/useYourSpendDataTest.ts @@ -7,19 +7,20 @@ * - awaitingApprovalQuery / repaidLast30DaysQuery are exposed on the return value * - search() is dispatched when focused and online; suppressed when offline */ -import {renderHook} from '@testing-library/react-native'; +import {act, renderHook} from '@testing-library/react-native'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useNetwork from '@hooks/useNetwork'; import {search} from '@libs/actions/Search'; -import {getDisplayableExpensifyCards} from '@libs/CardUtils'; +import {getDisplayableExpensifyCards, getDisplayableThirdPartyCards} from '@libs/CardUtils'; import {hasApprovalFlow} from '@libs/PolicyUtils'; import {buildSearchQueryJSON} from '@libs/SearchQueryUtils'; -import YOUR_SPEND_ROW_STATE from '@pages/home/YourSpendSection/const'; +import {YOUR_SPEND_ROW_STATE} from '@pages/home/YourSpendSection/const'; import {buildAwaitingApprovalQuery, buildRecentCardTransactionsQuery, buildRepaidLast30DaysQuery} from '@pages/home/YourSpendSection/queries'; import {useYourSpendData} from '@pages/home/YourSpendSection/useYourSpendData'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Card, Policy} from '@src/types/onyx'; +import type {CardFeedErrors, CardFeedErrorState} from '@src/types/onyx/DerivedValues'; import type {CurrentUserPersonalDetails} from '@src/types/onyx/PersonalDetails'; import type SearchResults from '@src/types/onyx/SearchResults'; @@ -31,6 +32,12 @@ const CARD_ID_2 = 22222; const CARD_LAST_FOUR_1 = '1111'; const CARD_LAST_FOUR_2 = '2222'; +// Third-party card IDs (distinct from Expensify card IDs above). +const THIRD_PARTY_CARD_ID_1 = 33333; +const THIRD_PARTY_CARD_ID_2 = 44444; +const THIRD_PARTY_LAST_FOUR_1 = '3333'; +const THIRD_PARTY_LAST_FOUR_2 = '4444'; + // Fixed query strings the mocked builders will return. // These must be valid query strings the search parser accepts, so // buildSearchQueryJSON can compute real hashes from them, matching @@ -39,6 +46,8 @@ const APPROVAL_QUERY = `type:expense status:outstanding from:${ACCOUNT_ID} reimb const PAYMENT_QUERY = `type:expense status:paid from:${ACCOUNT_ID} reimbursable:yes`; const CARD_QUERY_1 = `type:expense from:${ACCOUNT_ID} cardID:${CARD_ID_1}`; const CARD_QUERY_2 = `type:expense from:${ACCOUNT_ID} cardID:${CARD_ID_2}`; +const THIRD_PARTY_QUERY_1 = `type:expense from:${ACCOUNT_ID} cardID:${THIRD_PARTY_CARD_ID_1}`; +const THIRD_PARTY_QUERY_2 = `type:expense from:${ACCOUNT_ID} cardID:${THIRD_PARTY_CARD_ID_2}`; // Module mocks @@ -70,6 +79,7 @@ jest.mock('@hooks/useCurrentUserPersonalDetails', () => ({ jest.mock('@libs/CardUtils', () => ({ ...jest.requireActual>('@libs/CardUtils'), getDisplayableExpensifyCards: jest.fn(() => []), + getDisplayableThirdPartyCards: jest.fn(() => []), })); jest.mock('@libs/PolicyUtils', () => ({ @@ -83,6 +93,7 @@ const mockedUseNetwork = jest.mocked(useNetwork); const mockedUseCurrentUserPersonalDetails = jest.mocked(useCurrentUserPersonalDetails); const mockedSearch = jest.mocked(search); const mockedGetDisplayableExpensifyCards = jest.mocked(getDisplayableExpensifyCards); +const mockedGetDisplayableThirdPartyCards = jest.mocked(getDisplayableThirdPartyCards); const mockedHasApprovalFlow = jest.mocked(hasApprovalFlow); const mockedBuildAwaitingApprovalQuery = jest.mocked(buildAwaitingApprovalQuery); const mockedBuildRepaidLast30DaysQuery = jest.mocked(buildRepaidLast30DaysQuery); @@ -155,7 +166,24 @@ function setupPaymentSnapshot(results: SearchResults | undefined) { /** Seeds the allSnapshots collection with a card snapshot so the hook can read count/total/currency. */ function setupCardSnapshot(cardID: number, results: SearchResults | undefined) { - const cardQuery = cardID === CARD_ID_1 ? CARD_QUERY_1 : CARD_QUERY_2; + let cardQuery: string; + switch (cardID) { + case CARD_ID_1: + cardQuery = CARD_QUERY_1; + break; + case CARD_ID_2: + cardQuery = CARD_QUERY_2; + break; + case THIRD_PARTY_CARD_ID_1: + cardQuery = THIRD_PARTY_QUERY_1; + break; + case THIRD_PARTY_CARD_ID_2: + cardQuery = THIRD_PARTY_QUERY_2; + break; + default: + cardQuery = CARD_QUERY_2; + break; + } const hash = buildSearchQueryJSON(cardQuery)?.hash; if (!onyxData[ONYXKEYS.COLLECTION.SNAPSHOT]) { onyxData[ONYXKEYS.COLLECTION.SNAPSHOT] = {}; @@ -163,6 +191,41 @@ function setupCardSnapshot(cardID: number, results: SearchResults | undefined) { (onyxData[ONYXKEYS.COLLECTION.SNAPSHOT] as Record)[`${ONYXKEYS.COLLECTION.SNAPSHOT}${hash}`] = results; } +/** Builds a fully-populated `CardFeedErrors` value for `onyxData[ONYXKEYS.DERIVED.CARD_FEED_ERRORS]`. */ +function makeCardFeedErrors(overrides: Partial = {}): CardFeedErrors { + const defaultState: CardFeedErrorState = {shouldShowRBR: false, isFeedConnectionBroken: false, hasFeedErrors: false, hasWorkspaceErrors: false}; + return { + cardFeedErrors: {}, + cardsWithBrokenFeedConnection: {}, + personalCardsWithBrokenConnection: {}, + shouldShowRbrForWorkspaceAccountID: {}, + shouldShowRbrForFeedNameWithDomainID: {}, + all: defaultState, + companyCards: defaultState, + expensifyCard: defaultState, + personalCard: defaultState, + ...overrides, + }; +} + +/** Builds third-party `Card[]` fixtures for `getDisplayableThirdPartyCards.mockReturnValue`. */ +function makeThirdPartyCards(cards: Array<{cardID: number; lastFourPAN?: string; cardName?: string; bank?: string; fundID?: string; lastScrapeResult?: number}>): Card[] { + return cards.map((c) => ({ + accountID: ACCOUNT_ID, + bank: c.bank ?? CONST.COMPANY_CARD.FEED_BANK_NAME.VISA, + cardID: c.cardID, + cardName: c.cardName ?? '480801XXXXXX2554', + domainName: 'feed-a.exfy', + fraud: 'none', + fundID: c.fundID ?? '767578', + lastFourPAN: c.lastFourPAN ?? '', + lastScrape: '', + lastUpdated: '', + lastScrapeResult: c.lastScrapeResult, + state: CONST.EXPENSIFY_CARD.STATE.OPEN, + })) as unknown as Card[]; +} + /** Returns a typed offline payload for `useNetwork.mockReturnValue`. */ function networkState(isOffline: boolean): ReturnType { return {isOffline}; @@ -184,11 +247,25 @@ beforeEach(() => { mockedBuildAwaitingApprovalQuery.mockReturnValue(APPROVAL_QUERY); mockedBuildRepaidLast30DaysQuery.mockReturnValue(PAYMENT_QUERY); - mockedBuildRecentCardTransactionsQuery.mockImplementation((_accountID: number, cardID: number) => (cardID === CARD_ID_1 ? CARD_QUERY_1 : CARD_QUERY_2)); + mockedBuildRecentCardTransactionsQuery.mockImplementation((_accountID: number, cardID: number) => { + switch (cardID) { + case CARD_ID_1: + return CARD_QUERY_1; + case CARD_ID_2: + return CARD_QUERY_2; + case THIRD_PARTY_CARD_ID_1: + return THIRD_PARTY_QUERY_1; + case THIRD_PARTY_CARD_ID_2: + return THIRD_PARTY_QUERY_2; + default: + return CARD_QUERY_2; + } + }); mockedUseNetwork.mockReturnValue(networkState(false)); mockedUseCurrentUserPersonalDetails.mockReturnValue({accountID: ACCOUNT_ID, login: `${ACCOUNT_ID}@test.com`} as CurrentUserPersonalDetails); mockedGetDisplayableExpensifyCards.mockReturnValue([]); + mockedGetDisplayableThirdPartyCards.mockReturnValue([]); mockedHasApprovalFlow.mockReturnValue(false); // Default policies: one CORPORATE policy, no approval flow, payments enabled @@ -414,3 +491,159 @@ describe('useYourSpendData — search dispatch', () => { ); }); }); + +// third-party card rows +// +// These tests exercise the third-party card branch end-to-end via the hook. +// `getDisplayableThirdPartyCards` and `getDisplayableExpensifyCards` are both mocked, +// so each test seeds the displayable cards explicitly. Snapshot results are seeded +// through the same `setupCardSnapshot` helper used by the Expensify cardRows block. + +describe('useYourSpendData — third-party cardRows', () => { + it('orders Expensify Card rows before third-party card rows when both exist', () => { + mockedGetDisplayableExpensifyCards.mockReturnValue(makeDisplayableCards([{cardID: CARD_ID_1, lastFourPAN: CARD_LAST_FOUR_1}])); + mockedGetDisplayableThirdPartyCards.mockReturnValue(makeThirdPartyCards([{cardID: THIRD_PARTY_CARD_ID_1, lastFourPAN: THIRD_PARTY_LAST_FOUR_1}])); + setupCardSnapshot(CARD_ID_1, makeSearchResultsWithCount(2)); + setupCardSnapshot(THIRD_PARTY_CARD_ID_1, makeSearchResultsWithCount(3)); + const {result} = renderHook(() => useYourSpendData()); + expect(result.current.cardRows).toHaveLength(2); + expect(result.current.cardRows.at(0)?.cardID).toBe(CARD_ID_1); + expect(result.current.cardRows.at(1)?.cardID).toBe(THIRD_PARTY_CARD_ID_1); + }); + + it('produces a row for a third-party card with snapshot count > 0', () => { + mockedGetDisplayableThirdPartyCards.mockReturnValue(makeThirdPartyCards([{cardID: THIRD_PARTY_CARD_ID_1, lastFourPAN: THIRD_PARTY_LAST_FOUR_1}])); + setupCardSnapshot(THIRD_PARTY_CARD_ID_1, makeSearchResultsWithCount(5)); + const {result} = renderHook(() => useYourSpendData()); + expect(result.current.cardRows).toHaveLength(1); + expect(result.current.cardRows.at(0)).toMatchObject({cardID: THIRD_PARTY_CARD_ID_1, lastFour: THIRD_PARTY_LAST_FOUR_1, query: THIRD_PARTY_QUERY_1}); + }); + + it('produces no row for a third-party card with snapshot count === 0', () => { + mockedGetDisplayableThirdPartyCards.mockReturnValue(makeThirdPartyCards([{cardID: THIRD_PARTY_CARD_ID_1, lastFourPAN: THIRD_PARTY_LAST_FOUR_1}])); + setupCardSnapshot(THIRD_PARTY_CARD_ID_1, makeSearchResultsWithCount(0)); + const {result} = renderHook(() => useYourSpendData()); + expect(result.current.cardRows).toHaveLength(0); + }); + + it('tags the third-party row with kind=thirdParty and leaves spentFraction undefined', () => { + mockedGetDisplayableThirdPartyCards.mockReturnValue(makeThirdPartyCards([{cardID: THIRD_PARTY_CARD_ID_1, lastFourPAN: THIRD_PARTY_LAST_FOUR_1}])); + setupCardSnapshot(THIRD_PARTY_CARD_ID_1, makeSearchResultsWithCount(1)); + const {result} = renderHook(() => useYourSpendData()); + const row = result.current.cardRows.at(0); + expect(row?.kind).toBe('thirdParty'); + expect(row?.spentFraction).toBeUndefined(); + }); + + it('resolves lastFour from cardName ending in 4 digits when lastFourPAN is empty', () => { + mockedGetDisplayableThirdPartyCards.mockReturnValue(makeThirdPartyCards([{cardID: THIRD_PARTY_CARD_ID_1, lastFourPAN: '', cardName: 'Chase 9876'}])); + setupCardSnapshot(THIRD_PARTY_CARD_ID_1, makeSearchResultsWithCount(1)); + const {result} = renderHook(() => useYourSpendData()); + expect(result.current.cardRows).toHaveLength(1); + expect(result.current.cardRows.at(0)?.lastFour).toBe('9876'); + }); + + it('suppresses the row when lastFourPAN is empty and cardName has no trailing 4 digits', () => { + mockedGetDisplayableThirdPartyCards.mockReturnValue(makeThirdPartyCards([{cardID: THIRD_PARTY_CARD_ID_1, lastFourPAN: '', cardName: 'Chase'}])); + setupCardSnapshot(THIRD_PARTY_CARD_ID_1, makeSearchResultsWithCount(1)); + const {result} = renderHook(() => useYourSpendData()); + expect(result.current.cardRows).toHaveLength(0); + }); + + it('excludes any card the selector filters out (broken-state signals are owned by the selector)', () => { + // The selector receives `cardFeedErrors` and is unit-tested separately. Here we just verify + // the hook respects whatever set the selector returns: when the selector returns [], no row. + mockedGetDisplayableThirdPartyCards.mockReturnValue([]); + setupCardSnapshot(THIRD_PARTY_CARD_ID_1, makeSearchResultsWithCount(5)); + const {result} = renderHook(() => useYourSpendData()); + expect(result.current.cardRows).toHaveLength(0); + }); + + it('persists cached READY totals for a third-party card when the snapshot count is wiped', () => { + mockedGetDisplayableThirdPartyCards.mockReturnValue(makeThirdPartyCards([{cardID: THIRD_PARTY_CARD_ID_1, lastFourPAN: THIRD_PARTY_LAST_FOUR_1}])); + // First render: READY snapshot with count > 0 → row produced and total cached. + setupCardSnapshot(THIRD_PARTY_CARD_ID_1, { + search: {type: 'expense', status: '', offset: 0, hasMoreResults: false, hasResults: true, isLoading: false, count: 3, total: 1234, currency: 'USD'}, + data: {}, + } as SearchResults); + const {result, rerender} = renderHook(() => useYourSpendData()); + expect(result.current.cardRows.at(0)?.total).toBe(1234); + + // Search screen wipes count/total/currency on the shared snapshot. + setupCardSnapshot(THIRD_PARTY_CARD_ID_1, { + search: {type: 'expense', status: '', offset: 0, hasMoreResults: false, hasResults: true, isLoading: false, count: undefined, total: undefined, currency: undefined}, + data: {}, + } as unknown as SearchResults); + rerender(undefined); + // Cached total/currency must survive the wipe so the row stays. + expect(result.current.cardRows).toHaveLength(1); + expect(result.current.cardRows.at(0)?.total).toBe(1234); + expect(result.current.cardRows.at(0)?.currency).toBe('USD'); + }); + + it('fires search() for each third-party card snapshot when focused and online', () => { + mockedGetDisplayableThirdPartyCards.mockReturnValue( + makeThirdPartyCards([ + {cardID: THIRD_PARTY_CARD_ID_1, lastFourPAN: THIRD_PARTY_LAST_FOUR_1}, + {cardID: THIRD_PARTY_CARD_ID_2, lastFourPAN: THIRD_PARTY_LAST_FOUR_2}, + ]), + ); + renderHook(() => useYourSpendData()); + const hash1 = buildSearchQueryJSON(THIRD_PARTY_QUERY_1)?.hash; + const hash2 = buildSearchQueryJSON(THIRD_PARTY_QUERY_2)?.hash; + expect(search).toHaveBeenCalledWith(expect.objectContaining({queryJSON: expect.objectContaining({hash: hash1})})); + expect(search).toHaveBeenCalledWith(expect.objectContaining({queryJSON: expect.objectContaining({hash: hash2})})); + }); + + it('does not aggregate totals when two third-party rows have different currencies', () => { + mockedGetDisplayableThirdPartyCards.mockReturnValue( + makeThirdPartyCards([ + {cardID: THIRD_PARTY_CARD_ID_1, lastFourPAN: THIRD_PARTY_LAST_FOUR_1}, + {cardID: THIRD_PARTY_CARD_ID_2, lastFourPAN: THIRD_PARTY_LAST_FOUR_2}, + ]), + ); + setupCardSnapshot(THIRD_PARTY_CARD_ID_1, { + search: {type: 'expense', status: '', offset: 0, hasMoreResults: false, hasResults: true, isLoading: false, count: 2, total: 500, currency: 'USD'}, + data: {}, + } as SearchResults); + setupCardSnapshot(THIRD_PARTY_CARD_ID_2, { + search: {type: 'expense', status: '', offset: 0, hasMoreResults: false, hasResults: true, isLoading: false, count: 3, total: 2200, currency: 'EUR'}, + data: {}, + } as SearchResults); + const {result} = renderHook(() => useYourSpendData()); + expect(result.current.cardRows).toHaveLength(2); + const [r1, r2] = result.current.cardRows; + expect(r1).toMatchObject({cardID: THIRD_PARTY_CARD_ID_1, total: 500, currency: 'USD'}); + expect(r2).toMatchObject({cardID: THIRD_PARTY_CARD_ID_2, total: 2200, currency: 'EUR'}); + }); + + it('R-2 reactivity: removing a broken-feed entry from Onyx makes the row appear; adding it makes the row disappear', () => { + // The selector mock filters by what the hook passes in, so we can drive reactivity via Onyx. + mockedGetDisplayableThirdPartyCards.mockImplementation((_cardList, errors) => + makeThirdPartyCards([{cardID: THIRD_PARTY_CARD_ID_1, lastFourPAN: THIRD_PARTY_LAST_FOUR_1}]).filter( + (c) => !errors.cardsWithBrokenFeedConnection[c.cardID] && !errors.personalCardsWithBrokenConnection[c.cardID], + ), + ); + setupCardSnapshot(THIRD_PARTY_CARD_ID_1, makeSearchResultsWithCount(1)); + // Start: card is in broken-feed-connection map → row absent. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const brokenCard = makeThirdPartyCards([{cardID: THIRD_PARTY_CARD_ID_1, lastFourPAN: THIRD_PARTY_LAST_FOUR_1}]).at(0)!; + onyxData[ONYXKEYS.DERIVED.CARD_FEED_ERRORS] = makeCardFeedErrors({cardsWithBrokenFeedConnection: {[THIRD_PARTY_CARD_ID_1]: brokenCard}}); + const {result, rerender} = renderHook(() => useYourSpendData()); + expect(result.current.cardRows).toHaveLength(0); + + // Mutate Onyx: remove the broken-feed entry → row should appear on re-render. + act(() => { + onyxData[ONYXKEYS.DERIVED.CARD_FEED_ERRORS] = makeCardFeedErrors({cardsWithBrokenFeedConnection: {}}); + }); + rerender(undefined); + expect(result.current.cardRows).toHaveLength(1); + + // And back: re-add the broken-feed entry → row should disappear. + act(() => { + onyxData[ONYXKEYS.DERIVED.CARD_FEED_ERRORS] = makeCardFeedErrors({cardsWithBrokenFeedConnection: {[THIRD_PARTY_CARD_ID_1]: brokenCard}}); + }); + rerender(undefined); + expect(result.current.cardRows).toHaveLength(0); + }); +});