Skip to content

[Due for payment 2026-06-24] [$250] Admin unable to add unreported credit card transactions to a cardholder's existing report in NewDot without opening the report first #92519

Closed

Description

@m-natarajan

If you haven’t already, check out our contributing guidelines for onboarding and email contributors@expensify.com to request to join our Slack channel!


Version Number:
Reproducible in staging?: Needs reproduction (Requires unreported credit card transactions)
Reproducible in production?: Needs reproduction
If this was caught during regression testing, add the test name, ID and link from BrowserStack:
Email or phone of affected tester (no customers):
Logs: https://stackoverflow.com/c/expensify/questions/4856
Expensify/Expensify Issue URL:
Issue reported by: @rairibeiro
Slack conversation (hyperlinked to channel name): #Expensify Bugs

Action Performed:

  1. Open Spend > Expenses
  2. Select the unreported company card transactions > Move to report
  3. Observe the option available is “Create report” under workspace.
  4. Now open a user’s draft report
  5. Go back to Spend > Expenses
  6. Select the unreported company card transactions again > Move to report
  7. Observe the draft report you opened in Moving files and folders a little #4 now appears.

Expected Result:

The admin shouldn't have to open the draft report first in order to move the transactions to the user's draft report.

From @luacmartins:

It seems like that report destination list uses ONYXKEYS.DERIVED.OUTSTANDING_REPORTS_BY_POLICY_ID which is only computed from live Onyx reports. Search sends those in the snapshot, so the user probably doesn't have that report in live Onyx until you open it

I don't know when we started using ONYXKEYS.DERIVED.OUTSTANDING_REPORTS_BY_POLICY_ID but at least for Search, it should consider snapshot data

Actual Result:

The admin has to open the draft report first to then be able to see it in the "Move to report" menu.

Workaround:

Open the draft reports first, but that's unrealistic and bad UX.

Platforms:

Select the officially supported platforms where the issue was reproduced:

  • Android: App
  • Android: mWeb Chrome
  • iOS: App
  • iOS: mWeb Safari
  • iOS: mWeb Chrome
  • Windows: Chrome
  • MacOS: Chrome / Safari

Screenshots/Videos

The screenshot for the issue is in Slack thread

View all open jobs on GitHub

Upwork Automation - Do Not Edit
Issue OwnerCurrent Issue Owner: @mallenexpensify

