diff --git a/src/components/TransactionItemRow/DataCells/TypeCell.tsx b/src/components/TransactionItemRow/DataCells/TypeCell.tsx index 8d830a1184a6..7206f34f25c7 100644 --- a/src/components/TransactionItemRow/DataCells/TypeCell.tsx +++ b/src/components/TransactionItemRow/DataCells/TypeCell.tsx @@ -8,7 +8,7 @@ import useOnyx from '@hooks/useOnyx'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; -import {isTravelCard} from '@libs/CardUtils'; +import {isTravelCardTransaction} from '@libs/CardUtils'; import {getExpenseTypeTranslationKey, getTransactionType, isExpensifyCardTransaction, isManagedCardTransaction, isPending} from '@libs/TransactionUtils'; import variables from '@styles/variables'; @@ -73,7 +73,7 @@ function TypeCell({transactionItem, shouldUseNarrowLayout, shouldShowTooltip}: T const type = getTransactionType(transactionItem, card); const isExpensifyCard = isExpensifyCardTransaction(transactionItem); const isManagedCard = isManagedCardTransaction(transactionItem); - const isTravelInvoicingCard = isTravelCard(card); + const isTravelInvoicingCard = isTravelCardTransaction(transactionItem.feedCountry, card); const isPendingExpensifyCardTransaction = isExpensifyCard && isPending(transactionItem); const pendingIcon = isTravelInvoicingCard ? expensifyIcons.CreditCardWithPlaneHourglass : expensifyIcons.ExpensifyCardHourglass; const typeIcon = isPendingExpensifyCardTransaction ? pendingIcon : getTypeIcon(expensifyIcons, type, isExpensifyCard, isManagedCard, isTravelInvoicingCard); diff --git a/src/components/TransactionItemRow/index.tsx b/src/components/TransactionItemRow/index.tsx index bf2735699427..935dbea243bc 100644 --- a/src/components/TransactionItemRow/index.tsx +++ b/src/components/TransactionItemRow/index.tsx @@ -235,7 +235,7 @@ function TransactionItemRow({ const description = getDescription(transactionItem); const exchangeRateMessage = getExchangeRate(transactionItem, report?.currency ?? policy?.outputCurrency); - const cardName = getCompanyCardDescription(translate, transactionItem?.cardName, transactionItem?.cardID, nonPersonalAndWorkspaceCards); + const cardName = getCompanyCardDescription(translate, transactionItem?.cardName, transactionItem?.cardID, nonPersonalAndWorkspaceCards, transactionItem?.feedCountry); const isUnreported = transactionItem.reportID === CONST.REPORT.UNREPORTED_REPORT_ID; const shouldShowAttendees = (isUnreported ? !!isAttendeesEnabledForMovingPolicy : shouldShowAttendeesUtils(CONST.IOU.TYPE.SUBMIT, policy)) && transactionAttendees.length > 0; diff --git a/src/libs/CardUtils.ts b/src/libs/CardUtils.ts index ecef1cac8c93..df73e3cad8ed 100644 --- a/src/libs/CardUtils.ts +++ b/src/libs/CardUtils.ts @@ -248,17 +248,18 @@ function getCardDescriptionForSearchTable(card: Card, translate: LocalizedTransl * Returns the formatted card name for a company card. Returns an empty string * if the card is not a real card, but a cash expense */ -function getCompanyCardDescription(translate: LocalizedTranslate, transactionCardName?: string, cardID?: number, cards?: CardList) { +function getCompanyCardDescription(translate: LocalizedTranslate, transactionCardName?: string, cardID?: number, cards?: CardList, feedCountry?: string) { const formattedTransactionCardName = transactionCardName === CONST.EXPENSE.TYPE.CASH_CARD_NAME ? '' : transactionCardName; + const card = cardID ? cards?.[cardID] : undefined; - if (!cardID || !cards?.[cardID]) { - return formattedTransactionCardName; + // feedCountry travels with the transaction, so a travel card belonging to another member (absent from the viewer's card + // list) still shows the localized travel name instead of the server string. + if (isTravelCardTransaction(feedCountry, card)) { + return translate('cardTransactions.travelInvoicing'); } - const card = cards[cardID]; - - if (isTravelCard(card)) { - return translate('cardTransactions.travelInvoicing'); + if (!card) { + return formattedTransactionCardName; } if (isExpensifyCard(card)) { @@ -1757,6 +1758,15 @@ function isTravelCard(card: Card | undefined): boolean { return card?.nameValuePairs?.feedCountry === CONST.TRAVEL.PROGRAM_TRAVEL_US; } +/** + * A transaction is on a travel card when the backend stamps its feedCountry, which travels with the transaction so the icon + * resolves even for another member's card that isn't in the viewer's own card list. Falls back to the card object for old + * cached transactions that predate the feedCountry field. + */ +function isTravelCardTransaction(feedCountry: string | undefined, card: Card | undefined): boolean { + return feedCountry === CONST.TRAVEL.PROGRAM_TRAVEL_US || isTravelCard(card); +} + /** * Gets displayable Expensify cards, filtering out inactive cards and grouping combo cards * (physical + virtual pairs) so only the physical card is shown per domain. @@ -1979,6 +1989,7 @@ export { getBankName, isSelectedFeedExpired, isTravelCard, + isTravelCardTransaction, getCompanyFeeds, hasCompanyCardFeeds, isPersonalCardBrokenConnection, diff --git a/src/libs/DebugUtils.ts b/src/libs/DebugUtils.ts index 6f2f456831f5..19eab82fef33 100644 --- a/src/libs/DebugUtils.ts +++ b/src/libs/DebugUtils.ts @@ -1153,6 +1153,7 @@ function validateTransactionDraftProperty(key: keyof Transaction, value: string) bank: CONST.RED_BRICK_ROAD_PENDING_ACTION, liabilityType: CONST.RED_BRICK_ROAD_PENDING_ACTION, cardName: CONST.RED_BRICK_ROAD_PENDING_ACTION, + feedCountry: CONST.RED_BRICK_ROAD_PENDING_ACTION, cardNumber: CONST.RED_BRICK_ROAD_PENDING_ACTION, managedCard: CONST.RED_BRICK_ROAD_PENDING_ACTION, posted: CONST.RED_BRICK_ROAD_PENDING_ACTION, diff --git a/src/types/onyx/Transaction.ts b/src/types/onyx/Transaction.ts index 66a92ec097fb..18a162c1307e 100644 --- a/src/types/onyx/Transaction.ts +++ b/src/types/onyx/Transaction.ts @@ -671,6 +671,9 @@ type Transaction = OnyxCommon.OnyxValueWithOfflineFeedback< /** The display name of the purchaser card, if any */ cardName?: string; + /** The Expensify Card program the card belongs to (e.g. `TRAVEL_US`), used to derive the travel icon without needing the card in the viewer's own list */ + feedCountry?: string; + /** The masked PAN of the purchaser card, if any */ cardNumber?: string; diff --git a/tests/unit/CardUtilsTest.ts b/tests/unit/CardUtilsTest.ts index 35de624109c7..fce49083e9bd 100644 --- a/tests/unit/CardUtilsTest.ts +++ b/tests/unit/CardUtilsTest.ts @@ -66,6 +66,7 @@ import { isExpiredCard, isMatchingCard, isPersonalCard, + isTravelCardTransaction, isUkEuExpensifyCard, lastFourNumbersFromCardName, maskCardNumber, @@ -3280,6 +3281,34 @@ describe('CardUtils', () => { const description = getCompanyCardDescription(mockTranslate, 'Expensify Card - 6909', 99999, travelCardList); expect(description).toBe('Travel invoicing'); }); + + it("should return 'Travel invoicing' for another member's travel card that isn't in the viewer's card list", () => { + const description = getCompanyCardDescription(mockTranslate, 'Expensify Card - 6909', 99999, undefined, CONST.TRAVEL.PROGRAM_TRAVEL_US); + expect(description).toBe('Travel invoicing'); + }); + }); + + describe('isTravelCardTransaction', () => { + it("returns true from the transaction's feedCountry even when the card isn't in the viewer's list", () => { + expect(isTravelCardTransaction(CONST.TRAVEL.PROGRAM_TRAVEL_US, undefined)).toBe(true); + }); + + it('falls back to the card when the transaction has no feedCountry', () => { + const travelCardList = createMock({ + '99999': { + cardID: 99999, + bank: CONST.EXPENSIFY_CARD.BANK, + nameValuePairs: { + feedCountry: CONST.TRAVEL.PROGRAM_TRAVEL_US, + }, + }, + }); + expect(isTravelCardTransaction(undefined, travelCardList['99999'])).toBe(true); + }); + + it('returns false for a non-travel transaction with no card', () => { + expect(isTravelCardTransaction(CONST.COUNTRY.US, undefined)).toBe(false); + }); }); describe('Expensify card sort comparator', () => {