Skip to content
Open
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
23 changes: 22 additions & 1 deletion src/hooks/useCardFeeds.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type {OnyxCollection, ResultMetadata} from 'react-native-onyx';
import {getCombinedCardFeedsFromAllFeeds, getWorkspaceCardFeedsStatus} from '@libs/CardFeedUtils';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type {CardFeeds, CardFeedsStatusByDomainID, CombinedCardFeed, CombinedCardFeeds, CompanyCardFeedWithDomainID} from '@src/types/onyx';
import useFeedKeysWithAssignedCards from './useFeedKeysWithAssignedCards';
Expand All @@ -26,13 +27,33 @@ const useCardFeeds = (policyID: string | undefined): [CombinedCardFeeds | undefi
const feedKeysWithCards = useFeedKeysWithAssignedCards();
const defaultFeed = allFeeds?.[`${ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER}${workspaceAccountID}`];

// When workspaceAccountID is 0, find the domain ID by looking for a feed linked to this policy.
// This handles domain-based card accounts where no workspace account exists yet.
let effectiveWorkspaceAccountID = workspaceAccountID;
if (workspaceAccountID === CONST.DEFAULT_NUMBER_ID && policyID && allFeeds) {
const linkedDomainEntry = Object.entries(allFeeds).find(([onyxKey, feeds]) => {
const domainID = Number(onyxKey.split('_').at(-1));
if (!domainID) {
return false;
}
const companyCards = feeds?.settings?.companyCards;
if (!companyCards) {
return false;
}
return Object.values(companyCards).some((feedSettings) => feedSettings?.preferredPolicy === policyID || (feedSettings?.linkedPolicyIDs ?? []).includes(policyID));
});
if (linkedDomainEntry) {
effectiveWorkspaceAccountID = Number(linkedDomainEntry[0].split('_').at(-1));
}
}

let workspaceFeeds: CombinedCardFeeds | undefined;
if (policyID && allFeeds) {
const shouldIncludeFeedPredicate = (combinedCardFeed: CombinedCardFeed) => {
if (combinedCardFeed?.linkedPolicyIDs) {
return combinedCardFeed.linkedPolicyIDs.includes(policyID);
}
return combinedCardFeed.preferredPolicy ? combinedCardFeed.preferredPolicy === policyID : combinedCardFeed.domainID === workspaceAccountID;
return combinedCardFeed.preferredPolicy ? combinedCardFeed.preferredPolicy === policyID : combinedCardFeed.domainID === effectiveWorkspaceAccountID;
};
workspaceFeeds = getCombinedCardFeedsFromAllFeeds(allFeeds, shouldIncludeFeedPredicate, feedKeysWithCards);
}
Expand Down
83 changes: 47 additions & 36 deletions src/libs/actions/CompanyCards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,9 +250,11 @@ function addNewCompanyCardsFeed(
const parameters: RequestFeedSetupParams = {
policyID,
feedType,
feedDetails: Object.entries(feedDetails)
.map(([key, value]) => `${key}: ${value}`)
.join(', '),
feedDetails: feedDetails
? Object.entries(feedDetails)
.map(([key, value]) => `${key}: ${value}`)
.join(', ')
: '',
statementPeriodEnd,
statementPeriodEndDay,
};
Expand Down Expand Up @@ -910,39 +912,48 @@ function clearCompanyCardErrorField(domainOrWorkspaceAccountID: number, cardID:
}

function openPolicyCompanyCardsPage(policyID: string, domainOrWorkspaceAccountID: number, emailList: string[], translate: LocaleContextProps['translate']) {
const optimisticData: Array<OnyxUpdate<typeof ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER>> = [
{
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER}${domainOrWorkspaceAccountID}`,
value: {
isLoading: true,
errors: null,
},
},
];

const successData: Array<OnyxUpdate<typeof ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER>> = [
{
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER}${domainOrWorkspaceAccountID}`,
value: {
isLoading: false,
},
},
];

const failureData: Array<OnyxUpdate<typeof ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER>> = [
{
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER}${domainOrWorkspaceAccountID}`,
value: {
isLoading: false,
errors: {
[CONST.COMPANY_CARDS.WORKSPACE_FEEDS_LOAD_ERROR]: translate('workspace.companyCards.error.workspaceFeedsCouldNotBeLoadedMessage'),
},
},
},
];
// Skip loading state writes when domainOrWorkspaceAccountID is 0 since Onyx discards writes to collection keys with member ID '0'.
const onyxLoadingStateUpdates = domainOrWorkspaceAccountID !== CONST.DEFAULT_NUMBER_ID;

const optimisticData: Array<OnyxUpdate<typeof ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER>> = onyxLoadingStateUpdates
? [
{
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER}${domainOrWorkspaceAccountID}`,
value: {
isLoading: true,
errors: null,
},
},
]
: [];

const successData: Array<OnyxUpdate<typeof ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER>> = onyxLoadingStateUpdates
? [
{
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER}${domainOrWorkspaceAccountID}`,
value: {
isLoading: false,
},
},
]
: [];

const failureData: Array<OnyxUpdate<typeof ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER>> = onyxLoadingStateUpdates
? [
{
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER}${domainOrWorkspaceAccountID}`,
value: {
isLoading: false,
errors: {
[CONST.COMPANY_CARDS.WORKSPACE_FEEDS_LOAD_ERROR]: translate('workspace.companyCards.error.workspaceFeedsCouldNotBeLoadedMessage'),
},
},
},
]
: [];

