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
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import ONYXKEYS from '@src/ONYXKEYS';
import type {Policy, Transaction} from '@src/types/onyx';
import type {Participant} from '@src/types/onyx/IOU';
import type {Unit} from '@src/types/onyx/Policy';
import {isEmptyObject} from '@src/types/utils/EmptyObject';

import type {OnyxEntry} from 'react-native-onyx';

Expand Down Expand Up @@ -100,11 +101,24 @@ function DistanceRequestController({
// We want this effect to run when the transaction is moving from Self DM to an expense chat, or when the policy changes
const isPolicyChanged = prevPolicy?.id !== policy?.id;
const didSwitchPolicy = !!prevPolicy?.id && prevPolicy.id !== policy?.id;
if (!transactionID || !isDistanceRequest || !isPolicyExpenseChat || (!isMovingTransactionFromTrackExpense && !isPolicyChanged)) {
const errorKey = 'iou.error.invalidRate';

if (!transactionID || !isDistanceRequest) {
return;
}

// Moving the expense back to the self DM (or to a P2P recipient) leaves no workspace to validate against, so a
// rate error raised for the workspace it just left no longer applies. It has to be cleared here: every branch
// below is workspace-specific, so nothing else would ever take the message off the screen.
if (!isPolicyExpenseChat) {
clearFormErrors([errorKey]);
return;
}

if (!isMovingTransactionFromTrackExpense && !isPolicyChanged) {
return;
}

const errorKey = 'iou.error.invalidRate';
const policyRates = DistanceRequestUtils.getMileageRates(policy);

if (didSwitchPolicy && transaction?.comment?.customUnit?.rateAutoUpdated) {
Expand All @@ -125,6 +139,13 @@ function DistanceRequestController({
return;
}

// The workspace's custom units can still be loading at this point: selecting a participant resolves the new
// policy before Onyx has its rates, so validating now would flash an error that clears itself a moment later.
// With no rates to compare against we cannot tell whether the selected rate is valid - wait for the next run.
if (isEmptyObject(policyRates)) {
return;
}

// If none of the above conditions are met, display the rate error
setFormError(errorKey);
}, [
Expand Down
14 changes: 11 additions & 3 deletions src/hooks/usePolicyForTransaction.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import {getSelectedWorkspacePolicyID} from '@libs/IOUUtils';
import {getPolicyByCustomUnitID} from '@libs/PolicyUtils';
import {isExpenseUnreported} from '@libs/TransactionUtils';

import type {IOUAction} from '@src/CONST';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type {Policy, Transaction} from '@src/types/onyx';
Expand All @@ -18,7 +20,7 @@ type UsePolicyForTransactionParams = {
reportPolicyID: string | undefined;

/** The current action being performed */
action: string;
action: IOUAction;

/** The type of IOU (split, track, submit, etc.) */
iouType: string;
Expand Down Expand Up @@ -46,11 +48,17 @@ function usePolicyForTransaction({

const [customUnitPolicy] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: (policies: OnyxCollection<Policy>) => getPolicyByCustomUnitID(transaction, policies)});

const [reportPolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${reportPolicyID}`);
// The route report can lag behind the workspace the user actually picked. The in-place "To" picker on the
// confirmation page rewrites the transaction participants but leaves the route on the report the flow started
// from - the self-DM, whose policyID is the '_FAKE_' placeholder. Resolving the picked workspace here means
// every step page reached from the confirmation reads the same policy, instead of each one re-deriving it.
const resolvedPolicyID = getSelectedWorkspacePolicyID(transaction, action) ?? reportPolicyID;

const [reportPolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${resolvedPolicyID}`);
// Fall back to the draft policy from Onyx so callers that don't explicitly pass one still resolve a
// freshly created draft workspace (e.g. "Submit to my employer" with no existing workspace). Real
// policies always take precedence below, so this only kicks in while the workspace is still a draft.
const [policyDraftFromOnyx] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_DRAFTS}${reportPolicyID}`);
const [policyDraftFromOnyx] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_DRAFTS}${resolvedPolicyID}`);
const policyDraft = policyDraftProp ?? policyDraftFromOnyx;

const isUnreportedExpense = isExpenseUnreported(transaction);
Expand Down
26 changes: 26 additions & 0 deletions src/pages/iou/request/step/IOURequestStepConfirmation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import useOnyx from '@hooks/useOnyx';
import useOptimisticDraftTransactions from '@hooks/useOptimisticDraftTransactions';
import useParticipantsPolicies from '@hooks/useParticipantsPolicies';
import usePersonalPolicy from '@hooks/usePersonalPolicy';
import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses';
import usePolicyForTransaction from '@hooks/usePolicyForTransaction';
import usePreMountDestination from '@hooks/usePreMountDestination';
import usePrivateIsArchivedMap from '@hooks/usePrivateIsArchivedMap';
Expand Down Expand Up @@ -254,6 +255,7 @@ function IOURequestStepConfirmationContent({
const isTimeRequest = requestType === CONST.IOU.REQUEST_TYPE.TIME;
const [lastLocationPermissionPrompt] = useOnyx(ONYXKEYS.NVP_LAST_LOCATION_PERMISSION_PROMPT);
const [lastSelectedDistanceRates] = useOnyx(ONYXKEYS.NVP_LAST_SELECTED_DISTANCE_RATES);
const {policyForMovingExpenses} = usePolicyForMovingExpenses();
const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED);
const isLookingAroundUser = isLookingAroundSearchRoutingActive(introSelected?.choice === CONST.ONBOARDING_CHOICES.LOOKING_AROUND, isOffline);
const privateIsArchivedMap = usePrivateIsArchivedMap();
Expand Down Expand Up @@ -440,6 +442,29 @@ function IOURequestStepConfirmationContent({
if (shouldKeepOnSelfDM) {
setMoneyRequestParticipantsFromReport(activeTransactionID, selfDMReport, currentUserPersonalDetails.accountID);
setTransactionReport(activeTransactionID, {reportID: CONST.REPORT.UNREPORTED_REPORT_ID}, true);

// The rate the expense picked up from a workspace does not exist outside it, so leaving it in place
// makes the Rate field read "Pending..." and the amount go blank once the expense is back on the self
// DM. Re-resolve the rate the self DM itself uses, the same way starting a track distance expense does.
if (isDistanceRequest) {
const selfDMRateID = DistanceRequestUtils.getCustomUnitRateID({
reportID: selfDMReport?.reportID,
isPolicyExpenseChat: false,
isTrackDistanceExpense: true,
policy: policyForMovingExpenses,
lastSelectedDistanceRates,
expenseDate: transaction?.created,
});
setCustomUnitRateID(
activeTransactionID,
selfDMRateID,
transaction,
policyForMovingExpenses,
false,
policyForMovingExpenses?.outputCurrency ?? personalPolicy?.outputCurrency,
);
}

if (iouType !== CONST.IOU.TYPE.TRACK) {
navigation.setParams({iouType: CONST.IOU.TYPE.TRACK});
}
Expand Down Expand Up @@ -527,6 +552,7 @@ function IOURequestStepConfirmationContent({
blockDistanceRequestIfNeeded,
getCurrencyDecimals,
policyID,
policyForMovingExpenses,
],
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import DistanceRequestController from '@components/MoneyRequestConfirmationList/
import DistanceRequestUtils from '@libs/DistanceRequestUtils';

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

import React from 'react';

Expand Down Expand Up @@ -116,4 +116,89 @@ describe('DistanceRequestController', () => {
}),
);
});

describe('rate validation when the selected workspace changes', () => {
const RATE_ERROR = 'iou.error.invalidRate';

/** A workspace whose distance rates have not arrived from Onyx yet. */
const policyWithoutRates = createMock<Policy>({id: 'workspaceB', customUnits: {}});

/** A loaded workspace whose only rate matches neither the selected rate ID nor its value/unit (the unit comes from the custom unit attributes). */
const policyWithUnrelatedRate = createMock<Policy>({
id: 'workspaceB',
customUnits: {
unitID: {
customUnitID: 'unitID',
name: CONST.CUSTOM_UNITS.NAME_DISTANCE,
attributes: {unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS},
enabled: true,
rates: {
rateB: {customUnitRateID: 'rateB', rate: 999, currency: CONST.CURRENCY.USD, enabled: true, name: 'Other rate'},
},
},
},
});

const renderController = ({
policy,
isPolicyExpenseChat,
setFormError,
clearFormErrors,
}: {
policy: Policy | undefined;
isPolicyExpenseChat: boolean;
setFormError: jest.Mock;
clearFormErrors: jest.Mock;
}) =>
render(
<DistanceRequestController
transactionID="txn1"
transaction={transaction}
policy={policy}
isDistanceRequest
isManualDistanceRequest={false}
isPolicyExpenseChat={isPolicyExpenseChat}
isMovingTransactionFromTrackExpense={false}
isReadOnly={false}
isTypeSplit={false}
customUnitRateID="rateFromAnotherWorkspace"
mileageRate={{rate: 67, unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, currency: CONST.CURRENCY.USD}}
rate={67}
unit={CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES}
currency={CONST.CURRENCY.USD}
distance={DistanceRequestUtils.convertToDistanceInMeters(4, CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES)}
distanceRequestAmount={201}
shouldCalculateDistanceAmount={false}
currentUserAccountID={1}
isDistanceRequestWithPendingRoute={false}
hasRoute
defaultMileageRateCustomUnitRateID={undefined}
selectedParticipants={[]}
selectedParticipantsProp={[]}
setFormError={setFormError}
clearFormErrors={clearFormErrors}
/>,
);

it('does not flag the rate while the newly selected workspace still has no rates loaded', () => {
const setFormError = jest.fn();
renderController({policy: policyWithoutRates, isPolicyExpenseChat: true, setFormError, clearFormErrors: jest.fn()});

expect(setFormError).not.toHaveBeenCalled();
});

it('still flags the rate once the workspace rates are loaded and none of them match', () => {
const setFormError = jest.fn();
renderController({policy: policyWithUnrelatedRate, isPolicyExpenseChat: true, setFormError, clearFormErrors: jest.fn()});

expect(setFormError).toHaveBeenCalledWith(RATE_ERROR);
});

it('clears a workspace rate error once the expense is no longer on a workspace chat', () => {
const clearFormErrors = jest.fn();
renderController({policy: undefined, isPolicyExpenseChat: false, setFormError: jest.fn(), clearFormErrors});

expect(clearFormErrors).toHaveBeenCalledWith([RATE_ERROR]);
});
});
});
Loading
Loading