diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 957ad055805e..45b6cd8f03ed 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1984,6 +1984,8 @@ const CONST = { IN_APP: 'in-app', }, EXPORT_TEMPLATE: 'exportTemplate', + // Marker present in a template column formula (e.g. {expense:category:glcode}) that requires a workspace policyID to resolve GL codes + EXPORT_TEMPLATE_GL_CODE_COLUMN_MARKER: ':glcode', NEXT_STEP: { MESSAGE_KEY: { WAITING_TO_ADD_TRANSACTIONS: 'waitingToAddTransactions', diff --git a/src/hooks/useExportActions.ts b/src/hooks/useExportActions.ts index 7dce009065f3..92bc77e1157c 100644 --- a/src/hooks/useExportActions.ts +++ b/src/hooks/useExportActions.ts @@ -6,7 +6,7 @@ import type {PopoverMenuItem} from '@components/PopoverMenu'; import {useSearchSelectionActions} from '@components/Search/SearchContext'; import {openOldDotLink} from '@libs/actions/Link'; import {exportReportToCSV, exportReportToPDF, exportToIntegration, markAsManuallyExported} from '@libs/actions/Report'; -import {getExportTemplates, queueExportSearchWithTemplate} from '@libs/actions/Search'; +import {doesExportTemplateRequireWorkspacePolicy, getExportTemplates, queueExportSearchWithTemplate} from '@libs/actions/Search'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {getConnectedIntegration, getValidConnectedIntegration} from '@libs/PolicyUtils'; import {getFilteredReportActionsForReportView} from '@libs/ReportActionsUtils'; @@ -212,7 +212,13 @@ function useExportActions({reportID, policy, onPDFModalOpen}: UseExportActionsPa value: template.templateName, description: template.description, sentryLabel: CONST.SENTRY_LABEL.MORE_MENU.EXPORT_FILE, - onSelected: () => beginExportWithTemplate(template.templateName, template.type, transactionIDs, template.policyID), + onSelected: () => + beginExportWithTemplate( + template.templateName, + template.type, + transactionIDs, + template.policyID ?? (doesExportTemplateRequireWorkspacePolicy(template) ? moneyRequestReport?.policyID : undefined), + ), }; } diff --git a/src/hooks/useSearchBulkActions.ts b/src/hooks/useSearchBulkActions.ts index ab9361642928..d43577e2f612 100644 --- a/src/hooks/useSearchBulkActions.ts +++ b/src/hooks/useSearchBulkActions.ts @@ -19,6 +19,7 @@ import {setupMergeTransactionDataAndNavigate} from '@libs/actions/MergeTransacti import {deleteAppReport, exportReportToPDF, markAsManuallyExported, moveIOUReportToPolicy, moveIOUReportToPolicyAndInviteSubmitter} from '@libs/actions/Report'; import { approveMoneyRequestOnSearch, + doesExportTemplateRequireWorkspacePolicy, exportSearchItemsToCSV, exportToIntegrationOnSearch, getExportTemplates, @@ -572,7 +573,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { const {duplicateTransactions, duplicateTransactionViolations} = useDuplicateTransactionsAndViolations(selectedTransactionsKeys); const beginExportWithTemplate = useCallback( - (templateName: string, templateType: string, policyID: string | undefined) => { + (templateName: string, templateType: string, policyID: string | undefined, requiresWorkspacePolicy: boolean) => { const emptyReports = selectedReports?.filter((selectedReport) => { if (!selectedReport) { @@ -593,6 +594,22 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { return; } const serializedQuery = queryJSON ? serializeQueryJSONForBackend(queryJSON) : JSON.stringify(queryJSON); + + // Only resolve a workspace policyID for templates that reference GL codes. Account-level templates with plain + // {expense:category}/{expense:tag} columns must keep policyID undefined, otherwise the backend can't generate the + // file (see #93088). When resolving, only fall back to the selection's workspace when every selected transaction + // belongs to a single real workspace, so GL codes aren't resolved against the wrong workspace. + const resolveFallbackPolicyID = () => { + if (!requiresWorkspacePolicy) { + return undefined; + } + if (areAllMatchingItemsSelected) { + return queryJSON?.policyID?.length === 1 ? queryJSON.policyID.at(0) : undefined; + } + const allSelectedHavePolicy = Object.values(selectedTransactions).every((transaction) => !!transaction.policyID); + return allSelectedHavePolicy && selectedPolicyIDs.length === 1 ? selectedPolicyIDs.at(0) : undefined; + }; + const exportPolicyID = policyID ?? resolveFallbackPolicyID(); let exportID: string; if (areAllMatchingItemsSelected) { @@ -603,7 +620,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { jsonQuery: serializedQuery, reportIDList: [], transactionIDList: [], - policyID, + policyID: exportPolicyID, }, true, ); @@ -616,14 +633,24 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { jsonQuery: isGroupExport ? serializeQueryJSONForBackend(addSelectedGroupsFilter(queryJSON, selectedTransactions, currentSearchResults?.data)) : '{}', reportIDList: isGroupExport ? [] : selectedTransactionReportIDs, transactionIDList: isGroupExport ? [] : selectedTransactionsKeys, - policyID, + policyID: exportPolicyID, }, true, ); } setActiveExportID(exportID); }, - [selectedReports, selectedTransactions, isOffline, areAllMatchingItemsSelected, currentSearchResults?.data, queryJSON, selectedTransactionReportIDs, selectedTransactionsKeys], + [ + selectedReports, + selectedTransactions, + isOffline, + areAllMatchingItemsSelected, + currentSearchResults?.data, + queryJSON, + selectedTransactionReportIDs, + selectedTransactionsKeys, + selectedPolicyIDs, + ], ); const policyIDsWithVBBA = useMemo(() => { @@ -1509,7 +1536,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { icon: isStandardTemplate ? expensifyIcons.Table : expensifyIcons.TablePencil, description: template.description, onSelected: () => { - beginExportWithTemplate(template.templateName, template.type, template.policyID); + beginExportWithTemplate(template.templateName, template.type, template.policyID, doesExportTemplateRequireWorkspacePolicy(template)); }, shouldCloseModalOnSelect: true, shouldCallAfterModalHide: true, diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index 23d4526eb7e2..32be371571bd 100644 --- a/src/libs/actions/Search.ts +++ b/src/libs/actions/Search.ts @@ -1392,6 +1392,16 @@ function getExportTemplates( return [...exportTemplates, ...integrationsTemplates, ...accountInAppTemplates, ...policyInAppTemplates]; } +/** + * Account-level export templates have no policyID by design. Only templates that reference GL codes + * (e.g. {expense:category:glcode}) need a workspace policyID for the backend to resolve them. Plain + * {expense:category}/{expense:tag} templates must keep policyID undefined — sending a workspace policyID + * for them prevents the backend from generating the file for account-level templates (see #93088). + */ +function doesExportTemplateRequireWorkspacePolicy(template: ExportTemplate): boolean { + return (template.columns ?? []).some((column) => column.includes(CONST.EXPORT_TEMPLATE_GL_CODE_COLUMN_MARKER)); +} + /** * Updates the form values for the advanced filters search form. */ @@ -1698,6 +1708,7 @@ export { handleBulkPayItemSelected, isCurrencySupportWalletBulkPay, getExportTemplates, + doesExportTemplateRequireWorkspacePolicy, getReportType, getTotalFormattedAmount, setOptimisticDataForTransactionThreadPreview, diff --git a/src/types/onyx/ExportTemplate.ts b/src/types/onyx/ExportTemplate.ts index 14f9aae69a73..54754f88756f 100644 --- a/src/types/onyx/ExportTemplate.ts +++ b/src/types/onyx/ExportTemplate.ts @@ -16,6 +16,9 @@ type ExportTemplate = OnyxCommon.OnyxValueWithOfflineFeedback<{ /** Description of the template */ description: string; + + /** Column formulas the template exports (e.g. {expense:category:glcode}); present for in-app/CSV layout templates */ + columns?: string[]; }>; export default ExportTemplate;