From 7dd2d43e19b40cebfcd4cfbf724706120af121bd Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Tue, 14 Jul 2026 23:03:47 +0200 Subject: [PATCH 1/9] Scope draft-workspace name resolution to the passed policy Resolve the confirmation "To" name for a freshly created draft workspace (zero-workspace "Submit to my employer") via the policy explicitly passed to getPolicyName instead of broadening the early-bail guard with oldPolicyName, which affected all zero-policy users. The confirmation now forwards the draft policy for the matching participant so the name resolves through the normal path. --- src/libs/ReportUtils.ts | 10 +++------- .../iou/request/step/IOURequestStepConfirmation.tsx | 4 +++- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 06dc39974fb2..a3b07e3cefaa 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -1404,13 +1404,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; } const finalPolicy = (() => { diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx index 3446132b55c7..323cd325e4c4 100644 --- a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx +++ b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx @@ -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. From 5c9dfb9365cf10d63d1472f9cfd0fa2ed1f34f0c Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Tue, 14 Jul 2026 23:03:57 +0200 Subject: [PATCH 2/9] Show Submit to friend/employer on the expense details page The track-expense details page menu still showed the single "Submit to someone" option. Split it into "Submit to a friend" / "Submit to my employer" behind the SUBMIT_2026 beta, matching the whisper (ChatActionableButtons). Fixes #96085. --- src/CONST/index.ts | 2 + src/pages/DynamicReportDetailsPage.tsx | 107 ++++++++++++++++++------- 2 files changed, 81 insertions(+), 28 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 0429be45be59..1f46ffc91153 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -5230,6 +5230,8 @@ const CONST = { ERROR: 'error', TRACK: { SUBMIT: 'submit', + SUBMIT_TO_FRIEND: 'submitToFriend', + SUBMIT_TO_EMPLOYER: 'submitToEmployer', CATEGORIZE: 'categorize', SHARE: 'share', }, diff --git a/src/pages/DynamicReportDetailsPage.tsx b/src/pages/DynamicReportDetailsPage.tsx index 9aea8205858a..4fccb736749f 100644 --- a/src/pages/DynamicReportDetailsPage.tsx +++ b/src/pages/DynamicReportDetailsPage.tsx @@ -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'; @@ -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', @@ -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, @@ -734,6 +781,10 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report parentReport, delegateEmail, conciergeReportID, + canUseSubmit2026, + lastWorkspaceNumber, + translate, + currentUserPersonalDetails.displayName, ]); const icons = useMemo( From 503f5b3662f21b89d89edc77fd513983ab2f04d5 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Fri, 17 Jul 2026 01:07:33 +0200 Subject: [PATCH 3/9] Strip the Onyx policy object from the move-tracked-expense API payload moveTrackedExpenseToPolicy spread policyParams (which carries a full Onyx policy object for optimistic data) into the CategorizeTrackedExpense / AddTrackedExpenseToPolicy request. Non-Blob objects fail FormData serialization on Android, so the write failed and stamped "Unexpected error" on the expense preview. Fixes #96119. --- src/libs/actions/IOU/TrackExpense.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/libs/actions/IOU/TrackExpense.ts b/src/libs/actions/IOU/TrackExpense.ts index 1e37ab32823f..6b3aa3286683 100644 --- a/src/libs/actions/IOU/TrackExpense.ts +++ b/src/libs/actions/IOU/TrackExpense.ts @@ -2155,7 +2155,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, @@ -2190,7 +2194,7 @@ function moveTrackedExpenseToPolicy(trackedExpenseParams: TrackedExpenseParams, ...reportInformation, linkedTrackedExpenseReportAction: undefined, }, - ...policyParams, + ...policyApiParams, ...transactionParams, modifiedExpenseReportActionID, policyExpenseChatReportID: createdWorkspaceParams?.expenseChatReportID, From 4f6fa1d06b3a2ab33d7010fe579feae04d6f9923 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Fri, 17 Jul 2026 01:07:36 +0200 Subject: [PATCH 4/9] Resolve draft workspaces in usePolicyForTransaction Fall back to POLICY_DRAFTS when no real policy exists so callers that don't pass policyDraft (e.g. MoneyRequestConfirmationList) resolve a freshly created draft workspace. Fixes the sticky "Rate not valid" error (#96200) and the missing receipt empty state (#96214) in the zero-workspace "Submit to my employer" flow. --- src/hooks/usePolicyForTransaction.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/hooks/usePolicyForTransaction.ts b/src/hooks/usePolicyForTransaction.ts index 2eb3c5f14944..3baab41812af 100644 --- a/src/hooks/usePolicyForTransaction.ts +++ b/src/hooks/usePolicyForTransaction.ts @@ -35,13 +35,25 @@ type UsePolicyForTransactionResult = { policy: OnyxEntry; }; -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; From a0d0a1da0b0314c430fecc8f4a28774d8f525258 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Fri, 17 Jul 2026 01:08:41 +0200 Subject: [PATCH 5/9] Block per diem expenses from workspaces that can't process them Add a confirmation guard so a tracked per diem expense can't be moved into a workspace without per diem support (e.g. a Submit workspace created via "Submit to my employer"). The submission previously fell through to a request the backend can't resolve, leaving the report stuck loading. Fixes #96212. --- src/components/MoneyRequestConfirmationList.tsx | 1 + .../hooks/useConfirmationValidation.ts | 13 ++++++++++++- tests/unit/hooks/useConfirmationValidation.test.ts | 1 + 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/components/MoneyRequestConfirmationList.tsx b/src/components/MoneyRequestConfirmationList.tsx index 3ec4d336bef8..150c78ac0e98 100644 --- a/src/components/MoneyRequestConfirmationList.tsx +++ b/src/components/MoneyRequestConfirmationList.tsx @@ -481,6 +481,7 @@ function MoneyRequestConfirmationList({ isDistanceRequest, isDistanceRequestWithPendingRoute, isPerDiemRequest, + isMovingTransactionFromTrackExpense, isTimeRequest, routeError, isNewManualExpenseFlowEnabled, diff --git a/src/components/MoneyRequestConfirmationList/hooks/useConfirmationValidation.ts b/src/components/MoneyRequestConfirmationList/hooks/useConfirmationValidation.ts index c931c4b51319..0e650c5bd22c 100644 --- a/src/components/MoneyRequestConfirmationList/hooks/useConfirmationValidation.ts +++ b/src/components/MoneyRequestConfirmationList/hooks/useConfirmationValidation.ts @@ -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 { @@ -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; @@ -161,6 +164,7 @@ function useConfirmationValidation({ isDistanceRequest, isDistanceRequestWithPendingRoute, isPerDiemRequest, + isMovingTransactionFromTrackExpense, isTimeRequest, routeError, isNewManualExpenseFlowEnabled, @@ -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'}; + } + if (iouType !== CONST.IOU.TYPE.PAY) { const decimals = getCurrencyDecimals(iouCurrencyCode); if (isDistanceRequest && !isDistanceRequestWithPendingRoute && !validateAmount(String(iouAmount), decimals, CONST.IOU.DISTANCE_REQUEST_AMOUNT_MAX_LENGTH)) { diff --git a/tests/unit/hooks/useConfirmationValidation.test.ts b/tests/unit/hooks/useConfirmationValidation.test.ts index e71872c02a0e..83867f9ea004 100644 --- a/tests/unit/hooks/useConfirmationValidation.test.ts +++ b/tests/unit/hooks/useConfirmationValidation.test.ts @@ -97,6 +97,7 @@ const baseParams = { isDistanceRequest: false, isDistanceRequestWithPendingRoute: false, isPerDiemRequest: false, + isMovingTransactionFromTrackExpense: false, isTimeRequest: false, routeError: undefined, isNewManualExpenseFlowEnabled: false, From 38a764d512eb32925542fed6094deade145a619e Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Fri, 17 Jul 2026 01:08:42 +0200 Subject: [PATCH 6/9] Don't pre-insert draft-only reports under the confirmation RHP A draft-only report (the expense chat of a freshly created draft workspace) can't render as a report screen, so pre-inserting it stranded users on an infinite skeleton when backing out of "Submit to my employer" before submitting. Skip the REPORT_DRAFT fallback in the pre-insert readiness check. Fixes #96117. --- .../iou/request/step/IOURequestStepConfirmation.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx index 323cd325e4c4..5876fdc25891 100644 --- a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx +++ b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx @@ -559,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; From 7ef448f926c2381afceadd7937a9c38d39bb543f Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Sun, 19 Jul 2026 15:29:05 +0200 Subject: [PATCH 7/9] Update search test fixture: workspace avatar now resolves the snapshot policy name getPolicyName no longer bails to "Unavailable workspace" when the caller explicitly passes a policy, so getReportSections resolves the snapshot policy's real name for the expense report workspace avatar. --- tests/unit/Search/SearchUIUtilsTest.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/tests/unit/Search/SearchUIUtilsTest.ts b/tests/unit/Search/SearchUIUtilsTest.ts index 2447829c1282..cabd1c79d734 100644 --- a/tests/unit/Search/SearchUIUtilsTest.ts +++ b/tests/unit/Search/SearchUIUtilsTest.ts @@ -325,10 +325,12 @@ const approverAvatarIcon = { fallbackIcon: undefined, }; -const policyWorkspaceIcon = { - source: defaultWorkspaceAvatars.WorkspaceU, +// getReportSections passes the snapshot's policy to getPolicyName, so the workspace avatar resolves the +// policy's real name instead of falling back to "Unavailable workspace". +const resolvedPolicyWorkspaceIcon = { + source: defaultWorkspaceAvatars.WorkspaceP, type: CONST.ICON_TYPE_WORKSPACE, - name: 'Unavailable workspace', + name: policy.name, id: policyID, }; @@ -1184,7 +1186,7 @@ const transactionReportGroupListItems = createMock Date: Sun, 19 Jul 2026 15:52:25 +0200 Subject: [PATCH 8/9] Add tests covering the per diem move guard and getPolicyName policy param Covers the branches Codecov flagged as uncovered: the new per diem moveExpensesError guard in useConfirmationValidation and the getPolicyName change that resolves an explicitly passed policy for draft workspaces. --- tests/unit/ReportUtilsTest.ts | 16 +++++ .../hooks/useConfirmationValidation.test.ts | 63 +++++++++++++++++++ 2 files changed, 79 insertions(+) diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index 22adb95d21ce..bf851b3971eb 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -17564,6 +17564,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'}, diff --git a/tests/unit/hooks/useConfirmationValidation.test.ts b/tests/unit/hooks/useConfirmationValidation.test.ts index 83867f9ea004..08d814e6db3e 100644 --- a/tests/unit/hooks/useConfirmationValidation.test.ts +++ b/tests/unit/hooks/useConfirmationValidation.test.ts @@ -199,6 +199,69 @@ describe('useConfirmationValidation', () => { expect(result.current.validate()).toEqual({errorKey: 'iou.error.invalidSubrateLength'}); }); + describe('per diem move guard — moving a tracked per diem expense into a workspace', () => { + const PER_DIEM_TRANSACTION_OVERRIDES = { + amount: 5000, + iouRequestType: CONST.IOU.REQUEST_TYPE.PER_DIEM, + comment: {customUnit: {subRates: [{id: 'rate1', name: 'Breakfast', quantity: 1, rate: 5000}]}}, + } as const; + + function createPolicyWithPerDiemEnabled(): OnyxTypes.Policy { + return { + ...createRandomPolicy(1), + id: 'policy1', + type: CONST.POLICY.TYPE.CORPORATE, + customUnits: { + perDiemUnit: { + customUnitID: 'perDiemUnit', + name: CONST.CUSTOM_UNITS.NAME_PER_DIEM_INTERNATIONAL, + enabled: true, + rates: {}, + }, + }, + }; + } + + it('returns moveExpensesError when the destination workspace cannot process per diem', () => { + const {result} = renderHook(() => + useConfirmationValidation({ + ...baseParams, + isPerDiemRequest: true, + isMovingTransactionFromTrackExpense: true, + policy: {...createRandomPolicy(1), id: 'policy1', type: CONST.POLICY.TYPE.SUBMIT, customUnits: {}}, + transaction: createTransactionBase(PER_DIEM_TRANSACTION_OVERRIDES), + }), + ); + expect(result.current.validate()).toEqual({errorKey: 'iou.moveExpensesError'}); + }); + + it('returns errorKey: null when the destination workspace has per diem enabled', () => { + const {result} = renderHook(() => + useConfirmationValidation({ + ...baseParams, + isPerDiemRequest: true, + isMovingTransactionFromTrackExpense: true, + policy: createPolicyWithPerDiemEnabled(), + transaction: createTransactionBase(PER_DIEM_TRANSACTION_OVERRIDES), + }), + ); + expect(result.current.validate()).toEqual({errorKey: null}); + }); + + it('does not block a per diem expense that is not being moved from a tracked expense', () => { + const {result} = renderHook(() => + useConfirmationValidation({ + ...baseParams, + isPerDiemRequest: true, + isMovingTransactionFromTrackExpense: false, + policy: {...createRandomPolicy(1), id: 'policy1', type: CONST.POLICY.TYPE.SUBMIT, customUnits: {}}, + transaction: createTransactionBase(PER_DIEM_TRANSACTION_OVERRIDES), + }), + ); + expect(result.current.validate()).toEqual({errorKey: null}); + }); + }); + it('returns distanceAmountTooLarge when distance amount exceeds max', () => { const {result} = renderHook(() => useConfirmationValidation({ From 15e49f4492d646464cc1f5494e38a6ec5d5e7a6e Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Sun, 19 Jul 2026 16:55:57 +0200 Subject: [PATCH 9/9] Fix typecheck: don't make the per diem test fixture readonly with as const --- tests/unit/hooks/useConfirmationValidation.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/hooks/useConfirmationValidation.test.ts b/tests/unit/hooks/useConfirmationValidation.test.ts index 08d814e6db3e..bc7f0d4f0051 100644 --- a/tests/unit/hooks/useConfirmationValidation.test.ts +++ b/tests/unit/hooks/useConfirmationValidation.test.ts @@ -200,11 +200,11 @@ describe('useConfirmationValidation', () => { }); describe('per diem move guard — moving a tracked per diem expense into a workspace', () => { - const PER_DIEM_TRANSACTION_OVERRIDES = { + const PER_DIEM_TRANSACTION_OVERRIDES: Partial = { amount: 5000, iouRequestType: CONST.IOU.REQUEST_TYPE.PER_DIEM, comment: {customUnit: {subRates: [{id: 'rate1', name: 'Breakfast', quantity: 1, rate: 5000}]}}, - } as const; + }; function createPolicyWithPerDiemEnabled(): OnyxTypes.Policy { return {