Skip to content

Commit fec6b33

Browse files
authored
Merge pull request #94755 from Expensify/claude-fix-distance-expense-newuser-reportid
Fix: distance expense to new user lands on Workspace with 'previously existing chat' error (new manual expense flow)
2 parents 89c2732 + 20f3d16 commit fec6b33

2 files changed

Lines changed: 81 additions & 24 deletions

File tree

src/libs/Violations/ViolationsUtils.ts

Lines changed: 32 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -597,27 +597,40 @@ const ViolationsUtils = {
597597
const customUnitRateID = updatedTransaction?.comment?.customUnit?.customUnitRateID;
598598
const isDistanceRequestForCustomUnit = TransactionUtils.isDistanceRequest(updatedTransaction);
599599
if (customUnitRateID && customUnitRateID.length > 0 && (!isSelfDM || isDistanceRequestForCustomUnit)) {
600-
const isPerDiem = TransactionUtils.isPerDiemRequest(updatedTransaction);
601-
let policyForCustomUnitRate = policy;
602-
603-
if (!isPerDiem && isDistanceRequestForCustomUnit && !getDistanceRateCustomUnitRate(policy, customUnitRateID)) {
604-
policyForCustomUnitRate = distanceOriginalPolicy ?? policy;
605-
}
606-
607-
const customRate = isPerDiem ? getPerDiemRateCustomUnitRate(policy, customUnitRateID) : getDistanceRateCustomUnitRate(policyForCustomUnitRate, customUnitRateID);
608-
if (customRate && customRate.enabled !== false) {
609-
newTransactionViolations = reject(newTransactionViolations, {name: CONST.VIOLATIONS.CUSTOM_UNIT_OUT_OF_POLICY});
610-
newTransactionViolations = syncCustomUnitRateOutOfDateRangeViolation(newTransactionViolations, updatedTransaction, policyForCustomUnitRate);
611-
} else if (isSelfDM && isDistanceRequestForCustomUnit) {
612-
newTransactionViolations = reject(newTransactionViolations, {name: CONST.VIOLATIONS.CUSTOM_UNIT_RATE_OUT_OF_DATE_RANGE});
600+
// A P2P distance rate (FAKE_P2P_ID) isn't tied to any workspace, so it can never be "out of policy"
601+
// when the expense is genuinely P2P (a person chat or self DM). Clear any stale violation instead of
602+
// flagging it. This prevents a spurious "Rate not valid for this workspace" from appearing optimistically
603+
// when a P2P expense is created while a workspace policy is still in context (e.g. the new manual expense
604+
// flow switching the recipient from the default workspace to a person before the API distance response
605+
// arrives). We must NOT clear it when the transaction is still bound to a policy expense chat, because a
606+
// track expense moved onto a workspace intentionally keeps FAKE_P2P_ID until the user picks a workspace
607+
// rate, and the violation is what prompts them to do so — so that case falls through to the rate check below.
608+
const isTransactionOnPolicyExpenseChat = updatedTransaction.participants?.some((participant) => participant?.isPolicyExpenseChat);
609+
if (TransactionUtils.isCustomUnitRateIDForP2P(updatedTransaction) && !isTransactionOnPolicyExpenseChat) {
613610
newTransactionViolations = reject(newTransactionViolations, {name: CONST.VIOLATIONS.CUSTOM_UNIT_OUT_OF_POLICY});
614611
} else {
615-
newTransactionViolations = reject(newTransactionViolations, {name: CONST.VIOLATIONS.CUSTOM_UNIT_RATE_OUT_OF_DATE_RANGE});
616-
newTransactionViolations.push({
617-
name: CONST.VIOLATIONS.CUSTOM_UNIT_OUT_OF_POLICY,
618-
type: CONST.VIOLATION_TYPES.VIOLATION,
619-
showInReview: true,
620-
});
612+
const isPerDiem = TransactionUtils.isPerDiemRequest(updatedTransaction);
613+
let policyForCustomUnitRate = policy;
614+
615+
if (!isPerDiem && isDistanceRequestForCustomUnit && !getDistanceRateCustomUnitRate(policy, customUnitRateID)) {
616+
policyForCustomUnitRate = distanceOriginalPolicy ?? policy;
617+
}
618+
619+
const customRate = isPerDiem ? getPerDiemRateCustomUnitRate(policy, customUnitRateID) : getDistanceRateCustomUnitRate(policyForCustomUnitRate, customUnitRateID);
620+
if (customRate && customRate.enabled !== false) {
621+
newTransactionViolations = reject(newTransactionViolations, {name: CONST.VIOLATIONS.CUSTOM_UNIT_OUT_OF_POLICY});
622+
newTransactionViolations = syncCustomUnitRateOutOfDateRangeViolation(newTransactionViolations, updatedTransaction, policyForCustomUnitRate);
623+
} else if (isSelfDM && isDistanceRequestForCustomUnit) {
624+
newTransactionViolations = reject(newTransactionViolations, {name: CONST.VIOLATIONS.CUSTOM_UNIT_RATE_OUT_OF_DATE_RANGE});
625+
newTransactionViolations = reject(newTransactionViolations, {name: CONST.VIOLATIONS.CUSTOM_UNIT_OUT_OF_POLICY});
626+
} else {
627+
newTransactionViolations = reject(newTransactionViolations, {name: CONST.VIOLATIONS.CUSTOM_UNIT_RATE_OUT_OF_DATE_RANGE});
628+
newTransactionViolations.push({
629+
name: CONST.VIOLATIONS.CUSTOM_UNIT_OUT_OF_POLICY,
630+
type: CONST.VIOLATION_TYPES.VIOLATION,
631+
showInReview: true,
632+
});
633+
}
621634
}
622635
}
623636

src/pages/iou/request/step/IOURequestStepConfirmation.tsx

Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import useOnyx from '@hooks/useOnyx';
2323
import useOptimisticDraftTransactions from '@hooks/useOptimisticDraftTransactions';
2424
import useParticipantsPolicies from '@hooks/useParticipantsPolicies';
2525
import usePermissions from '@hooks/usePermissions';
26+
import usePersonalPolicy from '@hooks/usePersonalPolicy';
2627
import usePolicyForTransaction from '@hooks/usePolicyForTransaction';
2728
import usePrivateIsArchivedMap from '@hooks/usePrivateIsArchivedMap';
2829
import useReportAttributes from '@hooks/useReportAttributes';
@@ -35,6 +36,7 @@ import {setMoneyRequestBillable, setMoneyRequestReimbursable} from '@libs/action
3536
import {setTransactionReport} from '@libs/actions/Transaction';
3637
import {isMobileSafari} from '@libs/Browser';
3738
import {canUseTouchScreen} from '@libs/DeviceCapabilities';
39+
import DistanceRequestUtils from '@libs/DistanceRequestUtils';
3840
import getIsNarrowLayout from '@libs/getIsNarrowLayout';
3941
import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID';
4042
import {
@@ -54,7 +56,7 @@ import {submitWithDismissFirst} from '@libs/Navigation/helpers/submitWithDismiss
5456
import Navigation, {navigationRef} from '@libs/Navigation/Navigation';
5557
import type {MoneyRequestNavigatorParamList} from '@libs/Navigation/types';
5658
import {getParticipantsOption, getReportOption} from '@libs/OptionsListUtils';
57-
import {findSelfDMReportID, getReportOrDraftReport, isMoneyRequestReport, isPolicyExpenseChat as isPolicyExpenseChatUtils} from '@libs/ReportUtils';
59+
import {findSelfDMReportID, generateReportID, getReportOrDraftReport, isMoneyRequestReport, isPolicyExpenseChat as isPolicyExpenseChatUtils} from '@libs/ReportUtils';
5860
import {buildCannedSearchQuery, getCurrentSearchQueryJSON} from '@libs/SearchQueryUtils';
5961
import {cancelTracking, getPendingSubmitFollowUpAction, isTracking} from '@libs/telemetry/submitFollowUpAction';
6062
import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan';
@@ -67,7 +69,7 @@ import {
6769
isScanRequest,
6870
} from '@libs/TransactionUtils';
6971

70-
import {getIOURequestPolicyID, setMoneyRequestParticipants, setMoneyRequestParticipantsFromReport} from '@userActions/IOU/MoneyRequest';
72+
import {getIOURequestPolicyID, setCustomUnitRateID, setMoneyRequestCategory, setMoneyRequestParticipants, setMoneyRequestParticipantsFromReport} from '@userActions/IOU/MoneyRequest';
7173
import {setMoneyRequestReceipt} from '@userActions/IOU/Receipt';
7274
import {removeDraftTransaction, replaceDefaultDraftTransaction} from '@userActions/TransactionEdit';
7375

@@ -124,6 +126,7 @@ function IOURequestStepConfirmation({
124126
const participantsAutoAssignedFromRoute = route.name === SCREENS.MONEY_REQUEST.STEP_CONFIRMATION ? (params as StepConfirmationParams).participantsAutoAssigned : undefined;
125127

126128
const currentUserPersonalDetails = useCurrentUserPersonalDetails();
129+
const personalPolicy = usePersonalPolicy();
127130
const selfDMReport = useSelfDMReport();
128131
const personalDetails = usePersonalDetails();
129132
const allPolicyCategories = usePolicyCategories();
@@ -227,6 +230,7 @@ function IOURequestStepConfirmation({
227230
const isOdometerDistanceRequest = isOdometerDistanceRequestTransactionUtils(transaction);
228231
const isTimeRequest = requestType === CONST.IOU.REQUEST_TYPE.TIME;
229232
const [lastLocationPermissionPrompt] = useOnyx(ONYXKEYS.NVP_LAST_LOCATION_PERMISSION_PROMPT);
233+
const [lastSelectedDistanceRates] = useOnyx(ONYXKEYS.NVP_LAST_SELECTED_DISTANCE_RATES);
230234
const privateIsArchivedMap = usePrivateIsArchivedMap();
231235

232236
const receiptFilename = transaction?.receipt?.filename;
@@ -341,15 +345,55 @@ function IOURequestStepConfirmation({
341345
}
342346
setMoneyRequestParticipants(activeTransactionID, participantsList);
343347
const firstParticipant = participantsList.at(0);
344-
if (iouType !== CONST.IOU.TYPE.SPLIT) {
345-
setTransactionReport(activeTransactionID, {reportID: firstParticipant?.reportID ?? reportID}, true);
348+
if (iouType !== CONST.IOU.TYPE.SPLIT && firstParticipant) {
349+
const isPolicyExpenseChatParticipant = !!firstParticipant.isPolicyExpenseChat;
350+
351+
// A brand-new recipient picked by email has no chat yet (no reportID). Reusing the route's `reportID`
352+
// (which points at the flow's origin report - e.g. the default workspace chat this distance flow was
353+
// seeded with) leaves the expense bound to that workspace, so the backend rejects it with
354+
// "There is a previously existing chat between these users." Generate a fresh optimistic reportID for
355+
// P2P recipients, mirroring the legacy participants-step flow (useParticipantSubmission).
356+
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
357+
const participantReportID = firstParticipant.reportID || (isPolicyExpenseChatParticipant ? reportID : generateReportID());
358+
setTransactionReport(activeTransactionID, {reportID: participantReportID}, true);
359+
360+
// When switching from the auto-assigned default workspace to a P2P recipient we must also undo the
361+
// workspace-specific defaults the distance step applied: reset the mileage rate to the P2P rate and
362+
// clear the workspace's default category (and the category-derived tax). Otherwise the confirmation
363+
// keeps the workspace "Default Rate"/category and the expense stays bound to that workspace. This
364+
// mirrors what the legacy addParticipant/goToNextStep path does when a P2P recipient is selected.
365+
if (!isPolicyExpenseChatParticipant) {
366+
if (isDistanceRequest) {
367+
const p2pRateID = DistanceRequestUtils.getCustomUnitRateID({
368+
reportID: firstParticipant.reportID,
369+
isPolicyExpenseChat: false,
370+
policy: undefined,
371+
lastSelectedDistanceRates,
372+
expenseDate: transaction?.created,
373+
});
374+
setCustomUnitRateID(activeTransactionID, p2pRateID, transaction, undefined, false, personalPolicy?.outputCurrency);
375+
}
376+
setMoneyRequestCategory(activeTransactionID, '', undefined);
377+
}
346378
}
347379
}
348380
if (participantsList.length > 0) {
349381
closeParticipantPicker();
350382
}
351383
},
352-
[activeTransactionID, closeParticipantPicker, currentUserPersonalDetails.accountID, navigation, selfDMReport, iouType, transaction?.amount, transaction?.participants, reportID],
384+
[
385+
activeTransactionID,
386+
closeParticipantPicker,
387+
currentUserPersonalDetails.accountID,
388+
navigation,
389+
selfDMReport,
390+
iouType,
391+
reportID,
392+
isDistanceRequest,
393+
lastSelectedDistanceRates,
394+
transaction,
395+
personalPolicy?.outputCurrency,
396+
],
353397
);
354398

355399
useEffect(() => {

0 commit comments

Comments
 (0)