diff --git a/src/libs/CardUtils.ts b/src/libs/CardUtils.ts index a5bca8c09923..c7ba58b5dde4 100644 --- a/src/libs/CardUtils.ts +++ b/src/libs/CardUtils.ts @@ -1221,23 +1221,42 @@ function splitMaskedCardNumber(cardNumber: string | undefined, maskChar: string }; } -function isCardAlreadyAssigned(cardNumberToCheck: string, workspaceCardFeeds: OnyxCollection, domainOrWorkspaceAccountID: number): boolean { +function isCardAlreadyAssigned(cardNumberToCheck: string, workspaceCardFeeds: OnyxCollection, domainOrWorkspaceAccountID: number, feedName?: string): boolean { if (!cardNumberToCheck || !workspaceCardFeeds) { return false; } + const isDirectFeedType = isDirectFeed(feedName); + return Object.entries(workspaceCardFeeds).some(([key, workspaceCards]) => { if (!workspaceCards) { return false; } - const cardFeedKeyParts = key.split('_'); - const feedDomainID = Number(cardFeedKeyParts.at(1)); - if (cardFeedKeyParts.length !== 3 || cardFeedKeyParts.at(0) !== ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST || Number.isNaN(feedDomainID)) { + // Strip the collection prefix and split on the first underscore only, + // so feed names containing underscores (e.g., "plaid.ins_123456") are preserved. + const feedKey = key.replace(ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST, ''); + const separatorIndex = feedKey.indexOf('_'); + if (separatorIndex === -1) { return false; } - if (feedDomainID !== domainOrWorkspaceAccountID) { + const feedDomainID = Number(feedKey.substring(0, separatorIndex)); + const feedBankName = feedKey.substring(separatorIndex + 1); + + if (Number.isNaN(feedDomainID) || !feedBankName) { + return false; + } + + // For direct feeds (Plaid/OAuth): check across ALL workspaces that share the same feed name, + // because the same Plaid card should not be assignable to multiple workspaces. + // For commercial feeds: restrict to the current domain only, because different domains + // can legitimately have cards with the same masked display name (e.g., "VISA - 1234"). + if (isDirectFeedType) { + if (feedName && feedBankName !== feedName) { + return false; + } + } else if (feedDomainID !== domainOrWorkspaceAccountID) { return false; } diff --git a/src/pages/workspace/companyCards/assignCard/ConfirmationStep.tsx b/src/pages/workspace/companyCards/assignCard/ConfirmationStep.tsx index 9922bc8d739c..e9c71c8d091e 100644 --- a/src/pages/workspace/companyCards/assignCard/ConfirmationStep.tsx +++ b/src/pages/workspace/companyCards/assignCard/ConfirmationStep.tsx @@ -107,7 +107,7 @@ function ConfirmationStep({route}: ConfirmationStepProps) { // Check both encryptedCardNumber and cardName since isCardAlreadyAssigned matches on either // This handles cases where workspace card entries only have cardName (masked display name) stored const cardIdentifier = cardToAssign?.encryptedCardNumber ?? cardToAssign?.cardName; - if (cardIdentifier && isCardAlreadyAssigned(cardIdentifier, workspaceCardFeeds, domainOrWorkspaceAccountID)) { + if (cardIdentifier && isCardAlreadyAssigned(cardIdentifier, workspaceCardFeeds, domainOrWorkspaceAccountID, bankName)) { setCardError(getMicroSecondOnyxErrorWithTranslationKey('workspace.companyCards.cardAlreadyAssignedError')); return; } diff --git a/tests/unit/CardUtilsTest.ts b/tests/unit/CardUtilsTest.ts index 26c0436397be..2b87a37bc2c5 100644 --- a/tests/unit/CardUtilsTest.ts +++ b/tests/unit/CardUtilsTest.ts @@ -45,6 +45,7 @@ import { getYearFromExpirationDateString, hasIssuedExpensifyCard, hasOnlyOneCardToAssign, + isCardAlreadyAssigned, isCardFrozen, isCSVFeedOrExpensifyCard, isCustomFeed as isCustomFeedCardUtils, @@ -3236,6 +3237,142 @@ describe('CardUtils', () => { }); }); + describe('isCardAlreadyAssigned', () => { + it('should detect Plaid card assigned in the same workspace', () => { + const workspaceCardFeeds = { + [`cards_100_plaid.ins_19`]: { + '12345': { + cardID: 12345, + cardName: 'Plaid Credit Card 3333', + encryptedCardNumber: 'Plaid Credit Card 3333', + state: 3, + domainName: 'workspace1.exfy', + }, + }, + } as unknown as OnyxCollection; + + expect(isCardAlreadyAssigned('Plaid Credit Card 3333', workspaceCardFeeds, 100, 'plaid.ins_19')).toBe(true); + }); + + it('should detect Plaid card assigned in a different workspace with the same feed', () => { + const workspaceCardFeeds = { + [`cards_200_plaid.ins_19`]: { + '12345': { + cardID: 12345, + cardName: 'Plaid Credit Card 3333', + encryptedCardNumber: 'Plaid Credit Card 3333', + state: 3, + domainName: 'workspace2.exfy', + }, + }, + } as unknown as OnyxCollection; + + // Workspace 100 checking, card is assigned in workspace 200 — should be detected + expect(isCardAlreadyAssigned('Plaid Credit Card 3333', workspaceCardFeeds, 100, 'plaid.ins_19')).toBe(true); + }); + + it('should not match Plaid card from a different institution', () => { + const workspaceCardFeeds = { + [`cards_200_plaid.ins_99`]: { + '12345': { + cardID: 12345, + cardName: 'Plaid Credit Card 3333', + encryptedCardNumber: 'Plaid Credit Card 3333', + state: 3, + domainName: 'workspace2.exfy', + }, + }, + } as unknown as OnyxCollection; + + // Different institution feed (plaid.ins_19 vs plaid.ins_99) + expect(isCardAlreadyAssigned('Plaid Credit Card 3333', workspaceCardFeeds, 100, 'plaid.ins_19')).toBe(false); + }); + + it('should detect commercial card assigned in the same domain', () => { + const workspaceCardFeeds = { + cards_100_vcf: { + '12345': { + cardID: 12345, + cardName: 'VISA - 1234', + encryptedCardNumber: 'ENCRYPTED_ABC', + state: 3, + domainName: 'company.exfy', + }, + }, + } as unknown as OnyxCollection; + + expect(isCardAlreadyAssigned('ENCRYPTED_ABC', workspaceCardFeeds, 100, 'vcf')).toBe(true); + }); + + it('should not match commercial card with same display name in a different domain', () => { + const workspaceCardFeeds = { + cards_200_vcf: { + '12345': { + cardID: 12345, + cardName: 'VISA - 1234', + encryptedCardNumber: 'ENCRYPTED_DOMAIN_200', + state: 3, + domainName: 'other-company.exfy', + }, + }, + } as unknown as OnyxCollection; + + // Different domain, different encrypted number — should not match + expect(isCardAlreadyAssigned('ENCRYPTED_DOMAIN_100', workspaceCardFeeds, 100, 'vcf')).toBe(false); + }); + + it('should not match card pending deletion', () => { + const workspaceCardFeeds = { + [`cards_100_plaid.ins_19`]: { + '12345': { + cardID: 12345, + cardName: 'Plaid Credit Card 3333', + encryptedCardNumber: 'Plaid Credit Card 3333', + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + state: 3, + domainName: 'workspace1.exfy', + }, + }, + } as unknown as OnyxCollection; + + expect(isCardAlreadyAssigned('Plaid Credit Card 3333', workspaceCardFeeds, 100, 'plaid.ins_19')).toBe(false); + }); + + it('should detect OAuth card assigned in a different workspace', () => { + const workspaceCardFeeds = { + [`cards_200_oauth.chase.com`]: { + '12345': { + cardID: 12345, + cardName: 'CREDIT CARD...6607', + encryptedCardNumber: 'CREDIT CARD...6607', + state: 3, + domainName: 'workspace2.exfy', + }, + }, + } as unknown as OnyxCollection; + + expect(isCardAlreadyAssigned('CREDIT CARD...6607', workspaceCardFeeds, 100, 'oauth.chase.com')).toBe(true); + }); + + it('should fall back to domain-scoped check when feedName is not provided', () => { + const workspaceCardFeeds = { + cards_200_vcf: { + '12345': { + cardID: 12345, + cardName: 'VISA - 1234', + encryptedCardNumber: 'ENCRYPTED_ABC', + state: 3, + domainName: 'other.exfy', + }, + }, + } as unknown as OnyxCollection; + + // Without feedName, falls back to domain-scoped — domain 100 vs 200, should not match + expect(isCardAlreadyAssigned('ENCRYPTED_ABC', workspaceCardFeeds, 100)).toBe(false); + // Same domain should match + expect(isCardAlreadyAssigned('ENCRYPTED_ABC', workspaceCardFeeds, 200)).toBe(true); + }); + }); describe('getCardSettings', () => { const flatSettings = { paymentBankAccountID: 12345,