Skip to content

Commit eaedae9

Browse files
authored
Merge pull request Expensify#82179 from fedirjh/fix-duplicate-cards-OAuth-feeds
Enhance card mapping to include assigned cards for OAuth feeds
2 parents 404875b + 4d4e371 commit eaedae9

4 files changed

Lines changed: 377 additions & 108 deletions

File tree

src/hooks/useCompanyCards.ts

Lines changed: 56 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import type {OnyxCollection, OnyxEntry, ResultMetadata} from 'react-native-onyx';
2-
import {getCompanyCardFeed, getCompanyFeeds, getSelectedFeed} from '@libs/CardUtils';
2+
import {getCompanyCardFeed, getCompanyFeeds, getSelectedFeed, normalizeCardName} from '@libs/CardUtils';
33
import CONST from '@src/CONST';
44
import ONYXKEYS from '@src/ONYXKEYS';
55
import type {CardFeeds, CardList} from '@src/types/onyx';
6+
import type Card from '@src/types/onyx/Card';
67
import type {AssignableCardsList, WorkspaceCardsList} from '@src/types/onyx/Card';
78
import type {CardFeedsStatusByDomainID, CombinedCardFeeds, CompanyCardFeedWithDomainID, CompanyCardFeedWithNumber, CompanyFeeds} from '@src/types/onyx/CardFeeds';
89
import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue';
@@ -11,6 +12,13 @@ import type {CombinedCardFeed} from './useCardFeeds';
1112
import useCardsList from './useCardsList';
1213
import useOnyx from './useOnyx';
1314

