Skip to content

Commit 81390e7

Browse files
authored
Merge pull request Expensify#86420 from Expensify/claude-showDeactivatedCardsInWorkspace
Show admin-zeroed Expensify Cards on the workspace card page
2 parents 194f7f6 + c370a11 commit 81390e7

7 files changed

Lines changed: 165 additions & 23 deletions

src/libs/CardUtils.ts

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1047,22 +1047,28 @@ function checkIfNewFeedConnected(prevFeedsData: CombinedCardFeeds, currentFeedsD
10471047
};
10481048
}
10491049

1050-
function filterAllInactiveCards(cards: CardList | undefined) {
1050+
/**
1051+
* Filters cards by state. Closed and deactivated cards are excluded by default; suspended cards are
1052+
* only kept when frozen. When `includeDeactivated` is true (workspace card management views), admin-zeroed
1053+
* Expensify Cards are also kept — these are cards an admin set to a $0 limit, which the backend then
1054+
* transitions to deactivated/suspended. Admins still need to be able to find and manage them.
1055+
*/
1056+
function filterAllInactiveCards(cards: CardList | undefined, includeDeactivated = false) {
10511057
if (!cards) {
10521058
return {};
10531059
}
10541060

10551061
const closedStates = new Set<number>([CONST.EXPENSIFY_CARD.STATE.CLOSED, CONST.EXPENSIFY_CARD.STATE.STATE_DEACTIVATED]);
1056-
const filteredCards = filterObject(cards, (_key, card) => {
1062+
return filterObject(cards, (_key, card) => {
1063+
const isAdminZeroedExpensifyCard = isExpensifyCard(card) && isCardWithCustomZeroLimit(card);
10571064
if (card.state === CONST.EXPENSIFY_CARD.STATE.STATE_SUSPENDED) {
1058-
// Only include suspended cards that are frozen.
1059-
return !!card.nameValuePairs?.frozen;
1065+
return !!card.nameValuePairs?.frozen || (includeDeactivated && isAdminZeroedExpensifyCard);
1066+
}
1067+
if (card.state === CONST.EXPENSIFY_CARD.STATE.STATE_DEACTIVATED) {
1068+
return includeDeactivated && isAdminZeroedExpensifyCard;
10601069
}
1061-
10621070
return !closedStates.has(card.state);
10631071
});
1064-
1065-
return filteredCards;
10661072
}
10671073

10681074
function filterInactiveCards(cardsList: WorkspaceCardsList | undefined) {
@@ -1075,11 +1081,26 @@ function filterInactiveCards(cardsList: WorkspaceCardsList | undefined) {
10751081
} as WorkspaceCardsList;
10761082
}
10771083

1084+
/**
1085+
* Onyx selector for workspace Expensify Card management pages. Same as `filterInactiveCards`, but also
1086+
* keeps issued deactivated cards and zero-limit suspended cards so admins can view and edit them.
1087+
*/
1088+
function filterInactiveCardsForWorkspace(cardsList: WorkspaceCardsList | undefined) {
1089+
const {cardList, ...assignedCards} = cardsList ?? {};
1090+
const filteredAssignedCards = filterAllInactiveCards(assignedCards, true);
1091+
1092+
return {
1093+
...(cardList ? {cardList} : {}),
1094+
...filteredAssignedCards,
1095+
} as WorkspaceCardsList;
1096+
}
1097+
10781098
function getAllCardsForWorkspace(
10791099
workspaceAccountID: number,
10801100
allCardList: OnyxCollection<WorkspaceCardsList>,
10811101
cardFeeds?: CombinedCardFeeds,
10821102
expensifyCardSettings?: OnyxCollection<ExpensifyCardSettings>,
1103+
includeDeactivated = false,
10831104
): CardList {
10841105
const cards: CardList = {};
10851106
const companyCardsDomainFeeds = Object.entries(cardFeeds ?? {}).map(([feedName, feedData]) => ({domainID: feedData.domainID, feedName}));
@@ -1093,7 +1114,7 @@ function getAllCardsForWorkspace(
10931114
const isExpensifyDomainCards = expensifyCardsDomainIDs.some((domainID) => key.includes(domainID.toString()) && key.includes(CONST.EXPENSIFY_CARD.BANK));
10941115
if ((isWorkspaceAccountCards || isCompanyDomainCards || isExpensifyDomainCards) && values) {
10951116
const {cardList: assignableCards, ...assignedCards} = values ?? {};
1096-
const filteredCards = filterInactiveCards(assignedCards);
1117+
const filteredCards = filterAllInactiveCards(assignedCards, includeDeactivated);
10971118
Object.assign(cards, filteredCards);
10981119
}
10991120
}
@@ -1846,6 +1867,7 @@ export {
18461867
isPolicyIDInLinkedExpensifyCardPolicyList,
18471868
filterAllInactiveCards,
18481869
filterInactiveCards,
1870+
filterInactiveCardsForWorkspace,
18491871
getPersonalBankCardDetailsImage,
18501872
isCardPendingIssue,
18511873
isCardPendingActivate,

src/pages/workspace/expensifyCard/DynamicExpensifyCardLimitPage.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import useLocalize from '@hooks/useLocalize';
1515
import useOnyx from '@hooks/useOnyx';
1616
import useThemeStyles from '@hooks/useThemeStyles';
1717
import {updateExpensifyCardLimit} from '@libs/actions/Card';
18-
import {filterInactiveCards} from '@libs/CardUtils';
18+
import {filterInactiveCardsForWorkspace} from '@libs/CardUtils';
1919
import {convertToFrontendAmountAsString} from '@libs/CurrencyUtils';
2020
import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types';
2121
import {getFieldRequiredErrors} from '@libs/ValidationUtils';
@@ -44,7 +44,7 @@ function DynamicExpensifyCardLimitPage({route}: DynamicExpensifyCardLimitPagePro
4444

4545
const currency = useCurrencyForExpensifyCard({policyID});
4646

47-
const [cardsList] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${defaultFundID}_${CONST.EXPENSIFY_CARD.BANK}`, {selector: filterInactiveCards});
47+
const [cardsList] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${defaultFundID}_${CONST.EXPENSIFY_CARD.BANK}`, {selector: filterInactiveCardsForWorkspace});
4848
const card = cardsList?.[cardID];
4949

5050
const getPromptTextKey = useMemo((): ConfirmationWarningTranslationPaths => {

src/pages/workspace/expensifyCard/DynamicExpensifyCardLimitTypePage.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import usePolicy from '@hooks/usePolicy';
2323
import useThemeStyles from '@hooks/useThemeStyles';
2424
import {updateExpensifyCardLimitType} from '@libs/actions/Card';
2525
import {openPolicyEditCardLimitTypePage} from '@libs/actions/Policy/Policy';
26-
import {filterInactiveCards, getDefaultExpensifyCardLimitType} from '@libs/CardUtils';
26+
import {filterInactiveCardsForWorkspace, getDefaultExpensifyCardLimitType} from '@libs/CardUtils';
2727
import DateUtils from '@libs/DateUtils';
2828
import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types';
2929
import {getApprovalWorkflow} from '@libs/PolicyUtils';
@@ -48,7 +48,8 @@ function DynamicExpensifyCardLimitTypePage({route}: WorkspaceEditCardLimitTypePa
4848
const formRef = useRef<FormRef | null>(null);
4949
const policy = usePolicy(policyID);
5050
const defaultFundID = useDefaultFundID(policyID);
51-
const [cardsList] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${defaultFundID}_${CONST.EXPENSIFY_CARD.BANK}`, {selector: filterInactiveCards});
51+
const [cardsList] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${defaultFundID}_${CONST.EXPENSIFY_CARD.BANK}`, {selector: filterInactiveCardsForWorkspace});
52+
5253
const card = cardsList?.[cardID];
5354
const areApprovalsConfigured = getApprovalWorkflow(policy) !== CONST.POLICY.APPROVAL_MODE.OPTIONAL;
5455
const defaultLimitType = getDefaultExpensifyCardLimitType(policy);

src/pages/workspace/expensifyCard/DynamicExpensifyCardNamePage.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import useLocalize from '@hooks/useLocalize';
1212
import useOnyx from '@hooks/useOnyx';
1313
import useThemeStyles from '@hooks/useThemeStyles';
1414
import {updateExpensifyCardTitle} from '@libs/actions/Card';
15-
import {filterInactiveCards} from '@libs/CardUtils';
15+
import {filterInactiveCardsForWorkspace} from '@libs/CardUtils';
1616
import {addErrorMessage} from '@libs/ErrorUtils';
1717
import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types';
1818
import {getFieldRequiredErrors, isValidInputLength} from '@libs/ValidationUtils';
@@ -36,7 +36,7 @@ function DynamicExpensifyCardNamePage({route}: DynamicExpensifyCardNamePageProps
3636
const {inputCallbackRef} = useAutoFocusInput();
3737
const styles = useThemeStyles();
3838

39-
const [cardsList] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${defaultFundID}_${CONST.EXPENSIFY_CARD.BANK}`, {selector: filterInactiveCards});
39+
const [cardsList] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${defaultFundID}_${CONST.EXPENSIFY_CARD.BANK}`, {selector: filterInactiveCardsForWorkspace});
4040
const card = cardsList?.[cardID];
4141

4242
const goBack = useCallback(() => {

src/pages/workspace/expensifyCard/WorkspaceExpensifyCardDetailsPage.tsx

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ function WorkspaceExpensifyCardDetailsPage({route}: WorkspaceExpensifyCardDetail
8484
const expensifyCardSettings = useExpensifyCardFeeds(policyID);
8585
const [fundCardSettings] = useOnyx(`${ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_SETTINGS}${defaultFundID}`);
8686
const [allFeedsCards, allFeedsCardsResult] = useOnyx(ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST);
87-
const workspaceCards = getAllCardsForWorkspace(defaultFundID, allFeedsCards, cardFeeds, expensifyCardSettings);
87+
const workspaceCards = getAllCardsForWorkspace(defaultFundID, allFeedsCards, cardFeeds, expensifyCardSettings, /* includeDeactivated */ true);
8888

8989
const workspaceCard = workspaceCards?.[cardID];
9090
const card = workspaceCard ?? cardFromCardList;
@@ -96,6 +96,8 @@ function WorkspaceExpensifyCardDetailsPage({route}: WorkspaceExpensifyCardDetail
9696
const displayName = getDisplayNameOrDefault(cardholder);
9797
const translationForLimitType = getTranslationKeyForLimitType(card?.nameValuePairs?.limitType);
9898
const isAdmin = isPolicyAdmin(policy, session?.email);
99+
const isDeactivated = card?.state === CONST.EXPENSIFY_CARD.STATE.STATE_DEACTIVATED;
100+
99101
const shouldGoBack = useRef(false);
100102

101103
const fetchCardDetails = useCallback(() => {
@@ -345,12 +347,14 @@ function WorkspaceExpensifyCardDetailsPage({route}: WorkspaceExpensifyCardDetail
345347
onPress={handleFreezePress}
346348
/>
347349
)}
348-
<MenuItem
349-
icon={expensifyIcons.Trashcan}
350-
title={translate('workspace.expensifyCard.deactivate')}
351-
style={styles.mb1}
352-
onPress={() => (isOffline ? setIsOfflineModalVisible(true) : setIsDeactivateModalVisible(true))}
353-
/>
350+
{!isDeactivated && (
351+
<MenuItem
352+
icon={expensifyIcons.Trashcan}
353+
title={translate('workspace.expensifyCard.deactivate')}
354+
style={styles.mb1}
355+
onPress={() => (isOffline ? setIsOfflineModalVisible(true) : setIsDeactivateModalVisible(true))}
356+
/>
357+
)}
354358
<ConfirmModal
355359
title={translate('workspace.card.deactivateCardModal.deactivateCard')}
356360
isVisible={isDeactivateModalVisible}

src/pages/workspace/expensifyCard/WorkspaceExpensifyCardPage.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import useOnyx from '@hooks/useOnyx';
66
import usePolicy from '@hooks/usePolicy';
77
import useWorkspaceDocumentTitle from '@hooks/useWorkspaceDocumentTitle';
88
import {updateSelectedExpensifyCardFeed} from '@libs/actions/Card';
9-
import {filterInactiveCards, getCardSettings} from '@libs/CardUtils';
9+
import {filterInactiveCardsForWorkspace, getCardSettings} from '@libs/CardUtils';
1010
import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types';
1111
import type {WorkspaceSplitNavigatorParamList} from '@libs/Navigation/types';
1212
import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan';
@@ -28,7 +28,7 @@ function WorkspaceExpensifyCardPage({route}: WorkspaceExpensifyCardPageProps) {
2828

2929
const [cardSettings] = useOnyx(`${ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_SETTINGS}${defaultFundID}`);
3030
const settings = getCardSettings(cardSettings);
31-
const [cardsList] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${defaultFundID}_${CONST.EXPENSIFY_CARD.BANK}`, {selector: filterInactiveCards});
31+
const [cardsList] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${defaultFundID}_${CONST.EXPENSIFY_CARD.BANK}`, {selector: filterInactiveCardsForWorkspace});
3232

3333
const fetchExpensifyCards = useCallback(() => {
3434
updateSelectedExpensifyCardFeed(defaultFundID, policyID);

tests/unit/CardUtilsTest.ts

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
filterAllInactiveCards,
1414
filterCardsByNonExpensify,
1515
filterInactiveCards,
16+
filterInactiveCardsForWorkspace,
1617
flattenWorkspaceCardsList,
1718
formatCardExpiration,
1819
formatMaskedCardName,
@@ -3388,6 +3389,120 @@ describe('CardUtils', () => {
33883389
const result = filterAllInactiveCards(cards);
33893390
expect(Object.keys(result)).toHaveLength(0);
33903391
});
3392+
3393+
it('keeps admin-zeroed Expensify Cards (hasCustomUnapprovedExpenseLimit + limit 0) when includeDeactivated is true', () => {
3394+
const baseFields = {bank: CONST.EXPENSIFY_CARD.BANK, domainName: '', fraud: 'none', lastFourPAN: '', lastScrape: '', lastUpdated: ''};
3395+
const cards: CardList = {
3396+
active: {cardID: 1, state: CONST.EXPENSIFY_CARD.STATE.OPEN, ...baseFields, cardName: '1234', nameValuePairs: {unapprovedExpenseLimit: 1000}},
3397+
closed: {
3398+
cardID: 2,
3399+
state: CONST.EXPENSIFY_CARD.STATE.CLOSED,
3400+
...baseFields,
3401+
cardName: '5678',
3402+
nameValuePairs: {hasCustomUnapprovedExpenseLimit: true, unapprovedExpenseLimit: 0},
3403+
},
3404+
adminZeroedDeactivated: {
3405+
cardID: 3,
3406+
state: CONST.EXPENSIFY_CARD.STATE.STATE_DEACTIVATED,
3407+
...baseFields,
3408+
cardName: '9012',
3409+
nameValuePairs: {hasCustomUnapprovedExpenseLimit: true, unapprovedExpenseLimit: 0},
3410+
},
3411+
adminDeactivatedNonZero: {
3412+
cardID: 4,
3413+
state: CONST.EXPENSIFY_CARD.STATE.STATE_DEACTIVATED,
3414+
...baseFields,
3415+
cardName: '3456',
3416+
nameValuePairs: {hasCustomUnapprovedExpenseLimit: true, unapprovedExpenseLimit: 5000},
3417+
},
3418+
adminZeroedSuspended: {
3419+
cardID: 5,
3420+
state: CONST.EXPENSIFY_CARD.STATE.STATE_SUSPENDED,
3421+
...baseFields,
3422+
cardName: '',
3423+
nameValuePairs: {hasCustomUnapprovedExpenseLimit: true, unapprovedExpenseLimit: 0},
3424+
},
3425+
nonZeroSuspended: {cardID: 6, state: CONST.EXPENSIFY_CARD.STATE.STATE_SUSPENDED, ...baseFields, cardName: '', nameValuePairs: {unapprovedExpenseLimit: 5000}},
3426+
thirdPartyDeactivated: {
3427+
cardID: 7,
3428+
state: CONST.EXPENSIFY_CARD.STATE.STATE_DEACTIVATED,
3429+
...baseFields,
3430+
cardName: '7890',
3431+
bank: 'vcf',
3432+
nameValuePairs: {hasCustomUnapprovedExpenseLimit: true, unapprovedExpenseLimit: 0},
3433+
},
3434+
deactivatedNoCustomFlag: {
3435+
cardID: 8,
3436+
state: CONST.EXPENSIFY_CARD.STATE.STATE_DEACTIVATED,
3437+
...baseFields,
3438+
cardName: '0000',
3439+
nameValuePairs: {unapprovedExpenseLimit: 0},
3440+
},
3441+
} as unknown as CardList;
3442+
3443+
const ids = Object.values(filterAllInactiveCards(cards, true)).map((c) => c.cardID);
3444+
expect(ids).toEqual(expect.arrayContaining([1, 3, 5]));
3445+
expect(ids).not.toContain(2);
3446+
expect(ids).not.toContain(4);
3447+
expect(ids).not.toContain(6);
3448+
expect(ids).not.toContain(7);
3449+
expect(ids).not.toContain(8);
3450+
});
3451+
});
3452+
3453+
describe('filterInactiveCardsForWorkspace', () => {
3454+
it('keeps admin-zeroed Expensify Cards alongside active ones, drops everything else', () => {
3455+
const cardsList = {
3456+
cardList: {assignable1: 'encrypted1'},
3457+
active: {cardID: 1, state: CONST.EXPENSIFY_CARD.STATE.OPEN, bank: CONST.EXPENSIFY_CARD.BANK, cardName: '1234', nameValuePairs: {unapprovedExpenseLimit: 1000}},
3458+
closed: {cardID: 2, state: CONST.EXPENSIFY_CARD.STATE.CLOSED, bank: CONST.EXPENSIFY_CARD.BANK, cardName: '5678', nameValuePairs: {unapprovedExpenseLimit: 0}},
3459+
adminZeroedDeactivated: {
3460+
cardID: 3,
3461+
state: CONST.EXPENSIFY_CARD.STATE.STATE_DEACTIVATED,
3462+
bank: CONST.EXPENSIFY_CARD.BANK,
3463+
cardName: '9012',
3464+
nameValuePairs: {hasCustomUnapprovedExpenseLimit: true, unapprovedExpenseLimit: 0},
3465+
},
3466+
deactivatedNonZero: {
3467+
cardID: 4,
3468+
state: CONST.EXPENSIFY_CARD.STATE.STATE_DEACTIVATED,
3469+
bank: CONST.EXPENSIFY_CARD.BANK,
3470+
cardName: '3456',
3471+
nameValuePairs: {hasCustomUnapprovedExpenseLimit: true, unapprovedExpenseLimit: 1000},
3472+
},
3473+
adminZeroedSuspended: {
3474+
cardID: 5,
3475+
state: CONST.EXPENSIFY_CARD.STATE.STATE_SUSPENDED,
3476+
bank: CONST.EXPENSIFY_CARD.BANK,
3477+
cardName: '',
3478+
nameValuePairs: {hasCustomUnapprovedExpenseLimit: true, unapprovedExpenseLimit: 0},
3479+
},
3480+
nonZeroSuspended: {
3481+
cardID: 6,
3482+
state: CONST.EXPENSIFY_CARD.STATE.STATE_SUSPENDED,
3483+
bank: CONST.EXPENSIFY_CARD.BANK,
3484+
cardName: '',
3485+
nameValuePairs: {unapprovedExpenseLimit: 5000},
3486+
},
3487+
deactivatedNoCustomFlag: {
3488+
cardID: 7,
3489+
state: CONST.EXPENSIFY_CARD.STATE.STATE_DEACTIVATED,
3490+
bank: CONST.EXPENSIFY_CARD.BANK,
3491+
cardName: '7890',
3492+
nameValuePairs: {unapprovedExpenseLimit: 0},
3493+
},
3494+
} as unknown as Parameters<typeof filterInactiveCardsForWorkspace>[0];
3495+
3496+
const result = filterInactiveCardsForWorkspace(cardsList);
3497+
expect(result.active).toBeDefined();
3498+
expect(result.adminZeroedDeactivated).toBeDefined();
3499+
expect(result.adminZeroedSuspended).toBeDefined();
3500+
expect(result.closed).toBeUndefined();
3501+
expect(result.deactivatedNonZero).toBeUndefined();
3502+
expect(result.nonZeroSuspended).toBeUndefined();
3503+
expect(result.deactivatedNoCustomFlag).toBeUndefined();
3504+
expect(result.cardList).toBeDefined();
3505+
});
33913506
});
33923507

33933508
describe('UnassignedCard type through getFilteredCardList', () => {

0 commit comments

Comments
 (0)