From 845f216ec88b889ac6cb039a0410dafcb46582c6 Mon Sep 17 00:00:00 2001 From: "Sahil (via MelvinBot)" Date: Mon, 25 May 2026 14:59:03 +0000 Subject: [PATCH 1/6] Fix offline expense details not displaying after IOU payment When paying an IOU offline and then opening the expense from the workspace chat, the total showed as loading and no expense details were displayed. Three issues contributed: 1. createWorkspaceFromIOUPayment did not set parentReportID on the expense report to point to the new workspace chat, causing "Not Found" on large screens when the parent action lookup failed. 2. ReportFetchHandler's transaction thread creation guard blocked the offline fallback because hasOnceLoadedReportActions is only set on API success, which never fires offline. Added isOffline awareness so the thread can be created from locally available data. 3. convertIOUReportToExpenseReport did not set iouReportID on the new policy expense chat, preventing the expense detail view from resolving the report. Co-authored-by: Sahil --- src/libs/actions/Policy/Policy.ts | 1 + src/libs/actions/Report/index.ts | 18 ++++++++++++++++++ src/pages/inbox/ReportFetchHandler.tsx | 4 ++-- 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index 6932cf9459e3..8e0e0602c5dc 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -4451,6 +4451,7 @@ function createWorkspaceFromIOUPayment( const expenseReport = { ...iouReport, chatReportID: memberData.workspaceChatReportID, + parentReportID: memberData.workspaceChatReportID, policyID, policyName: workspaceName, type: CONST.REPORT.TYPE.EXPENSE, diff --git a/src/libs/actions/Report/index.ts b/src/libs/actions/Report/index.ts index 1b91d15105a8..24b39eb99bef 100644 --- a/src/libs/actions/Report/index.ts +++ b/src/libs/actions/Report/index.ts @@ -6881,6 +6881,24 @@ function convertIOUReportToExpenseReport(iouReport: Report, policy: Policy, poli }, }); + // Attach the moved report to the destination policy expense chat so the expense detail view + // can resolve the report via iouReportID when navigated to offline. + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${optimisticPolicyExpenseChatReportID}`, + value: { + iouReportID: reportID, + hasOutstandingChildRequest: !isReportManuallyReimbursed(iouReport), + }, + }); + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${optimisticPolicyExpenseChatReportID}`, + value: { + iouReportID: null, + }, + }); + // Create the MOVED report action and add it to the DM chat which indicates to the user where the report has been moved const movedReportAction = buildOptimisticMovedReportAction(iouReport.policyID, policyID, optimisticPolicyExpenseChatReportID, reportID, policy.name); optimisticData.push({ diff --git a/src/pages/inbox/ReportFetchHandler.tsx b/src/pages/inbox/ReportFetchHandler.tsx index 54456375f6b0..7e535677b68d 100644 --- a/src/pages/inbox/ReportFetchHandler.tsx +++ b/src/pages/inbox/ReportFetchHandler.tsx @@ -208,13 +208,13 @@ function ReportFetchHandler() { if ( transactionThreadReportID !== CONST.FAKE_REPORT_ID || transactionThreadReport?.reportID || - (!reportLoadingState.hasOnceLoadedReportActions && !reportMetadata?.isOptimisticReport) + (!reportLoadingState.hasOnceLoadedReportActions && !reportMetadata?.isOptimisticReport && !isOffline) ) { return; } createOneTransactionThread(); - }, [reportLoadingState.hasOnceLoadedReportActions, reportMetadata?.isOptimisticReport, transactionThreadReport?.reportID, transactionThreadReportID]); + }, [reportLoadingState.hasOnceLoadedReportActions, reportMetadata?.isOptimisticReport, transactionThreadReport?.reportID, transactionThreadReportID, isOffline]); useEffect(() => { if (isLoadingReportData || !prevIsLoadingReportData || !prevIsAnonymousUser.current || isAnonymousUser) { From 131b0cea99319da7787e6c1bbe3b59f3da0513c3 Mon Sep 17 00:00:00 2001 From: "Sahil (via MelvinBot)" Date: Sat, 30 May 2026 17:49:56 +0000 Subject: [PATCH 2/6] Fix moved expense showing "IOU" name after offline workspace pay When createWorkspaceFromIOUPayment runs offline (user pays the first time with workspace), the moved report kept the stale reportName "IOU" because the optimistic update spread the existing iouReport without recomputing the name. Both the policy expense chat preview and the report header read the same reportName field, so both displayed "IOU". Mirror what convertIOUReportToExpenseReport already does: - Recompute reportName via computeOptimisticReportName(expenseReport, newWorkspace, policyID, transactionsRecord). - Set childReportName on the moved report-preview action so the chat preview fallback matches. Co-authored-by: Sahil --- src/libs/actions/Policy/Policy.ts | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index 8e0e0602c5dc..04942ac38ca3 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -4448,7 +4448,8 @@ function createWorkspaceFromIOUPayment( // - change the sign of the report total // - update its policyID and policyName // - update the chatReportID to point to the new expense chat - const expenseReport = { + // - recompute reportName so the header and the policy expense chat preview don't show the stale "IOU" name + const expenseReport: Report = { ...iouReport, chatReportID: memberData.workspaceChatReportID, parentReportID: memberData.workspaceChatReportID, @@ -4457,6 +4458,19 @@ function createWorkspaceFromIOUPayment( type: CONST.REPORT.TYPE.EXPENSE, total: -(iouReport?.total ?? 0), }; + + const reportTransactions = ReportUtils.getReportTransactions(iouReportID); + const transactionsRecord: Record = {}; + for (const transaction of reportTransactions) { + if (transaction?.transactionID) { + transactionsRecord[transaction.transactionID] = transaction; + } + } + const computedExpenseReportName = ReportUtils.computeOptimisticReportName(expenseReport, newWorkspace as Policy, policyID, transactionsRecord); + if (computedExpenseReportName !== null) { + expenseReport.reportName = computedExpenseReportName; + } + optimisticData.push({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`, @@ -4468,9 +4482,6 @@ function createWorkspaceFromIOUPayment( value: iouReport, }); - // The expense report transactions need to have the amount reversed to negative values - const reportTransactions = ReportUtils.getReportTransactions(iouReportID); - // For performance reasons, we are going to compose a merge collection data for transactions const transactionsOptimisticData: Record = {}; const transactionFailureData: Record = {}; @@ -4530,12 +4541,14 @@ function createWorkspaceFromIOUPayment( if (reportPreviewAction?.reportActionID) { // Update the created timestamp of the report preview action to be after the expense chat created timestamp. + // Also set childReportName so the preview line falls back to the recomputed expense report name if needed. optimisticData.push({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${memberData.workspaceChatReportID}`, value: { [reportPreviewAction.reportActionID]: { ...reportPreviewAction, + childReportName: expenseReport.reportName, message: [ { type: CONST.REPORT.MESSAGE.TYPE.TEXT, From 304f5e44cfc002e94303877b508ebe67ae3bf1a8 Mon Sep 17 00:00:00 2001 From: "Sahil (via MelvinBot)" Date: Sun, 31 May 2026 15:18:22 +0000 Subject: [PATCH 3/6] Seed title fieldList so moved expense gets a meaningful name The previous attempt called computeOptimisticReportName, but for the offline workspace-from-IOU flow the new policy isn't in Onyx yet, so getReportFieldsByPolicyID returned {} and the fallback was the literal "New report" (DEFAULT_EXPENSE_REPORT_NAME). Two changes: - Policy.ts: seed newWorkspace.fieldList with the default title field (pattern "{report:type} {report:startdate}"), mirroring buildPolicyData. Also pass that fieldList through onto the converted expense report so Report.fieldList is consistent with its policy. - ReportUtils.ts: when the Onyx-resolved fieldList is empty, fall back to the passed policy's fieldList. This makes computeOptimisticReportName usable from contexts that construct optimistic data before the policy exists in Onyx, without changing behavior for any existing caller. Co-authored-by: Sahil --- src/libs/ReportUtils.ts | 6 +++++- src/libs/actions/Policy/Policy.ts | 13 +++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 6ea25362a41b..50e4596f806d 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -6681,7 +6681,11 @@ function computeOptimisticReportName(report: Report, policy: OnyxEntry, return null; } - const titleReportField = getTitleReportField(getReportFieldsByPolicyID(policyID) ?? {}); + // Prefer the Onyx-resolved fieldList by policyID; fall back to the passed policy's fieldList + // for callers building optimistic data before the policy is written to Onyx (e.g. createWorkspaceFromIOUPayment offline). + const reportFieldsFromOnyx = getReportFieldsByPolicyID(policyID) ?? {}; + const reportFields = Object.keys(reportFieldsFromOnyx).length > 0 ? reportFieldsFromOnyx : (policy?.fieldList ?? {}); + const titleReportField = getTitleReportField(reportFields); const formulaContext: FormulaContext = { report, policy, diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index 04942ac38ca3..289365dfb962 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -4247,6 +4247,18 @@ function createWorkspaceFromIOUPayment( disabledFields: {defaultBillable: true, reimbursable: false}, requiresCategory: true, defaultReimbursable: true, + // Seed the title report field so computeOptimisticReportName can produce a meaningful name + // (e.g. "Expense {date}") instead of falling back to "New report" when this policy has no fieldList yet. + fieldList: { + [CONST.POLICY.FIELDS.FIELD_LIST_TITLE]: { + defaultValue: CONST.POLICY.DEFAULT_REPORT_NAME_PATTERN, + type: CONST.POLICY.DEFAULT_FIELD_LIST_TYPE, + target: CONST.POLICY.DEFAULT_FIELD_LIST_TARGET, + name: CONST.POLICY.DEFAULT_FIELD_LIST_NAME, + fieldID: CONST.POLICY.FIELDS.FIELD_LIST_TITLE, + deletable: true, + }, + } as unknown as Policy['fieldList'], }; const optimisticData: Array< @@ -4457,6 +4469,7 @@ function createWorkspaceFromIOUPayment( policyName: workspaceName, type: CONST.REPORT.TYPE.EXPENSE, total: -(iouReport?.total ?? 0), + fieldList: newWorkspace.fieldList, }; const reportTransactions = ReportUtils.getReportTransactions(iouReportID); From 7e88101e6fa1480d7d999eab4b8c08644f56459b Mon Sep 17 00:00:00 2001 From: "Sahil (via MelvinBot)" Date: Tue, 2 Jun 2026 18:07:22 +0000 Subject: [PATCH 4/6] Remove defensive iouReportID block on destination chat in convertIOUReportToExpenseReport Co-authored-by: Sahil --- src/libs/actions/Report/index.ts | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/src/libs/actions/Report/index.ts b/src/libs/actions/Report/index.ts index 24b39eb99bef..1b91d15105a8 100644 --- a/src/libs/actions/Report/index.ts +++ b/src/libs/actions/Report/index.ts @@ -6881,24 +6881,6 @@ function convertIOUReportToExpenseReport(iouReport: Report, policy: Policy, poli }, }); - // Attach the moved report to the destination policy expense chat so the expense detail view - // can resolve the report via iouReportID when navigated to offline. - optimisticData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${optimisticPolicyExpenseChatReportID}`, - value: { - iouReportID: reportID, - hasOutstandingChildRequest: !isReportManuallyReimbursed(iouReport), - }, - }); - failureData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${optimisticPolicyExpenseChatReportID}`, - value: { - iouReportID: null, - }, - }); - // Create the MOVED report action and add it to the DM chat which indicates to the user where the report has been moved const movedReportAction = buildOptimisticMovedReportAction(iouReport.policyID, policyID, optimisticPolicyExpenseChatReportID, reportID, policy.name); optimisticData.push({ From 42a0b409c81b86544304ad8f87176fb636a617e6 Mon Sep 17 00:00:00 2001 From: "Sahil (via MelvinBot)" Date: Thu, 4 Jun 2026 16:22:44 +0000 Subject: [PATCH 5/6] Extract buildDefaultTitleFieldList helper to remove duplicated fieldList construction Consolidates the identical default title report field structure built in buildPolicyData, createDraftWorkspace, and createWorkspaceFromIOUPayment into a single shared helper, also eliminating the duplicated `as unknown as` cast. Co-authored-by: Sahil --- src/libs/actions/Policy/Policy.ts | 53 ++++++++++++------------------- 1 file changed, 21 insertions(+), 32 deletions(-) diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index cefc53361489..4f4c68d14c35 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -2526,6 +2526,24 @@ function getApprovalModeForNewWorkspace( return CONST.POLICY.APPROVAL_MODE.OPTIONAL; } +/** + * Builds the default title report field (`fieldList`) used when seeding a new policy. The object intentionally omits the + * non-essential `PolicyReportField` properties (orderWeight, values, keys, etc.), so it is cast to the expected type. + */ +function buildDefaultTitleFieldList(pendingFields?: Record): Policy['fieldList'] { + return { + [CONST.POLICY.FIELDS.FIELD_LIST_TITLE]: { + defaultValue: CONST.POLICY.DEFAULT_REPORT_NAME_PATTERN, + type: CONST.POLICY.DEFAULT_FIELD_LIST_TYPE, + target: CONST.POLICY.DEFAULT_FIELD_LIST_TARGET, + name: CONST.POLICY.DEFAULT_FIELD_LIST_NAME, + fieldID: CONST.POLICY.FIELDS.FIELD_LIST_TITLE, + deletable: true, + ...(pendingFields ? {pendingFields} : {}), + }, + } as unknown as Policy['fieldList']; +} + /** * Generates onyx data for creating a new workspace * @@ -2721,17 +2739,7 @@ function buildPolicyData(options: BuildPolicyDataOptions): OnyxData Date: Wed, 17 Jun 2026 18:59:36 +0000 Subject: [PATCH 6/6] Fix ESLint seatbelt: complete title field instead of unsafe type assertion buildDefaultTitleFieldList added a 9th @typescript-eslint/no-unsafe-type-assertion violation (via `as unknown as Policy['fieldList']`), exceeding the grandfathered limit of 8 for Policy.ts. Build a complete PolicyReportField so the value is assignable without any assertion. Co-authored-by: Sahil --- src/libs/actions/Policy/Policy.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index 3b308213bac2..b211a59e9e75 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -2523,8 +2523,7 @@ function getApprovalModeForNewWorkspace( } /** - * Builds the default title report field (`fieldList`) used when seeding a new policy. The object intentionally omits the - * non-essential `PolicyReportField` properties (orderWeight, values, keys, etc.), so it is cast to the expected type. + * Builds the default title report field (`fieldList`) used when seeding a new policy. */ function buildDefaultTitleFieldList(pendingFields?: Record): Policy['fieldList'] { return { @@ -2535,9 +2534,15 @@ function buildDefaultTitleFieldList(pendingFields?: Record