Repository navigation
[$250] IOU create action gets wrong actorAccountID when a non-owner opens a report before the owner, making auto-reported card expenses read-only for the owner #92769
Description
Coming from here.
Problem
For an auto-reported company-card expense on an OPEN expense report, the expense can become read-only for the report owner (the card holder) — no fields are editable and tags (e.g. Projects) aren't offered for editing — even though sibling transactions on the same card/feed/report are editable. The expense is correctly editable in Classic.
Root cause
NewDot gates money-request editing on isRequestor (currentUserAccountID === reportAction.actorAccountID) in canEditMoneyRequest (ReportUtils.ts#L4737-L4773). Once that returns false, canEditFieldOfMoneyRequest (ReportUtils.ts#L4941-L4948) returns false for every field, so the whole expense is read-only. (Admins/managers short-circuit to true, which is why an admin can still edit it.)
So the bug hinges on the IOU create action's actorAccountID being stamped as the wrong user.
For old transactions without a transaction thread, openReport materializes the money-request preview action and derives the actor from the parent report owner, falling back to the current user:
const parentReport =
transactionParentReportID === optimisticSelfDMReport?.reportID
? optimisticSelfDMReport
: allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionParentReportID}`];
const submitterAccountID = parentReport?.ownerAccountID ?? currentUserAccountID ?? CONST.DEFAULT_NUMBER_ID;src/libs/actions/Report/index.ts
When a non-owner opens the report before the owner does, allReports may not yet have the parent report loaded client-side, so parentReport resolves to undefined. submitterAccountID then falls back to currentUserAccountID (the non-owner who opened it), and optimisticIOUAction.actorAccountID is stamped with that account. Because this optimistic action is persisted to the server (via parameters.moneyRequestPreviewReportActionID), the wrong actor becomes canonical — permanently making the expense read-only for the actual owner.
Repro (conceptual)
- Auto-report a company-card transaction (old/legacy, no transaction thread) to user A's (card holder/report owner) OPEN expense report.
- Have a different user B (e.g. an admin/copilot) open that report before user A, while the parent report isn't yet in B's local Onyx cache.
- User A can no longer edit that expense (no editable fields, no Projects tag); sibling transactions remain editable.
Expected
The IOU create action's actorAccountID should always be the card holder / report owner, regardless of who opens the report first.
Suggested fix direction
The intended logic (use parentReport.ownerAccountID) is already wired up but silently falls back to currentUserAccountID when the parent report isn't in the local cache. Options:
- Ensure the parent report is fetched/available before running the legacy-transaction fallback, or
- Resolve the owner authoritatively (server-side) instead of relying on client-side Onyx state.
Evidence
Diagnosed from production logs for a real report where two identical company-card transactions (same card, same feed, same OPEN report) differed only in the IOU create action's actorAccountID: the editable one was attributed to the report owner, the non-editable one to another user who had opened the report first.
Upwork Automation - Do Not Edit
- Upwork Job URL: https://www.upwork.com/jobs/~022062876005514563258
- Upwork Job ID: 2062876005514563258
- Last Price Increase: 2026-06-12
Metadata
Metadata
Labels
Type
Projects
- StatusShow more project fieldsDone
Activity
MelvinBot commentedon Jun 5, 2026
Issue Analysis
Root Cause: The expense goes read-only for the owner because the IOU
createaction'sactorAccountIDis stamped as a non-owner, so the owner fails theisRequestorcheck that gates all editing. The wrong attribution is created when a non-owner opens the report first — but the part that makes it permanent/canonical is backend-side, because the App never transmits the actor to the server (only the action ID). A pure-App change cannot fix the persisted data.Recommended Fix: This requires a backend change (outside the
Expensify/Apprepo) and should be escalated to an internal engineer. TheOpenReportbackend command that materializes the missing money-request preview action for a legacy transaction must attribute the IOUcreateaction'sactorAccountIDto the expense report owner / card holder, not to the user who happened to callOpenReportfirst. No App code fix is recommended (see "Why a client-only fix won't work" below).Evidence chain (verified in App code)
All permalinks at App
de5724c.1. Read-only symptom is driven by
isRequestor.canEditMoneyRequestreturnsfalsefor a non-admin/non-manager whenisRequestoris false, andisRequestoriscurrentUserAccountID === reportAction.actorAccountID:src/libs/ReportUtils.ts:4737(isRequestor)src/libs/ReportUtils.ts:4759-4773— admins/managers short-circuit totrue; everyone else needsisRequestor. (This is why an admin can edit but the cardholder/owner can't.)Once
canEditMoneyRequestisfalse, every field is locked becausecanEditFieldOfMoneyRequestreturns early:src/libs/ReportUtils.ts:4941-4943This matches the symptoms exactly: no editable fields, no Projects tag, while siblings with the correct actor stay editable, and Classic is unaffected.
2. The wrong actor originates in the legacy-transaction fallback in
openReport.For old transactions with no transaction thread (
transaction && !parentReportActionID), the App materializes the preview action and derives the actor from the parent report owner, falling back to the current user when the parent report isn't in local Onyx:src/libs/actions/Report/index.ts:1615-1617—parentReport?.ownerAccountID ?? currentUserAccountID ?? …src/libs/actions/Report/index.ts:1637—optimisticIOUAction.actorAccountID = submitterAccountIDWhen a non-owner opens first and the parent report isn't cached,
submitterAccountIDfalls back to the non-owner → optimistic actor = non-owner.Why a client-only fix won't work (the key refinement to the hypothesis)
The original write-up says the optimistic wrong actor "is persisted to the server … the wrong actor becomes canonical." The App code shows the actor itself is never sent —
OpenReportonly transmits the action ID:src/libs/API/parameters/OpenReportParams.ts:3-31— hasmoneyRequestPreviewReportActionID, noactorAccountID.src/libs/actions/Report/index.ts:1689— onlyparameters.moneyRequestPreviewReportActionID = iouReportActionID.So the backend independently decides the actor when it creates the canonical preview action. The production evidence (canonical actor == the user who opened first) indicates the backend attributes it to the
OpenReportcaller rather than the report owner. Consequences:?? currentUserAccountIDfallback is optimistic-only, and is actually correct when the owner opens first (current user == owner). It does not transmit, and cannot fix, the persisted attribution.(Confidence note: the backend attribution is a strong inference from the App code + the issue's production logs; it is not code-verified here because backend repos are internal.)
Possibly related: #92519 — "Admin unable to add unreported credit card transactions to a cardholder's existing report … without opening the report first." Same area (admins, card transactions, report-open ordering); worth checking whether it shares this root cause.
Next Steps for Contributor+ team:
To act: This is a backend/internal fix — please escalate to an internal engineer with access to the
OpenReportcommand to correct theactorAccountIDattribution for materialized legacy preview actions.To refine:
@MelvinBot <your feedback>To reject: Explain why you are rejecting this analysis.
David-2002065 commentedon Jun 5, 2026
Proposal
What is the root cause of that problem?
The legacy-transaction fallback in
openReportstampsoptimisticIOUAction.actorAccountIDfromparentReport?.ownerAccountID ?? currentUserAccountID, and the??reaches the second arm whenever the parent report isn't already in the client'sallReportscollection. For a non-owner opening the report first, that resolves to the non-owner's accountID, and because the optimistic action ID is sent to the server viaparameters.moneyRequestPreviewReportActionIDand the response doesn't overwrite the client's optimisticactorAccountID, the wrong actor becomes the canonical attribution. Downstream,canEditMoneyRequestgates oncurrentUserAccountID === reportAction.actorAccountID, so the actual owner sees the expense as read-only.The offending block, line by line:
https://github.com/Expensify/App/blob/main/src/libs/actions/Report/index.ts#L1604-L1697Two reasons the
parentReportlookup misses for a non-owner who arrives first:openReportis the API call that fetches the report. At the moment optimistic data is being assembled,allReports[reportID]typically isn't populated yet for a first-visit. For a card-fed legacy expense report, the owner reaches it throughlastVisitedPathor LHN entry and theirOpenApppayload usually includes the report; a non-owner (admin, copilot, Slack-linked viewer) often arrives without it pre-loaded.parentReportresolution has no async retry. TheallReports[...]read is synchronous against the module-level snapshot atsrc/libs/actions/Report/index.ts:465-470. If the value isn't there, the fallback chainparentReport?.ownerAccountID ?? currentUserAccountID ?? CONST.DEFAULT_NUMBER_IDresolves to the second arm. There's no "wait for the report to be loaded, then re-derive the owner" — theactorAccountIDis baked into the optimistic action at the momentopenReportruns.Then
parameters.moneyRequestPreviewReportActionID = iouReportActionIDships the client-generated action ID to the server. The server creates the canonical action under that ID. The Onyx state already has the wrongactorAccountIDattached to that ID, and thesuccessDatafor this branch doesn't include an override that resets it — so the optimistic value sticks. From there, the gates incanEditMoneyRequestandcanEditFieldOfMoneyRequestsee the wrong actor and lock the actual owner out:https://github.com/Expensify/App/blob/main/src/libs/ReportUtils.ts#L4737-L4773https://github.com/Expensify/App/blob/main/src/libs/ReportUtils.ts#L4941-L4948The asymmetry the issue calls out — sibling transactions on the same card/feed/report remain editable — is consistent with this: the editable siblings had their optimistic action built when the owner (or someone with the parent report cached) opened the report; only the row whose action was first materialized by the wrong user is broken.
A few important properties of this bug that drive the fix scope:
!parentReportActionIDbranch) — modern transactions carry their own thread/preview action and never enter this fallback.parentReportActionIDbecoming non-empty on subsequent opens).What changes do you think we should make in order to solve the problem?
Two tracks: a client-side fix that prevents new instances of the bug, and a backend remediation that repairs the existing wrong-actor rows.
1. Don't fabricate the IOU
createaction client-side when we don't have authoritative owner context. The optimistic action is a UX nicety that lets the row render with the right attribution before the server responds. If we don't know who the owner is, fabricating with the wrong actor is worse than not fabricating at all — the row briefly renders with no attribution, then the server's response fills it in correctly. Update the fallback:if (transaction && !parentReportActionID) { const transactionParentReportID = parentReportID ?? transaction?.reportID; - const iouReportActionID = rand64(); - - // Get the parent report to determine the actual submitter/owner of the expense - // Use optimisticSelfDMReport if provided (when selfDM exists but wasn't in allReports) - const parentReport = - transactionParentReportID === optimisticSelfDMReport?.reportID - ? optimisticSelfDMReport - : allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionParentReportID}`]; - const submitterAccountID = parentReport?.ownerAccountID ?? currentUserAccountID ?? CONST.DEFAULT_NUMBER_ID; + // Resolve the parent report so we can stamp the correct submitter. If we can't, + // skip the optimistic action entirely — the server will create it with the correct actor + // via moneyRequestPreviewReportActionID and the UI will refresh on the success response. + // Without this guard, the actorAccountID falls back to currentUserAccountID and gets + // persisted, locking the actual owner out of editing the expense. + const parentReport = + transactionParentReportID === optimisticSelfDMReport?.reportID + ? optimisticSelfDMReport + : allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionParentReportID}`]; + + if (!parentReport?.ownerAccountID) { + Log.alert('[Report] Skipping legacy IOU optimistic action: parent report owner unknown', { + reportID, + transactionParentReportID, + transactionID: transaction.transactionID, + currentUserAccountID, + }); + // Still send the API call without moneyRequestPreviewReportActionID — the server + // owns this case end-to-end when the client can't derive the owner. + } else { + const iouReportActionID = rand64(); + const submitterAccountID = parentReport.ownerAccountID; + ... + parameters.moneyRequestPreviewReportActionID = iouReportActionID; + } ... }The
Log.alertbreadcrumb is important — it tracks how often this path is taken and gives the team a measurable signal that the backend-only fallback is being exercised, which de-risks the conservative branch.2. Add a defensive recheck in
canEditMoneyRequestfor legacy card-fed transactions. Even after (1), there are reports out there that already have the wrongactorAccountIDbaked in. The cheap, scoped repair: when the gate would returnfalsebecauseactorAccountID !== currentUserAccountID, check whether the report'sownerAccountID === currentUserAccountIDand whether the transaction is card-fed. If yes, treat the current user as the requestor for editing purposes. This is a narrow carve-out that only fires for the exact damaged shape the bug produces:function canEditMoneyRequest(reportAction: OnyxInputOrEntry<ReportAction>, transaction?: OnyxInputOrEntry<Transaction>): boolean { const isDeleted = isDeletedAction(reportAction); ... const isRequestor = currentUserAccountID === reportAction?.actorAccountID; + // Legacy-IOU repair: if the canonical actorAccountID was stamped wrong by a non-owner + // opening the report first (https://github.com/Expensify/App/issues/92769), fall back + // to the report's ownerAccountID. Scoped to card-fed transactions to avoid relaxing + // edit rules elsewhere. + const moneyRequestReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${(reportAction as ReportAction).reportID}`]; + const isOwnerOnCardTransaction = + !!moneyRequestReport?.ownerAccountID && + moneyRequestReport.ownerAccountID === currentUserAccountID && + (transaction?.managedCard === true || !!transaction?.cardID); + const effectiveIsRequestor = isRequestor || isOwnerOnCardTransaction; ... - if (!isRequestor) { + if (!effectiveIsRequestor) { return isAdmin || isManager; } ... }The scope (
managedCard === true || !!transaction?.cardID) keeps this from affecting non-card flows whereactorAccountIDis the source of truth. The check is also short-circuited by the existing admin/manager branches above, so admins continue to follow their own path.3. Server-side repair pass. The wrong-actor rows already in production won't fix themselves through (1) or (2) — they'll just stop accumulating new ones. An
Authengineer should sweep the IOUcreateactions whereactorAccountID !== iouReport.ownerAccountIDfor card-fed transactions and overwrite them to the report owner. (2) buys time for that repair to roll out without leaving customers locked out in the meantime.4. Add a regression test pinning the invariant. A Jest test that calls
openReportwithtransactionset,parentReportActionIDempty, andallReportsnot containing the parent report. Assert that either nomoneyRequestPreviewReportActionIDis sent OR the resultingoptimisticIOUAction.actorAccountID === parentReport.ownerAccountID. Either outcome satisfies the invariant the issue is calling out; whichever the team picks for (1) is the one the test checks.I'd land (1) and (2) together as the user-facing fix. (3) is the durable repair the backend team owns. (4) goes with whichever of (1) lands.
What alternative solutions did you explore? (Optional)
I considered fetching the parent report inline when it's missing —
await Report.fetchReport(transactionParentReportID)before computingsubmitterAccountID. That works but turnsopenReportinto a multi-step async flow (it currently builds optimistic data and dispatches the API call synchronously), which would force a refactor of every caller that expectsopenReportto be fire-and-forget. The conservative branch in (1) — skip the optimistic action and let the server handle it — is the smallest behavioral change with the same correctness.I also looked at deriving the submitter from the transaction itself for card-fed cases (
cardID → cardList[cardID].cardholderAccountID). That's correct in practice and avoids the parent-report dependency entirely, but couples the legacy-IOU-creation path to the card-list cache being loaded, which has its own pre-load timing. The check in (2) — "if the gate would deny but the user owns the report and the transaction is card-fed, allow" — captures the same intent without taking on the new dependency, and it composes cleanly with (1) for the going-forward case and with the server-side repair for the historical case.KJ21-ENG commentedon Jun 5, 2026
Proposal
Please re-state the problem that we are trying to solve in this issue.
For legacy auto-reported company-card expenses that do not already have a transaction thread / money request preview, the first person who opens the expense report can cause the generated IOU
createaction to be attributed to the wrong account. If that first opener is not the report owner / card holder, the real owner later fails theisRequestorcheck and the expense becomes read-only in NewDot.What is the root cause of that problem?
The legacy transaction fallback in
openReport()builds and persists a missing money-request preview action. It tries to set the action actor from the parent expense report owner:The problem is the fallback to
currentUserAccountID. In several entry points,createTransactionThreadReport()already receives the expense report from the route/search/list snapshot, butopenReport()then re-reads the parent report fromallReports. If that collection does not contain the parent report yet,parentReportisundefined, so the preview action is stamped with the opener's account instead of the expense report owner's account.That action ID is sent as
moneyRequestPreviewReportActionIDin theOpenReportrequest, and the backend also materializes the preview action for this legacy path. Once the wrongactorAccountIDis saved,canEditMoneyRequest()treats the actual owner as not being the requestor, andcanEditFieldOfMoneyRequest()returns false for every field.What changes do you think we should make in order to solve the problem?
Remove the viewer-account fallback from this legacy preview path and make the submitter/actor source authoritative.
On the App side, pass the known parent expense report (or at least its
ownerAccountID) fromcreateTransactionThreadReport()intoopenReport()when creating a transaction thread from aniouReport. Then, in the legacy transaction fallback, resolvesubmitterAccountIDfrom:optimisticSelfDMReportfor self-DM/unreported cases,allReports[REPORT + transactionParentReportID]?.ownerAccountID.If none of those sources provide an owner, do not fall back to
currentUserAccountIDfor a reported expense. Instead, skip client creation of the money-request preview for that open, or fetch/open with enough parent report data first, so we never generate a persisted IOU action with the wrong actor.On the API/backend side, make
OpenReportcreate the missing money-request preview actor from the transaction's parent report owner/card holder, not from the authenticated opener. The client can still providemoneyRequestPreviewReportActionIDas the idempotency/action ID, but the server should deriveactorAccountIDfrom the transaction/report relationship before saving the action. This prevents stale or missing client Onyx state from making the wrong actor canonical.What alternative solutions did you explore? (Optional)
Changing
canEditMoneyRequest()to also allow the report owner when the IOU action actor is wrong would make the affected expense editable, but it would leave corrupted report-action attribution in place and could mask other permission bugs. The safer fix is to create the preview action with the correct actor and avoid persisting the current viewer as a substitute owner.moxw commentedon Jun 5, 2026
Proposal
What is the root cause of that problem?
For legacy transactions with no transaction thread / money request preview,
openReport()creates the missing preview action insrc/libs/actions/Report/index.ts.In that fallback, the submitter is derived from the parent report:
This is unsafe because
createTransactionThreadReport()often already has the correctiouReport, but it only merges it into Onyx and then immediately callsopenReport(). The module-levelallReportsvalue used byopenReport()can still be stale/missing at that moment.When a non-owner opens the legacy card expense first,
parentReportcan beundefined, so the preview IOU action is created with the opener’scurrentUserAccountIDasactorAccountID. That wrong preview action ID is also sent asmoneyRequestPreviewReportActionID, so the server can create the canonical preview with the wrong actor.Later,
canEditMoneyRequest()insrc/libs/ReportUtils.tschecks:For the real report owner/card holder this is now false, and
canEditFieldOfMoneyRequest()returns false for every field. That is why the owner sees the expense as read-only while admins/managers can still edit through the separate admin/manager bypass.This is intermittent because it depends on who opens the old transaction first and whether the parent expense report has already hydrated into
allReports.What changes do you think we should make in order to solve the problem?
Fix the legacy preview creation path so it never derives the IOU
createactor from the user who happens to open the report.Minimal fix:
openReport()params to accept the resolved parent money request report, e.g.parentReport?: Report.createTransactionThreadReport(), pass the already resolvedreportToUse/iouReportintoopenReport().openReport()legacy fallback, resolve the parent report in this order:optimisticSelfDMReportwhen applicable,reportIDmatchestransactionParentReportID,allReports[REPORT_${transactionParentReportID}].currentUserAccountIDas the actor for self-DM / tracked-expense cases where the current user is actually the submitter. For policy expense reports, requireparentReport.ownerAccountID; if it is missing, do not create/sendmoneyRequestPreviewReportActionIDyet, and log the missing parent context instead of creating a poisoned preview action.That keeps the existing edit permission model intact and fixes the data corruption at the source.
Regression coverage:
Add a test beside the existing legacy preview fallback test in
tests/actions/ReportTest.tsthat simulates:R,createTransactionThreadReport()is called with aniouReportwhoseownerAccountIDis user A,allReportsdoes not yet containRat the momentopenReport()derives the preview actor.Assert that the created IOU action under
REPORT_ACTIONS_RhasactorAccountID === A, not B, and that theOpenReportAPI call still includes the generatedmoneyRequestPreviewReportActionID.Also keep/extend the self-DM legacy fallback test to confirm unreported/tracked expenses can still use the current user when there is no policy expense report owner.
What alternative solutions did you explore? (Optional)
Loosening
canEditMoneyRequest()to compare againstreport.ownerAccountIDwould make this specific owner editable again, but it leaves the wrong canonicalactorAccountIDin the preview action and risks changing permissions for unrelated IOU actions.Only changing the optimistic UI action is also insufficient because the same preview action ID is sent through
moneyRequestPreviewReportActionID; the server-side creation path must not be triggered with an opener-derived actor when the real parent owner is unknown.melvin-bot commentedon Jun 5, 2026
Triggered auto assignment to Contributor-plus team member for initial proposal review - @cretadn22 (
External)mukhrr commentedon Jun 5, 2026
Proposal
What is the root cause of that problem?
This is caused by the legacy transaction fallback added for old transactions that do not yet have a transaction thread or money-request preview.
That path runs only when
transaction && !parentReportActionID. It creates an optimistic IOU action, links it to the transaction thread, and passesmoneyRequestPreviewReportActionIDtoOpenReport, so the backend creates the missing preview action with that same ID.Permalinks:
App/src/libs/actions/Report/index.ts
Lines 1604 to 1689 in 632ff2f
OpenReportParamsincludesmoneyRequestPreviewReportActionID:App/src/libs/API/parameters/OpenReportParams.ts
Lines 23 to 27 in 632ff2f
Inside that fallback we try to derive the submitter from the parent report:
If the non-owner opens first and does not have
transactionParentReportIDloaded inallReports,parentReportisundefined, sosubmitterAccountIDbecomes the current viewer. Then we stamp that viewer onto the preview action:Editing later depends on that actor.
canEditMoneyRequestcomputesisRequestorfromreportAction.actorAccountID, andcanEditFieldOfMoneyRequestreturnsfalsebefore any field-specific checks whencanEditMoneyRequestis false.Permalinks:
isRequestorcheck:App/src/libs/ReportUtils.ts
Lines 4736 to 4760 in 632ff2f
App/src/libs/ReportUtils.ts
Lines 4908 to 4948 in 632ff2f
Similar cases do not have this exact risk:
from?.accountIDfrom the search result instead of the current viewer:App/src/libs/actions/Search.ts
Lines 1414 to 1432 in 632ff2f
iouReport.ownerAccountIDas submitter, not the viewer:App/src/libs/actions/Report/index.ts
Lines 6598 to 6604 in 632ff2f
buildOptimisticIOUReportActiondefaulting to the current user is correct there.Git history also points to this being introduced with the legacy transaction recovery work for issue 80180. The fallback and
moneyRequestPreviewReportActionIDwere added together, and issue 80180 explicitly says this code exists only to materialize old missing previews until legacy transactions are migrated.What changes do you think we should make in order to solve the problem?
We should stop using the current viewer as the fallback author for this legacy preview path.
The backend
OpenReporthandling formoneyRequestPreviewReportActionIDshould resolve the preview action actor from authoritative server data: the transaction’s parent report owner/card holder. That prevents whichever user opens first from determining ownership.On the client, keep the existing optimistic preview only when we can prove the owner from local data. If the parent report is missing, still allow the backend to create/return the preview, but do not stamp the viewer as the submitter.
Small client-side shape:
const parentReport = transactionParentReportID === optimisticSelfDMReport?.reportID ? optimisticSelfDMReport : allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionParentReportID}`]; -const submitterAccountID = parentReport?.ownerAccountID ?? currentUserAccountID ?? CONST.DEFAULT_NUMBER_ID; +const submitterAccountID = parentReport?.ownerAccountID; +const canBuildAuthoritativeOptimisticPreview = !!submitterAccountID;Then only override actor/person/avatar when
canBuildAuthoritativeOptimisticPreviewis true:To avoid regressions:
canEditMoneyRequest; the permission model is correct whenactorAccountIDis correct.parentReport.ownerAccountIDis available.OpenReportlegacy preview creation where the authenticated opener is not the report owner, verifying the created IOU action actor is the report owner.parentReportis missing, verifying we do not setactorAccountIDtocurrentUserAccountID.What alternative solutions did you explore? (Optional)
n/a
abbasifaizan70 commentedon Jun 5, 2026
Proposal
Please re-state the problem that we are trying to solve in this issue
An auto-reported company-card expense on an OPEN expense report can become permanently read-only for the report owner (card holder) in NewDot — no fields editable, no tags offered — while sibling transactions on the same card/feed/report remain editable. The same expense is correctly editable in Classic, and admins can still edit it.
What is the root cause of that problem?
NewDot gates money-request editing on
isRequestor(currentUserAccountID === reportAction.actorAccountID) incanEditMoneyRequestApp/src/libs/ReportUtils.ts
Lines 4676 to 4712 in 3d76089
Once that returns false,
canEditFieldOfMoneyRequestreturns false for every fieldApp/src/libs/ReportUtils.ts
Lines 4883 to 4951 in 3d76089
The wrong
actorAccountIDgets stamped by the legacy-transaction fallback inopenReportApp/src/libs/actions/Report/index.ts
Lines 1613 to 1701 in 3d76089
For old transactions without a transaction thread, it materializes the money-request preview action and derives the actor from the parent report owner, falling back to the current user
App/src/libs/actions/Report/index.ts
Lines 1619 to 1621 in 3d76089
There are two gaps:
createTransactionThreadReport(Report/index.ts#L2150) already holds the parent report (iouReport, e.g. from a search snapshot) and merges it into Onyx (Report/index.ts#L2188-L2193) — butOnyx.mergeis async andopenReportis called synchronously right after (Report/index.ts#L2204), so the module-levelallReportscache hasn't been updated yet. The owner information is available but silently lost.parameters.moneyRequestPreviewReportActionID(Report/index.ts#L1693) withactorAccountID = currentUserAccountID. If a non-owner (admin/copilot) opens the report first, the wrong actor becomes canonical — permanently making the expense read-only for the actual owner.Confirmed via a failing Jest test: a non-owner calling
createTransactionThreadReportfor a legacy transaction whose parent report is not yet in Onyx produces an IOU CREATE action with the non-owner'sactorAccountID(expected owner1, received2).What changes do you think we should make in order to solve the problem?
Two changes in
src/libs/actions/Report/index.ts:1. Thread the parent report through to
openReportcreateTransactionThreadReportalready has the parent report (iouReportfrom its caller / search snapshot) — pass it down instead of losing it. Add an optional param toOpenReportActionParams:In
createTransactionThreadReport'sopenReportcall:In the legacy fallback, resolve the submitter as
optimisticSelfDMReport→allReports→ the passedparentReport(when itsreportIDmatches):This fixes the common case (opening from search snapshot / fresh cache) so the actor is always the report owner.
2. Never persist an unverified actor
Gate the materialization of the preview action on having actually resolved the owner. If we can't, skip it (and skip
parameters.moneyRequestPreviewReportActionID) instead of falling back to the current user:The transaction (and violations) Onyx merges are kept unconditionally, so search-snapshot data still syncs. The selfDM/track-expense case is unaffected (selfDM reports carry
ownerAccountID= current user, which is correct there). Together: fix 1 makes the owner almost always resolvable; fix 2 guarantees that when it isn't, nothing wrong is persisted.Regression tests added to
tests/actions/ReportTest.ts:openReportdoes not persist a money request preview when the parent report owner cannot be resolved (no IOU action attributed to the opener; nomoneyRequestPreviewReportActionIDin the API call).openReportresolves the submitter from the newparentReportparam when the report isn't in Onyx (actor = owner; preview persisted).createTransactionThreadReportattributes the legacy preview to the report owner even when the parent report is not yet in Onyx (the exact production scenario).All three fail on
main(received actor = current user) and pass with the fix; the pre-existing legacy-fallback test still passes.What alternative solutions did you explore? (Optional)
OpenReport/GetReportfor the parent before materializing. Adds latency and complexity to a hot path for a rare race; passing the already-available report down achieves the same without extra requests.actorAccountIDfrom the parent report'sownerAccountIDfor IOU CREATE actions on company-card expenses). Out of scope for this PR but worth a follow-up.wildan-m commentedon Jun 5, 2026
🚫 Duplicated proposal withdrawn by 🤖 ProposalPolice.
21 remaining items
cretadn22 commentedon Jun 9, 2026
Before we review proposals, has anyone been able to reproduce this bug using a real account? we need to verify the root cause using actual data from an affected account to ensure the proposed fixes are accurate
David-2002065 commentedon Jun 9, 2026
@cretadn22 thanks for taking a look. Confirming the bug is verified from two independent angles — production data already in the issue body, plus a deterministic code path. Sharing the trace so it can be audited without a fresh repro account.
Production-data verification (already in the issue body): the reporter found two identical company-card transactions on the same card, same feed, same OPEN report, where the only field that differed on the IOU
createaction wasactorAccountID— the editable one was attributed to the report owner, the non-editable one to a different user who had opened the report first. Nothing else in the code stampsactorAccountIDon a legacy IOUcreateaction from non-server-derived state, so that asymmetry isolates the source to one path: the legacy fallback inopenReport.Deterministic code path (not a hard-to-hit race):
createTransactionThreadReportcallsOnyx.merge(...)on the parent report, then on the next line callsopenReport(...)without forwarding that parent.https://github.com/Expensify/App/blob/main/src/libs/actions/Report/index.ts#L2192-L2223
openReportthen reads the parent synchronously from the module-levelallReportssnapshot.https://github.com/Expensify/App/blob/main/src/libs/actions/Report/index.ts#L1604-L1697
That snapshot is only kept in sync via an asynchronous
Onyx.connect(...)callback.https://github.com/Expensify/App/blob/main/src/libs/actions/Report/index.ts#L465-L470
So the merge does not land in
allReportsbefore the read on the next line. For a non-owner opening a legacy card expense for the first time (LHN / search / copilot / direct link — anywhereOpenAppdidn't pre-load the parent),parentReport === undefinedis the deterministic outcome, the?? currentUserAccountIDresolves to the opener, andparameters.moneyRequestPreviewReportActionIDships that wrong-actor action ID to the server — where it becomes canonical and locks the owner viaisRequestorincanEditMoneyRequest.Precedent — the team has already fixed this exact class of bug, just for a different consumer. The
optimisticSelfDMReportparameter inopenReportexists only because this same stale-allReportsrace was hitting self-DM access. The fix in #88799 ("fix: recover stale DM access when opening profile message", issue #85793) was to pass the freshly-merged report explicitly so the legacy fallback resolves the owner from authoritative caller state instead of the async cache. EarlieractorAccountIDcorrections to the IOU preview path follow the same pattern: PR #51534 ("fix report preview optimistic actorAccountID") and commitbe7a4d1("set the correct actorAccountID of the report preview of expense report").This is the third time
?? currentUserAccountIDin this fallback has bitten — once for DMs, once for IOU preview attribution, and now for legacy company-card expense reports. The fix in my earlier proposal applies the same pattern that already shipped for DMs in #88799: thread the parent expense report (or itsownerAccountID) throughcreateTransactionThreadReport→openReport, and stop silently substituting the opener.I can add a Jest unit test that pins the invariant by mocking the stale-
allReportswindow — same shape as the regression test added with #88799 — if that helps lock the bar before we proceed.heroking777 commentedon Jun 9, 2026
Thank you for providing such detailed information about the bug and proposed solution. I'll review it step-by-step to ensure clarity and accuracy.
Root Cause Analysis
The root cause of the problem is that in the
openReportfunction's legacy fallback, theactorAccountIDfor an IOUcreateaction is incorrectly derived from either the parent report's owner or the current user if the parent report isn't available in the stale module-levelallReportscache. This can happen when a non-owner opens the report first before the owner does, leading to the wrong actor being persisted. Consequently, thecanEditMoneyRequestfunction locks out the actual card holder because it relies on theisRequestorcheck.Proposed Solution
The proposed solution is to add a check in the
ReportUtils.tsfile to treat the current user as the rightful editor if they own the report and the transaction is a managed (company) card expense, even if the persistedactorAccountIDis wrong. This would be done by modifying thecanEditMoneyRequestfunction as follows:Alternative Solutions Explored
The alternative solution explored involves modifying the
openReportfunction to pass the parent expense report or itsownerAccountIDexplicitly throughcreateTransactionThreadReport→openReport. This would prevent the fallback from using the current user's account ID when the parent report isn't available in the cache. The precedent for this approach is already established with similar fixes for DM access and IOU preview attribution.Verification
The bug has been verified using both production data and a deterministic code path. The production data shows two identical company-card transactions on the same card, same feed, same OPEN report, where the only difference in the IOU
createaction is theactorAccountID. The deterministic code path confirms that the issue arises from the stale-allReportsrace condition.Testing
A Jest unit test can be added to pin the invariant by mocking the stale-
allReportswindow. This would ensure that the fix is robust and prevents future regressions.Conclusion
The proposed solution is
abbasifaizan70 commentedon Jun 11, 2026
@cretadn22
Yes — verified at three levels:
Real affected data (production). The root cause was diagnosed from an actual affected report: two identical company-card transactions (same card, same feed, same OPEN report) differing only in the IOU CREATE action's actorAccountID — the editable one attributed to the report owner, the read-only one to another user who opened the report first. This is verifiable on any affected account: on a company-card expense, if the IOU CREATE action's actorAccountID ≠ the parent report's ownerAccountID, the expense is read-only for the owner, because canEditMoneyRequest gates editing on isRequestor. I'm happy to run this check against any affected report ID you can share.
The mechanism, reproduced and proven with tests. The corruption requires a race: a legacy transaction (no transaction thread) opened by a non-owner while the parent report is absent from their Onyx cache at the moment openReport's fallback runs — it then falls back to currentUserAccountID and persists it via moneyRequestPreviewReportActionID. That window can't be staged by hand on a live account (and succeeding would permanently corrupt the expense), so I wrote regression tests that stage exactly that state:Against current main: 3 tests fail — the materialized IOU action is stamped with the opener's accountID (Expected: 1, Received: 2). Full output: proof_before_fix.txt (attached).
Same tests with the proposed fix: all pass. Full output: proof_after_fix.txt (attached).
Anyone can verify: npx jest tests/actions/ReportTest.ts -t "legacy" with and without the fix applied.
3. The code path, verified on a real account (dev). Two-account workspace, signed in as the admin (22381781): simulating a legacy transaction (removing the IOU action while offline) triggers the fallback, which materializes a new optimistic IOU action whose actor determines the owner's edit access — exactly the path above. Screenshot attached (actor resolves to the owner 22250246 here because the parent report was cached; the bug is the cache-miss case the tests cover).
proof_after_fix.txt
proof_before_fix.txt
melvin-bot commentedon Jun 12, 2026
📣 It's been a week! Do we have any satisfactory proposals yet? Do we need to adjust the bounty for this issue? 💸
melvin-bot commentedon Jun 12, 2026
@mollfpr, @cretadn22 Whoops! This issue is 2 days overdue. Let's get this updated quick!
Tony-MK commentedon Jun 13, 2026
Proposal
Please re-state the problem that we are trying to solve in this issue.
For a legacy auto-reported company-card expense on an OPEN expense report, the expense becomes permanently read-only for the report owner (the card holder) in NewDot when a different user (admin, copilot, or manager) opens the report first. The IOU
createaction is stamped with the opener'sactorAccountIDinstead of the card holder's, causingcanEditMoneyRequestto returnfalsefor the real owner and locking every field.What is the root cause of that problem?
The editing gate —
canEditMoneyRequestinsrc/libs/ReportUtils.tsline 4717:For a non-admin, non-manager submitter on an OPEN expense report the final return path (line 4753) is:
When
isRequestorisfalsebecauseactorAccountIDwas stamped with a different user, every field is locked for the real card holder.The wrong stamp — legacy fallback in
src/libs/actions/Report/index.tslines 1620-1622:allReportsis a module-levelOnyx.connectsnapshot updated asynchronously. WhencreateTransactionThreadReport(line 2193) callsOnyx.merge(…, iouReport)and immediately callsopenReporton line 2205, the merge has not yet propagated. For a non-owner whoseOpenApppayload didn't pre-load the parent report,parentReportisundefined— sosubmitterAccountIDresolves tocurrentUserAccountID(the opener). That ID is stamped on the optimistic action and sent to the backend viaparameters.moneyRequestPreviewReportActionID. The badactorAccountIDis now a permanent server-side record.What changes do you think we should make in order to solve the problem?
Two-part fix. The key difference from @dilshodmackbook-sketch is in Part 1.
Part 1 — Thread the owner from the caller (no blank state)
The
openReportfunction cannot reliably resolveparentReport?.ownerAccountIDbecauseallReportsis stale at call time. But the caller already has the answer:createTransactionThreadReportholdsiouReportin scope (it just created it on line 2193). Pass the owner explicitly rather than re-deriving it from the stale cache.Why this is better than skipping the optimistic action: Dilshodmackbook-sketch's guard skips the entire optimistic preview when the owner is unknown — the preview row is blank for the non-owner opener until the server responds. Threading from the caller avoids that blank state: the optimistic preview renders immediately with the correct
actorAccountID, indistinguishable from the happy path.src/libs/actions/Report/index.ts— add toOpenReportActionParamstype (~line 291):In
createTransactionThreadReport(~line 2205), passiouReport?.ownerAccountID:openReport({ reportID: optimisticTransactionThreadReportID, // ...existing fields... + parentReportOwnerAccountID: iouReport?.ownerAccountID, });In
openReport's legacy fallback (~line 1620-1622), prefer the explicitly-passed value and remove the?? currentUserAccountIDfallback for reported expenses:CONST.DEFAULT_NUMBER_IDis a safe sentinel for cases where neither the explicit param nor the cache has the owner — the server creates its own canonical version. Removing?? currentUserAccountIDensures we never silently stamp the opener's ID.Part 2 — Heal already-corrupted records (permission-gate fix)
Records already persisted with the wrong
actorAccountIDremain locked for the real card holder regardless of Part 1. IncanEditMoneyRequest(src/libs/ReportUtils.ts), add a guard after the existing admin/manager bypass and before the finalisRequestorreturn:isManagedCardTransaction(TransactionUtils/index.ts:1313) checks!!transaction?.managedCard.isCurrentUserSubmitter(ReportUtils.ts:1598) checksreport.ownerAccountID === deprecatedCurrentUserAccountID. Both use authoritative server-provided fields that cannot be poisoned by the race.How does this differ from existing proposals?
openReport: skip optimistic action if owner unknowniouReport.ownerAccountIDfrom caller toopenReportallReportscache (then skipped if missing)iouReportin caller scope — always presentparentReportOwnerAccountID?: numberonOpenReportActionParamsBoth proposals share Part 2 (the
canEditMoneyRequestpermission-gate carve-out for card-fed transactions). The difference is purely in Part 1: skip-if-unknown (blank state) vs. thread-from-caller (no blank state).github-actions commentedon Jun 13, 2026
mollfpr commentedon Jun 15, 2026
Auth PR is deployed, and CQ is run. Waiting for confirmation on the user.
mollfpr commentedon Jun 15, 2026
Confirmed issue is fixed!