diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 81c50c890eb3..4b01b77625de 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -3796,6 +3796,15 @@ const CONST = { DEFAULT_MAX_EXPENSE_AMOUNT: 200000, DEFAULT_MAX_AMOUNT_NO_RECEIPT: 2500, DEFAULT_MAX_AMOUNT_NO_ITEMIZED_RECEIPT: 7500, + DEFAULT_PROHIBITED_EXPENSES: { + alcohol: false, + hotelIncidentals: false, + gambling: true, + tobacco: false, + adultEntertainment: true, + }, + DEFAULT_BILLABLE: false, + DEFAULT_REIMBURSABLE: true, DEFAULT_TAG_LIST: { Tag: { name: 'Tag', diff --git a/src/languages/de.ts b/src/languages/de.ts index 562881c135ee..444fcd963081 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -1033,6 +1033,7 @@ const translations: TranslationDeepObject = { title: 'Erste Schritte', createWorkspace: 'Workspace erstellen', connectAccounting: ({integrationName}: {integrationName: string}) => `Mit ${integrationName} verbinden`, + connectAccountingDefault: 'Mit Buchhaltung verbinden', customizeCategories: 'Buchhaltungskategorien anpassen', linkCompanyCards: 'Firmenkarten verknüpfen', setupRules: 'Ausgabelimits einrichten', diff --git a/src/languages/en.ts b/src/languages/en.ts index bb3359169c1a..39fd78e6b409 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1083,6 +1083,7 @@ const translations = { title: 'Getting started', createWorkspace: 'Create a workspace', connectAccounting: ({integrationName}: {integrationName: string}) => `Connect to ${integrationName}`, + connectAccountingDefault: 'Connect to accounting', customizeCategories: 'Customize accounting categories', linkCompanyCards: 'Link company cards', setupRules: 'Set up spend rules', diff --git a/src/languages/es.ts b/src/languages/es.ts index c5d180f627ab..0475fa12552d 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -968,6 +968,7 @@ const translations: TranslationDeepObject = { title: 'Primeros pasos', createWorkspace: 'Crear un espacio de trabajo', connectAccounting: ({integrationName}: {integrationName: string}) => `Conectar con ${integrationName}`, + connectAccountingDefault: 'Conectar a contabilidad', customizeCategories: 'Personalizar categorías contables', linkCompanyCards: 'Vincular tarjetas corporativas', setupRules: 'Configurar reglas de gasto', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 94e116020874..19a14f31af58 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -1049,6 +1049,7 @@ const translations: TranslationDeepObject = { title: 'Premiers pas', createWorkspace: 'Créer un espace de travail', connectAccounting: ({integrationName}: {integrationName: string}) => `Se connecter à ${integrationName}`, + connectAccountingDefault: 'Connecter à la comptabilité', customizeCategories: 'Personnaliser les catégories comptables', linkCompanyCards: 'Lier des cartes d’entreprise', setupRules: 'Configurer les règles de dépense', diff --git a/src/languages/it.ts b/src/languages/it.ts index 4e3f1d08de42..de8a5b828aae 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -1046,6 +1046,7 @@ const translations: TranslationDeepObject = { title: 'Per iniziare', createWorkspace: 'Crea uno spazio di lavoro', connectAccounting: ({integrationName}: {integrationName: string}) => `Connetti a ${integrationName}`, + connectAccountingDefault: 'Connetti alla contabilità', customizeCategories: 'Personalizza le categorie contabili', linkCompanyCards: 'Collega carte aziendali', setupRules: 'Configura le regole di spesa', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 228e9d37a34d..890139a96091 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -1029,6 +1029,7 @@ const translations: TranslationDeepObject = { title: 'はじめに', createWorkspace: 'ワークスペースを作成', connectAccounting: ({integrationName}: {integrationName: string}) => `${integrationName}に接続する`, + connectAccountingDefault: '会計ソフトに接続', customizeCategories: '会計カテゴリをカスタマイズする', linkCompanyCards: '会社カードを連携', setupRules: '支出ルールを設定', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 4e0bad97ba01..4376291040ef 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -1045,6 +1045,7 @@ const translations: TranslationDeepObject = { title: 'Aan de slag', createWorkspace: 'Maak een werkruimte', connectAccounting: ({integrationName}: {integrationName: string}) => `Verbind met ${integrationName}`, + connectAccountingDefault: 'Verbind met boekhouding', customizeCategories: 'Boekhoudcategorieën aanpassen', linkCompanyCards: 'Bedrijfspassen koppelen', setupRules: 'Uitgavenregels instellen', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index e0afd753dc80..c1e7788fdab2 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -1046,6 +1046,7 @@ const translations: TranslationDeepObject = { title: 'Pierwsze kroki', createWorkspace: 'Utwórz przestrzeń roboczą', connectAccounting: ({integrationName}: {integrationName: string}) => `Połącz z ${integrationName}`, + connectAccountingDefault: 'Połącz z księgowością', customizeCategories: 'Dostosuj kategorie księgowe', linkCompanyCards: 'Połącz firmowe karty', setupRules: 'Skonfiguruj zasady wydatków', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 4eed9ac82355..25fdad9d3ccb 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -1044,6 +1044,7 @@ const translations: TranslationDeepObject = { title: 'Introdução', createWorkspace: 'Criar um workspace', connectAccounting: ({integrationName}: {integrationName: string}) => `Conectar ao ${integrationName}`, + connectAccountingDefault: 'Conectar à contabilidade', customizeCategories: 'Personalizar categorias contábeis', linkCompanyCards: 'Vincular cartões corporativos', setupRules: 'Configurar regras de gasto', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 038d18646407..543512b72815 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -1012,6 +1012,7 @@ const translations: TranslationDeepObject = { title: '入门', createWorkspace: '创建工作区', connectAccounting: ({integrationName}: {integrationName: string}) => `连接到 ${integrationName}`, + connectAccountingDefault: '连接会计系统', customizeCategories: '自定义会计类别', linkCompanyCards: '关联公司卡', setupRules: '设置消费规则', diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index ce8f511615b1..b73c93030fe4 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -692,22 +692,70 @@ function hasCustomCategories(policyCategories: OnyxEntry): boo } /** - * Checks if a policy has any non-default rules configured. - * Defaults are: no approval/expense/coding rules and no custom rules text. + * Checks if a policy has any rules configured (structured rules, individual expense limits, or prohibited expenses). */ -function hasNonDefaultRules(policy: OnyxEntry): boolean { +function hasConfiguredRules(policy: OnyxEntry): boolean { if (!policy) { return false; } - const hasCustomRules = !!policy.customRules && policy.customRules.trim().length > 0; + if (!!policy.customRules && policy.customRules.trim().length > 0) { + return true; + } const {rules} = policy; - const hasApprovalRules = !!rules?.approvalRules && rules.approvalRules.length > 0; - const hasExpenseRules = !!rules?.expenseRules && rules.expenseRules.length > 0; - const hasCodingRules = !!rules?.codingRules && Object.keys(rules.codingRules).length > 0; + if (!!rules?.approvalRules && rules.approvalRules.length > 0) { + return true; + } + if (!!rules?.expenseRules && rules.expenseRules.length > 0) { + return true; + } + if (!!rules?.codingRules && Object.keys(rules.codingRules).length > 0) { + return true; + } + + if (!!policy.maxExpenseAmount && policy.maxExpenseAmount !== CONST.DISABLED_MAX_EXPENSE_VALUE && policy.maxExpenseAmount !== CONST.POLICY.DEFAULT_MAX_EXPENSE_AMOUNT) { + return true; + } + if (!!policy.maxExpenseAge && policy.maxExpenseAge !== CONST.DISABLED_MAX_EXPENSE_VALUE && policy.maxExpenseAge !== CONST.POLICY.DEFAULT_MAX_EXPENSE_AGE) { + return true; + } + if ( + !!policy.maxExpenseAmountNoReceipt && + policy.maxExpenseAmountNoReceipt !== CONST.DISABLED_MAX_EXPENSE_VALUE && + policy.maxExpenseAmountNoReceipt !== CONST.POLICY.DEFAULT_MAX_AMOUNT_NO_RECEIPT + ) { + return true; + } + if ( + !!policy.maxExpenseAmountNoItemizedReceipt && + policy.maxExpenseAmountNoItemizedReceipt !== CONST.DISABLED_MAX_EXPENSE_VALUE && + policy.maxExpenseAmountNoItemizedReceipt !== CONST.POLICY.DEFAULT_MAX_AMOUNT_NO_ITEMIZED_RECEIPT + ) { + return true; + } + + if (policy.defaultBillable) { + return true; + } + if (policy.defaultReimbursable === false) { + return true; + } + if (policy.eReceipts) { + return true; + } + if (policy.requireCompanyCardsEnabled) { + return true; + } - return hasCustomRules || hasApprovalRules || hasExpenseRules || hasCodingRules; + const {prohibitedExpenses} = policy; + return ( + !!prohibitedExpenses && + Object.entries(CONST.POLICY.DEFAULT_PROHIBITED_EXPENSES).some(([key, defaultValue]) => { + const value = prohibitedExpenses[key as keyof typeof CONST.POLICY.DEFAULT_PROHIBITED_EXPENSES]; + return value !== undefined && value !== defaultValue; + }) + ); } /** @@ -2132,7 +2180,7 @@ export { getTagLists, hasTags, hasCustomCategories, - hasNonDefaultRules, + hasConfiguredRules, getTaxByID, getUnitRateValue, getRateDisplayValue, diff --git a/src/pages/home/GettingStartedSection/hooks/useGettingStartedItems.ts b/src/pages/home/GettingStartedSection/hooks/useGettingStartedItems.ts index 75d7d723c4b1..57a7d35abe75 100644 --- a/src/pages/home/GettingStartedSection/hooks/useGettingStartedItems.ts +++ b/src/pages/home/GettingStartedSection/hooks/useGettingStartedItems.ts @@ -2,16 +2,24 @@ import useCardFeeds from '@hooks/useCardFeeds'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import {enablePolicyCategories} from '@libs/actions/Policy/Category'; import {hasCompanyCardFeeds} from '@libs/CardUtils'; import Navigation from '@libs/Navigation/Navigation'; -import {hasAccountingConnections, hasCustomCategories, hasNonDefaultRules, isPaidGroupPolicy, isPendingDeletePolicy, isPolicyAdmin} from '@libs/PolicyUtils'; +import { + getValidConnectedIntegration, + hasAccountingFeatureConnection, + hasConfiguredRules, + hasCustomCategories, + isPaidGroupPolicy, + isPendingDeletePolicy, + isPolicyAdmin, +} from '@libs/PolicyUtils'; import isWithinGettingStartedPeriod from '@pages/home/GettingStartedSection/utils/isWithinGettingStartedPeriod'; -import {enablePolicyCategories} from '@userActions/Policy/Category'; -import {enableCompanyCards, enablePolicyConnections, enablePolicyRules} from '@userActions/Policy/Policy'; +import {enableCompanyCards, enablePolicyConnections} from '@userActions/Policy/Policy'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; import type {Route} from '@src/ROUTES'; +import ROUTES from '@src/ROUTES'; type GettingStartedItem = { key: string; @@ -46,6 +54,7 @@ function useGettingStartedItems(): UseGettingStartedItemsResult { const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${activePolicyID}`); const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${activePolicyID}`); const [allCardFeeds] = useCardFeeds(activePolicyID); + const isAccountingEnabled = !!policy?.areConnectionsEnabled || hasAccountingFeatureConnection(policy); const emptyResult: UseGettingStartedItemsResult = {shouldShowSection: false, items: []}; @@ -77,12 +86,14 @@ function useGettingStartedItems(): UseGettingStartedItemsResult { const isDirectConnect = !!reportedIntegration && DIRECT_CONNECT_INTEGRATIONS.has(reportedIntegration); - if (isDirectConnect) { - const integrationName = CONST.ONBOARDING_ACCOUNTING_MAPPING[reportedIntegration as keyof typeof CONST.ONBOARDING_ACCOUNTING_MAPPING] ?? String(reportedIntegration); + if (isAccountingEnabled) { + const integrationName = isDirectConnect + ? (CONST.ONBOARDING_ACCOUNTING_MAPPING[reportedIntegration as keyof typeof CONST.ONBOARDING_ACCOUNTING_MAPPING] ?? String(reportedIntegration)) + : undefined; items.push({ key: 'connectAccounting', - label: translate('homePage.gettingStartedSection.connectAccounting', {integrationName}), - isComplete: hasAccountingConnections(policy), + label: integrationName ? translate('homePage.gettingStartedSection.connectAccounting', {integrationName}) : translate('homePage.gettingStartedSection.connectAccountingDefault'), + isComplete: !!getValidConnectedIntegration(policy) || Object.values(policy?.connections ?? {}).some((conn) => !!conn?.lastSync?.successfulDate), route: ROUTES.WORKSPACE_ACCOUNTING.getRoute(activePolicyID), isFeatureEnabled: policy.areConnectionsEnabled, enableFeature: () => enablePolicyConnections(activePolicyID, true, false), @@ -111,10 +122,8 @@ function useGettingStartedItems(): UseGettingStartedItemsResult { items.push({ key: 'setupRules', label: translate('homePage.gettingStartedSection.setupRules'), - isComplete: hasNonDefaultRules(policy), + isComplete: hasConfiguredRules(policy), route: ROUTES.WORKSPACE_RULES.getRoute(activePolicyID), - isFeatureEnabled: policy.areRulesEnabled, - enableFeature: () => enablePolicyRules(policy, true, false), }); } diff --git a/tests/unit/PolicyUtilsTest.ts b/tests/unit/PolicyUtilsTest.ts index 954426aaa7a5..b7419f585cc2 100644 --- a/tests/unit/PolicyUtilsTest.ts +++ b/tests/unit/PolicyUtilsTest.ts @@ -26,6 +26,7 @@ import { getTagListByOrderWeight, getUberConnectionErrorDirectlyFromPolicy, getUnitRateValue, + hasConfiguredRules, hasDependentTags, hasDynamicExternalWorkflow, hasIndependentTags, @@ -2449,4 +2450,195 @@ describe('PolicyUtils', () => { expect(hasIndependentTags(policy, undefined)).toBe(false); }); }); + + describe('hasConfiguredRules', () => { + it('returns false when policy is undefined', () => { + expect(hasConfiguredRules(undefined)).toBe(false); + }); + + it('returns false when policy has no rules configured', () => { + expect(hasConfiguredRules({} as Policy)).toBe(false); + }); + + describe('customRules', () => { + it('returns true when customRules is non-empty', () => { + expect(hasConfiguredRules({customRules: 'some rule'} as Policy)).toBe(true); + }); + + it('returns false when customRules is an empty string', () => { + expect(hasConfiguredRules({customRules: ''} as Policy)).toBe(false); + }); + + it('returns false when customRules is only whitespace', () => { + expect(hasConfiguredRules({customRules: ' '} as Policy)).toBe(false); + }); + }); + + describe('rules.approvalRules', () => { + it('returns true when approvalRules has items', () => { + const policy = {rules: {approvalRules: [{id: '1', applyWhen: [], approver: 'approver@test.com'}]}} as unknown as Policy; + expect(hasConfiguredRules(policy)).toBe(true); + }); + + it('returns false when approvalRules is empty', () => { + expect(hasConfiguredRules({rules: {approvalRules: []}} as unknown as Policy)).toBe(false); + }); + }); + + describe('rules.expenseRules', () => { + it('returns true when expenseRules has items', () => { + const policy = { + rules: { + expenseRules: [ + { + id: '1', + applyWhen: [], + tax: {field_id_TAX: {externalID: 'TAX_US'}}, + }, + ], + }, + } as unknown as Policy; + expect(hasConfiguredRules(policy)).toBe(true); + }); + + it('returns false when expenseRules is empty', () => { + expect(hasConfiguredRules({rules: {expenseRules: []}} as unknown as Policy)).toBe(false); + }); + }); + + describe('rules.codingRules', () => { + it('returns true when codingRules has entries', () => { + const policy = { + rules: { + codingRules: { + rule1: { + ruleID: 'rule1', + filters: {left: 'merchant', operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, right: 'Starbucks'}, + }, + }, + }, + } as unknown as Policy; + expect(hasConfiguredRules(policy)).toBe(true); + }); + + it('returns false when codingRules is empty', () => { + expect(hasConfiguredRules({rules: {codingRules: {}}} as unknown as Policy)).toBe(false); + }); + }); + + describe('maxExpenseAmount', () => { + it('returns true when maxExpenseAmount is set to a non-default value', () => { + expect(hasConfiguredRules({maxExpenseAmount: 500000} as Policy)).toBe(true); + }); + + it('returns false when maxExpenseAmount is the default value', () => { + expect(hasConfiguredRules({maxExpenseAmount: CONST.POLICY.DEFAULT_MAX_EXPENSE_AMOUNT} as Policy)).toBe(false); + }); + + it('returns false when maxExpenseAmount is the disabled value', () => { + expect(hasConfiguredRules({maxExpenseAmount: CONST.DISABLED_MAX_EXPENSE_VALUE} as Policy)).toBe(false); + }); + }); + + describe('maxExpenseAge', () => { + it('returns true when maxExpenseAge is set to a non-default value', () => { + expect(hasConfiguredRules({maxExpenseAge: 30} as Policy)).toBe(true); + }); + + it('returns false when maxExpenseAge is the default value', () => { + expect(hasConfiguredRules({maxExpenseAge: CONST.POLICY.DEFAULT_MAX_EXPENSE_AGE} as Policy)).toBe(false); + }); + + it('returns false when maxExpenseAge is the disabled value', () => { + expect(hasConfiguredRules({maxExpenseAge: CONST.DISABLED_MAX_EXPENSE_VALUE} as Policy)).toBe(false); + }); + }); + + describe('maxExpenseAmountNoReceipt', () => { + it('returns true when maxExpenseAmountNoReceipt is set to a non-default value', () => { + expect(hasConfiguredRules({maxExpenseAmountNoReceipt: 5000} as Policy)).toBe(true); + }); + + it('returns false when maxExpenseAmountNoReceipt is the default value', () => { + expect(hasConfiguredRules({maxExpenseAmountNoReceipt: CONST.POLICY.DEFAULT_MAX_AMOUNT_NO_RECEIPT} as Policy)).toBe(false); + }); + + it('returns false when maxExpenseAmountNoReceipt is the disabled value', () => { + expect(hasConfiguredRules({maxExpenseAmountNoReceipt: CONST.DISABLED_MAX_EXPENSE_VALUE} as Policy)).toBe(false); + }); + }); + + describe('maxExpenseAmountNoItemizedReceipt', () => { + it('returns true when maxExpenseAmountNoItemizedReceipt is set to a non-default value', () => { + expect(hasConfiguredRules({maxExpenseAmountNoItemizedReceipt: 10000} as Policy)).toBe(true); + }); + + it('returns false when maxExpenseAmountNoItemizedReceipt is the default value', () => { + expect(hasConfiguredRules({maxExpenseAmountNoItemizedReceipt: CONST.POLICY.DEFAULT_MAX_AMOUNT_NO_ITEMIZED_RECEIPT} as Policy)).toBe(false); + }); + + it('returns false when maxExpenseAmountNoItemizedReceipt is the disabled value', () => { + expect(hasConfiguredRules({maxExpenseAmountNoItemizedReceipt: CONST.DISABLED_MAX_EXPENSE_VALUE} as Policy)).toBe(false); + }); + }); + + describe('defaultBillable', () => { + it('returns true when defaultBillable is true', () => { + expect(hasConfiguredRules({defaultBillable: true} as Policy)).toBe(true); + }); + + it('returns false when defaultBillable is false', () => { + expect(hasConfiguredRules({defaultBillable: false} as Policy)).toBe(false); + }); + }); + + describe('defaultReimbursable', () => { + it('returns true when defaultReimbursable is false', () => { + expect(hasConfiguredRules({defaultReimbursable: false} as Policy)).toBe(true); + }); + + it('returns false when defaultReimbursable is true', () => { + expect(hasConfiguredRules({defaultReimbursable: true} as Policy)).toBe(false); + }); + }); + + describe('eReceipts', () => { + it('returns true when eReceipts is true', () => { + expect(hasConfiguredRules({eReceipts: true} as Policy)).toBe(true); + }); + + it('returns false when eReceipts is false', () => { + expect(hasConfiguredRules({eReceipts: false} as Policy)).toBe(false); + }); + }); + + describe('requireCompanyCardsEnabled', () => { + it('returns true when requireCompanyCardsEnabled is true', () => { + expect(hasConfiguredRules({requireCompanyCardsEnabled: true} as Policy)).toBe(true); + }); + + it('returns false when requireCompanyCardsEnabled is false', () => { + expect(hasConfiguredRules({requireCompanyCardsEnabled: false} as Policy)).toBe(false); + }); + }); + + describe('prohibitedExpenses', () => { + it('returns true when a prohibitedExpenses value differs from its default', () => { + // alcohol defaults to false — setting it to true triggers the rule + expect(hasConfiguredRules({prohibitedExpenses: {alcohol: true}} as Policy)).toBe(true); + }); + + it('returns true when gambling is disabled (differs from default true)', () => { + expect(hasConfiguredRules({prohibitedExpenses: {gambling: false}} as Policy)).toBe(true); + }); + + it('returns false when prohibitedExpenses matches all defaults', () => { + expect(hasConfiguredRules({prohibitedExpenses: {...CONST.POLICY.DEFAULT_PROHIBITED_EXPENSES}} as Policy)).toBe(false); + }); + + it('returns false when prohibitedExpenses is an empty object', () => { + expect(hasConfiguredRules({prohibitedExpenses: {}} as Policy)).toBe(false); + }); + }); + }); }); diff --git a/tests/unit/hooks/useGettingStartedItems.test.ts b/tests/unit/hooks/useGettingStartedItems.test.ts index c563d01d112d..b65ef75e9fc6 100644 --- a/tests/unit/hooks/useGettingStartedItems.test.ts +++ b/tests/unit/hooks/useGettingStartedItems.test.ts @@ -231,7 +231,7 @@ describe('useGettingStartedItems', () => { ]; it.each(directConnectIntegrations)('should show "Connect to $name" when user selected $key in onboarding', async ({key, name}) => { - await setupManageTeamScenario({accounting: key}); + await setupManageTeamScenario({accounting: key, policy: {areConnectionsEnabled: true}}); const {result} = renderHook(() => useGettingStartedItems()); @@ -241,7 +241,7 @@ describe('useGettingStartedItems', () => { }); it('should navigate to workspace accounting route', async () => { - await setupManageTeamScenario({accounting: CONST.POLICY.CONNECTIONS.NAME.QBO}); + await setupManageTeamScenario({accounting: CONST.POLICY.CONNECTIONS.NAME.QBO, policy: {areConnectionsEnabled: true}}); const {result} = renderHook(() => useGettingStartedItems()); @@ -252,7 +252,7 @@ describe('useGettingStartedItems', () => { it('should be not completed when workspace has no accounting connection', async () => { await setupManageTeamScenario({ accounting: CONST.POLICY.CONNECTIONS.NAME.QBO, - policy: {connections: undefined}, + policy: {areConnectionsEnabled: true, connections: undefined}, }); const {result} = renderHook(() => useGettingStartedItems()); @@ -265,10 +265,54 @@ describe('useGettingStartedItems', () => { await setupManageTeamScenario({ accounting: CONST.POLICY.CONNECTIONS.NAME.QBO, policy: { + areConnectionsEnabled: true, connections: { [CONST.POLICY.CONNECTIONS.NAME.QBO]: { config: {}, data: {}, + lastSync: {isConnected: true}, + }, + } as Policy['connections'], + }, + }); + + const {result} = renderHook(() => useGettingStartedItems()); + + const connectItem = result.current.items.find((item) => item.key === 'connectAccounting'); + expect(connectItem?.isComplete).toBe(true); + }); + + it('should not be completed when the initial connection attempt failed', async () => { + await setupManageTeamScenario({ + accounting: CONST.POLICY.CONNECTIONS.NAME.QBO, + policy: { + areConnectionsEnabled: true, + connections: { + [CONST.POLICY.CONNECTIONS.NAME.QBO]: { + config: {}, + data: {}, + lastSync: {isConnected: false}, + }, + } as Policy['connections'], + }, + }); + + const {result} = renderHook(() => useGettingStartedItems()); + + const connectItem = result.current.items.find((item) => item.key === 'connectAccounting'); + expect(connectItem?.isComplete).toBe(false); + }); + + it('should stay completed when a previously successful connection later breaks', async () => { + await setupManageTeamScenario({ + accounting: CONST.POLICY.CONNECTIONS.NAME.QBO, + policy: { + areConnectionsEnabled: true, + connections: { + [CONST.POLICY.CONNECTIONS.NAME.QBO]: { + config: {}, + data: {}, + lastSync: {isConnected: false, successfulDate: '2024-01-01'}, }, } as Policy['connections'], }, @@ -281,20 +325,81 @@ describe('useGettingStartedItems', () => { }); it('should not show the categories row when showing the connect row', async () => { - await setupManageTeamScenario({accounting: CONST.POLICY.CONNECTIONS.NAME.QBO}); + await setupManageTeamScenario({accounting: CONST.POLICY.CONNECTIONS.NAME.QBO, policy: {areConnectionsEnabled: true}}); const {result} = renderHook(() => useGettingStartedItems()); const categoriesItem = result.current.items.find((item) => item.key === 'customizeCategories'); expect(categoriesItem).toBeUndefined(); }); + + it('should show generic "Connect to accounting" when reportedIntegration is not set but a connection already exists (e.g. cache cleared after connecting)', async () => { + await setupManageTeamScenario({ + policy: { + areConnectionsEnabled: true, + connections: { + [CONST.POLICY.CONNECTIONS.NAME.QBO]: { + config: {}, + data: {}, + lastSync: {isConnected: true}, + }, + } as Policy['connections'], + }, + }); + + const {result} = renderHook(() => useGettingStartedItems()); + + const connectItem = result.current.items.find((item) => item.key === 'connectAccounting'); + expect(connectItem).toBeDefined(); + expect(connectItem?.label).toContain('connectAccountingDefault'); + }); + + it('should show "Customize accounting categories" when reportedIntegration is not set and no connections exist (e.g. cache cleared before connecting)', async () => { + await setupManageTeamScenario({policy: {areCategoriesEnabled: true}}); + + const {result} = renderHook(() => useGettingStartedItems()); + + const categoriesItem = result.current.items.find((item) => item.key === 'customizeCategories'); + expect(categoriesItem).toBeDefined(); + }); + + it('should have isFeatureEnabled=true when accounting connections feature is enabled', async () => { + await setupManageTeamScenario({accounting: CONST.POLICY.CONNECTIONS.NAME.QBO, policy: {areConnectionsEnabled: true}}); + + const {result} = renderHook(() => useGettingStartedItems()); + + const connectItem = result.current.items.find((item) => item.key === 'connectAccounting'); + expect(connectItem?.isFeatureEnabled).toBe(true); + }); + + it('should have isFeatureEnabled=false when accounting connections feature is not enabled but an existing connection makes the row visible', async () => { + await setupManageTeamScenario({ + accounting: CONST.POLICY.CONNECTIONS.NAME.QBO, + policy: { + areConnectionsEnabled: false, + connections: { + [CONST.POLICY.CONNECTIONS.NAME.QBO]: { + config: {}, + data: {}, + lastSync: {isConnected: true}, + }, + } as Policy['connections'], + }, + }); + + const {result} = renderHook(() => useGettingStartedItems()); + + const connectItem = result.current.items.find((item) => item.key === 'connectAccounting'); + expect(connectItem).toBeDefined(); + expect(connectItem?.isFeatureEnabled).toBe(false); + }); }); describe('row 2b - Customize accounting categories', () => { - const categoriesIntegrations = ['sap', 'oracle', 'microsoftDynamics', 'other', 'none', null]; + const categoriesIntegrations = ['sap', 'oracle', 'microsoftDynamics', 'other', 'none']; it.each(categoriesIntegrations)('should show "Customize accounting categories" when accounting choice is %s', async (accounting) => { - await setupManageTeamScenario({accounting}); + await setupManageTeamScenario({accounting, policy: {areCategoriesEnabled: true}}); const {result} = renderHook(() => useGettingStartedItems()); @@ -302,8 +407,27 @@ describe('useGettingStartedItems', () => { expect(categoriesItem).toBeDefined(); }); + it('should have isFeatureEnabled=true when categories feature is enabled', async () => { + await setupManageTeamScenario({accounting: 'none', policy: {areCategoriesEnabled: true}}); + + const {result} = renderHook(() => useGettingStartedItems()); + + const categoriesItem = result.current.items.find((item) => item.key === 'customizeCategories'); + expect(categoriesItem?.isFeatureEnabled).toBe(true); + }); + + it('should have isFeatureEnabled=false when categories feature is not enabled', async () => { + await setupManageTeamScenario({accounting: 'none', policy: {areCategoriesEnabled: false}}); + + const {result} = renderHook(() => useGettingStartedItems()); + + const categoriesItem = result.current.items.find((item) => item.key === 'customizeCategories'); + expect(categoriesItem).toBeDefined(); + expect(categoriesItem?.isFeatureEnabled).toBe(false); + }); + it('should navigate to workspace categories route', async () => { - await setupManageTeamScenario({accounting: 'none'}); + await setupManageTeamScenario({accounting: 'none', policy: {areCategoriesEnabled: true}}); const {result} = renderHook(() => useGettingStartedItems()); @@ -312,7 +436,7 @@ describe('useGettingStartedItems', () => { }); it('should not show the connect accounting row when showing the categories row', async () => { - await setupManageTeamScenario({accounting: 'none'}); + await setupManageTeamScenario({accounting: 'none', policy: {areCategoriesEnabled: true}}); const {result} = renderHook(() => useGettingStartedItems()); @@ -321,7 +445,7 @@ describe('useGettingStartedItems', () => { }); it('should be not completed when workspace has only default categories', async () => { - await setupManageTeamScenario({accounting: 'none'}); + await setupManageTeamScenario({accounting: 'none', policy: {areCategoriesEnabled: true}}); const {result} = renderHook(() => useGettingStartedItems()); @@ -345,7 +469,7 @@ describe('useGettingStartedItems', () => { }, }; await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${POLICY_ID}`, customCategories); - await setupManageTeamScenario({accounting: 'none'}); + await setupManageTeamScenario({accounting: 'none', policy: {areCategoriesEnabled: true}}); const {result} = renderHook(() => useGettingStartedItems()); @@ -441,18 +565,6 @@ describe('useGettingStartedItems', () => { expect(rulesItem).toBeUndefined(); }); - it('should have isFeatureEnabled=true when rules feature is enabled', async () => { - await setupManageTeamScenario({ - accounting: CONST.POLICY.CONNECTIONS.NAME.QBO, - policy: {areRulesEnabled: true}, - }); - - const {result} = renderHook(() => useGettingStartedItems()); - - const rulesItem = result.current.items.find((item) => item.key === 'setupRules'); - expect(rulesItem?.isFeatureEnabled).toBe(true); - }); - it('should not be included in items when rules feature is not enabled', async () => { await setupManageTeamScenario({ accounting: CONST.POLICY.CONNECTIONS.NAME.QBO, @@ -532,7 +644,7 @@ describe('useGettingStartedItems', () => { it('should return items in the correct order: createWorkspace, accounting/categories, companyCards, rules', async () => { await setupManageTeamScenario({ accounting: CONST.POLICY.CONNECTIONS.NAME.QBO, - policy: {areCompanyCardsEnabled: true, areRulesEnabled: true}, + policy: {areConnectionsEnabled: true, areCompanyCardsEnabled: true, areRulesEnabled: true}, }); const {result} = renderHook(() => useGettingStartedItems()); @@ -544,7 +656,7 @@ describe('useGettingStartedItems', () => { it('should return items in the correct order with categories instead of connect', async () => { await setupManageTeamScenario({ accounting: 'none', - policy: {areCompanyCardsEnabled: true, areRulesEnabled: true}, + policy: {areCategoriesEnabled: true, areCompanyCardsEnabled: true, areRulesEnabled: true}, }); const {result} = renderHook(() => useGettingStartedItems()); @@ -556,7 +668,7 @@ describe('useGettingStartedItems', () => { it('should contain three rows when areRulesEnabled is false', async () => { await setupManageTeamScenario({ accounting: CONST.POLICY.CONNECTIONS.NAME.QBO, - policy: {areCompanyCardsEnabled: false, areRulesEnabled: false}, + policy: {areConnectionsEnabled: true, areCompanyCardsEnabled: false, areRulesEnabled: false}, }); const {result} = renderHook(() => useGettingStartedItems()); diff --git a/tests/unit/pages/home/GettingStartedSection/GettingStartedSectionTest.tsx b/tests/unit/pages/home/GettingStartedSection/GettingStartedSectionTest.tsx index 2977e2672188..84d7d63c8796 100644 --- a/tests/unit/pages/home/GettingStartedSection/GettingStartedSectionTest.tsx +++ b/tests/unit/pages/home/GettingStartedSection/GettingStartedSectionTest.tsx @@ -43,10 +43,12 @@ async function setManageTeamUserState(overrides?: { integration?: string | null; areCompanyCardsEnabled?: boolean; areRulesEnabled?: boolean; + areAccountingEnabled?: boolean; + areCategoriesEnabled?: boolean; hasAccountingConnection?: boolean; hasCustomCategories?: boolean; hasCompanyCardConnection?: boolean; - hasNonDefaultRules?: boolean; + hasConfiguredRules?: boolean; trialStartDate?: string; }) { const trialStart = overrides?.trialStartDate ?? '2026-03-01'; @@ -65,6 +67,8 @@ async function setManageTeamUserState(overrides?: { role: CONST.POLICY.ROLE.ADMIN, areCompanyCardsEnabled: overrides?.areCompanyCardsEnabled ?? true, areRulesEnabled: overrides?.areRulesEnabled ?? true, + areConnectionsEnabled: overrides?.areAccountingEnabled, + areCategoriesEnabled: overrides?.areCategoriesEnabled, }; if (overrides?.hasAccountingConnection) { @@ -214,7 +218,7 @@ describe('GettingStartedSection', () => { }); it('shows "Connect to [system]" row for QBO integration', async () => { - await setManageTeamUserState({integration: 'quickbooksOnline'}); + await setManageTeamUserState({integration: 'quickbooksOnline', areAccountingEnabled: true}); renderGettingStartedSection(); @@ -222,7 +226,7 @@ describe('GettingStartedSection', () => { }); it('shows "Connect to [system]" row for Xero integration', async () => { - await setManageTeamUserState({integration: 'xero'}); + await setManageTeamUserState({integration: 'xero', areAccountingEnabled: true}); renderGettingStartedSection(); @@ -230,7 +234,7 @@ describe('GettingStartedSection', () => { }); it('shows "Customize accounting categories" for non-direct-connect integrations', async () => { - await setManageTeamUserState({integration: 'other'}); + await setManageTeamUserState({integration: 'other', areCategoriesEnabled: true}); renderGettingStartedSection(); @@ -239,7 +243,7 @@ describe('GettingStartedSection', () => { }); it('shows "Customize accounting categories" when no integration is selected', async () => { - await setManageTeamUserState({integration: 'none'}); + await setManageTeamUserState({integration: 'none', areCategoriesEnabled: true}); renderGettingStartedSection(); @@ -281,6 +285,7 @@ describe('GettingStartedSection', () => { it('renders rows in the expected order: workspace, accounting, cards, rules', async () => { await setManageTeamUserState({ integration: 'quickbooksOnline', + areAccountingEnabled: true, areCompanyCardsEnabled: true, areRulesEnabled: true, }); @@ -314,6 +319,7 @@ describe('GettingStartedSection', () => { it('accounting row is checked when workspace has a successful connection', async () => { await setManageTeamUserState({ integration: 'quickbooksOnline', + areAccountingEnabled: true, hasAccountingConnection: true, }); @@ -336,7 +342,7 @@ describe('GettingStartedSection', () => { }); it('navigates to workspace accounting when "Connect to [system]" row is pressed', async () => { - await setManageTeamUserState({integration: 'quickbooksOnline'}); + await setManageTeamUserState({integration: 'quickbooksOnline', areAccountingEnabled: true}); renderGettingStartedSection(); @@ -347,7 +353,7 @@ describe('GettingStartedSection', () => { }); it('navigates to workspace categories when "Customize categories" row is pressed', async () => { - await setManageTeamUserState({integration: 'other'}); + await setManageTeamUserState({integration: 'other', areCategoriesEnabled: true}); renderGettingStartedSection();