const params: OpenPolicyExpensifyCardsPageParams = {
policyID,
Expand Down
11 changes: 4 additions & 7 deletions src/pages/workspace/companyCards/WorkspaceCompanyCardsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,6 @@ function WorkspaceCompanyCardsPage({route}: WorkspaceCompanyCardsPageProps) {
}, [policy?.employeeList]);

const loadPolicyCompanyCardsPage = useCallback(() => {
// Skip the API call when workspaceAccountID is 0 -- Onyx discards writes to collection keys with member ID '0'.
if (domainOrWorkspaceAccountID === CONST.DEFAULT_NUMBER_ID) {
return;
}

const emailList = Object.keys(getMemberAccountIDsForWorkspace(employeeListRef.current));
openPolicyCompanyCardsPage(policyID, domainOrWorkspaceAccountID, emailList, translate);
}, [domainOrWorkspaceAccountID, policyID, translate]);
Expand All @@ -68,13 +63,15 @@ function WorkspaceCompanyCardsPage({route}: WorkspaceCompanyCardsPageProps) {

const isLoading = !isOffline && (!allCardFeeds || (isFeedAdded && isLoadingOnyxValue(cardListMetadata)));

const hasFeedsLoaded = !!allCardFeeds && Object.keys(allCardFeeds).length > 0;

useEffect(() => {
if (isOffline) {
if (isOffline || hasFeedsLoaded) {
return;
}

loadPolicyCompanyCardsPage();
}, [loadPolicyCompanyCardsPage, isOffline]);
}, [loadPolicyCompanyCardsPage, isOffline, hasFeedsLoaded]);

