From e071960feb9906aacc463698199ab6d58aa8d181 Mon Sep 17 00:00:00 2001 From: paulnjs Date: Wed, 25 Feb 2026 17:41:09 +0700 Subject: [PATCH 1/2] fix: Can assign the same card direct feed for 2 workspaces --- src/libs/CardUtils.ts | 29 +++- .../assignCard/ConfirmationStep.tsx | 2 +- tests/unit/CardUtilsTest.ts | 152 ++++++++++++++---- 3 files changed, 149 insertions(+), 34 deletions(-) diff --git a/src/libs/CardUtils.ts b/src/libs/CardUtils.ts index 725a6324f32d..b1688a5f4320 100644 --- a/src/libs/CardUtils.ts +++ b/src/libs/CardUtils.ts @@ -1143,23 +1143,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 0bde18264c73..7eb622227025 100644 --- a/tests/unit/CardUtilsTest.ts +++ b/tests/unit/CardUtilsTest.ts @@ -43,6 +43,7 @@ import { getYearFromExpirationDateString, hasIssuedExpensifyCard, hasOnlyOneCardToAssign, + isCardAlreadyAssigned, isCardFrozen, isCSVFeedOrExpensifyCard, isCustomFeed as isCustomFeedCardUtils, @@ -3120,45 +3121,140 @@ describe('CardUtils', () => { }); }); - describe('getBrokenConnectionUrlToFixPersonalCard', () => { - const environmentURL = 'https://dev.new.expensify.com'; + 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; - it('Should return undefined when cards is undefined', () => { - expect(getBrokenConnectionUrlToFixPersonalCard(undefined as unknown as Record, environmentURL)).toBeUndefined(); + expect(isCardAlreadyAssigned('Plaid Credit Card 3333', workspaceCardFeeds, 100, 'plaid.ins_19')).toBe(true); }); - it('Should return undefined when cards is null', () => { - expect(getBrokenConnectionUrlToFixPersonalCard(null as unknown as Record, environmentURL)).toBeUndefined(); + 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 return wallet URL when cards is empty object', () => { - const result = getBrokenConnectionUrlToFixPersonalCard({}, environmentURL); - expect(result).toBe(`${environmentURL}/settings/wallet`); + 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 return personal card details URL when there is exactly one card', () => { - const cards: Record = { - '1': {cardID: 12345} as Card, - }; - const result = getBrokenConnectionUrlToFixPersonalCard(cards, environmentURL); - expect(result).toBe(`${environmentURL}/settings/wallet/personal-card/12345`); + 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 return wallet URL when there are multiple cards', () => { - const cards: Record = { - '1': {cardID: 111} as Card, - '2': {cardID: 222} as Card, - }; - const result = getBrokenConnectionUrlToFixPersonalCard(cards, environmentURL); - expect(result).toBe(`${environmentURL}/settings/wallet`); + 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 use first card cardID for single-card URL', () => { - const cards: Record = { - cardKey: {cardID: 99999} as Card, - }; - const result = getBrokenConnectionUrlToFixPersonalCard(cards, environmentURL); - expect(result).toBe(`${environmentURL}/settings/wallet/personal-card/99999`); + 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); }); }); }); From c7027b039daa05baae780afcda57dbc5806c3684 Mon Sep 17 00:00:00 2001 From: paulnjs Date: Wed, 25 Feb 2026 17:53:35 +0700 Subject: [PATCH 2/2] update test --- tests/unit/CardUtilsTest.ts | 42 +++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/tests/unit/CardUtilsTest.ts b/tests/unit/CardUtilsTest.ts index 7eb622227025..43004f5e2704 100644 --- a/tests/unit/CardUtilsTest.ts +++ b/tests/unit/CardUtilsTest.ts @@ -3121,6 +3121,48 @@ describe('CardUtils', () => { }); }); + describe('getBrokenConnectionUrlToFixPersonalCard', () => { + const environmentURL = 'https://dev.new.expensify.com'; + + it('Should return undefined when cards is undefined', () => { + expect(getBrokenConnectionUrlToFixPersonalCard(undefined as unknown as Record, environmentURL)).toBeUndefined(); + }); + + it('Should return undefined when cards is null', () => { + expect(getBrokenConnectionUrlToFixPersonalCard(null as unknown as Record, environmentURL)).toBeUndefined(); + }); + + it('Should return wallet URL when cards is empty object', () => { + const result = getBrokenConnectionUrlToFixPersonalCard({}, environmentURL); + expect(result).toBe(`${environmentURL}/settings/wallet`); + }); + + it('Should return personal card details URL when there is exactly one card', () => { + const cards: Record = { + '1': {cardID: 12345} as Card, + }; + const result = getBrokenConnectionUrlToFixPersonalCard(cards, environmentURL); + expect(result).toBe(`${environmentURL}/settings/wallet/personal-card/12345`); + }); + + it('Should return wallet URL when there are multiple cards', () => { + const cards: Record = { + '1': {cardID: 111} as Card, + '2': {cardID: 222} as Card, + }; + const result = getBrokenConnectionUrlToFixPersonalCard(cards, environmentURL); + expect(result).toBe(`${environmentURL}/settings/wallet`); + }); + + it('Should use first card cardID for single-card URL', () => { + const cards: Record = { + cardKey: {cardID: 99999} as Card, + }; + const result = getBrokenConnectionUrlToFixPersonalCard(cards, environmentURL); + expect(result).toBe(`${environmentURL}/settings/wallet/personal-card/99999`); + }); + }); + describe('isCardAlreadyAssigned', () => { it('should detect Plaid card assigned in the same workspace', () => { const workspaceCardFeeds = {