@@ -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