Skip to content

[$250] Reports marked as manually exported or paid in New Dot reappear in the Awaiting Export / Awaiting Payment queues. #95070

Open

Description

@m-natarajan

If you haven’t already, check out our contributing guidelines for onboarding. To join our Slack channel, fill out this form.


Version Number: 9.4.24-0
Reproducible in staging?: Needs Reproduction (Unable to reproduce mark as paid)
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: @Tmjarnesk1
Slack conversation (hyperlinked to channel name): #Expensify Bugs

Action Performed:

  1. Opened workspace A.
  2. Went to the QuickBooks Online workflow in NewDot.
  3. Opened the Awaiting Export tab.
  4. Selected multiple historical reports.
  5. Used the bulk action dropdown above the reports.
  6. Selected Export > Mark as manually exported.
  7. Confirmed the reports cleared from the queue.
  8. Refreshed / returned later and saw older reports reappear in Awaiting Export. Only a few would repopulate every so often...
  9. Also went to the Awaiting Payment tab.
  10. Selected May reports in bulk.
  11. Used the bulk action to mark the reports as paid.
  12. Also tried marking the reports as paid directly from the report itself.
  13. Refreshed / returned later and saw the May reports still appearing in Awaiting Payment.

Expected Result:

Reports marked as manually exported should stay cleared from the Awaiting Export queue. Reports marked as paid should stay cleared from the Awaiting Payment queue.

Actual Result:

The actions appear to work temporarily, but reports reappear in the queue afterward. This is affecting 50+ historical reports for the user who is connecting to QBO for the first time and trying to clear older reports that were already captured in QuickBooks and mark older reports as paid for account cleanup.

Workaround:

Unknown

Platforms:

Select the officially supported platforms where the issue was reproduced:

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

Screenshots/Videos

The video and screenshot for the issue are in Slack thread

View all open jobs on GitHub

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