const loadPolicyCompanyCardsFeed = useCallback(() => {
if (isLoading || !bankName || isFeedPending || isOffline) {
Expand Down
112 changes: 112 additions & 0 deletions tests/unit/hooks/useCardFeeds.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/* eslint-disable @typescript-eslint/naming-convention */
import {renderHook, waitFor} from '@testing-library/react-native';
import Onyx from 'react-native-onyx';
import useCardFeeds from '@hooks/useCardFeeds';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import waitForBatchedUpdates from '../../utils/waitForBatchedUpdates';

const policyID = 'TEST_POLICY_123';
const domainID = 12345678;

// Real feed names that are already in the spell-check dictionary
const oldStyleFeed = CONST.COMPANY_CARD.FEED_BANK_NAME.AMEX_FILE_DOWNLOAD;
const oauthFeed = CONST.COMPANY_CARD.FEED_BANK_NAME.AMEX_DIRECT;

describe('useCardFeeds', () => {
beforeAll(() => {
Onyx.init({keys: ONYXKEYS});
});

beforeEach(async () => {
await Onyx.clear();
await waitForBatchedUpdates();
});

describe('effectiveWorkspaceAccountID fallback for domain-based card accounts', () => {
it('returns feeds from the linked domain when workspaceAccountID is 0 and a feed has preferredPolicy matching the policy', async () => {
await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {workspaceAccountID: 0});
await Onyx.merge(`${ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER}${domainID}`, {
settings: {
companyCards: {
// Old-style feed linking the domain to this policy — no cards, will be filtered by the gray zone rule
[oldStyleFeed]: {preferredPolicy: policyID, liabilityType: 'corporate'},
// Active OAuth feed with no preferredPolicy — should appear because domainID matches effectiveWorkspaceAccountID
[oauthFeed]: {preferredPolicy: '', liabilityType: 'corporate'},
},
oAuthAccountDetails: {
[oauthFeed]: {credentials: 'xxxx', expiration: 9999999999, accountList: ['Card 1']},
},
},
});
await Onyx.merge(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${domainID}_${oauthFeed}`, {
'123': {cardID: 123, cardName: 'Card 1'},
});
await waitForBatchedUpdates();

const {result} = renderHook(() => useCardFeeds(policyID));
await waitForBatchedUpdates();

await waitFor(() => {
const [workspaceFeeds] = result.current;
const feedKeys = Object.keys(workspaceFeeds ?? {});
expect(feedKeys.some((key) => key.includes(oauthFeed))).toBe(true);
expect(Object.values(workspaceFeeds ?? {}).every((feed) => feed.domainID === domainID)).toBe(true);
});
});

it('returns no feeds when workspaceAccountID is 0 and no domain has a feed linked to the policy', async () => {
await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {workspaceAccountID: 0});
await Onyx.merge(`${ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER}${domainID}`, {
settings: {
companyCards: {
[oauthFeed]: {preferredPolicy: '', liabilityType: 'corporate'},
},
oAuthAccountDetails: {
[oauthFeed]: {credentials: 'xxxx', expiration: 9999999999, accountList: ['Card 1']},
},
},
});
await Onyx.merge(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${domainID}_${oauthFeed}`, {
'123': {cardID: 123, cardName: 'Card 1'},
});
await waitForBatchedUpdates();

const {result} = renderHook(() => useCardFeeds(policyID));
await waitForBatchedUpdates();

await waitFor(() => {
const [workspaceFeeds] = result.current;
expect(Object.keys(workspaceFeeds ?? {}).length).toBe(0);
});
});

it('does not use the fallback when workspaceAccountID is non-zero', async () => {
const workspaceAccountID = 99999999;
await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {workspaceAccountID});
await Onyx.merge(`${ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER}${domainID}`, {
settings: {
companyCards: {
[oauthFeed]: {preferredPolicy: '', liabilityType: 'corporate'},
},
oAuthAccountDetails: {
[oauthFeed]: {credentials: 'xxxx', expiration: 9999999999, accountList: ['Card 1']},
},
},
});
await Onyx.merge(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${domainID}_${oauthFeed}`, {
'123': {cardID: 123, cardName: 'Card 1'},
});
await waitForBatchedUpdates();

const {result} = renderHook(() => useCardFeeds(policyID));
await waitForBatchedUpdates();

await waitFor(() => {
const [workspaceFeeds] = result.current;
const feedKeys = Object.keys(workspaceFeeds ?? {});
expect(feedKeys.some((key) => key.includes(oauthFeed))).toBe(false);
});
});
});
});
Loading