Skip to content
8 changes: 4 additions & 4 deletions src/components/MoneyRequestHeaderSecondaryActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<
Expand Down
3 changes: 2 additions & 1 deletion src/libs/PolicyUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -852,7 +852,8 @@ const isPolicyEmployee = (policyID: string | undefined, policy: OnyxEntry<Policy
/**
* Checks if the current user is an owner (creator) of the policy.
*/
const isPolicyOwner = (policy: OnyxInputOrEntry<Policy>, currentUserAccountID: number | undefined): boolean => !!currentUserAccountID && policy?.ownerAccountID === currentUserAccountID;
const isPolicyOwner = (policy: OnyxInputOrEntry<Pick<Policy, 'ownerAccountID'>>, 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.
Expand Down
81 changes: 60 additions & 21 deletions src/libs/ReportUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -3238,6 +3239,27 @@ function shouldCurrentUserSubmitReport(iouReport: OnyxEntry<Report>, 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<BillingRestrictionPolicy>,
ownerBillingGracePeriodEnd: OnyxEntry<number>,
userBillingGracePeriodEnds: OnyxCollection<BillingGraceEndPeriod>,
amountOwed: OnyxEntry<number>,
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
Expand Down Expand Up @@ -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);
Expand All @@ -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?.()) {
Expand All @@ -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);
Expand Down Expand Up @@ -12279,8 +12298,15 @@ type CreateDraftTransactionParams = {
userBillingGracePeriodEnds: OnyxCollection<BillingGraceEndPeriod>;
amountOwed: OnyxEntry<number>;
ownerBillingGracePeriodEnd?: OnyxEntry<number>;
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<Transaction>;
currentUserAccountID: number;
currentUserEmail: string;
Expand All @@ -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({
Expand All @@ -12308,16 +12339,15 @@ function createDraftTransactionAndNavigateToParticipantSelector({
userBillingGracePeriodEnds,
amountOwed,
ownerBillingGracePeriodEnd,
isRestrictedToPreferredPolicy = false,
preferredPolicyID,
restrictedPreferredPolicy,
transaction,
currentUserAccountID,
currentUserEmail,
currentUserLocalCurrency,
submitDestination = CONST.IOU.SUBMIT_DESTINATION.FRIEND,
defaultWorkspaceName = '',
filteredPoliciesCount,
firstPolicyID,
firstPolicy,
}: CreateDraftTransactionParams): void {
const transactionID = transaction?.transactionID;
if (!transactionID || !reportID) {
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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,
},
]);
Expand All @@ -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) {
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down
11 changes: 9 additions & 2 deletions src/libs/SubscriptionUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Policy, 'id' | 'ownerAccountID'>;

/**
* Whether the user's billable actions should be restricted.
*/
function shouldRestrictUserBillableActions(
policy: OnyxEntry<Policy>,
policy: OnyxEntry<Pick<Policy, 'ownerAccountID'>>,
ownerBillingGracePeriodEnd: OnyxEntry<number>,
userBillingGracePeriodEnds: OnyxCollection<BillingGraceEndPeriod>,
amountOwed: OnyxEntry<number>,
Expand Down Expand Up @@ -725,4 +732,4 @@ export {
hasInsufficientFundsError,
};

export type {DiscountInfo};
export type {BillingRestrictionPolicy, DiscountInfo};
16 changes: 8 additions & 8 deletions src/pages/DynamicReportDetailsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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]);
Expand Down Expand Up @@ -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").
Expand Down Expand Up @@ -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,
});
},
});
Expand All @@ -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,
});
},
});
Expand Down Expand Up @@ -799,7 +799,7 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report
showLastMemberLeavingModal,
isSmallScreenWidth,
isRestrictedToPreferredPolicy,
preferredPolicyID,
preferredPolicy,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding the whole preferredPolicy object to this dependency array rebuilds the entire report-details menu on any field change to that policy.

This memo produces the full menu-item list. With the object itself as a dep, it now invalidates when a member is added, a category is edited, or pendingFields flips during any workspace write — none of which affect the menu.

Once the selector returns a Pick projection (see my comment on selectors/Policy.ts:352), depend on the scalar the gate actually reads instead:

Suggested change
preferredPolicy,
preferredPolicyID,
preferredPolicy?.ownerAccountID,

Same applies to filteredPoliciesInfo?.firstPolicy at L819.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 121218a, at the Onyx read rather than in the dependency array.

Swapping the dep to preferredPolicy?.ownerAccountID while the memo body still closes over preferredPolicy trips react-hooks/exhaustive-deps, and it leaves the underlying read pulling the whole policy. So instead both reads are projected:

  • preferredPolicy now uses billingRestrictionPolicySelector, a module-level Pick<Policy, 'id' | 'ownerAccountID'> projection.
  • filteredPoliciesInfo.firstPolicy is the same projection, per your comment on selectors/Policy.ts:352.

Because useOnyx wraps selectors in createMemoizedSelector, a fixed-size output means deepEqual returns the previous reference when nothing relevant changed. So depending on the object is now equivalent to depending on the scalars: a member add, a category edit, or a pendingFields flip no longer invalidates the menu.

filteredPoliciesInfo?.firstPolicyID is out of the dep array (the param is gone), and so is preferredPolicyID — it was only there to be passed through, and ESLint flagged it as unnecessary once restrictedPreferredPolicy replaced it.

introSelected,
draftTransactionIDs,
activePolicy,
Expand All @@ -810,7 +810,7 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report
iouOriginalTransaction,
hasWorkspaceToSubmitTo,
filteredPoliciesInfo?.filteredPoliciesCount,
filteredPoliciesInfo?.firstPolicyID,
filteredPoliciesInfo?.firstPolicy,
parentReport,
delegateEmail,
conciergeReportID,
Expand Down
Loading
Loading