diff --git a/src/components/MoneyRequestHeaderSecondaryActions.tsx b/src/components/MoneyRequestHeaderSecondaryActions.tsx index 6b5198ac9b15..e13922082d18 100644 --- a/src/components/MoneyRequestHeaderSecondaryActions.tsx +++ b/src/components/MoneyRequestHeaderSecondaryActions.tsx @@ -86,7 +86,7 @@ import {useRoute} from '@react-navigation/native'; import {shouldFailAllRequestsSelector} from '@selectors/Network'; import {hasSeenTourSelector} from '@selectors/Onboarding'; import {personalDetailsLoginSelector} from '@selectors/PersonalDetails'; -import {createFilteredPoliciesInfoSelector, createHasWorkspaceToSubmitToSelector} from '@selectors/Policy'; +import {billingRestrictionPolicySelector, createFilteredPoliciesInfoSelector, createHasWorkspaceToSubmitToSelector} from '@selectors/Policy'; import {validTransactionDraftsSelector} from '@selectors/TransactionDraft'; import React, {useMemo, useRef, useState} from 'react'; @@ -193,6 +193,7 @@ function MoneyRequestHeaderSecondaryActions({reportID, onBackButtonPress}: Money const {isRestrictedToPreferredPolicy, preferredPolicyID} = usePreferredPolicy(); const filteredPoliciesInfoSelector = useMemo(() => createFilteredPoliciesInfoSelector(currentUserEmail), [currentUserEmail]); const [filteredPoliciesInfo] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: filteredPoliciesInfoSelector}); + const [preferredPolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(preferredPolicyID)}`, {selector: billingRestrictionPolicySelector}); const draftTransactionIDs = useMemo(() => Object.keys(transactionDrafts ?? {}), [transactionDrafts]); // Custom hooks @@ -387,14 +388,13 @@ function MoneyRequestHeaderSecondaryActions({reportID, onBackButtonPress}: Money userBillingGracePeriodEnds, amountOwed, ownerBillingGracePeriodEnd, - isRestrictedToPreferredPolicy, - preferredPolicyID, + restrictedPreferredPolicy: isRestrictedToPreferredPolicy ? preferredPolicy : undefined, transaction, currentUserAccountID: accountID, currentUserEmail: currentUserEmail ?? '', currentUserLocalCurrency: localCurrencyCode ?? CONST.CURRENCY.USD, filteredPoliciesCount: filteredPoliciesInfo?.filteredPoliciesCount ?? 0, - firstPolicyID: filteredPoliciesInfo?.firstPolicyID, + firstPolicy: filteredPoliciesInfo?.firstPolicy, }; const secondaryActionsImplementation: Partial< diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index 3ff13bc28e49..b2e8d69932b6 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -852,7 +852,8 @@ const isPolicyEmployee = (policyID: string | undefined, policy: OnyxEntry, currentUserAccountID: number | undefined): boolean => !!currentUserAccountID && policy?.ownerAccountID === currentUserAccountID; +const isPolicyOwner = (policy: OnyxInputOrEntry>, currentUserAccountID: number | undefined): boolean => + !!currentUserAccountID && policy?.ownerAccountID === currentUserAccountID; /** * Create an object mapping member emails to their accountIDs. Filter for members without errors if includeMemberWithErrors is false, and get the login email from the personalDetail object using the accountID. diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 95c1490246a4..1f58ffe541bd 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -103,6 +103,7 @@ import type {AddCommentOrAttachmentParams} from './API/parameters'; import type {FormulaContext, compute as computeFormula, computeWithMetadata as computeFormulaWithMetadata} from './Formula'; import type {MoneyRequestNavigatorParamList, ReportsSplitNavigatorParamList} from './Navigation/types'; import type {LastVisibleMessage} from './ReportActionsUtils'; +import type {BillingRestrictionPolicy} from './SubscriptionUtils'; import type {AvatarSource} from './UserAvatarUtils'; import {isIntuitEnterpriseSuiteConnection} from './AccountingUtils'; @@ -3238,6 +3239,27 @@ function shouldCurrentUserSubmitReport(iouReport: OnyxEntry, chatReport: return isOwnReportAndRetracted || isWaitingForSubmissionFromCurrentUser(chatReport, policy); } +/** + * Sends the user to the restricted action screen when the workspace's required payment is overdue, so every billable + * entry point gates on the same check instead of repeating it. Returns whether it navigated, so the caller can bail out. + * + * Takes the resolved policy rather than an ID on purpose: callers pass the snapshot they already hold, instead of this + * file's independently-timed `allPolicies` cache, which can lag a caller's own snapshot and let the gate fail open. + */ +function navigateToRestrictedActionIfNeeded( + policy: OnyxEntry, + ownerBillingGracePeriodEnd: OnyxEntry, + userBillingGracePeriodEnds: OnyxCollection, + amountOwed: OnyxEntry, + currentUserAccountID: number, +): boolean { + if (!policy || !shouldRestrictUserBillableActions(policy, ownerBillingGracePeriodEnd, userBillingGracePeriodEnds, amountOwed, currentUserAccountID)) { + return false; + } + Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(policy.id)); + return true; +} + /** * Returns the dropdown options for the add expense button * @param iouReport - The IOU report to add an expense to @@ -3295,9 +3317,8 @@ function getAddExpenseDropdownOptions({ if ( policy && policy.type !== CONST.POLICY.TYPE.PERSONAL && - shouldRestrictUserBillableActions(policy, ownerBillingGracePeriodEnd, userBillingGracePeriodEnds, amountOwed, currentUserAccountID) + navigateToRestrictedActionIfNeeded(policy, ownerBillingGracePeriodEnd, userBillingGracePeriodEnds, amountOwed, currentUserAccountID) ) { - Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(policy.id)); return; } startMoneyRequest(CONST.IOU.TYPE.SUBMIT, iouReportID, draftTransactionIDs, undefined, false, iouRequestBackToReport); @@ -3312,8 +3333,7 @@ function getAddExpenseDropdownOptions({ if (!iouReportID) { return; } - if (policy && shouldRestrictUserBillableActions(policy, ownerBillingGracePeriodEnd, userBillingGracePeriodEnds, amountOwed, currentUserAccountID)) { - Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(policy.id)); + if (navigateToRestrictedActionIfNeeded(policy, ownerBillingGracePeriodEnd, userBillingGracePeriodEnds, amountOwed, currentUserAccountID)) { return; } if (blockDistanceRequestIfNeeded?.()) { @@ -3329,8 +3349,7 @@ function getAddExpenseDropdownOptions({ icon: icons.ReceiptPlus, sentryLabel: CONST.SENTRY_LABEL.MORE_MENU.ADD_EXPENSE_EXISTING, onSelected: () => { - if (policy && shouldRestrictUserBillableActions(policy, ownerBillingGracePeriodEnd, userBillingGracePeriodEnds, amountOwed, currentUserAccountID)) { - Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(policy.id)); + if (navigateToRestrictedActionIfNeeded(policy, ownerBillingGracePeriodEnd, userBillingGracePeriodEnds, amountOwed, currentUserAccountID)) { return; } openUnreportedExpense(iouReportID, unreportedExpenseBackToReport); @@ -12279,8 +12298,15 @@ type CreateDraftTransactionParams = { userBillingGracePeriodEnds: OnyxCollection; amountOwed: OnyxEntry; ownerBillingGracePeriodEnd?: OnyxEntry; - isRestrictedToPreferredPolicy?: boolean; - preferredPolicyID?: string; + /** + * The preferred workspace, set only when the user is restricted to submitting there. One non-nullable value + * instead of an `isRestrictedToPreferredPolicy`/`preferredPolicyID`/`preferredPolicy` trio: the fast path it + * unlocks skips the participant picker, which is where the billing restriction is otherwise enforced, so + * "submit straight to the preferred workspace" and "here is the policy to gate on" have to be the same fact. + * As three parallel optionals a caller could supply the flag and the ID but not the policy — the flag and the + * ID come from the security group, the policy from Onyx — and silently disable the gate. + */ + restrictedPreferredPolicy?: BillingRestrictionPolicy; transaction: OnyxEntry; currentUserAccountID: number; currentUserEmail: string; @@ -12294,7 +12320,12 @@ type CreateDraftTransactionParams = { /** Localized default name for a workspace created on the fly (e.g. "Submit to my employer" with no existing workspace). */ defaultWorkspaceName?: string; filteredPoliciesCount: number; - firstPolicyID: string | undefined; + /** + * The single accessible workspace, from the same caller snapshot that produced the count above. This is the only + * handle on that workspace — there is deliberately no parallel `firstPolicyID`, so "I have a workspace to submit + * to" and "I have the policy to gate on" are the same fact and cannot come apart. + */ + firstPolicy: BillingRestrictionPolicy | undefined; }; function createDraftTransactionAndNavigateToParticipantSelector({ @@ -12308,8 +12339,7 @@ function createDraftTransactionAndNavigateToParticipantSelector({ userBillingGracePeriodEnds, amountOwed, ownerBillingGracePeriodEnd, - isRestrictedToPreferredPolicy = false, - preferredPolicyID, + restrictedPreferredPolicy, transaction, currentUserAccountID, currentUserEmail, @@ -12317,7 +12347,7 @@ function createDraftTransactionAndNavigateToParticipantSelector({ submitDestination = CONST.IOU.SUBMIT_DESTINATION.FRIEND, defaultWorkspaceName = '', filteredPoliciesCount, - firstPolicyID, + firstPolicy, }: CreateDraftTransactionParams): void { const transactionID = transaction?.transactionID; if (!transactionID || !reportID) { @@ -12361,8 +12391,7 @@ function createDraftTransactionAndNavigateToParticipantSelector({ } as Transaction); if (actionName === CONST.IOU.ACTION.CATEGORIZE) { - if (activePolicy && shouldRestrictUserBillableActions(activePolicy, ownerBillingGracePeriodEnd, userBillingGracePeriodEnds, amountOwed, currentUserAccountID)) { - Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(activePolicy.id)); + if (navigateToRestrictedActionIfNeeded(activePolicy, ownerBillingGracePeriodEnd, userBillingGracePeriodEnds, amountOwed, currentUserAccountID)) { return; } @@ -12407,14 +12436,14 @@ function createDraftTransactionAndNavigateToParticipantSelector({ return; } - const policyExpenseReportID = getPolicyExpenseChat(deprecatedCurrentUserAccountID, firstPolicyID)?.reportID; + const policyExpenseReportID = getPolicyExpenseChat(deprecatedCurrentUserAccountID, firstPolicy?.id)?.reportID; setMoneyRequestParticipants(transactionID, [ { selected: true, accountID: 0, isPolicyExpenseChat: true, reportID: policyExpenseReportID, - policyID: firstPolicyID, + policyID: firstPolicy?.id, searchText: activePolicy?.name, }, ]); @@ -12440,7 +12469,7 @@ function createDraftTransactionAndNavigateToParticipantSelector({ // "Submit to my employer" routes the expense into a workspace the user can submit to, based on how many they belong to. // Per issue #92704 the count spans every paid workspace the user is a member of (Collect/Control/Submit), so we reuse the - // shared shouldShowPolicy-based count (filteredPoliciesCount/firstPolicyID) that also backs the workspaces-only picker. + // shared shouldShowPolicy-based count (filteredPoliciesCount/firstPolicy) that also backs the workspaces-only picker. if (actionName === CONST.IOU.ACTION.SUBMIT && submitDestination === CONST.IOU.SUBMIT_DESTINATION.EMPLOYER) { // No accessible workspace: spin up a new Submit (submit2026) workspace and drop the expense into its draft report. if (filteredPoliciesCount === 0) { @@ -12464,8 +12493,13 @@ function createDraftTransactionAndNavigateToParticipantSelector({ } // Exactly one accessible workspace: skip the destination picker and submit straight to that workspace. - if (filteredPoliciesCount === 1 && firstPolicyID) { - const policyExpenseReport = getPolicyExpenseChat(deprecatedCurrentUserAccountID, firstPolicyID); + if (filteredPoliciesCount === 1 && firstPolicy) { + // The destination picker we skip here is where the billing restriction is normally enforced, so gate it here too. + if (navigateToRestrictedActionIfNeeded(firstPolicy, ownerBillingGracePeriodEnd, userBillingGracePeriodEnds, amountOwed, currentUserAccountID)) { + return; + } + + const policyExpenseReport = getPolicyExpenseChat(deprecatedCurrentUserAccountID, firstPolicy.id); if (policyExpenseReport) { // The draft inherits the source expense's unreported ID from the self DM. The picker we skip here is what // normally rebinds it to the destination chat, so without this the confirmation page still reads the draft @@ -12497,8 +12531,13 @@ function createDraftTransactionAndNavigateToParticipantSelector({ if (actionName === CONST.IOU.ACTION.SUBMIT || filteredPoliciesCount > 0) { // Check if user is restricted to preferred workspace for submit tracked expenses - if (isRestrictedToPreferredPolicy && preferredPolicyID) { - const policyExpenseReport = getPolicyExpenseChat(deprecatedCurrentUserAccountID, preferredPolicyID); + if (restrictedPreferredPolicy) { + // This branch skips the participant picker as well, so it needs the same billing-restriction gate. + if (navigateToRestrictedActionIfNeeded(restrictedPreferredPolicy, ownerBillingGracePeriodEnd, userBillingGracePeriodEnds, amountOwed, currentUserAccountID)) { + return; + } + + const policyExpenseReport = getPolicyExpenseChat(deprecatedCurrentUserAccountID, restrictedPreferredPolicy.id); if (policyExpenseReport) { // Same picker-skip as the single-workspace branch above, so the draft needs the same rebinding. diff --git a/src/libs/SubscriptionUtils.ts b/src/libs/SubscriptionUtils.ts index 410a5fa07ec5..ccc33056eddd 100644 --- a/src/libs/SubscriptionUtils.ts +++ b/src/libs/SubscriptionUtils.ts @@ -504,11 +504,18 @@ function canCancelSubscription( return true; } +/** + * The only policy fields the billing restriction depends on: `ownerAccountID` for the check itself and `id` for the + * restricted-action route. Callers can pass this fixed-size projection instead of a whole `Policy`, so a `useOnyx` + * selector carrying a policy to the gate does not drag `employeeList`/`customUnits` through its output deep-compare. + */ +type BillingRestrictionPolicy = Pick; + /** * Whether the user's billable actions should be restricted. */ function shouldRestrictUserBillableActions( - policy: OnyxEntry, + policy: OnyxEntry>, ownerBillingGracePeriodEnd: OnyxEntry, userBillingGracePeriodEnds: OnyxCollection, amountOwed: OnyxEntry, @@ -725,4 +732,4 @@ export { hasInsufficientFundsError, }; -export type {DiscountInfo}; +export type {BillingRestrictionPolicy, DiscountInfo}; diff --git a/src/pages/DynamicReportDetailsPage.tsx b/src/pages/DynamicReportDetailsPage.tsx index ca0cdc33b84f..627c4bb05d0e 100644 --- a/src/pages/DynamicReportDetailsPage.tsx +++ b/src/pages/DynamicReportDetailsPage.tsx @@ -147,7 +147,7 @@ import type {ValueOf} from 'type-fest'; import {StackActions, useFocusEffect} from '@react-navigation/native'; import {delegateEmailSelector} from '@selectors/Account'; import {hasSeenTourSelector} from '@selectors/Onboarding'; -import {createFilteredPoliciesInfoSelector, createHasWorkspaceToSubmitToSelector} from '@selectors/Policy'; +import {billingRestrictionPolicySelector, createFilteredPoliciesInfoSelector, createHasWorkspaceToSubmitToSelector} from '@selectors/Policy'; import {validTransactionDraftIDsSelector} from '@selectors/TransactionDraft'; import React, {useCallback, useEffect, useMemo, useState} from 'react'; import {View} from 'react-native'; @@ -244,6 +244,7 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report const {getCurrencyDecimals} = useCurrencyListActions(); const filteredPoliciesInfoSelector = useMemo(() => createFilteredPoliciesInfoSelector(currentUserPersonalDetails?.email), [currentUserPersonalDetails?.email]); const [filteredPoliciesInfo] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: filteredPoliciesInfoSelector}); + const [preferredPolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(preferredPolicyID)}`, {selector: billingRestrictionPolicySelector}); const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const {showConfirmModal} = useConfirmModal(); const reportForHeader = useMemo(() => getReportForHeader(report, parentReport), [report, parentReport]); @@ -563,14 +564,13 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report userBillingGracePeriodEnds, amountOwed, ownerBillingGracePeriodEnd, - isRestrictedToPreferredPolicy, - preferredPolicyID, + restrictedPreferredPolicy: isRestrictedToPreferredPolicy ? preferredPolicy : undefined, transaction: iouTransaction, currentUserAccountID: currentUserPersonalDetails.accountID, currentUserEmail: currentUserPersonalDetails.email ?? '', currentUserLocalCurrency, filteredPoliciesCount: filteredPoliciesInfo?.filteredPoliciesCount ?? 0, - firstPolicyID: filteredPoliciesInfo?.firstPolicyID, + firstPolicy: filteredPoliciesInfo?.firstPolicy, }; // "Submit to someone" splits into two destinations here too, matching the track-expense whisper: // submit to an individual ("a friend") or a submit-enabled workspace ("my employer"). @@ -635,7 +635,7 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report currentUserEmail: currentUserPersonalDetails.email ?? '', currentUserLocalCurrency, filteredPoliciesCount: filteredPoliciesInfo?.filteredPoliciesCount ?? 0, - firstPolicyID: filteredPoliciesInfo?.firstPolicyID, + firstPolicy: filteredPoliciesInfo?.firstPolicy, }); }, }); @@ -662,7 +662,7 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report currentUserEmail: currentUserPersonalDetails.email ?? '', currentUserLocalCurrency, filteredPoliciesCount: filteredPoliciesInfo?.filteredPoliciesCount ?? 0, - firstPolicyID: filteredPoliciesInfo?.firstPolicyID, + firstPolicy: filteredPoliciesInfo?.firstPolicy, }); }, }); @@ -799,7 +799,7 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report showLastMemberLeavingModal, isSmallScreenWidth, isRestrictedToPreferredPolicy, - preferredPolicyID, + preferredPolicy, introSelected, draftTransactionIDs, activePolicy, @@ -810,7 +810,7 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report iouOriginalTransaction, hasWorkspaceToSubmitTo, filteredPoliciesInfo?.filteredPoliciesCount, - filteredPoliciesInfo?.firstPolicyID, + filteredPoliciesInfo?.firstPolicy, parentReport, delegateEmail, conciergeReportID, diff --git a/src/pages/inbox/report/actionContents/ChatActionableButtons.tsx b/src/pages/inbox/report/actionContents/ChatActionableButtons.tsx index d80e92c5a686..3900579a663b 100644 --- a/src/pages/inbox/report/actionContents/ChatActionableButtons.tsx +++ b/src/pages/inbox/report/actionContents/ChatActionableButtons.tsx @@ -42,7 +42,7 @@ import type * as OnyxTypes from '@src/types/onyx'; import type {ValueOf} from 'type-fest'; -import {createFilteredPoliciesInfoSelector, createHasWorkspaceToSubmitToSelector} from '@selectors/Policy'; +import {billingRestrictionPolicySelector, createFilteredPoliciesInfoSelector, createHasWorkspaceToSubmitToSelector} from '@selectors/Policy'; import {validTransactionDraftIDsSelector} from '@selectors/TransactionDraft'; import React from 'react'; @@ -198,6 +198,7 @@ function TrackExpenseButtons({action, actionOwnerReportID}: TrackExpenseButtonsP const [amountOwed] = useOnyx(ONYXKEYS.NVP_PRIVATE_AMOUNT_OWED); const [ownerBillingGracePeriodEnd] = useOnyx(ONYXKEYS.NVP_PRIVATE_OWNER_BILLING_GRACE_PERIOD_END); const [filteredPoliciesInfo] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: createFilteredPoliciesInfoSelector(personalDetail.email)}); + const [preferredPolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(preferredPolicyID)}`, {selector: billingRestrictionPolicySelector}); const [trackExpenseTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(getOriginalMessage(action)?.transactionID)}`); const [actionOwnerReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getNonEmptyStringOnyxID(actionOwnerReportID)}`); const [hasWorkspaceToSubmitTo] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: createHasWorkspaceToSubmitToSelector(personalDetail.login)}); @@ -217,7 +218,8 @@ function TrackExpenseButtons({action, actionOwnerReportID}: TrackExpenseButtonsP currentUserEmail: personalDetail.email ?? '', currentUserLocalCurrency: personalDetail.localCurrencyCode ?? CONST.CURRENCY.USD, filteredPoliciesCount: filteredPoliciesInfo?.filteredPoliciesCount ?? 0, - firstPolicyID: filteredPoliciesInfo?.firstPolicyID, + firstPolicy: filteredPoliciesInfo?.firstPolicy, + restrictedPreferredPolicy: isRestrictedToPreferredPolicy ? preferredPolicy : undefined, }; const isSplitExpense = isSplitChildTransaction(trackExpenseTransaction); const shouldShowSubmitButtons = !isSplitExpense || !!hasWorkspaceToSubmitTo; @@ -225,8 +227,6 @@ function TrackExpenseButtons({action, actionOwnerReportID}: TrackExpenseButtonsP const submit = (submitDestination?: ValueOf) => { createDraftTransactionAndNavigateToParticipantSelector({ ...baseDraftTransactionParams, - isRestrictedToPreferredPolicy, - preferredPolicyID, actionName: CONST.IOU.ACTION.SUBMIT, submitDestination, defaultWorkspaceName: submitDestination && generateDefaultWorkspaceName(personalDetail.email ?? '', lastWorkspaceNumber, translate, personalDetail.displayName), diff --git a/src/selectors/Policy.ts b/src/selectors/Policy.ts index 81d7d5886c2f..926836b3e707 100644 --- a/src/selectors/Policy.ts +++ b/src/selectors/Policy.ts @@ -19,6 +19,7 @@ import { isTimeTrackingEnabled, shouldShowPolicy, } from '@libs/PolicyUtils'; +import type {BillingRestrictionPolicy} from '@libs/SubscriptionUtils'; import {getDefaultAvatarURL} from '@libs/UserAvatarUtils'; import CONST from '@src/CONST'; @@ -355,30 +356,38 @@ type FilteredPoliciesInfo = { /** Number of policies that should be shown to the user (short-circuited at 2) */ filteredPoliciesCount: number; - /** ID of the first policy that should be shown to the user */ - firstPolicyID: string | undefined; + /** + * The first policy that should be shown to the user, so callers can gate on it without re-reading a + * separately-timed policy cache. Projected to only the fields the billing gate needs, so this output stays + * fixed-size (see `policyMapper` above) and no `employeeList`/`customUnits` is deep-compared on a POLICY write. + */ + firstPolicy: BillingRestrictionPolicy | undefined; }; +// Fixed-size output: same shape on 5 workspaces or 5000, so no employeeList/customUnits deepEqual const createFilteredPoliciesInfoSelector = (email: string | undefined) => (policies: OnyxCollection): FilteredPoliciesInfo => { let filteredPoliciesCount = 0; - let firstPolicyID: string | undefined; + let firstPolicy: BillingRestrictionPolicy | undefined; for (const policy of Object.values(policies ?? {})) { if (!policy || !shouldShowPolicy(policy, false, email) || isTeachersUnitePolicyID(policy.id)) { continue; } if (filteredPoliciesCount === 0) { - firstPolicyID = policy.id; + firstPolicy = {id: policy.id, ownerAccountID: policy.ownerAccountID}; } filteredPoliciesCount++; if (filteredPoliciesCount > 1) { break; } } - return {filteredPoliciesCount, firstPolicyID}; + return {filteredPoliciesCount, firstPolicy}; }; +/** The preferred workspace, projected to only what the billing gate reads, for the same reason as `firstPolicy` above. */ +const billingRestrictionPolicySelector = (policy: OnyxEntry): BillingRestrictionPolicy | undefined => (policy ? {id: policy.id, ownerAccountID: policy.ownerAccountID} : undefined); + const hasOnlyPersonalPoliciesSelector = (policies: OnyxCollection): boolean => { return !Object.values(policies ?? {}).some((policy) => policy && policy.type !== CONST.POLICY.TYPE.PERSONAL && policy.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); }; @@ -521,6 +530,7 @@ export { createOwnedPaidPoliciesCountsSelector, createCopySettingsEligibleTargetsSelector, createFilteredPoliciesInfoSelector, + billingRestrictionPolicySelector, createWorkspaceListPoliciesSelector, activeAdminPoliciesSelector, hasActiveAdminPoliciesSelector, diff --git a/tests/actions/IOU/CreateDraftTransactionTest.ts b/tests/actions/IOU/CreateDraftTransactionTest.ts index 2028c4d46e78..f16d1c53c012 100644 --- a/tests/actions/IOU/CreateDraftTransactionTest.ts +++ b/tests/actions/IOU/CreateDraftTransactionTest.ts @@ -5,6 +5,7 @@ import Navigation from '@libs/Navigation/Navigation'; import type * as PolicyUtils from '@libs/PolicyUtils'; import '@libs/actions/IOU/MoneyRequest'; import {createDraftTransactionAndNavigateToParticipantSelector} from '@libs/ReportUtils'; +import type {BillingRestrictionPolicy} from '@libs/SubscriptionUtils'; import CONST from '@src/CONST'; import IntlStore from '@src/languages/IntlStore'; @@ -20,6 +21,7 @@ import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import currencyList from '../../unit/currencyList.json'; +import createRandomPolicy from '../../utils/collections/policies'; import createRandomReportAction from '../../utils/collections/reportActions'; import {createPolicyExpenseChat, createRandomReport, createSelfDM} from '../../utils/collections/reports'; import createRandomTransaction from '../../utils/collections/transaction'; @@ -174,7 +176,7 @@ describe('actions/IOU', () => { currentUserEmail: RORY_EMAIL, currentUserLocalCurrency: '', filteredPoliciesCount: 0, - firstPolicyID: undefined, + firstPolicy: undefined, }); await waitForBatchedUpdates(); @@ -230,7 +232,7 @@ describe('actions/IOU', () => { currentUserEmail: RORY_EMAIL, currentUserLocalCurrency: '', filteredPoliciesCount: 0, - firstPolicyID: undefined, + firstPolicy: undefined, }); await waitForBatchedUpdates(); @@ -281,7 +283,7 @@ describe('actions/IOU', () => { currentUserEmail: RORY_EMAIL, currentUserLocalCurrency: '', filteredPoliciesCount: 0, - firstPolicyID: undefined, + firstPolicy: undefined, }); await waitForBatchedUpdates(); @@ -321,7 +323,7 @@ describe('actions/IOU', () => { currentUserEmail: RORY_EMAIL, currentUserLocalCurrency: '', filteredPoliciesCount: 0, - firstPolicyID: undefined, + firstPolicy: undefined, }); await waitForBatchedUpdates(); @@ -370,7 +372,7 @@ describe('actions/IOU', () => { currentUserEmail: RORY_EMAIL, currentUserLocalCurrency: '', filteredPoliciesCount: 0, - firstPolicyID: undefined, + firstPolicy: undefined, }); await waitForBatchedUpdates(); @@ -412,7 +414,7 @@ describe('actions/IOU', () => { currentUserEmail: RORY_EMAIL, currentUserLocalCurrency: '', filteredPoliciesCount: 0, - firstPolicyID: undefined, + firstPolicy: undefined, }); await waitForBatchedUpdates(); @@ -449,7 +451,7 @@ describe('actions/IOU', () => { currentUserEmail: RORY_EMAIL, currentUserLocalCurrency: '', filteredPoliciesCount: 0, - firstPolicyID: undefined, + firstPolicy: undefined, }); await waitForBatchedUpdates(); @@ -467,6 +469,10 @@ describe('actions/IOU', () => { describe('submitting a tracked expense to an employer', () => { const POLICY_ID = 'policy-with-access'; + // A unix timestamp well in the past, so the owner's billing grace period has already elapsed. + const EXPIRED_GRACE_PERIOD_END = 1600000000; + // The workspace as the callers' policy selectors hand it to the billing gate: only the fields the gate reads. + const ACCESSIBLE_POLICY: BillingRestrictionPolicy = {id: POLICY_ID, ownerAccountID: RORY_ACCOUNT_ID}; async function setUpSelfDMTrackedExpense() { const selfDMReport = createSelfDM(1, RORY_ACCOUNT_ID); @@ -489,6 +495,19 @@ describe('actions/IOU', () => { return {selfDMReport, policyExpenseChat, trackedExpense}; } + /** Builds a workspace the current user owns, which makes `shouldRestrictUserBillableActions` fire once an amount is owed past the grace period. */ + async function setUpRestrictedPolicy() { + const policy: Policy = { + ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), + id: POLICY_ID, + ownerAccountID: RORY_ACCOUNT_ID, + owner: RORY_EMAIL, + }; + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, policy); + await waitForBatchedUpdates(); + return policy; + } + function getConfirmationRouteBackTo() { const confirmationRoute = jest .mocked(Navigation.navigate) @@ -529,7 +548,7 @@ describe('actions/IOU', () => { currentUserLocalCurrency: '', submitDestination: CONST.IOU.SUBMIT_DESTINATION.EMPLOYER, filteredPoliciesCount: 1, - firstPolicyID: POLICY_ID, + firstPolicy: ACCESSIBLE_POLICY, }); await waitForBatchedUpdates(); @@ -544,6 +563,74 @@ describe('actions/IOU', () => { ); }); + it('should show the restricted action screen when the only accessible workspace has an expired required payment', async () => { + // Given a tracked self DM expense and a single workspace the user owns that is past its billing grace period + const {selfDMReport, trackedExpense} = await setUpSelfDMTrackedExpense(); + const restrictedPolicy = await setUpRestrictedPolicy(); + + // When the expense is submitted to the employer, which would otherwise skip the destination picker + createDraftTransactionAndNavigateToParticipantSelector({ + reportID: selfDMReport.reportID, + reportActions: undefined, + actionName: CONST.IOU.ACTION.SUBMIT, + reportActionID: '1', + introSelected: {choice: CONST.ONBOARDING_CHOICES.MANAGE_TEAM}, + draftTransactionIDs: [], + activePolicy: undefined, + userBillingGracePeriodEnds: undefined, + ownerBillingGracePeriodEnd: EXPIRED_GRACE_PERIOD_END, + amountOwed: 1000, + transaction: trackedExpense, + currentUserAccountID: RORY_ACCOUNT_ID, + currentUserEmail: RORY_EMAIL, + currentUserLocalCurrency: '', + submitDestination: CONST.IOU.SUBMIT_DESTINATION.EMPLOYER, + filteredPoliciesCount: 1, + firstPolicy: restrictedPolicy, + }); + await waitForBatchedUpdates(); + + // Then the user lands on the restricted action screen instead of the confirmation page + expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.RESTRICTED_ACTION.getRoute(POLICY_ID)); + expect(Navigation.navigate).not.toHaveBeenCalledWith(expect.stringContaining('confirmation')); + + // And the draft is left unbound, so nothing can be submitted to the restricted workspace + const draftTransaction = await getDraftTransaction(trackedExpense.transactionID); + expect(draftTransaction?.reportID).toBe(CONST.REPORT.UNREPORTED_REPORT_ID); + }); + + it('should show the restricted action screen when the preferred workspace has an expired required payment', async () => { + // Given a tracked self DM expense and a preferred workspace the user owns that is past its billing grace period + const {selfDMReport, trackedExpense} = await setUpSelfDMTrackedExpense(); + const restrictedPolicy = await setUpRestrictedPolicy(); + + // When the expense is submitted, which would otherwise skip the participant picker for the preferred workspace + createDraftTransactionAndNavigateToParticipantSelector({ + reportID: selfDMReport.reportID, + reportActions: undefined, + actionName: CONST.IOU.ACTION.SUBMIT, + reportActionID: '1', + introSelected: {choice: CONST.ONBOARDING_CHOICES.MANAGE_TEAM}, + draftTransactionIDs: [], + activePolicy: undefined, + userBillingGracePeriodEnds: undefined, + ownerBillingGracePeriodEnd: EXPIRED_GRACE_PERIOD_END, + amountOwed: 1000, + restrictedPreferredPolicy: restrictedPolicy, + transaction: trackedExpense, + currentUserAccountID: RORY_ACCOUNT_ID, + currentUserEmail: RORY_EMAIL, + currentUserLocalCurrency: '', + filteredPoliciesCount: 1, + firstPolicy: restrictedPolicy, + }); + await waitForBatchedUpdates(); + + // Then the user lands on the restricted action screen instead of the confirmation page + expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.RESTRICTED_ACTION.getRoute(POLICY_ID)); + expect(Navigation.navigate).not.toHaveBeenCalledWith(expect.stringContaining('confirmation')); + }); + it('should send the user back to the report they are viewing when a draft workspace is created', async () => { // Given a tracked self DM expense the user drilled into, so the expense thread is the visible report const {selfDMReport, trackedExpense} = await setUpSelfDMTrackedExpense(); @@ -567,7 +654,7 @@ describe('actions/IOU', () => { submitDestination: CONST.IOU.SUBMIT_DESTINATION.EMPLOYER, defaultWorkspaceName: "Rory's Workspace", filteredPoliciesCount: 0, - firstPolicyID: undefined, + firstPolicy: undefined, }); await waitForBatchedUpdates(); @@ -598,7 +685,7 @@ describe('actions/IOU', () => { submitDestination: CONST.IOU.SUBMIT_DESTINATION.EMPLOYER, defaultWorkspaceName: "Rory's Workspace", filteredPoliciesCount: 0, - firstPolicyID: undefined, + firstPolicy: undefined, }); await waitForBatchedUpdates(); @@ -627,7 +714,7 @@ describe('actions/IOU', () => { currentUserLocalCurrency: '', submitDestination: CONST.IOU.SUBMIT_DESTINATION.EMPLOYER, filteredPoliciesCount: 2, - firstPolicyID: POLICY_ID, + firstPolicy: ACCESSIBLE_POLICY, }); await waitForBatchedUpdates(); diff --git a/tests/actions/IOUTest/TrackExpenseTest.ts b/tests/actions/IOUTest/TrackExpenseTest.ts index 9d21fc493a0c..5ea7bd77654c 100644 --- a/tests/actions/IOUTest/TrackExpenseTest.ts +++ b/tests/actions/IOUTest/TrackExpenseTest.ts @@ -404,7 +404,7 @@ describe('actions/IOU/TrackExpense', () => { currentUserEmail: RORY_EMAIL, currentUserLocalCurrency: '', filteredPoliciesCount: 0, - firstPolicyID: undefined, + firstPolicy: undefined, }); await waitForBatchedUpdates(); @@ -1523,7 +1523,7 @@ describe('actions/IOU/TrackExpense', () => { currentUserEmail: RORY_EMAIL, currentUserLocalCurrency: '', filteredPoliciesCount: 1, - firstPolicyID: policy.id, + firstPolicy: policy, }); await waitForBatchedUpdates(); diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index c0354efbabd0..a73f831b528d 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -19918,7 +19918,7 @@ describe('ReportUtils', () => { currentUserEmail, currentUserLocalCurrency: '', filteredPoliciesCount: 0, - firstPolicyID: undefined, + firstPolicy: undefined, }); expect(Navigation.navigate).not.toHaveBeenCalled(); @@ -19962,7 +19962,7 @@ describe('ReportUtils', () => { currentUserEmail, currentUserLocalCurrency: '', filteredPoliciesCount: 0, - firstPolicyID: undefined, + firstPolicy: undefined, }); // Then it should navigate to the restricted action page @@ -20002,7 +20002,7 @@ describe('ReportUtils', () => { currentUserEmail, currentUserLocalCurrency: '', filteredPoliciesCount: 0, - firstPolicyID: undefined, + firstPolicy: undefined, }); // Then it should navigate to the restricted action page @@ -20046,7 +20046,7 @@ describe('ReportUtils', () => { currentUserEmail, currentUserLocalCurrency: '', filteredPoliciesCount: 0, - firstPolicyID: undefined, + firstPolicy: undefined, }); // Then it should navigate to the category step @@ -20100,7 +20100,7 @@ describe('ReportUtils', () => { currentUserEmail, currentUserLocalCurrency: '', filteredPoliciesCount: 1, - firstPolicyID: ownPolicy.id, + firstPolicy: ownPolicy, }); // Then it should automatically pick the available policy and navigate to the category step @@ -20141,7 +20141,7 @@ describe('ReportUtils', () => { currentUserEmail, currentUserLocalCurrency: '', filteredPoliciesCount: 0, - firstPolicyID: undefined, + firstPolicy: undefined, }); // Then it should navigate to the upgrade page because no policies were found to categorize with @@ -20197,7 +20197,7 @@ describe('ReportUtils', () => { currentUserEmail, currentUserLocalCurrency: '', filteredPoliciesCount: 2, - firstPolicyID: policy1.id, + firstPolicy: policy1, }); // Then it should navigate to the upgrade page because it's ambiguous which policy to use @@ -20249,7 +20249,7 @@ describe('ReportUtils', () => { currentUserEmail, currentUserLocalCurrency: '', filteredPoliciesCount: 0, - firstPolicyID: undefined, + firstPolicy: undefined, }); // Then it should log a warning and not navigate @@ -20299,7 +20299,7 @@ describe('ReportUtils', () => { currentUserEmail, currentUserLocalCurrency: '', filteredPoliciesCount: 0, - firstPolicyID: undefined, + firstPolicy: undefined, }); // Then it should NOT navigate to restricted action page, but to category step @@ -20349,7 +20349,7 @@ describe('ReportUtils', () => { currentUserEmail, currentUserLocalCurrency: '', filteredPoliciesCount: 0, - firstPolicyID: undefined, + firstPolicy: undefined, }); // Then it should navigate to restricted action page @@ -20392,7 +20392,7 @@ describe('ReportUtils', () => { currentUserEmail, currentUserLocalCurrency: '', filteredPoliciesCount: 1, - firstPolicyID: policyFromParam.id, + firstPolicy: policyFromParam, }); // Then it should pick the policy from the policies param and navigate to the category step @@ -20439,7 +20439,7 @@ describe('ReportUtils', () => { currentUserEmail, currentUserLocalCurrency: '', filteredPoliciesCount: 1, - firstPolicyID: policyFromParam.id, + firstPolicy: policyFromParam, }); // Then it should navigate to the participant selector step @@ -20479,7 +20479,7 @@ describe('ReportUtils', () => { currentUserEmail, currentUserLocalCurrency: '', filteredPoliciesCount: 0, - firstPolicyID: undefined, + firstPolicy: undefined, }); // Then it should still navigate to participant selector since action is SUBMIT (SUBMIT always goes to participants)