Skip to content
4 changes: 2 additions & 2 deletions src/components/TransactionItemRow/DataCells/TypeCell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -74,7 +74,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 isPendingCardTransaction = isPending(transactionItem);
const getPendingIcon = () => {
if (isTravelInvoicingCard) {
Expand Down
2 changes: 1 addition & 1 deletion src/components/TransactionItemRow/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
25 changes: 18 additions & 7 deletions src/libs/CardUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,17 +247,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)) {
Expand Down Expand Up @@ -1744,6 +1745,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.
Expand Down Expand Up @@ -1966,6 +1976,7 @@ export {
getBankName,
isSelectedFeedExpired,
isTravelCard,
isTravelCardTransaction,
getCompanyFeeds,
hasCompanyCardFeeds,
isPersonalCardBrokenConnection,
Expand Down
2 changes: 2 additions & 0 deletions src/libs/DebugUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -992,6 +992,7 @@ function validateTransactionDraftProperty(key: keyof Transaction, value: string)
case 'linkedTrackedExpenseReportID':
case 'bank':
case 'cardName':
case 'feedCountry':
case 'cardNumber':
case 'taxValue':
case 'groupCurrency':
Expand Down Expand Up @@ -1153,6 +1154,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,
Expand Down
3 changes: 3 additions & 0 deletions src/types/onyx/Transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
29 changes: 29 additions & 0 deletions tests/unit/CardUtilsTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ import {
isExpiredCard,
isMatchingCard,
isPersonalCard,
isTravelCardTransaction,
isUkEuExpensifyCard,
lastFourNumbersFromCardName,
maskCardNumber,
Expand Down Expand Up @@ -3358,6 +3359,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<CardList>({
'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', () => {
Expand Down
Loading