Skip to content

Commit 0adae4c

Browse files
authored
Merge pull request Expensify#83737 from Expensify/cm-duplicate-assigned-cards
Fix duplicate assign card rows for commercial feed cards
2 parents ef03d98 + a50adf4 commit 0adae4c

2 files changed

Lines changed: 277 additions & 21 deletions

File tree

src/hooks/useCompanyCards.ts

Lines changed: 40 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -56,21 +56,28 @@ type UseCompanyCardsResult = Partial<{
5656
* Only the lastFourPAN path enriches the card; the other two confirm the card is already linked.
5757
*/
5858
function resolveCardListEntry(card: Card, cardListEntries: Array<[string, string]>): Card {
59-
if (!card.lastFourPAN) {
60-
return card;
61-
}
62-
6359
const {cardName, encryptedCardNumber, lastFourPAN} = card;
6460

61+
// Using || instead of ?? because an empty-string lastFourPAN should fall through to cardName
62+
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
63+
const panSuffix = lastFourPAN || cardName;
64+
6565
const isLinkedByEncrypted = encryptedCardNumber && cardListEntries.some(([, entryEncryptedCardNumber]) => entryEncryptedCardNumber === encryptedCardNumber);
66+
if (isLinkedByEncrypted) {
67+
return card;
68+
}
69+
6670
const normalizedCardName = cardName ? normalizeCardName(cardName) : undefined;
67-
const isLinkedByName = normalizedCardName && cardListEntries.some(([name]) => normalizeCardName(name) === normalizedCardName);
71+
const matchedByName = normalizedCardName ? cardListEntries.find(([name]) => normalizeCardName(name) === normalizedCardName) : undefined;
72+
if (matchedByName) {
73+
return {...card, encryptedCardNumber: matchedByName[1]};
74+
}
6875

69-
if (isLinkedByEncrypted || isLinkedByName) {
76+
if (!panSuffix) {
7077
return card;
7178
}
7279

73-
const [matchedCard, ...otherMatchedCards] = cardListEntries.filter(([name]) => name.endsWith(lastFourPAN)).slice(0, 2);
80+
const [matchedCard, ...otherMatchedCards] = cardListEntries.filter(([name]) => name.endsWith(panSuffix)).slice(0, 2);
7481

7582
// If there are other matched cards, return the original card.
7683
if (otherMatchedCards.length > 0) {
@@ -91,7 +98,7 @@ function buildCompanyCardEntries(
9198
assignedCards: CardList,
9299
feedName?: CompanyCardFeedWithDomainID,
93100
): CompanyCardEntry[] {
94-
const entries: CompanyCardEntry[] = [];
101+
const entriesMap = new Map<string, CompanyCardEntry>();
95102
const coveredNames = new Set<string>();
96103
const coveredEncrypted = new Set<string>();
97104

@@ -104,35 +111,47 @@ function buildCompanyCardEntries(
104111
continue;
105112
}
106113

107-
const resolved = resolveCardListEntry(card, cardListEntries);
108-
const {cardName = card.cardName, encryptedCardNumber = card.cardName} = resolved;
114+
const {cardName = card.cardName, encryptedCardNumber = card.cardName} = resolveCardListEntry(card, cardListEntries);
115+
const normalizedName = normalizeCardName(cardName);
116+
const cardEntryID = encryptedCardNumber ?? normalizedName;
117+
118+
const existingEntry = entriesMap.get(cardEntryID);
119+
120+
const isRicherRecord = card.lastFourPAN && !existingEntry?.assignedCard?.lastFourPAN;
109121

110-
entries.push({cardName, encryptedCardNumber, isAssigned: true, assignedCard: card});
111-
coveredNames.add(normalizeCardName(cardName));
122+
// Skip duplicate when two assigned-card records (e.g. old-format + new-format) resolve to the same cardList entry.
123+
if (!existingEntry || isRicherRecord) {
124+
entriesMap.set(cardEntryID, {cardName, encryptedCardNumber, isAssigned: true, assignedCard: card});
125+
}
126+
127+
coveredNames.add(normalizedName);
112128
if (encryptedCardNumber !== cardName) {
113129
coveredEncrypted.add(encryptedCardNumber);
114130
}
115131
}
116132

117133
// Phase 2: Add remaining unassigned cards. cardList first so its encryptedCardNumber takes precedence.
118-
for (const [name, encryptedCardNumber] of cardListEntries) {
119-
if (coveredNames.has(normalizeCardName(name)) || coveredEncrypted.has(encryptedCardNumber)) {
134+
for (const [cardName, encryptedCardNumber] of cardListEntries) {
135+
const normalizedName = normalizeCardName(cardName);
136+
if (coveredNames.has(normalizedName) || coveredEncrypted.has(encryptedCardNumber) || entriesMap.has(encryptedCardNumber)) {
120137
continue;
121138
}
122-
entries.push({cardName: name, encryptedCardNumber, isAssigned: false});
123-
coveredNames.add(normalizeCardName(name));
139+
140+
entriesMap.set(encryptedCardNumber, {cardName, encryptedCardNumber, isAssigned: false});
141+
coveredNames.add(normalizedName);
124142
coveredEncrypted.add(encryptedCardNumber);
125143
}
126144

127-
for (const name of filterAmexDirectParentCard(accountList ?? [], feedName)) {
128-
if (coveredNames.has(normalizeCardName(name))) {
145+
for (const cardName of filterAmexDirectParentCard(accountList ?? [], feedName)) {
146+
const normalizedName = normalizeCardName(cardName);
147+
if (coveredNames.has(normalizedName) || coveredEncrypted.has(cardName) || entriesMap.has(normalizedName)) {
129148
continue;
130149
}
131-
entries.push({cardName: name, encryptedCardNumber: name, isAssigned: false});
132-
coveredNames.add(normalizeCardName(name));
150+
entriesMap.set(normalizedName, {cardName, encryptedCardNumber: cardName, isAssigned: false});
151+
coveredNames.add(normalizedName);
133152
}
134153

135-
return entries;
154+
return Array.from(entriesMap.values());
136155
}
137156

138157
function useCompanyCards({policyID, feedName: feedNameProp}: UseCompanyCardsProps): UseCompanyCardsResult {

tests/unit/hooks/useCompanyCards.test.ts

Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -733,6 +733,243 @@ describe('useCompanyCards', () => {
733733
// lastFourPAN resolves both cardName and encryptedCardNumber from cardList
734734
expect(result.current.companyCardEntries).toEqual([entry('111222XXXX31234', 'v1:NEW_ENCRYPTED', true)]);
735735
});
736+
737+
it('should deduplicate when an old-format (4-digit) and new-format card both resolve to the same cardList entry', async () => {
738+
const cdfCardsList = {
739+
cardList: {
740+
'553312XXXXXX0487': 'v1:ENCRYPTED_0487',
741+
},
742+
// Old-format card: cardName is just last 4 digits, lastFourPAN is empty
743+
'8100': {
744+
cardID: 8100,
745+
accountID: 11,
746+
bank: 'gl1025',
747+
cardName: '0487',
748+
lastFourPAN: '',
749+
domainName: 'expensify-policy://123456',
750+
state: 3,
751+
},
752+
// New-format card: full masked name with lastFourPAN
753+
'8101': {
754+
cardID: 8101,
755+
accountID: 11,
756+
bank: 'gl1025',
757+
cardName: '553312XXXXXX0487',
758+
lastFourPAN: '0487',
759+
domainName: 'expensify-policy://123456',
760+
state: 3,
761+
},
762+
};
763+
764+
await Onyx.merge(`${ONYXKEYS.COLLECTION.LAST_SELECTED_FEED}${mockPolicyID}`, mockCustomFeed);
765+
mockUseCardFeedsHook(mockCustomFeedData);
766+
mockUseCardsListHook(cdfCardsList);
767+
768+
const {result} = renderHook(() => useCompanyCards({policyID: mockPolicyID}));
769+
770+
const entries = result.current.companyCardEntries ?? [];
771+
772+
// Only one entry should appear — the duplicate is suppressed
773+
expect(entries).toHaveLength(1);
774+
expect(entries.at(0)).toMatchObject({cardName: '553312XXXXXX0487', encryptedCardNumber: 'v1:ENCRYPTED_0487', isAssigned: true});
775+
776+
// The canonical (new-format) card with lastFourPAN should be kept as assignedCard
777+
expect(entries.at(0)?.assignedCard?.cardID).toBe(8101);
778+
expect(entries.at(0)?.assignedCard?.lastFourPAN).toBe('0487');
779+
});
780+
781+
it('should enrich encryptedCardNumber via name match when card has no encryptedCardNumber', async () => {
782+
const cdfCardsList = {
783+
cardList: {
784+
'553312XXXXXX0487': 'v1:ENCRYPTED_0487',
785+
},
786+
'8200': {
787+
cardID: 8200,
788+
accountID: 11,
789+
bank: 'gl1025',
790+
cardName: '553312XXXXXX0487',
791+
domainName: 'expensify-policy://123456',
792+
state: 3,
793+
},
794+
};
795+
796+
await Onyx.merge(`${ONYXKEYS.COLLECTION.LAST_SELECTED_FEED}${mockPolicyID}`, mockCustomFeed);
797+
mockUseCardFeedsHook(mockCustomFeedData);
798+
mockUseCardsListHook(cdfCardsList);
799+
800+
const {result} = renderHook(() => useCompanyCards({policyID: mockPolicyID}));
801+
802+
// isLinkedByName enriches the assigned card with encryptedCardNumber from cardList, and Phase 2 skips the cardList entry
803+
expect(result.current.companyCardEntries).toEqual([entry('553312XXXXXX0487', 'v1:ENCRYPTED_0487', true)]);
804+
});
805+
806+
it('should use cardName as panSuffix when lastFourPAN is undefined', async () => {
807+
const cdfCardsList = {
808+
cardList: {
809+
'553312XXXXXX0487': 'v1:ENCRYPTED_0487',
810+
},
811+
'8300': {
812+
cardID: 8300,
813+
accountID: 11,
814+
bank: 'gl1025',
815+
cardName: '0487',
816+
domainName: 'expensify-policy://123456',
817+
state: 3,
818+
},
819+
};
820+
821+
await Onyx.merge(`${ONYXKEYS.COLLECTION.LAST_SELECTED_FEED}${mockPolicyID}`, mockCustomFeed);
822+
mockUseCardFeedsHook(mockCustomFeedData);
823+
mockUseCardsListHook(cdfCardsList);
824+
825+
const {result} = renderHook(() => useCompanyCards({policyID: mockPolicyID}));
826+
827+
// cardName '0487' is used as panSuffix when lastFourPAN is absent, resolving via suffix match
828+
expect(result.current.companyCardEntries).toEqual([entry('553312XXXXXX0487', 'v1:ENCRYPTED_0487', true)]);
829+
});
830+
831+
it('should deduplicate assigned cards but not suppress unrelated accountList entries with old-format names', async () => {
832+
const feedWithAccountList: CompanyCardFeedWithDomainID = `${CONST.COMPANY_CARD.FEED_BANK_NAME.CHASE}#${domainID}` as CompanyCardFeedWithDomainID;
833+
const feedData = {
834+
[feedWithAccountList]: {
835+
...mockOAuthFeedData[mockOAuthFeed],
836+
accountList: ['0487', 'SOME OTHER CARD'],
837+
},
838+
};
839+
const cdfCardsList = {
840+
cardList: {
841+
'553312XXXXXX0487': 'v1:ENCRYPTED_0487',
842+
},
843+
'8100': {
844+
cardID: 8100,
845+
accountID: 11,
846+
bank: 'gl1025',
847+
cardName: '0487',
848+
lastFourPAN: '',
849+
domainName: 'expensify-policy://123456',
850+
state: 3,
851+
},
852+
'8101': {
853+
cardID: 8101,
854+
accountID: 11,
855+
bank: 'gl1025',
856+
cardName: '553312XXXXXX0487',
857+
lastFourPAN: '0487',
858+
domainName: 'expensify-policy://123456',
859+
state: 3,
860+
},
861+
};
862+
863+
await Onyx.merge(`${ONYXKEYS.COLLECTION.LAST_SELECTED_FEED}${mockPolicyID}`, feedWithAccountList);
864+
mockUseCardFeedsHook(feedData);
865+
mockUseCardsListHook(cdfCardsList);
866+
867+
const {result} = renderHook(() => useCompanyCards({policyID: mockPolicyID}));
868+
869+
const entries = result.current.companyCardEntries ?? [];
870+
871+
// Deduplication works for assigned cards: only 1 assigned entry.
872+
// coveredNames tracks the resolved name '553312XXXXXX0487', so the accountList entry '0487' passes through as unassigned.
873+
expect(entries).toHaveLength(3);
874+
expect(entries.at(0)).toMatchObject({cardName: '553312XXXXXX0487', encryptedCardNumber: 'v1:ENCRYPTED_0487', isAssigned: true});
875+
expect(entries.at(1)).toMatchObject({cardName: '0487', isAssigned: false});
876+
expect(entries.at(2)).toMatchObject({cardName: 'SOME OTHER CARD', isAssigned: false});
877+
});
878+
879+
it('should deduplicate three cards resolving to the same cardList entry', async () => {
880+
const cdfCardsList = {
881+
cardList: {
882+
'553312XXXXXX0487': 'v1:ENCRYPTED_0487',
883+
},
884+
// Old-format card
885+
'8100': {
886+
cardID: 8100,
887+
accountID: 11,
888+
bank: 'gl1025',
889+
cardName: '0487',
890+
lastFourPAN: '',
891+
domainName: 'expensify-policy://123456',
892+
state: 3,
893+
},
894+
// New-format card with lastFourPAN
895+
'8101': {
896+
cardID: 8101,
897+
accountID: 11,
898+
bank: 'gl1025',
899+
cardName: '553312XXXXXX0487',
900+
lastFourPAN: '0487',
901+
domainName: 'expensify-policy://123456',
902+
state: 3,
903+
},
904+
// Another new-format card with matching encryptedCardNumber
905+
'8102': {
906+
cardID: 8102,
907+
accountID: 11,
908+
bank: 'gl1025',
909+
cardName: '553312XXXXXX0487',
910+
encryptedCardNumber: 'v1:ENCRYPTED_0487',
911+
lastFourPAN: '0487',
912+
domainName: 'expensify-policy://123456',
913+
state: 3,
914+
},
915+
};
916+
917+
await Onyx.merge(`${ONYXKEYS.COLLECTION.LAST_SELECTED_FEED}${mockPolicyID}`, mockCustomFeed);
918+
mockUseCardFeedsHook(mockCustomFeedData);
919+
mockUseCardsListHook(cdfCardsList);
920+
921+
const {result} = renderHook(() => useCompanyCards({policyID: mockPolicyID}));
922+
923+
const entries = result.current.companyCardEntries ?? [];
924+
925+
// All three cards resolve to the same cardList entry — only one appears
926+
expect(entries).toHaveLength(1);
927+
expect(entries.at(0)).toMatchObject({cardName: '553312XXXXXX0487', encryptedCardNumber: 'v1:ENCRYPTED_0487', isAssigned: true});
928+
});
929+
930+
it('should deduplicate correctly when new-format card is processed before old-format card', async () => {
931+
const cdfCardsList = {
932+
cardList: {
933+
'553312XXXXXX0487': 'v1:ENCRYPTED_0487',
934+
},
935+
// New-format card has lower numeric key — processed first
936+
'8099': {
937+
cardID: 8099,
938+
accountID: 11,
939+
bank: 'gl1025',
940+
cardName: '553312XXXXXX0487',
941+
lastFourPAN: '0487',
942+
domainName: 'expensify-policy://123456',
943+
state: 3,
944+
},
945+
// Old-format card processed second
946+
'8100': {
947+
cardID: 8100,
948+
accountID: 11,
949+
bank: 'gl1025',
950+
cardName: '0487',
951+
lastFourPAN: '',
952+
domainName: 'expensify-policy://123456',
953+
state: 3,
954+
},
955+
};
956+
957+
await Onyx.merge(`${ONYXKEYS.COLLECTION.LAST_SELECTED_FEED}${mockPolicyID}`, mockCustomFeed);
958+
mockUseCardFeedsHook(mockCustomFeedData);
959+
mockUseCardsListHook(cdfCardsList);
960+
961+
const {result} = renderHook(() => useCompanyCards({policyID: mockPolicyID}));
962+
963+
const entries = result.current.companyCardEntries ?? [];
964+
965+
// Same result regardless of iteration order
966+
expect(entries).toHaveLength(1);
967+
expect(entries.at(0)).toMatchObject({cardName: '553312XXXXXX0487', encryptedCardNumber: 'v1:ENCRYPTED_0487', isAssigned: true});
968+
969+
// The canonical (new-format) card should be kept as assignedCard regardless of iteration order
970+
expect(entries.at(0)?.assignedCard?.cardID).toBe(8099);
971+
expect(entries.at(0)?.assignedCard?.lastFourPAN).toBe('0487');
972+
});
736973
});
737974

738975
describe('card ID consistency', () => {

0 commit comments

Comments
 (0)