Activity

  1. added
    BugSomething is broken. Auto assigns a BugZero manager.
    on Jun 3, 2026
  2. MelvinBot commented on Jun 3, 2026

    @MelvinBot
    Contributor

    Proposal

    When a user selects unreported credit card transactions in Spend → Expenses and taps Move to report, the picker shows only "Create report" — none of the user's existing outstanding reports appear (Expensify Classic lists them correctly).

    What is the root cause of that problem?

    The report list is built by getOutstandingReportsForUser, which keeps only reports where report.ownerAccountID === reportOwnerAccountID (ReportUtils.ts:11584). That owner ID flows from the selected transactions, where each transaction's owner is set to reportAction.actorAccountID with no fallback (Search/index.tsx:169):

    ownerAccountID: item.reportAction?.actorAccountID,

    For an unreported credit card transaction, that action's actorAccountID is not the viewing user's accountID, so the resolved owner owns none of the user's outstanding reports → getOutstandingReportsForUser returns [] → the picker shows only the "Create report" header. A manually-created unreported expense isn't affected because its action's actor is the user (or is absent and falls back to the current user) — which is why this is card-specific.

    ⚠️ Honesty note: the code chain that empties the list is verified end-to-end, but I could not confirm from code alone what actorAccountID value the backend writes for unreported card transactions. The issue is marked Needs reproduction with no IDs/logs, so this should be confirmed during repro (steps below).

    Verified data flow (file:line evidence)
    1. Search/index.tsx:169 sets each selected transaction's ownerAccountID = item.reportAction?.actorAccountID (also mirrored at L866 and L929). Note the analogous "from" resolver does add a fallback: reportAction?.actorAccountID ?? report?.ownerAccountID (SearchUIUtils.ts:2110).
    2. SearchTransactionsChangeReport.tsx:77-98 derives targetOwnerAccountID from the first numeric ownerAccountID in the selection, then passes it to the picker.
    3. IOURequestEditReportCommon.tsx:82-92 keeps targetOwnerAccountID as resolvedReportOwnerAccountID (only falls back to current user when it's undefined), then calls useOutstandingReports(...).
    4. getOutstandingReportsForUser (ReportUtils.ts:11584) filters on report.ownerAccountID === reportOwnerAccountID → returns [] when the owner is wrong → only the "Create report" header renders (IOURequestEditReportCommon.tsx:319-330).

    What changes do you think we should make in order to solve the problem?

    Make the owner resolution robust instead of trusting actorAccountID blindly:

    • In Search/index.tsx:169 (and the mirrored writes at L866/L929), mirror the SearchUIUtils fallback — e.g. item.reportAction?.actorAccountID ?? item.report?.ownerAccountID — and for unreported items resolve to the current user's accountID.
    • Equivalently, in SearchTransactionsChangeReport.tsx:77-98, when areAllTransactionsUnreported is true, resolve targetOwnerAccountID to the current user's accountID.

    The fix must preserve the admin-moving-a-member's-expense case (the destination report should stay owned by the true expense owner), so default to the current user only for the user's own unreported expenses.

    What alternative solutions did you explore? (Optional)

    Related issues: #92370 (same Move-to-report / unreported surface) and #89390 (imported card expenses not merging/reporting).

    To confirm the root cause: reproduce with an unreported credit card transaction and inspect the selected-transaction payload — verify ownerAccountID/reportAction.actorAccountID differs from the current user's accountID and that getOutstandingReportsForUser returns []. Also confirm whether a manually-created unreported expense behaves correctly in the same build (card-specific vs. #91961).


    Next Steps for Contributor+ team:
    To accept: @MelvinBot implement this to create a draft PR.
    To refine: @MelvinBot <your feedback>
    To reject: Explain why you are rejecting Melvin's proposal.

  3. added
    ExternalAdded to denote the issue can be worked on by a contributor
    on Jun 3, 2026
  4. added
    Help WantedApply this label when an issue is open to proposals by contributors
    on Jun 3, 2026
  5. mukhrr commented on Jun 3, 2026

    @mukhrr
    Contributor

    🚨 Edited by proposal-police: This proposal was edited at 2026-06-03 14:36:22 UTC.

    Proposal

    What is the root cause of that problem?

    The Move-to-report flow opened from Search crosses a data-source boundary. Search renders from the snapshot (currentSearchResults.data), but SearchTransactionsChangeReport hands off to IOURequestEditReportCommon, whose destination picker reads reports only from live Onyx via useOutstandingReports(...). The eligible destination report can exist only in the Search snapshot until it is opened.

    Repro confirms it: the report appears only after you open the cardholder's draft report once. Opening it doesn't change eligibility — it just hydrates it into live ONYXKEYS.COLLECTION.REPORT, which lets ONYXKEYS.DERIVED.OUTSTANDING_REPORTS_BY_POLICY_ID include it. So this isn't an owner-resolution or canAddTransaction problem (those already pass once the report is live) — it's candidate availability.

    The existing Search hooks already solve this "data only in snapshot" problem by merging the snapshot in memory at consumption (never writing it back): useMergeTransactions, useAllTransactions, useSearchBulkEditPolicyID. The picker just never received this treatment.

    What changes do you think we should make in order to solve the problem?

    Make the Search picker snapshot-aware by merging in memory — keep the snapshot coupling in the Search screen, keep the shared component generic, and write nothing to live Onyx.

    1. Build snapshot-only destination candidates in SearchTransactionsChangeReport (filtered through the existing eligibility helper, so owner/policy/outstanding checks still apply):

    +const {currentSearchResults} = useSearchResultsContext();
    +
    +const additionalOutstandingReports = useMemo(() => {
    +    const byPolicy: Record<string, NonNullable<OnyxCollection<Report>>> = {};
    +    for (const [key, value] of Object.entries(currentSearchResults?.data ?? {})) {
    +        const report = value as OnyxEntry<Report>;
    +        // snapshot reports NOT already in live Onyx — useOutstandingReports already covers live ones
    +        if (!key.startsWith(ONYXKEYS.COLLECTION.REPORT) || !report?.reportID || !report.policyID || allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`]) { continue; }
    +        byPolicy[report.policyID] = {...byPolicy[report.policyID], [`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`]: report};
    +    }
    +    return Object.entries(byPolicy).flatMap(([policyID, reports]) => getOutstandingReportsForUser(policyID, targetOwnerAccountID, reports));
    +}, [currentSearchResults?.data, allReports, targetOwnerAccountID]);

    (add useSearchResultsContext, getOutstandingReportsForUser, OnyxEntry, Report to the existing imports)

    Pass them to the picker, and let selection fall back to the snapshot object if the report isn't live yet (the move action is the one legitimate place a write happens):

     <IOURequestEditReportCommon
         // ...
    +    additionalOutstandingReports={additionalOutstandingReports}
     />
    -const destinationReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${item.value}`];
    +const destinationReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${item.value}`] ?? item.report;

    2. In IOURequestEditReportCommon, merge once and route the merged list through every outstandingReports reference — this is what keeps "Create report" working and is the fix the repro actually needs:

     type Props = {
         // ...
    +    additionalOutstandingReports?: Array<OnyxEntry<Report>>;
     };
     const outstandingReports = useOutstandingReports(selectedReportID, selectedPolicyID, resolvedReportOwnerAccountID, isEditing);
    +const mergedOutstandingReports = useMemo(
    +    () => dedupeByReportID([...outstandingReports, ...(additionalOutstandingReports ?? [])]),
    +    [outstandingReports, additionalOutstandingReports],
    +);

    Replace outstandingReports with mergedOutstandingReports at all of: the reportOptions early-return + body + deps (L131/L135/L172), shouldShowNotFoundPage (L276 + its deps), and shouldShowTextInput (L305). And carry the report/policy on each row for the avatar:

     .map((report) => {
         const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`];
         return {
             // ...existing fields (text, value, reportID, icons, ...)
    +        report,
    +        policy,
         };
     })

    ⚠️ The early-return guard is the critical one: with only return … swapped, if (outstandingReports.length === 0) return [] (L131) still fires in the repro (live list is empty) and the snapshot report never renders.

    3. Fix the avatar via the fallback the avatar component already exposes — no Onyx write. ReportActionAvatars resolves report = reportFromOnyx ?? reportProp and policy = policyFromOnyx ?? policyProp (L122‑L125); InviteMemberListItem just doesn't forward them today (L91):

     <ReportActionAvatars
         reportID={item.reportID}
    +    report={item.report}
    +    policy={item.policy}
         accountIDs={accountID ? [accountID] : undefined}
     />
     // SelectionList/ListItem/types.ts — alongside the existing reportID?/policyID?/icons?
    +report?: OnyxEntry<Report>;
    +policy?: OnyxEntry<Policy>;

    dedupeByReportID is a tiny pure helper (live entries kept first), so it's unit-testable in isolation.

    Regression control

    • No change to the derived value, useOutstandingReports, or owner/policy/eligibility filters — snapshot candidates pass the same getOutstandingReportsForUser + canAddTransaction/admin‑manager checks.
    • Non-Search callers (IOURequestStepReport) pass no additionalOutstandingReports → behavior byte-for-byte identical.
    • Nothing written to live Onyx; snapshot data never persists into the global store.
    • "Create report" stays on the existing customListHeaderContent/listEmptyContent paths, now driven by the true merged count.

    What alternative solutions did you explore? (Optional)

    n/a

  6. David-2002065 commented on Jun 3, 2026

    @David-2002065
    Contributor

    🚨 Edited by proposal-police: This proposal was edited at 2026-06-03 14:37:23 UTC.

    Proposal

    What is the root cause of that problem?

    The "Move to report" RHP for selected transactions in Spend > Expenses is SearchTransactionsChangeReport, which feeds its data into IOURequestEditReportCommon. The list of pickable reports comes from useOutstandingReports, which filters reports by their ownerAccountID. That filter resolves to resolvedReportOwnerAccountID here:

    https://github.com/Expensify/App/blob/main/src/pages/iou/request/step/IOURequestEditReportCommon.tsx#L82-L92

    const resolvedReportOwnerAccountID = useMemo(() => {
        if (targetOwnerAccountID !== undefined) {
            return targetOwnerAccountID;
        }
        if (selectedReport?.ownerAccountID !== undefined) {
            return selectedReport.ownerAccountID;
        }
        return currentUserPersonalDetails.accountID;
    }, [targetOwnerAccountID, selectedReport?.ownerAccountID, currentUserPersonalDetails.accountID]);

    For an all-unreported selection from Search:

    const ownerFromSelection = selectedTransactionsKeys
        .map((transactionKey) => selectedTransactions[transactionKey]?.ownerAccountID)
        .find((ownerID) => typeof ownerID === 'number');
    if (ownerFromSelection !== undefined) {
        return ownerFromSelection;
    }

    But selectedTransactions[key].ownerAccountID is populated in the Search transaction list with transactionItem.reportAction?.actorAccountID:

    https://github.com/Expensify/App/blob/main/src/components/Search/index.tsx#L860-L869
    https://github.com/Expensify/App/blob/main/src/components/Search/index.tsx#L922-L932

    For an unreported credit card transaction, the reportAction that backs the search row is the card-feed action that imported the expense — and actorAccountID on those actions is the account that imported/created the feed entry (the cardholder when self-imported, but for managed/centrally administered cards it can be the Expensify card service / admin account, NOT the current user). That's a number, so ownerFromSelection returns it and targetOwnerAccountID becomes "not the current user".

    The fallback to currentUserPersonalDetails.accountID in IOURequestEditReportCommon is never reached because targetOwnerAccountID !== undefined. useOutstandingReports then filters via getOutstandingReportsForUser(..., ownerAccountID=<actorAccountID>, ...):

    https://github.com/Expensify/App/blob/main/src/libs/ReportUtils.ts#L11574-L11590

    …which keeps only reports where report.ownerAccountID === <that other account>. The user's own existing expense reports don't match, so the outstanding list is empty and the picker collapses to just the createReportOption header.

    That matches what the bug describes: only "Create report" shows. (And it explains why Expensify Classic is fine — Classic doesn't go through this owner-filtered list.)

    What changes do you think we should make in order to solve the problem?

    I think the fix belongs in IOURequestEditReportCommon: when we already know the selection is fully unreported (the isUnreported prop is already plumbed in from SearchTransactionsChangeReport and IOURequestStepReport), the user moving the expenses is the only meaningful "destination owner" — the cardholder is operating on their own self-DM expenses, the destination report will be theirs. So resolvedReportOwnerAccountID should short-circuit to the current user in that case, ignoring whatever actorAccountID the underlying card-feed action happens to carry:

         const resolvedReportOwnerAccountID = useMemo(() => {
    +        // For unreported selections, the current user is moving their own expenses to a destination
    +        // report they own. The selection's `ownerAccountID` here is derived from the underlying
    +        // reportAction's actorAccountID, which for card-feed actions can be the importer/admin
    +        // rather than the cardholder — using it would filter the picker by the wrong owner and
    +        // hide the user's existing reports (issue #92519).
    +        if (isUnreported) {
    +            return currentUserPersonalDetails.accountID;
    +        }
             if (targetOwnerAccountID !== undefined) {
                 return targetOwnerAccountID;
             }
             if (selectedReport?.ownerAccountID !== undefined) {
                 return selectedReport.ownerAccountID;
             }
             return currentUserPersonalDetails.accountID;
    -    }, [targetOwnerAccountID, selectedReport?.ownerAccountID, currentUserPersonalDetails.accountID]);
    +    }, [isUnreported, targetOwnerAccountID, selectedReport?.ownerAccountID, currentUserPersonalDetails.accountID]);

    That's enough to get the picker to call useOutstandingReports(..., ownerAccountID=currentUser, ...), hit the shouldUseAllPolicies branch (because selectedPolicyID is undefined for the unreported case), iterate every non-personal policy and return the user's outstanding reports. They then pass the canAddTransaction(report, undefined, true) filter in reportOptions and render as picker rows.

    Both IOURequestStepReport and SearchTransactionsChangeReport already pass isUnreported correctly:

    https://github.com/Expensify/App/blob/main/src/pages/iou/request/step/IOURequestStepReport.tsx#L47
    https://github.com/Expensify/App/blob/main/src/pages/Search/SearchTransactionsChangeReport.tsx#L75-L76

    …so no change is needed in the callers.

    What alternative solutions did you explore? (Optional)

    • Fix it at the source — stop populating selectedTransactions[key].ownerAccountID from reportAction?.actorAccountID for unreported rows in src/components/Search/index.tsx. That would make targetOwnerAccountID legitimately undefined for these rows and let the existing fallback in IOURequestEditReportCommon kick in. I dropped this because that ownerAccountID value is also read by useSearchBulkActions for multi-owner detection (https://github.com/Expensify/App/blob/main/src/hooks/useSearchBulkActions.ts#L1612-L1629). Blanking it out for unreported rows would shift the meaning of hasMultipleOwners and risk re-introducing the "Move to report" bulk action on selections that mix unreported expenses across users. Scoping the fix to the consumer (IOURequestEditReportCommon) is safer.

    • Treat the missing match as a signal and fall back to current user only when no reports come back. That would mean calling useOutstandingReports a second time with currentUserAccountID, which is wasteful and produces a confusing dependency graph. The semantic fix — "an unreported selection has no meaningful prior owner; use the user moving it" — is cleaner.

    • Override targetOwnerAccountID inside SearchTransactionsChangeReport.tsx instead. Equivalent in behavior, but the same bug also reaches IOURequestStepReport for single unreported card transactions opened from the header Move to report — patching IOURequestEditReportCommon fixes both call sites in one place.

  7. KJ21-ENG commented on Jun 3, 2026

    @KJ21-ENG
    Contributor

    Proposal

    What is the root cause of that problem?

    The updated title/OP and the reviewer test point to a Search snapshot hydration gap in the Move to report picker, with two related snapshot-data omissions in the picker row rendering.

    SearchTransactionsChangeReport is opened from Search, so the selected expenses and some related reports can exist in currentSearchResults.data before the same reports/policies/personal details are hydrated into the live Onyx collections.

    The current picker path is still live-Onyx first:

    • SearchTransactionsChangeReport renders IOURequestEditReportCommon for selected Search expenses.
    • IOURequestEditReportCommon gets destination reports from useOutstandingReports(...).
    • useOutstandingReports reads ONYXKEYS.DERIVED.OUTSTANDING_REPORTS_BY_POLICY_ID.
    • That derived value is computed from live ONYXKEYS.COLLECTION.REPORT, not from the query-scoped Search snapshot.

    So before the cardholder draft report is opened, the report can be present in the Search result snapshot but absent from the live derived outstanding-report list. The destination picker therefore omits the existing draft report. Opening the report first hydrates live report data, which makes the same destination appear.

    The reviewer follow-up also exposed two smaller consequences of the same boundary:

    1. The Create report option can disappear for real-data shapes where the selected Search expense is not treated as reportID: "0" unreported, or where owner/admin context is not present in live Onyx yet. The Search picker needs to carry the selected transaction workspace and explicitly allow this option for the Search move flow.
    2. The destination row avatar can be wrong if the row only passes reportID into ReportActionAvatars. Snapshot-backed destination reports may not exist in live COLLECTION.REPORT, so the avatar component cannot resolve the report/policy and falls back to the wrong icon. The row must preserve and forward the snapshot report/policy data. It should not force the explicit policyID avatar branch, because that renders the workspace as the primary avatar. The expected expense-report avatar is the cardholder/user as primary with the workspace as the subscript.

    This is not primarily a canAddTransaction() bug and not a reason to loosen destination report filtering. The existing owner, workspace, report-state, and canAddTransaction() checks are still correct; the missing piece is that the Search move picker was only using live Onyx candidates and live row metadata.

    What changes do you think we should make in order to solve the problem?

    Keep the existing live-Onyx picker path and eligibility rules, but let the Search Move to report screen provide Search snapshot data as additional local inputs.

    1. In SearchTransactionsChangeReport, read currentSearchResults from useSearchResultsContext() and collect from currentSearchResults.data:

      • snapshot reports,
      • snapshot report name-value-pairs,
      • snapshot policies,
      • snapshot personal details.
    2. Build snapshot destination candidates with getOutstandingReportsForUser(policyID, targetOwnerAccountID, snapshotReports, snapshotReportNameValuePairs, true). This reuses the existing pending-delete, outstanding-state, archived-report, policy, and owner checks.

    3. Derive the selected transaction workspace from the selected Search rows whenever all selected rows share a direct policyID. Use that for the Search move flow's selectedPolicyID/transactionPolicyID fallback so both existing destinations and Create report stay scoped to the transaction workspace instead of falling back to an unrelated active/default workspace.

    4. Pass the snapshot candidates and metadata into IOURequestEditReportCommon with scoped props such as:

      • additionalOutstandingReports,
      • additionalPolicies,
      • additionalPersonalDetails,
      • shouldShowCreateReportOption.
    5. In IOURequestEditReportCommon, merge live and snapshot values locally:

      • merge useOutstandingReports(...) with additionalOutstandingReports and dedupe by reportID,
      • merge live policies with additionalPolicies,
      • merge live personal details with additionalPersonalDetails, with live values taking precedence.
    6. Run the existing sort/search/filter pipeline on the merged destination list. Keep the existing canAddTransaction(report, undefined, true), admin, manager, per-diem, and time-request checks in place, but read policy/personal-details data from the merged local collections.

    7. Preserve the snapshot report and policy on each generated picker item. In SearchTransactionsChangeReport.selectReport, use allReports[reportID] ?? item.report when calling changeTransactionsReport, because a snapshot-backed report can be selected before it exists in live allReports.

    8. Forward item.report and item.policy from InviteMemberListItem to ReportActionAvatars. Do not pass an explicit policyID for these report rows; that triggers the workspace-primary avatar branch. Passing the report/policy lets ReportActionAvatars use the normal expense-report avatar logic: cardholder/user primary plus workspace subscript.

    This keeps the blast radius limited to the Search move-to-report flow:

    • No global change to ONYXKEYS.DERIVED.OUTSTANDING_REPORTS_BY_POLICY_ID.
    • No writing Search snapshot reports into live COLLECTION.REPORT.
    • No loosening of getOutstandingReportsForUser().
    • No bypass of canAddTransaction() or report ownership checks.
    • Non-Search money request/report picker flows continue using live outstanding reports only.

    What alternative solutions did you explore? (Optional)

    • Updating OUTSTANDING_REPORTS_BY_POLICY_ID to include Search snapshot data. I rejected this because the derived value is global live-Onyx state, while Search snapshots are query/screen scoped.
    • Writing snapshot reports into live COLLECTION.REPORT. I rejected this because it would make temporary Search snapshot data global and could create stale live state.
    • Only fixing owner resolution for selected unreported card rows. This helps scoping, but does not fully solve the updated OP because opening the draft report first fixes the issue by hydrating live report data.
    • Passing only policyID to fix the avatar. I rejected this after testing because ReportActionAvatars treats explicit policyID as the workspace-avatar path. The correct report row should pass report/policy so the cardholder remains the primary avatar and the workspace appears only as the subscript.
  8. yusufdeveloper2903 commented on Jun 3, 2026

    @yusufdeveloper2903
    Contributor

    Proposal

    Please re-state the problem that we are trying to solve in this issue.

    When a user selects an unreported credit-card expense in Search (Spend → Expenses) and chooses Selected → Move to report, the only destination offered is "Create report" — the user's existing outstanding reports are never listed, even though Expensify Classic offers them.

    What is the root cause of that problem?

    The "Move to report" screen for a Search selection is SearchTransactionsChangeReport, which renders the shared IOURequestEditReportCommon. The list of existing reports it shows is built from useOutstandingReports, and that hook scopes the result to a single owner account:

    // src/libs/ReportUtils.ts  (getOutstandingReportsForUser)
    return Object.values(reports).filter(
        (report) =>
            report?.pendingFields?.preview !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE &&
            isReportOutstanding(report, policyID, reportNameValuePairs, allowSubmitted) &&
            report?.ownerAccountID === reportOwnerAccountID,   // ← everything hinges on this account
    );

    https://github.com/Expensify/App/blob/2d8b35ed632/src/libs/ReportUtils.ts#L11578-L11583

    So the whole question is: what owner account does reportOwnerAccountID resolve to for an unreported card expense? Tracing it back:

    1. IOURequestEditReportCommon resolves the owner here, falling back to the current user when nothing better is available:

      if (targetOwnerAccountID !== undefined) { return targetOwnerAccountID; }
      if (selectedReport?.ownerAccountID !== undefined) { return selectedReport.ownerAccountID; }
      return currentUserPersonalDetails.accountID;

      https://github.com/Expensify/App/blob/2d8b35ed632/src/pages/iou/request/step/IOURequestEditReportCommon.tsx#L82-L92

    2. targetOwnerAccountID comes from SearchTransactionsChangeReport, which tries to read the owner off the selection metadata and otherwise returns undefined:

      // Prefer owner metadata attached to each selection (handles unreported expenses)
      const ownerFromSelection = selectedTransactionsKeys
          .map((k) => selectedTransactions[k]?.ownerAccountID)
          .find((ownerID) => typeof ownerID === 'number');
      if (ownerFromSelection !== undefined) { return ownerFromSelection; }
      // …then tries a reported report's owner; for all-unreported it returns undefined

      https://github.com/Expensify/App/blob/2d8b35ed632/src/pages/Search/SearchTransactionsChangeReport.tsx#L77-L98

    3. The selection's ownerAccountID is populated only from the transaction's money-request action:

      ownerAccountID: transactionItem.reportAction?.actorAccountID,

      https://github.com/Expensify/App/blob/2d8b35ed632/src/components/Search/index.tsx#L866
      For an unreported transaction (reportID === '0') there is no money-request action in the Search payload, so reportAction is undefined → ownerAccountID is undefined → targetOwnerAccountID is undefined → reportOwnerAccountID falls back to the current user.

    That fallback is correct by design when the viewer owns the expenses (the app's own convention is "unreported ⇒ creator is the current user" — see getReportOwnerAccountIDAsAttendee, TransactionUtils.ts#L1087-L1089). The problem is that the single-expense flow recovers the real owner of an unreported expense from its IOU action, while the bulk Search flow has no equivalent and silently defaults to the current user:

    // src/pages/iou/request/step/IOURequestStepReport.tsx — single expense recovers the true owner
    const ownerAccountID = isUnreported
        ? iouActions?.find((a) => getOriginalMessage(a)?.IOUTransactionID === transaction.transactionID)?.actorAccountID
        : selectedReport?.ownerAccountID;

    https://github.com/Expensify/App/blob/2d8b35ed632/src/pages/iou/request/step/IOURequestStepReport.tsx#L67-L69

    The net effect: for unreported company-card expenses — the canonical case where the expense's owner is the cardholder, not necessarily the viewer, and is never carried on the Search selection — getOutstandingReportsForUser is asked for reports owned by the wrong account. None match, so reportOptions is empty and IOURequestEditReportCommon renders only the "Create report" header (listEmptyContent={createReportOption}):
    https://github.com/Expensify/App/blob/2d8b35ed632/src/pages/iou/request/step/IOURequestEditReportCommon.tsx#L130-L172

    Note on certainty: the issue is still flagged "Needs reproduction (requires unreported credit card transactions)". The owner-scoping mechanism above is verified at code level and is the only path that collapses the list to "Create report"; the exact account whose reports are being filtered out can only be confirmed against the affected card account. I've called this out honestly rather than overstate it.

    What changes do you think we should make in order to solve the problem?

    Give the bulk Search flow the same owner-recovery the single-expense flow already has, so an unreported expense's destination list is scoped to the owner of the expense rather than blindly to the viewer.

    The cleanest place is where the selection's ownerAccountID is populated — recover the owner of an unreported transaction from its created/IOU action (resolved from Onyx) instead of relying on a reportAction that doesn't exist for unreported items:

    - ownerAccountID: transactionItem.reportAction?.actorAccountID,
    + // Unreported transactions carry no money-request action in the Search payload, so fall back to
    + // the creator recorded on the transaction's own thread/IOU action.
    + ownerAccountID: transactionItem.reportAction?.actorAccountID ?? getUnreportedExpenseOwnerAccountID(transactionItem),

    (applied at both selection-build sites — index.tsx#L866 and #L929).

    Supporting work (described, not diffed):

    • Add a small getUnreportedExpenseOwnerAccountID(transaction) helper next to getReportOwnerAccountIDAsAttendee in TransactionUtils, reusing the existing IOU-action lookup pattern (getOriginalMessage(action).IOUTransactionID === transaction.transactionID → actorAccountID) and defaulting to the current user when nothing is found — preserving today's correct behaviour for the user's own expenses.
    • With a real ownerAccountID on each selection, SearchTransactionsChangeReport's existing ownerFromSelection branch (#L83-L86) automatically picks it up, so getOutstandingReportsForUser is scoped to the correct owner and that owner's outstanding reports appear.

    Edge cases:

    • Mixed owners: the bulk "Move to report" action is already disabled when a selection spans multiple owners (hasMultipleOwners in useSearchBulkActions, #L1629-L1641), so a single resolved owner is safe.
    • Offline: purely a read/derived-data change (owner lookup + filter) — no new writes, so offline behaviour is unaffected.
    • Own expenses (today's happy path): the helper still resolves to the current user, so nothing regresses for users moving their own unreported expenses.

    What alternative solutions did you explore? (Optional)

    • Drop the report.ownerAccountID === reportOwnerAccountID filter for unreported moves — rejected: it would let a user move expenses onto reports that aren't theirs and breaks the per-owner scoping every other caller relies on.
    • Always fall back to the current user (status quo) — rejected: that's exactly the current behaviour, correct only when the viewer owns the card expenses, and is what produces the empty list for company-card expenses owned by someone else.
  9. kamronbek04 commented on Jun 3, 2026

    @kamronbek04
    Contributor

    Proposal

    What is the root cause of that problem?

    The "Move to report" sheet builds its list of existing destination reports in IOURequestEditReportCommon. The outstanding reports it gets from useOutstandingReports are run through a final eligibility filter, and the gate that decides whether a report survives is canAddTransaction(report, undefined, true) — note the third argument, isMovingTransaction = true:

    https://github.com/Expensify/App/blob/main/src/pages/iou/request/step/IOURequestEditReportCommon.tsx#L139-L157

    Inside canAddTransaction, there's a block that rejects reports under a workspace configured with instant submit + Submit-and-Close. It has two reasons to reject, OR'd together — but only the second one was guarded by !isMovingTransaction:

    https://github.com/Expensify/App/blob/main/src/libs/ReportUtils.ts#L2775-L2782

    if (
        isInstantSubmitEnabled(policy) &&
        isSubmitAndClose(policy) &&
        (hasOnlyNonReimbursableTransactions(moneyRequestReport?.reportID) ||
            (!isMovingTransaction && !isOpenExpenseReport(moneyRequestReport) && policy?.reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO))
    ) {
        return false;
    }

    This is where credit-card expenses specifically fall over. hasOnlyNonReimbursableTransactions returns true for any report whose transactions are all non-reimbursable — and company/credit-card expenses are non-reimbursable by definition (the company pays the card, the employee isn't reimbursed):

    https://github.com/Expensify/App/blob/main/src/libs/ReportUtils.ts#L2557-L2564

    So on the exact workspace setup company cards normally use (instant submit + Submit-and-Close), the user's existing card reports each contain only non-reimbursable transactions, hasOnlyNonReimbursableTransactions is true, and canAddTransaction returns false — for the move flow too, because that first branch is not gated by isMovingTransaction. Every existing report gets filtered out of reportOptions, the list renders empty, and the only thing left is the "Create report" header. The isReportPolicyAdmin || isReportManager fallback in the list filter doesn't save a regular cardholder, since they're neither an admin nor the report's manager.

    This is a regression from the change that introduced the isMovingTransaction parameter (added 2025-11-21, commit 7ed380d6e794). The intent of that flag was precisely to relax the instant-submit/Submit-and-Close restriction when a user is explicitly moving an expense into a report rather than auto-adding one that would trigger submit-and-close — but the guard was only applied to the reimbursement-choice branch and not to the hasOnlyNonReimbursableTransactions branch, leaving card reports blocked.

    What changes do you think we should make in order to solve the problem?

    Hoist the !isMovingTransaction guard so it gates the entire instant-submit/Submit-and-Close exclusion, not just one of its two branches. The auto-submit-and-close concern is about creating/auto-adding an expense that would immediately close the report; when the user deliberately picks a destination in "Move to report", that concern doesn't apply, which is the whole reason the flag exists.

         if (
             isInstantSubmitEnabled(policy) &&
             isSubmitAndClose(policy) &&
    -        (hasOnlyNonReimbursableTransactions(moneyRequestReport?.reportID) ||
    -            (!isMovingTransaction && !isOpenExpenseReport(moneyRequestReport) && policy?.reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO))
    +        !isMovingTransaction &&
    +        (hasOnlyNonReimbursableTransactions(moneyRequestReport?.reportID) ||
    +            (!isOpenExpenseReport(moneyRequestReport) && policy?.reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO))
         ) {
             return false;
         }

    The behavior of the create/add path is byte-for-byte unchanged: when isMovingTransaction is false, !isMovingTransaction is true, so the block still evaluates hasOnlyNonReimbursableTransactions(...) || (!isOpenExpenseReport(...) && reimbursementNo) exactly as before. Only the move path changes — moving an expense into an instant-submit/Submit-and-Close report (including a card-only one) is now allowed, which is the desired outcome.

    The blast radius is tightly bounded. isMovingTransaction is only ever true at a single call site — the move-to-report destination filter at IOURequestEditReportCommon.tsx#L150. Every other canAddTransaction caller (the add-expense button, secondary actions, isReportOutstanding, duplicate handling) uses the default false, so none of them see any change. Scope boundary: this does not touch useOutstandingReports, getOutstandingReportsForUser's owner filter, or the report-creation path — it only stops the move-destination list from hiding eligible card reports. One thing for reviewers to confirm: that the server-side changeTransactionsReport/move accepts the card expense into a Submit-and-Close report without re-rejecting it, so the now-visible option also completes successfully.

    Verification approach

    On a workspace with instant submit + Submit-and-Close enabled and at least one existing open expense report that holds only company-card (non-reimbursable) expenses, go to Spend > Expenses, select an unreported card expense, and choose Selected > Move to report. After the fix the existing card report(s) appear as selectable options alongside "Create report"; before the fix only "Create report" shows. Sanity-check the non-card path too: moving a reimbursable expense, and creating a brand-new expense on the same policy, should behave exactly as they do today.

    What alternative solutions did you explore?

    Adding the existing reports back by loosening getOutstandingReportsForUser or useOutstandingReports. I rejected it because those layers are working correctly here — for an unreported expense the owner resolves to the current user and the user's outstanding reports are returned; the reports are only dropped later by the canAddTransaction eligibility filter. Changing the outstanding-reports source would mask the real gate and risk surfacing genuinely ineligible reports (the exact problem that got a prior useOutstandingReports filter change reverted as a deploy blocker).

    I also considered special-casing the list filter in IOURequestEditReportCommon to skip canAddTransaction for card transactions. That spreads card-awareness into the UI layer and duplicates eligibility logic that already lives in canAddTransaction, where the isMovingTransaction flag was purpose-built to express exactly this distinction. Fixing the flag's coverage in one place keeps the eligibility rule centralized.

  10. melvin-bot commented on Jun 3, 2026

    @melvin-bot

    Triggered auto assignment to Contributor-plus team member for initial proposal review - @Krishna2323 (External)

  11. moxw commented on Jun 3, 2026

    @moxw

    Proposal

    What is the root cause of that problem?

    The search bulk “Move to report” flow loses the real submitter/cardholder context for unreported expenses.

    In src/components/Search/index.tsx, mapTransactionItemToSelectedEntry() stores the selected row owner as only:

    ownerAccountID: item.reportAction?.actorAccountID

    That is not reliable for unreported credit card transactions, because they do not have a normal expense report and their owner/action context can come from the linked tracked/unreported action or policy participant metadata instead of item.reportAction.

    Then src/pages/Search/SearchTransactionsChangeReport.tsx passes that missing/incorrect targetOwnerAccountID into IOURequestEditReportCommon. The shared picker calls useOutstandingReports(...), and getOutstandingReportsForUser() only returns reports where:

    report.ownerAccountID === ownerAccountID

    So when the owner is missing or falls back to the wrong account, every existing report for the real cardholder is filtered out. The screen still has enough policy context to show the Create report option, which is why only that option remains.

    What changes do you think we should make in order to solve the problem?

    Preserve and consume the real owner/policy context when selecting unreported transactions from Search.

    1. In mapTransactionItemToSelectedEntry() in src/components/Search/index.tsx, derive an effective action and owner more defensively:
    • Prefer item.reportAction
    • Fall back to item.linkedTrackedExpenseReportAction for unreported/tracked card transactions
    • Fall back to item.report?.ownerAccountID for reported expenses
    • Store that derived action in the selected transaction info so later screens use the same source
    • Preserve the transaction workspace policy from the search row or from the policy expense chat participant when the transaction has no report

    Conceptually:

    const effectiveReportAction = item.reportAction ?? item.linkedTrackedExpenseReportAction;
    const policyParticipant = item.participants?.find((participant) => participant.isPolicyExpenseChat);
    
    ownerAccountID: effectiveReportAction?.actorAccountID ?? item.report?.ownerAccountID,
    reportAction: effectiveReportAction,
    policyID: item.policyID ?? policyParticipant?.policyID,
    1. In SearchTransactionsChangeReport, derive targetOwnerAccountID and a common transactionPolicyID from the selected transaction metadata before falling back to report data.

    For all-unreported selections, there is no selected report, so the page should use:

    • the common selected ownerAccountID
    • the common selected policyID

    Then pass that policy into both usePolicyForMovingExpenses(...) and IOURequestEditReportCommon / useOutstandingReports via selectedPolicyID or transactionPolicyID.

    That keeps the report picker scoped to the cardholder and the transaction workspace, so existing open/submitted reports owned by that cardholder are included.

    1. Keep the existing report permission filtering in IOURequestEditReportCommon.

    The picker should still apply canAddTransaction(...), admin/manager checks, per diem checks, billing restrictions, and archived/closed report filtering. The fix should only correct the owner/policy inputs used before those filters run.

    1. Add regression coverage for the search move-report path:
    • Selected unreported card transaction with no normal report, but with linked/unreported action owner metadata
    • Existing outstanding report under the same policy owned by that cardholder
    • Open SearchTransactionsChangeReport
    • Verify the existing report is listed, not only Create report
    • Verify selecting it calls changeTransactionsReport() with that report and policy

    Also cover the multi-select case so transactions from different owners are still blocked by the existing bulk action owner check.

    What alternative solutions did you explore? (Optional)

    I considered changing useOutstandingReports() to ignore ownerAccountID for unreported transactions, but that would expose reports for the wrong submitter and could let admins move a cardholder’s expense into another user’s report. The safer fix is to pass the correct owner/policy context from Search.

  12. changed the title [-]Unable to add unreported credit card transactions to an existing report in New dot[/-] [+][$250] Unable to add unreported credit card transactions to an existing report in New dot[/+] on Jun 3, 2026
  13. 75 remaining items

  14. karan-1302 commented on Jun 26, 2026

    @karan-1302
    Contributor

    @mallenexpensify I’ve accepted the job on upwork. Also, I’m not getting automatic job offers. Thanks.

  15. mallenexpensify commented on Jun 27, 2026

    @mallenexpensify
    Contributor

    @karan-1302 paid, summary updated above. Thx.

    @karan-1302 in a dedicated post, can you enter your Upwork details? I'm hoping they'll be saved to send you auto-offers in the future. Thx

    Contributor details
    Your Expensify account email: email@email.com
    Upwork Profile Link: upwork.com/freelancers/~xxxxx

    Please do so even if you've already done so before, we're trying to figure out the bug that's preventing auto-offers. Thx

  16. karan-1302 commented on Jun 30, 2026

    @karan-1302
    Contributor

    Contributor details
    Your Expensify account email: karangupta95797@gmail.com
    Upwork Profile Link: https://www.upwork.com/freelancers/~01a2f461a042db1768

  17. melvin-bot commented on Jun 30, 2026

    @melvin-bot

    ✅ Contributor details stored successfully. Thank you for contributing to Expensify!

  18. karan-1302 commented on Jun 30, 2026

    @karan-1302
    Contributor
  19. mallenexpensify commented on Jul 1, 2026

    @mallenexpensify
    Contributor

    Thx @karan-1302 , Thanks for entering your contributor details, if you're not auto-sent an Upwork offer when you're assigned to your next issue, please tag me in that issue.

  20. laurenreidexpensify commented on Jul 6, 2026

    @laurenreidexpensify
    Contributor

    $250 approved for @Krishna2323

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

Awaiting PaymentAuto-added when associated PR is deployed to productionBugSomething is broken. Auto assigns a BugZero manager.DailyKSv2ExternalAdded to denote the issue can be worked on by a contributor

Type

No type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions