Skip to content

Commit a0bb986

Browse files
authored
Merge pull request Expensify#77117 from allgandalf/allowAddingFeedForCollectCSV
[Internal QA] Don't count CSV uploads in Classic as a feed in ND
2 parents 99867a7 + 11f2866 commit a0bb986

6 files changed

Lines changed: 198 additions & 34 deletions

File tree

src/hooks/useIsBlockedToAddFeed.ts

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import {useEffect, useState} from 'react';
2-
import {getCompanyFeeds} from '@libs/CardUtils';
1+
import {useMemo} from 'react';
2+
import {getCompanyFeeds, isCSVFeedOrExpensifyCard} from '@libs/CardUtils';
33
import {isCollectPolicy} from '@libs/PolicyUtils';
44
import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue';
55
import useCardFeeds from './useCardFeeds';
@@ -10,6 +10,7 @@ import usePolicy from './usePolicy';
1010
*
1111
* Collect plan workspaces are limited to one company card feed. This hook checks if the workspace already has
1212
* a feed and returns whether users should be blocked from adding more feeds.
13+
* CSV uploads from Classic and Expensify Cards should not count toward this limit.
1314
*
1415
* @param policyID - The ID of the workspace/policy to check
1516
* @returns An object containing:
@@ -19,24 +20,27 @@ import usePolicy from './usePolicy';
1920
function useIsBlockedToAddFeed(policyID?: string) {
2021
const policy = usePolicy(policyID);
2122
const [cardFeeds, allFeedsResult, defaultFeed] = useCardFeeds(policyID);
22-
const companyFeeds = getCompanyFeeds(cardFeeds, true);
23+
// Include pending feeds in the count to prevent users from adding multiple feeds
24+
// Pending feeds count toward the limit because the backend checks before adding
25+
const companyFeeds = getCompanyFeeds(cardFeeds, true, false);
2326
const isCollect = isCollectPolicy(policy);
2427
const isAllFeedsResultLoading = isLoadingOnyxValue(allFeedsResult);
25-
const [prevCompanyFeedsLength, setPrevCompanyFeedsLength] = useState(0);
2628

2729
const isLoading = !cardFeeds || !!defaultFeed?.isLoading;
2830

29-
useEffect(() => {
31+
// Count feeds excluding CSV uploads from Classic and Expensify Cards
32+
// Include pending feeds in the count to enforce the limit
33+
const connectedFeedsCount = useMemo(() => {
3034
if (isLoading) {
31-
return;
35+
return 0;
3236
}
33-
const connectedFeeds = Object.entries(companyFeeds)?.length;
34-
setPrevCompanyFeedsLength(connectedFeeds);
35-
// eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps -- we don't want this effect to run again
36-
}, [isLoading]);
37+
const feeds = companyFeeds ?? {};
38+
const nonCSVFeeds = Object.keys(feeds).filter((feedKey) => !isCSVFeedOrExpensifyCard(feedKey));
39+
return nonCSVFeeds.length;
40+
}, [isLoading, companyFeeds]);
3741

3842
return {
39-
isBlockedToAddNewFeeds: isCollect && !isLoading && prevCompanyFeedsLength >= 1,
43+
isBlockedToAddNewFeeds: isCollect && !isLoading && connectedFeedsCount >= 1,
4044
isAllFeedsResultLoading: isCollect && (isLoading || isAllFeedsResultLoading),
4145
};
4246
}

src/libs/CardUtils.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -397,6 +397,21 @@ function isCustomFeed(feed: CompanyCardFeedWithNumber | undefined): boolean {
397397
return [CONST.COMPANY_CARD.FEED_BANK_NAME.MASTER_CARD, CONST.COMPANY_CARD.FEED_BANK_NAME.VISA, CONST.COMPANY_CARD.FEED_BANK_NAME.AMEX].some((value) => feed.startsWith(value));
398398
}
399399

400+
/**
401+
* Checks if a feed key represents a CSV feed or Expensify Card.
402+
* CSV feeds include feeds starting with "csv" or "ccupload", or containing "ccupload".
403+
* Expensify Cards also don't count toward feed limits.
404+
*
405+
* @param feedKey - The feed key to check
406+
* @returns true if the feed is a CSV feed or Expensify Card, false otherwise
407+
*/
408+
function isCSVFeedOrExpensifyCard(feedKey: string): boolean {
409+
const lowerFeedKey = feedKey.toLowerCase();
410+
// Exclude CSV feeds (feed types starting with "csv" or "ccupload", or containing "ccupload")
411+
// Also exclude Expensify Cards which don't count toward the limit
412+
return lowerFeedKey.startsWith('csv') || lowerFeedKey.startsWith('ccupload') || feedKey.includes(CONST.COMPANY_CARD.FEED_BANK_NAME.CSV) || feedKey === 'Expensify Card';
413+
}
414+
400415
function getOriginalCompanyFeeds(cardFeeds: OnyxEntry<CardFeeds>): CompanyFeeds {
401416
return Object.fromEntries(
402417
Object.entries(cardFeeds?.settings?.companyCards ?? {}).filter(([key, value]) => {
@@ -922,6 +937,7 @@ export {
922937
isSelectedFeedExpired,
923938
getCompanyFeeds,
924939
isCustomFeed,
940+
isCSVFeedOrExpensifyCard,
925941
getBankCardDetailsImage,
926942
getSelectedFeed,
927943
getPlaidCountry,

src/pages/workspace/companyCards/BankConnection/index.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,13 +84,16 @@ function BankConnection({policyID: policyIDFromProps, feed, route}: BankConnecti
8484
}, [url]);
8585

8686
useEffect(() => {
87-
if (!policyID || !isBlockedToAddNewFeeds) {
87+
// Only redirect if blocked AND we're not in the middle of adding a feed (i.e., no feed selected and not a new feed)
88+
// If feed is defined, user is assigning cards to an existing feed, so don't redirect
89+
// If isNewFeedConnected is true, user just successfully added a feed, so don't redirect
90+
if (!policyID || !isBlockedToAddNewFeeds || feed || isNewFeedConnected) {
8891
return;
8992
}
9093
Navigation.navigate(ROUTES.WORKSPACE_UPGRADE.getRoute(policyID, CONST.UPGRADE_FEATURE_INTRO_MAPPING.companyCards.alias, ROUTES.WORKSPACE_COMPANY_CARDS.getRoute(policyID)), {
9194
forceReplace: true,
9295
});
93-
}, [isBlockedToAddNewFeeds, policyID]);
96+
}, [isBlockedToAddNewFeeds, policyID, feed, isNewFeedConnected]);
9497

9598
const handleBackButtonPress = () => {
9699
customWindow?.close();

src/pages/workspace/companyCards/addNew/AddNewCardPage.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,13 +47,17 @@ function AddNewCardPage({policy}: WithPolicyAndFullscreenLoadingProps) {
4747
const isAddCardFeedLoading = isLoadingOnyxValue(addNewCardFeedMetadata);
4848

4949
useEffect(() => {
50-
if (!policyID || !isBlockedToAddNewFeeds) {
50+
// Only redirect if blocked AND user is trying to start a new flow (currentStep is SELECT_BANK or undefined/initial state)
51+
// Don't redirect if user is already in the middle of adding a feed (other steps)
52+
// Don't redirect if user just successfully added a feed (isNewFeedConnected would be true in BankConnection)
53+
const isInitialStep = !currentStep || currentStep === CONST.COMPANY_CARDS.STEP.SELECT_BANK;
54+
if (!policyID || !isBlockedToAddNewFeeds || !isInitialStep) {
5155
return;
5256
}
5357
Navigation.navigate(ROUTES.WORKSPACE_UPGRADE.getRoute(policyID, CONST.UPGRADE_FEATURE_INTRO_MAPPING.companyCards.alias, ROUTES.WORKSPACE_COMPANY_CARDS.getRoute(policyID)), {
5458
forceReplace: true,
5559
});
56-
}, [isBlockedToAddNewFeeds, policyID]);
60+
}, [isBlockedToAddNewFeeds, policyID, currentStep]);
5761

5862
useEffect(() => {
5963
return () => {

tests/unit/CardUtilsTest.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import {
3232
getSelectedFeed,
3333
getYearFromExpirationDateString,
3434
hasIssuedExpensifyCard,
35+
isCSVFeedOrExpensifyCard,
3536
isCustomFeed as isCustomFeedCardUtils,
3637
isExpensifyCard,
3738
isExpensifyCardFullySetUp,
@@ -499,6 +500,63 @@ describe('CardUtils', () => {
499500
});
500501
});
501502

503+
describe('isCSVFeedOrExpensifyCard', () => {
504+
it('Should return true for feed key starting with "csv" (lowercase)', () => {
505+
expect(isCSVFeedOrExpensifyCard('csv123')).toBe(true);
506+
});
507+
508+
it('Should return true for feed key starting with "CSV" (uppercase)', () => {
509+
expect(isCSVFeedOrExpensifyCard('CSV123')).toBe(true);
510+
});
511+
512+
it('Should return true for feed key starting with "Csv" (mixed case)', () => {
513+
expect(isCSVFeedOrExpensifyCard('Csv123')).toBe(true);
514+
});
515+
516+
it('Should return true for feed key starting with "ccupload" (lowercase)', () => {
517+
expect(isCSVFeedOrExpensifyCard('ccupload123')).toBe(true);
518+
});
519+
520+
it('Should return true for feed key starting with "CCUPLOAD" (uppercase)', () => {
521+
expect(isCSVFeedOrExpensifyCard('CCUPLOAD123')).toBe(true);
522+
});
523+
524+
it('Should return true for feed key starting with "Ccupload" (mixed case)', () => {
525+
expect(isCSVFeedOrExpensifyCard('Ccupload123')).toBe(true);
526+
});
527+
528+
it('Should return true for feed key containing "ccupload"', () => {
529+
expect(isCSVFeedOrExpensifyCard('prefix-ccupload-suffix')).toBe(true);
530+
});
531+
532+
it('Should return true for feed key containing CONST.COMPANY_CARD.FEED_BANK_NAME.CSV', () => {
533+
expect(isCSVFeedOrExpensifyCard(`prefix-${CONST.COMPANY_CARD.FEED_BANK_NAME.CSV}-suffix`)).toBe(true);
534+
});
535+
536+
it('Should return true for "Expensify Card" feed key', () => {
537+
expect(isCSVFeedOrExpensifyCard('Expensify Card')).toBe(true);
538+
});
539+
540+
it('Should return false for regular feed keys', () => {
541+
expect(isCSVFeedOrExpensifyCard(CONST.COMPANY_CARD.FEED_BANK_NAME.VISA)).toBe(false);
542+
expect(isCSVFeedOrExpensifyCard(CONST.COMPANY_CARD.FEED_BANK_NAME.MASTER_CARD)).toBe(false);
543+
expect(isCSVFeedOrExpensifyCard(CONST.COMPANY_CARD.FEED_BANK_NAME.AMEX)).toBe(false);
544+
expect(isCSVFeedOrExpensifyCard(CONST.COMPANY_CARD.FEED_BANK_NAME.CHASE)).toBe(false);
545+
});
546+
547+
it('Should return false for feed keys that contain "csv" but do not start with it', () => {
548+
expect(isCSVFeedOrExpensifyCard('vcf-csv-feed')).toBe(false);
549+
});
550+
551+
it('Should return true for feed keys that contain "ccupload" anywhere (not just at start)', () => {
552+
expect(isCSVFeedOrExpensifyCard('prefix-ccupload-suffix')).toBe(true);
553+
});
554+
555+
it('Should return false for empty string', () => {
556+
expect(isCSVFeedOrExpensifyCard('')).toBe(false);
557+
});
558+
});
559+
502560
describe('getOriginalCompanyFeeds', () => {
503561
it('Should return both custom and direct feeds with filtered out "Expensify Card" bank', () => {
504562
const companyFeeds = getOriginalCompanyFeeds(cardFeedsCollection.FAKE_ID_1);

tests/unit/hooks/useIsBlockedToAddFeed.test.ts

Lines changed: 98 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,6 @@ import createRandomPolicy from '../../utils/collections/policies';
88

99
const mockPolicyID = '123456';
1010

11-
const delay = (ms: number) =>
12-
new Promise((resolve) => {
13-
setTimeout(resolve, ms);
14-
});
15-
1611
const mockPolicy = {...createRandomPolicy(Number(mockPolicyID), CONST.POLICY.TYPE.TEAM, 'TestPolicy'), policyID: mockPolicyID, workspaceAccountID: Number(mockPolicyID)};
1712

1813
const mockCardFeeds = {
@@ -41,8 +36,14 @@ jest.mock('@hooks/useCardFeeds', () => ({
4136
}));
4237
describe('useIsBlockedToAddFeed', () => {
4338
beforeEach(async () => {
39+
await Onyx.clear();
4440
await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${mockPolicy?.policyID}`, mockPolicy);
4541
});
42+
43+
afterEach(async () => {
44+
await Onyx.clear();
45+
jest.clearAllMocks();
46+
});
4647
it('should return true if collect policy and feed already exists', () => {
4748
(useCardFeeds as jest.Mock).mockReturnValue([mockCardFeeds, {status: 'loaded'}]);
4849
const {result} = renderHook(() => useIsBlockedToAddFeed(mockPolicyID));
@@ -56,26 +57,21 @@ describe('useIsBlockedToAddFeed', () => {
5657
expect(result?.current.isBlockedToAddNewFeeds).toBe(false);
5758
});
5859

59-
it('should return isBlockedToAddNewFeeds as false if collect policy and new feed added', async () => {
60-
(useCardFeeds as jest.Mock).mockReturnValue([{}, {status: 'loaded'}]);
61-
const {result, rerender} = renderHook(() => useIsBlockedToAddFeed(mockPolicyID));
62-
expect(result.current.isBlockedToAddNewFeeds).toBe(false);
63-
// Set initial empty state and wait for new connection to be established
64-
await delay(2000);
60+
it('should return isBlockedToAddNewFeeds as false if collect policy and CSV feed exists (allows adding another feed)', async () => {
6561
(useCardFeeds as jest.Mock).mockReturnValue([
6662
{
67-
ins: {
68-
feed: 'ins',
69-
customFeedName: 'Regions Bank cards',
70-
accountList: ['Plaid Checking 0000', 'Plaid Credit Card 3333'],
63+
// eslint-disable-next-line @typescript-eslint/naming-convention
64+
'csv#123456': {
65+
feed: 'csv#123456',
66+
customFeedName: 'CSV Upload',
67+
accountList: ['Card 0000'],
7168
},
7269
},
7370
{status: 'loaded'},
7471
]);
75-
// Wait to set state happened
76-
await delay(2000);
72+
const {result} = renderHook(() => useIsBlockedToAddFeed(mockPolicyID));
7773

78-
rerender(mockPolicyID);
74+
// CSV feeds don't count toward the limit, so user can add another feed
7975
expect(result.current.isBlockedToAddNewFeeds).toBe(false);
8076
});
8177

@@ -125,7 +121,90 @@ describe('useIsBlockedToAddFeed', () => {
125121
expect(result.current.isBlockedToAddNewFeeds).toBe(false);
126122

127123
(useCardFeeds as jest.Mock).mockReturnValue([mockCardFeeds, {status: 'loaded'}, {isLoading: false}]);
128-
rerender({policyID: mockPolicyID});
124+
rerender(mockPolicyID);
125+
expect(result.current.isBlockedToAddNewFeeds).toBe(true);
126+
});
127+
128+
it('should return true if collect policy and pending feed exists (pending feeds count toward limit)', () => {
129+
(useCardFeeds as jest.Mock).mockReturnValue([
130+
{
131+
// eslint-disable-next-line @typescript-eslint/naming-convention
132+
'plaid.ins_19#123456': {
133+
feed: 'plaid.ins_19',
134+
domainID: 123456,
135+
pending: true,
136+
liabilityType: 'corporate',
137+
},
138+
},
139+
{status: 'loaded'},
140+
]);
141+
const {result} = renderHook(() => useIsBlockedToAddFeed(mockPolicyID));
142+
143+
// Pending feeds count toward the limit, so user should be blocked from adding another feed
144+
expect(result.current.isBlockedToAddNewFeeds).toBe(true);
145+
});
146+
147+
it('should return false if collect policy and pending CSV feed exists (pending CSV feeds do not count toward limit)', () => {
148+
(useCardFeeds as jest.Mock).mockReturnValue([
149+
{
150+
// eslint-disable-next-line @typescript-eslint/naming-convention
151+
'csv#123456': {
152+
feed: 'csv#123456',
153+
customFeedName: 'CSV Upload',
154+
accountList: ['Card 0000'],
155+
pending: true,
156+
},
157+
},
158+
{status: 'loaded'},
159+
]);
160+
const {result} = renderHook(() => useIsBlockedToAddFeed(mockPolicyID));
161+
162+
// Pending CSV feeds don't count toward the limit, so user can add another feed
163+
expect(result.current.isBlockedToAddNewFeeds).toBe(false);
164+
});
165+
166+
it('should return true if collect policy has both regular feed and pending feed', () => {
167+
(useCardFeeds as jest.Mock).mockReturnValue([
168+
{
169+
// eslint-disable-next-line @typescript-eslint/naming-convention
170+
'plaid.ins_19#123456': {
171+
feed: 'plaid.ins_19',
172+
domainID: 123456,
173+
pending: false,
174+
liabilityType: 'corporate',
175+
},
176+
// eslint-disable-next-line @typescript-eslint/naming-convention
177+
'oauth.chase.com#123456': {
178+
feed: 'oauth.chase.com',
179+
domainID: 123456,
180+
pending: true,
181+
liabilityType: 'corporate',
182+
},
183+
},
184+
{status: 'loaded'},
185+
]);
186+
const {result} = renderHook(() => useIsBlockedToAddFeed(mockPolicyID));
187+
188+
// Both regular and pending feeds count, so user should be blocked
189+
expect(result.current.isBlockedToAddNewFeeds).toBe(true);
190+
});
191+
192+
it('should return true if collect policy has only pending feed (no regular feeds)', () => {
193+
(useCardFeeds as jest.Mock).mockReturnValue([
194+
{
195+
// eslint-disable-next-line @typescript-eslint/naming-convention
196+
'oauth.chase.com#123456': {
197+
feed: 'oauth.chase.com',
198+
domainID: 123456,
199+
pending: true,
200+
liabilityType: 'corporate',
201+
},
202+
},
203+
{status: 'loaded'},
204+
]);
205+
const {result} = renderHook(() => useIsBlockedToAddFeed(mockPolicyID));
206+
207+
// Pending feeds count toward the limit, so even with only a pending feed, user should be blocked
129208
expect(result.current.isBlockedToAddNewFeeds).toBe(true);
130209
});
131210
});

0 commit comments

Comments
 (0)