You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
[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
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:
Open Spend > Expenses
Select the unreported company card transactions > Move to report
Observe the option available is “Create report” under workspace.
Now open a user’s draft report
Go back to Spend > Expenses
Select the unreported company card transactions again > Move to report
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:
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):
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)
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).
IOURequestEditReportCommon.tsx:82-92 keeps targetOwnerAccountID as resolvedReportOwnerAccountID (only falls back to current user when it's undefined), then calls useOutstandingReports(...).
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)
perf: narrow REPORT_NAME_VALUE_PAIRS subscription in ReportField #91961 (merged 2026-05-29, just before this report) narrowed the report-name-value-pair subscription in useOutstandingReports. If repro shows manual unreported expenses are also affected (not just card), that PR is the more likely culprit and should be checked first.
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.
🚨 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):
2. In IOURequestEditReportCommon, merge once and route the merged list through everyoutstandingReports reference — this is what keeps "Create report" working and is the fix the repro actually needs:
type Props = {
// ...
+ additionalOutstandingReports?: Array<OnyxEntry<Report>>;
};
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:
⚠️ 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):
// 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)
🚨 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:
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>, ...):
…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:
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.
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(...).
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:
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.
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.
In SearchTransactionsChangeReport, read currentSearchResults from useSearchResultsContext() and collect from currentSearchResults.data:
snapshot reports,
snapshot report name-value-pairs,
snapshot policies,
snapshot personal details.
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.
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.
Pass the snapshot candidates and metadata into IOURequestEditReportCommon with scoped props such as:
additionalOutstandingReports,
additionalPolicies,
additionalPersonalDetails,
shouldShowCreateReportOption.
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.
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.
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.
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.
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)returnObject.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);
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)constownerFromSelection=selectedTransactionsKeys.map((k)=>selectedTransactions[k]?.ownerAccountID).find((ownerID)=>typeofownerID==='number');if(ownerFromSelection!==undefined){returnownerFromSelection;}// …then tries a reported report's owner; for all-unreported it returns undefined
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 ownerconstownerAccountID=isUnreported
? iouActions?.find((a)=>getOriginalMessage(a)?.IOUTransactionID===transaction.transactionID)?.actorAccountID
: selectedReport?.ownerAccountID;
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),
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.
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:
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:
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):
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.
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.
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.
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
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.
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.
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.
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
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.
Activity
MelvinBot commentedon Jun 3, 2026
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 wherereport.ownerAccountID === reportOwnerAccountID(ReportUtils.ts:11584). That owner ID flows from the selected transactions, where each transaction's owner is set toreportAction.actorAccountIDwith no fallback (Search/index.tsx:169):For an unreported credit card transaction, that action's
actorAccountIDis not the viewing user's accountID, so the resolved owner owns none of the user's outstanding reports →getOutstandingReportsForUserreturns[]→ 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.Verified data flow (file:line evidence)
Search/index.tsx:169sets each selected transaction'sownerAccountID = 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).SearchTransactionsChangeReport.tsx:77-98derivestargetOwnerAccountIDfrom the first numericownerAccountIDin the selection, then passes it to the picker.IOURequestEditReportCommon.tsx:82-92keepstargetOwnerAccountIDasresolvedReportOwnerAccountID(only falls back to current user when it'sundefined), then callsuseOutstandingReports(...).getOutstandingReportsForUser(ReportUtils.ts:11584) filters onreport.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
actorAccountIDblindly:Search/index.tsx:169(and the mirrored writes at L866/L929), mirror theSearchUIUtilsfallback — e.g.item.reportAction?.actorAccountID ?? item.report?.ownerAccountID— and for unreported items resolve to the current user's accountID.SearchTransactionsChangeReport.tsx:77-98, whenareAllTransactionsUnreportedis true, resolvetargetOwnerAccountIDto 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)
useOutstandingReports. If repro shows manual unreported expenses are also affected (not just card), that PR is the more likely culprit and should be checked first.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.actorAccountIDdiffers from the current user's accountID and thatgetOutstandingReportsForUserreturns[]. 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 thisto create a draft PR.To refine:
@MelvinBot <your feedback>To reject: Explain why you are rejecting Melvin's proposal.
mukhrr commentedon Jun 3, 2026
🚨 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), butSearchTransactionsChangeReporthands off toIOURequestEditReportCommon, whose destination picker reads reports only from live Onyx viauseOutstandingReports(...). 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 letsONYXKEYS.DERIVED.OUTSTANDING_REPORTS_BY_POLICY_IDinclude it. So this isn't an owner-resolution orcanAddTransactionproblem (those already pass once the report is live) — it's candidate availability.App/src/pages/Search/SearchTransactionsChangeReport.tsx
Line 227 in 8d37315
useOutstandingReports(...):App/src/pages/iou/request/step/IOURequestEditReportCommon.tsx
Line 128 in 8d37315
App/src/hooks/useOutstandingReports.ts
Line 41 in 8d37315
COLLECTION.REPORT:App/src/libs/actions/OnyxDerived/configs/outstandingReportsByPolicyID.ts
Line 9 in 8d37315
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):(add
useSearchResultsContext,getOutstandingReportsForUser,OnyxEntry,Reportto 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} />2. In
IOURequestEditReportCommon, merge once and route the merged list through everyoutstandingReportsreference — this is what keeps "Create report" working and is the fix the repro actually needs:type Props = { // ... + additionalOutstandingReports?: Array<OnyxEntry<Report>>; };Replace
outstandingReportswithmergedOutstandingReportsat all of: thereportOptionsearly-return + body + deps (L131/L135/L172),shouldShowNotFoundPage(L276 + its deps), andshouldShowTextInput(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, }; })3. Fix the avatar via the fallback the avatar component already exposes — no Onyx write.
ReportActionAvatarsresolvesreport = reportFromOnyx ?? reportPropandpolicy = policyFromOnyx ?? policyProp(L122‑L125);InviteMemberListItemjust doesn't forward them today (L91):<ReportActionAvatars reportID={item.reportID} + report={item.report} + policy={item.policy} accountIDs={accountID ? [accountID] : undefined} />dedupeByReportIDis a tiny pure helper (live entries kept first), so it's unit-testable in isolation.Regression control
useOutstandingReports, or owner/policy/eligibility filters — snapshot candidates pass the samegetOutstandingReportsForUser+canAddTransaction/admin‑manager checks.IOURequestStepReport) pass noadditionalOutstandingReports→ behavior byte-for-byte identical.customListHeaderContent/listEmptyContentpaths, now driven by the true merged count.What alternative solutions did you explore? (Optional)
n/a
David-2002065 commentedon Jun 3, 2026
🚨 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 intoIOURequestEditReportCommon. The list of pickable reports comes fromuseOutstandingReports, which filters reports by their ownerAccountID. That filter resolves toresolvedReportOwnerAccountIDhere:https://github.com/Expensify/App/blob/main/src/pages/iou/request/step/IOURequestEditReportCommon.tsx#L82-L92
For an all-unreported selection from Search:
selectedReportIDis forced toundefined(line https://github.com/Expensify/App/blob/main/src/pages/Search/SearchTransactionsChangeReport.tsx#L66-L69), soselectedReportis undefined.targetOwnerAccountIDis calculated here: https://github.com/Expensify/App/blob/main/src/pages/Search/SearchTransactionsChangeReport.tsx#L77-L98, and the very first attempt is to pick "owner metadata attached to each selection":But
selectedTransactions[key].ownerAccountIDis populated in the Search transaction list withtransactionItem.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
reportActionthat backs the search row is the card-feed action that imported the expense — andactorAccountIDon 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, soownerFromSelectionreturns it andtargetOwnerAccountIDbecomes "not the current user".The fallback to
currentUserPersonalDetails.accountIDinIOURequestEditReportCommonis never reached becausetargetOwnerAccountID !== undefined.useOutstandingReportsthen filters viagetOutstandingReportsForUser(..., 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 thecreateReportOptionheader.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 (theisUnreportedprop is already plumbed in fromSearchTransactionsChangeReportandIOURequestStepReport), 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. SoresolvedReportOwnerAccountIDshould short-circuit to the current user in that case, ignoring whateveractorAccountIDthe 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 theshouldUseAllPoliciesbranch (becauseselectedPolicyIDis undefined for the unreported case), iterate every non-personal policy and return the user's outstanding reports. They then pass thecanAddTransaction(report, undefined, true)filter inreportOptionsand render as picker rows.Both
IOURequestStepReportandSearchTransactionsChangeReportalready passisUnreportedcorrectly: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].ownerAccountIDfromreportAction?.actorAccountIDfor unreported rows insrc/components/Search/index.tsx. That would maketargetOwnerAccountIDlegitimatelyundefinedfor these rows and let the existing fallback inIOURequestEditReportCommonkick in. I dropped this because thatownerAccountIDvalue is also read byuseSearchBulkActionsfor 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 ofhasMultipleOwnersand 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
useOutstandingReportsa second time withcurrentUserAccountID, 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
targetOwnerAccountIDinsideSearchTransactionsChangeReport.tsxinstead. Equivalent in behavior, but the same bug also reachesIOURequestStepReportfor single unreported card transactions opened from the headerMove to report— patchingIOURequestEditReportCommonfixes both call sites in one place.KJ21-ENG commentedon Jun 3, 2026
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.
SearchTransactionsChangeReportis opened from Search, so the selected expenses and some related reports can exist incurrentSearchResults.databefore the same reports/policies/personal details are hydrated into the live Onyx collections.The current picker path is still live-Onyx first:
SearchTransactionsChangeReportrendersIOURequestEditReportCommonfor selected Search expenses.IOURequestEditReportCommongets destination reports fromuseOutstandingReports(...).useOutstandingReportsreadsONYXKEYS.DERIVED.OUTSTANDING_REPORTS_BY_POLICY_ID.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:
Create reportoption can disappear for real-data shapes where the selected Search expense is not treated asreportID: "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.reportIDintoReportActionAvatars. Snapshot-backed destination reports may not exist in liveCOLLECTION.REPORT, so the avatar component cannot resolve the report/policy and falls back to the wrong icon. The row must preserve and forward the snapshotreport/policydata. It should not force the explicitpolicyIDavatar 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, andcanAddTransaction()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.
In
SearchTransactionsChangeReport, readcurrentSearchResultsfromuseSearchResultsContext()and collect fromcurrentSearchResults.data: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.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'sselectedPolicyID/transactionPolicyIDfallback so both existing destinations andCreate reportstay scoped to the transaction workspace instead of falling back to an unrelated active/default workspace.Pass the snapshot candidates and metadata into
IOURequestEditReportCommonwith scoped props such as:additionalOutstandingReports,additionalPolicies,additionalPersonalDetails,shouldShowCreateReportOption.In
IOURequestEditReportCommon, merge live and snapshot values locally:useOutstandingReports(...)withadditionalOutstandingReportsand dedupe byreportID,additionalPolicies,additionalPersonalDetails, with live values taking precedence.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.Preserve the snapshot
reportandpolicyon each generated picker item. InSearchTransactionsChangeReport.selectReport, useallReports[reportID] ?? item.reportwhen callingchangeTransactionsReport, because a snapshot-backed report can be selected before it exists in liveallReports.Forward
item.reportanditem.policyfromInviteMemberListItemtoReportActionAvatars. Do not pass an explicitpolicyIDfor these report rows; that triggers the workspace-primary avatar branch. Passing the report/policy letsReportActionAvatarsuse 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:
ONYXKEYS.DERIVED.OUTSTANDING_REPORTS_BY_POLICY_ID.COLLECTION.REPORT.getOutstandingReportsForUser().canAddTransaction()or report ownership checks.What alternative solutions did you explore? (Optional)
OUTSTANDING_REPORTS_BY_POLICY_IDto include Search snapshot data. I rejected this because the derived value is global live-Onyx state, while Search snapshots are query/screen scoped.COLLECTION.REPORT. I rejected this because it would make temporary Search snapshot data global and could create stale live state.policyIDto fix the avatar. I rejected this after testing becauseReportActionAvatarstreats explicitpolicyIDas the workspace-avatar path. The correct report row should passreport/policyso the cardholder remains the primary avatar and the workspace appears only as the subscript.yusufdeveloper2903 commentedon Jun 3, 2026
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 sharedIOURequestEditReportCommon. The list of existing reports it shows is built fromuseOutstandingReports, and that hook scopes the result to a single owner account:https://github.com/Expensify/App/blob/2d8b35ed632/src/libs/ReportUtils.ts#L11578-L11583
So the whole question is: what owner account does
reportOwnerAccountIDresolve to for an unreported card expense? Tracing it back:IOURequestEditReportCommonresolves the owner here, falling back to the current user when nothing better is available:https://github.com/Expensify/App/blob/2d8b35ed632/src/pages/iou/request/step/IOURequestEditReportCommon.tsx#L82-L92
targetOwnerAccountIDcomes fromSearchTransactionsChangeReport, which tries to read the owner off the selection metadata and otherwise returnsundefined:https://github.com/Expensify/App/blob/2d8b35ed632/src/pages/Search/SearchTransactionsChangeReport.tsx#L77-L98
The selection's
ownerAccountIDis populated only from the transaction's money-request action: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, soreportActionisundefined→ownerAccountIDisundefined→targetOwnerAccountIDisundefined→reportOwnerAccountIDfalls 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: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 —
getOutstandingReportsForUseris asked for reports owned by the wrong account. None match, soreportOptionsis empty andIOURequestEditReportCommonrenders only the "Create report" header (listEmptyContent={createReportOption}):https://github.com/Expensify/App/blob/2d8b35ed632/src/pages/iou/request/step/IOURequestEditReportCommon.tsx#L130-L172
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
ownerAccountIDis populated — recover the owner of an unreported transaction from its created/IOU action (resolved from Onyx) instead of relying on areportActionthat doesn't exist for unreported items:(applied at both selection-build sites — index.tsx#L866 and #L929).
Supporting work (described, not diffed):
getUnreportedExpenseOwnerAccountID(transaction)helper next togetReportOwnerAccountIDAsAttendeeinTransactionUtils, 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.ownerAccountIDon each selection,SearchTransactionsChangeReport's existingownerFromSelectionbranch (#L83-L86) automatically picks it up, sogetOutstandingReportsForUseris scoped to the correct owner and that owner's outstanding reports appear.Edge cases:
hasMultipleOwnersinuseSearchBulkActions, #L1629-L1641), so a single resolved owner is safe.What alternative solutions did you explore? (Optional)
report.ownerAccountID === reportOwnerAccountIDfilter 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.kamronbek04 commentedon Jun 3, 2026
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 fromuseOutstandingReportsare run through a final eligibility filter, and the gate that decides whether a report survives iscanAddTransaction(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
This is where credit-card expenses specifically fall over.
hasOnlyNonReimbursableTransactionsreturns 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,
hasOnlyNonReimbursableTransactionsis true, andcanAddTransactionreturns false — for the move flow too, because that first branch is not gated byisMovingTransaction. Every existing report gets filtered out ofreportOptions, the list renders empty, and the only thing left is the "Create report" header. TheisReportPolicyAdmin || isReportManagerfallback 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
isMovingTransactionparameter (added 2025-11-21, commit7ed380d6e794). 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 thehasOnlyNonReimbursableTransactionsbranch, leaving card reports blocked.What changes do you think we should make in order to solve the problem?
Hoist the
!isMovingTransactionguard 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
isMovingTransactionis false,!isMovingTransactionis true, so the block still evaluateshasOnlyNonReimbursableTransactions(...) || (!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.
isMovingTransactionis only evertrueat a single call site — the move-to-report destination filter atIOURequestEditReportCommon.tsx#L150. Every othercanAddTransactioncaller (the add-expense button, secondary actions,isReportOutstanding, duplicate handling) uses the defaultfalse, so none of them see any change. Scope boundary: this does not touchuseOutstandingReports,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-sidechangeTransactionsReport/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
getOutstandingReportsForUseroruseOutstandingReports. 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 thecanAddTransactioneligibility filter. Changing the outstanding-reports source would mask the real gate and risk surfacing genuinely ineligible reports (the exact problem that got a prioruseOutstandingReportsfilter change reverted as a deploy blocker).I also considered special-casing the list filter in
IOURequestEditReportCommonto skipcanAddTransactionfor card transactions. That spreads card-awareness into the UI layer and duplicates eligibility logic that already lives incanAddTransaction, where theisMovingTransactionflag was purpose-built to express exactly this distinction. Fixing the flag's coverage in one place keeps the eligibility rule centralized.melvin-bot commentedon Jun 3, 2026
Triggered auto assignment to Contributor-plus team member for initial proposal review - @Krishna2323 (
External)moxw commentedon Jun 3, 2026
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?.actorAccountIDThat 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.tsxpasses that missing/incorrecttargetOwnerAccountIDintoIOURequestEditReportCommon. The shared picker callsuseOutstandingReports(...), andgetOutstandingReportsForUser()only returns reports where:report.ownerAccountID === ownerAccountIDSo 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 reportoption, 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.
mapTransactionItemToSelectedEntry()insrc/components/Search/index.tsx, derive an effective action and owner more defensively:item.reportActionitem.linkedTrackedExpenseReportActionfor unreported/tracked card transactionsitem.report?.ownerAccountIDfor reported expensesConceptually:
SearchTransactionsChangeReport, derivetargetOwnerAccountIDand a commontransactionPolicyIDfrom the selected transaction metadata before falling back to report data.For all-unreported selections, there is no selected report, so the page should use:
ownerAccountIDpolicyIDThen pass that policy into both
usePolicyForMovingExpenses(...)andIOURequestEditReportCommon/useOutstandingReportsviaselectedPolicyIDortransactionPolicyID.That keeps the report picker scoped to the cardholder and the transaction workspace, so existing open/submitted reports owned by that cardholder are included.
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.report, but with linked/unreported action owner metadataSearchTransactionsChangeReportCreate reportchangeTransactionsReport()with that report and policyAlso 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 ignoreownerAccountIDfor 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.[-]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[/+]75 remaining items
karan-1302 commentedon Jun 26, 2026
@mallenexpensify I’ve accepted the job on upwork. Also, I’m not getting automatic job offers. Thanks.
mallenexpensify commentedon Jun 27, 2026
@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
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
karan-1302 commentedon Jun 30, 2026
Contributor details
Your Expensify account email: karangupta95797@gmail.com
Upwork Profile Link: https://www.upwork.com/freelancers/~01a2f461a042db1768
melvin-bot commentedon Jun 30, 2026
✅ Contributor details stored successfully. Thank you for contributing to Expensify!
karan-1302 commentedon Jun 30, 2026
@mallenexpensify done!
mallenexpensify commentedon Jul 1, 2026
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.
laurenreidexpensify commentedon Jul 6, 2026
$250 approved for @Krishna2323