15+
type CompanyCardEntry = {
16+
cardName: string;
17+
encryptedCardNumber: string;
18+
isAssigned: boolean;
19+
assignedCard?: Card;
20+
};
21+
1422
type UseCompanyCardsProps = {
1523
policyID: string | undefined;
1624
feedName?: CompanyCardFeedWithDomainID;
@@ -21,7 +29,7 @@ type UseCompanyCardsResult = Partial<{
2129
feedName: CompanyCardFeedWithDomainID;
2230
cardList: AssignableCardsList;
2331
assignedCards: CardList;
24-
cardNamesToEncryptedCardNumberMapping: Record<string, string>;
32+
companyCardEntries: CompanyCardEntry[];
2533
workspaceCardFeedsStatus: CardFeedsStatusByDomainID;
2634
allCardFeeds: CombinedCardFeeds;
2735
companyCardFeeds: CompanyFeeds;
@@ -39,6 +47,49 @@ type UseCompanyCardsResult = Partial<{
3947
};
4048
};
4149

50+
/**
51+
* Builds a list of card entries by starting from assignedCards (source of truth for assignments),
52+
* then filling in remaining unassigned cards from accountList/cardList.
53+
*/
54+
function buildCompanyCardEntries(accountList: string[] | undefined, cardList: AssignableCardsList | undefined, assignedCards: CardList): CompanyCardEntry[] {
55+
const entries: CompanyCardEntry[] = [];
56+
const coveredNames = new Set<string>();
57+
const coveredEncrypted = new Set<string>();
58+
59+
// Phase 1: Assigned cards first — these are the source of truth.
60+
for (const card of Object.values(assignedCards)) {
61+
if (!card?.cardName) {
62+
continue;
63+
}
64+
const encryptedCardNumber = card.encryptedCardNumber ?? card.cardName;
65+
entries.push({cardName: card.cardName, encryptedCardNumber, isAssigned: true, assignedCard: card});
66+
coveredNames.add(normalizeCardName(card.cardName));
67+
if (card.encryptedCardNumber) {
68+
coveredEncrypted.add(card.encryptedCardNumber);
69+
}
70+
}
71+
72+
// Phase 2: Add remaining unassigned cards. cardList first so its encryptedCardNumber takes precedence.
73+
for (const [name, encryptedCardNumber] of Object.entries(cardList ?? {})) {
74+
if (coveredNames.has(normalizeCardName(name)) || coveredEncrypted.has(encryptedCardNumber)) {
75+
continue;
76+
}
77+
entries.push({cardName: name, encryptedCardNumber, isAssigned: false});
78+
coveredNames.add(normalizeCardName(name));
79+
coveredEncrypted.add(encryptedCardNumber);
80+
}
81+
82+
for (const name of accountList ?? []) {
83+
if (coveredNames.has(normalizeCardName(name))) {
84+
continue;
85+
}
86+
entries.push({cardName: name, encryptedCardNumber: name, isAssigned: false});
87+
coveredNames.add(normalizeCardName(name));
88+
}
89+
90+
return entries;
91+
}
92+
4293
function useCompanyCards({policyID, feedName: feedNameProp}: UseCompanyCardsProps): UseCompanyCardsResult {
4394
// If an empty string is passed, we need to use an invalid key to avoid fetching the whole collection.
4495
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
@@ -56,14 +107,7 @@ function useCompanyCards({policyID, feedName: feedNameProp}: UseCompanyCardsProp
56107
const selectedFeed = feedName && companyCardFeeds[feedName];
57108

58109
const {cardList, ...assignedCards} = cardsList ?? {};
59-
const cardNamesToEncryptedCardNumberMapping: Record<string, string> = {};
60-
61-
for (const cardName of selectedFeed?.accountList ?? []) {
62-
cardNamesToEncryptedCardNumberMapping[cardName] = cardName;
63-
}
64-
for (const [cardName, encryptedCardNumber] of Object.entries(cardList ?? {})) {
65-
cardNamesToEncryptedCardNumberMapping[cardName] = encryptedCardNumber;
66-
}
110+
const companyCardEntries = buildCompanyCardEntries(selectedFeed?.accountList, cardList, assignedCards);
67111

68112
const onyxMetadata = {
69113
cardListMetadata,
@@ -86,7 +130,7 @@ function useCompanyCards({policyID, feedName: feedNameProp}: UseCompanyCardsProp
86130
companyCardFeeds,
87131
cardList,
88132
assignedCards,
89-
cardNamesToEncryptedCardNumberMapping,
133+
companyCardEntries,
90134
workspaceCardFeedsStatus,
91135
selectedFeed,
92136
bankName,
@@ -99,4 +143,4 @@ function useCompanyCards({policyID, feedName: feedNameProp}: UseCompanyCardsProp
99143
}
100144

101145
export default useCompanyCards;
102-
export type {UseCompanyCardsResult};
146+
export type {CompanyCardEntry, UseCompanyCardsResult};

src/libs/CardUtils.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -304,10 +304,13 @@ function isMatchingCard(card: Card, encryptedCardNumber: string, cardName: strin
304304
return false;
305305
}
306306

307-
// Normalize both strings to remove special characters (®, ™, ©, etc.)
308-
// This handles differences between OAuth provider card names and stored card names
309-
const normalize = (str: string) => str.replaceAll(/[^\w\s-]/g, '').trim();
310-
return normalize(card.cardName) === normalize(cardName);
307+
return normalizeCardName(card.cardName) === normalizeCardName(cardName);
308+
}
309+
310+
// Normalize both strings to remove special characters (®, ™, ©, etc.)
311+
// This handles differences between OAuth provider card names and stored card names
312+
function normalizeCardName(cardName: string): string {
313+
return cardName.replaceAll(/[^\w\s-]/g, '').trim();
311314
}
312315

313316
function getMCardNumberString(cardNumber: string): string {
@@ -1205,6 +1208,7 @@ export {
12051208
isSmartLimitEnabled,
12061209
lastFourNumbersFromCardName,
12071210
isMatchingCard,
1211+
normalizeCardName,
12081212
hasIssuedExpensifyCard,
12091213
isExpensifyCardFullySetUp,
12101214
filterAllInactiveCards,

src/pages/workspace/companyCards/WorkspaceCompanyCardsTable/index.tsx

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,14 @@ import useOnyx from '@hooks/useOnyx';
1717
import useResponsiveLayout from '@hooks/useResponsiveLayout';
1818
import useThemeStyles from '@hooks/useThemeStyles';
1919
import {resetFailedWorkspaceCompanyCardUnassignment} from '@libs/actions/CompanyCards';
20-
import {getDefaultCardName, getPlaidInstitutionId, isMatchingCard} from '@libs/CardUtils';
20+
import {getDefaultCardName, getPlaidInstitutionId} from '@libs/CardUtils';
2121
import tokenizedSearch from '@libs/tokenizedSearch';
2222
import WorkspaceCompanyCardPageEmptyState from '@pages/workspace/companyCards/WorkspaceCompanyCardPageEmptyState';
2323
import WorkspaceCompanyCardsFeedAddedEmptyPage from '@pages/workspace/companyCards/WorkspaceCompanyCardsFeedAddedEmptyPage';
2424
import WorkspaceCompanyCardsFeedPendingPage from '@pages/workspace/companyCards/WorkspaceCompanyCardsFeedPendingPage';
2525
import variables from '@styles/variables';
2626
import CONST from '@src/CONST';
2727
import ONYXKEYS from '@src/ONYXKEYS';
28-
import type {Card} from '@src/types/onyx';
2928
import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue';
3029
import WorkspaceCompanyCardsTableHeaderButtons from './WorkspaceCompanyCardsTableHeaderButtons';
3130
import WorkspaceCompanyCardTableItem from './WorkspaceCompanyCardsTableItem';
@@ -78,7 +77,7 @@ function WorkspaceCompanyCardsTable({
7877
feedName,
7978
bankName,
8079
assignedCards,
81-
cardNamesToEncryptedCardNumberMapping,
80+
companyCardEntries,
8281
workspaceCardFeedsStatus,
8382
selectedFeed,
8483
isInitiallyLoadingFeeds,
@@ -113,7 +112,7 @@ function WorkspaceCompanyCardsTable({
113112
}
114113

115114
const isLoadingFeed = (!feedName && isInitiallyLoadingFeeds) || !isPolicyLoaded || isLoadingOnyxValue(lastSelectedFeedMetadata) || !!selectedFeedStatus?.isLoading;
116-
const isLoadingCards = Object.keys(cardNamesToEncryptedCardNumberMapping ?? {}).length === 0 ? isLoadingOnyxValue(cardListMetadata) : false;
115+
const isLoadingCards = (companyCardEntries ?? []).length === 0 ? isLoadingOnyxValue(cardListMetadata) : false;
117116
const isLoadingPage = !isOffline && (isLoadingFeed || isLoadingOnyxValue(personalDetailsMetadata) || areWorkspaceCardFeedsLoading);
118117
const isLoading = isLoadingPage || isLoadingFeed;
119118

@@ -150,23 +149,22 @@ function WorkspaceCompanyCardsTable({
150149

151150
const cardsData: WorkspaceCompanyCardTableItemData[] = isLoadingCards
152151
? []
153-
: (Object.entries(cardNamesToEncryptedCardNumberMapping ?? {}).map(([cardName, encryptedCardNumber]) => {
154-
const assignedCard = Object.values(assignedCards ?? {}).find((card: Card) => isMatchingCard(card, encryptedCardNumber, cardName));
152+
: (companyCardEntries ?? []).map(({cardName, encryptedCardNumber, isAssigned, assignedCard}) => {
155153
const cardholder = assignedCard?.accountID ? personalDetails?.[assignedCard.accountID] : undefined;
156154

157155
return {
158156
cardName,
159157
encryptedCardNumber,
160158
customCardName: assignedCard?.cardID && customCardNames?.[assignedCard.cardID] ? customCardNames?.[assignedCard.cardID] : getDefaultCardName(cardholder?.displayName ?? ''),
161159
isCardDeleted: assignedCard?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE,
162-
isAssigned: !!assignedCard,
160+
isAssigned,
163161
assignedCard,
164162
cardholder,
165163
errors: assignedCard?.errors,
166164
pendingAction: assignedCard?.pendingAction,
167165
onDismissError: () => resetFailedWorkspaceCompanyCardUnassignment(domainOrWorkspaceAccountID, bankName, assignedCard?.cardID),
168166
};
169-
}) ?? []);
167+
});
170168

171169
const keyExtractor = (item: WorkspaceCompanyCardTableItemData, index: number) => `${item.cardName}_${index}`;
172170

0 commit comments

Comments
 (0)