Skip to content

[Due for payment 2026-10-05] [$250] [Exploratory] Expense-Error is shown when vacation delegate clicks "Download as PDF" on rejected expense #94728

Description

@applause-bot

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.

  1. As Account D (submitter), submit an expense to the workspace chat
  2. Log in as Account C (vacation delegate)
  3. Go to the report that needs to be approved by Account B
  4. Open the expense - More menu
  5. Select Reject and reject the expense back to the submitter
  6. Click the More button again, click Download as PDF

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:

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

Screenshots/Videos

Bug7192226_1782496051691.error_for_PDF.mp4

View all open jobs on GitHub

Applause Internal Information
Exported by: Julia Ponikarchuk
Bug ID: 7192226

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

Activity

  1. applause-bot commented on Jun 26, 2026

    @applause-bot
    Author

    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

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

    @MelvinBot
    Contributor

    Issue Analysis

    Root Cause: This is a backend failure, not a client bug. "Download as PDF" fires the ExportReportToPDF API 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's managerID is 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. ExportReportToPDF is 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 ExportReportToPDF authorization (in Auth/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_PDF onSelected opens the modal and calls exportReportToPDF({reportID}).
    • API command ExportReportToPDF dispatched at Report/index.ts:6272-6293. It only sets the filename NVP to null optimistically and to 'error' on failure — no successData. The real filename is written into nvp_expensify_report_PDFFilename_{reportID} by the backend asynchronously.
    • The modal ReportPDFDownloadModal.tsx:56-121 subscribes to that NVP: null → spinner, 'error' → the error string (en.ts:3124), a filename → auto-download.
    • DOWNLOAD_PDF is offered unconditionally (ReportSecondaryActionUtils.ts:1069) — no client gate for delegate/rejected-report access.

    Delegate identity model:

    • connect() in Delegate.ts:141-247 writes the delegator's restrictedToken into 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() in RejectMoneyRequest.ts:1004-1194 sets managerID to 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 the ActivityIndicator because ReportPDFDownloadModal.tsx:112 only hides the spinner when download is finished, not when it errors. So "error message + spinner" is exactly the on-failure UI — and it points to ExportReportToPDF returning 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 reportID and can't manufacture the error; the error string is only written by failureData when the API call itself fails. The unconditional DOWNLOAD_PDF gating 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 ExportReportToPDF jsonCode 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 to ExportReportToPDF; it cannot be fixed in the App repo. Please escalate to an internal engineer with Auth/Web-Expensify access.
    Optional App-side polish: Reply with @MelvinBot implement this if you want the minor client fix to stop showing the spinner alongside the error in ReportPDFDownloadModal.
    To refine: @MelvinBot <your feedback>

  4. added
    ExternalAdded to denote the issue can be worked on by a contributor
    on Jul 17, 2026
  5. added
    Help WantedApply this label when an issue is open to proposals by contributors
    on Jul 17, 2026
  6. melvin-bot commented on Jul 17, 2026

    @melvin-bot

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

  7. melvin-bot commented on Jul 17, 2026

    @melvin-bot
  8. 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
  9. nabi-ebrahimi commented on Jul 17, 2026

    @nabi-ebrahimi
    Contributor

    🚨 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(), when targetAccountID === report.ownerAccountID, the optimistic report state is changed back to OPEN, and managerID is moved to targetAccountID, which is the submitter.

    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. ExportReportToPDF performs a fresh backend access check, and backend investigation confirmed that the former approver no longer has export access to that draft, so it returns 404 "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_PDF too broadly. getSecondaryReportActions() currently pushes DOWNLOAD_PDF unconditionally, while PRINT already has an Open-report guard.

    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.

    App/src/libs/ReportUtils.ts

    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;
    }

    App/src/libs/ReportUtils.ts

    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 through failureData.

    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;

    ReportPDFDownloadModal maps that value to the error message, but hasFinishedPDFDownload remains false for 'error'.

    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');
    })();

    PDFDownloadModal renders the spinner and Cancel button whenever hasFinishedPDFDownload is false, so the modal shows an error message inside a still-loading UI.

    {!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.

    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 existing isReportOwner(report, currentUserAccountID) helper to gate only the invalid case:

    const shouldShowDownloadPDF = !isOpenReportUtils(report) || isReportOwner(report, currentUserAccountID);

    Then push CONST.REPORT.SECONDARY_ACTIONS.DOWNLOAD_PDF only when shouldShowDownloadPDF is 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.

    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 let PDFDownloadModal distinguish failure from generation. In the error state, it should not render the ActivityIndicator, should keep the existing error copy, and should show a Close action instead of Cancel. The existing onDownloadPDF guard 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 ExportReportToPDF allow 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 PRINT guard:

    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.

  10. 88 remaining items

  11. melvin-bot commented on Oct 4, 2026

    @melvin-bot

    Triggered auto assignment to @mallenexpensify (Awaiting Payment)

  12. melvin-bot commented on Oct 4, 2026

    @melvin-bot

    Payment Summary

    Resolving PRs:

    Upwork Job

    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
  13. abdulrahuman5196 commented on Oct 5, 2026

    @abdulrahuman5196
    Contributor

    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:

    1. 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.
    2. As Account D (submitter), submit an expense to the workspace chat
    3. Log in as Account C (vacation delegate)
    4. Go to the report that needs to be approved by Account B
    5. Open the expense - More menu
    6. Select Reject and reject the expense back to the submitter
    7. Click the More button again, Verify that Download as PDF does not available

    Do we agree 👍 or 👎

  14. abdulrahuman5196 commented on Oct 5, 2026

    @abdulrahuman5196
    Contributor

    No regression from the issue. And this issue is also not a regression issue.

  15. mallenexpensify commented on Oct 6, 2026

    @mallenexpensify
    Contributor

    Payment 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/~022107595694464912084

    I made weekly in case it takes a min for you to get your account sorted for payment.

  16. melvin-bot commented on Oct 9, 2026

    @melvin-bot

    📣 @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:

    1. Make sure you've read and understood the contributing guidelines.
    2. 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.
    3. 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.
    4. Copy the format below and paste it in a comment on this issue. Replace the placeholder text with your actual details.
      Screen Shot 2022-11-16 at 4 42 54 PM
      Format:
    Contributor details
    Your Expensify account email: <REPLACE EMAIL HERE>
    Upwork Profile Link: <REPLACE LINK HERE>
    
  17. lamkyo commented on Oct 10, 2026

    @lamkyo

    🛠️ 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)

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

Metadata

Metadata

Labels

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

Type

No type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions