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
2 changes: 2 additions & 0 deletions src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5281,6 +5281,8 @@ const CONST = {
ERROR: 'error',
TRACK: {
SUBMIT: 'submit',
SUBMIT_TO_FRIEND: 'submitToFriend',
SUBMIT_TO_EMPLOYER: 'submitToEmployer',
CATEGORIZE: 'categorize',
SHARE: 'share',
},
Expand Down
1 change: 1 addition & 0 deletions src/components/MoneyRequestConfirmationList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -481,6 +481,7 @@ function MoneyRequestConfirmationList({
isDistanceRequest,
isDistanceRequestWithPendingRoute,
isPerDiemRequest,
isMovingTransactionFromTrackExpense,
isTimeRequest,
routeError,
isNewManualExpenseFlowEnabled,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {isCategoryMissing} from '@libs/CategoryUtils';
import {convertToFrontendAmountAsString} from '@libs/CurrencyUtils';
import {isTaxAmountInvalid, isValidMoneyRequestAmount, validateAmount} from '@libs/MoneyRequestUtils';
import type {getTagLists as getTagListsFn} from '@libs/PolicyUtils';
import {isAttendeeTrackingEnabled} from '@libs/PolicyUtils';
import {canSubmitPerDiemExpenseFromWorkspace, isAttendeeTrackingEnabled} from '@libs/PolicyUtils';
import {hasEnabledTags, hasMatchingTag} from '@libs/TagsOptionsListUtils';
import {isValidTimeExpenseAmount} from '@libs/TimeTrackingUtils';
import {
Expand Down Expand Up @@ -106,6 +106,9 @@ type UseConfirmationValidationParams = {
/** Whether the transaction is a per-diem request */
isPerDiemRequest: boolean;

/** Whether a tracked expense is being moved into a workspace (submit/categorize/share from the self-DM) */
isMovingTransactionFromTrackExpense: boolean;

/** Whether the transaction is a time-tracking request */
isTimeRequest: boolean;

Expand Down Expand Up @@ -161,6 +164,7 @@ function useConfirmationValidation({
isDistanceRequest,
isDistanceRequestWithPendingRoute,
isPerDiemRequest,
isMovingTransactionFromTrackExpense,
isTimeRequest,
routeError,
isNewManualExpenseFlowEnabled,
Expand Down Expand Up @@ -281,6 +285,13 @@ function useConfirmationValidation({
return {errorKey: 'iou.error.invalidSubrateLength'};
}

// Per diem is a Control-plan feature, so block moving a tracked per diem expense into a workspace that can't
// process it (e.g. a Submit workspace created via "Submit to my employer"). Without this guard the submission
// falls through to a request the backend can't resolve, leaving the destination report stuck loading.
if (isPerDiemRequest && isMovingTransactionFromTrackExpense && !canSubmitPerDiemExpenseFromWorkspace(policy)) {
return {errorKey: 'iou.moveExpensesError'};
Comment thread
abzokhattab marked this conversation as resolved.
}

if (iouType !== CONST.IOU.TYPE.PAY) {
const decimals = getCurrencyDecimals(iouCurrencyCode);
if (isDistanceRequest && !isDistanceRequestWithPendingRoute && !validateAmount(String(iouAmount), decimals, CONST.IOU.DISTANCE_REQUEST_AMOUNT_MAX_LENGTH)) {
Expand Down
14 changes: 13 additions & 1 deletion src/hooks/usePolicyForTransaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,25 @@ type UsePolicyForTransactionResult = {
policy: OnyxEntry<Policy>;
};

function usePolicyForTransaction({transaction, reportPolicyID, action, iouType, policyDraft, isPerDiemRequest}: UsePolicyForTransactionParams): UsePolicyForTransactionResult {
function usePolicyForTransaction({
transaction,
reportPolicyID,
action,
iouType,
policyDraft: policyDraftProp,
isPerDiemRequest,
}: UsePolicyForTransactionParams): UsePolicyForTransactionResult {
const {policyForMovingExpenses} = usePolicyForMovingExpenses();

const customUnitID = transaction?.comment?.customUnit?.customUnitID;
const [customUnitPolicy] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: (policies) => getPolicyByCustomUnitID(transaction, policies)}, [customUnitID]);

const [reportPolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${reportPolicyID}`);
// 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 policyDraft = policyDraftProp ?? policyDraftFromOnyx;

const isUnreportedExpense = isExpenseUnreported(transaction);
const isCreatingTrackExpense = action === CONST.IOU.ACTION.CREATE && iouType === CONST.IOU.TYPE.TRACK;
Expand Down
10 changes: 3 additions & 7 deletions src/libs/ReportUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1405,13 +1405,9 @@ function getPolicyName(params: GetPolicyNameParams): string {
const noPolicyFound = params.returnEmptyIfNotFound ? '' : (params.unavailableTranslation ?? unavailableTranslation);
const parentReport = report ? getRootParentReport({report, reports}) : undefined;

// Bail only when none of the sources used below can resolve a name. oldPolicyName is included because it's a valid
// fallback (see below) and is the only name a draft workspace's expense chat carries (e.g. "Submit to my employer"
// with no persisted policies) — without it this returned "Unavailable workspace" for those drafts.
if (
(!report?.policyName && !report?.oldPolicyName && !parentReport?.policyName && !parentReport?.oldPolicyName && isEmptyObject(policies) && isEmptyObject(allPolicies)) ||
isEmptyObject(report)
) {
// A caller that explicitly passes `policy` (e.g. a draft workspace's policy from getReportOption) can resolve a name
// even for a user with no persisted policies, so don't bail in that case.
if ((!report?.policyName && !parentReport?.policyName && isEmptyObject(policy) && isEmptyObject(policies) && isEmptyObject(allPolicies)) || isEmptyObject(report)) {
return noPolicyFound;
Comment thread
abzokhattab marked this conversation as resolved.
}
const finalPolicy = (() => {
Expand Down
8 changes: 6 additions & 2 deletions src/libs/actions/IOU/TrackExpense.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2158,7 +2158,11 @@ function moveTrackedExpenseToPolicy(trackedExpenseParams: TrackedExpenseParams,
const {accountID: currentUserAccountID} = currentUser;
const {optimisticData, successData, failureData} = onyxData ?? {};
const {transactionID} = transactionParams;
const {isDraftPolicy} = policyParams;
// `policy` is a full Onyx object used only for optimistic data — it must not be serialized into the API payload.
// FormData turns it into "[object Object]" and non-Blob objects make the request fail on Android (see
// validateFormDataParameter), which stamps the failureData "Unexpected error" on the expense preview.
const {policy: omittedPolicy, ...policyApiParams} = policyParams;
const {isDraftPolicy} = policyApiParams;
const {
actionableWhisperReportActionID,
moneyRequestReportID,
Expand Down Expand Up @@ -2193,7 +2197,7 @@ function moveTrackedExpenseToPolicy(trackedExpenseParams: TrackedExpenseParams,
...reportInformation,
linkedTrackedExpenseReportAction: undefined,
},
...policyParams,
...policyApiParams,
...transactionParams,
modifiedExpenseReportActionID,
policyExpenseChatReportID: createdWorkspaceParams?.expenseChatReportID,
Expand Down
107 changes: 79 additions & 28 deletions src/pages/DynamicReportDetailsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,18 +25,21 @@ import useDuplicateTransactionsAndViolations from '@hooks/useDuplicateTransactio
import useDynamicBackPath from '@hooks/useDynamicBackPath';
import useGetIOUReportFromReportAction from '@hooks/useGetIOUReportFromReportAction';
import useHasOutstandingChildTask from '@hooks/useHasOutstandingChildTask';
import useLastWorkspaceNumber from '@hooks/useLastWorkspaceNumber';
import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
import useLocalize from '@hooks/useLocalize';
import useNetwork from '@hooks/useNetwork';
import useOnyx from '@hooks/useOnyx';
import usePaginatedReportActions from '@hooks/usePaginatedReportActions';
import useParentReportAction from '@hooks/useParentReportAction';
import usePermissions from '@hooks/usePermissions';
import usePreferredPolicy from '@hooks/usePreferredPolicy';
import useReportAttributes, {useDerivedReportNameByReportID} from '@hooks/useReportAttributes';
import useReportIsArchived from '@hooks/useReportIsArchived';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useThemeStyles from '@hooks/useThemeStyles';

import {generateDefaultWorkspaceName} from '@libs/actions/Policy/Policy';
import getBase62ReportID from '@libs/getBase62ReportID';
import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID';
import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute';
Expand Down Expand Up @@ -174,6 +177,9 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report
const {isOffline} = useNetwork();
const {isRestrictedToPreferredPolicy, preferredPolicyID} = usePreferredPolicy();
const activePolicy = useActivePolicy();
const {isBetaEnabled} = usePermissions();
const canUseSubmit2026 = isBetaEnabled(CONST.BETAS.SUBMIT_2026);
const lastWorkspaceNumber = useLastWorkspaceNumber();
const styles = useThemeStyles();
const expensifyIcons = useMemoizedLazyExpensifyIcons([
'Users',
Expand Down Expand Up @@ -508,34 +514,75 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report
const whisperAction = getTrackExpenseActionableWhisper(iouTransactionID, moneyRequestReport?.reportID);
const actionableWhisperReportActionID = whisperAction?.reportActionID;
const currentUserLocalCurrency = currentUserPersonalDetails.localCurrencyCode ?? CONST.CURRENCY.USD;
items.push({
key: CONST.REPORT_DETAILS_MENU_ITEM.TRACK.SUBMIT,
translationKey: 'actionableMentionTrackExpense.submit',
icon: expensifyIcons.Send,
isAnonymousAction: false,
shouldShowRightIcon: true,
action: () => {
createDraftTransactionAndNavigateToParticipantSelector({
reportID: actionReportID,
actionName: CONST.IOU.ACTION.SUBMIT,
reportActionID: actionableWhisperReportActionID,
introSelected,
draftTransactionIDs,
activePolicy,
userBillingGracePeriodEnds,
amountOwed,
ownerBillingGracePeriodEnd,
isRestrictedToPreferredPolicy,
preferredPolicyID,
transaction: iouTransaction,
currentUserAccountID: currentUserPersonalDetails.accountID,
currentUserEmail: currentUserPersonalDetails.email ?? '',
currentUserLocalCurrency,
filteredPoliciesCount: filteredPoliciesInfo?.filteredPoliciesCount ?? 0,
firstPolicyID: filteredPoliciesInfo?.firstPolicyID,
});
},
});
const baseSubmitParams = {
reportID: actionReportID,
reportActionID: actionableWhisperReportActionID,
introSelected,
draftTransactionIDs,
activePolicy,
userBillingGracePeriodEnds,
amountOwed,
ownerBillingGracePeriodEnd,
isRestrictedToPreferredPolicy,
preferredPolicyID,
transaction: iouTransaction,
currentUserAccountID: currentUserPersonalDetails.accountID,
currentUserEmail: currentUserPersonalDetails.email ?? '',
currentUserLocalCurrency,
filteredPoliciesCount: filteredPoliciesInfo?.filteredPoliciesCount ?? 0,
firstPolicyID: filteredPoliciesInfo?.firstPolicyID,
};
if (canUseSubmit2026) {
// On the Submit (submit2026) plan, "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").
const defaultWorkspaceName = generateDefaultWorkspaceName(currentUserPersonalDetails.email ?? '', lastWorkspaceNumber, translate, currentUserPersonalDetails.displayName);
items.push(
{
key: CONST.REPORT_DETAILS_MENU_ITEM.TRACK.SUBMIT_TO_FRIEND,
translationKey: 'actionableMentionTrackExpense.submitToFriend',
icon: expensifyIcons.Send,
isAnonymousAction: false,
shouldShowRightIcon: true,
action: () => {
createDraftTransactionAndNavigateToParticipantSelector({
...baseSubmitParams,
actionName: CONST.IOU.ACTION.SUBMIT,
submitDestination: CONST.IOU.SUBMIT_DESTINATION.FRIEND,
defaultWorkspaceName,
});
},
},
{
key: CONST.REPORT_DETAILS_MENU_ITEM.TRACK.SUBMIT_TO_EMPLOYER,
translationKey: 'actionableMentionTrackExpense.submitToEmployer',
icon: expensifyIcons.Send,
isAnonymousAction: false,
shouldShowRightIcon: true,
action: () => {
createDraftTransactionAndNavigateToParticipantSelector({
...baseSubmitParams,
actionName: CONST.IOU.ACTION.SUBMIT,
submitDestination: CONST.IOU.SUBMIT_DESTINATION.EMPLOYER,
defaultWorkspaceName,
});
},
},
);
} else {
items.push({
key: CONST.REPORT_DETAILS_MENU_ITEM.TRACK.SUBMIT,
translationKey: 'actionableMentionTrackExpense.submit',
icon: expensifyIcons.Send,
isAnonymousAction: false,
shouldShowRightIcon: true,
action: () => {
createDraftTransactionAndNavigateToParticipantSelector({
...baseSubmitParams,
actionName: CONST.IOU.ACTION.SUBMIT,
});
},
});
}
if (Permissions.canUseTrackFlows()) {
items.push({
key: CONST.REPORT_DETAILS_MENU_ITEM.TRACK.CATEGORIZE,
Expand Down Expand Up @@ -734,6 +781,10 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report
parentReport,
delegateEmail,
conciergeReportID,
canUseSubmit2026,
lastWorkspaceNumber,
translate,
currentUserPersonalDetails.displayName,
]);

const icons = useMemo(
Expand Down
13 changes: 10 additions & 3 deletions src/pages/iou/request/step/IOURequestStepConfirmation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -276,7 +276,9 @@ function IOURequestStepConfirmation({
}
const privateIsArchived = privateIsArchivedMap[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${participant.reportID}`];
const participantReportDraft = reportDrafts?.[`${ONYXKEYS.COLLECTION.REPORT_DRAFT}${participant.reportID}`];
const participantPolicy = participant.policyID ? participantsPolicies[participant.policyID] : policy;
// participantsPolicies only holds persisted policies, so fall back to the transaction policy when it's this
// participant's workspace — e.g. a freshly created draft workspace ("Submit to my employer" with no existing one).
const participantPolicy = (participant.policyID ? participantsPolicies[participant.policyID] : policy) ?? (participant.policyID === policy?.id ? policy : undefined);
// Phone contacts always have an optimistic accountID but no reportID; getReportOption
// is designed for report-backed participants and discards participant.text, so route
// any participant without a reportID to getParticipantsOption instead.
Expand Down Expand Up @@ -557,8 +559,13 @@ function IOURequestStepConfirmation({
// Don't pre-insert if the report is already showing - it would push a duplicate route.
const hasValidDestination = !!destinationReportID && Navigation.getTopmostReportId() !== destinationReportID;

// The report must be in Onyx so the pre-inserted screen can render immediately.
const isDestinationReportLoaded = !!destinationReportID && !!getReportOrDraftReport(destinationReportID, undefined, undefined, undefined, destinationReport)?.reportID;
// The report must be in the REPORT collection so the pre-inserted screen can render immediately. A draft-only
// report (e.g. the expense chat of a freshly created draft workspace in the zero-workspace "Submit to my
// employer" flow) can't render — the report screen only reads COLLECTION.REPORT — so pre-inserting one would
// strand the user on an infinite skeleton if they back out before submitting. Passing an empty draft to
// getReportOrDraftReport skips its REPORT_DRAFT fallback while keeping the module-cache fallback for
// real reports that useOnyx hasn't hydrated yet.
const isDestinationReportLoaded = !!destinationReportID && !!getReportOrDraftReport(destinationReportID, undefined, undefined, {}, destinationReport)?.reportID;

const shouldPreInsertReport = canUseReportPreInsert && isOutsideRHP && hasValidDestination && isDestinationReportLoaded;

Expand Down
16 changes: 16 additions & 0 deletions tests/unit/ReportUtilsTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17580,6 +17580,22 @@ describe('ReportUtils', () => {
expect(result).toBe('Test Policy Name');
});

it('should resolve the name from the policy parameter when the report carries no policy name', () => {
// Regression: a draft workspace's expense chat has no policyName/oldPolicyName and the draft policy isn't
// persisted, so the explicitly passed policy used to be ignored and "Unavailable workspace" was returned.
const reportWithoutPolicyName: Report = {
...createRandomReport(1, undefined),
policyID: 'policy123',
policyName: undefined,
oldPolicyName: undefined,
};
const result = getPolicyName({
report: reportWithoutPolicyName,
policy: testPolicy,
});
expect(result).toBe('Test Policy Name');
});

it('should find policy by policyID in the policies array', () => {
const policies: Policy[] = [
{...createRandomPolicy(1), id: 'other1', name: 'Other 1'},
Expand Down
Loading
Loading