Proposal: Merge Multiple Reports into a Single Report
References: WN Post · Pre-design 1 · Pre-design 2 · Figma · Implementation Pre-Design
Background
Members frequently end up with multiple reports containing expenses they want to manage together. Today, consolidation requires opening reports individually, selecting expenses, and using the existing "Move to report" action -- but users keep asking for a merge feature.
Problem
When a user has multiple editable reports representing the same body of work, consolidating all associated expenses into a single report requires manually moving expenses between reports -- extra navigation that also leaves behind unnecessary empty reports.
Solution
Add a "Merge" action to the report bulk-selection menu when two or more editable reports owned by the same account are selected.
Flow
- Selection -- When a member selects multiple reports in the report list, a new "Merge" action appears in the "X selected" menu.
- All selected reports must be owned by the same account.
- Available to any user with edit access, including approvers and admins.
- All selected reports must be on the same workspace. Cross-workspace merges are not supported in v1.
- All selected reports must have the same approver/manager (to avoid cross-approver issues with held expenses).
- Cross-account merges are not supported.
- Report picker -- Selecting "Merge" launches a report-selection RHP prompting the member to choose which report to retain.
- Destination selection -- The chosen report becomes the destination report.
- Confirm -- When the user selects "Confirm":
- All expenses from every source report are moved into the destination report.
- The destination report's state, workspace, approval chain, and all other report-level attributes remain unchanged.
- Empty source reports are permanently deleted, including any comments on them.
After the merge completes, the member is navigated to the destination report and shown a confirmation message.
Out of Scope
- "Merge reports" in the individual report More menu -- Adds complexity; may not be necessary if merge is available from Reports.
- Additional warning before merging -- Keep it simple; explain behavior in the RHP; revisit if users report accidental merges.
- Preserving or merging comments from source reports -- Follows existing patterns when an expense is moved and the empty report is deleted.
- Reusing an existing command -- A new command is needed to maintain 1:1:1 mapping.
- Cross-policy / cross-workspace merges -- v1 requires all selected reports be on the same workspace; users can move reports to one workspace first. This also avoids the Expensify Card-off-paid-policy and reimbursable/non-reimbursable-mix edge cases.
Implementation
1. canMergeReports(selectedReports, currentUserAccountID) in ReportUtils.ts
Returns false if:
- Fewer than 2 reports are selected.
- Selected reports span multiple
ownerAccountIDs (cross-account not supported).
- Selected reports span multiple
policyIDs (cross-workspace not supported in v1).
- Selected reports are not all in the same state (
stateNum/statusNum). Only reports at the same point in the workflow can be merged -- this prevents merging Open drafts onto Processing reports and bypassing approvals.
- Selected Processing reports have different approvers/managers (
managerID). (Redundant for Open reports -- the same-state requirement above prevents mixing Open and Processing, and same-account + same-workspace already guarantees a matching managerID for drafts.)
- Any report fails
canUserPerformWriteAction(report) or the user is neither isReportOwner(report) nor an admin/approver with write access.
Add unit tests for every eligibility branch. Note: this checks eligibility to start the merge; it does not yet know the destination (chosen later in the RHP).
2. CONST.SEARCH.BULK_ACTION_TYPES.MERGE_REPORTS
Namespace under SEARCH (not a top-level BULK_ACTION_TYPES), matching existing bulk-action constants.
3. WRITE_COMMANDS.MERGE_REPORTS + param type in src/libs/API/types.ts
- Add
MERGE_REPORTS: 'MergeReports' to WRITE_COMMANDS.
- Add
MergeReportsParams to WriteCommandParameters:
destinationReportID: string
sourceReportIDs: string[] (a real array, derived as selected minus destination)
- Plus any transaction->threadData map mirrored from
ChangeTransactionsReport if reusing its move primitive.
4. mergeReports(...) action -- reuse the existing move builder
FE responsibility: mergeReports() reuses the optimistic-data helpers from changeTransactionsReport() in src/libs/actions/Transaction.ts rather than reimplementing "move + delete empty" in Report.ts. That function already moves transactions, updates source totals/counts, manages transaction threads, and tracks staleReportIDs. The actual move + delete of empty source reports is performed by the backend MergeReports command (see Step 9).
- Optimistic data: move every transaction from each source report into the destination, then mark each emptied source report as deleted (comments included).
- Include
ONYXKEYS.COLLECTION.SNAPSHOT updates.
- Add unit + snapshot tests.
Splits: Split expenses are preserved through the merge -- the split link lives on transaction data (comment.originalTransactionID), not on the source report -- as long as the move goes through the existing Report::addTransactions() path (which ChangeTransactionsReport already uses). Confirmed by lakchote; no special handling needed.
5. Surface the action in src/hooks/useSearchBulkActions.ts
Follow the existing pattern: a local computed gate (e.g. queryJSON?.type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT) combined with canMergeReports(...), mirroring helpers like shouldShowBulkDuplicateOption().
- Add the
MERGE_REPORTS bulk action.
- On selection, navigate to the merge RHP route.
6. Route + Navigator Entry
Model on the real move flow, not a non-existent CHANGE_REPORT:
- Route: mirror
ROUTES.MOVE_TRANSACTIONS_SEARCH_RHP (search/move-transactions/search/:backTo?)
- Screen: mirror
SCREENS.SEARCH.TRANSACTIONS_CHANGE_REPORT_SEARCH_RHP
7. SearchReportsMergeReport.tsx (modeled on SearchTransactionsChangeReport.tsx)
- Pull selected report IDs from
SearchContext.
- Render
IOURequestEditReportCommon (src/pages/iou/request/step/IOURequestEditReportCommon.tsx) -- confirm its props (transactionIDs / selectReport / selectedReportID) fit a destination picker; the list should be the selected reports themselves, not an unrelated report set.
- The chosen report =
destinationReportID (stored in local state).
- Bottom-aligned Confirm button, disabled until a destination is chosen.
- On confirm:
mergeReports(destinationReportID, derivedSourceReportIDs).
8. Post-merge Cleanup
- Navigate to the destination report on success.
- Clear bulk-selection state from
SearchContext (clearSelectedTransactions or equivalent).
- If the user is currently viewing one of the source reports, navigate away before it is deleted.
9. Backend Command -- MergeReports (new -- was missing entirely)
The backend MergeReports command performs the actual move + delete of empty source reports. The move flow's backend is the model:
- Web-Expensify:
ChangeTransactionsReport is registered in apiCommandTypes.php as a write ('w') command. Add a sibling MergeReports => 'w' and a handler in the Report command domain (mirror _tests/integration/api/Report/ChangeTransactionsReportOnSearchTest.php and add MergeReportsTest.php alongside).
- Auth:
ChangeTransactionsReport ultimately calls ReportTransactions(authToken, reportID, transactionIDList, ...) in auth/command/ReportTransactions.cpp (reportID -1 = deleted bucket), which moves expenses through the existing Report::addTransactions() path.
- MergeReports server logic: validate same owner + same workspace + same state + same approver (for Processing reports) + write access server-side (don't trust the client), then for each source report move its transactions into the destination (reuse
ReportTransactions per source with previousReportID set), and delete each emptied source report -- all in one atomic command so partial merges can't happen.
- Decision needed before writing FE optimistic data: confirm whether
MergeReports should be a thin Web wrapper looping ReportTransactions, or a dedicated Auth command. Leaning Auth-side for atomicity; the looping-Web approach is lower-risk if per-source moves are already transactional. The response shape affects how FE reconciles.
- Maintains the 1:1:1 mapping:
MERGE_REPORTS <-> Web MergeReports <-> Auth command.
Order of Implementation
Vertical slice (backend -> eligibility -> command/params -> action -> UI -> wiring), with the riskiest unknown (backend) front-loaded so it's not discovered at the UI step.
-
Lock design (done) -- destination model, destinationReportID, permission primitive (same-account + same-workspace + same-state + same-approver-for-Processing + write access), outstanding-report investigation parked.
-
Backend MergeReports -- Web command registration + handler + Auth move/delete logic, reusing ReportTransactions + integration tests. Nothing reconciles without it.
-
canMergeReports() in ReportUtils.ts -- report-level permission checks (same account, workspace, state, approver for Processing reports, write access) + unit tests.
-
Constants + command types -- CONST.SEARCH.BULK_ACTION_TYPES.MERGE_REPORTS, WRITE_COMMANDS.MERGE_REPORTS, MergeReportsParams (plain-array params).
-
mergeReports() action -- built on changeTransactionsReport's optimistic-data builder + SNAPSHOT updates + unit/snapshot tests.
-
Route + navigator entry + SearchReportsMergeReport.tsx -- modeled on the move flow, as a destination picker.
-
Wire into useSearchBulkActions.ts + post-merge cleanup (navigation, clear selection).
-
(Separate, not blocking) Investigate the outstanding-report / isForwardedReport permission question independently; only ship if validated.
Sub-issues
Broken out into a backend-first vertical slice:
The backend command and canMergeReports() (#94870) have no shared dependency and can run in parallel — the natural backend/frontend split.
Issue Owner: @garrettmknight
Issue Owner
Current Issue Owner: @garrettmknight
Proposal: Merge Multiple Reports into a Single Report
References: WN Post · Pre-design 1 · Pre-design 2 · Figma · Implementation Pre-Design
Background
Members frequently end up with multiple reports containing expenses they want to manage together. Today, consolidation requires opening reports individually, selecting expenses, and using the existing "Move to report" action -- but users keep asking for a merge feature.
Problem
When a user has multiple editable reports representing the same body of work, consolidating all associated expenses into a single report requires manually moving expenses between reports -- extra navigation that also leaves behind unnecessary empty reports.
Solution
Add a "Merge" action to the report bulk-selection menu when two or more editable reports owned by the same account are selected.
Flow
After the merge completes, the member is navigated to the destination report and shown a confirmation message.
Out of Scope
Implementation
1.
canMergeReports(selectedReports, currentUserAccountID)inReportUtils.tsReturns
falseif:ownerAccountIDs (cross-account not supported).policyIDs (cross-workspace not supported in v1).stateNum/statusNum). Only reports at the same point in the workflow can be merged -- this prevents merging Open drafts onto Processing reports and bypassing approvals.managerID). (Redundant for Open reports -- the same-state requirement above prevents mixing Open and Processing, and same-account + same-workspace already guarantees a matchingmanagerIDfor drafts.)canUserPerformWriteAction(report)or the user is neitherisReportOwner(report)nor an admin/approver with write access.Add unit tests for every eligibility branch. Note: this checks eligibility to start the merge; it does not yet know the destination (chosen later in the RHP).
2.
CONST.SEARCH.BULK_ACTION_TYPES.MERGE_REPORTSNamespace under
SEARCH(not a top-levelBULK_ACTION_TYPES), matching existing bulk-action constants.3.
WRITE_COMMANDS.MERGE_REPORTS+ param type insrc/libs/API/types.tsMERGE_REPORTS: 'MergeReports'toWRITE_COMMANDS.MergeReportsParamstoWriteCommandParameters:destinationReportID: stringsourceReportIDs: string[](a real array, derived as selected minus destination)ChangeTransactionsReportif reusing its move primitive.4.
mergeReports(...)action -- reuse the existing move builderFE responsibility:
mergeReports()reuses the optimistic-data helpers fromchangeTransactionsReport()insrc/libs/actions/Transaction.tsrather than reimplementing "move + delete empty" inReport.ts. That function already moves transactions, updates source totals/counts, manages transaction threads, and tracksstaleReportIDs. The actual move + delete of empty source reports is performed by the backendMergeReportscommand (see Step 9).ONYXKEYS.COLLECTION.SNAPSHOTupdates.Splits: Split expenses are preserved through the merge -- the split link lives on transaction data (
comment.originalTransactionID), not on the source report -- as long as the move goes through the existingReport::addTransactions()path (whichChangeTransactionsReportalready uses). Confirmed bylakchote; no special handling needed.5. Surface the action in
src/hooks/useSearchBulkActions.tsFollow the existing pattern: a local computed gate (e.g.
queryJSON?.type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT) combined withcanMergeReports(...), mirroring helpers likeshouldShowBulkDuplicateOption().MERGE_REPORTSbulk action.6. Route + Navigator Entry
Model on the real move flow, not a non-existent
CHANGE_REPORT:ROUTES.MOVE_TRANSACTIONS_SEARCH_RHP(search/move-transactions/search/:backTo?)SCREENS.SEARCH.TRANSACTIONS_CHANGE_REPORT_SEARCH_RHP7.
SearchReportsMergeReport.tsx(modeled onSearchTransactionsChangeReport.tsx)SearchContext.IOURequestEditReportCommon(src/pages/iou/request/step/IOURequestEditReportCommon.tsx) -- confirm its props (transactionIDs/selectReport/selectedReportID) fit a destination picker; the list should be the selected reports themselves, not an unrelated report set.destinationReportID(stored in local state).mergeReports(destinationReportID, derivedSourceReportIDs).8. Post-merge Cleanup
SearchContext(clearSelectedTransactionsor equivalent).9. Backend Command --
MergeReports(new -- was missing entirely)The backend
MergeReportscommand performs the actual move + delete of empty source reports. The move flow's backend is the model:ChangeTransactionsReportis registered inapiCommandTypes.phpas a write ('w') command. Add a siblingMergeReports => 'w'and a handler in the Report command domain (mirror_tests/integration/api/Report/ChangeTransactionsReportOnSearchTest.phpand addMergeReportsTest.phpalongside).ChangeTransactionsReportultimately callsReportTransactions(authToken, reportID, transactionIDList, ...)inauth/command/ReportTransactions.cpp(reportID -1= deleted bucket), which moves expenses through the existingReport::addTransactions()path.ReportTransactionsper source withpreviousReportIDset), and delete each emptied source report -- all in one atomic command so partial merges can't happen.MergeReportsshould be a thin Web wrapper loopingReportTransactions, or a dedicated Auth command. Leaning Auth-side for atomicity; the looping-Web approach is lower-risk if per-source moves are already transactional. The response shape affects how FE reconciles.MERGE_REPORTS<-> WebMergeReports<-> Auth command.Order of Implementation
Vertical slice (backend -> eligibility -> command/params -> action -> UI -> wiring), with the riskiest unknown (backend) front-loaded so it's not discovered at the UI step.
Lock design (done) -- destination model,
destinationReportID, permission primitive (same-account + same-workspace + same-state + same-approver-for-Processing + write access), outstanding-report investigation parked.Backend
MergeReports-- Web command registration + handler + Auth move/delete logic, reusingReportTransactions+ integration tests. Nothing reconciles without it.canMergeReports()inReportUtils.ts-- report-level permission checks (same account, workspace, state, approver for Processing reports, write access) + unit tests.Constants + command types --
CONST.SEARCH.BULK_ACTION_TYPES.MERGE_REPORTS,WRITE_COMMANDS.MERGE_REPORTS,MergeReportsParams(plain-array params).mergeReports()action -- built onchangeTransactionsReport's optimistic-data builder + SNAPSHOT updates + unit/snapshot tests.Route + navigator entry +
SearchReportsMergeReport.tsx-- modeled on the move flow, as a destination picker.Wire into
useSearchBulkActions.ts+ post-merge cleanup (navigation, clear selection).(Separate, not blocking) Investigate the outstanding-report /
isForwardedReportpermission question independently; only ship if validated.Sub-issues
Broken out into a backend-first vertical slice:
MergeReportscommand (Web-Expensify + Auth) — https://github.com/Expensify/Expensify/issues/653440canMergeReports()eligibility helper + unit tests → [Due for payment 2026-07-14] [Merge Reports][FE]canMergeReports()eligibility helper + unit tests #94870mergeReports()action + optimistic data + Constants + command types → [Due for payment 2026-07-28] [Merge Reports][FE]mergeReports()action + optimistic data + Constants + command types #94872SearchReportsMergeReport.tsx→ [Merge Reports][FE] Merge RHP: route + navigator +SearchReportsMergeReport.tsx#94873The backend command and
canMergeReports()(#94870) have no shared dependency and can run in parallel — the natural backend/frontend split.Issue Owner: @garrettmknight
Issue Owner
Current Issue Owner: @garrettmknight