Skip to content

[Tracking] Merge Reports #92950

Description

@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

  1. 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.
Image
  1. Report picker -- Selecting "Merge" launches a report-selection RHP prompting the member to choose which report to retain.
Image
  1. Destination selection -- The chosen report becomes the destination report.
Image
  1. 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.

  1. Lock design (done) -- destination model, destinationReportID, permission primitive (same-account + same-workspace + same-state + same-approver-for-Processing + write access), outstanding-report investigation parked.

  2. Backend MergeReports -- Web command registration + handler + Auth move/delete logic, reusing ReportTransactions + integration tests. Nothing reconciles without it.

  3. canMergeReports() in ReportUtils.ts -- report-level permission checks (same account, workspace, state, approver for Processing reports, write access) + unit tests.

  4. Constants + command types -- CONST.SEARCH.BULK_ACTION_TYPES.MERGE_REPORTS, WRITE_COMMANDS.MERGE_REPORTS, MergeReportsParams (plain-array params).

  5. mergeReports() action -- built on changeTransactionsReport's optimistic-data builder + SNAPSHOT updates + unit/snapshot tests.

  6. Route + navigator entry + SearchReportsMergeReport.tsx -- modeled on the move flow, as a destination picker.

  7. Wire into useSearchBulkActions.ts + post-merge cleanup (navigation, clear selection).

  8. (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 OwnerCurrent Issue Owner: @garrettmknight

Metadata

Metadata

Labels

NewFeatureSomething to build that is a new item.WeeklyKSv2

Type

No type

Projects

Status
In Progress

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions