Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion src/pages/iou/request/step/IOURequestStepDistanceMap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {shouldUseTransactionDraft} from '@libs/IOUUtils';
import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute';
import Navigation from '@libs/Navigation/Navigation';
import {isPolicyExpenseChat as isPolicyExpenseChatUtil} from '@libs/ReportUtils';
import shouldUseDefaultExpensePolicyUtil from '@libs/shouldUseDefaultExpensePolicy';
import {doesMoneyRequestDraftHaveUserInput, getRateID, getRequestType} from '@libs/TransactionUtils';

import CONST from '@src/CONST';
Expand Down Expand Up @@ -77,10 +78,25 @@ function IOURequestStepDistanceMap({
const isArchived = useReportIsArchived(report?.reportID);
const selfDMReport = useSelfDMReport();
const {policy} = usePolicyForTransaction({transaction, reportPolicyID: report?.policyID, action, iouType});
const blockDistanceRequestIfNeeded = useBlockDistanceRequest({policyID: policy?.id, isDistanceRequest: true});
const personalPolicy = usePersonalPolicy();
const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST);
const defaultExpensePolicy = useDefaultExpensePolicy();
const [amountOwed] = useOnyx(ONYXKEYS.NVP_PRIVATE_AMOUNT_OWED);
const [userBillingGracePeriodEnds] = useOnyx(ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_USER_BILLING_GRACE_PERIOD_END);
const [ownerBillingGracePeriodEnd] = useOnyx(ONYXKEYS.NVP_PRIVATE_OWNER_BILLING_GRACE_PERIOD_END);
const shouldUseDefaultExpensePolicy = shouldUseDefaultExpensePolicyUtil(
iouType,
defaultExpensePolicy,
amountOwed,
userBillingGracePeriodEnds,
ownerBillingGracePeriodEnd,
currentUserPersonalDetails.accountID,
);
const shouldAutoReportToDefaultWorkspace = shouldUseDefaultExpensePolicy && (!!defaultExpensePolicy?.autoReporting || !!personalPolicy?.autoReporting);
const blockDistanceRequestIfNeeded = useBlockDistanceRequest({
policyID: report?.policyID ?? (shouldAutoReportToDefaultWorkspace ? defaultExpensePolicy?.id : undefined),

@MelvinBot MelvinBot Sep 13, 2026

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.

You're right about the ?? semantics — _FAKE_ is truthy, so it short-circuits the fallback. But I traced it and it's a no-op here, for two independent reasons:

1. _FAKE_ and undefined behave identically inside the hook. getBlockReason only uses the ID as a key lookup into policiesRequiringMapOrGPS, which is built from the real POLICY collection:

src/hooks/useBlockDistanceRequest.ts:91

if (!policyIDToCheck || !policiesRequiringMapOrGPS?.[policyIDToCheck] || isEditingExistingDistanceRequest) {

No policy is ever stored under _FAKE_, so it bails at the second condition instead of the first. Same result, and no risk of a wrong workspace name leaking into the modal copy.

2. The fallback branch is unreachable when policyID is _FAKE_. The fallback only matters if shouldAutoReportToDefaultWorkspace is true, and that hard-gates on iouType === create:

src/libs/shouldUseDefaultExpensePolicy.ts:18

Every create distance entry point passes a freshly generated report ID that has no Onyx report — e.g. FloatingActionButtonAndPopover.tsx:36 feeding TrackDistanceMenuItem.tsx:53, plus the generateReportID() call sites in QuickCreationActionsBar, SearchActionsBarCreateButton, and useCreateNavigationSuggestions. So report is undefined, report?.policyID is undefined, and the fallback fires as intended. Conversely, when a report actually carries _FAKE_ (Self-DM / DM, from buildOptimisticChatReport's default), iouType is track/submit, so shouldAutoReportToDefaultWorkspace is false and the fallback would resolve to undefined anyway.

I couldn't construct a case where a _FAKE_ report reaches this step with iouType === create. If you know of one, say so and I'll fix it.

One note on scope: this expression is copied verbatim from mainDynamicIOURequestStepDistanceManual.tsx:161 and IOURequestStepDistanceOdometer.tsx:176. If you'd still rather normalize _FAKE_ defensively, I'd do it in all three at once so they don't drift — happy to push that if you want it.


view run · no recording available

isDistanceRequest: true,
});
const [skipConfirmation] = useOnyx(`${ONYXKEYS.COLLECTION.SKIP_CONFIRMATION}${transactionID}`);
const [optimisticWaypoints, setOptimisticWaypoints] = useState<WaypointCollection | null>(null);
const [betas] = useOnyx(ONYXKEYS.BETAS);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -349,8 +349,9 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) {
const transactionIDs = transactions?.map((tx) => tx.transactionID);
const [storedTransactions] = useTransactionsByID(transactionIDs);

// Only a workspace destination can enforce a workspace's distance rules.
const blockDistanceRequestIfNeeded = useBlockDistanceRequest({
policyID: policy?.id,
policyID: isPolicyExpenseChat ? policy?.id : undefined,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Keep sender-workspace checks for invoice distances

For invoice expenses, the applicable workspace comes from the isSender participant (as getIOURequestPolicyID() explicitly handles), while an invoice room and its participants are not policy expense chats. Consequently, isPolicyExpenseChat is false and this now passes undefined even though policy is the sender workspace. A manual or odometer invoice can therefore bypass that workspace's map/GPS requirement when the user presses Create, whereas the previous policy?.id check displayed the required blocking modal.

Useful? React with 👍 / 👎.

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.

Not valid. You are right that policy resolves to the sender workspace for invoices, but the scenario it describes can't happen

isDistanceRequest,
isManualDistanceRequest,
isOdometerDistanceRequest,
Expand Down
209 changes: 209 additions & 0 deletions tests/ui/IOURequestStepDistanceMapCommuterExclusionTest.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
import {act, render} from '@testing-library/react-native';

import {CurrentUserPersonalDetailsProvider} from '@components/CurrentUserPersonalDetailsProvider';
import {LocaleContextProvider} from '@components/LocaleContextProvider';
import {ModalActions} from '@components/Modal/Global/ModalContext';
import OnyxListItemProvider from '@components/OnyxListItemProvider';

import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types';
import type {MoneyRequestNavigatorParamList} from '@libs/Navigation/types';

import IOURequestStepDistanceMap from '@pages/iou/request/step/IOURequestStepDistanceMap';

import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type SCREENS from '@src/SCREENS';
import type {Policy, Report, Transaction} from '@src/types/onyx';

import type {ValueOf} from 'type-fest';

import {NavigationContainer} from '@react-navigation/native';
import React from 'react';
import Onyx from 'react-native-onyx';

import createMock from '../utils/createMock';
import * as TestHelper from '../utils/TestHelper';
import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct';

type MockConfirmModalResult = {action: ValueOf<typeof ModalActions>};

const mockShowConfirmModal = jest.fn<Promise<MockConfirmModalResult>, [Record<string, unknown>]>();

// Plain object, not a jest mock, so `jest.clearAllMocks()` can't wipe the captured props.
const mockTabContentProps = {current: undefined as {submitWaypoints?: () => void} | undefined};

jest.mock('@hooks/useConfirmModal', () => () => ({
showConfirmModal: mockShowConfirmModal,
}));

jest.mock('@pages/iou/request/step/DistanceMapTabContent', () => ({
__esModule: true,
default: (props: {submitWaypoints: () => void}) => {
mockTabContentProps.current = props;
return null;
},
}));

// Needs a navigator-provided route object, and the discard prompt is unrelated to the guard under test.
jest.mock('@hooks/useDiscardChangesConfirmation', () => ({
__esModule: true,
default: () => ({suppressDiscardPrompt: jest.fn()}),
}));

jest.mock('@rnmapbox/maps', () => ({
default: jest.fn(),
MarkerView: jest.fn(),
setAccessToken: jest.fn(),
}));

jest.mock('@libs/actions/MapboxToken', () => ({
init: jest.fn(),
stop: jest.fn(),
}));

const ACCOUNT_ID = 1;
const ACCOUNT_LOGIN = 'member@example.com';
const SELF_DM_REPORT_ID = 'selfDM1';
const WORKSPACE_CHAT_REPORT_ID = 'workspaceChat1';
const RESTRICTED_POLICY_ID = 'restrictedPolicy1';
const TRANSACTION_ID = 'transaction1';

type DistanceMapScreenProps = PlatformStackScreenProps<MoneyRequestNavigatorParamList, typeof SCREENS.MONEY_REQUEST.STEP_DISTANCE_MAP>;

/**
* Seeds the state from the bug report: the member's only (and therefore default) workspace excludes commutes by
* home and office, and the member has no home address saved.
*/
async function setUpOnyx() {
await act(async () => {
await Onyx.set(ONYXKEYS.SESSION, {accountID: ACCOUNT_ID, email: ACCOUNT_LOGIN});
await Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, {[ACCOUNT_ID]: {accountID: ACCOUNT_ID, login: ACCOUNT_LOGIN}});
await Onyx.set(ONYXKEYS.PRIVATE_PERSONAL_DETAILS, {addresses: []});
await Onyx.set(ONYXKEYS.NVP_ACTIVE_POLICY_ID, RESTRICTED_POLICY_ID);
await Onyx.set(
`${ONYXKEYS.COLLECTION.POLICY}${RESTRICTED_POLICY_ID}`,
createMock<Policy>({
id: RESTRICTED_POLICY_ID,
type: CONST.POLICY.TYPE.TEAM,
role: CONST.POLICY.ROLE.USER,
name: 'Restricted workspace',
areDistanceRatesEnabled: true,
commuterExclusions: {method: CONST.POLICY.COMMUTER_EXCLUSION_METHOD.HOME_AND_OFFICE},
}),
);
await Onyx.set(
`${ONYXKEYS.COLLECTION.REPORT}${SELF_DM_REPORT_ID}`,
createMock<Report>({
reportID: SELF_DM_REPORT_ID,
type: CONST.REPORT.TYPE.CHAT,
chatType: CONST.REPORT.CHAT_TYPE.SELF_DM,
participants: {[ACCOUNT_ID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}},
}),
);
await Onyx.set(
`${ONYXKEYS.COLLECTION.REPORT}${WORKSPACE_CHAT_REPORT_ID}`,
createMock<Report>({
reportID: WORKSPACE_CHAT_REPORT_ID,
type: CONST.REPORT.TYPE.CHAT,
chatType: CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT,
policyID: RESTRICTED_POLICY_ID,
isOwnPolicyExpenseChat: true,
ownerAccountID: ACCOUNT_ID,
participants: {[ACCOUNT_ID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}},
}),
);
// The route and the recent waypoints are seeded so the screen has nothing left to fetch on mount.
await Onyx.set(ONYXKEYS.NVP_RECENT_WAYPOINTS, []);
await Onyx.set(
`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${TRANSACTION_ID}`,
createMock<Transaction>({
transactionID: TRANSACTION_ID,
iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP,
comment: {
waypoints: {
waypoint0: {address: 'Start address', lat: 1, lng: 1, keyForList: 'start_waypoint'},
waypoint1: {address: 'Stop address', lat: 2, lng: 2, keyForList: 'stop_waypoint'},
},
},
routes: {
route0: {
distance: 1000,
geometry: {coordinates: [[1, 1] as [number, number], [2, 2] as [number, number]], type: 'LineString'},
},
},
}),
);
});
}

async function renderMapStep(reportID: string, iouType: ValueOf<typeof CONST.IOU.TYPE>) {
render(
<OnyxListItemProvider>
<CurrentUserPersonalDetailsProvider>
<LocaleContextProvider>
<NavigationContainer>
<IOURequestStepDistanceMap
route={createMock<DistanceMapScreenProps['route']>({
params: {
action: CONST.IOU.ACTION.CREATE,
iouType,
reportID,
transactionID: TRANSACTION_ID,
},
})}
navigation={createMock<DistanceMapScreenProps['navigation']>({})}
/>
</NavigationContainer>
</LocaleContextProvider>
</CurrentUserPersonalDetailsProvider>
</OnyxListItemProvider>,
);

await waitForBatchedUpdatesWithAct();
}

describe('IOURequestStepDistanceMap commuter exclusion guard', () => {
beforeAll(() => {
Onyx.init({keys: ONYXKEYS});
});

beforeEach(() => {
mockShowConfirmModal.mockClear();
mockShowConfirmModal.mockResolvedValue({action: ModalActions.CLOSE});
mockTabContentProps.current = undefined;
});

afterEach(async () => {
await act(async () => {
await Onyx.clear();
});
});

it('does not ask for a home address when the expense is a personal Self-DM track expense', async () => {
// Given a member with no home address whose default workspace excludes commutes by home and office
await setUpOnyx();

// When they tap Next on a map distance expense started from their Self-DM
await renderMapStep(SELF_DM_REPORT_ID, CONST.IOU.TYPE.TRACK);
await act(async () => {
mockTabContentProps.current?.submitWaypoints?.();
});

// Then the workspace's rule doesn't apply, because the expense isn't going to that workspace
expect(mockShowConfirmModal).not.toHaveBeenCalled();
});

it('still asks for a home address when the expense is going to a workspace that excludes commutes', async () => {
// Given the same member and workspace
await setUpOnyx();

// When they tap Next on a map distance expense started from that workspace's chat
await renderMapStep(WORKSPACE_CHAT_REPORT_ID, CONST.IOU.TYPE.SUBMIT);
await act(async () => {
mockTabContentProps.current?.submitWaypoints?.();
});

// Then they're asked for a home address, since the workspace needs it to exclude the commute
expect(mockShowConfirmModal).toHaveBeenCalledWith(expect.objectContaining({title: TestHelper.translateLocal('iou.homeAddressRequired.title')}));
});
});
Loading