Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 24 additions & 5 deletions src/libs/CardUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1221,23 +1221,42 @@ function splitMaskedCardNumber(cardNumber: string | undefined, maskChar: string
};
}

function isCardAlreadyAssigned(cardNumberToCheck: string, workspaceCardFeeds: OnyxCollection<WorkspaceCardsList>, domainOrWorkspaceAccountID: number): boolean {
function isCardAlreadyAssigned(cardNumberToCheck: string, workspaceCardFeeds: OnyxCollection<WorkspaceCardsList>, 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;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
137 changes: 137 additions & 0 deletions tests/unit/CardUtilsTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import {
getYearFromExpirationDateString,
hasIssuedExpensifyCard,
hasOnlyOneCardToAssign,
isCardAlreadyAssigned,
isCardFrozen,
isCSVFeedOrExpensifyCard,
isCustomFeed as isCustomFeedCardUtils,
Expand Down Expand Up @@ -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<WorkspaceCardsList>;

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<WorkspaceCardsList>;

// 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<WorkspaceCardsList>;

// 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<WorkspaceCardsList>;

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<WorkspaceCardsList>;

// 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<WorkspaceCardsList>;

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<WorkspaceCardsList>;

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<WorkspaceCardsList>;

// 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,
Expand Down
Loading