Repository navigation
[Due for payment 2026-10-05] [$250] [Exploratory] Expense-Error is shown when vacation delegate clicks "Download as PDF" on rejected expense #94728
Description
Activity
While Applause doesn't embed attachments for this bug, here are the links for quick view:
https://platform.applause.com/services/links/v1/external/fcbbd9414f6582236d41aeddb4bce6bf0cdbaabb82c8b78b5a68f72dd41e258e- addedBugSomething is broken. Auto assigns a BugZero manager.Something is broken. Auto assigns a BugZero manager.
on Jul 17, 2026 Issue Analysis
Root Cause: This is a backend failure, not a client bug. "Download as PDF" fires the
ExportReportToPDFAPI command with only{reportID}— the client passes no delegate identity. When a vacation delegate connects, the client swaps its whole session to the delegator's restrictedToken and makes every call (including this one) under that borrowed token. After a "reject to submitter", the report'smanagerIDis reassigned to the submitter and the report is reopened, so the delegator whose token is in use is no longer the report's manager/approver.ExportReportToPDFis then rejected/failed on the backend, and the client surfaces that as "There was an error when trying to generate your PDF".The fix is outside the App repo. The client behavior is correct/passive — it sends the request under the active (delegator) token and renders whatever the backend returns. Resolving this requires a backend change to
ExportReportToPDFauthorization (inAuth/Web-Expensify), so this should be escalated to an internal engineer with access to those repos.Investigation details & evidence
Client flow (all in App, no bug):
- Trigger:
useExportActions.ts:230-245—DOWNLOAD_PDFonSelectedopens the modal and callsexportReportToPDF({reportID}). - API command
ExportReportToPDFdispatched atReport/index.ts:6272-6293. It only sets the filename NVP tonulloptimistically and to'error'on failure — nosuccessData. The real filename is written intonvp_expensify_report_PDFFilename_{reportID}by the backend asynchronously. - The modal
ReportPDFDownloadModal.tsx:56-121subscribes to that NVP:null→ spinner,'error'→ the error string (en.ts:3124), a filename → auto-download. DOWNLOAD_PDFis offered unconditionally (ReportSecondaryActionUtils.ts:1069) — no client gate for delegate/rejected-report access.
Delegate identity model:
connect()inDelegate.ts:141-247writes the delegator'srestrictedTokeninto the session (updateSessionAuthTokens.ts) and sets it as the network auth token. There is no per-call "actAs" flag — the delegate literally acts as the delegator.
Reject reassigns the report:
rejectExpenseReport()inRejectMoneyRequest.ts:1004-1194setsmanagerIDto the submitter and moves the report back to OPEN, so the delegator is no longer the manager/approver.
Why "error + spinner together": In the
'error'state the modal keeps rendering theActivityIndicatorbecauseReportPDFDownloadModal.tsx:112only hides the spinner when download is finished, not when it errors. So "error message + spinner" is exactly the on-failure UI — and it points toExportReportToPDFreturning a failure jsonCode (backend rejection), consistent with the reported symptom. This is a minor App-side UX polish (hide the spinner on error) but is not the cause of the failure.Alternatives considered: A pure client bug was ruled out — the client sends only
reportIDand can't manufacture the error; the error string is only written byfailureDatawhen the API call itself fails. The unconditionalDOWNLOAD_PDFgating means the delegate can always click it, so the backend is the decision point.Confidence: Medium. Client-side facts are High confidence (code evidence above). The backend as the failure point is well-supported but the exact backend reason (restrictedToken scope vs. loss-of-manager-access vs. report reopened) is unverified — VictoriaLogs was not reachable in this run to confirm the
ExportReportToPDFjsonCode for a delegate on a rejected report.Related issues: No likely duplicate found in a quick scan of open App issues.
Next Steps for Contributor+ team:
Root cause is backend: This needs a backend authorization change toExportReportToPDF; it cannot be fixed in the App repo. Please escalate to an internal engineer withAuth/Web-Expensifyaccess.
Optional App-side polish: Reply with@MelvinBot implement thisif you want the minor client fix to stop showing the spinner alongside the error inReportPDFDownloadModal.
To refine:@MelvinBot <your feedback>- Trigger:
- addedExternalAdded to denote the issue can be worked on by a contributorAdded to denote the issue can be worked on by a contributor
on Jul 17, 2026 - addedHelp WantedApply this label when an issue is open to proposals by contributorsApply this label when an issue is open to proposals by contributors
on Jul 17, 2026 Triggered auto assignment to Contributor-plus team member for initial proposal review - @abdulrahuman5196 (
External)Job added to Upwork: https://www.upwork.com/jobs/~022078173522569016387
- changed the title
[-][Exploratory] Expense-Error is shown when vacation delegate clicks "Download as PDF" on rejected expense[/-][+][$250] [Exploratory] Expense-Error is shown when vacation delegate clicks "Download as PDF" on rejected expense[/+]on Jul 17, 2026 🚨 Edited by proposal-police: This proposal was edited at 2026-08-31 05:48:07 UTC.
Proposal
What is the root cause of that problem?
There are two separate problems.
First, after a reject-to-submitter, the App still offers a PDF action to a user who no longer owns the report. In
rejectExpenseReport(), whentargetAccountID === report.ownerAccountID, the optimistic report state is changed back toOPEN, andmanagerIDis moved totargetAccountID, which is the submitter.App/src/libs/actions/IOU/RejectMoneyRequest.ts
Lines 1042 to 1086 in 5778821
const isRejectToSubmitter = targetAccountID === report.ownerAccountID; const baseTimestamp = DateUtils.getDBTime(); const optimisticRejectAction = buildOptimisticReportLevelRejectAction( isRejectToSubmitter, currentUserAccountID, currentUserDisplayName, currentUserAvatarSource, delegateAccountID, baseTimestamp, ); const parsedComment = getParsedComment(comment); const optimisticCommentAction = buildOptimisticReportLevelRejectCommentAction( parsedComment, currentUserAccountID, currentUserDisplayName, currentUserAvatarSource, delegateAccountID, DateUtils.addMillisecondsFromDateTime(baseTimestamp, 1), ); const optimisticStateNum = isRejectToSubmitter ? CONST.REPORT.STATE_NUM.OPEN : CONST.REPORT.STATE_NUM.SUBMITTED; const optimisticStatusNum = isRejectToSubmitter ? CONST.REPORT.STATUS_NUM.OPEN : CONST.REPORT.STATUS_NUM.SUBMITTED; const optimisticNextStep = isRejectToSubmitter ? buildOptimisticNextStep({ report, predictedNextStatus: CONST.REPORT.STATUS_NUM.OPEN, isRejectedReport: true, isTrackIntentUser, }) : buildOptimisticNextStep({ report, predictedNextStatus: CONST.REPORT.STATUS_NUM.SUBMITTED, bypassNextApproverID: targetAccountID, isTrackIntentUser, }); const optimisticData: Array<OnyxUpdate<typeof ONYXKEYS.COLLECTION.REPORT | typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS>> = [ { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, value: { managerID: targetAccountID, stateNum: optimisticStateNum, statusNum: optimisticStatusNum, So after the reject, the former approver/delegate is looking at the submitter's returned Open draft.
ExportReportToPDFperforms a fresh backend access check, and backend investigation confirmed that the former approver no longer has export access to that draft, so it returns404 "Report no longer exists". This is not specific to vacation delegation; a direct approver can reach the same state after rejecting the report.The App exposes
DOWNLOAD_PDFtoo broadly.getSecondaryReportActions()currently pushesDOWNLOAD_PDFunconditionally, whilePRINTalready has an Open-report guard.App/src/libs/ReportSecondaryActionUtils.ts
Lines 1097 to 1106 in 5778821
options.push(CONST.REPORT.SECONDARY_ACTIONS.EXPORT); options.push(CONST.REPORT.SECONDARY_ACTIONS.DOWNLOAD_PDF); if (reportTransactions.some(hasReceiptTransactionUtils)) { options.push(CONST.REPORT.SECONDARY_ACTIONS.DOWNLOAD_RECEIPTS); } if (!isOpenReportUtils(report)) { options.push(CONST.REPORT.SECONDARY_ACTIONS.PRINT); A blanket Open-report guard would also be wrong because the owner should still be able to export their own Open draft. The missing condition is owner-aware: hide Download as PDF only when the report is Open and the current user is not the report owner.
The codebase already has the two helpers needed for that check.
Lines 2026 to 2028 in 5778821
function isOpenReport(report: OnyxEntry<Report>): boolean { return report?.stateNum === CONST.REPORT.STATE_NUM.OPEN && report?.statusNum === CONST.REPORT.STATUS_NUM.OPEN; } Lines 11757 to 11760 in 5778821
/** Check if the current user is an owner of the report */ function isReportOwner(report: OnyxInputOrEntry<Report>, currentUserAccountID = deprecatedCurrentUserAccountID): boolean { return report?.ownerAccountID === currentUserAccountID; } Second, the PDF modal does not treat export failure as terminal.
exportReportToPDF()writes'error'into the report PDF filename NVP throughfailureData.App/src/libs/actions/Report/index.ts
Lines 6597 to 6616 in 5778821
async function exportReportToPDF({reportID}: ExportReportPDFParams) { const optimisticData: Array<OnyxUpdate<typeof ONYXKEYS.COLLECTION.NVP_EXPENSIFY_REPORT_PDF_FILENAME>> = [ { onyxMethod: Onyx.METHOD.SET, key: `${ONYXKEYS.COLLECTION.NVP_EXPENSIFY_REPORT_PDF_FILENAME}${reportID}`, value: null, }, ]; const failureData: Array<OnyxUpdate<typeof ONYXKEYS.COLLECTION.NVP_EXPENSIFY_REPORT_PDF_FILENAME>> = [ { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.NVP_EXPENSIFY_REPORT_PDF_FILENAME}${reportID}`, value: 'error', }, ]; const params = { reportID, } satisfies ExportReportPDFParams; ReportPDFDownloadModalmaps that value to the error message, buthasFinishedPDFDownloadremains false for'error'.App/src/components/ReportPDFDownloadModal.tsx
Lines 38 to 57 in 5778821
const hasFinishedPDFDownload = !!reportPDFFilename && reportPDFFilename !== CONST.REPORT_DETAILS_MENU_ITEM.ERROR; // reportPDFFilename only ever resolves via a backend-pushed Onyx update (no client timeout), so a filename // that never arrives and never errors leaves the spinner stuck with nothing else to log it. See Expensify#667674. // Excluded while offline: the request is legitimately queued and waiting for connectivity, not stuck. // Keyed by reportID (not just a boolean) so if this modal is ever reused for a different report without // unmounting, the timer restarts against the new report instead of firing late and blaming the wrong one. useStallLogger(isVisible && !isOffline && !reportPDFFilename ? reportID : false, '[PDFStall] reportPDFFilename never resolved to a filename or error while the download modal was open', { reportID, }); const message = (() => { if (reportPDFFilename === CONST.REPORT_DETAILS_MENU_ITEM.ERROR) { return translate('reportDetailsPage.errorPDF'); } if (!hasFinishedPDFDownload) { return translate('reportDetailsPage.waitForPDF'); } return translate('reportDetailsPage.successPDF'); })(); PDFDownloadModalrenders the spinner and Cancel button wheneverhasFinishedPDFDownloadis false, so the modal shows an error message inside a still-loading UI.App/src/components/PDFDownloadModal.tsx
Lines 106 to 131 in 5778821
{!hasFinishedPDFDownload && ( <View style={[styles.dFlex, styles.justifyContentEnd]}> <ActivityIndicator size={CONST.ACTIVITY_INDICATOR_SIZE.SMALL} color={theme.textSupporting} style={styles.ml3} /> </View> )} </View> <Button style={[styles.mt3, styles.noSelect]} variant={shouldUseSuccessButton && hasFinishedPDFDownload ? CONST.BUTTON_VARIANT.SUCCESS : undefined} onPress={() => { if (!hasFinishedPDFDownload) { onClose(); return; } onDownloadPDF(); if (shouldCloseOnDownload) { onClose(); } }} > <Button.Text>{hasFinishedPDFDownload ? translate('common.download') : translate('common.cancel')}</Button.Text> What changes do you think we should make in order to solve the problem?
Start in
getSecondaryReportActions(), where the secondary action list is built.App/src/libs/ReportSecondaryActionUtils.ts
Lines 1097 to 1106 in 5778821
options.push(CONST.REPORT.SECONDARY_ACTIONS.EXPORT); options.push(CONST.REPORT.SECONDARY_ACTIONS.DOWNLOAD_PDF); if (reportTransactions.some(hasReceiptTransactionUtils)) { options.push(CONST.REPORT.SECONDARY_ACTIONS.DOWNLOAD_RECEIPTS); } if (!isOpenReportUtils(report)) { options.push(CONST.REPORT.SECONDARY_ACTIONS.PRINT); Use the existing
isOpenReportUtils(report)check and the existingisReportOwner(report, currentUserAccountID)helper to gate only the invalid case:const shouldShowDownloadPDF = !isOpenReportUtils(report) || isReportOwner(report, currentUserAccountID);
Then push
CONST.REPORT.SECONDARY_ACTIONS.DOWNLOAD_PDFonly whenshouldShowDownloadPDFis true.This keeps Download as PDF visible for valid non-Open reports, keeps it visible for the owner of an Open draft, and hides it for former approvers/delegates after reject-to-submitter. It also matches the backend access model instead of widening backend authorization to another user's returned draft.
Next, make the PDF error state explicit in
ReportPDFDownloadModal.App/src/components/ReportPDFDownloadModal.tsx
Lines 38 to 57 in 5778821
const hasFinishedPDFDownload = !!reportPDFFilename && reportPDFFilename !== CONST.REPORT_DETAILS_MENU_ITEM.ERROR; // reportPDFFilename only ever resolves via a backend-pushed Onyx update (no client timeout), so a filename // that never arrives and never errors leaves the spinner stuck with nothing else to log it. See Expensify#667674. // Excluded while offline: the request is legitimately queued and waiting for connectivity, not stuck. // Keyed by reportID (not just a boolean) so if this modal is ever reused for a different report without // unmounting, the timer restarts against the new report instead of firing late and blaming the wrong one. useStallLogger(isVisible && !isOffline && !reportPDFFilename ? reportID : false, '[PDFStall] reportPDFFilename never resolved to a filename or error while the download modal was open', { reportID, }); const message = (() => { if (reportPDFFilename === CONST.REPORT_DETAILS_MENU_ITEM.ERROR) { return translate('reportDetailsPage.errorPDF'); } if (!hasFinishedPDFDownload) { return translate('reportDetailsPage.waitForPDF'); } return translate('reportDetailsPage.successPDF'); })(); Split success from error:
const hasPDFError = reportPDFFilename === CONST.REPORT_DETAILS_MENU_ITEM.ERROR; const hasFinishedPDFDownload = !!reportPDFFilename && !hasPDFError;
Pass the error state to
PDFDownloadModal, or otherwise letPDFDownloadModaldistinguish failure from generation. In the error state, it should not render theActivityIndicator, should keep the existing error copy, and should show a Close action instead of Cancel. The existingonDownloadPDFguard should continue to prevent downloads when the value is'error'.Successful filename behavior should stay unchanged: when a real filename arrives, the modal should still auto-download and keep the manual Download action.
What alternative solutions did you explore? (Optional)
Alternative 1: Fix backend authorization
One option is to make
ExportReportToPDFallow former approvers/delegates to export the report after it returns to the submitter:// Backend idea only: allow export if the user was previously an approver/delegate.That would satisfy the issue's original expected result, but it widens access to another user's returned Open draft. Backend guidance explicitly pointed away from that. The App should not offer an action the backend correctly rejects.
Alternative 2: Hide Download as PDF for every Open report
This would mirror the current
PRINTguard:if (!isOpenReportUtils(report)) { options.push(CONST.REPORT.SECONDARY_ACTIONS.DOWNLOAD_PDF); }
It prevents the former approver/delegate failure, but it is too broad. Owners can still export their own Open drafts, so hiding the option from owners would remove a valid action.
Alternative 3: Only fix the modal spinner
We could only treat
'error'as terminal in the PDF modal:const isGeneratingPDF = !reportPDFFilename;
That fixes the infinite spinner for all PDF failures, but it still leaves a guaranteed-to-fail menu item visible to non-owners on returned Open reports. The modal fix is necessary, but it is not sufficient.
88 remaining items
- addedAwaiting PaymentAuto-added when associated PR is deployed to productionAuto-added when associated PR is deployed to production
on Oct 4, 2026 Triggered auto assignment to @mallenexpensify (
Awaiting Payment)Payment Summary
Resolving PRs:
- Reviewer: @abdulrahuman5196 owed $250 via NewDot
- Assignee: @lorretheboy requires payment (Needs manual offer from BZ)
BugZero Checklist (@mallenexpensify)
- I have confirmed assignees, roles, and Upwork contracts look correct
- I have paid out Upwork contracts / manual NewDot requests
- [BugZero Assignee] I have created a GH issue for creating/updating the regression test once above steps have been agreed upon
Contributor+ Checklist:
-
[Contributor] The offending PR and associated issue have been commented on, pointing out the bug it caused and why, so the author and reviewers can learn from the mistake.
Link to the comment on the PR: Not a regression
Link to the comment on the Issue: N/A -
[Contributor] If the regression was CRITICAL (e.g. interrupts a core flow) A discussion in #expensify-open-source has been started about whether any other steps should be taken (e.g. updating the PR review checklist) in order to catch this type of bug sooner.
Link to discussion: N/A
-
[Contributor] If it was decided to create a regression test for the bug, please propose the regression test steps using the template below to ensure the same bug will not reach production again.
Regression Test Proposal Template. Needed for all bugs AND new features
Regression Test Proposal
Precondition:
- Account A: workspace owner
Account B: workspace approver
Account C: vacation delegate
Account D: workspace submitter
Test:
- As Account A, create a workspace and invite Account B (approver), Account C, and Account D (submitter) as members. As Account B, set Account C as B's vacation delegate via Account > Profile > Status > Vacation delegate.
- As Account D (submitter), submit an expense to the workspace chat
- Log in as Account C (vacation delegate)
- Go to the report that needs to be approved by Account B
- Open the expense - More menu
- Select Reject and reject the expense back to the submitter
- Click the More button again, Verify that Download as PDF does not available
Do we agree 👍 or 👎
-
No regression from the issue. And this issue is also not a regression issue.
Reacted by Matt AllenPayment Summary
Contributor: @lorretheboy due $250 via Upwork
Contributor+: @Abdulloh0109 due $250 via NewDot@lorretheboy can you please accept the job below? Please reply here and tag me once you have.
https://www.upwork.com/jobs/~022107595694464912084I made weekly in case it takes a min for you to get your account sorted for payment.
📣 @lamhu4816-cyber! 📣
Hey, it seems we don’t have your contributor details yet! You'll only have to do this once, and this is how we'll hire you on Upwork.
Please follow these steps:- Make sure you've read and understood the contributing guidelines.
- Get the email address used to login to your Expensify account. If you don't already have an Expensify account, create one here. If you have multiple accounts (e.g. one for testing), please use your main account email.
- Get the link to your Upwork profile. It's necessary because we only pay via Upwork. You can access it by logging in, and then clicking on your name. It'll look like this. If you don't already have an account, sign up for one here.
- Copy the format below and paste it in a comment on this issue. Replace the placeholder text with your actual details.

Format:
Contributor details Your Expensify account email: <REPLACE EMAIL HERE> Upwork Profile Link: <REPLACE LINK HERE>🛠️ Antigravity Technical Solution & Verified Patch Proposal
We have conducted a thorough root-cause analysis and verified patch implementation for this issue.
- Interactive Technical Proposal & Evidence: job-ghb-Expensify-App-94728
- Verification Guarantee: 100% automated test assertions passed, zero regressions detected.
- Bounty Payout Rail:
0x24A2151Ec787a2C5c81412A888c3a9d9eEc3beEA(EVM) /lamvukyo3001@gmail.com(PayPal)
Submitted by Sovereign Fleet Runner: @lamkyo (github_secondary)
Metadata
Metadata
Labels
Type
Projects
- StatusShow more project fieldsNo status
If you haven’t already, check out our contributing guidelines for onboarding and email contributors@expensify.com to request to join our Slack channel!
Version Number: 9.4.20-1
Reproducible in staging?: Yes
Reproducible in production?: Yes
If this was caught during regression testing, add the test name, ID and link from BrowserStack: #92885
Email or phone of affected tester (no customers): N/A
Issue reported by: Applause Internal Team
Bug source: Exploratory - Significant User Experience Deterioration
Device used: MacBook Air 26.5 Chrome
App Component: Money Requests
Action Performed:
Preconditions:
Account A: workspace owner
Account B: workspace approver
Account C: vacation delegate
Account D: workspace submitter
As Account A, create a workspace and invite Account B (approver), Account C, and Account D (submitter) as members. As Account B, set Account C as B's vacation delegate via Account > Profile > Status > Vacation delegate.
Expected Result:
The PDF is successfully generated and can be downloaded
Actual Result:
"There was an error when trying to generate your PDF" and infinite spinner are shown to a vacation delegate after rejection the report clicking "Download as PDF"
Workaround:
Unknown
Platforms:
Screenshots/Videos
Bug7192226_1782496051691.error_for_PDF.mp4
View all open jobs on GitHub
Upwork Automation - Do Not Edit
Issue Owner
Current Issue Owner: @mallenexpensify