From 034dd3c0dd2269bca1b2b4f0f270b4a90aae5977 Mon Sep 17 00:00:00 2001 From: dukenv0307 Date: Mon, 5 Oct 2026 10:02:18 +0700 Subject: [PATCH 01/13] Use one export rule across the to-do, the sidebar and the report page --- src/CONST/index.ts | 1 + src/libs/PolicyUtils.ts | 36 +++- src/libs/ReportPrimaryActionUtils.ts | 32 ++- src/libs/ReportUtils.ts | 10 + src/libs/SearchUIUtils.ts | 12 +- src/libs/TodosUtils.ts | 2 +- tests/unit/Search/SearchUIUtilsTest.ts | 52 +++++ tests/unit/TodosUtilsTest.ts | 282 +++++++++++++++++++++++++ tests/unit/useTodoCountsTest.tsx | 7 +- 9 files changed, 419 insertions(+), 15 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 69bd3923072f..3d8256d6b3a0 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1806,6 +1806,7 @@ const CONST = { MOVED_TRANSACTION: 'MOVEDTRANSACTION', UNREPORTED_TRANSACTION: 'UNREPORTEDTRANSACTION', OUTDATED_BANK_ACCOUNT: 'OUTDATEDBANKACCOUNT', // OldDot Action + QUEUED_FOR_EXPORT: 'QUEUEDFOREXPORT', REIMBURSED: 'REIMBURSED', REIMBURSEMENT_ACH_BOUNCE: 'REIMBURSEMENTACHBOUNCE', // OldDot Action REIMBURSEMENT_ACH_CANCELED: 'REIMBURSEMENTACHCANCELED', // OldDot Action diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index 127d09b54957..8237a0d3fa39 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -46,7 +46,7 @@ import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type {NullishDeep, OnyxCollection, OnyxEntry} from 'react-native-onyx'; import type {TupleToUnion, ValueOf} from 'type-fest'; -import {Str} from 'expensify-common'; +import {CONST as COMMON_CONST, Str} from 'expensify-common'; import {getQuickbooksOnlineIntegrationName, isQBORefreshTokenExpiringSoon} from './AccountingUtils'; import {getBankAccountFromID} from './actions/BankAccounts'; @@ -3145,6 +3145,38 @@ function getValidConnectedIntegration(policy: Policy | undefined, connectionName return connectionNames.find((integration) => !!policy?.connections?.[integration] && !isConnectionUnverified(policy, integration)); } +/** + * The accounting method configured on a connection. Each integration keeps it somewhere different, and a connection + * without one reads as accrual, the same default the backend's export rules use. + */ +function getIntegrationAccountingMethod(policy: Policy | undefined, connectedIntegration?: ConnectionName): ValueOf { + const connections = policy?.connections; + const accrual = COMMON_CONST.INTEGRATIONS.ACCOUNTING_METHOD.ACCRUAL; + + switch (connectedIntegration) { + case CONST.POLICY.CONNECTIONS.NAME.NETSUITE: + return connections?.netsuite?.options?.config?.accountingMethod ?? accrual; + case CONST.POLICY.CONNECTIONS.NAME.QBO: + return connections?.quickbooksOnline?.config?.accountingMethod ?? accrual; + case CONST.POLICY.CONNECTIONS.NAME.QBD: + return connections?.quickbooksDesktop?.config?.export?.accountingMethod ?? accrual; + case CONST.POLICY.CONNECTIONS.NAME.XERO: + return connections?.xero?.config?.export?.accountingMethod ?? accrual; + case CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT: + return connections?.intacct?.config?.export?.accountingMethod ?? accrual; + case CONST.POLICY.CONNECTIONS.NAME.RILLET: + return connections?.rillet?.config?.export?.accountingMethod ?? accrual; + case CONST.POLICY.CONNECTIONS.NAME.DUALENTRY: + return connections?.dualEntry?.config?.export?.accountingMethod ?? accrual; + case CONST.POLICY.CONNECTIONS.NAME.CAMPFIRE: + return connections?.campfire?.config?.export?.accountingMethod ?? accrual; + case CONST.POLICY.CONNECTIONS.NAME.BUSINESS_CENTRAL: + return connections?.businessCentral?.config?.export?.accountingMethod ?? accrual; + default: + return accrual; + } +} + function hasIntegrationAutoSync(policy: Policy | undefined, connectedIntegration?: ConnectionName) { if (!isAccountingConnectionName(connectedIntegration)) { return false; @@ -3524,6 +3556,7 @@ function getConnectionExporters(policy: OnyxInputOrEntry): Array) { if (isChatReportArchived) { return false; @@ -285,7 +297,7 @@ function isPrimaryPayAction({ return invoiceReceiverPolicy?.role === CONST.POLICY.ROLE.ADMIN && reimbursableSpend > 0; } -function isExportAction(report: Report, currentUserLogin: string, policy?: Policy, reportActions?: ReportAction[]) { +function isExportAction(report: Report, currentUserLogin: string, policy?: Policy, reportActions?: ReportAction[], options?: IsExportActionOptions) { if (!policy) { return false; } @@ -300,7 +312,11 @@ function isExportAction(report: Report, currentUserLogin: string, policy?: Polic const isAdmin = policy?.role === CONST.POLICY.ROLE.ADMIN; const isReportExporter = isPreferredExporter(policy, currentUserLogin); - if (!isReportExporter && !isAdmin) { + + // The to-dos pass shouldAllowAdmin false because the backend's export to-do is for the exporter alone, while the + // report page offers the action to any admin. + const shouldAllowAdmin = options?.shouldAllowAdmin ?? true; + if (!isReportExporter && !(shouldAllowAdmin && isAdmin)) { return false; } @@ -315,7 +331,12 @@ function isExportAction(report: Report, currentUserLogin: string, policy?: Polic return false; } - if (report.isWaitingOnBankAccount) { + // An export already in flight needs no second one, unless the last one failed + if (isQueuedForExportUtil(reportActions) && !hasExportError) { + return false; + } + + if (report.isWaitingOnBankAccount || isArchivedReport(options?.reportNameValuePairs)) { return false; } @@ -323,6 +344,11 @@ function isExportAction(report: Report, currentUserLogin: string, policy?: Polic const isReportApproved = isReportApprovedUtils({report}); const isReportClosed = isClosedReportUtils(report); + // Cash basis only exports once the report is paid, so approval alone leaves nothing to export + if (getIntegrationAccountingMethod(policy, connectedIntegration) === COMMON_CONST.INTEGRATIONS.ACCOUNTING_METHOD.CASH) { + return isReportReimbursed; + } + if (isReportApproved || isReportReimbursed || isReportClosed) { return true; } diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 3052ec571071..9af2f48c4798 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -13516,6 +13516,15 @@ function getIntegrationIcon( return undefined; } +/** + * Whether an export is already in flight, which the backend records as the report's latest action. + */ +function isQueuedForExport(reportActions?: ReportAction[]): boolean { + const latestAction = reportActions?.reduce((latest, action) => (!latest || action.created > latest.created ? action : latest), undefined); + + return latestAction?.actionName === CONST.REPORT.ACTIONS.TYPE.QUEUED_FOR_EXPORT; +} + function canBeExported(report: OnyxEntry) { if (!report?.statusNum) { return false; @@ -14905,6 +14914,7 @@ export { getIntegrationIcon, canBeExported, isExported, + isQueuedForExport, hasExpensifyGuidesEmails, hasExportError, hasOnlyNonReimbursableTransactions, diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index f2228be83faf..766012c2a5d7 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -72,7 +72,6 @@ import FILTER_KEYS, {AMOUNT_FILTER_KEYS, DATE_FILTER_KEYS, TEXT_FILTER_KEYS} fro import type {HasFilterValues, SearchAdvancedFiltersKey} from '@src/types/form/SearchAdvancedFiltersForm'; import type * as OnyxTypes from '@src/types/onyx'; import type {ViolationsSnapshot} from '@src/types/onyx/OriginalMessage'; -import type {ConnectionName} from '@src/types/onyx/Policy'; import type {SaveSearchItem} from '@src/types/onyx/SaveSearch'; import type SearchResults from '@src/types/onyx/SearchResults'; import type { @@ -109,7 +108,6 @@ import type {CardFeedForDisplay} from './CardFeedUtils'; import type {SearchKey} from './SearchKeyUtils'; import type {SearchTypeMenuItem} from './SearchSuggestionUtils'; -import {hasSynchronizationErrorMessage} from './actions/connections'; import {startMoneyRequest} from './actions/IOU/MoneyRequest'; import {canApproveIOU, canIOUBePaid, canSubmitReport} from './actions/IOU/ReportWorkflow'; import {createTransactionThreadReport} from './actions/Report'; @@ -135,6 +133,7 @@ import { getCommaSeparatedTagNameWithSanitizedColons, getSubmitToAccountID, getTagGLCode, + getValidConnectedIntegration, isArchivedOrPendingDeletePolicy, isControlPolicy, isGroupPolicy, @@ -142,6 +141,7 @@ import { isPolicyAdmin, isPolicyApprover, isPolicyPayer, + isPreferredExporter, isSubmitPolicy, } from './PolicyUtils'; import { @@ -738,16 +738,13 @@ function getSuggestedSearchesVisibility( const isPayer = isPolicyPayer(policy, currentUserEmail); const isAdmin = policy.role === CONST.POLICY.ROLE.ADMIN; const isAuditor = policy.role === CONST.POLICY.ROLE.AUDITOR; - const isExporter = policy.exporter === currentUserEmail; + const isExporter = !!currentUserEmail && isPreferredExporter(policy, currentUserEmail); const isSubmittedTo = !!currentUserEmail && Object.values(policy.employeeList ?? {}).some((employee) => employee.submitsTo === currentUserEmail || employee.forwardsTo === currentUserEmail); const isUserApprover = !!currentUserEmail && isPolicyApprover(policy, currentUserEmail); const isApprovalEnabled = policy.approvalMode ? policy.approvalMode !== CONST.POLICY.APPROVAL_MODE.OPTIONAL : false; - const hasExportError = (Object.keys(policy.connections ?? {}) as ConnectionName[]).some((connection) => { - return hasSynchronizationErrorMessage(policy, connection, false); - }); const isPaymentEnabled = arePaymentsEnabled(policy); const hasVBBA = !!policy.achAccount?.bankAccountID && policy.achAccount.state === CONST.BANK_ACCOUNT.STATE.OPEN; const hasReimburser = !!policy.achAccount?.reimburser; @@ -756,7 +753,8 @@ function getSuggestedSearchesVisibility( const isEligibleForSubmitSuggestion = isGroupPolicyEligible; const isEligibleForPaySuggestion = isPaidPolicy && isPayer; const isPolicyEligibleForApproveSuggestion = isGroupPolicyEligible && isEligibleForApproveSuggestion(policy.approvalMode, isUserApprover, isSubmittedTo); - const isEligibleForExportSuggestion = isExporter && !hasExportError; + // A sync error does not stop the user exporting by hand, and the server still lists those reports + const isEligibleForExportSuggestion = isExporter && !!getValidConnectedIntegration(policy); const isEligibleForStatementsSuggestion = isPaidPolicy && (hasCardFeed || !!defaultExpensifyCard); const isEligibleForUnapprovedCashSuggestion = isPaidPolicy && (isAdmin || isAuditor) && isApprovalEnabled && isPaymentEnabled; const isEligibleForUnapprovedCardSuggestion = isPaidPolicy && (isAdmin || isAuditor) && isApprovalEnabled && (hasCardFeed || !!defaultExpensifyCard); diff --git a/src/libs/TodosUtils.ts b/src/libs/TodosUtils.ts index 2942ae2a3a2a..8ac270e8932f 100644 --- a/src/libs/TodosUtils.ts +++ b/src/libs/TodosUtils.ts @@ -166,7 +166,7 @@ function reportMatchesTodoBucket( ); 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; + return isExportAction(report, login, policy, reportActions, {shouldAllowAdmin: false, reportNameValuePairs: reportNameValuePair}); } default: return false; diff --git a/tests/unit/Search/SearchUIUtilsTest.ts b/tests/unit/Search/SearchUIUtilsTest.ts index 345082275d22..1873b6de8b30 100644 --- a/tests/unit/Search/SearchUIUtilsTest.ts +++ b/tests/unit/Search/SearchUIUtilsTest.ts @@ -8849,6 +8849,13 @@ describe('SearchUIUtils', () => { approvalMode: CONST.POLICY.APPROVAL_MODE.ADVANCED, approver: adminEmail, exporter: adminEmail, + // The export suggestion needs a connection whose exporter is this user + connections: createMock({ + [CONST.POLICY.CONNECTIONS.NAME.QBO]: { + lastSync: {isConnected: true, isSuccessful: true, isAuthenticationError: false, source: 'DIRECT'}, + config: {export: {exporter: adminEmail}}, + }, + }), achAccount: { bankAccountID: 1, reimburser: adminEmail, @@ -8952,6 +8959,13 @@ describe('SearchUIUtils', () => { approvalMode: CONST.POLICY.APPROVAL_MODE.ADVANCED, approver: adminEmail, exporter: adminEmail, + // The export suggestion needs a connection whose exporter is this user + connections: createMock({ + [CONST.POLICY.CONNECTIONS.NAME.QBO]: { + lastSync: {isConnected: true, isSuccessful: true, isAuthenticationError: false, source: 'DIRECT'}, + config: {export: {exporter: adminEmail}}, + }, + }), areWorkflowsEnabled: false, achAccount: { bankAccountID: 1, @@ -9023,6 +9037,13 @@ describe('SearchUIUtils', () => { approvalMode: CONST.POLICY.APPROVAL_MODE.ADVANCED, approver: adminEmail, exporter: adminEmail, + // The export suggestion needs a connection whose exporter is this user + connections: createMock({ + [CONST.POLICY.CONNECTIONS.NAME.QBO]: { + lastSync: {isConnected: true, isSuccessful: true, isAuthenticationError: false, source: 'DIRECT'}, + config: {export: {exporter: adminEmail}}, + }, + }), areWorkflowsEnabled: true, achAccount: { bankAccountID: 1, @@ -9605,6 +9626,13 @@ describe('SearchUIUtils', () => { approvalMode: CONST.POLICY.APPROVAL_MODE.ADVANCED, approver: adminEmail, exporter: adminEmail, + // The export suggestion needs a connection whose exporter is this user + connections: createMock({ + [CONST.POLICY.CONNECTIONS.NAME.QBO]: { + lastSync: {isConnected: true, isSuccessful: true, isAuthenticationError: false, source: 'DIRECT'}, + config: {export: {exporter: adminEmail}}, + }, + }), achAccount: { bankAccountID: 1, reimburser: adminEmail, @@ -9677,6 +9705,13 @@ describe('SearchUIUtils', () => { approvalMode: CONST.POLICY.APPROVAL_MODE.ADVANCED, approver: adminEmail, exporter: adminEmail, + // The export suggestion needs a connection whose exporter is this user + connections: createMock({ + [CONST.POLICY.CONNECTIONS.NAME.QBO]: { + lastSync: {isConnected: true, isSuccessful: true, isAuthenticationError: false, source: 'DIRECT'}, + config: {export: {exporter: adminEmail}}, + }, + }), areExpensifyCardsEnabled: true, areCompanyCardsEnabled: true, achAccount: { @@ -9750,6 +9785,13 @@ describe('SearchUIUtils', () => { approvalMode: CONST.POLICY.APPROVAL_MODE.ADVANCED, approver: adminEmail, exporter: adminEmail, + // The export suggestion needs a connection whose exporter is this user + connections: createMock({ + [CONST.POLICY.CONNECTIONS.NAME.QBO]: { + lastSync: {isConnected: true, isSuccessful: true, isAuthenticationError: false, source: 'DIRECT'}, + config: {export: {exporter: adminEmail}}, + }, + }), reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES, reimburser: adminEmail, achAccount: { @@ -9846,6 +9888,13 @@ describe('SearchUIUtils', () => { approvalMode: CONST.POLICY.APPROVAL_MODE.ADVANCED, approver: adminEmail, exporter: adminEmail, + // The export suggestion needs a connection whose exporter is this user + connections: createMock({ + [CONST.POLICY.CONNECTIONS.NAME.QBO]: { + lastSync: {isConnected: true, isSuccessful: true, isAuthenticationError: false, source: 'DIRECT'}, + config: {export: {exporter: adminEmail}}, + }, + }), reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES, achAccount: { bankAccountID: 1, @@ -10370,6 +10419,7 @@ describe('SearchUIUtils', () => { connections: createMock({ [CONST.POLICY.CONNECTIONS.NAME.NETSUITE]: { verified: false, + options: {config: {exporter: adminEmail}}, lastSync: { errorDate: new Date().toISOString(), errorMessage: 'Error', @@ -10387,6 +10437,8 @@ describe('SearchUIUtils', () => { const response = SearchUIUtils.getSuggestedSearchesVisibility(adminEmail, {}, policies, undefined); expect(response.visibility.export).toBe(false); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + policies[policyKey]!.connections![CONST.POLICY.CONNECTIONS.NAME.NETSUITE]!.verified = true; // eslint-disable-next-line @typescript-eslint/no-non-null-assertion policies[policyKey]!.connections![CONST.POLICY.CONNECTIONS.NAME.NETSUITE]!.lastSync = { errorDate: '', diff --git a/tests/unit/TodosUtilsTest.ts b/tests/unit/TodosUtilsTest.ts index 1770212e3948..bd64cef94b9e 100644 --- a/tests/unit/TodosUtilsTest.ts +++ b/tests/unit/TodosUtilsTest.ts @@ -1,9 +1,12 @@ +import {getReportPrimaryAction} from '@libs/ReportPrimaryActionUtils'; +import {getSuggestedSearchesVisibility} from '@libs/SearchUIUtils'; import createTodosReportsAndTransactions, {buildTransactionsByReportID, getTodoReportsForSearchKey} from '@libs/TodosUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Policy, Report, ReportAction, Transaction} from '@src/types/onyx'; +import {CONST as COMMON_CONST} from 'expensify-common'; import Onyx from 'react-native-onyx'; import createMock from '../utils/createMock'; @@ -660,3 +663,282 @@ describe('TodosUtils', () => { }); }); }); + +describe('TodosUtils export bucket', () => { + const EXPORT_POLICY_ID = 'policy_export'; + const EXPORT_REPORT_ID = 'report_export'; + const OTHER_USER_EMAIL = 'other@mail.com'; + + // Every connection but NetSuite counts as verified only once it has a lastSync + const LAST_SYNC = {isConnected: true, isSuccessful: true, isAuthenticationError: false, source: 'DIRECT'}; + + // Each integration keeps its exporter in a different place, so the rule has to read all of them. + const EXPORTER_BUILDERS: Array<[string, (exporter: string) => Policy['connections']]> = [ + [CONST.POLICY.CONNECTIONS.NAME.QBO, (exporter) => ({quickbooksOnline: {lastSync: LAST_SYNC, config: {export: {exporter}}}}) as Policy['connections']], + [CONST.POLICY.CONNECTIONS.NAME.NETSUITE, (exporter) => ({netsuite: {options: {config: {exporter}}}}) as Policy['connections']], + [CONST.POLICY.CONNECTIONS.NAME.XERO, (exporter) => ({xero: {lastSync: LAST_SYNC, config: {export: {exporter}}}}) as Policy['connections']], + [CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT, (exporter) => ({intacct: {lastSync: LAST_SYNC, config: {export: {exporter}}}}) as Policy['connections']], + [CONST.POLICY.CONNECTIONS.NAME.QBD, (exporter) => ({quickbooksDesktop: {lastSync: LAST_SYNC, config: {export: {exporter}}}}) as Policy['connections']], + [CONST.POLICY.CONNECTIONS.NAME.RILLET, (exporter) => ({rillet: {lastSync: LAST_SYNC, config: {export: {exporter}}}}) as Policy['connections']], + [CONST.POLICY.CONNECTIONS.NAME.DUALENTRY, (exporter) => ({dualEntry: {lastSync: LAST_SYNC, config: {export: {exporter}}}}) as Policy['connections']], + [CONST.POLICY.CONNECTIONS.NAME.CAMPFIRE, (exporter) => ({campfire: {lastSync: LAST_SYNC, config: {export: {exporter}}}}) as Policy['connections']], + [ + CONST.POLICY.CONNECTIONS.NAME.CERTINIA, + (exporter) => ({financialforce: {lastSync: LAST_SYNC, config: {credentials: {enterpriseUrl: 'https://example.my.salesforce.com'}, export: {exporter}}}}) as Policy['connections'], + ], + ]; + + type ExportPolicyOptions = { + policyExporter?: string; + connectionExporter?: string; + isAutoSyncEnabled?: boolean; + role?: Policy['role']; + }; + + const createExportPolicy = ({ + policyExporter = '', + connectionExporter = CURRENT_USER_EMAIL, + isAutoSyncEnabled = false, + role = CONST.POLICY.ROLE.ADMIN, + }: ExportPolicyOptions = {}): Policy => + createMock({ + ...createMockPolicy(EXPORT_POLICY_ID, {role, exporter: policyExporter}), + connections: { + [CONST.POLICY.CONNECTIONS.NAME.QBO]: { + lastSync: {isConnected: true, isSuccessful: true, isAuthenticationError: false, source: 'DIRECT'}, + config: { + autoSync: {jobID: 'job123', enabled: isAutoSyncEnabled}, + export: {exporter: connectionExporter}, + }, + }, + }, + }); + + const createApprovedReport = () => + createMockReport(EXPORT_REPORT_ID, { + policyID: EXPORT_POLICY_ID, + stateNum: CONST.REPORT.STATE_NUM.APPROVED, + statusNum: CONST.REPORT.STATUS_NUM.APPROVED, + ownerAccountID: OTHER_USER_ACCOUNT_ID, + }); + + const buildParams = ( + policy: Policy, + report = createApprovedReport(), + allReportActions?: Record>, + allReportNameValuePairs?: Record, + ) => ({ + ...baseParams, + allReports: toReportsCollection([report]), + allTransactions: toTransactionsCollection([createMockTransaction(`trans_${report.reportID}`, report.reportID)]), + allPolicies: toPoliciesCollection([policy]), + allReportActions, + allReportNameValuePairs, + }); + + const getExportedReportIDs = ( + policy: Policy, + report = createApprovedReport(), + allReportActions?: Record>, + allReportNameValuePairs?: Record, + ) => createTodosReportsAndTransactions(buildParams(policy, report, allReportActions, allReportNameValuePairs)).reportsToExport.map((exportedReport) => exportedReport.reportID); + + describe('exporter', () => { + it('includes the report when policy.exporter is the current user', () => { + expect(getExportedReportIDs(createExportPolicy({policyExporter: CURRENT_USER_EMAIL}))).toEqual([EXPORT_REPORT_ID]); + }); + + it('excludes the report when both the policy and connection exporters are someone else', () => { + expect(getExportedReportIDs(createExportPolicy({policyExporter: OTHER_USER_EMAIL, connectionExporter: OTHER_USER_EMAIL}))).toEqual([]); + }); + + it('includes the report when policy.exporter is empty and the connection exporter is the current user', () => { + expect(getExportedReportIDs(createExportPolicy({policyExporter: ''}))).toEqual([EXPORT_REPORT_ID]); + }); + + it.each(EXPORTER_BUILDERS)('reads the connection exporter for %s while policy.exporter is empty', (_connectionName, buildConnections) => { + const policy = createMock({ + ...createMockPolicy(EXPORT_POLICY_ID, {role: CONST.POLICY.ROLE.ADMIN, exporter: ''}), + connections: buildConnections(CURRENT_USER_EMAIL), + }); + + expect(getExportedReportIDs(policy)).toEqual([EXPORT_REPORT_ID]); + }); + + it('excludes the report for an admin who is not an exporter', () => { + expect(getExportedReportIDs(createExportPolicy({connectionExporter: OTHER_USER_EMAIL, role: CONST.POLICY.ROLE.ADMIN}))).toEqual([]); + }); + + it('includes the report for a non-admin who is the exporter', () => { + expect(getExportedReportIDs(createExportPolicy({role: CONST.POLICY.ROLE.USER}))).toEqual([EXPORT_REPORT_ID]); + }); + }); + + describe('auto-sync', () => { + it('excludes the report when auto-sync is on, since auto-sync exports it', () => { + expect(getExportedReportIDs(createExportPolicy({isAutoSyncEnabled: true}))).toEqual([]); + }); + + it('includes the report when auto-sync is on but the export failed', () => { + const report = {...createApprovedReport(), hasExportError: true}; + + expect(getExportedReportIDs(createExportPolicy({isAutoSyncEnabled: true}), report)).toEqual([EXPORT_REPORT_ID]); + }); + + it('includes the report when auto-sync is off', () => { + expect(getExportedReportIDs(createExportPolicy({isAutoSyncEnabled: false}))).toEqual([EXPORT_REPORT_ID]); + }); + }); + + describe('report state', () => { + it('excludes a report that was already exported', () => { + const report = {...createApprovedReport(), isExportedToIntegration: true}; + + expect(getExportedReportIDs(createExportPolicy(), report)).toEqual([]); + }); + + it('excludes a report whose export is already queued', () => { + const queuedAction = createMock({ + reportActionID: 'queued_action', + actionName: CONST.REPORT.ACTIONS.TYPE.QUEUED_FOR_EXPORT, + created: '2024-06-01 00:00:00.000', + }); + + expect(getExportedReportIDs(createExportPolicy(), createApprovedReport(), {[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${EXPORT_REPORT_ID}`]: {queued_action: queuedAction}})).toEqual( + [], + ); + }); + + it('excludes an archived report', () => { + // The to-do reads the archive flag from the report's chat, which is where it lives + const archivedNVPs = {[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}chat_${EXPORT_REPORT_ID}`]: {private_isArchived: '2024-01-01 00:00:00'}}; + + expect(getExportedReportIDs(createExportPolicy(), createApprovedReport(), undefined, archivedNVPs)).toEqual([]); + }); + + it('excludes a report waiting on a bank account', () => { + const waitingReport = {...createApprovedReport(), isWaitingOnBankAccount: true}; + + expect(getExportedReportIDs(createExportPolicy(), waitingReport)).toEqual([]); + }); + + // App requires a verified connection while the backend does not, so this is a known and accepted difference + it('excludes a report whose only connection is unverified', () => { + const policy = createMock({ + ...createMockPolicy(EXPORT_POLICY_ID, {role: CONST.POLICY.ROLE.ADMIN, exporter: ''}), + connections: { + [CONST.POLICY.CONNECTIONS.NAME.NETSUITE]: {verified: false, options: {config: {exporter: CURRENT_USER_EMAIL}}}, + } as Policy['connections'], + }); + + expect(getExportedReportIDs(policy)).toEqual([]); + }); + + it('excludes an approved report on a cash-basis connection until it is paid', () => { + const policy = createExportPolicy(); + const cashPolicy = createMock({ + ...policy, + connections: { + [CONST.POLICY.CONNECTIONS.NAME.QBO]: { + ...policy.connections?.quickbooksOnline, + config: {...policy.connections?.quickbooksOnline?.config, accountingMethod: COMMON_CONST.INTEGRATIONS.ACCOUNTING_METHOD.CASH}, + }, + }, + }); + + expect(getExportedReportIDs(cashPolicy)).toEqual([]); + + const paidReport = {...createApprovedReport(), statusNum: CONST.REPORT.STATUS_NUM.REIMBURSED}; + expect(getExportedReportIDs(cashPolicy, paidReport)).toEqual([EXPORT_REPORT_ID]); + }); + }); +}); + +describe('export rule across surfaces', () => { + const SURFACE_POLICY_ID = 'policy_surface'; + const SURFACE_REPORT_ID = 'report_surface'; + + const createSurfacePolicy = ( + isAutoSyncEnabled: boolean, + {connectionExporter = CURRENT_USER_EMAIL, role = CONST.POLICY.ROLE.ADMIN}: {connectionExporter?: string; role?: Policy['role']} = {}, + ): Policy => + createMock({ + ...createMockPolicy(SURFACE_POLICY_ID, {role, exporter: ''}), + connections: { + [CONST.POLICY.CONNECTIONS.NAME.QBO]: { + lastSync: {isConnected: true, isSuccessful: true, isAuthenticationError: false, source: 'DIRECT'}, + config: { + autoSync: {jobID: 'job123', enabled: isAutoSyncEnabled}, + export: {exporter: connectionExporter}, + }, + }, + }, + }); + + const createSurfaceReport = () => + createMockReport(SURFACE_REPORT_ID, { + policyID: SURFACE_POLICY_ID, + stateNum: CONST.REPORT.STATE_NUM.APPROVED, + statusNum: CONST.REPORT.STATUS_NUM.APPROVED, + ownerAccountID: OTHER_USER_ACCOUNT_ID, + }); + + const getSurfaceResults = (policy: Policy) => { + const report = createSurfaceReport(); + const transaction = createMockTransaction(`trans_${SURFACE_REPORT_ID}`, SURFACE_REPORT_ID); + + const isInTodoBucket = createTodosReportsAndTransactions({ + ...baseParams, + allReports: toReportsCollection([report]), + allTransactions: toTransactionsCollection([transaction]), + allPolicies: toPoliciesCollection([policy]), + }).reportsToExport.some((exportedReport) => exportedReport.reportID === SURFACE_REPORT_ID); + + const isSidebarItemVisible = getSuggestedSearchesVisibility(CURRENT_USER_EMAIL, {}, toPoliciesCollection([policy]), undefined).visibility[CONST.SEARCH.SEARCH_KEYS.EXPORT]; + + const primaryAction = getReportPrimaryAction({ + rules: undefined, + currentUserLogin: CURRENT_USER_EMAIL, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + report, + ownerLogin: '', + chatReport: createMockReport(`chat_${SURFACE_REPORT_ID}`, {type: CONST.REPORT.TYPE.CHAT}), + reportTransactions: [transaction], + violations: {}, + bankAccountList: {}, + policy, + isChatReportArchived: false, + }); + + return {isInTodoBucket, isSidebarItemVisible, isPrimaryExportAction: primaryAction === CONST.REPORT.PRIMARY_ACTIONS.EXPORT_TO_ACCOUNTING}; + }; + + it('keeps the report page action for an admin who is not the exporter, but not the to-do', () => { + const results = getSurfaceResults(createSurfacePolicy(false, {connectionExporter: 'someoneelse@mail.com'})); + + // The to-do and the sidebar belong to the exporter, while the report page lets any admin export + expect(results.isInTodoBucket).toBe(false); + expect(results.isSidebarItemVisible).toBe(false); + expect(results.isPrimaryExportAction).toBe(true); + }); + + it('offers the report nowhere for a member who is neither the exporter nor an admin', () => { + const results = getSurfaceResults(createSurfacePolicy(false, {connectionExporter: 'someoneelse@mail.com', role: CONST.POLICY.ROLE.USER})); + + expect(results).toEqual({isInTodoBucket: false, isSidebarItemVisible: false, isPrimaryExportAction: false}); + }); + + it('shows the report in the to-do, the sidebar and the report page when it needs a manual export', () => { + expect(getSurfaceResults(createSurfacePolicy(false))).toEqual({isInTodoBucket: true, isSidebarItemVisible: true, isPrimaryExportAction: true}); + }); + + it('drops the report from the to-do and the report page when auto-sync exports it', () => { + const results = getSurfaceResults(createSurfacePolicy(true)); + + expect(results.isInTodoBucket).toBe(false); + expect(results.isPrimaryExportAction).toBe(false); + + // The sidebar item stays, since the user is still an exporter and can open an empty list + expect(results.isSidebarItemVisible).toBe(true); + }); +}); diff --git a/tests/unit/useTodoCountsTest.tsx b/tests/unit/useTodoCountsTest.tsx index d9b73bb5ffc5..dc3f6581ee1c 100644 --- a/tests/unit/useTodoCountsTest.tsx +++ b/tests/unit/useTodoCountsTest.tsx @@ -437,7 +437,7 @@ describe('useTodoCounts', () => { }); }); - it('does not count export reports when the user is a connection-level exporter but not policy.exporter', async () => { + it('counts export reports when the user is a connection-level exporter but not policy.exporter', async () => { const EXPORT_POLICY_ID = 'policy_export_mismatch'; const reportID = 'export_mismatch_report'; @@ -448,7 +448,8 @@ describe('useTodoCounts', () => { ownerAccountID: OTHER_USER_ACCOUNT_ID, isWaitingOnBankAccount: false, }); - // policy.exporter is someone else, even though the connection-level exporter is the current user. + // policy.exporter is someone else, but the connection-level exporter is the current user, which is what the + // server's export to-do matches on. const policy = createPolicyWithQBOConnection(EXPORT_POLICY_ID, {policyExporter: 'someone-else@mail.com', connectionExporter: CURRENT_USER_EMAIL}); await Onyx.set(ONYXKEYS.SESSION, {email: CURRENT_USER_EMAIL, accountID: CURRENT_USER_ACCOUNT_ID}); @@ -458,7 +459,7 @@ describe('useTodoCounts', () => { const {result} = await renderTodoCounts(); - expect(result.current.counts[CONST.SEARCH.SEARCH_KEYS.EXPORT]).toBe(0); + expect(result.current.counts[CONST.SEARCH.SEARCH_KEYS.EXPORT]).toBe(1); }); describe('uses primary login from personalDetailsList', () => { From 6973914521a3b0c5e0c1db9a143296c920d91997 Mon Sep 17 00:00:00 2001 From: dukenv0307 Date: Mon, 5 Oct 2026 11:05:03 +0700 Subject: [PATCH 02/13] lint fix --- src/libs/TodosUtils.ts | 8 +++++++- tests/unit/TodosUtilsTest.ts | 31 +++++++++++++++---------------- 2 files changed, 22 insertions(+), 17 deletions(-) diff --git a/src/libs/TodosUtils.ts b/src/libs/TodosUtils.ts index 8ac270e8932f..7b97c57efeb5 100644 --- a/src/libs/TodosUtils.ts +++ b/src/libs/TodosUtils.ts @@ -73,6 +73,9 @@ type TodoBucketContext = { /** The report's name-value pair (looked up by `chatReportID`) - feeds the submit/pay workflow checks */ reportNameValuePair: OnyxEntry; + /** The expense report's own name-value pair - the export predicate reads its archive flag, as the server does */ + expenseReportNameValuePair: OnyxEntry; + /** The report's transactions - inspected for amount, reimbursability, and hold status */ reportTransactions: Transaction[]; @@ -118,6 +121,7 @@ function reportMatchesTodoBucket( { policy, reportNameValuePair, + expenseReportNameValuePair, reportTransactions, reportMetadata, allReportActions, @@ -166,7 +170,7 @@ function reportMatchesTodoBucket( ); case CONST.SEARCH.SEARCH_KEYS.EXPORT: { const reportActions = Object.values(allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`] ?? []); - return isExportAction(report, login, policy, reportActions, {shouldAllowAdmin: false, reportNameValuePairs: reportNameValuePair}); + return isExportAction(report, login, policy, reportActions, {shouldAllowAdmin: false, reportNameValuePairs: expenseReportNameValuePair}); } default: return false; @@ -213,6 +217,7 @@ function createTodosReportsAndTransactions({ const context: TodoBucketContext = { policy: allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`], reportNameValuePair: allReportNameValuePairs?.[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report.chatReportID}`], + expenseReportNameValuePair: allReportNameValuePairs?.[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report.reportID}`], reportTransactions, reportMetadata: allReportMetadata?.[`${ONYXKEYS.COLLECTION.REPORT_METADATA}${report.reportID}`], allReportActions, @@ -277,6 +282,7 @@ function getTodoReportsForSearchKey( const context: TodoBucketContext = { policy: allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`], reportNameValuePair: allReportNameValuePairs?.[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report.chatReportID}`], + expenseReportNameValuePair: allReportNameValuePairs?.[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report.reportID}`], reportTransactions, reportMetadata: allReportMetadata?.[`${ONYXKEYS.COLLECTION.REPORT_METADATA}${report.reportID}`], allReportActions, diff --git a/tests/unit/TodosUtilsTest.ts b/tests/unit/TodosUtilsTest.ts index bd64cef94b9e..c97e93e51a12 100644 --- a/tests/unit/TodosUtilsTest.ts +++ b/tests/unit/TodosUtilsTest.ts @@ -5,6 +5,7 @@ import createTodosReportsAndTransactions, {buildTransactionsByReportID, getTodoR import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Policy, Report, ReportAction, Transaction} from '@src/types/onyx'; +import type {Connections} from '@src/types/onyx/Policy'; import {CONST as COMMON_CONST} from 'expensify-common'; import Onyx from 'react-native-onyx'; @@ -674,17 +675,17 @@ describe('TodosUtils export bucket', () => { // Each integration keeps its exporter in a different place, so the rule has to read all of them. const EXPORTER_BUILDERS: Array<[string, (exporter: string) => Policy['connections']]> = [ - [CONST.POLICY.CONNECTIONS.NAME.QBO, (exporter) => ({quickbooksOnline: {lastSync: LAST_SYNC, config: {export: {exporter}}}}) as Policy['connections']], - [CONST.POLICY.CONNECTIONS.NAME.NETSUITE, (exporter) => ({netsuite: {options: {config: {exporter}}}}) as Policy['connections']], - [CONST.POLICY.CONNECTIONS.NAME.XERO, (exporter) => ({xero: {lastSync: LAST_SYNC, config: {export: {exporter}}}}) as Policy['connections']], - [CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT, (exporter) => ({intacct: {lastSync: LAST_SYNC, config: {export: {exporter}}}}) as Policy['connections']], - [CONST.POLICY.CONNECTIONS.NAME.QBD, (exporter) => ({quickbooksDesktop: {lastSync: LAST_SYNC, config: {export: {exporter}}}}) as Policy['connections']], - [CONST.POLICY.CONNECTIONS.NAME.RILLET, (exporter) => ({rillet: {lastSync: LAST_SYNC, config: {export: {exporter}}}}) as Policy['connections']], - [CONST.POLICY.CONNECTIONS.NAME.DUALENTRY, (exporter) => ({dualEntry: {lastSync: LAST_SYNC, config: {export: {exporter}}}}) as Policy['connections']], - [CONST.POLICY.CONNECTIONS.NAME.CAMPFIRE, (exporter) => ({campfire: {lastSync: LAST_SYNC, config: {export: {exporter}}}}) as Policy['connections']], + [CONST.POLICY.CONNECTIONS.NAME.QBO, (exporter) => createMock({quickbooksOnline: {lastSync: LAST_SYNC, config: {export: {exporter}}}})], + [CONST.POLICY.CONNECTIONS.NAME.NETSUITE, (exporter) => createMock({netsuite: {options: {config: {exporter}}}})], + [CONST.POLICY.CONNECTIONS.NAME.XERO, (exporter) => createMock({xero: {lastSync: LAST_SYNC, config: {export: {exporter}}}})], + [CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT, (exporter) => createMock({intacct: {lastSync: LAST_SYNC, config: {export: {exporter}}}})], + [CONST.POLICY.CONNECTIONS.NAME.QBD, (exporter) => createMock({quickbooksDesktop: {lastSync: LAST_SYNC, config: {export: {exporter}}}})], + [CONST.POLICY.CONNECTIONS.NAME.RILLET, (exporter) => createMock({rillet: {lastSync: LAST_SYNC, config: {export: {exporter}}}})], + [CONST.POLICY.CONNECTIONS.NAME.DUALENTRY, (exporter) => createMock({dualEntry: {lastSync: LAST_SYNC, config: {export: {exporter}}}})], + [CONST.POLICY.CONNECTIONS.NAME.CAMPFIRE, (exporter) => createMock({campfire: {lastSync: LAST_SYNC, config: {export: {exporter}}}})], [ CONST.POLICY.CONNECTIONS.NAME.CERTINIA, - (exporter) => ({financialforce: {lastSync: LAST_SYNC, config: {credentials: {enterpriseUrl: 'https://example.my.salesforce.com'}, export: {exporter}}}}) as Policy['connections'], + (exporter) => createMock({financialforce: {lastSync: LAST_SYNC, config: {credentials: {enterpriseUrl: 'https://example.my.salesforce.com'}, export: {exporter}}}}), ], ]; @@ -804,14 +805,12 @@ describe('TodosUtils export bucket', () => { created: '2024-06-01 00:00:00.000', }); - expect(getExportedReportIDs(createExportPolicy(), createApprovedReport(), {[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${EXPORT_REPORT_ID}`]: {queued_action: queuedAction}})).toEqual( - [], - ); + expect(getExportedReportIDs(createExportPolicy(), createApprovedReport(), {[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${EXPORT_REPORT_ID}`]: {queuedAction}})).toEqual([]); }); it('excludes an archived report', () => { - // The to-do reads the archive flag from the report's chat, which is where it lives - const archivedNVPs = {[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}chat_${EXPORT_REPORT_ID}`]: {private_isArchived: '2024-01-01 00:00:00'}}; + // The server reads the archive flag from the expense report itself, not from its chat + const archivedNVPs = {[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${EXPORT_REPORT_ID}`]: {private_isArchived: '2024-01-01 00:00:00'}}; expect(getExportedReportIDs(createExportPolicy(), createApprovedReport(), undefined, archivedNVPs)).toEqual([]); }); @@ -826,9 +825,9 @@ describe('TodosUtils export bucket', () => { it('excludes a report whose only connection is unverified', () => { const policy = createMock({ ...createMockPolicy(EXPORT_POLICY_ID, {role: CONST.POLICY.ROLE.ADMIN, exporter: ''}), - connections: { + connections: createMock({ [CONST.POLICY.CONNECTIONS.NAME.NETSUITE]: {verified: false, options: {config: {exporter: CURRENT_USER_EMAIL}}}, - } as Policy['connections'], + }), }); expect(getExportedReportIDs(policy)).toEqual([]); From 3ea555236668cc76e34ccd10988bba1ee89a3233 Mon Sep 17 00:00:00 2001 From: dukenv0307 Date: Mon, 5 Oct 2026 11:14:49 +0700 Subject: [PATCH 03/13] ts fix --- tests/unit/TodosUtilsTest.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/TodosUtilsTest.ts b/tests/unit/TodosUtilsTest.ts index c97e93e51a12..761cf5c399d9 100644 --- a/tests/unit/TodosUtilsTest.ts +++ b/tests/unit/TodosUtilsTest.ts @@ -671,7 +671,7 @@ describe('TodosUtils export bucket', () => { const OTHER_USER_EMAIL = 'other@mail.com'; // Every connection but NetSuite counts as verified only once it has a lastSync - const LAST_SYNC = {isConnected: true, isSuccessful: true, isAuthenticationError: false, source: 'DIRECT'}; + const LAST_SYNC = {isConnected: true, isSuccessful: true, isAuthenticationError: false, source: 'DIRECT'} as const; // Each integration keeps its exporter in a different place, so the rule has to read all of them. const EXPORTER_BUILDERS: Array<[string, (exporter: string) => Policy['connections']]> = [ From 32c01d1288f15516ecab2d727bde4b4be668b34f Mon Sep 17 00:00:00 2001 From: dukenv0307 Date: Mon, 5 Oct 2026 14:46:34 +0700 Subject: [PATCH 04/13] create getLastApprovalResetTime func --- src/libs/ReportUtils.ts | 43 ++++++++++++++++++++---------------- tests/unit/TodosUtilsTest.ts | 20 +++++++++++++++++ 2 files changed, 44 insertions(+), 19 deletions(-) diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 9af2f48c4798..c660a8c3d46a 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -13550,6 +13550,23 @@ function getIntegrationNameFromExportMessage(reportActions: OnyxEntry([ + CONST.REPORT.ACTIONS.TYPE.REJECTED_TO_SUBMITTER, + CONST.REPORT.ACTIONS.TYPE.RETRACTED, + CONST.REPORT.ACTIONS.TYPE.SUBMITTED, + CONST.REPORT.ACTIONS.TYPE.ACTION_DELEGATE_SUBMIT, + CONST.REPORT.ACTIONS.TYPE.REOPENED, + CONST.REPORT.ACTIONS.TYPE.UNAPPROVED, +]); + +/** + * The `created` time of the report's most recent approval reset, or an empty string when it has never been reset. + */ +function getLastApprovalResetTime(reportActionList: ReportAction[]): string { + return reportActionList.reduce((latest, action) => (RESET_APPROVAL_ACTION_TYPES.has(action.actionName) && action.created > latest ? action.created : latest), ''); +} + function isExported(reportActions: OnyxEntry | ReportAction[], report?: OnyxEntry): boolean { if (report?.isExportedToIntegration !== undefined) { return report.isExportedToIntegration; @@ -13562,26 +13579,12 @@ function isExported(reportActions: OnyxEntry | ReportAction[], re const reportActionList = Array.isArray(reportActions) ? reportActions : Object.values(reportActions); - // Actions that reset the approval state and invalidate previous exports - const resetApprovalActionTypes = new Set([ - CONST.REPORT.ACTIONS.TYPE.REJECTED_TO_SUBMITTER, - CONST.REPORT.ACTIONS.TYPE.RETRACTED, - CONST.REPORT.ACTIONS.TYPE.SUBMITTED, - CONST.REPORT.ACTIONS.TYPE.ACTION_DELEGATE_SUBMIT, - CONST.REPORT.ACTIONS.TYPE.REOPENED, - CONST.REPORT.ACTIONS.TYPE.UNAPPROVED, - ]); const validExportLabels = new Set(Object.values(CONST.EXPORT_LABELS)); - let lastResetCreated = ''; + const lastResetCreated = getLastApprovalResetTime(reportActionList); let lastSuccessfulExportCreated = ''; for (const action of reportActionList) { - if (resetApprovalActionTypes.has(action.actionName)) { - if (action.created > lastResetCreated) { - lastResetCreated = action.created; - } - } if (isExportIntegrationAction(action)) { const originalMessage = getOriginalMessage(action); const label = originalMessage?.label; @@ -13613,11 +13616,13 @@ function hasExportError(reportActions: OnyxEntry | ReportAction[] return false; } - if (Array.isArray(reportActions)) { - return reportActions.some((action) => isIntegrationMessageAction(action) && !getOriginalMessage(action)?.result?.reconciled); - } + const reportActionList = Array.isArray(reportActions) ? reportActions : Object.values(reportActions); + + // A message older than the last reset describes an export the report has since moved past, which is how the + // server reads it too + const lastResetCreated = getLastApprovalResetTime(reportActionList); - return Object.values(reportActions).some((action) => isIntegrationMessageAction(action) && !getOriginalMessage(action)?.result?.reconciled); + return reportActionList.some((action) => isIntegrationMessageAction(action) && !getOriginalMessage(action)?.result?.reconciled && action.created > lastResetCreated); } function doesReportContainRequestsFromMultipleUsers(iouReport: OnyxEntry, shouldExcludeDeletedTransactions = false): boolean { diff --git a/tests/unit/TodosUtilsTest.ts b/tests/unit/TodosUtilsTest.ts index 761cf5c399d9..6741dd076ad4 100644 --- a/tests/unit/TodosUtilsTest.ts +++ b/tests/unit/TodosUtilsTest.ts @@ -786,6 +786,26 @@ describe('TodosUtils export bucket', () => { expect(getExportedReportIDs(createExportPolicy({isAutoSyncEnabled: true}), report)).toEqual([EXPORT_REPORT_ID]); }); + it('ignores a failure message that predates the last approval reset', () => { + const actionsFor = (actions: Record) => ({[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${EXPORT_REPORT_ID}`]: actions}); + const failureMessage = createMock({ + reportActionID: 'failureMessage', + actionName: CONST.REPORT.ACTIONS.TYPE.INTEGRATIONS_MESSAGE, + created: '2024-06-01 00:00:00.000', + }); + const unapproved = createMock({ + reportActionID: 'unapproved', + actionName: CONST.REPORT.ACTIONS.TYPE.UNAPPROVED, + created: '2024-06-02 00:00:00.000', + }); + + // The failure keeps the report actionable while it is the report's latest word on the export + expect(getExportedReportIDs(createExportPolicy({isAutoSyncEnabled: true}), createApprovedReport(), actionsFor({failureMessage}))).toEqual([EXPORT_REPORT_ID]); + + // Unapproving and approving again moves the report past that failure, so auto-sync owns it once more + expect(getExportedReportIDs(createExportPolicy({isAutoSyncEnabled: true}), createApprovedReport(), actionsFor({failureMessage, unapproved}))).toEqual([]); + }); + it('includes the report when auto-sync is off', () => { expect(getExportedReportIDs(createExportPolicy({isAutoSyncEnabled: false}))).toEqual([EXPORT_REPORT_ID]); }); From 920bf86e10701a9e03d311063b271abadf8e8fb3 Mon Sep 17 00:00:00 2001 From: dukenv0307 Date: Tue, 13 Oct 2026 10:36:15 +0700 Subject: [PATCH 05/13] fix some bot comments --- src/libs/PolicyUtils/index.ts | 2 +- src/libs/ReportPrimaryActionUtils.ts | 4 ++-- src/libs/ReportUtils.ts | 7 +++++-- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/libs/PolicyUtils/index.ts b/src/libs/PolicyUtils/index.ts index edf69dacb53c..d950919124ed 100644 --- a/src/libs/PolicyUtils/index.ts +++ b/src/libs/PolicyUtils/index.ts @@ -38,7 +38,7 @@ import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type {NullishDeep, OnyxCollection, OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; -import {CONST as COMMON_CONST, Str} from 'expensify-common'; +import {Str} from 'expensify-common'; import {getAccountingConnectionNames, hasAccountingFeatureConnection} from './connections'; import {canMemberWrite, getPolicyRole, isPolicyAdmin, isPolicyApprover, isPolicyOwner, isPolicyUser} from './permissions'; diff --git a/src/libs/ReportPrimaryActionUtils.ts b/src/libs/ReportPrimaryActionUtils.ts index 1d68169dc3dc..42363c088c3f 100644 --- a/src/libs/ReportPrimaryActionUtils.ts +++ b/src/libs/ReportPrimaryActionUtils.ts @@ -335,7 +335,7 @@ function isExportAction(report: Report, currentUserLogin: string, policy?: Polic } // An export already in flight needs no second one, unless the last one failed - if (isQueuedForExportUtil(reportActions) && !hasExportError) { + if (isQueuedForExportUtil(reportActions, report) && !hasExportError) { return false; } @@ -602,7 +602,7 @@ function getReportPrimaryAction(params: GetReportPrimaryActionParams): ValueOf): boolean { const latestAction = reportActions?.reduce((latest, action) => (!latest || action.created > latest.created ? action : latest), undefined); + if (latestAction) { + return latestAction.actionName === CONST.REPORT.ACTIONS.TYPE.QUEUED_FOR_EXPORT; + } - return latestAction?.actionName === CONST.REPORT.ACTIONS.TYPE.QUEUED_FOR_EXPORT; + return report?.lastActionType === CONST.REPORT.ACTIONS.TYPE.QUEUED_FOR_EXPORT; } function canBeExported(report: OnyxEntry) { From 0b3aa80d9f2eb59c31668107375fde16f05a5c58 Mon Sep 17 00:00:00 2001 From: dukenv0307 Date: Tue, 13 Oct 2026 11:05:20 +0700 Subject: [PATCH 06/13] Pass expense-report NVPs into the search export check --- src/libs/SearchUIUtils.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index 50c757b91e0f..674c772259b1 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -2159,7 +2159,8 @@ function getActions( } const policy = getPolicyFromKey(data, report); - const isExportAvailable = !isTransaction && isExportAction(report, currentUserLogin, policy, reportActions); + const reportNVP = getReportNameValuePairsFromKey(data, report); + const isExportAvailable = !isTransaction && isExportAction(report, currentUserLogin, policy, reportActions, {reportNameValuePairs: reportNVP}); if (isSettled(report) && !isExportAvailable) { return [CONST.SEARCH.ACTION_TYPES.PAID]; @@ -2189,8 +2190,6 @@ function getActions( allReportTransactions = transaction ? [transaction] : []; } - const reportNVP = getReportNameValuePairsFromKey(data, report); - const isChatReportArchived = isArchivedReport(data[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report.chatReportID}`]); // Only invoice rows reach here. They get report actions only when the invoice has a single line. From 99458cfb30b178f568f00ea261d1e6c9b41a742e Mon Sep 17 00:00:00 2001 From: dukenv0307 Date: Tue, 13 Oct 2026 15:40:07 +0700 Subject: [PATCH 07/13] Honor queued lastActionType with partial action caches --- src/libs/ReportUtils.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index dcf40a6529ab..8cb877b1ffcd 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -13957,15 +13957,13 @@ function getIntegrationIcon( } /** - * Whether an export is already in flight, which the backend records as the report's latest action. + * Whether an export is already in flight. Reads both the cached actions and lastActionType, since cached actions can be + * empty, stale or partial on a cold start or offline. */ function isQueuedForExport(reportActions?: ReportAction[], report?: OnyxEntry): boolean { const latestAction = reportActions?.reduce((latest, action) => (!latest || action.created > latest.created ? action : latest), undefined); - if (latestAction) { - return latestAction.actionName === CONST.REPORT.ACTIONS.TYPE.QUEUED_FOR_EXPORT; - } - return report?.lastActionType === CONST.REPORT.ACTIONS.TYPE.QUEUED_FOR_EXPORT; + return latestAction?.actionName === CONST.REPORT.ACTIONS.TYPE.QUEUED_FOR_EXPORT || report?.lastActionType === CONST.REPORT.ACTIONS.TYPE.QUEUED_FOR_EXPORT; } function canBeExported(report: OnyxEntry) { @@ -14070,7 +14068,7 @@ function hasExportError(reportActions: OnyxEntry | ReportAction[] return false; } - // Only the latest integration message reflects the current export state; a later reconciled message clears an earlier failure + // Only the latest integration message reflects the current export state. A later reconciled message clears an earlier failure. const latestMessage = integrationMessages.reduce((latest, action) => (action.created > latest.created ? action : latest)); return !getOriginalMessage(latestMessage)?.result?.reconciled && latestMessage.created > lastResetCreated; From bb98a145a8b990b1e06b2aa6c490cb7a2d066571 Mon Sep 17 00:00:00 2001 From: dukenv0307 Date: Mon, 19 Oct 2026 09:19:02 +0700 Subject: [PATCH 08/13] update the non-reimbursable case --- src/libs/ReportPreviewActionUtils.ts | 11 +++++++++-- src/libs/ReportPrimaryActionUtils.ts | 9 ++++++--- src/libs/SearchUIUtils.ts | 14 +++++++------- src/libs/TodosUtils.ts | 2 +- 4 files changed, 23 insertions(+), 13 deletions(-) diff --git a/src/libs/ReportPreviewActionUtils.ts b/src/libs/ReportPreviewActionUtils.ts index 538393ce859f..d639379a91ad 100644 --- a/src/libs/ReportPreviewActionUtils.ts +++ b/src/libs/ReportPreviewActionUtils.ts @@ -4,9 +4,12 @@ import type {BankAccountList, Policy, Report, ReportMetadata, Rule, Transaction, import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; +import {CONST as COMMON_CONST} from 'expensify-common'; + import { arePaymentsEnabled, canMemberWrite, + getIntegrationAccountingMethod, getReimbursementChoice, getSubmitToAccountID, getValidConnectedIntegration, @@ -199,7 +202,7 @@ function canPay( return invoiceReceiverPolicy?.role === CONST.POLICY.ROLE.ADMIN && reimbursableSpend > 0; } -function canExport(report: Report, currentUserLogin: string, policy?: Policy) { +function canExport(report: Report, currentUserLogin: string, policy?: Policy, transactions?: Transaction[]) { const isExpense = isExpenseReport(report); const isExporter = policy ? isPreferredExporter(policy, currentUserLogin) : false; const isReimbursed = isSettled(report); @@ -226,6 +229,10 @@ function canExport(report: Report, currentUserLogin: string, policy?: Policy) { return false; } + if (getIntegrationAccountingMethod(policy, connectedIntegration) === COMMON_CONST.INTEGRATIONS.ACCOUNTING_METHOD.CASH) { + return isReimbursed || ((isApproved || isClosed) && hasOnlyNonReimbursableTransactions(report.reportID, transactions)); + } + return isApproved || isReimbursed || isClosed; } @@ -294,7 +301,7 @@ function getReportPreviewAction({ if (canPay(report, isReportArchived, currentUserAccountID, currentUserLogin, bankAccountList, transactions, policy, invoiceReceiverPolicy)) { return CONST.REPORT.REPORT_PREVIEW_ACTIONS.PAY; } - if (canExport(report, currentUserLogin, policy)) { + if (canExport(report, currentUserLogin, policy, transactions)) { return CONST.REPORT.REPORT_PREVIEW_ACTIONS.EXPORT_TO_ACCOUNTING; } diff --git a/src/libs/ReportPrimaryActionUtils.ts b/src/libs/ReportPrimaryActionUtils.ts index ec54d576b825..9da3e4a37a97 100644 --- a/src/libs/ReportPrimaryActionUtils.ts +++ b/src/libs/ReportPrimaryActionUtils.ts @@ -116,6 +116,9 @@ type IsExportActionOptions = { /** Needed to skip archived reports, which the backend never returns as exportable */ reportNameValuePairs?: ReportNameValuePairs; + + /** Needed for the cash-basis carve-out on card-only reports; Search snapshot rows aren't reliably in Onyx */ + reportTransactions?: Transaction[]; }; function isAddExpenseAction(report: Report, reportTransactions: Transaction[], isChatReportArchived: boolean, rules: OnyxCollection) { @@ -347,9 +350,9 @@ function isExportAction(report: Report, currentUserLogin: string, policy?: Polic const isReportApproved = isReportApprovedUtils({report}); const isReportClosed = isClosedReportUtils(report); - // Cash basis only exports once the report is paid, so approval alone leaves nothing to export + // Cash basis holds back only reimbursable spend, so a card-only approved or closed report can still export if (getIntegrationAccountingMethod(policy, connectedIntegration) === COMMON_CONST.INTEGRATIONS.ACCOUNTING_METHOD.CASH) { - return isReportReimbursed; + return isReportReimbursed || ((isReportApproved || isReportClosed) && hasOnlyNonReimbursableTransactions(report.reportID, options?.reportTransactions)); } if (isReportApproved || isReportReimbursed || isReportClosed) { @@ -602,7 +605,7 @@ function getReportPrimaryAction(params: GetReportPrimaryActionParams): ValueOf Date: Tue, 20 Oct 2026 08:56:14 +0700 Subject: [PATCH 09/13] Card-only reports lose Export until transactions load --- src/libs/ReportPreviewActionUtils.ts | 3 ++- src/libs/ReportPrimaryActionUtils.ts | 5 +++-- src/libs/ReportUtils.ts | 20 ++++++++++++++++---- 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/src/libs/ReportPreviewActionUtils.ts b/src/libs/ReportPreviewActionUtils.ts index d639379a91ad..2f02cc2542a2 100644 --- a/src/libs/ReportPreviewActionUtils.ts +++ b/src/libs/ReportPreviewActionUtils.ts @@ -27,6 +27,7 @@ import { getParentReport, hasExportError as hasExportErrorUtil, hasOnlyNonReimbursableTransactions, + isCardOnlyReport, hasSettledZeroReimbursableSpend, isClosedReport, isCurrentUserSubmitter, @@ -230,7 +231,7 @@ function canExport(report: Report, currentUserLogin: string, policy?: Policy, tr } if (getIntegrationAccountingMethod(policy, connectedIntegration) === COMMON_CONST.INTEGRATIONS.ACCOUNTING_METHOD.CASH) { - return isReimbursed || ((isApproved || isClosed) && hasOnlyNonReimbursableTransactions(report.reportID, transactions)); + return isReimbursed || ((isApproved || isClosed) && isCardOnlyReport(report, transactions)); } return isApproved || isReimbursed || isClosed; diff --git a/src/libs/ReportPrimaryActionUtils.ts b/src/libs/ReportPrimaryActionUtils.ts index 9da3e4a37a97..088c212786d0 100644 --- a/src/libs/ReportPrimaryActionUtils.ts +++ b/src/libs/ReportPrimaryActionUtils.ts @@ -44,6 +44,7 @@ import { hasExportError as hasExportErrorUtil, hasOnlyHeldExpenses, hasOnlyNonReimbursableTransactions, + isCardOnlyReport, hasSettledZeroReimbursableSpend, isArchivedReport, isClosedReport as isClosedReportUtils, @@ -338,7 +339,7 @@ function isExportAction(report: Report, currentUserLogin: string, policy?: Polic } // An export already in flight needs no second one, unless the last one failed - if (isQueuedForExportUtil(reportActions, report) && !hasExportError) { + if (isQueuedForExportUtil(reportActions) && !hasExportError) { return false; } @@ -352,7 +353,7 @@ function isExportAction(report: Report, currentUserLogin: string, policy?: Polic // Cash basis holds back only reimbursable spend, so a card-only approved or closed report can still export if (getIntegrationAccountingMethod(policy, connectedIntegration) === COMMON_CONST.INTEGRATIONS.ACCOUNTING_METHOD.CASH) { - return isReportReimbursed || ((isReportApproved || isReportClosed) && hasOnlyNonReimbursableTransactions(report.reportID, options?.reportTransactions)); + return isReportReimbursed || ((isReportApproved || isReportClosed) && isCardOnlyReport(report, options?.reportTransactions)); } if (isReportApproved || isReportReimbursed || isReportClosed) { diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 8c3fd3210179..5fb37811f6d2 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -2938,6 +2938,18 @@ function hasOnlyNonReimbursableTransactions(iouReportID: string | undefined, tra return transactions.every((transaction) => !getReimbursable(transaction)); } +/** + * Whether a report is card-only (has non-reimbursable spend and nothing reimbursable). Prefers the per-transaction answer when + * expenses are loaded, and falls back to the stored report totals otherwise, matching the backend's "no reimbursable transaction" check. + */ +function isCardOnlyReport(report: OnyxEntry, transactionsParam?: Transaction[]): boolean { + if (transactionsParam && transactionsParam.length > 0) { + return hasOnlyNonReimbursableTransactions(report?.reportID, transactionsParam); + } + + return getReimbursableTotal(report) === 0 && (report?.nonReimbursableTotal ?? 0) !== 0; +} + /** * Checks if a report has only one transaction associated with it */ @@ -14151,13 +14163,12 @@ function getIntegrationIcon( } /** - * Whether an export is already in flight. Reads both the cached actions and lastActionType, since cached actions can be - * empty, stale or partial on a cold start or offline. + * Whether an export is already in flight, which the backend records as the report's latest action. */ -function isQueuedForExport(reportActions?: ReportAction[], report?: OnyxEntry): boolean { +function isQueuedForExport(reportActions?: ReportAction[]): boolean { const latestAction = reportActions?.reduce((latest, action) => (!latest || action.created > latest.created ? action : latest), undefined); - return latestAction?.actionName === CONST.REPORT.ACTIONS.TYPE.QUEUED_FOR_EXPORT || report?.lastActionType === CONST.REPORT.ACTIONS.TYPE.QUEUED_FOR_EXPORT; + return latestAction?.actionName === CONST.REPORT.ACTIONS.TYPE.QUEUED_FOR_EXPORT; } function canBeExported(report: OnyxEntry) { @@ -15608,6 +15619,7 @@ export { hasExpensifyGuidesEmails, hasExportError, hasOnlyNonReimbursableTransactions, + isCardOnlyReport, hasSettledZeroReimbursableSpend, isPayOptional, getReportLastMessage, From e8b8af6b168d1a2678f1145ae761fe4b205b87d1 Mon Sep 17 00:00:00 2001 From: dukenv0307 Date: Thu, 22 Oct 2026 08:39:56 +0700 Subject: [PATCH 10/13] update comments --- src/libs/ReportPrimaryActionUtils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/ReportPrimaryActionUtils.ts b/src/libs/ReportPrimaryActionUtils.ts index db9aab22cb16..679bad14ee53 100644 --- a/src/libs/ReportPrimaryActionUtils.ts +++ b/src/libs/ReportPrimaryActionUtils.ts @@ -117,7 +117,7 @@ type IsExportActionOptions = { /** Needed to skip archived reports, which the backend never returns as exportable */ reportNameValuePairs?: ReportNameValuePairs; - /** Needed for the cash-basis carve-out on card-only reports; Search snapshot rows aren't reliably in Onyx */ + /** Needed for the cash-basis carve-out on card-only reports. Search snapshot rows aren't reliably in Onyx. */ reportTransactions?: Transaction[]; }; From 46714861dc2a035f47a337e5db100b38e55d524d Mon Sep 17 00:00:00 2001 From: dukenv0307 Date: Fri, 23 Oct 2026 12:36:13 +0700 Subject: [PATCH 11/13] add more tests --- src/libs/PolicyUtils/connections.ts | 10 ++-- src/libs/ReportActionsUtils.ts | 6 ++ tests/actions/ReportPreviewActionUtilsTest.ts | 40 +++++++++++++ tests/unit/ReportPrimaryActionUtilsTest.ts | 59 +++++++++++++++++++ 4 files changed, 111 insertions(+), 4 deletions(-) diff --git a/src/libs/PolicyUtils/connections.ts b/src/libs/PolicyUtils/connections.ts index 6b2d282e5655..6c89f94a0133 100644 --- a/src/libs/PolicyUtils/connections.ts +++ b/src/libs/PolicyUtils/connections.ts @@ -1,3 +1,8 @@ +/** + * Which accounting integration is connected, whether it is syncing, and who exports. + * Extracted from PolicyUtils/index.ts to keep that file smaller. + */ + import type {LocaleContextProps} from '@components/LocaleContextProvider'; import {isConnectionUnverified} from '@libs/actions/connections'; @@ -9,10 +14,6 @@ import type {ConnectionLastSync, ConnectionName, Connections, NetSuiteConnection import type {OnyxEntry} from 'react-native-onyx'; import type {TupleToUnion, ValueOf} from 'type-fest'; -/** - * Which accounting integration is connected, whether it is syncing, and who exports. - * Extracted from PolicyUtils/index.ts to keep that file smaller. - */ import {CONST as COMMON_CONST} from 'expensify-common'; type AccountingConnectionName = TupleToUnion; @@ -128,6 +129,7 @@ function getConnectionExporters(policy: OnyxInputOrEntry): Array { expect(result).toBe(CONST.REPORT.REPORT_PREVIEW_ACTIONS.APPROVE); }); }); + + it('canExport returns true on a cash-basis policy when every expense is non-reimbursable', async () => { + const report = { + ...createRandomReport(REPORT_ID, undefined), + type: CONST.REPORT.TYPE.EXPENSE, + ownerAccountID: CURRENT_USER_ACCOUNT_ID, + stateNum: CONST.REPORT.STATE_NUM.APPROVED, + statusNum: CONST.REPORT.STATUS_NUM.APPROVED, + isWaitingOnBankAccount: false, + }; + + const policy = createRandomPolicy(0); + policy.type = CONST.POLICY.TYPE.CORPORATE; + policy.connections = { + [CONST.POLICY.CONNECTIONS.NAME.NETSUITE]: createMock({ + options: {config: {accountingMethod: 'CASH'}}, + }), + }; + policy.reimbursementChoice = CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO; + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, report); + + const transaction = createMock({reportID: `${REPORT_ID}`, reimbursable: false}); + + const {result: isReportArchived} = renderHook(() => useReportIsArchived(report?.parentReportID)); + await waitForBatchedUpdatesWithAct(); + expect( + getReportPreviewAction({ + isReportArchived: isReportArchived.current, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + currentUserLogin: CURRENT_USER_EMAIL, + report, + policy, + transactions: [transaction], + bankAccountList: {}, + reportMetadata: undefined, + ownerLogin: CURRENT_USER_EMAIL, + rules: undefined, + }), + ).toBe(CONST.REPORT.REPORT_PREVIEW_ACTIONS.EXPORT_TO_ACCOUNTING); + }); }); diff --git a/tests/unit/ReportPrimaryActionUtilsTest.ts b/tests/unit/ReportPrimaryActionUtilsTest.ts index 4fc31cd29725..46dd0cbe18ad 100644 --- a/tests/unit/ReportPrimaryActionUtilsTest.ts +++ b/tests/unit/ReportPrimaryActionUtilsTest.ts @@ -3013,3 +3013,62 @@ describe('isExportAction and isPreferredExporter for todos filtering', () => { expect(isPreferredExporter(policy, CURRENT_USER_EMAIL)).toBe(true); }); }); + +describe('isExportAction cash-basis card-only carve-out', () => { + const buildCashQBOPolicy = () => + createMock({ + role: CONST.POLICY.ROLE.ADMIN, + connections: { + [CONST.POLICY.CONNECTIONS.NAME.QBO]: { + config: { + autoSync: {enabled: false}, + accountingMethod: 'CASH', + export: {exporter: CURRENT_USER_EMAIL}, + }, + }, + }, + }); + + const buildApprovedReport = (overrides: Partial = {}) => + createMock({ + reportID: '1', + type: CONST.REPORT.TYPE.EXPENSE, + stateNum: CONST.REPORT.STATE_NUM.APPROVED, + statusNum: CONST.REPORT.STATUS_NUM.APPROVED, + isWaitingOnBankAccount: false, + ...overrides, + }); + + beforeEach(() => { + jest.mocked(getValidConnectedIntegration).mockReturnValue(CONST.POLICY.CONNECTIONS.NAME.QBO); + }); + + it('returns true when every expense is non-reimbursable', () => { + const policy = buildCashQBOPolicy(); + const report = buildApprovedReport(); + const reportTransactions = [ + createMock({transactionID: 't1', reportID: '1', reimbursable: false}), + createMock({transactionID: 't2', reportID: '1', reimbursable: false}), + ]; + + expect(isExportAction(report, CURRENT_USER_EMAIL, policy, [], {reportTransactions})).toBe(true); + }); + + it('returns false when at least one expense is reimbursable', () => { + const policy = buildCashQBOPolicy(); + const report = buildApprovedReport(); + const reportTransactions = [ + createMock({transactionID: 't1', reportID: '1', reimbursable: false}), + createMock({transactionID: 't2', reportID: '1', reimbursable: true}), + ]; + + expect(isExportAction(report, CURRENT_USER_EMAIL, policy, [], {reportTransactions})).toBe(false); + }); + + it('falls back to report totals when transactions have not hydrated yet', () => { + const policy = buildCashQBOPolicy(); + const report = buildApprovedReport({total: -500, nonReimbursableTotal: -500, reimbursableTotal: undefined}); + + expect(isExportAction(report, CURRENT_USER_EMAIL, policy, [], {})).toBe(true); + }); +}); From bc4f5818119e51acff5d39a55c92a98f01215878 Mon Sep 17 00:00:00 2001 From: "Nguyen Van Duc (via MelvinBot)" Date: Mon, 26 Oct 2026 01:55:13 +0000 Subject: [PATCH 12/13] Add Given, When, Then comments to the export rule tests Co-authored-by: Nguyen Van Duc --- tests/actions/ReportPreviewActionUtilsTest.ts | 5 + tests/unit/ReportPrimaryActionUtilsTest.ts | 11 ++ tests/unit/TodosUtilsTest.ts | 165 +++++++++++++++--- tests/unit/useTodoCountsTest.tsx | 6 +- 4 files changed, 161 insertions(+), 26 deletions(-) diff --git a/tests/actions/ReportPreviewActionUtilsTest.ts b/tests/actions/ReportPreviewActionUtilsTest.ts index 74aee9ff4f06..f6a224fe8abc 100644 --- a/tests/actions/ReportPreviewActionUtilsTest.ts +++ b/tests/actions/ReportPreviewActionUtilsTest.ts @@ -1780,6 +1780,8 @@ describe('getReportPreviewAction', () => { }); it('canExport returns true on a cash-basis policy when every expense is non-reimbursable', async () => { + // Given an approved report whose expenses are all non-reimbursable, on a cash-basis NetSuite policy. Cash-basis + // reports normally wait until they are paid, but a card-only report has nothing to reimburse so it is never paid const report = { ...createRandomReport(REPORT_ID, undefined), type: CONST.REPORT.TYPE.EXPENSE, @@ -1803,6 +1805,9 @@ describe('getReportPreviewAction', () => { const {result: isReportArchived} = renderHook(() => useReportIsArchived(report?.parentReportID)); await waitForBatchedUpdatesWithAct(); + + // When the exporter views the report preview + // Then Export is offered right away, so the report is not stuck waiting for a payment that never comes expect( getReportPreviewAction({ isReportArchived: isReportArchived.current, diff --git a/tests/unit/ReportPrimaryActionUtilsTest.ts b/tests/unit/ReportPrimaryActionUtilsTest.ts index 46dd0cbe18ad..11040d6703ac 100644 --- a/tests/unit/ReportPrimaryActionUtilsTest.ts +++ b/tests/unit/ReportPrimaryActionUtilsTest.ts @@ -3044,6 +3044,8 @@ describe('isExportAction cash-basis card-only carve-out', () => { }); it('returns true when every expense is non-reimbursable', () => { + // Given an approved report on a cash-basis QBO policy where every expense is non-reimbursable, so the report + // has nothing to pay and would never reach the paid state that cash-basis exports normally wait for const policy = buildCashQBOPolicy(); const report = buildApprovedReport(); const reportTransactions = [ @@ -3051,10 +3053,13 @@ describe('isExportAction cash-basis card-only carve-out', () => { createMock({transactionID: 't2', reportID: '1', reimbursable: false}), ]; + // When checking whether the exporter can export it + // Then export is allowed while the report is only approved expect(isExportAction(report, CURRENT_USER_EMAIL, policy, [], {reportTransactions})).toBe(true); }); it('returns false when at least one expense is reimbursable', () => { + // Given an approved report on a cash-basis QBO policy with one reimbursable expense that still has to be paid const policy = buildCashQBOPolicy(); const report = buildApprovedReport(); const reportTransactions = [ @@ -3062,13 +3067,19 @@ describe('isExportAction cash-basis card-only carve-out', () => { createMock({transactionID: 't2', reportID: '1', reimbursable: true}), ]; + // When checking whether the exporter can export it + // Then export is blocked, because cash-basis accounting records the report only once it is paid expect(isExportAction(report, CURRENT_USER_EMAIL, policy, [], {reportTransactions})).toBe(false); }); it('falls back to report totals when transactions have not hydrated yet', () => { + // Given an approved card-only report on a cash-basis QBO policy whose transactions have not loaded yet, so + // only the report totals show that nothing is reimbursable const policy = buildCashQBOPolicy(); const report = buildApprovedReport({total: -500, nonReimbursableTotal: -500, reimbursableTotal: undefined}); + // When checking whether the exporter can export it without any transactions + // Then export is still allowed, so the action does not flicker while the transactions load expect(isExportAction(report, CURRENT_USER_EMAIL, policy, [], {})).toBe(true); }); }); diff --git a/tests/unit/TodosUtilsTest.ts b/tests/unit/TodosUtilsTest.ts index 6741dd076ad4..1f027691f50e 100644 --- a/tests/unit/TodosUtilsTest.ts +++ b/tests/unit/TodosUtilsTest.ts @@ -746,47 +746,102 @@ describe('TodosUtils export bucket', () => { describe('exporter', () => { it('includes the report when policy.exporter is the current user', () => { - expect(getExportedReportIDs(createExportPolicy({policyExporter: CURRENT_USER_EMAIL}))).toEqual([EXPORT_REPORT_ID]); + // Given a policy that names the current user as its exporter + const policy = createExportPolicy({policyExporter: CURRENT_USER_EMAIL}); + + // When the export to-do is built + const exportedReportIDs = getExportedReportIDs(policy); + + // Then the approved report is listed, because the exporter is the person expected to export it + expect(exportedReportIDs).toEqual([EXPORT_REPORT_ID]); }); it('excludes the report when both the policy and connection exporters are someone else', () => { - expect(getExportedReportIDs(createExportPolicy({policyExporter: OTHER_USER_EMAIL, connectionExporter: OTHER_USER_EMAIL}))).toEqual([]); + // Given a policy where both the policy and the connection name another user as the exporter + const policy = createExportPolicy({policyExporter: OTHER_USER_EMAIL, connectionExporter: OTHER_USER_EMAIL}); + + // When the export to-do is built + const exportedReportIDs = getExportedReportIDs(policy); + + // Then the report is not listed, because exporting it is someone else's job + expect(exportedReportIDs).toEqual([]); }); it('includes the report when policy.exporter is empty and the connection exporter is the current user', () => { - expect(getExportedReportIDs(createExportPolicy({policyExporter: ''}))).toEqual([EXPORT_REPORT_ID]); + // Given a policy with no policy.exporter, whose connection names the current user as its exporter + const policy = createExportPolicy({policyExporter: ''}); + + // When the export to-do is built + const exportedReportIDs = getExportedReportIDs(policy); + + // Then the report is listed, because the server matches the to-do on the connection exporter + expect(exportedReportIDs).toEqual([EXPORT_REPORT_ID]); }); it.each(EXPORTER_BUILDERS)('reads the connection exporter for %s while policy.exporter is empty', (_connectionName, buildConnections) => { + // Given a policy with no policy.exporter, whose connection stores the current user as exporter in that + // integration's own location const policy = createMock({ ...createMockPolicy(EXPORT_POLICY_ID, {role: CONST.POLICY.ROLE.ADMIN, exporter: ''}), connections: buildConnections(CURRENT_USER_EMAIL), }); - expect(getExportedReportIDs(policy)).toEqual([EXPORT_REPORT_ID]); + // When the export to-do is built + const exportedReportIDs = getExportedReportIDs(policy); + + // Then the report is listed, so no integration is missed because of where it keeps its exporter + expect(exportedReportIDs).toEqual([EXPORT_REPORT_ID]); }); it('excludes the report for an admin who is not an exporter', () => { - expect(getExportedReportIDs(createExportPolicy({connectionExporter: OTHER_USER_EMAIL, role: CONST.POLICY.ROLE.ADMIN}))).toEqual([]); + // Given an admin on a policy whose connection names another user as exporter + const policy = createExportPolicy({connectionExporter: OTHER_USER_EMAIL, role: CONST.POLICY.ROLE.ADMIN}); + + // When the export to-do is built + const exportedReportIDs = getExportedReportIDs(policy); + + // Then the report is not listed, because being an admin alone does not make the export the user's to-do + expect(exportedReportIDs).toEqual([]); }); it('includes the report for a non-admin who is the exporter', () => { - expect(getExportedReportIDs(createExportPolicy({role: CONST.POLICY.ROLE.USER}))).toEqual([EXPORT_REPORT_ID]); + // Given a regular member whom the connection names as exporter + const policy = createExportPolicy({role: CONST.POLICY.ROLE.USER}); + + // When the export to-do is built + const exportedReportIDs = getExportedReportIDs(policy); + + // Then the report is listed, because the exporter role does not depend on being an admin + expect(exportedReportIDs).toEqual([EXPORT_REPORT_ID]); }); }); describe('auto-sync', () => { it('excludes the report when auto-sync is on, since auto-sync exports it', () => { - expect(getExportedReportIDs(createExportPolicy({isAutoSyncEnabled: true}))).toEqual([]); + // Given the current user is the exporter on a connection with auto-sync on + const policy = createExportPolicy({isAutoSyncEnabled: true}); + + // When the export to-do is built + const exportedReportIDs = getExportedReportIDs(policy); + + // Then the report is not listed, since auto-sync exports it without the user's help + expect(exportedReportIDs).toEqual([]); }); it('includes the report when auto-sync is on but the export failed', () => { + // Given an approved report whose automatic export failed, on a connection with auto-sync on const report = {...createApprovedReport(), hasExportError: true}; - expect(getExportedReportIDs(createExportPolicy({isAutoSyncEnabled: true}), report)).toEqual([EXPORT_REPORT_ID]); + // When the export to-do is built + const exportedReportIDs = getExportedReportIDs(createExportPolicy({isAutoSyncEnabled: true}), report); + + // Then the report is listed, because auto-sync gave up and the exporter has to export it by hand + expect(exportedReportIDs).toEqual([EXPORT_REPORT_ID]); }); it('ignores a failure message that predates the last approval reset', () => { + // Given an approved report on an auto-sync connection that has an integration failure message, and a + // later version of it that was unapproved after that failure const actionsFor = (actions: Record) => ({[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${EXPORT_REPORT_ID}`]: actions}); const failureMessage = createMock({ reportActionID: 'failureMessage', @@ -799,50 +854,80 @@ describe('TodosUtils export bucket', () => { created: '2024-06-02 00:00:00.000', }); - // The failure keeps the report actionable while it is the report's latest word on the export + // When the export to-do is built while the failure is the report's latest word on the export + // Then the report is listed, because the failure means the exporter has to export it by hand expect(getExportedReportIDs(createExportPolicy({isAutoSyncEnabled: true}), createApprovedReport(), actionsFor({failureMessage}))).toEqual([EXPORT_REPORT_ID]); - // Unapproving and approving again moves the report past that failure, so auto-sync owns it once more + // When the export to-do is built after the report was unapproved and approved again + // Then the report is not listed, because the reset moves it past that failure and auto-sync owns it once more expect(getExportedReportIDs(createExportPolicy({isAutoSyncEnabled: true}), createApprovedReport(), actionsFor({failureMessage, unapproved}))).toEqual([]); }); it('includes the report when auto-sync is off', () => { - expect(getExportedReportIDs(createExportPolicy({isAutoSyncEnabled: false}))).toEqual([EXPORT_REPORT_ID]); + // Given the current user is the exporter on a connection with auto-sync off + const policy = createExportPolicy({isAutoSyncEnabled: false}); + + // When the export to-do is built + const exportedReportIDs = getExportedReportIDs(policy); + + // Then the report is listed, because nothing exports it unless the exporter does + expect(exportedReportIDs).toEqual([EXPORT_REPORT_ID]); }); }); describe('report state', () => { it('excludes a report that was already exported', () => { + // Given an approved report that was already exported to the integration const report = {...createApprovedReport(), isExportedToIntegration: true}; - expect(getExportedReportIDs(createExportPolicy(), report)).toEqual([]); + // When the export to-do is built + const exportedReportIDs = getExportedReportIDs(createExportPolicy(), report); + + // Then the report is not listed, so the exporter is not asked to export it twice + expect(exportedReportIDs).toEqual([]); }); it('excludes a report whose export is already queued', () => { + // Given an approved report with an export already queued const queuedAction = createMock({ reportActionID: 'queued_action', actionName: CONST.REPORT.ACTIONS.TYPE.QUEUED_FOR_EXPORT, created: '2024-06-01 00:00:00.000', }); - expect(getExportedReportIDs(createExportPolicy(), createApprovedReport(), {[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${EXPORT_REPORT_ID}`]: {queuedAction}})).toEqual([]); + // When the export to-do is built + const exportedReportIDs = getExportedReportIDs(createExportPolicy(), createApprovedReport(), {[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${EXPORT_REPORT_ID}`]: {queuedAction}}); + + // Then the report is not listed, because the export is already on its way + expect(exportedReportIDs).toEqual([]); }); it('excludes an archived report', () => { - // The server reads the archive flag from the expense report itself, not from its chat + // Given an approved report that is archived. The server reads the archive flag from the expense report + // itself, not from its chat const archivedNVPs = {[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${EXPORT_REPORT_ID}`]: {private_isArchived: '2024-01-01 00:00:00'}}; - expect(getExportedReportIDs(createExportPolicy(), createApprovedReport(), undefined, archivedNVPs)).toEqual([]); + // When the export to-do is built + const exportedReportIDs = getExportedReportIDs(createExportPolicy(), createApprovedReport(), undefined, archivedNVPs); + + // Then the report is not listed, because an archived report can no longer be acted on + expect(exportedReportIDs).toEqual([]); }); it('excludes a report waiting on a bank account', () => { + // Given an approved report that is waiting on a bank account const waitingReport = {...createApprovedReport(), isWaitingOnBankAccount: true}; - expect(getExportedReportIDs(createExportPolicy(), waitingReport)).toEqual([]); + // When the export to-do is built + const exportedReportIDs = getExportedReportIDs(createExportPolicy(), waitingReport); + + // Then the report is not listed, because it is not ready to export until the bank account is set up + expect(exportedReportIDs).toEqual([]); }); - // App requires a verified connection while the backend does not, so this is a known and accepted difference it('excludes a report whose only connection is unverified', () => { + // Given a policy whose only connection is an unverified NetSuite connection that names the current user as + // exporter const policy = createMock({ ...createMockPolicy(EXPORT_POLICY_ID, {role: CONST.POLICY.ROLE.ADMIN, exporter: ''}), connections: createMock({ @@ -850,10 +935,16 @@ describe('TodosUtils export bucket', () => { }), }); - expect(getExportedReportIDs(policy)).toEqual([]); + // When the export to-do is built + const exportedReportIDs = getExportedReportIDs(policy); + + // Then the report is not listed. App requires a verified connection while the backend does not, so this + // is a known and accepted difference + expect(exportedReportIDs).toEqual([]); }); it('excludes an approved report on a cash-basis connection until it is paid', () => { + // Given the current user is the exporter on a cash-basis QBO connection const policy = createExportPolicy(); const cashPolicy = createMock({ ...policy, @@ -865,8 +956,12 @@ describe('TodosUtils export bucket', () => { }, }); + // When the export to-do is built while the report is only approved + // Then the report is not listed, because cash-basis accounting records the report only once it is paid expect(getExportedReportIDs(cashPolicy)).toEqual([]); + // When the export to-do is built after the report is paid + // Then the report is listed, since it is now ready to export const paidReport = {...createApprovedReport(), statusNum: CONST.REPORT.STATUS_NUM.REIMBURSED}; expect(getExportedReportIDs(cashPolicy, paidReport)).toEqual([EXPORT_REPORT_ID]); }); @@ -933,31 +1028,53 @@ describe('export rule across surfaces', () => { }; it('keeps the report page action for an admin who is not the exporter, but not the to-do', () => { - const results = getSurfaceResults(createSurfacePolicy(false, {connectionExporter: 'someoneelse@mail.com'})); + // Given an admin on a policy whose connection names another user as exporter, with auto-sync off + const policy = createSurfacePolicy(false, {connectionExporter: 'someoneelse@mail.com'}); + + // When the to-do, the sidebar and the report page each decide whether to offer the export + const results = getSurfaceResults(policy); - // The to-do and the sidebar belong to the exporter, while the report page lets any admin export + // Then only the report page offers it, because the to-do and the sidebar belong to the exporter while the + // report page lets any admin export expect(results.isInTodoBucket).toBe(false); expect(results.isSidebarItemVisible).toBe(false); expect(results.isPrimaryExportAction).toBe(true); }); it('offers the report nowhere for a member who is neither the exporter nor an admin', () => { - const results = getSurfaceResults(createSurfacePolicy(false, {connectionExporter: 'someoneelse@mail.com', role: CONST.POLICY.ROLE.USER})); + // Given a regular member on a policy whose connection names another user as exporter + const policy = createSurfacePolicy(false, {connectionExporter: 'someoneelse@mail.com', role: CONST.POLICY.ROLE.USER}); + + // When the to-do, the sidebar and the report page each decide whether to offer the export + const results = getSurfaceResults(policy); + // Then none of them offer it, because the member has no right to export the report expect(results).toEqual({isInTodoBucket: false, isSidebarItemVisible: false, isPrimaryExportAction: false}); }); it('shows the report in the to-do, the sidebar and the report page when it needs a manual export', () => { - expect(getSurfaceResults(createSurfacePolicy(false))).toEqual({isInTodoBucket: true, isSidebarItemVisible: true, isPrimaryExportAction: true}); + // Given the current user is the exporter on a connection with auto-sync off + const policy = createSurfacePolicy(false); + + // When the to-do, the sidebar and the report page each decide whether to offer the export + const results = getSurfaceResults(policy); + + // Then all three offer it, so the surfaces agree on a report that only the user can export + expect(results).toEqual({isInTodoBucket: true, isSidebarItemVisible: true, isPrimaryExportAction: true}); }); it('drops the report from the to-do and the report page when auto-sync exports it', () => { - const results = getSurfaceResults(createSurfacePolicy(true)); + // Given the current user is the exporter on a connection with auto-sync on + const policy = createSurfacePolicy(true); + + // When the to-do, the sidebar and the report page each decide whether to offer the export + const results = getSurfaceResults(policy); + // Then the to-do and the report page drop it, because auto-sync exports it without the user's help expect(results.isInTodoBucket).toBe(false); expect(results.isPrimaryExportAction).toBe(false); - // The sidebar item stays, since the user is still an exporter and can open an empty list + // Then the sidebar item stays, since the user is still an exporter and can open an empty list expect(results.isSidebarItemVisible).toBe(true); }); }); diff --git a/tests/unit/useTodoCountsTest.tsx b/tests/unit/useTodoCountsTest.tsx index d3717d2cc287..9c9354bf44b6 100644 --- a/tests/unit/useTodoCountsTest.tsx +++ b/tests/unit/useTodoCountsTest.tsx @@ -438,6 +438,8 @@ describe('useTodoCounts', () => { }); it('counts export reports when the user is a connection-level exporter but not policy.exporter', async () => { + // Given an approved report on a policy where policy.exporter is someone else, but the connection-level exporter + // is the current user, which is what the server's export to-do matches on const EXPORT_POLICY_ID = 'policy_export_mismatch'; const reportID = 'export_mismatch_report'; @@ -448,8 +450,6 @@ describe('useTodoCounts', () => { ownerAccountID: OTHER_USER_ACCOUNT_ID, isWaitingOnBankAccount: false, }); - // policy.exporter is someone else, but the connection-level exporter is the current user, which is what the - // server's export to-do matches on. const policy = createPolicyWithQBOConnection(EXPORT_POLICY_ID, {policyExporter: 'someone-else@mail.com', connectionExporter: CURRENT_USER_EMAIL}); await Onyx.set(ONYXKEYS.SESSION, {email: CURRENT_USER_EMAIL, accountID: CURRENT_USER_ACCOUNT_ID}); @@ -457,8 +457,10 @@ describe('useTodoCounts', () => { await setReports([report]); await waitForBatchedUpdates(); + // When the to-do counts are computed const {result} = await renderTodoCounts(); + // Then the report counts toward Export, so the badge matches the list the server returns expect(result.current.counts[CONST.SEARCH.SEARCH_KEYS.EXPORT]).toBe(1); }); From 3db605fda417770deba7dcaead2dbf96454156a0 Mon Sep 17 00:00:00 2001 From: "Nguyen Van Duc (via MelvinBot)" Date: Mon, 26 Oct 2026 01:55:21 +0000 Subject: [PATCH 13/13] Update ESLint seatbelt for SearchUIUtils Co-authored-by: Nguyen Van Duc --- config/eslint/eslint.seatbelt.tsv | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/eslint/eslint.seatbelt.tsv b/config/eslint/eslint.seatbelt.tsv index b48dd68810a4..98d5e37d5840 100644 --- a/config/eslint/eslint.seatbelt.tsv +++ b/config/eslint/eslint.seatbelt.tsv @@ -832,7 +832,7 @@ "../../src/libs/ReportUtils.ts" "rulesdir/no-onyx-connect" 13 "../../src/libs/sanitizeLogParams.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/libs/SearchQueryUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 48 -"../../src/libs/SearchUIUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 71 +"../../src/libs/SearchUIUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 70 "../../src/libs/SearchUIUtils.ts" "no-restricted-imports" 1 "../../src/libs/SidebarUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/libs/Sound/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 3