diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 50a768955b25..7f4157a5a819 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -135,7 +135,10 @@ const ROUTES = { SETTINGS_TIMEZONE_SELECT: 'settings/profile/timezone/select', SETTINGS_PRONOUNS: 'settings/profile/pronouns', SETTINGS_PREFERENCES: 'settings/preferences', - SETTINGS_SUBSCRIPTION: {route: 'settings/subscription', getRoute: (backTo?: string) => getUrlWithBackToParam('settings/subscription', backTo)}, + SETTINGS_SUBSCRIPTION: { + route: 'settings/subscription', + getRoute: (backTo?: string) => getUrlWithBackToParam('settings/subscription', backTo), + }, SETTINGS_SUBSCRIPTION_SIZE: { route: 'settings/subscription/subscription-size', getRoute: (canChangeSize: 0 | 1) => `settings/subscription/subscription-size?canChangeSize=${canChangeSize as number}` as const, @@ -333,7 +336,10 @@ const ROUTES = { route: 'r/:reportID/edit/policyField/:policyID/:fieldID', getRoute: (reportID: string | undefined, policyID: string | undefined, fieldID: string, backTo?: string) => { if (!policyID || !reportID) { - Log.warn('Invalid policyID or reportID is used to build the EDIT_REPORT_FIELD_REQUEST route', {policyID, reportID}); + Log.warn('Invalid policyID or reportID is used to build the EDIT_REPORT_FIELD_REQUEST route', { + policyID, + reportID, + }); } return getUrlWithBackToParam(`r/${reportID}/edit/policyField/${policyID}/${encodeURIComponent(fieldID)}` as const, backTo); }, @@ -1110,7 +1116,12 @@ const ROUTES = { }, WORKSPACE_ACCOUNTING_QUICKBOOKS_ONLINE_ADVANCED: { route: 'settings/workspaces/:policyID/accounting/quickbooks-online/advanced', - getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/quickbooks-online/advanced` as const, + getRoute: (policyID: string | undefined) => { + if (!policyID) { + Log.warn('Invalid policyID is used to build the WORKSPACE_ACCOUNTING_QUICKBOOKS_ONLINE_ADVANCED route'); + } + return `settings/workspaces/${policyID}/accounting/quickbooks-online/advanced` as const; + }, }, WORKSPACE_ACCOUNTING_QUICKBOOKS_ONLINE_ACCOUNT_SELECTOR: { route: 'settings/workspaces/:policyID/accounting/quickbooks-online/account-selector', @@ -1127,8 +1138,12 @@ const ROUTES = { }, WORKSPACE_ACCOUNTING_RECONCILIATION_ACCOUNT_SETTINGS: { route: 'settings/workspaces/:policyID/accounting/:connection/card-reconciliation/account', - getRoute: (policyID: string, connection?: ValueOf) => - `settings/workspaces/${policyID}/accounting/${connection as string}/card-reconciliation/account` as const, + getRoute: (policyID: string | undefined, connection?: ValueOf) => { + if (!policyID) { + Log.warn('Invalid policyID is used to build the WORKSPACE_ACCOUNTING_RECONCILIATION_ACCOUNT_SETTINGS route'); + } + return `settings/workspaces/${policyID}/accounting/${connection as string}/card-reconciliation/account` as const; + }, }, WORKSPACE_ACCOUNTING_MULTI_CONNECTION_SELECTOR: { route: 'settings/workspaces/:policyID/accounting/:connection/connection-selector', @@ -1767,7 +1782,12 @@ const ROUTES = { }, POLICY_ACCOUNTING_XERO_ADVANCED: { route: 'settings/workspaces/:policyID/accounting/xero/advanced', - getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/xero/advanced` as const, + getRoute: (policyID: string | undefined) => { + if (!policyID) { + Log.warn('Invalid policyID is used to build the POLICY_ACCOUNTING_XERO_ADVANCED route'); + } + return `settings/workspaces/${policyID}/accounting/xero/advanced` as const; + }, }, POLICY_ACCOUNTING_XERO_BILL_STATUS_SELECTOR: { route: 'settings/workspaces/:policyID/accounting/xero/export/purchase-bill-status-selector', @@ -2128,7 +2148,12 @@ const ROUTES = { }, POLICY_ACCOUNTING_SAGE_INTACCT_ADVANCED: { route: 'settings/workspaces/:policyID/accounting/sage-intacct/advanced', - getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/sage-intacct/advanced` as const, + getRoute: (policyID: string | undefined) => { + if (!policyID) { + Log.warn('Invalid policyID is used to build the POLICY_ACCOUNTING_SAGE_INTACCT_ADVANCED route'); + } + return `settings/workspaces/${policyID}/accounting/sage-intacct/advanced` as const; + }, }, POLICY_ACCOUNTING_SAGE_INTACCT_PAYMENT_ACCOUNT: { route: 'settings/workspaces/:policyID/accounting/sage-intacct/advanced/payment-account', diff --git a/src/libs/CardUtils.ts b/src/libs/CardUtils.ts index 088c8d2f6e5e..2981df506363 100644 --- a/src/libs/CardUtils.ts +++ b/src/libs/CardUtils.ts @@ -9,7 +9,7 @@ import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import type {OnyxValues} from '@src/ONYXKEYS'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {BankAccountList, Card, CardFeeds, CardList, CompanyCardFeed, PersonalDetailsList, WorkspaceCardsList} from '@src/types/onyx'; +import type {BankAccountList, Card, CardFeeds, CardList, CompanyCardFeed, ExpensifyCardSettings, PersonalDetailsList, Policy, WorkspaceCardsList} from '@src/types/onyx'; import type {FilteredCardList} from '@src/types/onyx/Card'; import type {CompanyCardFeedWithNumber, CompanyCardNicknames, CompanyFeeds, DirectCardFeedData} from '@src/types/onyx/CardFeeds'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; @@ -529,6 +529,13 @@ function hasCardListObject(workspaceAccountID: number, feedName: CompanyCardFeed return !!workspaceCards.cardList; } +/** + * Check if the Expensify Card is fully set up and a new card can be issued + */ +function isExpensifyCardFullySetUp(policy?: OnyxEntry, cardSettings?: OnyxEntry): boolean { + return !!(policy?.areExpensifyCardsEnabled && cardSettings?.paymentBankAccountID); +} + export { isExpensifyCard, isCorporateCard, @@ -569,4 +576,5 @@ export { checkIfFeedConnectionIsBroken, hasIssuedExpensifyCard, hasCardListObject, + isExpensifyCardFullySetUp, }; diff --git a/src/pages/workspace/accounting/PolicyAccountingPage.tsx b/src/pages/workspace/accounting/PolicyAccountingPage.tsx index 3303ff79219b..fcfc16ad4ede 100644 --- a/src/pages/workspace/accounting/PolicyAccountingPage.tsx +++ b/src/pages/workspace/accounting/PolicyAccountingPage.tsx @@ -34,6 +34,7 @@ import {getRouteParamForConnection} from '@libs/AccountingUtils'; import {isAuthenticationError, isConnectionInProgress, isConnectionUnverified, removePolicyConnection, syncConnection} from '@libs/actions/connections'; import {getAssignedSupportData} from '@libs/actions/Policy/Policy'; import {getConciergeReportID} from '@libs/actions/Report'; +import {isExpensifyCardFullySetUp} from '@libs/CardUtils'; import { areSettingsInErrorFields, findCurrentXeroOrganization, @@ -118,7 +119,7 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { const currentXeroOrganization = findCurrentXeroOrganization(tenants, policy?.connections?.xero?.config?.tenantID); const shouldShowSynchronizationError = !!synchronizationError; const shouldShowReinstallConnectorMenuItem = shouldShowSynchronizationError && connectedIntegration === CONST.POLICY.CONNECTIONS.NAME.QBD; - const shouldShowCardReconciliationOption = policy?.areExpensifyCardsEnabled && cardSettings?.paymentBankAccountID; + const shouldShowCardReconciliationOption = isExpensifyCardFullySetUp(policy, cardSettings); const overflowMenu: ThreeDotsMenuProps['menuItems'] = useMemo( () => [ @@ -292,7 +293,12 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { return; } - const iconProps = integrationData?.icon ? {icon: integrationData.icon, iconType: CONST.ICON_TYPE_AVATAR} : {}; + const iconProps = integrationData?.icon + ? { + icon: integrationData.icon, + iconType: CONST.ICON_TYPE_AVATAR, + } + : {}; return { ...iconProps, @@ -416,7 +422,10 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { ), diff --git a/src/pages/workspace/accounting/reconciliation/CardReconciliationPage.tsx b/src/pages/workspace/accounting/reconciliation/CardReconciliationPage.tsx index 19429e147426..70aa71d6a314 100644 --- a/src/pages/workspace/accounting/reconciliation/CardReconciliationPage.tsx +++ b/src/pages/workspace/accounting/reconciliation/CardReconciliationPage.tsx @@ -8,7 +8,8 @@ import Text from '@components/Text'; import TextLink from '@components/TextLink'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; -import * as AccountingUtils from '@libs/AccountingUtils'; +import {getConnectionNameFromRouteParam} from '@libs/AccountingUtils'; +import {isExpensifyCardFullySetUp} from '@libs/CardUtils'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import Navigation from '@navigation/Navigation'; import type {SettingsNavigatorParamList} from '@navigation/types'; @@ -16,7 +17,7 @@ import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnections'; import withPolicyConnections from '@pages/workspace/withPolicyConnections'; import ToggleSettingOptionRow from '@pages/workspace/workflows/ToggleSettingsOptionRow'; -import * as Card from '@userActions/Card'; +import {toggleContinuousReconciliation} from '@userActions/Card'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -29,24 +30,24 @@ function CardReconciliationPage({policy, route}: CardReconciliationPageProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); - const workspaceAccountID = policy?.workspaceAccountID ?? -1; + const workspaceAccountID = policy?.workspaceAccountID ?? CONST.DEFAULT_NUMBER_ID; const [isContinuousReconciliationOn] = useOnyx(`${ONYXKEYS.COLLECTION.EXPENSIFY_CARD_USE_CONTINUOUS_RECONCILIATION}${workspaceAccountID}`); const [cardSettings] = useOnyx(`${ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_SETTINGS}${workspaceAccountID}`); const [currentConnectionName] = useOnyx(`${ONYXKEYS.COLLECTION.EXPENSIFY_CARD_CONTINUOUS_RECONCILIATION_CONNECTION}${workspaceAccountID}`); const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); - const paymentBankAccountID = cardSettings?.paymentBankAccountID ?? 0; + const paymentBankAccountID = cardSettings?.paymentBankAccountID ?? CONST.DEFAULT_NUMBER_ID; const bankAccountTitle = bankAccountList?.[paymentBankAccountID]?.title ?? ''; - const policyID = policy?.id ?? '-1'; + const policyID = policy?.id; const {connection} = route.params; - const connectionName = AccountingUtils.getConnectionNameFromRouteParam(connection) as ConnectionName; + const connectionName = getConnectionNameFromRouteParam(connection) as ConnectionName; const autoSync = !!policy?.connections?.[connectionName]?.config?.autoSync?.enabled; - const shouldShow = policy?.areExpensifyCardsEnabled && cardSettings?.paymentBankAccountID; + const shouldShow = isExpensifyCardFullySetUp(policy, cardSettings); - const toggleContinuousReconciliation = (value: boolean) => { - Card.toggleContinuousReconciliation(workspaceAccountID, value, connectionName, currentConnectionName); + const handleToggleContinuousReconciliation = (value: boolean) => { + toggleContinuousReconciliation(workspaceAccountID, value, connectionName, currentConnectionName); if (value) { Navigation.navigate(ROUTES.WORKSPACE_ACCOUNTING_RECONCILIATION_ACCOUNT_SETTINGS.getRoute(policyID, connection)); } @@ -93,7 +94,7 @@ function CardReconciliationPage({policy, route}: CardReconciliationPageProps) { switchAccessibilityLabel={translate('workspace.accounting.continuousReconciliation')} disabled={!autoSync} isActive={!!isContinuousReconciliationOn} - onToggle={toggleContinuousReconciliation} + onToggle={handleToggleContinuousReconciliation} wrapperStyle={styles.ph5} /> {!autoSync && ( diff --git a/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx b/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx index a9c6bd14f0c2..5b8caab13534 100644 --- a/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx +++ b/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx @@ -22,7 +22,7 @@ import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import {setPolicyPreventSelfApproval} from '@libs/actions/Policy/Policy'; import {removeApprovalWorkflow as removeApprovalWorkflowAction, updateApprovalWorkflow} from '@libs/actions/Workflow'; -import {getAllCardsForWorkspace, getCardFeedIcon, getCompanyFeeds, maskCardNumber} from '@libs/CardUtils'; +import {getAllCardsForWorkspace, getCardFeedIcon, getCompanyFeeds, isExpensifyCardFullySetUp, maskCardNumber} from '@libs/CardUtils'; import {convertToDisplayString} from '@libs/CurrencyUtils'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import {getDisplayNameOrDefault} from '@libs/PersonalDetailsUtils'; @@ -85,7 +85,6 @@ function WorkspaceMemberDetailsPage({personalDetails, policy, route}: WorkspaceM const ownerDetails = useMemo(() => personalDetails?.[policy?.ownerAccountID ?? CONST.DEFAULT_NUMBER_ID] ?? ({} as PersonalDetails), [personalDetails, policy?.ownerAccountID]); const policyOwnerDisplayName = formatPhoneNumber(getDisplayNameOrDefault(ownerDetails)) ?? policy?.owner ?? ''; const hasMultipleFeeds = Object.values(getCompanyFeeds(cardFeeds)).filter((feed) => !feed.pending).length > 0; - const paymentBankAccountID = cardSettings?.paymentBankAccountID; const workspaceCards = getAllCardsForWorkspace(workspaceAccountID, cardList); const policyApproverEmail = policy?.approver; @@ -261,7 +260,7 @@ function WorkspaceMemberDetailsPage({personalDetails, policy, route}: WorkspaceM return ; } - const shouldShowCardsSection = (!!paymentBankAccountID && !!policy?.areExpensifyCardsEnabled) || hasMultipleFeeds; + const shouldShowCardsSection = isExpensifyCardFullySetUp(policy, cardSettings) || hasMultipleFeeds; return ( { if (!selectedFeed) { setShouldShowError(true); @@ -132,26 +136,25 @@ function WorkspaceMemberNewCardPage({route, personalDetails}: WorkspaceMemberNew ), })); - const feeds = - workspaceAccountID && policy?.areExpensifyCardsEnabled - ? [ - ...companyCardFeeds, - { - value: CONST.EXPENSIFY_CARD.NAME, - text: translate('workspace.common.expensifyCard'), - keyForList: CONST.EXPENSIFY_CARD.NAME, - isSelected: selectedFeed === CONST.EXPENSIFY_CARD.NAME, - leftElement: ( - - ), - }, - ] - : companyCardFeeds; + const feeds = shouldShowExpensifyCard + ? [ + ...companyCardFeeds, + { + value: CONST.EXPENSIFY_CARD.NAME, + text: translate('workspace.common.expensifyCard'), + keyForList: CONST.EXPENSIFY_CARD.NAME, + isSelected: selectedFeed === CONST.EXPENSIFY_CARD.NAME, + leftElement: ( + + ), + }, + ] + : companyCardFeeds; const goBack = () => Navigation.goBack(); diff --git a/tests/unit/CardUtilsTest.ts b/tests/unit/CardUtilsTest.ts index e14b886c35d6..99e28e1e5763 100644 --- a/tests/unit/CardUtilsTest.ts +++ b/tests/unit/CardUtilsTest.ts @@ -14,9 +14,10 @@ import { getYearFromExpirationDateString, hasIssuedExpensifyCard, isCustomFeed as isCustomFeedCardUtils, + isExpensifyCardFullySetUp, maskCardNumber, } from '@src/libs/CardUtils'; -import type {CardFeeds, CompanyCardFeed, WorkspaceCardsList} from '@src/types/onyx'; +import type {CardFeeds, CompanyCardFeed, ExpensifyCardSettings, Policy, WorkspaceCardsList} from '@src/types/onyx'; import type {CompanyCardFeedWithNumber} from '@src/types/onyx/CardFeeds'; const shortDate = '0924'; @@ -180,6 +181,22 @@ const customFeedCardsList = { } as unknown as WorkspaceCardsList; const customFeedName = 'Custom feed name'; +const policyWithCardsEnabled = { + areExpensifyCardsEnabled: true, +} as unknown as Policy; + +const policyWithCardsDisabled = { + areExpensifyCardsEnabled: false, +} as unknown as Policy; + +const cardSettingsWithPaymentBankAccountID = { + paymentBankAccountID: '12345', +} as unknown as ExpensifyCardSettings; + +const cardSettingsWithoutPaymentBankAccountID = { + paymentBankAccountID: undefined, +} as unknown as ExpensifyCardSettings; + const cardFeedsCollection: OnyxCollection = { // Policy with both custom and direct feeds FAKE_ID_1: { @@ -582,4 +599,31 @@ describe('CardUtils', () => { expect(hasIssuedExpensifyCard(workspaceAccountID, {})).toBe(false); }); }); + + describe('isExpensifyCardFullySetUp', () => { + it('should return true when policy has enabled cards and cardSettings has payment bank account ID', () => { + const result = isExpensifyCardFullySetUp(policyWithCardsEnabled, cardSettingsWithPaymentBankAccountID); + expect(result).toBe(true); + }); + + it('should return false when policy has disabled cards', () => { + const result = isExpensifyCardFullySetUp(policyWithCardsDisabled, cardSettingsWithoutPaymentBankAccountID); + expect(result).toBe(false); + }); + + it('should return false when cardSettings has no payment bank account ID', () => { + const result = isExpensifyCardFullySetUp(policyWithCardsEnabled, cardSettingsWithoutPaymentBankAccountID); + expect(result).toBe(false); + }); + + it('should return false when cardSettings is undefined', () => { + const result = isExpensifyCardFullySetUp(policyWithCardsEnabled, undefined); + expect(result).toBe(false); + }); + + it('should return false when both policy and cardSettings are undefined', () => { + const result = isExpensifyCardFullySetUp(undefined, undefined); + expect(result).toBe(false); + }); + }); });