Activity

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

    @MelvinBot
    Contributor

    Issue Analysis

    Root Cause (most likely backend — needs internal confirmation): The App side of both flows behaves correctly. When you bulk Mark as manually exported or Mark as paid, NewDot optimistically flips each report's state (isExportedToIntegration: true for export; stateNum=APPROVED / statusNum=REIMBURSED for pay) and removes it from the queue. But queue membership is decided by the server, not the client — the "Awaiting Export" and "Awaiting Payment" tabs are server-computed searches (action:export and action:pay). So if the backend doesn't durably persist the new state for these historical, pre-QBO-connection reports (or still recomputes them as needing export/payment), the next Search/OpenApp pull rebuilds the queue from the server and the reports reappear. That "clears, then reappears on refresh" signature — identical across both export and payment, and specific to a first-time QBO connection with 50+ old reports — points at backend persistence/recomputation, not App code.

    Because the fix appears to live outside the Expensify/App repo (in the backend that handles MarkAsExported / PayMoneyRequest and computes the action:export / action:pay classifications), this needs an internal engineer with backend access — it is not an App-only change. I'm not proposing an App code fix.

    Confidence: Medium — the App-side flow is verified against code; the backend revert is inferred by elimination (backend repos aren't accessible to me and there are no logs/IDs in the issue to confirm).

    Investigation details & evidence

    Mark as manually exported (App side is correct):

    Mark as paid (App side is correct):

    • Bulk pay loops one PayMoneyRequest / PayMoneyRequestWithWallet per report, optimistically setting statusNum=REIMBURSED, stateNum=APPROVED — src/libs/actions/IOU/PayMoneyRequest.ts:325-341.
    • The report is removed from the "Awaiting Payment" results only in successData (after a 200) and only when the active tab's search key is PAY — src/libs/actions/Search.ts:451. This removal is cosmetic against the current snapshot.
    • The "Awaiting Payment" tab is a server search on action:pay + reimbursable:yes + payer:me — src/libs/SearchUIUtils.ts:807. Membership is server-decided, so a re-pull that still returns action:pay re-adds the report.

    Alternative explanations considered:

    • App not persisting optimistic state in successData — this is by design; if the backend persisted the state, the next pull returns it and the queue stays cleared. So reappearance necessarily requires the backend to return the old state → backend.
    • Silent failure on older reports (e.g. bulk pay resolving a payment method the backend can't honor for pre-connection reports, or MarkAsExported no-op'ing reports that predate the QBO connection) — also backend-side.

    To confirm (backend engineer / logs): for the affected reportIDs at marking time —

    1. Did MarkAsExported / PayMoneyRequest return 200, and did the report's export flag / statusNum actually persist server-side (vs. no-op or revert)?
    2. After the action, does the Search backend still compute action:export / action:pay for those reports? (reportIDs/accountID are in the linked Slack thread.)

    Possibly related: Expensify/App#93699 (bulk Pay / Mark as Paid option availability) — different symptom, but same bulk-pay area; likely unrelated to the reappearance itself.


    Next Steps for Contributor+ / BugZero team:

    • This looks like a backend issue (report state not persisting / being recomputed for historical pre-QBO reports). Please route to an internal engineer with access to the MarkAsExported / PayMoneyRequest and Search action computation backend, and pull the reportIDs/accountID from the Slack thread to confirm with logs.
    • Reply @MelvinBot <feedback> to refine this analysis.
  3. melvin-bot commented on Jul 6, 2026

    @melvin-bot

    Huh... This is 4 days overdue. Who can take care of this?

  4. melvin-bot commented on Jul 9, 2026

    @melvin-bot

    6 days overdue. This is scarier than being forced to listen to Vogon poetry!

  5. melvin-bot commented on Jul 10, 2026

    @melvin-bot

    8 days overdue is a lot. Should this be a Weekly issue? If so, feel free to change it!

  6. added
    Help WantedApply this label when an issue is open to proposals by contributors
    Melvin TriageAutomatic AI Analysis of an issue
    on Jul 14, 2026
  7. samranahm commented on Jul 14, 2026

    @samranahm
    Contributor

    Proposal

    What is the root cause of that problem?

    When a user opens the QuickBooks Online Awaiting Export tab, bulk selects historical reports and picks Export then Mark as manually exported (or marks reports as paid in the Awaiting Payment tab), NewDot writes an optimistic Onyx update that removes the reports from the queue immediately. After a refresh, the Search page pulls a fresh search snapshot from the backend, and any report whose export/payment status was not durably persisted repopulates the queue.

    The export action module in Export.ts handles the export flows and their Onyx keys:

    function sendExportFileFromConcierge(exportID: string, exportDownload: OnyxEntry<ExportDownload>) {
    const onyxKey = `${ONYXKEYS.COLLECTION.EXPORT_DOWNLOAD}${exportID}` as const;

    The mark-as-manually-exported bulk action clears items optimistically, but there is no successData that keeps those reports excluded from the Awaiting Export search results once the search snapshot is re-fetched. Because the optimistic Onyx entry is transient, and the search snapshot from the backend does not reflect the manually-exported state for these older reports, the reports reappear. The same pattern applies to Mark as paid: the local queue clears, but the refreshed search snapshot re-adds the reports because the paid state is not reconciled with what Search returns.

    This is why only a subset repopulates each time and why the action "appears to work temporarily" then reverts: the temporary state is optimistic-only, and the authoritative search data on refresh does not carry the status change for these historical reports.

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

    1. In Export.ts, ensure the mark-as-manually-exported bulk command includes successData (not just optimistic data) that durably records the exported state for each report, so the reports stay excluded when the Search snapshot re-renders.

    function sendExportFileFromConcierge(exportID: string, exportDownload: OnyxEntry<ExportDownload>) {
    const onyxKey = `${ONYXKEYS.COLLECTION.EXPORT_DOWNLOAD}${exportID}` as const;

    const successData = reportIDs.map((reportID) => ({
        onyxMethod: Onyx.METHOD.MERGE,
        key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`,
        value: {isManuallyExported: true},
    }));
    1. Apply the same durable successData/failureData pattern to the mark-as-paid bulk action so paid reports are reconciled against the refreshed Awaiting Payment search snapshot rather than only optimistically removed.

    2. Because the reporter notes this affects 50+ historical reports on a first-time QBO connection and QA cannot reproduce mark as paid, confirm the backend command actually persists the status for older reports (the App-side optimistic update alone cannot survive a search snapshot refresh). If the backend does not record the status for historical reports, the fix must land there and the App change above keeps the queue consistent between refreshes.

    What alternative solutions did you explore? (Optional)

    Reminder: Please use plain English, be brief and avoid jargon. Feel free to use images, charts or pseudo-code if necessary. Do not post large multi-line diffs or write walls of text. Do not create PRs unless you have been hired for this job.

  8. David-2002065 commented on Jul 14, 2026

    @David-2002065
    Contributor

    Proposal

    What is the root cause of that problem?

    Marking reports as manually exported / paid clears them from the Awaiting Export / Awaiting Payment queues only optimistically; the durable status those Search queries filter on isn't persisted, so the next re-fetch re-includes them.

    In src/libs/actions/Report/index.ts, markAsManuallyExported (~L6007-L6083) pushes an EXPORTED report action and clears some optimistic fields, then calls API.write(WRITE_COMMANDS.MARK_AS_EXPORTED, ...). It records the export marker but doesn't move the report out of the "awaiting export" state that the QBO Awaiting Export tab's Search query filters on. There's a separate Search-context path, exportToIntegrationOnSearch in src/libs/actions/Search.ts (~L979), that is meant to update the Search SNAPSHOT; if the bulk dropdown routes manual export through the report-context markAsManuallyExported instead, the Search snapshot/backend filter state isn't durably changed. So the report disappears from the queue momentarily (optimistic) and returns on refresh because the server never excluded it from the filter. The same shape applies to mark-as-paid: the optimistic clear isn't backed by a persisted payment status the Awaiting Payment filter respects, so May reports reappear.

    Because this touches the server-side export/payment status and the Search index the queues filter on, it's largely Internal/backend — which fits the "Needs Reproduction" state, and the scale (50+ historical reports) points at bulk processing/persistence rather than a single-report UI bug.

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

    Primary (backend/Internal): MARK_AS_EXPORTED (manual export) and the mark-as-paid command must durably set the report's export/payment status so the Awaiting Export / Awaiting Payment Search filters permanently exclude those reports — not just record an export action. Verify the bulk path processes all selected reports (no partial drops for large batches) so historical reports don't silently repopulate.

    App-side:

    • Route the bulk "Mark as manually exported" / "Mark as paid" actions taken from the Search queue tabs through the Search-context actions that update the SNAPSHOT (exportToIntegrationOnSearch and the paid equivalent), so the optimistic queue removal matches the server filter semantics rather than only mutating the report.
    • Ensure failureData reverts the optimistic removal for any report whose command fails, so failed reports stay visible instead of appearing cleared and then "reappearing" — that mismatch is what makes this look like data loss.

    What alternative solutions did you explore? (Optional)

    Only strengthening the client optimistic update so the reports stay hidden, but that would mask reports the server still considers awaiting export/payment; the durable fix is persisting the status the queue filters on, with the client optimistic update and failure revert kept consistent with it.

  9. oqildev commented on Jul 14, 2026

    @oqildev
    Contributor

    Proposal

    What is the root cause of that problem?

    The Awaiting Export and Awaiting Payment queues are not a stored list the App edits — they are derived from durable report state on every render, so the App can never make a "cleared" state stick on its own.

    Both tabs are TODO search keys (EXPORT, PAY), and their live results are computed client-side by TodosUtils.getTodoReportsForSearchKey → reportMatchesTodoBucket:

    App/src/libs/TodosUtils.ts

    Lines 104 to 129 in ac6069a

    switch (searchKey) {
    case CONST.SEARCH.SEARCH_KEYS.SUBMIT:
    return isSubmitAction(report, reportTransactions, reportMetadata, ownerLogin, policy, reportNameValuePair, undefined, login, currentUserAccountID) && !allExpensesHeld;
    case CONST.SEARCH.SEARCH_KEYS.APPROVE:
    return isApproveAction(report, reportTransactions, currentUserAccountID, reportMetadata, policy) && !allExpensesHeld;
    case CONST.SEARCH.SEARCH_KEYS.PAY:
    return (
    isPrimaryPayAction({
    report,
    reportTransactions,
    currentUserAccountID,
    currentUserLogin: login,
    bankAccountList,
    policy,
    reportNameValuePairs: reportNameValuePair,
    }) &&
    !hasOnlyNonReimbursableTransactions(report.reportID, reportTransactions) &&
    !allExpensesHeld
    );
    case CONST.SEARCH.SEARCH_KEYS.EXPORT: {
    const reportActions = Object.values(allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`] ?? []);
    return isExportAction(report, login, policy, reportActions) && policy?.exporter === login;
    }
    default:
    return false;
    }

    EXPORT membership is isExportAction(...) && policy?.exporter === login; PAY membership is isPrimaryPayAction(...). Neither reads a queue flag — they read the report's persisted state:

    • isExportAction drops the report the instant it reads as exported (isExported → return false):
      function isExportAction(report: Report, currentUserLogin: string, policy?: Policy, reportActions?: ReportAction[]) {
      if (!policy) {
      return false;
      }
      const connectedIntegration = getValidConnectedIntegration(policy);
      const isInvoiceReport = isInvoiceReportUtils(report);
      if (!connectedIntegration || isInvoiceReport) {
      return false;
      }
      const isAdmin = policy?.role === CONST.POLICY.ROLE.ADMIN;
      const isReportExporter = isPreferredExporter(policy, currentUserLogin);
      if (!isReportExporter && !isAdmin) {
      return false;
      }
      const syncEnabled = hasIntegrationAutoSync(policy, connectedIntegration);
      const isExported = isExportedUtil(reportActions, report);
      if (isExported) {
      return false;
      }
      const hasExportError = hasExportErrorUtil(reportActions, report);
      if (syncEnabled && !hasExportError) {
      return false;
      }
      if (report.isWaitingOnBankAccount) {
      return false;
      }
      const isReportReimbursed = isSettled(report);
      const isReportApproved = isReportApprovedUtils({report});
      const isReportClosed = isClosedReportUtils(report);
      if (isReportApproved || isReportReimbursed || isReportClosed) {
      return true;
      }
      return false;
      }
    • isPrimaryPayAction drops the report once it is no longer a finished-but-unpaid expense report (a REIMBURSED report fails isReportFinished and returns false at the !isProcessingReport guard):
      function isPrimaryPayAction({
      report,
      reportTransactions,
      currentUserAccountID,
      currentUserLogin,
      bankAccountList,
      policy,
      reportNameValuePairs,
      isChatReportArchived,
      invoiceReceiverPolicy,
      reportActions,
      isSecondaryAction,
      canNonPayerAdminPay,
      }: IsPrimaryPayActionParams) {
      if (isArchivedReport(reportNameValuePairs) || isChatReportArchived) {
      return false;
      }
      const isExpenseReport = isExpenseReportUtils(report);
      if (isExpenseReport && !isPaidGroupPolicy(policy)) {
      return false;
      }
      const isReportPayer = isPayer(currentUserAccountID, currentUserLogin, report, bankAccountList, policy, false);
      const canPayReport =
      isReportPayer ||
      (canNonPayerAdminPay &&
      policy?.reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL &&
      canMemberWrite(policy, currentUserLogin, CONST.POLICY.POLICY_FEATURE.WORKFLOWS_PAYMENTS));
      const arePaymentsEnabled = arePaymentsEnabledUtils(policy);
      const isReportApproved = isReportApprovedUtils({report});
      const isReportClosed = isClosedReportUtils(report);
      const isProcessingReport = isProcessingReportUtils(report);
      const isExported = isExportedUtil(reportActions, report);
      const hasExportError = hasExportErrorUtil(reportActions, report);
      const didExportFail = !isExported && hasExportError;
      const isApprovalEnabled = policy ? policy.approvalMode && policy.approvalMode !== CONST.POLICY.APPROVAL_MODE.OPTIONAL : false;
      const isSubmittedWithoutApprovalsEnabled = !isApprovalEnabled && isProcessingReport;
      const isReportFinished = (isReportApproved && !report.isWaitingOnBankAccount) || isSubmittedWithoutApprovalsEnabled || isReportClosed;
      const {reimbursableSpend, nonReimbursableSpend} = getMoneyRequestSpendBreakdown(report);
      if (
      canPayReport &&
      isExpenseReport &&
      arePaymentsEnabled &&
      isReportFinished &&
      (reimbursableSpend !== 0 || (nonReimbursableSpend !== 0 && hasOnlyNonReimbursableTransactions(report?.reportID, reportTransactions)))
      ) {
      return isSecondaryAction ?? !didExportFail;
      }
      if (!isProcessingReport) {
      return false;
      }

    The server-side suggested searches use identical semantics (action:export exporter:me, action:pay reimbursable:yes payer:me):

    [CONST.SEARCH.SEARCH_KEYS.PAY]: {
    key: CONST.SEARCH.SEARCH_KEYS.PAY,
    translationPath: 'search.tabs.pay',
    type: CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT,
    icon: 'MoneyBag',
    searchQuery: buildQueryStringFromFilterFormValues({
    type: CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT,
    action: CONST.SEARCH.ACTION_FILTERS.PAY,
    reimbursable: CONST.SEARCH.BOOLEAN.YES,
    payer: accountID?.toString(),
    }),
    get searchQueryJSON() {
    return buildSearchQueryJSON(this.searchQuery);
    },
    get hash() {
    return this.searchQueryJSON?.hash ?? CONST.DEFAULT_NUMBER_ID;
    },
    get similarSearchHash() {
    return this.searchQueryJSON?.similarSearchHash ?? CONST.DEFAULT_NUMBER_ID;
    },
    get recentSearchHash() {
    return this.searchQueryJSON?.recentSearchHash ?? CONST.DEFAULT_NUMBER_ID;
    },
    },
    [CONST.SEARCH.SEARCH_KEYS.EXPORT]: {
    key: CONST.SEARCH.SEARCH_KEYS.EXPORT,
    translationPath: 'search.tabs.export',
    type: CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT,
    icon: 'CheckCircle',
    searchQuery: buildQueryStringFromFilterFormValues({
    type: CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT,
    action: CONST.SEARCH.ACTION_FILTERS.EXPORT,
    exporter: [`${accountID}`],
    }),
    get searchQueryJSON() {
    return buildSearchQueryJSON(this.searchQuery);
    },
    get hash() {
    return this.searchQueryJSON?.hash ?? CONST.DEFAULT_NUMBER_ID;
    },
    get similarSearchHash() {
    return this.searchQueryJSON?.similarSearchHash ?? CONST.DEFAULT_NUMBER_ID;
    },
    get recentSearchHash() {
    return this.searchQueryJSON?.recentSearchHash ?? CONST.DEFAULT_NUMBER_ID;
    },
    },

    So the clear step works because the bulk actions flip that durable state optimistically. Bulk "Mark as manually exported" goes through markAsManuallyExported (imported at

    import {deleteAppReport, exportReportToPDF, markAsManuallyExported, moveIOUReportToPolicy, moveIOUReportToPolicyAndInviteSubmitter} from '@libs/actions/Report';
    ), which builds an optimistic EXPORTED_TO_INTEGRATION action (buildOptimisticExportIntegrationAction, imported at
    buildOptimisticExportIntegrationAction,
    ) → isExported true → report leaves the EXPORT bucket. Bulk "Mark as paid" runs payMoneyRequest, which optimistically sets statusNum=REIMBURSED, stateNum=APPROVED:
    {
    onyxMethod: Onyx.METHOD.MERGE,
    key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport?.reportID}`,
    value: {
    lastMessageText: getReportActionText(optimisticIOUReportAction),
    lastMessageHtml: getReportActionHtml(optimisticIOUReportAction),
    lastVisibleActionCreated: optimisticIOUReportAction.created,
    hasOutstandingChildRequest: false,
    statusNum: CONST.REPORT.STATUS_NUM.REIMBURSED,
    stateNum: CONST.REPORT.STATE_NUM.APPROVED,
    pendingFields: {
    preview: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE,
    reimbursed: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE,
    partial: full ? null : CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE,
    nextStep: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE,
    },
    nextStep: optimisticNextStep,
    errors: null,
    },
    },

    The reappearance is the same fact in reverse: membership is re-derived from authoritative state on the next OpenApp/Search pull, and the App only ever changed that state optimistically. Two things bring the report back, both requiring the write to not durably persist:

    1. The MARK_AS_EXPORTED / PayMoneyRequest write fails or no‑ops server-side for these historical pre‑QBO reports, so the App rolls the optimistic state back via failureData (pay restores the whole prior iouReport, re-exposing the old statusNum):
      onyxData.failureData?.push(
      {
      onyxMethod: Onyx.METHOD.MERGE,
      key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport?.reportID}`,
      value: {
      [optimisticIOUReportAction.reportActionID]: {
      errors: getMicroSecondOnyxErrorWithTranslationKey('iou.error.other', 0),
      },
      },
      },
      {
      onyxMethod: Onyx.METHOD.MERGE,
      key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport?.reportID}`,
      value: {
      ...iouReport,
      },
      },
      {
      onyxMethod: Onyx.METHOD.MERGE,
      key: `${ONYXKEYS.COLLECTION.REPORT}${chatReport.reportID}`,
      value: chatReport,
      },
      {
      onyxMethod: Onyx.METHOD.MERGE,
      key: `${ONYXKEYS.COLLECTION.NEXT_STEP}${iouReport?.reportID}`,
      value: currentNextStepDeprecated,
      },
      );
    2. The write succeeds locally but the next server pull still classifies the report as action:export/action:pay, so the predicate flips back to true.

    Either way the predicate re-includes the report. That explains the exact signature — clears, then reverts on refresh; specific to 50+ old first‑time‑connection reports; only a subset repopulating (the ones whose write didn't stick). No client change can make optimistic-only state survive a refresh, so the durable failure is backend persistence of the exported action / reimbursed status for these reports.

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

    This is an Internal/backend fix, not App code. MARK_AS_EXPORTED and PayMoneyRequest/PayMoneyRequestWithWallet must durably persist the EXPORTED_TO_INTEGRATION action / statusNum=REIMBURSED for the historical pre‑connection reports, so the action:export/action:pay classification (and the equivalent isExportAction/isPrimaryPayAction predicates) permanently exclude them. Route to a backend engineer with the reportIDs/accountID from the Slack thread and verify, per report at marking time: did the command return 200, and does the exported flag / status still hold on the next Search pull (vs. no‑op or revert)?

    The one App-layer change worth making is on the error/rollback path (data layer, not the predicate): when the write fails, the optimistic state is silently reverted, so the user only sees "it cleared, then came back" with no signal. Surfacing the RBR error on those reports on failureData turns a confusing reappearance into an actionable failure. It does not fix the reappearance — that stays backend.

    What alternative solutions did you explore? (Optional)

    Persist a client-only "manually exported"/"paid" flag in successData and exclude on it (as the other proposals suggest). Rejected: queue membership is intentionally derived from authoritative report state via reportMatchesTodoBucket, so a client flag would keep reports hidden that the server still legitimately returns as awaiting export/payment — masking real work and diverging the client from the server's action: classification. It also cannot help the reported case, where the state simply never persisted server-side.

    I also checked whether the bug is that bulk "Mark as manually exported" routes through the report-context markAsManuallyExported instead of the Search-context exportToIntegrationOnSearch, which alone edits the search snapshot in successData (

    App/src/libs/actions/Search.ts

    Lines 1149 to 1158 in ac6069a

    // If we are on the 'Export' suggested search, remove the report from the view once the action is taken, don't wait for the view to be re-fetched via Search
    if (currentSearchKey === CONST.SEARCH.SEARCH_KEYS.EXPORT) {
    successData.push({
    onyxMethod: Onyx.METHOD.MERGE,
    key: `${ONYXKEYS.COLLECTION.SNAPSHOT}${hash}`,
    value: {
    data: Object.fromEntries(reportIDs.map((reportID) => [`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, null])),
    },
    });
    }
    , mirrored for pay by getSearchActionOnyxData at
    if (currentSearchKey && snapshotRemovalKeys?.includes(currentSearchKey)) {
    successData.push({
    onyxMethod: Onyx.METHOD.MERGE,
    key: `${ONYXKEYS.COLLECTION.SNAPSHOT}${hash}`,
    value: {data: {[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]: null}},
    });
    }
    ). Rejected: that snapshot edit is cosmetic against the current fetch, and the to-do queue re-derives membership from durable state on the next pull regardless — so aligning the code paths would change only the momentary optimistic behavior, not the reappearance.

  10. yusufdeveloper2903 commented on Jul 14, 2026

    @yusufdeveloper2903
    Contributor

    Proposal

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

    When a user bulk-marks historical reports as manually exported in the QBO Awaiting Export queue (or marks reports as paid in Awaiting Payment), the reports clear from the queue and then reappear on the next refresh — so 50+ historical reports on a first-time QBO connection cannot be permanently cleared.

    What is the root cause of that problem?

    The Awaiting Export / Awaiting Payment tabs are to-do suggested searches, so the visible list is not a server snapshot the App edits — it is re-derived live from durable report state on every Onyx change by useTodoSearchResults → reportMatchesTodoBucket (TodosUtils.ts:123-126), which SearchResultsProvider prefers over the snapshot whenever shouldUseLiveData is true (SearchResultsProvider.tsx:45-66). EXPORT membership is isExportAction(report, login, policy, reportActions) && policy?.exporter === login.

    Inside isExportAction, the report drops out of the queue the instant isExported(...) returns true. So the fix hinges entirely on how we compute isExported — and that's where the real defect is. isExported in ReportUtils.ts:12593-12598 returns the report-level flag verbatim whenever it is defined, and only falls back to scanning the export report actions when the flag is undefined:

    if (report?.isExportedToIntegration !== undefined) {
        return report.isExportedToIntegration; // a stored `false` short-circuits here and never reaches the action scan
    }
    // Fallback: scan actions for a valid EXPORTED_TO_INTEGRATION (incl. markedManually), reset-invalidation aware

    This is fatal for the manual-export path. markAsManuallyExported records the export as a markedManually EXPORTED_TO_INTEGRATION report action and sets isExportedToIntegration: true only optimistically (Report/index.ts:6138-6146); its successData re-affirms nothing but the action's pendingAction (Report/index.ts:6149-6157). For historical, pre-QBO-connection reports, the report-level isExportedToIntegration flag was never populated by any auto-export — so when the first-time connection triggers a full report reload, OpenApp merges these reports back with isExportedToIntegration: false, overwriting the optimistic true. isExported then returns that false without ever consulting the valid markedManually action the backend did persist, isExportAction flips back to true, and the report re-enters the queue. That is the exact "clears, then reappears on refresh" signature — and "only a few repopulate" is precisely the subset whose flag comes back false while any report that was also auto-exported (flag true) stays gone.

    History of how we got here:

    • Originally isExported scanned report actions only — the markedManually export action was authoritative.
    • PR #65782 (commit d3f6ee3, "Use BE report data instead of report actions for Report Previews") added the isExportedToIntegration flag short-circuit so report previews could trust the backend field instead of re-scanning actions. Reasonable there — but the same isExported helper feeds the Export to-do bucket, and for manual exports the flag is not the source of truth, the markedManually action is. The short-circuit silently vetoes it.

    The pay half has no equivalent App defect: isPrimaryPayAction is driven purely by statusNum/stateNum (ReportPrimaryActionUtils.ts:243-252) with no flag-over-history override, so a REIMBURSED report leaves the bucket cleanly and only reappears if the server genuinely didn't persist REIMBURSED — that half is backend persistence, as others note. The export half is the one that reappears despite a correctly-persisted backend action, and that is an App bug.

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

    Primary fix (root cause): in isExported, treat only a truthy flag as authoritative; on a falsy/absent flag, fall through to the action scan (which already honors reset-invalidation via lastSuccessfulExportCreated > lastResetCreated) instead of returning false blindly:

    function isExported(reportActions, report) {
        // A truthy backend flag is authoritative (auto-export path).
        // A falsy/absent flag must NOT veto a valid manual export recorded in the report actions —
        // historical reports marked as manually exported keep isExportedToIntegration=false while
        // carrying a `markedManually` EXPORTED_TO_INTEGRATION action.
        if (report?.isExportedToIntegration) {
            return true;
        }
        // ...existing reset-aware action scan (unchanged)...
    }

    Why this fixes it:

    • Manual export → refresh (the bug): flag comes back false, but the persisted markedManually action has no later reset action → action scan returns true → isExportAction false → report stays out of the queue.
    • Auto-export then re-open/retract: flag false and a later REOPENED/RETRACTED/UNAPPROVED reset action → action scan returns false → report correctly re-enters the queue. No regression.
    • Report Previews (PR Use BE report data instead of report actions for Report Previews #65782's original surface): a truthy flag still short-circuits to true exactly as before; only the falsy branch now defers to history instead of asserting "not exported."

    Result: reports marked as manually exported stay cleared from the Awaiting Export queue across refreshes, because the queue derivation now trusts the persisted manual-export action instead of a stale isExportedToIntegration: false flag. The residual pay-side reappearance is the backend persisting REIMBURSED for historical reports and should be confirmed with the reportIDs/accountID in the Slack thread.

    What alternative solutions did you explore? (Optional)

    • Persist the export state in markAsManuallyExported's successData (or a new isManuallyExported flag) so the optimistic value survives. It cannot — the field is backend-owned; the first-time-connection report reload merges the server's isExportedToIntegration: false back over any client value. Only a derivation that respects the persisted action survives the refresh. Adding a second client-only flag also diverges the App from the server's action:export classification.
    • Reconcile the search SNAPSHOT in successData (mirror exportToIntegrationOnSearch). Misdirected for these tabs — they render live useTodoSearchResults data, not the snapshot (SearchResultsProvider.tsx:45-66), so a snapshot edit is cosmetic and the queue re-derives membership from durable state on the next pull regardless.
    • Treat the whole thing as backend-only. Incomplete — it leaves the isExported flag-over-action veto in place, so even after the backend persists the manual export action correctly, the App still re-adds these historical reports to the queue. The backend persistence matters for the pay half, but the export half will not clear durably without the derivation fix above.
  11. github-actions commented on Jul 14, 2026

    @github-actions
    Contributor

    ⚠️ @yusufdeveloper2903 Your proposal is a duplicate of an already existing proposal and has been automatically withdrawn to prevent spam. Please review the existing proposals before submitting a new one.

  12. 36 remaining items

  13. VictoriaExpensify commented on Aug 7, 2026

    @VictoriaExpensify
    Contributor

    Any update on this one @chuckdries and @situchan ?

  14. chuckdries commented on Aug 7, 2026

    @chuckdries
    Contributor

    Trying to repro again today

  15. chuckdries commented on Aug 11, 2026

    @chuckdries
    Contributor

    I still haven't successfully reproduced the issue. I have asked for volunteers to hand off to a BC

  16. self-assigned this
    on Aug 12, 2026
  17. removed
    Help WantedApply this label when an issue is open to proposals by contributors
    on Aug 12, 2026
  18. hungvu193 commented on Aug 12, 2026

    @hungvu193
    Contributor

    I have some bandwidth, so assigning this to myself and I'll try to reproduce it 👀

  19. removed
    ExternalAdded to denote the issue can be worked on by a contributor
    on Aug 12, 2026
  20. hungvu193 commented on Aug 12, 2026

    @hungvu193
    Contributor
    1. Opened workspace A.
    2. Went to the QuickBooks Online workflow in NewDot.
    3. Opened the Awaiting Export tab.

    @MelvinBot How can I find the Awaiting Export tab. inside QuickBooks Online workflow in NewDot.?

  21. MelvinBot commented on Aug 12, 2026

    @MelvinBot
    Contributor

    hungvu193 "Awaiting export" isn't inside the QuickBooks Online connection screens — it's a suggested search in the Reports view, under the Accounting section of the left-hand search menu.

    To get there: Left nav → Reports → in the search list on the left, Accounting → Awaiting export. (It's a saved search for action:export, not a QBO-specific page — the reporter just reached it while cleaning up QBO reports.)

    Why you might not see it: the "Awaiting export" item only renders when, for at least one workspace, you are the configured exporter and that workspace's accounting connection has no sync error — isEligibleForExportSuggestion = isExporter && !hasExportError (SearchUIUtils.ts:1163). So to reproduce, make sure your account is set as the workspace's exporter (Workspace → Accounting → set the preferred exporter to you) on a workspace with a working QBO connection.

    Code references
  22. hungvu193 commented on Aug 14, 2026

    @hungvu193
    Contributor

    I also couldn't able to reproduce this one 🤔 . Can we ask the customer if they're still able to reproduce it?

  23. mvtglobally commented on Aug 14, 2026

    @mvtglobally

    Issue not reproducible during KI retests. (First week)

  24. melvin-bot commented on Sep 7, 2026

    @melvin-bot

    This issue has not been updated in over 15 days. @chuckdries, @hungvu193, @situchan eroding to Monthly issue.

    P.S. Is everyone reading this sure this is really a near-term priority? Be brave: if you disagree, go ahead and close it out. If someone disagrees, they'll reopen it, and if they don't: one less thing to do!

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

Metadata

Metadata

Labels

BugSomething is broken. Auto assigns a BugZero manager.Melvin TriageAutomatic AI Analysis of an issueMonthlyKSv2

Type

No type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions