Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
034dd3c
Use one export rule across the to-do, the sidebar and the report page
dukenv0307 Oct 5, 2026
6973914
lint fix
dukenv0307 Oct 5, 2026
3ea5552
ts fix
dukenv0307 Oct 5, 2026
32c01d1
create getLastApprovalResetTime func
dukenv0307 Oct 5, 2026
ececbfc
merge main
dukenv0307 Oct 9, 2026
6c927d9
merge main
dukenv0307 Oct 13, 2026
920bf86
fix some bot comments
dukenv0307 Oct 13, 2026
0b3aa80
Pass expense-report NVPs into the search export check
dukenv0307 Oct 13, 2026
99458cf
Honor queued lastActionType with partial action caches
dukenv0307 Oct 13, 2026
d72b9d0
Merge branch 'main' into unify-export-rule
dukenv0307 Oct 13, 2026
583d4a4
Merge branch 'main' into unify-export-rule
dukenv0307 Oct 16, 2026
364b0b1
Merge branch 'main' into unify-export-rule
dukenv0307 Oct 19, 2026
bb98a14
update the non-reimbursable case
dukenv0307 Oct 19, 2026
69064af
Merge branch 'main' into unify-export-rule
dukenv0307 Oct 20, 2026
8de29f4
Card-only reports lose Export until transactions load
dukenv0307 Oct 20, 2026
19ba9ee
merge main
dukenv0307 Oct 22, 2026
e8b8af6
update comments
dukenv0307 Oct 22, 2026
d1a0cdd
Merge branch 'main' into unify-export-rule
dukenv0307 Oct 22, 2026
747178a
Merge branch 'main' into unify-export-rule
dukenv0307 Oct 23, 2026
4671486
add more tests
dukenv0307 Oct 23, 2026
25a1657
Merge branch 'main' into unify-export-rule
MelvinBot Oct 26, 2026
bc4f581
Add Given, When, Then comments to the export rule tests
MelvinBot Oct 26, 2026
3db605f
Update ESLint seatbelt for SearchUIUtils
MelvinBot Oct 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion config/eslint/eslint.seatbelt.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2126,6 +2126,7 @@ const CONST = {
UNREPORTED_TRANSACTION: 'UNREPORTEDTRANSACTION',
UNDELETED_TRANSACTION: 'UNDELETEDTRANSACTION',
OUTDATED_BANK_ACCOUNT: 'OUTDATEDBANKACCOUNT', // OldDot Action
QUEUED_FOR_EXPORT: 'QUEUEDFOREXPORT',
Comment thread
dukenv0307 marked this conversation as resolved.
Resolved
REIMBURSED: 'REIMBURSED',
REIMBURSEMENT_ACH_BOUNCE: 'REIMBURSEMENTACHBOUNCE', // OldDot Action
REIMBURSEMENT_ACH_CANCELED: 'REIMBURSEMENTACHCANCELED', // OldDot Action
Expand Down
40 changes: 39 additions & 1 deletion src/libs/PolicyUtils/connections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
* 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';
Expand All @@ -11,7 +12,9 @@ import type {OnyxInputOrEntry, Policy} from '@src/types/onyx';
import type {ConnectionLastSync, ConnectionName, Connections, NetSuiteConnection, PolicyConnectionSyncProgress} from '@src/types/onyx/Policy';

import type {OnyxEntry} from 'react-native-onyx';
import type {TupleToUnion} from 'type-fest';
import type {TupleToUnion, ValueOf} from 'type-fest';

import {CONST as COMMON_CONST} from 'expensify-common';

type AccountingConnectionName = TupleToUnion<typeof CONST.POLICY.CONNECTIONS.ACCOUNTING_CONNECTION_NAMES>;

Expand Down Expand Up @@ -124,10 +127,44 @@ function getConnectionExporters(policy: OnyxInputOrEntry<Policy>): Array<string
policy?.connections?.rillet?.config?.export?.exporter,
policy?.connections?.dualEntry?.config?.export?.exporter,
policy?.connections?.campfire?.config?.export?.exporter,
policy?.connections?.financialforce?.config?.export?.exporter,
Comment thread
mountiny marked this conversation as resolved.
Resolved
policy?.connections?.businessCentral?.config?.export?.exporter,
policy?.connections?.zohoBooks?.config?.export?.exporter,
];
}

/**
* 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<typeof COMMON_CONST.INTEGRATIONS.ACCOUNTING_METHOD> {
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;
Comment thread
dukenv0307 marked this conversation as resolved.
Resolved
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;
}
}

export {
getAccountingConnectionNames,
getUberConnectionErrorDirectlyFromPolicy,
Expand All @@ -141,4 +178,5 @@ export {
getCurrentConnectionName,
isPreferredExporter,
getConnectionExporters,
getIntegrationAccountingMethod,
};
6 changes: 6 additions & 0 deletions src/libs/ReportActionsUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1355,6 +1355,12 @@ function shouldReportActionBeVisible(
return false;
}

// Queued-for-export is a backend bookkeeping action with no user-facing message. Hide it explicitly so a future
// backend change that adds message text does not surface it in chat. Matches Auth's Report::HIDDEN_ACTIONS.
if (actionName === CONST.REPORT.ACTIONS.TYPE.QUEUED_FOR_EXPORT) {
return false;
}

// Hide automatic TAKE_CONTROL actions created by OldDot's auto-pay workflow.
// When automaticAction is true and there are no mentionedAccountIDs, this indicates
// the TAKE_CONTROL was a side effect of auto-pay rather than a manual approver change.
Expand Down
12 changes: 10 additions & 2 deletions src/libs/ReportPreviewActionUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
canAdminPayReport,
getIntegrationAccountingMethod,
getSubmitToAccountID,
getValidConnectedIntegration,
hasDynamicExternalWorkflow,
Expand All @@ -22,6 +25,7 @@ import {
getParentReport,
hasExportError as hasExportErrorUtil,
hasOnlyNonReimbursableTransactions,
isCardOnlyReport,
hasSettledZeroReimbursableSpend,
isClosedReport,
isCurrentUserSubmitter,
Expand Down Expand Up @@ -193,7 +197,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);
Expand All @@ -220,6 +224,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) && isCardOnlyReport(report, transactions));
}

return isApproved || isReimbursed || isClosed;
}

Expand Down Expand Up @@ -288,7 +296,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;
}

Expand Down
38 changes: 34 additions & 4 deletions src/libs/ReportPrimaryActionUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,12 @@ import type {BankAccountList, Policy, Report, ReportAction, ReportMetadata, Repo
import type {OnyxCollection, OnyxEntry} from 'react-native-onyx';
import type {ValueOf} from 'type-fest';

import {CONST as COMMON_CONST} from 'expensify-common';

import {
arePaymentsEnabled as arePaymentsEnabledUtils,
canAdminPayReport,
getIntegrationAccountingMethod,
getManagerAccountID,
getSubmitToAccountID,
getValidConnectedIntegration,
Expand Down Expand Up @@ -40,6 +43,7 @@ import {
hasExportError as hasExportErrorUtil,
hasOnlyHeldExpenses,
hasOnlyNonReimbursableTransactions,
isCardOnlyReport,
hasSettledZeroReimbursableSpend,
isArchivedReport,
isClosedReport as isClosedReportUtils,
Expand All @@ -53,6 +57,7 @@ import {
isPayBlocked,
isPayer,
isProcessingReport as isProcessingReportUtils,
isQueuedForExport as isQueuedForExportUtil,
isReportApproved as isReportApprovedUtils,
isReportManager,
isSettled,
Expand Down Expand Up @@ -105,6 +110,17 @@ type IsPrimaryPayActionParams = {
canNonPayerAdminPay?: boolean;
};

type IsExportActionOptions = {
/** Whether an admin who is not the exporter can export. Defaults to true. */
shouldAllowAdmin?: boolean;

/** 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<Rule>) {
if (isChatReportArchived) {
return false;
Expand Down Expand Up @@ -280,7 +296,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;
}
Expand All @@ -295,7 +311,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;
}

Expand All @@ -310,14 +330,24 @@ 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)) {
Comment thread
dukenv0307 marked this conversation as resolved.
Resolved
Comment thread
dukenv0307 marked this conversation as resolved.
Resolved
return false;
}

const isReportReimbursed = isSettled(report);
const isReportApproved = isReportApprovedUtils({report});
const isReportClosed = isClosedReportUtils(report);

// 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) && isCardOnlyReport(report, options?.reportTransactions));
}

if (isReportApproved || isReportReimbursed || isReportClosed) {
return true;
}
Expand Down Expand Up @@ -569,7 +599,7 @@ function getReportPrimaryAction(params: GetReportPrimaryActionParams): ValueOf<t
return CONST.REPORT.PRIMARY_ACTIONS.PAY;
}

if (isExportAction(report, currentUserLogin, policy, reportActions)) {
if (isExportAction(report, currentUserLogin, policy, reportActions, {reportNameValuePairs, reportTransactions})) {
return CONST.REPORT.PRIMARY_ACTIONS.EXPORT_TO_ACCOUNTING;
}

Expand Down
72 changes: 54 additions & 18 deletions src/libs/ReportUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2944,6 +2944,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<Report>, transactionsParam?: Transaction[]): boolean {
Comment thread
dukenv0307 marked this conversation as resolved.
Resolved
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
*/
Expand Down Expand Up @@ -14200,6 +14212,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<ReportAction | undefined>((latest, action) => (!latest || action.created > latest.created ? action : latest), undefined);

return latestAction?.actionName === CONST.REPORT.ACTIONS.TYPE.QUEUED_FOR_EXPORT;
}

function canBeExported(report: OnyxEntry<Report>) {
if (!report?.statusNum) {
return false;
Expand Down Expand Up @@ -14235,6 +14256,23 @@ function getIntegrationNameFromExportMessage(reportActions: OnyxEntry<ReportActi
}
}

/** Actions that reset the approval state, which invalidates any export or export failure recorded before them */
const RESET_APPROVAL_ACTION_TYPES = new Set<string>([
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<ReportActions> | ReportAction[], report?: OnyxEntry<Report>): boolean {
if (report?.isExportedToIntegration !== undefined) {
return report.isExportedToIntegration;
Expand All @@ -14247,26 +14285,12 @@ function isExported(reportActions: OnyxEntry<ReportActions> | 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<string>([
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<string>(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;
Expand Down Expand Up @@ -14297,11 +14321,21 @@ function hasExportError(reportActions: OnyxEntry<ReportActions> | 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);

const integrationMessages = reportActionList.filter(isIntegrationMessageAction);
if (integrationMessages.length === 0) {
return false;
}

return Object.values(reportActions).some((action) => isIntegrationMessageAction(action) && !getOriginalMessage(action)?.result?.reconciled);
// 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;
}

function doesReportContainRequestsFromMultipleUsers(iouReport: OnyxEntry<Report>, shouldExcludeDeletedTransactions = false): boolean {
Expand Down Expand Up @@ -15645,9 +15679,11 @@ export {
canBeExported,
canShareReport,
isExported,
isQueuedForExport,
hasExpensifyGuidesEmails,
hasExportError,
hasOnlyNonReimbursableTransactions,
isCardOnlyReport,
hasSettledZeroReimbursableSpend,
isPayOptional,
getReportLastMessage,
Expand Down
Loading
Loading