From 7293bb4a459e2aa2fc19c07f7354a2a4d9d3302f Mon Sep 17 00:00:00 2001 From: Mukher Date: Thu, 2 Apr 2026 09:27:29 +0500 Subject: [PATCH 01/32] Add transactionAutoSelections parameter to policy deletion APIs and implement auto-selection logic --- .../DeletePolicyDistanceRatesParams.ts | 2 + .../API/parameters/DeletePolicyTagsParams.ts | 2 + .../API/parameters/DeletePolicyTaxesParams.ts | 2 + .../DeleteWorkspaceCategoriesParams.ts | 5 + src/libs/ReportUtils.ts | 158 ++++++++++++++++-- src/libs/actions/Policy/Category.ts | 12 +- src/libs/actions/Policy/DistanceRate.ts | 32 +++- src/libs/actions/Policy/Tag.ts | 5 +- src/libs/actions/TaxRate.ts | 61 ++++--- .../PolicyDistanceRateDetailsPage.tsx | 21 ++- .../distanceRates/PolicyDistanceRatesPage.tsx | 16 +- .../workspace/taxes/WorkspaceEditTaxPage.tsx | 4 +- .../workspace/taxes/WorkspaceTaxesPage.tsx | 6 +- tests/actions/PolicyTaxTest.ts | 17 +- tests/unit/DistanceRateTest.ts | 8 +- 15 files changed, 286 insertions(+), 65 deletions(-) diff --git a/src/libs/API/parameters/DeletePolicyDistanceRatesParams.ts b/src/libs/API/parameters/DeletePolicyDistanceRatesParams.ts index d4f972ff9757..bd0c9a353445 100644 --- a/src/libs/API/parameters/DeletePolicyDistanceRatesParams.ts +++ b/src/libs/API/parameters/DeletePolicyDistanceRatesParams.ts @@ -2,6 +2,8 @@ type DeletePolicyDistanceRatesParams = { policyID: string; customUnitID: string; customUnitRateID: string[]; + /** JSON-encoded array of auto-selected transaction updates when only one valid value remains. */ + transactionAutoSelections?: string; }; export default DeletePolicyDistanceRatesParams; diff --git a/src/libs/API/parameters/DeletePolicyTagsParams.ts b/src/libs/API/parameters/DeletePolicyTagsParams.ts index 0094ce318390..961bcfd4096f 100644 --- a/src/libs/API/parameters/DeletePolicyTagsParams.ts +++ b/src/libs/API/parameters/DeletePolicyTagsParams.ts @@ -5,6 +5,8 @@ type DeletePolicyTagsParams = { * Array */ tags: string; + /** JSON-encoded array of auto-selected transaction updates when only one valid value remains. */ + transactionAutoSelections?: string; }; export default DeletePolicyTagsParams; diff --git a/src/libs/API/parameters/DeletePolicyTaxesParams.ts b/src/libs/API/parameters/DeletePolicyTaxesParams.ts index 9e0963cdcb28..b444fc8d9d3e 100644 --- a/src/libs/API/parameters/DeletePolicyTaxesParams.ts +++ b/src/libs/API/parameters/DeletePolicyTaxesParams.ts @@ -6,6 +6,8 @@ type DeletePolicyTaxesParams = { * Each element is a tax name */ taxNames: string; + /** JSON-encoded array of auto-selected transaction updates when only one valid value remains. */ + transactionAutoSelections?: string; }; export default DeletePolicyTaxesParams; diff --git a/src/libs/API/parameters/DeleteWorkspaceCategoriesParams.ts b/src/libs/API/parameters/DeleteWorkspaceCategoriesParams.ts index 07a8103a9b06..d884f8c58651 100644 --- a/src/libs/API/parameters/DeleteWorkspaceCategoriesParams.ts +++ b/src/libs/API/parameters/DeleteWorkspaceCategoriesParams.ts @@ -5,6 +5,11 @@ type DeleteWorkspaceCategoriesParams = { * Each element in the array is a string that specifies the name of a category. */ categories: string; + /** + * JSON-encoded array of auto-selected transaction updates when only one valid value remains. + * Each element: {transactionID, category?, tag?, taxCode?} + */ + transactionAutoSelections?: string; }; export default DeleteWorkspaceCategoriesParams; diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 7be23bade49a..ffa6ef315ec8 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -162,6 +162,7 @@ import { getPolicyNameByID, getPolicyRole, getRuleApprovers, + getSortedTagKeys, getSubmitToAccountID, hasDependentTags as hasDependentTagsPolicyUtils, hasDynamicExternalWorkflow, @@ -290,6 +291,7 @@ import { getRecentTransactions, getReimbursable, getTag, + getTagArrayFromName, getTaxAmount, getTaxCode, getTaxName, @@ -2075,27 +2077,31 @@ function isAwaitingFirstLevelApproval(report: OnyxEntry): boolean { */ function pushTransactionViolationsOnyxData( onyxData: OnyxData< - typeof ONYXKEYS.COLLECTION.POLICY_CATEGORIES | typeof ONYXKEYS.COLLECTION.POLICY | typeof ONYXKEYS.COLLECTION.POLICY_TAGS | typeof ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS + | typeof ONYXKEYS.COLLECTION.POLICY_CATEGORIES + | typeof ONYXKEYS.COLLECTION.POLICY + | typeof ONYXKEYS.COLLECTION.POLICY_TAGS + | typeof ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS + | typeof ONYXKEYS.COLLECTION.TRANSACTION >, policyData: PolicyData, policyUpdate: Partial = {}, categoriesUpdate: Record> = {}, tagListsUpdate: Record> = {}, ) { - const nonInvoiceReportTransactionsAndViolations = policyData.reports.reduce((acc, report) => { + const nonInvoiceReportItems = policyData.reports.reduce>((acc, report) => { // Skipping invoice reports since they should not have any category or tag violations if (isInvoiceReport(report)) { return acc; } const reportTransactionsAndViolations = policyData.transactionsAndViolations[report.reportID]; if (!isEmptyObject(reportTransactionsAndViolations) && !isEmptyObject(reportTransactionsAndViolations.transactions)) { - acc.push(reportTransactionsAndViolations); + acc.push({report, transactionsAndViolations: reportTransactionsAndViolations}); } return acc; }, []); - if (nonInvoiceReportTransactionsAndViolations.length === 0) { - return; + if (nonInvoiceReportItems.length === 0) { + return []; } const updatedTagListsNames = Object.keys(tagListsUpdate); @@ -2106,7 +2112,7 @@ function pushTransactionViolationsOnyxData( const isTagListsUpdateEmpty = updatedTagListsNames.length === 0; const isCategoriesUpdateEmpty = updatedCategoriesNames.length === 0; if (isPolicyUpdateEmpty && isTagListsUpdateEmpty && isCategoriesUpdateEmpty) { - return; + return []; } // Merge the existing policy with the optimistic updates @@ -2166,19 +2172,149 @@ function pushTransactionViolationsOnyxData( }, {}), }; - const hasDependentTags = hasDependentTagsPolicyUtils(optimisticPolicy, optimisticTagLists); + const hasDependentTagsValue = hasDependentTagsPolicyUtils(optimisticPolicy, optimisticTagLists); + + // Compute sole remaining values for auto-selection when a policy value is deleted + const enabledCategoryKeys = Object.entries(optimisticCategories) + .filter(([, cat]) => cat.enabled && cat.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) + .map(([key]) => key); + const singleRemainingCategory = enabledCategoryKeys.length === 1 ? enabledCategoryKeys.at(0) : undefined; + + const tagListKeys = Object.keys(optimisticTagLists); + + // Single-level tag auto-selection + let singleRemainingTag: string | undefined; + if (tagListKeys.length === 1) { + const tagListName = tagListKeys.at(0) ?? ''; + const enabledTagKeys = Object.entries(optimisticTagLists[tagListName]?.tags ?? {}) + .filter(([, tag]) => tag.enabled && tag.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) + .map(([key]) => key); + singleRemainingTag = enabledTagKeys.length === 1 ? enabledTagKeys.at(0) : undefined; + } + + // Multi-level tag auto-selection (per level) + let perLevelSingleTag: Array = []; + if (tagListKeys.length > 1) { + const sortedTagKeys = getSortedTagKeys(optimisticTagLists); + perLevelSingleTag = sortedTagKeys.map((key) => { + const tags = optimisticTagLists[key]?.tags ?? {}; + const enabledKeys = Object.entries(tags) + .filter(([, tag]) => tag.enabled && tag.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) + .map(([k]) => k); + return enabledKeys.length === 1 ? enabledKeys.at(0) : undefined; + }); + } + + // Tax auto-selection + const optimisticTaxes = optimisticPolicy.taxRates?.taxes ?? {}; + const enabledTaxKeys = Object.entries(optimisticTaxes) + .filter(([, tax]) => !tax.isDisabled && tax.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) + .map(([key]) => key); + const singleRemainingTaxCode = enabledTaxKeys.length === 1 ? enabledTaxKeys.at(0) : undefined; + + // Collect auto-selected transaction updates to return to callers for the API request + const autoSelections: Array<{transactionID: string; category?: string; tag?: string; taxCode?: string}> = []; // Iterate through all policy reports to find transactions that need optimistic violations - for (const {transactions, violations} of nonInvoiceReportTransactionsAndViolations) { + for (const { + report, + transactionsAndViolations: {transactions, violations}, + } of nonInvoiceReportItems) { + const isEligibleForAutoSelect = isOpenOrProcessingReport(report); + for (const transaction of Object.values(transactions)) { + let modifiedTransaction = transaction; + const transactionUpdates: Partial = {}; + const transactionRollback: Partial = {}; + + if (isEligibleForAutoSelect) { + // Category auto-select: if the transaction's category is out of policy and only one enabled category remains + if (singleRemainingCategory && transaction.category && !optimisticCategories[transaction.category]?.enabled) { + transactionUpdates.category = singleRemainingCategory; + transactionRollback.category = transaction.category; + } + + // Single-level tag auto-select + if (tagListKeys.length === 1 && singleRemainingTag && transaction.tag) { + const tagListName = tagListKeys.at(0) ?? ''; + const isTagInPolicy = !!optimisticTagLists[tagListName]?.tags?.[transaction.tag]?.enabled; + if (!isTagInPolicy) { + transactionUpdates.tag = singleRemainingTag; + transactionRollback.tag = transaction.tag; + } + } + + // Multi-level tag auto-select + if (tagListKeys.length > 1 && transaction.tag) { + const sortedTagKeys = getSortedTagKeys(optimisticTagLists); + const currentTags = getTagArrayFromName(transaction.tag); + let anyTagChanged = false; + const newTags = [...currentTags]; + + for (let i = 0; i < sortedTagKeys.length; i++) { + const currentTag = currentTags.at(i); + if (!currentTag) { + continue; + } + const sortedTagKey = sortedTagKeys.at(i) ?? ''; + const levelTags = optimisticTagLists[sortedTagKey]?.tags ?? {}; + const isInPolicy = !!levelTags[currentTag]?.enabled; + const singleTag = perLevelSingleTag.at(i); + if (!isInPolicy && singleTag) { + newTags[i] = singleTag; + anyTagChanged = true; + } + } + + if (anyTagChanged) { + transactionUpdates.tag = newTags.join(CONST.COLON); + transactionRollback.tag = transaction.tag; + } + } + + // Tax auto-select: if the transaction's tax code is out of policy and only one enabled tax remains + if (singleRemainingTaxCode && transaction.taxCode) { + const isTaxInPolicy = !!optimisticTaxes[transaction.taxCode] && !optimisticTaxes[transaction.taxCode].isDisabled; + if (!isTaxInPolicy) { + transactionUpdates.taxCode = singleRemainingTaxCode; + transactionRollback.taxCode = transaction.taxCode; + } + } + } + + // If auto-selection modified the transaction, push optimistic transaction updates + if (Object.keys(transactionUpdates).length > 0) { + modifiedTransaction = {...transaction, ...transactionUpdates}; + + onyxData.optimisticData?.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, + value: transactionUpdates, + }); + + onyxData.failureData?.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, + value: transactionRollback, + }); + + // Collect auto-selection data for the API request + autoSelections.push({ + transactionID: transaction.transactionID, + ...(transactionUpdates.category !== undefined && {category: transactionUpdates.category}), + ...(transactionUpdates.tag !== undefined && {tag: transactionUpdates.tag}), + ...(transactionUpdates.taxCode !== undefined && {taxCode: transactionUpdates.taxCode}), + }); + } + const existingViolations = violations[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transaction.transactionID}`]; const optimisticViolations = ViolationsUtils.getViolationsOnyxData( - transaction, + modifiedTransaction, existingViolations ?? [], optimisticPolicy, optimisticTagLists, optimisticCategories, - hasDependentTags, + hasDependentTagsValue, false, ); @@ -2192,6 +2328,8 @@ function pushTransactionViolationsOnyxData( } } } + + return autoSelections; } /** diff --git a/src/libs/actions/Policy/Category.ts b/src/libs/actions/Policy/Category.ts index 7223a9a81863..a7a1001741a2 100644 --- a/src/libs/actions/Policy/Category.ts +++ b/src/libs/actions/Policy/Category.ts @@ -78,7 +78,12 @@ type SetWorkspaceCategoryEnabledParams = { function appendSetupCategoriesOnboardingData( onyxData: OnyxData< - typeof ONYXKEYS.COLLECTION.POLICY_CATEGORIES | typeof ONYXKEYS.COLLECTION.POLICY_CATEGORIES_DRAFT | typeof ONYXKEYS.COLLECTION.REPORT | typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS + | typeof ONYXKEYS.COLLECTION.POLICY_CATEGORIES + | typeof ONYXKEYS.COLLECTION.POLICY_CATEGORIES_DRAFT + | typeof ONYXKEYS.COLLECTION.REPORT + | typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS + | typeof ONYXKEYS.COLLECTION.TRANSACTION + | typeof ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS >, setupCategoryTaskReport: OnyxEntry, setupCategoryTaskParentReport: OnyxEntry, @@ -1348,7 +1353,7 @@ function deleteWorkspaceCategories( } : {}; - const onyxData: OnyxData = { + const onyxData: OnyxData = { optimisticData: [ { onyxMethod: Onyx.METHOD.MERGE, @@ -1382,7 +1387,7 @@ function deleteWorkspaceCategories( ], }; - pushTransactionViolationsOnyxData(onyxData, policyData, optimisticPolicyData, optimisticPolicyCategoriesData); + const autoSelections = pushTransactionViolationsOnyxData(onyxData, policyData, optimisticPolicyData, optimisticPolicyCategoriesData); appendSetupCategoriesOnboardingData( onyxData, setupCategoryTaskReport, @@ -1396,6 +1401,7 @@ function deleteWorkspaceCategories( const parameters = { policyID, categories: JSON.stringify(categoryNamesToDelete), + ...(autoSelections.length > 0 && {transactionAutoSelections: JSON.stringify(autoSelections)}), }; API.write(WRITE_COMMANDS.DELETE_WORKSPACE_CATEGORIES, parameters, onyxData); diff --git a/src/libs/actions/Policy/DistanceRate.ts b/src/libs/actions/Policy/DistanceRate.ts index 762e0030cf7a..a662659b90b8 100644 --- a/src/libs/actions/Policy/DistanceRate.ts +++ b/src/libs/actions/Policy/DistanceRate.ts @@ -394,7 +394,7 @@ function deletePolicyDistanceRates( policyID: string, customUnit: CustomUnit, rateIDsToDelete: string[], - transactionIDsAffected: string[], + transactionsAffected: Array<{transactionID: string; customUnitRateID: string}>, transactionViolations: OnyxCollection, ) { const currentRates = customUnit.rates; @@ -413,7 +413,13 @@ function deletePolicyDistanceRates( }; } - const optimisticData: Array> = [ + // Check if there's exactly one remaining enabled rate for auto-selection + const remainingEnabledRateIDs = Object.entries(currentRates) + .filter(([rateID, rate]) => !rateIDsToDelete.includes(rateID) && rate.enabled) + .map(([rateID]) => rateID); + const singleRemainingRateID = remainingEnabledRateIDs.length === 1 ? remainingEnabledRateIDs.at(0) : undefined; + + const optimisticData: Array> = [ { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, @@ -427,7 +433,7 @@ function deletePolicyDistanceRates( }, ]; - const failureData: Array> = [ + const failureData: Array> = [ { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, @@ -443,13 +449,30 @@ function deletePolicyDistanceRates( const optimisticTransactionsViolations: Array> = []; const failureTransactionsViolations: Array> = []; + const distanceRateAutoSelections: Array<{transactionID: string; customUnitRateID: string}> = []; - for (const transactionID of transactionIDsAffected) { + for (const {transactionID, customUnitRateID} of transactionsAffected) { const currentTransactionViolations = transactionViolations?.[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`] ?? []; if (currentTransactionViolations.some((violation) => violation.name === CONST.VIOLATIONS.CUSTOM_UNIT_OUT_OF_POLICY)) { return; } + // If there's exactly one remaining enabled rate, auto-select it instead of adding a violation + if (singleRemainingRateID) { + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + value: {comment: {customUnit: {customUnitRateID: singleRemainingRateID}}}, + }); + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + value: {comment: {customUnit: {customUnitRateID}}}, + }); + distanceRateAutoSelections.push({transactionID, customUnitRateID: singleRemainingRateID}); + continue; + } + optimisticTransactionsViolations.push({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`, @@ -477,6 +500,7 @@ function deletePolicyDistanceRates( policyID, customUnitID: customUnit.customUnitID, customUnitRateID: rateIDsToDelete, + ...(distanceRateAutoSelections.length > 0 && {transactionAutoSelections: JSON.stringify(distanceRateAutoSelections)}), }; API.write(WRITE_COMMANDS.DELETE_POLICY_DISTANCE_RATES, params, {optimisticData, failureData}); diff --git a/src/libs/actions/Policy/Tag.ts b/src/libs/actions/Policy/Tag.ts index 85d11f28327e..cf12333e4a2c 100644 --- a/src/libs/actions/Policy/Tag.ts +++ b/src/libs/actions/Policy/Tag.ts @@ -473,7 +473,7 @@ function deletePolicyTags(policyData: PolicyData, tagsToDelete: string[]) { }, }; - const onyxData: OnyxData = { + const onyxData: OnyxData = { optimisticData: [ { onyxMethod: Onyx.METHOD.MERGE, @@ -519,11 +519,12 @@ function deletePolicyTags(policyData: PolicyData, tagsToDelete: string[]) { ], }; - pushTransactionViolationsOnyxData(onyxData, policyData, {}, {}, policyTagsOptimisticData); + const autoSelections = pushTransactionViolationsOnyxData(onyxData, policyData, {}, {}, policyTagsOptimisticData); const parameters = { policyID, tags: JSON.stringify(tagsToDelete), + ...(autoSelections.length > 0 && {transactionAutoSelections: JSON.stringify(autoSelections)}), }; API.write(WRITE_COMMANDS.DELETE_POLICY_TAGS, parameters, onyxData); diff --git a/src/libs/actions/TaxRate.ts b/src/libs/actions/TaxRate.ts index 07f9a4b942b2..3ef983fa4463 100644 --- a/src/libs/actions/TaxRate.ts +++ b/src/libs/actions/TaxRate.ts @@ -2,6 +2,7 @@ import type {NullishDeep, OnyxEntry} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import type {FormOnyxValues} from '@components/Form/types'; import type {LocaleContextProps, LocalizedTranslate} from '@components/LocaleContextProvider'; +import type PolicyData from '@hooks/usePolicyData/types'; import * as API from '@libs/API'; import type { CreatePolicyTaxParams, @@ -13,6 +14,7 @@ import type { } from '@libs/API/parameters'; import {WRITE_COMMANDS} from '@libs/API/types'; import {getDistanceRateCustomUnit} from '@libs/PolicyUtils'; +import {pushTransactionViolationsOnyxData} from '@libs/ReportUtils'; import {getFieldRequiredErrors, isExistingTaxCode, isExistingTaxName, isValidPercentage} from '@libs/ValidationUtils'; import CONST from '@src/CONST'; import {getMicroSecondOnyxErrorWithTranslationKey} from '@src/libs/ErrorUtils'; @@ -282,7 +284,8 @@ type TaxRateDeleteMap = Record< | null >; -function deletePolicyTaxes(policy: OnyxEntry, taxesToDelete: string[], localeCompare: LocaleContextProps['localeCompare']) { +function deletePolicyTaxes(policyData: PolicyData, taxesToDelete: string[], localeCompare: LocaleContextProps['localeCompare']) { + const policy = policyData.policy; const policyTaxRates = policy?.taxRates?.taxes; const foreignTaxDefault = policy?.taxRates?.foreignTaxDefault; const firstTaxID = Object.keys(policyTaxRates ?? {}) @@ -328,11 +331,15 @@ function deletePolicyTaxes(policy: OnyxEntry, taxesToDelete: string[], l }; } - const onyxData: OnyxData = { + const customUnitsOptimistic = distanceRateCustomUnit && customUnitID ? {customUnits: {[customUnitID]: {rates: optimisticRates}}} : {}; + const customUnitsSuccess = distanceRateCustomUnit && customUnitID ? {customUnits: {[customUnitID]: {rates: successRates}}} : {}; + const customUnitsFailure = distanceRateCustomUnit && customUnitID ? {customUnits: {[customUnitID]: {rates: failureRates}}} : {}; + + const onyxData: OnyxData = { optimisticData: [ { onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, + key: `${ONYXKEYS.COLLECTION.POLICY}${policy?.id}`, value: { taxRates: { pendingFields: {foreignTaxDefault: isForeignTaxRemoved ? CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE : null}, @@ -342,20 +349,14 @@ function deletePolicyTaxes(policy: OnyxEntry, taxesToDelete: string[], l return acc; }, {}), }, - // @ts-expect-error - will be solved in https://github.com/Expensify/App/issues/73830 - customUnits: distanceRateCustomUnit && - customUnitID && { - [customUnitID]: { - rates: optimisticRates, - }, - }, + ...customUnitsOptimistic, }, }, ], successData: [ { onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, + key: `${ONYXKEYS.COLLECTION.POLICY}${policy?.id}`, value: { taxRates: { pendingFields: {foreignTaxDefault: null}, @@ -364,20 +365,14 @@ function deletePolicyTaxes(policy: OnyxEntry, taxesToDelete: string[], l return acc; }, {}), }, - // @ts-expect-error - will be solved in https://github.com/Expensify/App/issues/73830 - customUnits: distanceRateCustomUnit && - customUnitID && { - [customUnitID]: { - rates: successRates, - }, - }, + ...customUnitsSuccess, }, }, ], failureData: [ { onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, + key: `${ONYXKEYS.COLLECTION.POLICY}${policy?.id}`, value: { taxRates: { pendingFields: {foreignTaxDefault: null}, @@ -390,21 +385,33 @@ function deletePolicyTaxes(policy: OnyxEntry, taxesToDelete: string[], l return acc; }, {}), }, - // @ts-expect-error - will be solved in https://github.com/Expensify/App/issues/73830 - customUnits: distanceRateCustomUnit && - customUnitID && { - [customUnitID]: { - rates: failureRates, - }, - }, + ...customUnitsFailure, }, }, ], }; + // Build the optimistic policy update for tax deletion to pass to violation calculation + const policyTaxUpdate = { + taxRates: { + ...policy?.taxRates, + foreignTaxDefault: (isForeignTaxRemoved ? firstTaxID : foreignTaxDefault) ?? '', + taxes: { + ...policyTaxRates, + ...taxesToDelete.reduce>((acc, taxID) => { + acc[taxID] = {...policyTaxRates[taxID], pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, isDisabled: true}; + return acc; + }, {}), + }, + }, + } as Partial; + + const autoSelections = pushTransactionViolationsOnyxData(onyxData, policyData, policyTaxUpdate); + const parameters = { - policyID: policy.id, + policyID: policy?.id, taxNames: JSON.stringify(taxesToDelete.map((taxID) => policyTaxRates[taxID].name)), + ...(autoSelections.length > 0 && {transactionAutoSelections: JSON.stringify(autoSelections)}), } satisfies DeletePolicyTaxesParams; API.write(WRITE_COMMANDS.DELETE_POLICY_TAXES, parameters, onyxData); diff --git a/src/pages/workspace/distanceRates/PolicyDistanceRateDetailsPage.tsx b/src/pages/workspace/distanceRates/PolicyDistanceRateDetailsPage.tsx index 89463fce9951..c0ea5bc22643 100644 --- a/src/pages/workspace/distanceRates/PolicyDistanceRateDetailsPage.tsx +++ b/src/pages/workspace/distanceRates/PolicyDistanceRateDetailsPage.tsx @@ -63,7 +63,11 @@ function PolicyDistanceRateDetailsPage({route}: PolicyDistanceRateDetailsPagePro const transactionsSelector = useCallback( (transactions: OnyxCollection) => { - return Object.values(transactions ?? {}).reduce((transactionIDs, transaction) => { + const result: {transactionIDs: Set; transactionsAffected: Array<{transactionID: string; customUnitRateID: string}>} = { + transactionIDs: new Set(), + transactionsAffected: [], + }; + for (const transaction of Object.values(transactions ?? {})) { if ( transaction && transaction.reportID && @@ -73,18 +77,23 @@ function PolicyDistanceRateDetailsPage({route}: PolicyDistanceRateDetailsPagePro transaction?.comment?.customUnit?.customUnitRateID && transaction?.comment?.customUnit?.customUnitRateID === rateID ) { - transactionIDs.add(transaction?.transactionID); + result.transactionIDs.add(transaction.transactionID); + result.transactionsAffected.push({ + transactionID: transaction.transactionID, + customUnitRateID: transaction.comment.customUnit.customUnitRateID, + }); } - return transactionIDs; - }, new Set()); + } + return result; }, [customUnitID, rateID, policyReports], ); - const [eligibleTransactionIDs] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION, { + const [eligibleTransactionsData] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION, { selector: transactionsSelector, }); + const eligibleTransactionIDs = eligibleTransactionsData?.transactionIDs; const transactionViolations = useTransactionViolation(eligibleTransactionIDs); const icons = useMemoizedLazyExpensifyIcons(['Trashcan']); @@ -130,7 +139,7 @@ function PolicyDistanceRateDetailsPage({route}: PolicyDistanceRateDetailsPagePro const deleteRate = () => { Navigation.goBack(); - deletePolicyDistanceRates(policyID, customUnit, [rateID], Array.from(eligibleTransactionIDs ?? []), transactionViolations); + deletePolicyDistanceRates(policyID, customUnit, [rateID], eligibleTransactionsData?.transactionsAffected ?? [], transactionViolations); setIsDeleteModalVisible(false); }; diff --git a/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx b/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx index e34cefbd4e01..f8e571484520 100644 --- a/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx +++ b/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx @@ -121,16 +121,20 @@ function PolicyDistanceRatesPage({ transaction?.comment?.customUnit?.customUnitRateID && rateIDs.has(transaction?.comment?.customUnit?.customUnitRateID) ) { + const rateID = transaction.comment.customUnit.customUnitRateID; transactionsData.transactionIDs.add(transaction.transactionID); - if (!transactionsData.rateIDToTransactionIDsMap[transaction?.comment?.customUnit?.customUnitRateID]) { + if (!transactionsData.rateIDToTransactionsMap[rateID]) { // eslint-disable-next-line no-param-reassign - transactionsData.rateIDToTransactionIDsMap[transaction?.comment?.customUnit?.customUnitRateID] = []; + transactionsData.rateIDToTransactionsMap[rateID] = []; } - transactionsData.rateIDToTransactionIDsMap[transaction?.comment?.customUnit?.customUnitRateID]?.push(transaction?.transactionID); + transactionsData.rateIDToTransactionsMap[rateID]?.push({ + transactionID: transaction.transactionID, + customUnitRateID: rateID, + }); } return transactionsData; }, - {transactionIDs: new Set(), rateIDToTransactionIDsMap: {} as Record}, + {transactionIDs: new Set(), rateIDToTransactionsMap: {} as Record>}, ); }, [customUnit?.customUnitID, rateIDs, policyReports], @@ -311,9 +315,9 @@ function PolicyDistanceRatesPage({ return; } - const transactionIDsAffected = selectedDistanceRates.flatMap((rateID) => eligibleTransactionsData?.rateIDToTransactionIDsMap?.[rateID] ?? []); + const transactionsAffected = selectedDistanceRates.flatMap((rateID) => eligibleTransactionsData?.rateIDToTransactionsMap?.[rateID] ?? []); - deletePolicyDistanceRates(policyID, customUnit, selectedDistanceRates, transactionIDsAffected, transactionViolations); + deletePolicyDistanceRates(policyID, customUnit, selectedDistanceRates, transactionsAffected, transactionViolations); setIsDeleteModalVisible(false); // eslint-disable-next-line @typescript-eslint/no-deprecated diff --git a/src/pages/workspace/taxes/WorkspaceEditTaxPage.tsx b/src/pages/workspace/taxes/WorkspaceEditTaxPage.tsx index 40e54ffcb228..61b61a4deda7 100644 --- a/src/pages/workspace/taxes/WorkspaceEditTaxPage.tsx +++ b/src/pages/workspace/taxes/WorkspaceEditTaxPage.tsx @@ -11,6 +11,7 @@ import Text from '@components/Text'; import useConfirmModal from '@hooks/useConfirmModal'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; +import usePolicyData from '@hooks/usePolicyData'; import useThemeStyles from '@hooks/useThemeStyles'; import {clearTaxRateFieldError, deletePolicyTaxes, setPolicyTaxesEnabled} from '@libs/actions/TaxRate'; import {getLatestErrorField} from '@libs/ErrorUtils'; @@ -36,6 +37,7 @@ function WorkspaceEditTaxPage({ }: WorkspaceEditTaxPageBaseProps) { const styles = useThemeStyles(); const {translate, localeCompare} = useLocalize(); + const policyData = usePolicyData(policyID); const currentTaxID = getCurrentTaxID(policy, taxID); const currentTaxRate = currentTaxID && policy?.taxRates?.taxes?.[currentTaxID]; const {showConfirmModal} = useConfirmModal(); @@ -62,7 +64,7 @@ function WorkspaceEditTaxPage({ if (!policyID) { return; } - deletePolicyTaxes(policy, [taxID], localeCompare); + deletePolicyTaxes(policyData, [taxID], localeCompare); Navigation.goBack(); }; diff --git a/src/pages/workspace/taxes/WorkspaceTaxesPage.tsx b/src/pages/workspace/taxes/WorkspaceTaxesPage.tsx index 04b9671a29e4..a3e65dccec6f 100644 --- a/src/pages/workspace/taxes/WorkspaceTaxesPage.tsx +++ b/src/pages/workspace/taxes/WorkspaceTaxesPage.tsx @@ -22,6 +22,7 @@ import useLocalize from '@hooks/useLocalize'; import useMobileSelectionMode from '@hooks/useMobileSelectionMode'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; +import usePolicyData from '@hooks/usePolicyData'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSearchBackPress from '@hooks/useSearchBackPress'; import useSearchResults from '@hooks/useSearchResults'; @@ -63,6 +64,7 @@ function WorkspaceTaxesPage({ }, }: WorkspaceTaxesPageProps) { useWorkspaceDocumentTitle(policy?.name, 'workspace.common.taxes'); + const policyData = usePolicyData(policyID); // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {shouldUseNarrowLayout, isSmallScreenWidth} = useResponsiveLayout(); const styles = useThemeStyles(); @@ -246,13 +248,13 @@ function WorkspaceTaxesPage({ if (!policy?.id) { return; } - deletePolicyTaxes(policy, selectedTaxesIDs, localeCompare); + deletePolicyTaxes(policyData, selectedTaxesIDs, localeCompare); // eslint-disable-next-line @typescript-eslint/no-deprecated InteractionManager.runAfterInteractions(() => { setSelectedTaxesIDs([]); }); - }, [policy, selectedTaxesIDs, localeCompare]); + }, [policy?.id, policyData, selectedTaxesIDs, localeCompare]); const toggleTaxes = useCallback( (isEnabled: boolean) => { diff --git a/tests/actions/PolicyTaxTest.ts b/tests/actions/PolicyTaxTest.ts index afebe7674f4e..59d3671e9f8f 100644 --- a/tests/actions/PolicyTaxTest.ts +++ b/tests/actions/PolicyTaxTest.ts @@ -1,4 +1,5 @@ import Onyx from 'react-native-onyx'; +import type PolicyData from '@hooks/usePolicyData/types'; import {createPolicyTax, deletePolicyTaxes, renamePolicyTax, setPolicyTaxCode, setPolicyTaxesEnabled, updatePolicyTaxValue} from '@libs/actions/TaxRate'; import CONST from '@src/CONST'; import OnyxUpdateManager from '@src/libs/actions/OnyxUpdateManager'; @@ -33,6 +34,16 @@ describe('actions/PolicyTax', () => { }, }, }; + function createPolicyData(policy: PolicyType): PolicyData { + return { + policy, + tags: {}, + categories: {}, + reports: [], + transactionsAndViolations: {}, + }; + } + beforeAll(() => { Onyx.init({ keys: ONYXKEYS, @@ -704,7 +715,7 @@ describe('actions/PolicyTax', () => { const taxID = 'id_TAX_RATE_1'; mockFetch?.pause?.(); - deletePolicyTaxes(fakePolicy, [taxID], TestHelper.localeCompare); + deletePolicyTaxes(createPolicyData(fakePolicy), [taxID], TestHelper.localeCompare); return waitForBatchedUpdates() .then( () => @@ -758,7 +769,7 @@ describe('actions/PolicyTax', () => { }, }; mockFetch?.pause?.(); - deletePolicyTaxes(fakePolicyWithForeignTaxDefault, [taxID], TestHelper.localeCompare); + deletePolicyTaxes(createPolicyData(fakePolicyWithForeignTaxDefault), [taxID], TestHelper.localeCompare); return waitForBatchedUpdates() .then( () => @@ -805,7 +816,7 @@ describe('actions/PolicyTax', () => { const taxID = 'id_TAX_RATE_1'; mockFetch?.pause?.(); - deletePolicyTaxes(fakePolicy, [taxID], TestHelper.localeCompare); + deletePolicyTaxes(createPolicyData(fakePolicy), [taxID], TestHelper.localeCompare); return waitForBatchedUpdates() .then( () => diff --git a/tests/unit/DistanceRateTest.ts b/tests/unit/DistanceRateTest.ts index fa2970093db0..134ba7d372b7 100644 --- a/tests/unit/DistanceRateTest.ts +++ b/tests/unit/DistanceRateTest.ts @@ -78,7 +78,13 @@ describe('DistanceRate', () => { await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, policy); if (policy.customUnits) { - deletePolicyDistanceRates(policy.id, policy.customUnits[customUnitID], [customUnitRateID1], [transaction1.transactionID], undefined); + deletePolicyDistanceRates( + policy.id, + policy.customUnits[customUnitID], + [customUnitRateID1], + [{transactionID: transaction1.transactionID, customUnitRateID: customUnitRateID1}], + undefined, + ); } await waitForBatchedUpdates(); const transactionViolations = await new Promise>((resolve) => { From 9c97f2adb8c6442b10ab9163bdfbebb8bca4a77f Mon Sep 17 00:00:00 2001 From: Mukher Date: Sat, 4 Apr 2026 10:55:40 +0500 Subject: [PATCH 02/32] Revert "Add transactionAutoSelections parameter to policy deletion APIs and implement auto-selection logic" --- src/libs/API/parameters/DeletePolicyDistanceRatesParams.ts | 2 -- src/libs/API/parameters/DeletePolicyTagsParams.ts | 2 -- src/libs/API/parameters/DeletePolicyTaxesParams.ts | 2 -- src/libs/API/parameters/DeleteWorkspaceCategoriesParams.ts | 5 ----- src/libs/actions/Policy/Category.ts | 3 +-- src/libs/actions/Policy/DistanceRate.ts | 3 --- src/libs/actions/Policy/Tag.ts | 3 +-- src/libs/actions/TaxRate.ts | 3 +-- 8 files changed, 3 insertions(+), 20 deletions(-) diff --git a/src/libs/API/parameters/DeletePolicyDistanceRatesParams.ts b/src/libs/API/parameters/DeletePolicyDistanceRatesParams.ts index bd0c9a353445..d4f972ff9757 100644 --- a/src/libs/API/parameters/DeletePolicyDistanceRatesParams.ts +++ b/src/libs/API/parameters/DeletePolicyDistanceRatesParams.ts @@ -2,8 +2,6 @@ type DeletePolicyDistanceRatesParams = { policyID: string; customUnitID: string; customUnitRateID: string[]; - /** JSON-encoded array of auto-selected transaction updates when only one valid value remains. */ - transactionAutoSelections?: string; }; export default DeletePolicyDistanceRatesParams; diff --git a/src/libs/API/parameters/DeletePolicyTagsParams.ts b/src/libs/API/parameters/DeletePolicyTagsParams.ts index 961bcfd4096f..0094ce318390 100644 --- a/src/libs/API/parameters/DeletePolicyTagsParams.ts +++ b/src/libs/API/parameters/DeletePolicyTagsParams.ts @@ -5,8 +5,6 @@ type DeletePolicyTagsParams = { * Array */ tags: string; - /** JSON-encoded array of auto-selected transaction updates when only one valid value remains. */ - transactionAutoSelections?: string; }; export default DeletePolicyTagsParams; diff --git a/src/libs/API/parameters/DeletePolicyTaxesParams.ts b/src/libs/API/parameters/DeletePolicyTaxesParams.ts index b444fc8d9d3e..9e0963cdcb28 100644 --- a/src/libs/API/parameters/DeletePolicyTaxesParams.ts +++ b/src/libs/API/parameters/DeletePolicyTaxesParams.ts @@ -6,8 +6,6 @@ type DeletePolicyTaxesParams = { * Each element is a tax name */ taxNames: string; - /** JSON-encoded array of auto-selected transaction updates when only one valid value remains. */ - transactionAutoSelections?: string; }; export default DeletePolicyTaxesParams; diff --git a/src/libs/API/parameters/DeleteWorkspaceCategoriesParams.ts b/src/libs/API/parameters/DeleteWorkspaceCategoriesParams.ts index d884f8c58651..07a8103a9b06 100644 --- a/src/libs/API/parameters/DeleteWorkspaceCategoriesParams.ts +++ b/src/libs/API/parameters/DeleteWorkspaceCategoriesParams.ts @@ -5,11 +5,6 @@ type DeleteWorkspaceCategoriesParams = { * Each element in the array is a string that specifies the name of a category. */ categories: string; - /** - * JSON-encoded array of auto-selected transaction updates when only one valid value remains. - * Each element: {transactionID, category?, tag?, taxCode?} - */ - transactionAutoSelections?: string; }; export default DeleteWorkspaceCategoriesParams; diff --git a/src/libs/actions/Policy/Category.ts b/src/libs/actions/Policy/Category.ts index a7a1001741a2..4fb7e9bd1222 100644 --- a/src/libs/actions/Policy/Category.ts +++ b/src/libs/actions/Policy/Category.ts @@ -1387,7 +1387,7 @@ function deleteWorkspaceCategories( ], }; - const autoSelections = pushTransactionViolationsOnyxData(onyxData, policyData, optimisticPolicyData, optimisticPolicyCategoriesData); + pushTransactionViolationsOnyxData(onyxData, policyData, optimisticPolicyData, optimisticPolicyCategoriesData); appendSetupCategoriesOnboardingData( onyxData, setupCategoryTaskReport, @@ -1401,7 +1401,6 @@ function deleteWorkspaceCategories( const parameters = { policyID, categories: JSON.stringify(categoryNamesToDelete), - ...(autoSelections.length > 0 && {transactionAutoSelections: JSON.stringify(autoSelections)}), }; API.write(WRITE_COMMANDS.DELETE_WORKSPACE_CATEGORIES, parameters, onyxData); diff --git a/src/libs/actions/Policy/DistanceRate.ts b/src/libs/actions/Policy/DistanceRate.ts index a662659b90b8..d611d28c0f69 100644 --- a/src/libs/actions/Policy/DistanceRate.ts +++ b/src/libs/actions/Policy/DistanceRate.ts @@ -449,7 +449,6 @@ function deletePolicyDistanceRates( const optimisticTransactionsViolations: Array> = []; const failureTransactionsViolations: Array> = []; - const distanceRateAutoSelections: Array<{transactionID: string; customUnitRateID: string}> = []; for (const {transactionID, customUnitRateID} of transactionsAffected) { const currentTransactionViolations = transactionViolations?.[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`] ?? []; @@ -469,7 +468,6 @@ function deletePolicyDistanceRates( key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, value: {comment: {customUnit: {customUnitRateID}}}, }); - distanceRateAutoSelections.push({transactionID, customUnitRateID: singleRemainingRateID}); continue; } @@ -500,7 +498,6 @@ function deletePolicyDistanceRates( policyID, customUnitID: customUnit.customUnitID, customUnitRateID: rateIDsToDelete, - ...(distanceRateAutoSelections.length > 0 && {transactionAutoSelections: JSON.stringify(distanceRateAutoSelections)}), }; API.write(WRITE_COMMANDS.DELETE_POLICY_DISTANCE_RATES, params, {optimisticData, failureData}); diff --git a/src/libs/actions/Policy/Tag.ts b/src/libs/actions/Policy/Tag.ts index cf12333e4a2c..9ace92663a75 100644 --- a/src/libs/actions/Policy/Tag.ts +++ b/src/libs/actions/Policy/Tag.ts @@ -519,12 +519,11 @@ function deletePolicyTags(policyData: PolicyData, tagsToDelete: string[]) { ], }; - const autoSelections = pushTransactionViolationsOnyxData(onyxData, policyData, {}, {}, policyTagsOptimisticData); + pushTransactionViolationsOnyxData(onyxData, policyData, {}, {}, policyTagsOptimisticData); const parameters = { policyID, tags: JSON.stringify(tagsToDelete), - ...(autoSelections.length > 0 && {transactionAutoSelections: JSON.stringify(autoSelections)}), }; API.write(WRITE_COMMANDS.DELETE_POLICY_TAGS, parameters, onyxData); diff --git a/src/libs/actions/TaxRate.ts b/src/libs/actions/TaxRate.ts index 3ef983fa4463..841feb98bdbe 100644 --- a/src/libs/actions/TaxRate.ts +++ b/src/libs/actions/TaxRate.ts @@ -406,12 +406,11 @@ function deletePolicyTaxes(policyData: PolicyData, taxesToDelete: string[], loca }, } as Partial; - const autoSelections = pushTransactionViolationsOnyxData(onyxData, policyData, policyTaxUpdate); + pushTransactionViolationsOnyxData(onyxData, policyData, policyTaxUpdate); const parameters = { policyID: policy?.id, taxNames: JSON.stringify(taxesToDelete.map((taxID) => policyTaxRates[taxID].name)), - ...(autoSelections.length > 0 && {transactionAutoSelections: JSON.stringify(autoSelections)}), } satisfies DeletePolicyTaxesParams; API.write(WRITE_COMMANDS.DELETE_POLICY_TAXES, parameters, onyxData); From 84e8ff6d48b507d13adf3e7e4d8dd0b7f6120cf1 Mon Sep 17 00:00:00 2001 From: Mukher Date: Mon, 27 Apr 2026 08:51:04 +0500 Subject: [PATCH 03/32] removed tax/distance rate and fixed related tests --- src/libs/ReportUtils.ts | 33 +--------- src/libs/actions/Policy/DistanceRate.ts | 29 ++------- src/libs/actions/TaxRate.ts | 65 +++++++++---------- .../PolicyDistanceRateDetailsPage.tsx | 21 ++---- .../distanceRates/PolicyDistanceRatesPage.tsx | 16 ++--- .../workspace/taxes/WorkspaceEditTaxPage.tsx | 4 +- .../workspace/taxes/WorkspaceTaxesPage.tsx | 6 +- tests/actions/PolicyTaxTest.ts | 17 +---- tests/unit/DistanceRateTest.ts | 8 +-- 9 files changed, 56 insertions(+), 143 deletions(-) diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index ea25e92eae29..9c17d841acbd 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -2067,7 +2067,7 @@ function pushTransactionViolationsOnyxData( }, []); if (nonInvoiceReportItems.length === 0) { - return []; + return; } const updatedTagListsNames = Object.keys(tagListsUpdate); @@ -2078,7 +2078,7 @@ function pushTransactionViolationsOnyxData( const isTagListsUpdateEmpty = updatedTagListsNames.length === 0; const isCategoriesUpdateEmpty = updatedCategoriesNames.length === 0; if (isPolicyUpdateEmpty && isTagListsUpdateEmpty && isCategoriesUpdateEmpty) { - return []; + return; } // Merge the existing policy with the optimistic updates @@ -2171,16 +2171,6 @@ function pushTransactionViolationsOnyxData( }); } - // Tax auto-selection - const optimisticTaxes = optimisticPolicy.taxRates?.taxes ?? {}; - const enabledTaxKeys = Object.entries(optimisticTaxes) - .filter(([, tax]) => !tax.isDisabled && tax.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) - .map(([key]) => key); - const singleRemainingTaxCode = enabledTaxKeys.length === 1 ? enabledTaxKeys.at(0) : undefined; - - // Collect auto-selected transaction updates to return to callers for the API request - const autoSelections: Array<{transactionID: string; category?: string; tag?: string; taxCode?: string}> = []; - // Iterate through all policy reports to find transactions that need optimistic violations for (const { report, @@ -2237,15 +2227,6 @@ function pushTransactionViolationsOnyxData( transactionRollback.tag = transaction.tag; } } - - // Tax auto-select: if the transaction's tax code is out of policy and only one enabled tax remains - if (singleRemainingTaxCode && transaction.taxCode) { - const isTaxInPolicy = !!optimisticTaxes[transaction.taxCode] && !optimisticTaxes[transaction.taxCode].isDisabled; - if (!isTaxInPolicy) { - transactionUpdates.taxCode = singleRemainingTaxCode; - transactionRollback.taxCode = transaction.taxCode; - } - } } // If auto-selection modified the transaction, push optimistic transaction updates @@ -2263,14 +2244,6 @@ function pushTransactionViolationsOnyxData( key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, value: transactionRollback, }); - - // Collect auto-selection data for the API request - autoSelections.push({ - transactionID: transaction.transactionID, - ...(transactionUpdates.category !== undefined && {category: transactionUpdates.category}), - ...(transactionUpdates.tag !== undefined && {tag: transactionUpdates.tag}), - ...(transactionUpdates.taxCode !== undefined && {taxCode: transactionUpdates.taxCode}), - }); } const existingViolations = violations[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transaction.transactionID}`]; @@ -2294,8 +2267,6 @@ function pushTransactionViolationsOnyxData( } } } - - return autoSelections; } /** diff --git a/src/libs/actions/Policy/DistanceRate.ts b/src/libs/actions/Policy/DistanceRate.ts index d611d28c0f69..762e0030cf7a 100644 --- a/src/libs/actions/Policy/DistanceRate.ts +++ b/src/libs/actions/Policy/DistanceRate.ts @@ -394,7 +394,7 @@ function deletePolicyDistanceRates( policyID: string, customUnit: CustomUnit, rateIDsToDelete: string[], - transactionsAffected: Array<{transactionID: string; customUnitRateID: string}>, + transactionIDsAffected: string[], transactionViolations: OnyxCollection, ) { const currentRates = customUnit.rates; @@ -413,13 +413,7 @@ function deletePolicyDistanceRates( }; } - // Check if there's exactly one remaining enabled rate for auto-selection - const remainingEnabledRateIDs = Object.entries(currentRates) - .filter(([rateID, rate]) => !rateIDsToDelete.includes(rateID) && rate.enabled) - .map(([rateID]) => rateID); - const singleRemainingRateID = remainingEnabledRateIDs.length === 1 ? remainingEnabledRateIDs.at(0) : undefined; - - const optimisticData: Array> = [ + const optimisticData: Array> = [ { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, @@ -433,7 +427,7 @@ function deletePolicyDistanceRates( }, ]; - const failureData: Array> = [ + const failureData: Array> = [ { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, @@ -450,27 +444,12 @@ function deletePolicyDistanceRates( const optimisticTransactionsViolations: Array> = []; const failureTransactionsViolations: Array> = []; - for (const {transactionID, customUnitRateID} of transactionsAffected) { + for (const transactionID of transactionIDsAffected) { const currentTransactionViolations = transactionViolations?.[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`] ?? []; if (currentTransactionViolations.some((violation) => violation.name === CONST.VIOLATIONS.CUSTOM_UNIT_OUT_OF_POLICY)) { return; } - // If there's exactly one remaining enabled rate, auto-select it instead of adding a violation - if (singleRemainingRateID) { - optimisticData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, - value: {comment: {customUnit: {customUnitRateID: singleRemainingRateID}}}, - }); - failureData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, - value: {comment: {customUnit: {customUnitRateID}}}, - }); - continue; - } - optimisticTransactionsViolations.push({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`, diff --git a/src/libs/actions/TaxRate.ts b/src/libs/actions/TaxRate.ts index 841feb98bdbe..f60085d146db 100644 --- a/src/libs/actions/TaxRate.ts +++ b/src/libs/actions/TaxRate.ts @@ -2,7 +2,6 @@ import type {NullishDeep, OnyxEntry} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import type {FormOnyxValues} from '@components/Form/types'; import type {LocaleContextProps, LocalizedTranslate} from '@components/LocaleContextProvider'; -import type PolicyData from '@hooks/usePolicyData/types'; import * as API from '@libs/API'; import type { CreatePolicyTaxParams, @@ -14,7 +13,6 @@ import type { } from '@libs/API/parameters'; import {WRITE_COMMANDS} from '@libs/API/types'; import {getDistanceRateCustomUnit} from '@libs/PolicyUtils'; -import {pushTransactionViolationsOnyxData} from '@libs/ReportUtils'; import {getFieldRequiredErrors, isExistingTaxCode, isExistingTaxName, isValidPercentage} from '@libs/ValidationUtils'; import CONST from '@src/CONST'; import {getMicroSecondOnyxErrorWithTranslationKey} from '@src/libs/ErrorUtils'; @@ -284,8 +282,7 @@ type TaxRateDeleteMap = Record< | null >; -function deletePolicyTaxes(policyData: PolicyData, taxesToDelete: string[], localeCompare: LocaleContextProps['localeCompare']) { - const policy = policyData.policy; +function deletePolicyTaxes(policy: OnyxEntry, taxesToDelete: string[], localeCompare: LocaleContextProps['localeCompare']) { const policyTaxRates = policy?.taxRates?.taxes; const foreignTaxDefault = policy?.taxRates?.foreignTaxDefault; const firstTaxID = Object.keys(policyTaxRates ?? {}) @@ -297,7 +294,7 @@ function deletePolicyTaxes(policyData: PolicyData, taxesToDelete: string[], loca (rate) => !!rate.attributes?.taxRateExternalID && taxesToDelete.includes(rate.attributes?.taxRateExternalID), ); - if (!policyTaxRates) { + if (!policy || !policyTaxRates) { console.debug('Policy or tax rates not found'); return; } @@ -331,15 +328,11 @@ function deletePolicyTaxes(policyData: PolicyData, taxesToDelete: string[], loca }; } - const customUnitsOptimistic = distanceRateCustomUnit && customUnitID ? {customUnits: {[customUnitID]: {rates: optimisticRates}}} : {}; - const customUnitsSuccess = distanceRateCustomUnit && customUnitID ? {customUnits: {[customUnitID]: {rates: successRates}}} : {}; - const customUnitsFailure = distanceRateCustomUnit && customUnitID ? {customUnits: {[customUnitID]: {rates: failureRates}}} : {}; - - const onyxData: OnyxData = { + const onyxData: OnyxData = { optimisticData: [ { onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.POLICY}${policy?.id}`, + key: `${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, value: { taxRates: { pendingFields: {foreignTaxDefault: isForeignTaxRemoved ? CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE : null}, @@ -349,14 +342,21 @@ function deletePolicyTaxes(policyData: PolicyData, taxesToDelete: string[], loca return acc; }, {}), }, - ...customUnitsOptimistic, + customUnits: + distanceRateCustomUnit && customUnitID + ? { + [customUnitID]: { + rates: optimisticRates, + }, + } + : undefined, }, }, ], successData: [ { onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.POLICY}${policy?.id}`, + key: `${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, value: { taxRates: { pendingFields: {foreignTaxDefault: null}, @@ -365,14 +365,21 @@ function deletePolicyTaxes(policyData: PolicyData, taxesToDelete: string[], loca return acc; }, {}), }, - ...customUnitsSuccess, + customUnits: + distanceRateCustomUnit && customUnitID + ? { + [customUnitID]: { + rates: successRates, + }, + } + : undefined, }, }, ], failureData: [ { onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.POLICY}${policy?.id}`, + key: `${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, value: { taxRates: { pendingFields: {foreignTaxDefault: null}, @@ -385,31 +392,21 @@ function deletePolicyTaxes(policyData: PolicyData, taxesToDelete: string[], loca return acc; }, {}), }, - ...customUnitsFailure, + customUnits: + distanceRateCustomUnit && customUnitID + ? { + [customUnitID]: { + rates: failureRates, + }, + } + : undefined, }, }, ], }; - // Build the optimistic policy update for tax deletion to pass to violation calculation - const policyTaxUpdate = { - taxRates: { - ...policy?.taxRates, - foreignTaxDefault: (isForeignTaxRemoved ? firstTaxID : foreignTaxDefault) ?? '', - taxes: { - ...policyTaxRates, - ...taxesToDelete.reduce>((acc, taxID) => { - acc[taxID] = {...policyTaxRates[taxID], pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, isDisabled: true}; - return acc; - }, {}), - }, - }, - } as Partial; - - pushTransactionViolationsOnyxData(onyxData, policyData, policyTaxUpdate); - const parameters = { - policyID: policy?.id, + policyID: policy.id, taxNames: JSON.stringify(taxesToDelete.map((taxID) => policyTaxRates[taxID].name)), } satisfies DeletePolicyTaxesParams; diff --git a/src/pages/workspace/distanceRates/PolicyDistanceRateDetailsPage.tsx b/src/pages/workspace/distanceRates/PolicyDistanceRateDetailsPage.tsx index c91717a5dd8f..51dd73fb631d 100644 --- a/src/pages/workspace/distanceRates/PolicyDistanceRateDetailsPage.tsx +++ b/src/pages/workspace/distanceRates/PolicyDistanceRateDetailsPage.tsx @@ -63,11 +63,7 @@ function PolicyDistanceRateDetailsPage({route}: PolicyDistanceRateDetailsPagePro const transactionsSelector = useCallback( (transactions: OnyxCollection) => { - const result: {transactionIDs: Set; transactionsAffected: Array<{transactionID: string; customUnitRateID: string}>} = { - transactionIDs: new Set(), - transactionsAffected: [], - }; - for (const transaction of Object.values(transactions ?? {})) { + return Object.values(transactions ?? {}).reduce((transactionIDs, transaction) => { if ( transaction?.reportID && policyReports?.has(transaction.reportID) && @@ -76,23 +72,18 @@ function PolicyDistanceRateDetailsPage({route}: PolicyDistanceRateDetailsPagePro transaction?.comment?.customUnit?.customUnitRateID && transaction?.comment?.customUnit?.customUnitRateID === rateID ) { - result.transactionIDs.add(transaction.transactionID); - result.transactionsAffected.push({ - transactionID: transaction.transactionID, - customUnitRateID: transaction.comment.customUnit.customUnitRateID, - }); + transactionIDs.add(transaction?.transactionID); } - } - return result; + return transactionIDs; + }, new Set()); }, [customUnitID, rateID, policyReports], ); - const [eligibleTransactionsData] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION, { + const [eligibleTransactionIDs] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION, { selector: transactionsSelector, }); - const eligibleTransactionIDs = eligibleTransactionsData?.transactionIDs; const transactionViolations = useTransactionViolation(eligibleTransactionIDs); const icons = useMemoizedLazyExpensifyIcons(['Trashcan']); @@ -146,7 +137,7 @@ function PolicyDistanceRateDetailsPage({route}: PolicyDistanceRateDetailsPagePro }; const deleteRate = () => { - deletePolicyDistanceRates(policyID, customUnit, [rateID], eligibleTransactionsData?.transactionsAffected ?? [], transactionViolations); + deletePolicyDistanceRates(policyID, customUnit, [rateID], Array.from(eligibleTransactionIDs ?? []), transactionViolations); Navigation.setNavigationActionToMicrotaskQueue(() => Navigation.goBack()); }; diff --git a/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx b/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx index 04a05dd84a4c..32f7272d68ee 100644 --- a/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx +++ b/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx @@ -120,20 +120,16 @@ function PolicyDistanceRatesPage({ transaction?.comment?.customUnit?.customUnitRateID && rateIDs.has(transaction?.comment?.customUnit?.customUnitRateID) ) { - const rateID = transaction.comment.customUnit.customUnitRateID; transactionsData.transactionIDs.add(transaction.transactionID); - if (!transactionsData.rateIDToTransactionsMap[rateID]) { + if (!transactionsData.rateIDToTransactionIDsMap[transaction?.comment?.customUnit?.customUnitRateID]) { // eslint-disable-next-line no-param-reassign - transactionsData.rateIDToTransactionsMap[rateID] = []; + transactionsData.rateIDToTransactionIDsMap[transaction?.comment?.customUnit?.customUnitRateID] = []; } - transactionsData.rateIDToTransactionsMap[rateID]?.push({ - transactionID: transaction.transactionID, - customUnitRateID: rateID, - }); + transactionsData.rateIDToTransactionIDsMap[transaction?.comment?.customUnit?.customUnitRateID]?.push(transaction?.transactionID); } return transactionsData; }, - {transactionIDs: new Set(), rateIDToTransactionsMap: {} as Record>}, + {transactionIDs: new Set(), rateIDToTransactionIDsMap: {} as Record}, ); }, [customUnit?.customUnitID, rateIDs, policyReports], @@ -323,9 +319,9 @@ function PolicyDistanceRatesPage({ return; } - const transactionsAffected = selectedDistanceRates.flatMap((rateID) => eligibleTransactionsData?.rateIDToTransactionsMap?.[rateID] ?? []); + const transactionIDsAffected = selectedDistanceRates.flatMap((rateID) => eligibleTransactionsData?.rateIDToTransactionIDsMap?.[rateID] ?? []); - deletePolicyDistanceRates(policyID, customUnit, selectedDistanceRates, transactionsAffected, transactionViolations); + deletePolicyDistanceRates(policyID, customUnit, selectedDistanceRates, transactionIDsAffected, transactionViolations); // eslint-disable-next-line @typescript-eslint/no-deprecated InteractionManager.runAfterInteractions(() => { diff --git a/src/pages/workspace/taxes/WorkspaceEditTaxPage.tsx b/src/pages/workspace/taxes/WorkspaceEditTaxPage.tsx index 33aec4c62670..9070572b6062 100644 --- a/src/pages/workspace/taxes/WorkspaceEditTaxPage.tsx +++ b/src/pages/workspace/taxes/WorkspaceEditTaxPage.tsx @@ -11,7 +11,6 @@ import Text from '@components/Text'; import useConfirmModal from '@hooks/useConfirmModal'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; -import usePolicyData from '@hooks/usePolicyData'; import useThemeStyles from '@hooks/useThemeStyles'; import {clearTaxRateFieldError, deletePolicyTaxes, setPolicyTaxesEnabled} from '@libs/actions/TaxRate'; import {getLatestErrorField} from '@libs/ErrorUtils'; @@ -37,7 +36,6 @@ function WorkspaceEditTaxPage({ }: WorkspaceEditTaxPageBaseProps) { const styles = useThemeStyles(); const {translate, localeCompare} = useLocalize(); - const policyData = usePolicyData(policyID); const currentTaxID = getCurrentTaxID(policy, taxID); const currentTaxRate = currentTaxID && policy?.taxRates?.taxes?.[currentTaxID]; const {showConfirmModal} = useConfirmModal(); @@ -71,7 +69,7 @@ function WorkspaceEditTaxPage({ if (!policyID) { return; } - deletePolicyTaxes(policyData, [taxID], localeCompare); + deletePolicyTaxes(policy, [taxID], localeCompare); Navigation.goBack(); }; diff --git a/src/pages/workspace/taxes/WorkspaceTaxesPage.tsx b/src/pages/workspace/taxes/WorkspaceTaxesPage.tsx index bc9fa89e5475..586fd75e2d28 100644 --- a/src/pages/workspace/taxes/WorkspaceTaxesPage.tsx +++ b/src/pages/workspace/taxes/WorkspaceTaxesPage.tsx @@ -22,7 +22,6 @@ import useLocalize from '@hooks/useLocalize'; import useMobileSelectionMode from '@hooks/useMobileSelectionMode'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; -import usePolicyData from '@hooks/usePolicyData'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSearchBackPress from '@hooks/useSearchBackPress'; import useSearchResults from '@hooks/useSearchResults'; @@ -64,7 +63,6 @@ function WorkspaceTaxesPage({ }, }: WorkspaceTaxesPageProps) { useWorkspaceDocumentTitle(policy?.name, 'workspace.common.taxes'); - const policyData = usePolicyData(policyID); // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {shouldUseNarrowLayout, isSmallScreenWidth} = useResponsiveLayout(); const styles = useThemeStyles(); @@ -249,13 +247,13 @@ function WorkspaceTaxesPage({ if (!policy?.id) { return; } - deletePolicyTaxes(policyData, selectedTaxesIDs, localeCompare); + deletePolicyTaxes(policy, selectedTaxesIDs, localeCompare); // eslint-disable-next-line @typescript-eslint/no-deprecated InteractionManager.runAfterInteractions(() => { setSelectedTaxesIDs([]); }); - }, [policy?.id, policyData, selectedTaxesIDs, localeCompare]); + }, [policy, selectedTaxesIDs, localeCompare]); const toggleTaxes = useCallback( (isEnabled: boolean) => { diff --git a/tests/actions/PolicyTaxTest.ts b/tests/actions/PolicyTaxTest.ts index 59d3671e9f8f..afebe7674f4e 100644 --- a/tests/actions/PolicyTaxTest.ts +++ b/tests/actions/PolicyTaxTest.ts @@ -1,5 +1,4 @@ import Onyx from 'react-native-onyx'; -import type PolicyData from '@hooks/usePolicyData/types'; import {createPolicyTax, deletePolicyTaxes, renamePolicyTax, setPolicyTaxCode, setPolicyTaxesEnabled, updatePolicyTaxValue} from '@libs/actions/TaxRate'; import CONST from '@src/CONST'; import OnyxUpdateManager from '@src/libs/actions/OnyxUpdateManager'; @@ -34,16 +33,6 @@ describe('actions/PolicyTax', () => { }, }, }; - function createPolicyData(policy: PolicyType): PolicyData { - return { - policy, - tags: {}, - categories: {}, - reports: [], - transactionsAndViolations: {}, - }; - } - beforeAll(() => { Onyx.init({ keys: ONYXKEYS, @@ -715,7 +704,7 @@ describe('actions/PolicyTax', () => { const taxID = 'id_TAX_RATE_1'; mockFetch?.pause?.(); - deletePolicyTaxes(createPolicyData(fakePolicy), [taxID], TestHelper.localeCompare); + deletePolicyTaxes(fakePolicy, [taxID], TestHelper.localeCompare); return waitForBatchedUpdates() .then( () => @@ -769,7 +758,7 @@ describe('actions/PolicyTax', () => { }, }; mockFetch?.pause?.(); - deletePolicyTaxes(createPolicyData(fakePolicyWithForeignTaxDefault), [taxID], TestHelper.localeCompare); + deletePolicyTaxes(fakePolicyWithForeignTaxDefault, [taxID], TestHelper.localeCompare); return waitForBatchedUpdates() .then( () => @@ -816,7 +805,7 @@ describe('actions/PolicyTax', () => { const taxID = 'id_TAX_RATE_1'; mockFetch?.pause?.(); - deletePolicyTaxes(createPolicyData(fakePolicy), [taxID], TestHelper.localeCompare); + deletePolicyTaxes(fakePolicy, [taxID], TestHelper.localeCompare); return waitForBatchedUpdates() .then( () => diff --git a/tests/unit/DistanceRateTest.ts b/tests/unit/DistanceRateTest.ts index f5c0a6a89302..13a549e20181 100644 --- a/tests/unit/DistanceRateTest.ts +++ b/tests/unit/DistanceRateTest.ts @@ -78,13 +78,7 @@ describe('DistanceRate', () => { await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, policy); if (policy.customUnits) { - deletePolicyDistanceRates( - policy.id, - policy.customUnits[customUnitID], - [customUnitRateID1], - [{transactionID: transaction1.transactionID, customUnitRateID: customUnitRateID1}], - undefined, - ); + deletePolicyDistanceRates(policy.id, policy.customUnits[customUnitID], [customUnitRateID1], [transaction1.transactionID], undefined); } await waitForBatchedUpdates(); const transactionViolations = await new Promise>((resolve) => { From b43341ad2fa5919b42ab6c26d892c3d8da390cc1 Mon Sep 17 00:00:00 2001 From: Mukher Date: Mon, 27 Apr 2026 11:06:03 +0500 Subject: [PATCH 04/32] fixed ai reviews --- src/libs/ReportUtils.ts | 34 ++- tests/unit/ReportUtilsTest.ts | 383 ++++++++++++++++++++++++++++++++++ 2 files changed, 399 insertions(+), 18 deletions(-) diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 9c17d841acbd..f0bbb8eaf849 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -2159,17 +2159,14 @@ function pushTransactionViolationsOnyxData( } // Multi-level tag auto-selection (per level) - let perLevelSingleTag: Array = []; - if (tagListKeys.length > 1) { - const sortedTagKeys = getSortedTagKeys(optimisticTagLists); - perLevelSingleTag = sortedTagKeys.map((key) => { - const tags = optimisticTagLists[key]?.tags ?? {}; - const enabledKeys = Object.entries(tags) - .filter(([, tag]) => tag.enabled && tag.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) - .map(([k]) => k); - return enabledKeys.length === 1 ? enabledKeys.at(0) : undefined; - }); - } + const sortedTagKeys = tagListKeys.length > 1 ? getSortedTagKeys(optimisticTagLists) : []; + const perLevelSingleTag: Array = sortedTagKeys.map((key) => { + const tags = optimisticTagLists[key]?.tags ?? {}; + const enabledKeys = Object.entries(tags) + .filter(([, tag]) => tag.enabled && tag.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) + .map(([k]) => k); + return enabledKeys.length === 1 ? enabledKeys.at(0) : undefined; + }); // Iterate through all policy reports to find transactions that need optimistic violations for (const { @@ -2184,14 +2181,15 @@ function pushTransactionViolationsOnyxData( const transactionRollback: Partial = {}; if (isEligibleForAutoSelect) { - // Category auto-select: if the transaction's category is out of policy and only one enabled category remains - if (singleRemainingCategory && transaction.category && !optimisticCategories[transaction.category]?.enabled) { + // Category auto-select: gated to calls that actually mutate categories so toggling unrelated + // policy settings doesn't rewrite transaction category values. + if (!isCategoriesUpdateEmpty && singleRemainingCategory && transaction.category && !optimisticCategories[transaction.category]?.enabled) { transactionUpdates.category = singleRemainingCategory; transactionRollback.category = transaction.category; } - // Single-level tag auto-select - if (tagListKeys.length === 1 && singleRemainingTag && transaction.tag) { + // Single-level tag auto-select: gated to tag mutations for the same reason. + if (!isTagListsUpdateEmpty && tagListKeys.length === 1 && singleRemainingTag && transaction.tag) { const tagListName = tagListKeys.at(0) ?? ''; const isTagInPolicy = !!optimisticTagLists[tagListName]?.tags?.[transaction.tag]?.enabled; if (!isTagInPolicy) { @@ -2200,9 +2198,9 @@ function pushTransactionViolationsOnyxData( } } - // Multi-level tag auto-select - if (tagListKeys.length > 1 && transaction.tag) { - const sortedTagKeys = getSortedTagKeys(optimisticTagLists); + // Multi-level tag auto-select. Skipped for dependent-tag policies because the per-level + // sole-remaining check ignores parent-tag filtering and could write an invalid combination. + if (!isTagListsUpdateEmpty && !hasDependentTagsValue && tagListKeys.length > 1 && transaction.tag) { const currentTags = getTagArrayFromName(transaction.tag); let anyTagChanged = false; const newTags = [...currentTags]; diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index 09363d8f5213..d34a5305495b 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -172,6 +172,8 @@ import type { Policy, PolicyEmployeeList, PolicyTag, + PolicyTagLists, + PolicyTags, Report, ReportAction, ReportActions, @@ -9675,6 +9677,387 @@ describe('ReportUtils', () => { expect(onyxData).toMatchObject(expectedOnyxData); }); + + it('should auto-select the sole remaining category for transactions on open reports instead of pushing a violation', async () => { + // Given a policy with 2 enabled categories, where the first one is being deleted + const fakePolicyCategories = createRandomPolicyCategories(2); + for (const cat of Object.values(fakePolicyCategories)) { + // eslint-disable-next-line no-param-reassign + cat.enabled = true; + } + const categoryNames = Object.keys(fakePolicyCategories); + const categoryToDelete = categoryNames.at(0) ?? ''; + const remainingCategory = categoryNames.at(1) ?? ''; + const fakePolicyCategoriesUpdate = { + [categoryToDelete]: { + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + enabled: false, + }, + }; + + const fakePolicyID = '0'; + const fakePolicy = { + ...createRandomPolicy(0), + id: fakePolicyID, + requiresCategory: true, + areCategoriesEnabled: true, + }; + + // Given an OPEN report (eligible for auto-selection) + const openReport: Report = { + ...mockIOUReport, + policyID: fakePolicyID, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + }; + + const fakePolicyReports: Record<`${typeof ONYXKEYS.COLLECTION.REPORT}${string}`, Report> = { + [`${ONYXKEYS.COLLECTION.REPORT}${openReport.reportID}`]: openReport, + }; + + await Onyx.multiSet({ + ...fakePolicyReports, + [`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${fakePolicyID}`]: fakePolicyCategories, + [`${ONYXKEYS.COLLECTION.POLICY}${fakePolicyID}`]: fakePolicy, + [`${ONYXKEYS.COLLECTION.TRANSACTION}${mockTransaction.transactionID}`]: { + ...mockTransaction, + reportID: openReport.reportID, + policyID: fakePolicyID, + category: categoryToDelete, + }, + }); + + await waitForBatchedUpdates(); + const {result} = renderHook(() => usePolicyData(fakePolicyID), {wrapper: OnyxListItemProvider}); + await waitForBatchedUpdates(); + + const onyxData = {optimisticData: [], failureData: []}; + pushTransactionViolationsOnyxData(onyxData, result.current, {}, fakePolicyCategoriesUpdate, {}); + + // The optimistic data should contain a transaction merge auto-selecting the remaining category + expect(onyxData.optimisticData).toContainEqual({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${mockTransaction.transactionID}`, + value: {category: remainingCategory}, + }); + // The failure data should restore the original category + expect(onyxData.failureData).toContainEqual({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${mockTransaction.transactionID}`, + value: {category: categoryToDelete}, + }); + }); + + it('should auto-select the sole remaining single-level tag for transactions on open reports', async () => { + // Given a policy with 2 tags, where the first one is being deleted + const fakePolicyTagListName = 'Tag List'; + const fakePolicyTagsLists = createRandomPolicyTags(fakePolicyTagListName, 2); + const tagNames = Object.keys(fakePolicyTagsLists?.[fakePolicyTagListName]?.tags ?? {}); + const tagToDelete = tagNames.at(0) ?? ''; + const remainingTag = tagNames.at(1) ?? ''; + const fakePolicyTagListsUpdate: Record>>> = { + [fakePolicyTagListName]: { + tags: { + [tagToDelete]: {pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, enabled: false}, + }, + }, + }; + + const fakePolicyID = '0'; + const fakePolicy = { + ...createRandomPolicy(0), + id: fakePolicyID, + requiresTag: true, + areTagsEnabled: true, + }; + + const openReport: Report = { + ...mockIOUReport, + policyID: fakePolicyID, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + }; + + const fakePolicyReports: Record<`${typeof ONYXKEYS.COLLECTION.REPORT}${string}`, Report> = { + [`${ONYXKEYS.COLLECTION.REPORT}${openReport.reportID}`]: openReport, + }; + + await Onyx.multiSet({ + ...fakePolicyReports, + [`${ONYXKEYS.COLLECTION.POLICY_TAGS}${fakePolicyID}`]: fakePolicyTagsLists, + [`${ONYXKEYS.COLLECTION.POLICY}${fakePolicyID}`]: fakePolicy, + [`${ONYXKEYS.COLLECTION.TRANSACTION}${mockTransaction.transactionID}`]: { + ...mockTransaction, + reportID: openReport.reportID, + policyID: fakePolicyID, + tag: tagToDelete, + }, + }); + + await waitForBatchedUpdates(); + const {result} = renderHook(() => usePolicyData(fakePolicyID), {wrapper: OnyxListItemProvider}); + await waitForBatchedUpdates(); + + const onyxData = {optimisticData: [], failureData: []}; + pushTransactionViolationsOnyxData(onyxData, result.current, {}, {}, fakePolicyTagListsUpdate); + + expect(onyxData.optimisticData).toContainEqual({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${mockTransaction.transactionID}`, + value: {tag: remainingTag}, + }); + expect(onyxData.failureData).toContainEqual({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${mockTransaction.transactionID}`, + value: {tag: tagToDelete}, + }); + }); + + it('should not auto-select when more than one enabled category remains after deletion', async () => { + // Given a policy with 3 enabled categories, where the first one is being deleted (2 remain — auto-select should NOT trigger) + const fakePolicyCategories = createRandomPolicyCategories(3); + for (const cat of Object.values(fakePolicyCategories)) { + // eslint-disable-next-line no-param-reassign + cat.enabled = true; + } + const categoryToDelete = Object.keys(fakePolicyCategories).at(0) ?? ''; + const fakePolicyCategoriesUpdate = { + [categoryToDelete]: { + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + enabled: false, + }, + }; + + const fakePolicyID = '0'; + const fakePolicy = { + ...createRandomPolicy(0), + id: fakePolicyID, + requiresCategory: true, + areCategoriesEnabled: true, + }; + + const openReport: Report = { + ...mockIOUReport, + policyID: fakePolicyID, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + }; + + const fakePolicyReports: Record<`${typeof ONYXKEYS.COLLECTION.REPORT}${string}`, Report> = { + [`${ONYXKEYS.COLLECTION.REPORT}${openReport.reportID}`]: openReport, + }; + + await Onyx.multiSet({ + ...fakePolicyReports, + [`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${fakePolicyID}`]: fakePolicyCategories, + [`${ONYXKEYS.COLLECTION.POLICY}${fakePolicyID}`]: fakePolicy, + [`${ONYXKEYS.COLLECTION.TRANSACTION}${mockTransaction.transactionID}`]: { + ...mockTransaction, + reportID: openReport.reportID, + policyID: fakePolicyID, + category: categoryToDelete, + }, + }); + + await waitForBatchedUpdates(); + const {result} = renderHook(() => usePolicyData(fakePolicyID), {wrapper: OnyxListItemProvider}); + await waitForBatchedUpdates(); + + const onyxData = {optimisticData: [], failureData: []}; + pushTransactionViolationsOnyxData(onyxData, result.current, {}, fakePolicyCategoriesUpdate, {}); + + // No transaction merge should be present — only the violation push + const hasTransactionMerge = onyxData.optimisticData.some((update: {key: string}) => update.key === `${ONYXKEYS.COLLECTION.TRANSACTION}${mockTransaction.transactionID}`); + expect(hasTransactionMerge).toBe(false); + // A categoryOutOfPolicy violation should still be created + expect(onyxData.optimisticData).toContainEqual( + expect.objectContaining({ + key: `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${mockTransaction.transactionID}`, + value: expect.arrayContaining([expect.objectContaining({name: CONST.VIOLATIONS.CATEGORY_OUT_OF_POLICY})]), + }), + ); + }); + + it('should not auto-select for transactions on approved reports (only open or processing)', async () => { + // Given a sole remaining enabled category but the report is already APPROVED (not eligible for auto-select) + const fakePolicyCategories = createRandomPolicyCategories(2); + for (const cat of Object.values(fakePolicyCategories)) { + // eslint-disable-next-line no-param-reassign + cat.enabled = true; + } + const categoryToDelete = Object.keys(fakePolicyCategories).at(0) ?? ''; + const fakePolicyCategoriesUpdate = { + [categoryToDelete]: { + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + enabled: false, + }, + }; + + const fakePolicyID = '0'; + const fakePolicy = { + ...createRandomPolicy(0), + id: fakePolicyID, + requiresCategory: true, + areCategoriesEnabled: true, + }; + + // Report is APPROVED — auto-select should be skipped + const approvedReport: Report = { + ...mockIOUReport, + policyID: fakePolicyID, + stateNum: CONST.REPORT.STATE_NUM.APPROVED, + statusNum: CONST.REPORT.STATUS_NUM.APPROVED, + }; + + const fakePolicyReports: Record<`${typeof ONYXKEYS.COLLECTION.REPORT}${string}`, Report> = { + [`${ONYXKEYS.COLLECTION.REPORT}${approvedReport.reportID}`]: approvedReport, + }; + + await Onyx.multiSet({ + ...fakePolicyReports, + [`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${fakePolicyID}`]: fakePolicyCategories, + [`${ONYXKEYS.COLLECTION.POLICY}${fakePolicyID}`]: fakePolicy, + [`${ONYXKEYS.COLLECTION.TRANSACTION}${mockTransaction.transactionID}`]: { + ...mockTransaction, + reportID: approvedReport.reportID, + policyID: fakePolicyID, + category: categoryToDelete, + }, + }); + + await waitForBatchedUpdates(); + const {result} = renderHook(() => usePolicyData(fakePolicyID), {wrapper: OnyxListItemProvider}); + await waitForBatchedUpdates(); + + const onyxData = {optimisticData: [], failureData: []}; + pushTransactionViolationsOnyxData(onyxData, result.current, {}, fakePolicyCategoriesUpdate, {}); + + // No transaction merge should be present + const hasTransactionMerge = onyxData.optimisticData.some((update: {key: string}) => update.key === `${ONYXKEYS.COLLECTION.TRANSACTION}${mockTransaction.transactionID}`); + expect(hasTransactionMerge).toBe(false); + }); + + it('should not auto-select category when the call only updates policy settings (no categoriesUpdate)', async () => { + // Given a sole remaining enabled category, but the caller only passes a policyUpdate + // (e.g. toggling an unrelated workspace setting). Auto-select must NOT mutate transactions. + const fakePolicyCategories = createRandomPolicyCategories(2); + for (const cat of Object.values(fakePolicyCategories)) { + // eslint-disable-next-line no-param-reassign + cat.enabled = true; + } + const categoryNames = Object.keys(fakePolicyCategories); + const transactionCategory = categoryNames.at(0) ?? ''; + // Mark this category disabled in Onyx so it is "out of policy" without any pending DELETE update + fakePolicyCategories[transactionCategory].enabled = false; + + const fakePolicyID = '0'; + const fakePolicy = { + ...createRandomPolicy(0), + id: fakePolicyID, + requiresCategory: true, + areCategoriesEnabled: true, + }; + + const openReport: Report = { + ...mockIOUReport, + policyID: fakePolicyID, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + }; + + const fakePolicyReports: Record<`${typeof ONYXKEYS.COLLECTION.REPORT}${string}`, Report> = { + [`${ONYXKEYS.COLLECTION.REPORT}${openReport.reportID}`]: openReport, + }; + + await Onyx.multiSet({ + ...fakePolicyReports, + [`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${fakePolicyID}`]: fakePolicyCategories, + [`${ONYXKEYS.COLLECTION.POLICY}${fakePolicyID}`]: fakePolicy, + [`${ONYXKEYS.COLLECTION.TRANSACTION}${mockTransaction.transactionID}`]: { + ...mockTransaction, + reportID: openReport.reportID, + policyID: fakePolicyID, + category: transactionCategory, + }, + }); + + await waitForBatchedUpdates(); + const {result} = renderHook(() => usePolicyData(fakePolicyID), {wrapper: OnyxListItemProvider}); + await waitForBatchedUpdates(); + + const onyxData = {optimisticData: [], failureData: []}; + // policyUpdate-only call (simulates setPolicyRulesEnabled and similar) + pushTransactionViolationsOnyxData(onyxData, result.current, {requiresCategory: true}, {}, {}); + + const hasTransactionMerge = onyxData.optimisticData.some((update: {key: string}) => update.key === `${ONYXKEYS.COLLECTION.TRANSACTION}${mockTransaction.transactionID}`); + expect(hasTransactionMerge).toBe(false); + }); + + it('should not auto-select multi-level tags when the policy has dependent tags', async () => { + // Given a multi-level tag policy with dependent tags, where one level has a sole remaining tag. + // Auto-select should be skipped because the per-level sole-remaining check ignores parent filtering. + const level1Tags: PolicyTags = { + Engineering: {name: 'Engineering', enabled: true, rules: {parentTagsFilter: ''}}, + }; + const level2Tags: PolicyTags = { + Q1: {name: 'Q1', enabled: true, rules: {parentTagsFilter: 'Engineering'}}, + }; + const fakePolicyTagsLists: PolicyTagLists = { + Department: {name: 'Department', orderWeight: 0, required: false, tags: level1Tags}, + Quarter: {name: 'Quarter', orderWeight: 1, required: false, tags: level2Tags}, + }; + + const fakePolicyID = '0'; + const fakePolicy = { + ...createRandomPolicy(0), + id: fakePolicyID, + requiresTag: true, + areTagsEnabled: true, + hasMultipleTagLists: true, + }; + + const openReport: Report = { + ...mockIOUReport, + policyID: fakePolicyID, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + }; + + const fakePolicyReports: Record<`${typeof ONYXKEYS.COLLECTION.REPORT}${string}`, Report> = { + [`${ONYXKEYS.COLLECTION.REPORT}${openReport.reportID}`]: openReport, + }; + + await Onyx.multiSet({ + ...fakePolicyReports, + [`${ONYXKEYS.COLLECTION.POLICY_TAGS}${fakePolicyID}`]: fakePolicyTagsLists, + [`${ONYXKEYS.COLLECTION.POLICY}${fakePolicyID}`]: fakePolicy, + [`${ONYXKEYS.COLLECTION.TRANSACTION}${mockTransaction.transactionID}`]: { + ...mockTransaction, + reportID: openReport.reportID, + policyID: fakePolicyID, + // Out-of-policy combination — neither value matches enabled tags above + tag: `Sales${CONST.COLON}Q4`, + }, + }); + + await waitForBatchedUpdates(); + const {result} = renderHook(() => usePolicyData(fakePolicyID), {wrapper: OnyxListItemProvider}); + await waitForBatchedUpdates(); + + // tagListsUpdate is non-empty so we exercise the multi-level branch's dependent-tag guard + const tagsToDelete: Record>> = { + Sales: {pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, enabled: false}, + }; + const fakePolicyTagListsUpdate: Record>>> = { + Department: {tags: tagsToDelete}, + }; + + const onyxData = {optimisticData: [], failureData: []}; + pushTransactionViolationsOnyxData(onyxData, result.current, {}, {}, fakePolicyTagListsUpdate); + + const hasTransactionMerge = onyxData.optimisticData.some((update: {key: string}) => update.key === `${ONYXKEYS.COLLECTION.TRANSACTION}${mockTransaction.transactionID}`); + expect(hasTransactionMerge).toBe(false); + }); }); describe('canLeaveChat', () => { From 227f710e8a4dc2ac0be870cf1ea529caf6860e03 Mon Sep 17 00:00:00 2001 From: Mukher Date: Wed, 29 Apr 2026 07:19:40 +0500 Subject: [PATCH 05/32] added tests for disabling tag/category case --- tests/unit/ReportUtilsTest.ts | 132 ++++++++++++++++++++++++++++++++++ 1 file changed, 132 insertions(+) diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index d34a5305495b..7904759939c8 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -9813,6 +9813,138 @@ describe('ReportUtils', () => { }); }); + it('should auto-select the sole remaining category when a category is disabled (not deleted)', async () => { + // Given a policy with 2 enabled categories, where the first one is being disabled (UPDATE, not DELETE) + const fakePolicyCategories = createRandomPolicyCategories(2); + for (const cat of Object.values(fakePolicyCategories)) { + // eslint-disable-next-line no-param-reassign + cat.enabled = true; + } + const categoryNames = Object.keys(fakePolicyCategories); + const categoryToDisable = categoryNames.at(0) ?? ''; + const remainingCategory = categoryNames.at(1) ?? ''; + const fakePolicyCategoriesUpdate = { + [categoryToDisable]: { + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + enabled: false, + }, + }; + + const fakePolicyID = '0'; + const fakePolicy = { + ...createRandomPolicy(0), + id: fakePolicyID, + requiresCategory: true, + areCategoriesEnabled: true, + }; + + const openReport: Report = { + ...mockIOUReport, + policyID: fakePolicyID, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + }; + + const fakePolicyReports: Record<`${typeof ONYXKEYS.COLLECTION.REPORT}${string}`, Report> = { + [`${ONYXKEYS.COLLECTION.REPORT}${openReport.reportID}`]: openReport, + }; + + await Onyx.multiSet({ + ...fakePolicyReports, + [`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${fakePolicyID}`]: fakePolicyCategories, + [`${ONYXKEYS.COLLECTION.POLICY}${fakePolicyID}`]: fakePolicy, + [`${ONYXKEYS.COLLECTION.TRANSACTION}${mockTransaction.transactionID}`]: { + ...mockTransaction, + reportID: openReport.reportID, + policyID: fakePolicyID, + category: categoryToDisable, + }, + }); + + await waitForBatchedUpdates(); + const {result} = renderHook(() => usePolicyData(fakePolicyID), {wrapper: OnyxListItemProvider}); + await waitForBatchedUpdates(); + + const onyxData = {optimisticData: [], failureData: []}; + pushTransactionViolationsOnyxData(onyxData, result.current, {}, fakePolicyCategoriesUpdate, {}); + + expect(onyxData.optimisticData).toContainEqual({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${mockTransaction.transactionID}`, + value: {category: remainingCategory}, + }); + expect(onyxData.failureData).toContainEqual({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${mockTransaction.transactionID}`, + value: {category: categoryToDisable}, + }); + }); + + it('should auto-select the sole remaining single-level tag when a tag is disabled (not deleted)', async () => { + // Given a policy with 2 tags, where the first one is being disabled (UPDATE, not DELETE) + const fakePolicyTagListName = 'Tag List'; + const fakePolicyTagsLists = createRandomPolicyTags(fakePolicyTagListName, 2); + const tagNames = Object.keys(fakePolicyTagsLists?.[fakePolicyTagListName]?.tags ?? {}); + const tagToDisable = tagNames.at(0) ?? ''; + const remainingTag = tagNames.at(1) ?? ''; + const fakePolicyTagListsUpdate: Record>>> = { + [fakePolicyTagListName]: { + tags: { + [tagToDisable]: {pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, enabled: false}, + }, + }, + }; + + const fakePolicyID = '0'; + const fakePolicy = { + ...createRandomPolicy(0), + id: fakePolicyID, + requiresTag: true, + areTagsEnabled: true, + }; + + const openReport: Report = { + ...mockIOUReport, + policyID: fakePolicyID, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + }; + + const fakePolicyReports: Record<`${typeof ONYXKEYS.COLLECTION.REPORT}${string}`, Report> = { + [`${ONYXKEYS.COLLECTION.REPORT}${openReport.reportID}`]: openReport, + }; + + await Onyx.multiSet({ + ...fakePolicyReports, + [`${ONYXKEYS.COLLECTION.POLICY_TAGS}${fakePolicyID}`]: fakePolicyTagsLists, + [`${ONYXKEYS.COLLECTION.POLICY}${fakePolicyID}`]: fakePolicy, + [`${ONYXKEYS.COLLECTION.TRANSACTION}${mockTransaction.transactionID}`]: { + ...mockTransaction, + reportID: openReport.reportID, + policyID: fakePolicyID, + tag: tagToDisable, + }, + }); + + await waitForBatchedUpdates(); + const {result} = renderHook(() => usePolicyData(fakePolicyID), {wrapper: OnyxListItemProvider}); + await waitForBatchedUpdates(); + + const onyxData = {optimisticData: [], failureData: []}; + pushTransactionViolationsOnyxData(onyxData, result.current, {}, {}, fakePolicyTagListsUpdate); + + expect(onyxData.optimisticData).toContainEqual({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${mockTransaction.transactionID}`, + value: {tag: remainingTag}, + }); + expect(onyxData.failureData).toContainEqual({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${mockTransaction.transactionID}`, + value: {tag: tagToDisable}, + }); + }); + it('should not auto-select when more than one enabled category remains after deletion', async () => { // Given a policy with 3 enabled categories, where the first one is being deleted (2 remain — auto-select should NOT trigger) const fakePolicyCategories = createRandomPolicyCategories(3); From c3b694b79feb954bd0a5be5ddcaeb93edbb56d93 Mon Sep 17 00:00:00 2001 From: Mukher Date: Thu, 30 Apr 2026 16:02:32 +0500 Subject: [PATCH 06/32] Extracted private helper getOptimisticPolicyState --- src/libs/ReportUtils.ts | 282 ++++++++++++++++++---------- src/libs/actions/Policy/Category.ts | 30 ++- src/libs/actions/Policy/Policy.ts | 6 +- src/libs/actions/Policy/Tag.ts | 35 +++- src/libs/actions/TaxRate.ts | 2 +- tests/unit/ReportUtilsTest.ts | 20 +- 6 files changed, 249 insertions(+), 126 deletions(-) diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index f0bbb8eaf849..8578d522253d 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -2032,30 +2032,19 @@ function isAwaitingFirstLevelApproval(report: OnyxEntry): boolean { return isProcessingReport(report) && submitsToAccountID === report.managerID; } +type PolicyOptimisticOnyxData = OnyxData< + | typeof ONYXKEYS.COLLECTION.POLICY_CATEGORIES + | typeof ONYXKEYS.COLLECTION.POLICY + | typeof ONYXKEYS.COLLECTION.POLICY_TAGS + | typeof ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS + | typeof ONYXKEYS.COLLECTION.TRANSACTION +>; + /** - * Updates optimistic transaction violations to OnyxData for the given policy and categories onyx update. - * - * @param onyxData - The OnyxData object to push updates to - * @param policyData - The current policy Data - * @param policyUpdate - Changed policy properties, if none pass empty object - * @param categoriesUpdate - Changed categories properties, if none pass empty object - * @param tagListsUpdate - Changed tag properties, if none pass empty object + * Returns the list of policy reports (excluding invoice reports) that have transactions to evaluate. */ -function pushTransactionViolationsOnyxData( - onyxData: OnyxData< - | typeof ONYXKEYS.COLLECTION.POLICY_CATEGORIES - | typeof ONYXKEYS.COLLECTION.POLICY - | typeof ONYXKEYS.COLLECTION.POLICY_TAGS - | typeof ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS - | typeof ONYXKEYS.COLLECTION.TRANSACTION - >, - policyData: PolicyData, - policyUpdate: Partial = {}, - categoriesUpdate: Record> = {}, - tagListsUpdate: Record> = {}, -) { - const nonInvoiceReportItems = policyData.reports.reduce>((acc, report) => { - // Skipping invoice reports since they should not have any category or tag violations +function getNonInvoiceReportItemsForPolicy(policyData: PolicyData): Array<{report: Report; transactionsAndViolations: ReportTransactionsAndViolations}> { + return policyData.reports.reduce>((acc, report) => { if (isInvoiceReport(report)) { return acc; } @@ -2065,26 +2054,24 @@ function pushTransactionViolationsOnyxData( } return acc; }, []); +} - if (nonInvoiceReportItems.length === 0) { - return; - } - - const updatedTagListsNames = Object.keys(tagListsUpdate); - const updatedCategoriesNames = Object.keys(categoriesUpdate); - - // If there are no updates to policy, categories or tags, return early +/** + * Merges the existing policy data with the optimistic update payloads to produce the post-update view + * used by both auto-selection and violation calculation. + */ +function getOptimisticPolicyState( + policyData: PolicyData, + policyUpdate: Partial, + categoriesUpdate: Record>, + tagListsUpdate: Record>, +) { const isPolicyUpdateEmpty = isEmptyObject(policyUpdate); - const isTagListsUpdateEmpty = updatedTagListsNames.length === 0; - const isCategoriesUpdateEmpty = updatedCategoriesNames.length === 0; - if (isPolicyUpdateEmpty && isTagListsUpdateEmpty && isCategoriesUpdateEmpty) { - return; - } + const isCategoriesUpdateEmpty = isEmptyObject(categoriesUpdate); + const isTagListsUpdateEmpty = isEmptyObject(tagListsUpdate); - // Merge the existing policy with the optimistic updates const optimisticPolicy = isPolicyUpdateEmpty ? policyData.policy : {...policyData.policy, ...policyUpdate}; - // Merge the existing categories with the optimistic updates const optimisticCategories = isCategoriesUpdateEmpty ? policyData.categories : { @@ -2101,7 +2088,6 @@ function pushTransactionViolationsOnyxData( }, {}), }; - // Merge the existing tag lists with the optimistic updates const optimisticTagLists = isTagListsUpdateEmpty ? policyData.tags : { @@ -2140,7 +2126,65 @@ function pushTransactionViolationsOnyxData( const hasDependentTagsValue = hasDependentTagsPolicyUtils(optimisticPolicy, optimisticTagLists); - // Compute sole remaining values for auto-selection when a policy value is deleted + return { + optimisticPolicy, + optimisticCategories, + optimisticTagLists, + hasDependentTagsValue, + isPolicyUpdateEmpty, + isCategoriesUpdateEmpty, + isTagListsUpdateEmpty, + }; +} + +/** + * Reads any pending transaction merges already pushed to the OnyxData object so callers running + * after `pushTransactionAutoSelectionsOnyxData` see the auto-selected category/tag values when + * computing violations. + */ +function getPendingTransactionUpdate(onyxData: PolicyOptimisticOnyxData, transactionID: string): Partial { + const targetKey = `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`; + const merges: Partial = {}; + for (const update of onyxData.optimisticData ?? []) { + if (update.onyxMethod === Onyx.METHOD.MERGE && update.key === targetKey) { + Object.assign(merges, update.value as Partial); + } + } + return merges; +} + +/** + * Auto-selects the sole remaining enabled category/tag for transactions on open or processing reports + * when a category/tag is being deleted or disabled. Pushes optimistic transaction merges and matching + * failure rollbacks to the provided OnyxData object. + * + * Call this BEFORE `pushTransactionViolationsOnyxData` so that violations are recomputed against + * the auto-selected values (suppressing the violation that would otherwise be created). + */ +function pushTransactionAutoSelectionsOnyxData( + onyxData: PolicyOptimisticOnyxData, + policyData: PolicyData, + policyUpdate: Partial = {}, + categoriesUpdate: Record> = {}, + tagListsUpdate: Record> = {}, +) { + // Auto-selection is only meaningful when categories or tag lists are being updated + if (isEmptyObject(categoriesUpdate) && isEmptyObject(tagListsUpdate)) { + return; + } + + const nonInvoiceReportItems = getNonInvoiceReportItemsForPolicy(policyData); + if (nonInvoiceReportItems.length === 0) { + return; + } + + const {optimisticCategories, optimisticTagLists, hasDependentTagsValue, isCategoriesUpdateEmpty, isTagListsUpdateEmpty} = getOptimisticPolicyState( + policyData, + policyUpdate, + categoriesUpdate, + tagListsUpdate, + ); + const enabledCategoryKeys = Object.entries(optimisticCategories) .filter(([, cat]) => cat.enabled && cat.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) .map(([key]) => key); @@ -2148,7 +2192,6 @@ function pushTransactionViolationsOnyxData( const tagListKeys = Object.keys(optimisticTagLists); - // Single-level tag auto-selection let singleRemainingTag: string | undefined; if (tagListKeys.length === 1) { const tagListName = tagListKeys.at(0) ?? ''; @@ -2158,7 +2201,6 @@ function pushTransactionViolationsOnyxData( singleRemainingTag = enabledTagKeys.length === 1 ? enabledTagKeys.at(0) : undefined; } - // Multi-level tag auto-selection (per level) const sortedTagKeys = tagListKeys.length > 1 ? getSortedTagKeys(optimisticTagLists) : []; const perLevelSingleTag: Array = sortedTagKeys.map((key) => { const tags = optimisticTagLists[key]?.tags ?? {}; @@ -2168,81 +2210,124 @@ function pushTransactionViolationsOnyxData( return enabledKeys.length === 1 ? enabledKeys.at(0) : undefined; }); - // Iterate through all policy reports to find transactions that need optimistic violations for (const { report, - transactionsAndViolations: {transactions, violations}, + transactionsAndViolations: {transactions}, } of nonInvoiceReportItems) { - const isEligibleForAutoSelect = isOpenOrProcessingReport(report); + if (!isOpenOrProcessingReport(report)) { + continue; + } for (const transaction of Object.values(transactions)) { - let modifiedTransaction = transaction; const transactionUpdates: Partial = {}; const transactionRollback: Partial = {}; - if (isEligibleForAutoSelect) { - // Category auto-select: gated to calls that actually mutate categories so toggling unrelated - // policy settings doesn't rewrite transaction category values. - if (!isCategoriesUpdateEmpty && singleRemainingCategory && transaction.category && !optimisticCategories[transaction.category]?.enabled) { - transactionUpdates.category = singleRemainingCategory; - transactionRollback.category = transaction.category; - } + // Category auto-select: gated to calls that include a category update (delete or disable) + // so toggling unrelated policy settings doesn't rewrite transaction category values. + if (!isCategoriesUpdateEmpty && singleRemainingCategory && transaction.category && !optimisticCategories[transaction.category]?.enabled) { + transactionUpdates.category = singleRemainingCategory; + transactionRollback.category = transaction.category; + } - // Single-level tag auto-select: gated to tag mutations for the same reason. - if (!isTagListsUpdateEmpty && tagListKeys.length === 1 && singleRemainingTag && transaction.tag) { - const tagListName = tagListKeys.at(0) ?? ''; - const isTagInPolicy = !!optimisticTagLists[tagListName]?.tags?.[transaction.tag]?.enabled; - if (!isTagInPolicy) { - transactionUpdates.tag = singleRemainingTag; - transactionRollback.tag = transaction.tag; - } + // Single-level tag auto-select: gated to calls that include a tag-list update for the same reason. + if (!isTagListsUpdateEmpty && tagListKeys.length === 1 && singleRemainingTag && transaction.tag) { + const tagListName = tagListKeys.at(0) ?? ''; + const isTagInPolicy = !!optimisticTagLists[tagListName]?.tags?.[transaction.tag]?.enabled; + if (!isTagInPolicy) { + transactionUpdates.tag = singleRemainingTag; + transactionRollback.tag = transaction.tag; } + } - // Multi-level tag auto-select. Skipped for dependent-tag policies because the per-level - // sole-remaining check ignores parent-tag filtering and could write an invalid combination. - if (!isTagListsUpdateEmpty && !hasDependentTagsValue && tagListKeys.length > 1 && transaction.tag) { - const currentTags = getTagArrayFromName(transaction.tag); - let anyTagChanged = false; - const newTags = [...currentTags]; - - for (let i = 0; i < sortedTagKeys.length; i++) { - const currentTag = currentTags.at(i); - if (!currentTag) { - continue; - } - const sortedTagKey = sortedTagKeys.at(i) ?? ''; - const levelTags = optimisticTagLists[sortedTagKey]?.tags ?? {}; - const isInPolicy = !!levelTags[currentTag]?.enabled; - const singleTag = perLevelSingleTag.at(i); - if (!isInPolicy && singleTag) { - newTags[i] = singleTag; - anyTagChanged = true; - } + // Multi-level tag auto-select. Skipped for dependent-tag policies because the per-level + // sole-remaining check ignores parent-tag filtering and could write an invalid combination. + if (!isTagListsUpdateEmpty && !hasDependentTagsValue && tagListKeys.length > 1 && transaction.tag) { + const currentTags = getTagArrayFromName(transaction.tag); + let anyTagChanged = false; + const newTags = [...currentTags]; + + for (let i = 0; i < sortedTagKeys.length; i++) { + const currentTag = currentTags.at(i); + if (!currentTag) { + continue; } - - if (anyTagChanged) { - transactionUpdates.tag = newTags.join(CONST.COLON); - transactionRollback.tag = transaction.tag; + const sortedTagKey = sortedTagKeys.at(i) ?? ''; + const levelTags = optimisticTagLists[sortedTagKey]?.tags ?? {}; + const isInPolicy = !!levelTags[currentTag]?.enabled; + const singleTag = perLevelSingleTag.at(i); + if (!isInPolicy && singleTag) { + newTags[i] = singleTag; + anyTagChanged = true; } } + + if (anyTagChanged) { + transactionUpdates.tag = newTags.join(CONST.COLON); + transactionRollback.tag = transaction.tag; + } } - // If auto-selection modified the transaction, push optimistic transaction updates - if (Object.keys(transactionUpdates).length > 0) { - modifiedTransaction = {...transaction, ...transactionUpdates}; + if (Object.keys(transactionUpdates).length === 0) { + continue; + } - onyxData.optimisticData?.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, - value: transactionUpdates, - }); + onyxData.optimisticData?.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, + value: transactionUpdates, + }); + onyxData.failureData?.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, + value: transactionRollback, + }); + } + } +} - onyxData.failureData?.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, - value: transactionRollback, - }); - } +/** + * Updates optimistic transaction violations to OnyxData for the given policy and categories onyx update. + * + * If `pushTransactionAutoSelectionsOnyxData` was called earlier on the same OnyxData object, this + * function picks up the auto-selected transaction values from `onyxData.optimisticData` and uses + * them when computing violations — so a transaction whose category/tag was just auto-replaced will + * not have a stale `*OutOfPolicy` violation pushed for it. + * + * @param onyxData - The OnyxData object to push updates to + * @param policyData - The current policy Data + * @param policyUpdate - Changed policy properties, if none pass empty object + * @param categoriesUpdate - Changed categories properties, if none pass empty object + * @param tagListsUpdate - Changed tag properties, if none pass empty object + */ +function pushTransactionViolationsOnyxData( + onyxData: PolicyOptimisticOnyxData, + policyData: PolicyData, + policyUpdate: Partial = {}, + categoriesUpdate: Record> = {}, + tagListsUpdate: Record> = {}, +) { + const nonInvoiceReportItems = getNonInvoiceReportItemsForPolicy(policyData); + if (nonInvoiceReportItems.length === 0) { + return; + } + + const {optimisticPolicy, optimisticCategories, optimisticTagLists, hasDependentTagsValue, isPolicyUpdateEmpty, isCategoriesUpdateEmpty, isTagListsUpdateEmpty} = getOptimisticPolicyState( + policyData, + policyUpdate, + categoriesUpdate, + tagListsUpdate, + ); + + if (isPolicyUpdateEmpty && isCategoriesUpdateEmpty && isTagListsUpdateEmpty) { + return; + } + + for (const { + transactionsAndViolations: {transactions, violations}, + } of nonInvoiceReportItems) { + for (const transaction of Object.values(transactions)) { + const pendingUpdate = getPendingTransactionUpdate(onyxData, transaction.transactionID); + const modifiedTransaction = isEmptyObject(pendingUpdate) ? transaction : {...transaction, ...pendingUpdate}; const existingViolations = violations[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transaction.transactionID}`]; const optimisticViolations = ViolationsUtils.getViolationsOnyxData( @@ -13834,6 +13919,7 @@ export { getHumanReadableStatus, getReportPersonalDetailsParticipants, isWorkspaceEligibleForReportChange, + pushTransactionAutoSelectionsOnyxData, pushTransactionViolationsOnyxData, navigateOnDeleteExpense, canRejectReportAction, diff --git a/src/libs/actions/Policy/Category.ts b/src/libs/actions/Policy/Category.ts index c24904ca07b4..6ef2b633d847 100644 --- a/src/libs/actions/Policy/Category.ts +++ b/src/libs/actions/Policy/Category.ts @@ -33,7 +33,7 @@ import Log from '@libs/Log'; import enhanceParameters from '@libs/Network/enhanceParameters'; import {hasEnabledOptions} from '@libs/OptionsListUtils'; import {goBackWhenEnableFeature} from '@libs/PolicyUtils'; -import {pushTransactionViolationsOnyxData} from '@libs/ReportUtils'; +import {pushTransactionAutoSelectionsOnyxData, pushTransactionViolationsOnyxData} from '@libs/ReportUtils'; import {getFinishOnboardingTaskOnyxData} from '@userActions/Task'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -453,6 +453,8 @@ function setWorkspaceCategoryEnabled({ ], }; + pushTransactionAutoSelectionsOnyxData(onyxData, policyData, {}, policyCategoriesOptimisticData); + pushTransactionViolationsOnyxData(onyxData, policyData, {}, policyCategoriesOptimisticData); appendSetupCategoriesOnboardingData( onyxData, @@ -600,8 +602,9 @@ function setPolicyCategoryReceiptsRequired(policyData: PolicyData, categoryName: ], }; - pushTransactionViolationsOnyxData(onyxData, policyData, {}, policyCategoriesOptimisticData); + pushTransactionAutoSelectionsOnyxData(onyxData, policyData, {}, policyCategoriesOptimisticData); + pushTransactionViolationsOnyxData(onyxData, policyData, {}, policyCategoriesOptimisticData); const parameters: SetPolicyCategoryReceiptsRequiredParams = { policyID, categoryName, @@ -665,8 +668,9 @@ function removePolicyCategoryReceiptsRequired(policyData: PolicyData, categoryNa ], }; - pushTransactionViolationsOnyxData(onyxData, policyData, {}, policyCategoriesOptimisticData); + pushTransactionAutoSelectionsOnyxData(onyxData, policyData, {}, policyCategoriesOptimisticData); + pushTransactionViolationsOnyxData(onyxData, policyData, {}, policyCategoriesOptimisticData); const parameters: RemovePolicyCategoryReceiptsRequiredParams = { policyID, categoryName, @@ -729,8 +733,9 @@ function setPolicyCategoryItemizedReceiptsRequired(policyData: PolicyData, categ ], }; - pushTransactionViolationsOnyxData(onyxData, policyData, {}, policyCategoriesOptimisticData); + pushTransactionAutoSelectionsOnyxData(onyxData, policyData, {}, policyCategoriesOptimisticData); + pushTransactionViolationsOnyxData(onyxData, policyData, {}, policyCategoriesOptimisticData); const parameters: SetPolicyCategoryItemizedReceiptsRequiredParams = { policyID, categoryName, @@ -794,8 +799,9 @@ function removePolicyCategoryItemizedReceiptsRequired(policyData: PolicyData, ca ], }; - pushTransactionViolationsOnyxData(onyxData, policyData, {}, policyCategoriesOptimisticData); + pushTransactionAutoSelectionsOnyxData(onyxData, policyData, {}, policyCategoriesOptimisticData); + pushTransactionViolationsOnyxData(onyxData, policyData, {}, policyCategoriesOptimisticData); const parameters: RemovePolicyCategoryItemizedReceiptsRequiredParams = { policyID, categoryName, @@ -865,8 +871,9 @@ function setPolicyCategoryReceiptsAndItemizedReceiptRequired(policyData: PolicyD ], }; - pushTransactionViolationsOnyxData(onyxData, policyData, {}, policyCategoriesOptimisticData); + pushTransactionAutoSelectionsOnyxData(onyxData, policyData, {}, policyCategoriesOptimisticData); + pushTransactionViolationsOnyxData(onyxData, policyData, {}, policyCategoriesOptimisticData); const parameters: SetPolicyCategoryReceiptsAndItemizedReceiptRequiredParams = { policyID, categoryName, @@ -1099,8 +1106,9 @@ function renamePolicyCategory(policyData: PolicyData, policyCategory: {oldName: return acc; }, {}); - pushTransactionViolationsOnyxData(onyxData, {...policyData, categories: policyCategories}, policyOptimisticData, policyCategoriesOptimisticData); + pushTransactionAutoSelectionsOnyxData(onyxData, {...policyData, categories: policyCategories}, policyOptimisticData, policyCategoriesOptimisticData); + pushTransactionViolationsOnyxData(onyxData, {...policyData, categories: policyCategories}, policyOptimisticData, policyCategoriesOptimisticData); const parameters = { policyID, categories: JSON.stringify({ @@ -1296,8 +1304,9 @@ function setWorkspaceRequiresCategory(policyData: PolicyData, requiresCategory: ], }; - pushTransactionViolationsOnyxData(onyxData, policyData, policyOptimisticData); + pushTransactionAutoSelectionsOnyxData(onyxData, policyData, policyOptimisticData); + pushTransactionViolationsOnyxData(onyxData, policyData, policyOptimisticData); const parameters = { policyID, requiresCategory, @@ -1389,6 +1398,8 @@ function deleteWorkspaceCategories( ], }; + pushTransactionAutoSelectionsOnyxData(onyxData, policyData, optimisticPolicyData, optimisticPolicyCategoriesData); + pushTransactionViolationsOnyxData(onyxData, policyData, optimisticPolicyData, optimisticPolicyCategoriesData); appendSetupCategoriesOnboardingData( onyxData, @@ -1476,8 +1487,9 @@ function enablePolicyCategories(policyData: PolicyData, enabled: boolean, should ], }; - pushTransactionViolationsOnyxData(onyxData, policyData, policyUpdate, policyCategoriesUpdate); + pushTransactionAutoSelectionsOnyxData(onyxData, policyData, policyUpdate, policyCategoriesUpdate); + pushTransactionViolationsOnyxData(onyxData, policyData, policyUpdate, policyCategoriesUpdate); const parameters: EnablePolicyCategoriesParams = {policyID, enabled}; // We can't use writeWithNoDuplicatesEnableFeatureConflicts because the categories data is also changed when disabling/enabling this feature diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index e3ae881fc9fb..d3519aa5d33e 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -5012,14 +5012,16 @@ function enablePolicyRules(policy: OnyxEntry, enabled: boolean, shouldGo ], }; if (policyData) { - ReportUtils.pushTransactionViolationsOnyxData(onyxData, policyData, { + const policyUpdate = { areRulesEnabled: enabled, preventSelfApproval: false, ...(!enabled ? DISABLED_MAX_EXPENSE_VALUES : {}), pendingFields: { areRulesEnabled: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, }, - }); + }; + ReportUtils.pushTransactionAutoSelectionsOnyxData(onyxData, policyData, policyUpdate); + ReportUtils.pushTransactionViolationsOnyxData(onyxData, policyData, policyUpdate); } if (enabled && isControlPolicy(policy) && policy?.outputCurrency === CONST.CURRENCY.USD) { diff --git a/src/libs/actions/Policy/Tag.ts b/src/libs/actions/Policy/Tag.ts index 907b8f913fa9..b29318f44cc9 100644 --- a/src/libs/actions/Policy/Tag.ts +++ b/src/libs/actions/Policy/Tag.ts @@ -26,7 +26,7 @@ import Log from '@libs/Log'; import enhanceParameters from '@libs/Network/enhanceParameters'; import * as PolicyUtils from '@libs/PolicyUtils'; import {goBackWhenEnableFeature} from '@libs/PolicyUtils'; -import {pushTransactionViolationsOnyxData} from '@libs/ReportUtils'; +import {pushTransactionAutoSelectionsOnyxData, pushTransactionViolationsOnyxData} from '@libs/ReportUtils'; import {getTagArrayFromName} from '@libs/TransactionUtils'; import type {PolicyTagList} from '@pages/workspace/tags/types'; import {getFinishOnboardingTaskOnyxData} from '@userActions/Task'; @@ -204,8 +204,9 @@ function createPolicyTag({ ], }; - pushTransactionViolationsOnyxData(onyxData, policyData, {}, {}, tagListsOptimisticData); + pushTransactionAutoSelectionsOnyxData(onyxData, policyData, {}, {}, tagListsOptimisticData); + pushTransactionViolationsOnyxData(onyxData, policyData, {}, {}, tagListsOptimisticData); const parameters = { policyID, tags: JSON.stringify([{name: newTagName}]), @@ -348,6 +349,17 @@ function setWorkspaceTagEnabled(policyData: PolicyData, tagsToUpdate: Record, taxesToDelete: string[], l (rate) => !!rate.attributes?.taxRateExternalID && taxesToDelete.includes(rate.attributes?.taxRateExternalID), ); - if (!policy || !policyTaxRates) { + if (!policyTaxRates) { console.debug('Policy or tax rates not found'); return; } diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index 7904759939c8..5814741ed9e3 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -141,6 +141,7 @@ import { parseReportActionHtmlToText, parseReportRouteParams, prepareOnboardingOnyxData, + pushTransactionAutoSelectionsOnyxData, pushTransactionViolationsOnyxData, reasonForReportToBeInOptionList, requiresAttentionFromCurrentUser, @@ -9643,8 +9644,9 @@ describe('ReportUtils', () => { const onyxData = {optimisticData: [], failureData: []}; - pushTransactionViolationsOnyxData(onyxData, result.current, {}, fakePolicyCategoriesUpdate, fakePolicyTagListsUpdate); + pushTransactionAutoSelectionsOnyxData(onyxData, result.current, {}, fakePolicyCategoriesUpdate, fakePolicyTagListsUpdate); + pushTransactionViolationsOnyxData(onyxData, result.current, {}, fakePolicyCategoriesUpdate, fakePolicyTagListsUpdate); const expectedOnyxData = { // Expecting the optimistic data to contain the OUT_OF_POLICY violations for the deleted category and tag optimisticData: [ @@ -9732,8 +9734,8 @@ describe('ReportUtils', () => { await waitForBatchedUpdates(); const onyxData = {optimisticData: [], failureData: []}; + pushTransactionAutoSelectionsOnyxData(onyxData, result.current, {}, fakePolicyCategoriesUpdate, {}); pushTransactionViolationsOnyxData(onyxData, result.current, {}, fakePolicyCategoriesUpdate, {}); - // The optimistic data should contain a transaction merge auto-selecting the remaining category expect(onyxData.optimisticData).toContainEqual({ onyxMethod: Onyx.METHOD.MERGE, @@ -9799,8 +9801,8 @@ describe('ReportUtils', () => { await waitForBatchedUpdates(); const onyxData = {optimisticData: [], failureData: []}; + pushTransactionAutoSelectionsOnyxData(onyxData, result.current, {}, {}, fakePolicyTagListsUpdate); pushTransactionViolationsOnyxData(onyxData, result.current, {}, {}, fakePolicyTagListsUpdate); - expect(onyxData.optimisticData).toContainEqual({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.TRANSACTION}${mockTransaction.transactionID}`, @@ -9866,8 +9868,8 @@ describe('ReportUtils', () => { await waitForBatchedUpdates(); const onyxData = {optimisticData: [], failureData: []}; + pushTransactionAutoSelectionsOnyxData(onyxData, result.current, {}, fakePolicyCategoriesUpdate, {}); pushTransactionViolationsOnyxData(onyxData, result.current, {}, fakePolicyCategoriesUpdate, {}); - expect(onyxData.optimisticData).toContainEqual({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.TRANSACTION}${mockTransaction.transactionID}`, @@ -9931,8 +9933,8 @@ describe('ReportUtils', () => { await waitForBatchedUpdates(); const onyxData = {optimisticData: [], failureData: []}; + pushTransactionAutoSelectionsOnyxData(onyxData, result.current, {}, {}, fakePolicyTagListsUpdate); pushTransactionViolationsOnyxData(onyxData, result.current, {}, {}, fakePolicyTagListsUpdate); - expect(onyxData.optimisticData).toContainEqual({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.TRANSACTION}${mockTransaction.transactionID}`, @@ -9996,8 +9998,8 @@ describe('ReportUtils', () => { await waitForBatchedUpdates(); const onyxData = {optimisticData: [], failureData: []}; + pushTransactionAutoSelectionsOnyxData(onyxData, result.current, {}, fakePolicyCategoriesUpdate, {}); pushTransactionViolationsOnyxData(onyxData, result.current, {}, fakePolicyCategoriesUpdate, {}); - // No transaction merge should be present — only the violation push const hasTransactionMerge = onyxData.optimisticData.some((update: {key: string}) => update.key === `${ONYXKEYS.COLLECTION.TRANSACTION}${mockTransaction.transactionID}`); expect(hasTransactionMerge).toBe(false); @@ -10062,8 +10064,8 @@ describe('ReportUtils', () => { await waitForBatchedUpdates(); const onyxData = {optimisticData: [], failureData: []}; + pushTransactionAutoSelectionsOnyxData(onyxData, result.current, {}, fakePolicyCategoriesUpdate, {}); pushTransactionViolationsOnyxData(onyxData, result.current, {}, fakePolicyCategoriesUpdate, {}); - // No transaction merge should be present const hasTransactionMerge = onyxData.optimisticData.some((update: {key: string}) => update.key === `${ONYXKEYS.COLLECTION.TRANSACTION}${mockTransaction.transactionID}`); expect(hasTransactionMerge).toBe(false); @@ -10119,8 +10121,8 @@ describe('ReportUtils', () => { const onyxData = {optimisticData: [], failureData: []}; // policyUpdate-only call (simulates setPolicyRulesEnabled and similar) + pushTransactionAutoSelectionsOnyxData(onyxData, result.current, {requiresCategory: true}, {}, {}); pushTransactionViolationsOnyxData(onyxData, result.current, {requiresCategory: true}, {}, {}); - const hasTransactionMerge = onyxData.optimisticData.some((update: {key: string}) => update.key === `${ONYXKEYS.COLLECTION.TRANSACTION}${mockTransaction.transactionID}`); expect(hasTransactionMerge).toBe(false); }); @@ -10185,8 +10187,8 @@ describe('ReportUtils', () => { }; const onyxData = {optimisticData: [], failureData: []}; + pushTransactionAutoSelectionsOnyxData(onyxData, result.current, {}, {}, fakePolicyTagListsUpdate); pushTransactionViolationsOnyxData(onyxData, result.current, {}, {}, fakePolicyTagListsUpdate); - const hasTransactionMerge = onyxData.optimisticData.some((update: {key: string}) => update.key === `${ONYXKEYS.COLLECTION.TRANSACTION}${mockTransaction.transactionID}`); expect(hasTransactionMerge).toBe(false); }); From ee8d9155609a918c497f4ed3167839112dd9fda8 Mon Sep 17 00:00:00 2001 From: Mukher Date: Thu, 30 Apr 2026 16:22:42 +0500 Subject: [PATCH 07/32] fixed prettier --- tests/unit/ReportUtilsTest.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index 5814741ed9e3..46002b05865c 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -9684,7 +9684,6 @@ describe('ReportUtils', () => { // Given a policy with 2 enabled categories, where the first one is being deleted const fakePolicyCategories = createRandomPolicyCategories(2); for (const cat of Object.values(fakePolicyCategories)) { - // eslint-disable-next-line no-param-reassign cat.enabled = true; } const categoryNames = Object.keys(fakePolicyCategories); @@ -9819,7 +9818,6 @@ describe('ReportUtils', () => { // Given a policy with 2 enabled categories, where the first one is being disabled (UPDATE, not DELETE) const fakePolicyCategories = createRandomPolicyCategories(2); for (const cat of Object.values(fakePolicyCategories)) { - // eslint-disable-next-line no-param-reassign cat.enabled = true; } const categoryNames = Object.keys(fakePolicyCategories); @@ -9951,7 +9949,6 @@ describe('ReportUtils', () => { // Given a policy with 3 enabled categories, where the first one is being deleted (2 remain — auto-select should NOT trigger) const fakePolicyCategories = createRandomPolicyCategories(3); for (const cat of Object.values(fakePolicyCategories)) { - // eslint-disable-next-line no-param-reassign cat.enabled = true; } const categoryToDelete = Object.keys(fakePolicyCategories).at(0) ?? ''; @@ -10016,7 +10013,6 @@ describe('ReportUtils', () => { // Given a sole remaining enabled category but the report is already APPROVED (not eligible for auto-select) const fakePolicyCategories = createRandomPolicyCategories(2); for (const cat of Object.values(fakePolicyCategories)) { - // eslint-disable-next-line no-param-reassign cat.enabled = true; } const categoryToDelete = Object.keys(fakePolicyCategories).at(0) ?? ''; @@ -10076,7 +10072,6 @@ describe('ReportUtils', () => { // (e.g. toggling an unrelated workspace setting). Auto-select must NOT mutate transactions. const fakePolicyCategories = createRandomPolicyCategories(2); for (const cat of Object.values(fakePolicyCategories)) { - // eslint-disable-next-line no-param-reassign cat.enabled = true; } const categoryNames = Object.keys(fakePolicyCategories); From 4213c623ebb3e076a7f4b3b0403297aa6a194ef5 Mon Sep 17 00:00:00 2001 From: Mukher Date: Mon, 4 May 2026 11:44:48 +0500 Subject: [PATCH 08/32] Refactor pushTransactionAutoSelectionsOnyxData to return updates map --- src/libs/ReportUtils.ts | 44 ++++++++++++----------------- src/libs/actions/Policy/Category.ts | 26 ++++------------- src/libs/actions/Policy/Policy.ts | 1 - src/libs/actions/Policy/Tag.ts | 23 ++++----------- tests/unit/ReportUtilsTest.ts | 36 +++++++++++------------ 5 files changed, 48 insertions(+), 82 deletions(-) diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 8578d522253d..e2025787026a 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -2137,26 +2137,11 @@ function getOptimisticPolicyState( }; } -/** - * Reads any pending transaction merges already pushed to the OnyxData object so callers running - * after `pushTransactionAutoSelectionsOnyxData` see the auto-selected category/tag values when - * computing violations. - */ -function getPendingTransactionUpdate(onyxData: PolicyOptimisticOnyxData, transactionID: string): Partial { - const targetKey = `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`; - const merges: Partial = {}; - for (const update of onyxData.optimisticData ?? []) { - if (update.onyxMethod === Onyx.METHOD.MERGE && update.key === targetKey) { - Object.assign(merges, update.value as Partial); - } - } - return merges; -} - /** * Auto-selects the sole remaining enabled category/tag for transactions on open or processing reports * when a category/tag is being deleted or disabled. Pushes optimistic transaction merges and matching - * failure rollbacks to the provided OnyxData object. + * failure rollbacks to the provided OnyxData object, and returns the per-transaction updates so callers + * can hand them to `pushTransactionViolationsOnyxData` for violation recomputation. * * Call this BEFORE `pushTransactionViolationsOnyxData` so that violations are recomputed against * the auto-selected values (suppressing the violation that would otherwise be created). @@ -2167,15 +2152,17 @@ function pushTransactionAutoSelectionsOnyxData( policyUpdate: Partial = {}, categoriesUpdate: Record> = {}, tagListsUpdate: Record> = {}, -) { +): Map> { + const autoSelections = new Map>(); + // Auto-selection is only meaningful when categories or tag lists are being updated if (isEmptyObject(categoriesUpdate) && isEmptyObject(tagListsUpdate)) { - return; + return autoSelections; } const nonInvoiceReportItems = getNonInvoiceReportItemsForPolicy(policyData); if (nonInvoiceReportItems.length === 0) { - return; + return autoSelections; } const {optimisticCategories, optimisticTagLists, hasDependentTagsValue, isCategoriesUpdateEmpty, isTagListsUpdateEmpty} = getOptimisticPolicyState( @@ -2271,6 +2258,8 @@ function pushTransactionAutoSelectionsOnyxData( continue; } + autoSelections.set(transaction.transactionID, transactionUpdates); + onyxData.optimisticData?.push({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, @@ -2283,21 +2272,23 @@ function pushTransactionAutoSelectionsOnyxData( }); } } + + return autoSelections; } /** * Updates optimistic transaction violations to OnyxData for the given policy and categories onyx update. * - * If `pushTransactionAutoSelectionsOnyxData` was called earlier on the same OnyxData object, this - * function picks up the auto-selected transaction values from `onyxData.optimisticData` and uses - * them when computing violations — so a transaction whose category/tag was just auto-replaced will - * not have a stale `*OutOfPolicy` violation pushed for it. + * Pass the map returned by `pushTransactionAutoSelectionsOnyxData` as `transactionAutoSelections` so + * a transaction whose category/tag was just auto-replaced is evaluated against the new value (and does + * not get a stale `*OutOfPolicy` violation). * * @param onyxData - The OnyxData object to push updates to * @param policyData - The current policy Data * @param policyUpdate - Changed policy properties, if none pass empty object * @param categoriesUpdate - Changed categories properties, if none pass empty object * @param tagListsUpdate - Changed tag properties, if none pass empty object + * @param transactionAutoSelections - Auto-selected category/tag updates per transactionID (from `pushTransactionAutoSelectionsOnyxData`) */ function pushTransactionViolationsOnyxData( onyxData: PolicyOptimisticOnyxData, @@ -2305,6 +2296,7 @@ function pushTransactionViolationsOnyxData( policyUpdate: Partial = {}, categoriesUpdate: Record> = {}, tagListsUpdate: Record> = {}, + transactionAutoSelections: Map> = new Map>(), ) { const nonInvoiceReportItems = getNonInvoiceReportItemsForPolicy(policyData); if (nonInvoiceReportItems.length === 0) { @@ -2326,8 +2318,8 @@ function pushTransactionViolationsOnyxData( transactionsAndViolations: {transactions, violations}, } of nonInvoiceReportItems) { for (const transaction of Object.values(transactions)) { - const pendingUpdate = getPendingTransactionUpdate(onyxData, transaction.transactionID); - const modifiedTransaction = isEmptyObject(pendingUpdate) ? transaction : {...transaction, ...pendingUpdate}; + const pendingUpdate = transactionAutoSelections.get(transaction.transactionID); + const modifiedTransaction = pendingUpdate ? {...transaction, ...pendingUpdate} : transaction; const existingViolations = violations[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transaction.transactionID}`]; const optimisticViolations = ViolationsUtils.getViolationsOnyxData( diff --git a/src/libs/actions/Policy/Category.ts b/src/libs/actions/Policy/Category.ts index 6ef2b633d847..75b16e8ae8b2 100644 --- a/src/libs/actions/Policy/Category.ts +++ b/src/libs/actions/Policy/Category.ts @@ -453,9 +453,9 @@ function setWorkspaceCategoryEnabled({ ], }; - pushTransactionAutoSelectionsOnyxData(onyxData, policyData, {}, policyCategoriesOptimisticData); + const autoSelections = pushTransactionAutoSelectionsOnyxData(onyxData, policyData, {}, policyCategoriesOptimisticData); - pushTransactionViolationsOnyxData(onyxData, policyData, {}, policyCategoriesOptimisticData); + pushTransactionViolationsOnyxData(onyxData, policyData, {}, policyCategoriesOptimisticData, {}, autoSelections); appendSetupCategoriesOnboardingData( onyxData, setupCategoryTaskReport, @@ -602,8 +602,6 @@ function setPolicyCategoryReceiptsRequired(policyData: PolicyData, categoryName: ], }; - pushTransactionAutoSelectionsOnyxData(onyxData, policyData, {}, policyCategoriesOptimisticData); - pushTransactionViolationsOnyxData(onyxData, policyData, {}, policyCategoriesOptimisticData); const parameters: SetPolicyCategoryReceiptsRequiredParams = { policyID, @@ -668,8 +666,6 @@ function removePolicyCategoryReceiptsRequired(policyData: PolicyData, categoryNa ], }; - pushTransactionAutoSelectionsOnyxData(onyxData, policyData, {}, policyCategoriesOptimisticData); - pushTransactionViolationsOnyxData(onyxData, policyData, {}, policyCategoriesOptimisticData); const parameters: RemovePolicyCategoryReceiptsRequiredParams = { policyID, @@ -733,8 +729,6 @@ function setPolicyCategoryItemizedReceiptsRequired(policyData: PolicyData, categ ], }; - pushTransactionAutoSelectionsOnyxData(onyxData, policyData, {}, policyCategoriesOptimisticData); - pushTransactionViolationsOnyxData(onyxData, policyData, {}, policyCategoriesOptimisticData); const parameters: SetPolicyCategoryItemizedReceiptsRequiredParams = { policyID, @@ -799,8 +793,6 @@ function removePolicyCategoryItemizedReceiptsRequired(policyData: PolicyData, ca ], }; - pushTransactionAutoSelectionsOnyxData(onyxData, policyData, {}, policyCategoriesOptimisticData); - pushTransactionViolationsOnyxData(onyxData, policyData, {}, policyCategoriesOptimisticData); const parameters: RemovePolicyCategoryItemizedReceiptsRequiredParams = { policyID, @@ -871,8 +863,6 @@ function setPolicyCategoryReceiptsAndItemizedReceiptRequired(policyData: PolicyD ], }; - pushTransactionAutoSelectionsOnyxData(onyxData, policyData, {}, policyCategoriesOptimisticData); - pushTransactionViolationsOnyxData(onyxData, policyData, {}, policyCategoriesOptimisticData); const parameters: SetPolicyCategoryReceiptsAndItemizedReceiptRequiredParams = { policyID, @@ -1106,8 +1096,6 @@ function renamePolicyCategory(policyData: PolicyData, policyCategory: {oldName: return acc; }, {}); - pushTransactionAutoSelectionsOnyxData(onyxData, {...policyData, categories: policyCategories}, policyOptimisticData, policyCategoriesOptimisticData); - pushTransactionViolationsOnyxData(onyxData, {...policyData, categories: policyCategories}, policyOptimisticData, policyCategoriesOptimisticData); const parameters = { policyID, @@ -1304,8 +1292,6 @@ function setWorkspaceRequiresCategory(policyData: PolicyData, requiresCategory: ], }; - pushTransactionAutoSelectionsOnyxData(onyxData, policyData, policyOptimisticData); - pushTransactionViolationsOnyxData(onyxData, policyData, policyOptimisticData); const parameters = { policyID, @@ -1398,9 +1384,9 @@ function deleteWorkspaceCategories( ], }; - pushTransactionAutoSelectionsOnyxData(onyxData, policyData, optimisticPolicyData, optimisticPolicyCategoriesData); + const autoSelections = pushTransactionAutoSelectionsOnyxData(onyxData, policyData, optimisticPolicyData, optimisticPolicyCategoriesData); - pushTransactionViolationsOnyxData(onyxData, policyData, optimisticPolicyData, optimisticPolicyCategoriesData); + pushTransactionViolationsOnyxData(onyxData, policyData, optimisticPolicyData, optimisticPolicyCategoriesData, {}, autoSelections); appendSetupCategoriesOnboardingData( onyxData, setupCategoryTaskReport, @@ -1487,9 +1473,9 @@ function enablePolicyCategories(policyData: PolicyData, enabled: boolean, should ], }; - pushTransactionAutoSelectionsOnyxData(onyxData, policyData, policyUpdate, policyCategoriesUpdate); + const autoSelections = pushTransactionAutoSelectionsOnyxData(onyxData, policyData, policyUpdate, policyCategoriesUpdate); - pushTransactionViolationsOnyxData(onyxData, policyData, policyUpdate, policyCategoriesUpdate); + pushTransactionViolationsOnyxData(onyxData, policyData, policyUpdate, policyCategoriesUpdate, {}, autoSelections); const parameters: EnablePolicyCategoriesParams = {policyID, enabled}; // We can't use writeWithNoDuplicatesEnableFeatureConflicts because the categories data is also changed when disabling/enabling this feature diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index d3519aa5d33e..808cf6cb0622 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -5020,7 +5020,6 @@ function enablePolicyRules(policy: OnyxEntry, enabled: boolean, shouldGo areRulesEnabled: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, }, }; - ReportUtils.pushTransactionAutoSelectionsOnyxData(onyxData, policyData, policyUpdate); ReportUtils.pushTransactionViolationsOnyxData(onyxData, policyData, policyUpdate); } diff --git a/src/libs/actions/Policy/Tag.ts b/src/libs/actions/Policy/Tag.ts index b29318f44cc9..f5469198533c 100644 --- a/src/libs/actions/Policy/Tag.ts +++ b/src/libs/actions/Policy/Tag.ts @@ -204,8 +204,6 @@ function createPolicyTag({ ], }; - pushTransactionAutoSelectionsOnyxData(onyxData, policyData, {}, {}, tagListsOptimisticData); - pushTransactionViolationsOnyxData(onyxData, policyData, {}, {}, tagListsOptimisticData); const parameters = { policyID, @@ -349,7 +347,7 @@ function setWorkspaceTagEnabled(policyData: PolicyData, tagsToUpdate: Record { const onyxData = {optimisticData: [], failureData: []}; - pushTransactionAutoSelectionsOnyxData(onyxData, result.current, {}, fakePolicyCategoriesUpdate, fakePolicyTagListsUpdate); + const autoSelections = pushTransactionAutoSelectionsOnyxData(onyxData, result.current, {}, fakePolicyCategoriesUpdate, fakePolicyTagListsUpdate); - pushTransactionViolationsOnyxData(onyxData, result.current, {}, fakePolicyCategoriesUpdate, fakePolicyTagListsUpdate); + pushTransactionViolationsOnyxData(onyxData, result.current, {}, fakePolicyCategoriesUpdate, fakePolicyTagListsUpdate, autoSelections); const expectedOnyxData = { // Expecting the optimistic data to contain the OUT_OF_POLICY violations for the deleted category and tag optimisticData: [ @@ -9733,8 +9733,8 @@ describe('ReportUtils', () => { await waitForBatchedUpdates(); const onyxData = {optimisticData: [], failureData: []}; - pushTransactionAutoSelectionsOnyxData(onyxData, result.current, {}, fakePolicyCategoriesUpdate, {}); - pushTransactionViolationsOnyxData(onyxData, result.current, {}, fakePolicyCategoriesUpdate, {}); + const autoSelections = pushTransactionAutoSelectionsOnyxData(onyxData, result.current, {}, fakePolicyCategoriesUpdate, {}); + pushTransactionViolationsOnyxData(onyxData, result.current, {}, fakePolicyCategoriesUpdate, {}, autoSelections); // The optimistic data should contain a transaction merge auto-selecting the remaining category expect(onyxData.optimisticData).toContainEqual({ onyxMethod: Onyx.METHOD.MERGE, @@ -9800,8 +9800,8 @@ describe('ReportUtils', () => { await waitForBatchedUpdates(); const onyxData = {optimisticData: [], failureData: []}; - pushTransactionAutoSelectionsOnyxData(onyxData, result.current, {}, {}, fakePolicyTagListsUpdate); - pushTransactionViolationsOnyxData(onyxData, result.current, {}, {}, fakePolicyTagListsUpdate); + const autoSelections = pushTransactionAutoSelectionsOnyxData(onyxData, result.current, {}, {}, fakePolicyTagListsUpdate); + pushTransactionViolationsOnyxData(onyxData, result.current, {}, {}, fakePolicyTagListsUpdate, autoSelections); expect(onyxData.optimisticData).toContainEqual({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.TRANSACTION}${mockTransaction.transactionID}`, @@ -9866,8 +9866,8 @@ describe('ReportUtils', () => { await waitForBatchedUpdates(); const onyxData = {optimisticData: [], failureData: []}; - pushTransactionAutoSelectionsOnyxData(onyxData, result.current, {}, fakePolicyCategoriesUpdate, {}); - pushTransactionViolationsOnyxData(onyxData, result.current, {}, fakePolicyCategoriesUpdate, {}); + const autoSelections = pushTransactionAutoSelectionsOnyxData(onyxData, result.current, {}, fakePolicyCategoriesUpdate, {}); + pushTransactionViolationsOnyxData(onyxData, result.current, {}, fakePolicyCategoriesUpdate, {}, autoSelections); expect(onyxData.optimisticData).toContainEqual({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.TRANSACTION}${mockTransaction.transactionID}`, @@ -9931,8 +9931,8 @@ describe('ReportUtils', () => { await waitForBatchedUpdates(); const onyxData = {optimisticData: [], failureData: []}; - pushTransactionAutoSelectionsOnyxData(onyxData, result.current, {}, {}, fakePolicyTagListsUpdate); - pushTransactionViolationsOnyxData(onyxData, result.current, {}, {}, fakePolicyTagListsUpdate); + const autoSelections = pushTransactionAutoSelectionsOnyxData(onyxData, result.current, {}, {}, fakePolicyTagListsUpdate); + pushTransactionViolationsOnyxData(onyxData, result.current, {}, {}, fakePolicyTagListsUpdate, autoSelections); expect(onyxData.optimisticData).toContainEqual({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.TRANSACTION}${mockTransaction.transactionID}`, @@ -9995,8 +9995,8 @@ describe('ReportUtils', () => { await waitForBatchedUpdates(); const onyxData = {optimisticData: [], failureData: []}; - pushTransactionAutoSelectionsOnyxData(onyxData, result.current, {}, fakePolicyCategoriesUpdate, {}); - pushTransactionViolationsOnyxData(onyxData, result.current, {}, fakePolicyCategoriesUpdate, {}); + const autoSelections = pushTransactionAutoSelectionsOnyxData(onyxData, result.current, {}, fakePolicyCategoriesUpdate, {}); + pushTransactionViolationsOnyxData(onyxData, result.current, {}, fakePolicyCategoriesUpdate, {}, autoSelections); // No transaction merge should be present — only the violation push const hasTransactionMerge = onyxData.optimisticData.some((update: {key: string}) => update.key === `${ONYXKEYS.COLLECTION.TRANSACTION}${mockTransaction.transactionID}`); expect(hasTransactionMerge).toBe(false); @@ -10060,8 +10060,8 @@ describe('ReportUtils', () => { await waitForBatchedUpdates(); const onyxData = {optimisticData: [], failureData: []}; - pushTransactionAutoSelectionsOnyxData(onyxData, result.current, {}, fakePolicyCategoriesUpdate, {}); - pushTransactionViolationsOnyxData(onyxData, result.current, {}, fakePolicyCategoriesUpdate, {}); + const autoSelections = pushTransactionAutoSelectionsOnyxData(onyxData, result.current, {}, fakePolicyCategoriesUpdate, {}); + pushTransactionViolationsOnyxData(onyxData, result.current, {}, fakePolicyCategoriesUpdate, {}, autoSelections); // No transaction merge should be present const hasTransactionMerge = onyxData.optimisticData.some((update: {key: string}) => update.key === `${ONYXKEYS.COLLECTION.TRANSACTION}${mockTransaction.transactionID}`); expect(hasTransactionMerge).toBe(false); @@ -10116,8 +10116,8 @@ describe('ReportUtils', () => { const onyxData = {optimisticData: [], failureData: []}; // policyUpdate-only call (simulates setPolicyRulesEnabled and similar) - pushTransactionAutoSelectionsOnyxData(onyxData, result.current, {requiresCategory: true}, {}, {}); - pushTransactionViolationsOnyxData(onyxData, result.current, {requiresCategory: true}, {}, {}); + const autoSelections = pushTransactionAutoSelectionsOnyxData(onyxData, result.current, {requiresCategory: true}, {}, {}); + pushTransactionViolationsOnyxData(onyxData, result.current, {requiresCategory: true}, {}, {}, autoSelections); const hasTransactionMerge = onyxData.optimisticData.some((update: {key: string}) => update.key === `${ONYXKEYS.COLLECTION.TRANSACTION}${mockTransaction.transactionID}`); expect(hasTransactionMerge).toBe(false); }); @@ -10182,8 +10182,8 @@ describe('ReportUtils', () => { }; const onyxData = {optimisticData: [], failureData: []}; - pushTransactionAutoSelectionsOnyxData(onyxData, result.current, {}, {}, fakePolicyTagListsUpdate); - pushTransactionViolationsOnyxData(onyxData, result.current, {}, {}, fakePolicyTagListsUpdate); + const autoSelections = pushTransactionAutoSelectionsOnyxData(onyxData, result.current, {}, {}, fakePolicyTagListsUpdate); + pushTransactionViolationsOnyxData(onyxData, result.current, {}, {}, fakePolicyTagListsUpdate, autoSelections); const hasTransactionMerge = onyxData.optimisticData.some((update: {key: string}) => update.key === `${ONYXKEYS.COLLECTION.TRANSACTION}${mockTransaction.transactionID}`); expect(hasTransactionMerge).toBe(false); }); From 0756c3a8db1c88eea8f01b3ea60b806ce1eda8f8 Mon Sep 17 00:00:00 2001 From: Mukher Date: Mon, 4 May 2026 15:48:10 +0500 Subject: [PATCH 09/32] reverted unnecessary code change --- src/libs/actions/Policy/Category.ts | 8 ++++++++ src/libs/actions/Policy/Policy.ts | 5 ++--- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/libs/actions/Policy/Category.ts b/src/libs/actions/Policy/Category.ts index 75b16e8ae8b2..1dedb110c313 100644 --- a/src/libs/actions/Policy/Category.ts +++ b/src/libs/actions/Policy/Category.ts @@ -603,6 +603,7 @@ function setPolicyCategoryReceiptsRequired(policyData: PolicyData, categoryName: }; pushTransactionViolationsOnyxData(onyxData, policyData, {}, policyCategoriesOptimisticData); + const parameters: SetPolicyCategoryReceiptsRequiredParams = { policyID, categoryName, @@ -667,6 +668,7 @@ function removePolicyCategoryReceiptsRequired(policyData: PolicyData, categoryNa }; pushTransactionViolationsOnyxData(onyxData, policyData, {}, policyCategoriesOptimisticData); + const parameters: RemovePolicyCategoryReceiptsRequiredParams = { policyID, categoryName, @@ -730,6 +732,7 @@ function setPolicyCategoryItemizedReceiptsRequired(policyData: PolicyData, categ }; pushTransactionViolationsOnyxData(onyxData, policyData, {}, policyCategoriesOptimisticData); + const parameters: SetPolicyCategoryItemizedReceiptsRequiredParams = { policyID, categoryName, @@ -794,6 +797,7 @@ function removePolicyCategoryItemizedReceiptsRequired(policyData: PolicyData, ca }; pushTransactionViolationsOnyxData(onyxData, policyData, {}, policyCategoriesOptimisticData); + const parameters: RemovePolicyCategoryItemizedReceiptsRequiredParams = { policyID, categoryName, @@ -864,6 +868,7 @@ function setPolicyCategoryReceiptsAndItemizedReceiptRequired(policyData: PolicyD }; pushTransactionViolationsOnyxData(onyxData, policyData, {}, policyCategoriesOptimisticData); + const parameters: SetPolicyCategoryReceiptsAndItemizedReceiptRequiredParams = { policyID, categoryName, @@ -1097,6 +1102,7 @@ function renamePolicyCategory(policyData: PolicyData, policyCategory: {oldName: }, {}); pushTransactionViolationsOnyxData(onyxData, {...policyData, categories: policyCategories}, policyOptimisticData, policyCategoriesOptimisticData); + const parameters = { policyID, categories: JSON.stringify({ @@ -1293,6 +1299,7 @@ function setWorkspaceRequiresCategory(policyData: PolicyData, requiresCategory: }; pushTransactionViolationsOnyxData(onyxData, policyData, policyOptimisticData); + const parameters = { policyID, requiresCategory, @@ -1476,6 +1483,7 @@ function enablePolicyCategories(policyData: PolicyData, enabled: boolean, should const autoSelections = pushTransactionAutoSelectionsOnyxData(onyxData, policyData, policyUpdate, policyCategoriesUpdate); pushTransactionViolationsOnyxData(onyxData, policyData, policyUpdate, policyCategoriesUpdate, {}, autoSelections); + const parameters: EnablePolicyCategoriesParams = {policyID, enabled}; // We can't use writeWithNoDuplicatesEnableFeatureConflicts because the categories data is also changed when disabling/enabling this feature diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index 808cf6cb0622..e3ae881fc9fb 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -5012,15 +5012,14 @@ function enablePolicyRules(policy: OnyxEntry, enabled: boolean, shouldGo ], }; if (policyData) { - const policyUpdate = { + ReportUtils.pushTransactionViolationsOnyxData(onyxData, policyData, { areRulesEnabled: enabled, preventSelfApproval: false, ...(!enabled ? DISABLED_MAX_EXPENSE_VALUES : {}), pendingFields: { areRulesEnabled: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, }, - }; - ReportUtils.pushTransactionViolationsOnyxData(onyxData, policyData, policyUpdate); + }); } if (enabled && isControlPolicy(policy) && policy?.outputCurrency === CONST.CURRENCY.USD) { From 2ae9d23ee29d2b61d050a394b5acb9287e93e9dd Mon Sep 17 00:00:00 2001 From: Mukher Date: Thu, 7 May 2026 07:36:44 +0500 Subject: [PATCH 10/32] reverted multi-level tags auto-selection --- src/libs/ReportUtils.ts | 50 ++++------------------------------- tests/unit/ReportUtilsTest.ts | 12 ++++----- 2 files changed, 11 insertions(+), 51 deletions(-) diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index e2025787026a..3a8cce6b5c05 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -152,7 +152,6 @@ import { getPolicyNameByID, getPolicyRole, getRuleApprovers, - getSortedTagKeys, getSubmitToAccountID, hasDependentTags as hasDependentTagsPolicyUtils, hasDynamicExternalWorkflow, @@ -272,7 +271,6 @@ import { getRecentTransactions, getReimbursable, getTag, - getTagArrayFromName, getTaxAmount, getTaxCode, getTaxName, @@ -2165,12 +2163,7 @@ function pushTransactionAutoSelectionsOnyxData( return autoSelections; } - const {optimisticCategories, optimisticTagLists, hasDependentTagsValue, isCategoriesUpdateEmpty, isTagListsUpdateEmpty} = getOptimisticPolicyState( - policyData, - policyUpdate, - categoriesUpdate, - tagListsUpdate, - ); + const {optimisticCategories, optimisticTagLists, isCategoriesUpdateEmpty, isTagListsUpdateEmpty} = getOptimisticPolicyState(policyData, policyUpdate, categoriesUpdate, tagListsUpdate); const enabledCategoryKeys = Object.entries(optimisticCategories) .filter(([, cat]) => cat.enabled && cat.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) @@ -2179,6 +2172,10 @@ function pushTransactionAutoSelectionsOnyxData( const tagListKeys = Object.keys(optimisticTagLists); + // Auto-replace is scoped to single-level tags only. Web-E's removeTags() throws "NTagging not supported yet" for + // multi-level tag lists, and the auto-replace metadata we send to Auth doesn't carry tagListIndex/tagListName, + // so the backend can't know which level of the multi-level tag string should be replaced. Multi-level support + // is tracked as a separate NTag follow-up. let singleRemainingTag: string | undefined; if (tagListKeys.length === 1) { const tagListName = tagListKeys.at(0) ?? ''; @@ -2188,15 +2185,6 @@ function pushTransactionAutoSelectionsOnyxData( singleRemainingTag = enabledTagKeys.length === 1 ? enabledTagKeys.at(0) : undefined; } - const sortedTagKeys = tagListKeys.length > 1 ? getSortedTagKeys(optimisticTagLists) : []; - const perLevelSingleTag: Array = sortedTagKeys.map((key) => { - const tags = optimisticTagLists[key]?.tags ?? {}; - const enabledKeys = Object.entries(tags) - .filter(([, tag]) => tag.enabled && tag.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) - .map(([k]) => k); - return enabledKeys.length === 1 ? enabledKeys.at(0) : undefined; - }); - for (const { report, transactionsAndViolations: {transactions}, @@ -2226,34 +2214,6 @@ function pushTransactionAutoSelectionsOnyxData( } } - // Multi-level tag auto-select. Skipped for dependent-tag policies because the per-level - // sole-remaining check ignores parent-tag filtering and could write an invalid combination. - if (!isTagListsUpdateEmpty && !hasDependentTagsValue && tagListKeys.length > 1 && transaction.tag) { - const currentTags = getTagArrayFromName(transaction.tag); - let anyTagChanged = false; - const newTags = [...currentTags]; - - for (let i = 0; i < sortedTagKeys.length; i++) { - const currentTag = currentTags.at(i); - if (!currentTag) { - continue; - } - const sortedTagKey = sortedTagKeys.at(i) ?? ''; - const levelTags = optimisticTagLists[sortedTagKey]?.tags ?? {}; - const isInPolicy = !!levelTags[currentTag]?.enabled; - const singleTag = perLevelSingleTag.at(i); - if (!isInPolicy && singleTag) { - newTags[i] = singleTag; - anyTagChanged = true; - } - } - - if (anyTagChanged) { - transactionUpdates.tag = newTags.join(CONST.COLON); - transactionRollback.tag = transaction.tag; - } - } - if (Object.keys(transactionUpdates).length === 0) { continue; } diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index 29253c860ebb..a4727862ab68 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -10122,14 +10122,15 @@ describe('ReportUtils', () => { expect(hasTransactionMerge).toBe(false); }); - it('should not auto-select multi-level tags when the policy has dependent tags', async () => { - // Given a multi-level tag policy with dependent tags, where one level has a sole remaining tag. - // Auto-select should be skipped because the per-level sole-remaining check ignores parent filtering. + it('should not auto-select tags for multi-level tag policies', async () => { + // Auto-replace is intentionally scoped to single-level tags. Web-E does not support multi-level tag + // removal yet, and the auto-replace metadata sent to Auth doesn't carry tagListIndex/tagListName, + // so the backend can't determine which level of the multi-level tag string to replace. const level1Tags: PolicyTags = { - Engineering: {name: 'Engineering', enabled: true, rules: {parentTagsFilter: ''}}, + Engineering: {name: 'Engineering', enabled: true}, }; const level2Tags: PolicyTags = { - Q1: {name: 'Q1', enabled: true, rules: {parentTagsFilter: 'Engineering'}}, + Q1: {name: 'Q1', enabled: true}, }; const fakePolicyTagsLists: PolicyTagLists = { Department: {name: 'Department', orderWeight: 0, required: false, tags: level1Tags}, @@ -10173,7 +10174,6 @@ describe('ReportUtils', () => { const {result} = renderHook(() => usePolicyData(fakePolicyID), {wrapper: OnyxListItemProvider}); await waitForBatchedUpdates(); - // tagListsUpdate is non-empty so we exercise the multi-level branch's dependent-tag guard const tagsToDelete: Record>> = { Sales: {pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, enabled: false}, }; From 307c5693685b2a1715f0db212b74af4e7d73167c Mon Sep 17 00:00:00 2001 From: Mukhriddin Shakhriyorov <71601329+mukhrr@users.noreply.github.com> Date: Mon, 1 Jun 2026 19:00:34 +0500 Subject: [PATCH 11/32] Update tests/unit/ReportUtilsTest.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lucien Akchoté --- tests/unit/ReportUtilsTest.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index 99413909c1ab..4ebaf1360547 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -10259,6 +10259,7 @@ describe('ReportUtils', () => { const onyxData = {optimisticData: [], failureData: []}; const autoSelections = pushTransactionAutoSelectionsOnyxData(onyxData, result.current, {}, fakePolicyCategoriesUpdate, {}); pushTransactionViolationsOnyxData(onyxData, result.current, {}, fakePolicyCategoriesUpdate, {}, autoSelections); + // The optimistic data should contain a transaction merge auto-selecting the remaining category expect(onyxData.optimisticData).toContainEqual({ onyxMethod: Onyx.METHOD.MERGE, From d466ad14af826c3e6ca7e90a973e99064a9cb700 Mon Sep 17 00:00:00 2001 From: Mukhriddin Shakhriyorov <71601329+mukhrr@users.noreply.github.com> Date: Mon, 1 Jun 2026 19:00:52 +0500 Subject: [PATCH 12/32] Update tests/unit/ReportUtilsTest.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lucien Akchoté --- tests/unit/ReportUtilsTest.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index 4ebaf1360547..2d5c1ba83fb9 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -10640,7 +10640,6 @@ describe('ReportUtils', () => { await waitForBatchedUpdates(); const onyxData = {optimisticData: [], failureData: []}; - // policyUpdate-only call (simulates setPolicyRulesEnabled and similar) const autoSelections = pushTransactionAutoSelectionsOnyxData(onyxData, result.current, {requiresCategory: true}, {}, {}); pushTransactionViolationsOnyxData(onyxData, result.current, {requiresCategory: true}, {}, {}, autoSelections); const hasTransactionMerge = onyxData.optimisticData.some((update: {key: string}) => update.key === `${ONYXKEYS.COLLECTION.TRANSACTION}${mockTransaction.transactionID}`); From 5f949883a9013a622be8489ad81b79ca844f8473 Mon Sep 17 00:00:00 2001 From: Mukhriddin Shakhriyorov <71601329+mukhrr@users.noreply.github.com> Date: Mon, 1 Jun 2026 19:01:08 +0500 Subject: [PATCH 13/32] Update tests/unit/ReportUtilsTest.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lucien Akchoté --- tests/unit/ReportUtilsTest.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index 2d5c1ba83fb9..5b14c8ddfeee 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -10601,6 +10601,7 @@ describe('ReportUtils', () => { } const categoryNames = Object.keys(fakePolicyCategories); const transactionCategory = categoryNames.at(0) ?? ''; + // Mark this category disabled in Onyx so it is "out of policy" without any pending DELETE update fakePolicyCategories[transactionCategory].enabled = false; From addaecee90d0ccb46671c8349673c5541cf93522 Mon Sep 17 00:00:00 2001 From: Mukhriddin Shakhriyorov <71601329+mukhrr@users.noreply.github.com> Date: Mon, 1 Jun 2026 19:01:25 +0500 Subject: [PATCH 14/32] Update tests/unit/ReportUtilsTest.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lucien Akchoté --- tests/unit/ReportUtilsTest.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index 5b14c8ddfeee..7bea8ba6a282 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -10522,6 +10522,7 @@ describe('ReportUtils', () => { const onyxData = {optimisticData: [], failureData: []}; const autoSelections = pushTransactionAutoSelectionsOnyxData(onyxData, result.current, {}, fakePolicyCategoriesUpdate, {}); pushTransactionViolationsOnyxData(onyxData, result.current, {}, fakePolicyCategoriesUpdate, {}, autoSelections); + // No transaction merge should be present — only the violation push const hasTransactionMerge = onyxData.optimisticData.some((update: {key: string}) => update.key === `${ONYXKEYS.COLLECTION.TRANSACTION}${mockTransaction.transactionID}`); expect(hasTransactionMerge).toBe(false); From c748696aefb9ea035b9e226fe4ac50da25b633bf Mon Sep 17 00:00:00 2001 From: Mukhriddin Shakhriyorov <71601329+mukhrr@users.noreply.github.com> Date: Mon, 1 Jun 2026 19:02:05 +0500 Subject: [PATCH 15/32] Update tests/unit/ReportUtilsTest.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lucien Akchoté --- tests/unit/ReportUtilsTest.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index 7bea8ba6a282..388d84f0af44 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -10266,6 +10266,7 @@ describe('ReportUtils', () => { key: `${ONYXKEYS.COLLECTION.TRANSACTION}${mockTransaction.transactionID}`, value: {category: remainingCategory}, }); + // The failure data should restore the original category expect(onyxData.failureData).toContainEqual({ onyxMethod: Onyx.METHOD.MERGE, From 4dfd693f154c9e9bf7b0741f6225575abc3caeaf Mon Sep 17 00:00:00 2001 From: Mukhriddin Shakhriyorov <71601329+mukhrr@users.noreply.github.com> Date: Mon, 1 Jun 2026 19:02:40 +0500 Subject: [PATCH 16/32] Update tests/unit/ReportUtilsTest.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lucien Akchoté --- tests/unit/ReportUtilsTest.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index 388d84f0af44..bd7a214aa4fc 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -10589,6 +10589,7 @@ describe('ReportUtils', () => { const onyxData = {optimisticData: [], failureData: []}; const autoSelections = pushTransactionAutoSelectionsOnyxData(onyxData, result.current, {}, fakePolicyCategoriesUpdate, {}); pushTransactionViolationsOnyxData(onyxData, result.current, {}, fakePolicyCategoriesUpdate, {}, autoSelections); + // No transaction merge should be present const hasTransactionMerge = onyxData.optimisticData.some((update: {key: string}) => update.key === `${ONYXKEYS.COLLECTION.TRANSACTION}${mockTransaction.transactionID}`); expect(hasTransactionMerge).toBe(false); From 06e8370c212ba9f32536f23ed97a645cd6502eb0 Mon Sep 17 00:00:00 2001 From: Mukhriddin Shakhriyorov <71601329+mukhrr@users.noreply.github.com> Date: Mon, 1 Jun 2026 19:02:58 +0500 Subject: [PATCH 17/32] Update tests/unit/ReportUtilsTest.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lucien Akchoté --- tests/unit/ReportUtilsTest.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index bd7a214aa4fc..120d5b2f4552 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -10527,6 +10527,7 @@ describe('ReportUtils', () => { // No transaction merge should be present — only the violation push const hasTransactionMerge = onyxData.optimisticData.some((update: {key: string}) => update.key === `${ONYXKEYS.COLLECTION.TRANSACTION}${mockTransaction.transactionID}`); expect(hasTransactionMerge).toBe(false); + // A categoryOutOfPolicy violation should still be created expect(onyxData.optimisticData).toContainEqual( expect.objectContaining({ From d6cda4c9a649443de30bb7375a2fdcafd464794a Mon Sep 17 00:00:00 2001 From: Mukher Date: Tue, 2 Jun 2026 06:11:47 +0500 Subject: [PATCH 18/32] fixed category and tag outOfPolcy error offline --- src/libs/Violations/ViolationsUtils.ts | 14 ++++++----- .../actions/IOUTest/UpdateMoneyRequestTest.ts | 2 ++ tests/unit/ViolationUtilsTest.ts | 25 ++++++++++++++++--- 3 files changed, 31 insertions(+), 10 deletions(-) diff --git a/src/libs/Violations/ViolationsUtils.ts b/src/libs/Violations/ViolationsUtils.ts index bf1b5e52a3fc..b416d35f40bb 100644 --- a/src/libs/Violations/ViolationsUtils.ts +++ b/src/libs/Violations/ViolationsUtils.ts @@ -96,15 +96,16 @@ function getTagViolationsForSingleLevelTags( const hasEnabledTagsInList = hasEnabledTags(policyTags); let newTransactionViolations = [...transactionViolations]; - // Add 'tagOutOfPolicy' violation if tag is not in policy and there are enabled tags - if (!hasTagOutOfPolicyViolation && updatedTransaction.tag && !isTagInPolicy && hasEnabledTagsInList) { + // Add 'tagOutOfPolicy' if the tag is not in policy. Not gated on enabled tags remaining, so deleting the + // last tag still flags a transaction that holds the deleted tag. Mirrors 'categoryOutOfPolicy'. + if (!hasTagOutOfPolicyViolation && updatedTransaction.tag && !isTagInPolicy) { const tagName = policyTagList[policyTagListName]?.name; const tagNameToShow = isDefaultTagName(tagName) ? undefined : tagName; newTransactionViolations.push({name: CONST.VIOLATIONS.TAG_OUT_OF_POLICY, type: CONST.VIOLATION_TYPES.VIOLATION, data: {tagName: tagNameToShow}, showInReview: true}); } - // Remove 'tagOutOfPolicy' violation if tag is empty, in policy, or there are no enabled tags - if (hasTagOutOfPolicyViolation && (!updatedTransaction.tag || isTagInPolicy || !hasEnabledTagsInList)) { + // Remove 'tagOutOfPolicy' violation if tag is empty or in policy + if (hasTagOutOfPolicyViolation && (!updatedTransaction.tag || isTagInPolicy)) { newTransactionViolations = reject(newTransactionViolations, {name: CONST.VIOLATIONS.TAG_OUT_OF_POLICY}); } @@ -414,8 +415,9 @@ const ViolationsUtils = { newTransactionViolations = reject(newTransactionViolations, {name: CONST.VIOLATIONS.SMARTSCAN_FAILED}); } - // Calculate client-side category violations - const policyRequiresCategories = !!policy.requiresCategory; + // Calculate client-side category violations. Also run when the transaction has a category (not just + // when the policy requires one) so disabling that category flags it optimistically. Mirrors tags below. + const policyRequiresCategories = !!policy.requiresCategory || !!updatedTransaction.category; if (policyRequiresCategories) { const hasCategoryOutOfPolicyViolation = transactionViolations.some((violation) => violation.name === 'categoryOutOfPolicy'); const hasMissingCategoryViolation = transactionViolations.some((violation) => violation.name === 'missingCategory'); diff --git a/tests/actions/IOUTest/UpdateMoneyRequestTest.ts b/tests/actions/IOUTest/UpdateMoneyRequestTest.ts index 1a682ceaba5f..eef2e7cc7ed7 100644 --- a/tests/actions/IOUTest/UpdateMoneyRequestTest.ts +++ b/tests/actions/IOUTest/UpdateMoneyRequestTest.ts @@ -597,6 +597,8 @@ describe('actions/IOU/UpdateMoneyRequest', () => { reportID: expenseReportID, amount: 10000, currency: CONST.CURRENCY.USD, + // No category so the test stays focused on the rejected-expense violation + category: undefined, }; const policy: Policy = { diff --git a/tests/unit/ViolationUtilsTest.ts b/tests/unit/ViolationUtilsTest.ts index f5aa4d373b26..f7eddbbe44ae 100644 --- a/tests/unit/ViolationUtilsTest.ts +++ b/tests/unit/ViolationUtilsTest.ts @@ -939,6 +939,23 @@ describe('getViolationsOnyxData', () => { expect(result.value).not.toContainEqual(categoryOutOfPolicyViolation); expect(result.value).not.toContainEqual(missingCategoryViolation); }); + + it('should add categoryOutOfPolicy when the transaction has a category that is not in policy', () => { + transaction.category = 'Office Supplies'; + policyCategories = {Food: {name: 'Food', enabled: true}}; + + const result = ViolationsUtils.getViolationsOnyxData({ + updatedTransaction: transaction, + transactionViolations, + policy, + policyTagList: policyTags, + policyCategories, + hasDependentTags: false, + isInvoiceTransaction: false, + }); + + expect(result.value).toContainEqual(categoryOutOfPolicyViolation); + }); }); describe('policyRequiresTags', () => { @@ -1135,7 +1152,7 @@ describe('getViolationsOnyxData', () => { expect(result.value).not.toContainEqual(missingTagViolation); }); - it('should not add tagOutOfPolicy when transaction has a stale tag and no tags are enabled', () => { + it('should add tagOutOfPolicy when transaction has a stale tag and no tags are enabled', () => { policyTags = { Meals: { name: 'Meals', @@ -1159,10 +1176,10 @@ describe('getViolationsOnyxData', () => { isInvoiceTransaction: false, }); - expect(result.value).not.toContainEqual(tagOutOfPolicyViolation); + expect(result.value).toContainEqual({...tagOutOfPolicyViolation, data: {tagName: 'Meals'}}); }); - it('should remove existing tagOutOfPolicy when transaction has a stale tag and no tags are enabled', () => { + it('should keep existing tagOutOfPolicy when transaction has a stale tag and no tags are enabled', () => { policyTags = { Meals: { name: 'Meals', @@ -1187,7 +1204,7 @@ describe('getViolationsOnyxData', () => { isInvoiceTransaction: false, }); - expect(result.value).not.toContainEqual(tagOutOfPolicyViolation); + expect(result.value).toContainEqual(tagOutOfPolicyViolation); expect(result.value).toContainEqual(duplicatedTransactionViolation); }); }); From 99e81cf151110216ff6e2ea6a625baf2ce51cb77 Mon Sep 17 00:00:00 2001 From: Mukher Date: Tue, 9 Jun 2026 08:48:06 +0500 Subject: [PATCH 19/32] Fix stale optimistic violations when toggling policy categories, tags, and tax - Recompute transaction violations on the category/tag/tax feature toggles so the report preview and expense details update immediately instead of showing stale errors until the report reloads - Add a delete-confirmation modal to category/tag rows when the feature is disabled or no enabled tags remain, matching the tax row - Clear the tag violation optimistically when a tag is removed offline - Remove a stale "Missing category" once categories are no longer required - Scope the recompute so a category/tag toggle never changes tax violations - Don't flag an expense with no tax code as tax-out-of-policy --- .../ReportActionItem/MoneyRequestView.tsx | 82 +++++- src/languages/en.ts | 10 + src/languages/es.ts | 10 + src/libs/ReportUtils.ts | 12 + src/libs/Violations/ViolationsUtils.ts | 8 +- src/libs/actions/IOU/UpdateMoneyRequest.ts | 4 + src/libs/actions/Policy/Policy.ts | 12 +- .../WorkspaceMoreFeaturesPage/index.tsx | 4 +- .../actions/IOUTest/UpdateMoneyRequestTest.ts | 58 ++++ tests/unit/ReportUtilsTest.ts | 255 ++++++++++++++++++ tests/unit/ViolationUtilsTest.ts | 47 ++++ 11 files changed, 495 insertions(+), 7 deletions(-) diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index 5e04cb2f558a..6978bb8d72f4 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -44,7 +44,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import useTransactionViolations from '@hooks/useTransactionViolations'; import type {ViolationField} from '@hooks/useViolations'; import useViolations from '@hooks/useViolations'; -import {updateMoneyRequestBillable, updateMoneyRequestReimbursable, updateMoneyRequestTaxRate} from '@libs/actions/IOU/UpdateMoneyRequest'; +import {updateMoneyRequestBillable, updateMoneyRequestCategory, updateMoneyRequestReimbursable, updateMoneyRequestTag, updateMoneyRequestTaxRate} from '@libs/actions/IOU/UpdateMoneyRequest'; import initSplitExpense from '@libs/actions/SplitExpenses'; import {enrichAndSortAttendees, getIsMissingAttendeesViolation} from '@libs/AttendeeUtils'; import {getBrokenConnectionUrlToFixPersonalCard, getCompanyCardDescription} from '@libs/CardUtils'; @@ -433,6 +433,11 @@ function MoneyRequestView({ // transactionTag can be an empty string // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const shouldShowTag = (isPolicyExpenseChat || isExpenseUnreported) && (transactionTag || (canEdit && hasEnabledTags(policyTagLists))); + // Surface a delete confirmation (like tax) when the value is stale and there's nothing valid to select, instead + // of navigating to edit. Categories need at least one, so they only hit this when disabled; tags can be fully + // emptied, so also cover "no enabled tags remain". Scoped to single-level tag lists. + const shouldShowCategoryDisabledAlert = !policy?.areCategoriesEnabled && !!category; + const shouldShowTagDisabledAlert = (!policy?.areTagsEnabled || !hasEnabledTags(policyTagLists)) && !!transactionTag && policyTagLists.length <= 1; const shouldShowBillable = (isPolicyExpenseChat || isExpenseUnreported) && (!!transactionBillable || isBillableEnabledOnPolicy(policy) || !!updatedTransaction?.billable); const isCurrentTransactionReimbursableDifferentFromPolicyDefault = policy?.defaultReimbursable !== undefined && !!(updatedTransaction?.reimbursable ?? transactionReimbursable) !== policy.defaultReimbursable; @@ -699,6 +704,72 @@ function MoneyRequestView({ }); }; + const showCategoryDisabledAlert = () => { + const transactionID = transaction?.transactionID; + if (!transactionID) { + return; + } + showConfirmModal({ + title: translate('iou.categoryDisabledAlert.title'), + prompt: translate('iou.categoryDisabledAlert.prompt'), + confirmText: translate('iou.categoryDisabledAlert.confirmText'), + cancelText: translate('common.cancel'), + }).then(({action}) => { + if (action !== ModalActions.CONFIRM || !canEdit) { + return; + } + + updateMoneyRequestCategory({ + transactionID, + transactionThreadReport, + parentReport, + category: '', + policy, + policyTagList, + policyCategories, + policyRecentlyUsedCategories: undefined, + currentUserAccountIDParam, + currentUserEmailParam, + isASAPSubmitBetaEnabled, + parentReportNextStep, + delegateAccountID, + }); + }); + }; + + const showTagDisabledAlert = () => { + const transactionID = transaction?.transactionID; + if (!transactionID) { + return; + } + showConfirmModal({ + title: translate('iou.tagDisabledAlert.title'), + prompt: translate('iou.tagDisabledAlert.prompt'), + confirmText: translate('iou.tagDisabledAlert.confirmText'), + cancelText: translate('common.cancel'), + }).then(({action}) => { + if (action !== ModalActions.CONFIRM || !canEdit) { + return; + } + + updateMoneyRequestTag({ + transactionID, + transactionThreadReport, + parentReport, + tag: '', + policy, + policyTagList, + policyRecentlyUsedTags: undefined, + policyCategories, + currentUserAccountIDParam, + currentUserEmailParam, + isASAPSubmitBetaEnabled, + parentReportNextStep, + delegateAccountID, + }); + }); + }; + const distanceCopyValue = !canEditDistance ? distanceToDisplay : undefined; const distanceRateCopyValue = !canEditDistanceRate ? rateToDisplay : undefined; const amountCopyValue = !canEditAmount ? amountTitle : undefined; @@ -885,6 +956,10 @@ function MoneyRequestView({ if (!transaction?.transactionID || !transactionThreadReport?.reportID) { return; } + if (shouldShowTagDisabledAlert) { + showTagDisabledAlert(); + return; + } Navigation.navigate( ROUTES.MONEY_REQUEST_STEP_TAG.getRoute( CONST.IOU.ACTION.EDIT, @@ -1084,6 +1159,11 @@ function MoneyRequestView({ shouldShowRightIcon={canEdit} titleStyle={styles.flex1} onPress={() => { + if (shouldShowCategoryDisabledAlert) { + showCategoryDisabledAlert(); + return; + } + if (shouldNavigateToUpgradePath && transactionThreadReport) { Navigation.navigate( ROUTES.MONEY_REQUEST_UPGRADE.getRoute({ diff --git a/src/languages/en.ts b/src/languages/en.ts index ef149ad0c94d..7261e5b7f180 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1731,6 +1731,16 @@ const translations = { prompt: 'Enable tax tracking on the workspace to edit the expense details or delete the tax from this expense.', confirmText: 'Delete tax', }, + categoryDisabledAlert: { + title: 'Category disabled', + prompt: 'Enable categories on the workspace to edit the expense details or delete the category from this expense.', + confirmText: 'Delete category', + }, + tagDisabledAlert: { + title: 'Tag disabled', + prompt: 'Enable tags on the workspace to edit the expense details or delete the tag from this expense.', + confirmText: 'Delete tag', + }, }, transactionMerge: { listPage: { diff --git a/src/languages/es.ts b/src/languages/es.ts index 810476825385..65e7f842b428 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -1640,6 +1640,16 @@ const translations: TranslationDeepObject = { prompt: 'Habilita el seguimiento de impuestos en el espacio de trabajo para editar los detalles del gasto o eliminar el impuesto de este gasto.', confirmText: 'Eliminar impuesto', }, + categoryDisabledAlert: { + title: 'Categoría deshabilitada', + prompt: 'Habilita las categorías en el espacio de trabajo para editar los detalles del gasto o eliminar la categoría de este gasto.', + confirmText: 'Eliminar categoría', + }, + tagDisabledAlert: { + title: 'Etiqueta deshabilitada', + prompt: 'Habilita las etiquetas en el espacio de trabajo para editar los detalles del gasto o eliminar la etiqueta de este gasto.', + confirmText: 'Eliminar etiqueta', + }, }, transactionMerge: { listPage: { diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 7f4d2a519aa8..9044d34d274b 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -2296,6 +2296,11 @@ function pushTransactionViolationsOnyxData( return; } + // taxOutOfPolicy depends only on tax tracking, tax rates and the transaction's own tax data — none of + // which a category/tag/rules toggle changes. Only let the recompute touch it when the update concerns + // tax tracking, otherwise an unrelated toggle would flash a spurious "tax no longer valid" violation. + const isTaxTrackingUpdate = policyUpdate.tax !== undefined; + for (const { transactionsAndViolations: {transactions, violations}, } of nonInvoiceReportItems) { @@ -2314,6 +2319,13 @@ function pushTransactionViolationsOnyxData( isInvoiceTransaction: false, }); + // Keep the pre-toggle taxOutOfPolicy state when the update isn't about tax tracking. + const recomputedViolations = optimisticViolations.value; + if (!isTaxTrackingUpdate && Array.isArray(recomputedViolations)) { + const preservedTaxViolations = (existingViolations ?? []).filter((violation) => violation.name === CONST.VIOLATIONS.TAX_OUT_OF_POLICY); + optimisticViolations.value = [...recomputedViolations.filter((violation) => violation.name !== CONST.VIOLATIONS.TAX_OUT_OF_POLICY), ...preservedTaxViolations]; + } + if (!isEmptyObject(optimisticViolations)) { onyxData.optimisticData?.push(optimisticViolations); onyxData.failureData?.push({ diff --git a/src/libs/Violations/ViolationsUtils.ts b/src/libs/Violations/ViolationsUtils.ts index 306d7afbb446..e81f3810f5b3 100644 --- a/src/libs/Violations/ViolationsUtils.ts +++ b/src/libs/Violations/ViolationsUtils.ts @@ -448,6 +448,10 @@ const ViolationsUtils = { if (!hasMissingCategoryViolation && policyRequiresCategories && !categoryKey && !isSelfDM) { newTransactionViolations.push({name: 'missingCategory', type: CONST.VIOLATION_TYPES.VIOLATION, showInReview: true}); } + } else if (transactionViolations.some((violation) => violation.name === 'missingCategory')) { + // Categories aren't required and none is set, so a leftover 'missingCategory' is stale (e.g. after the + // workspace disables categories). Remove it so the optimistic state matches the backend. + newTransactionViolations = reject(newTransactionViolations, {name: 'missingCategory'}); } // Calculate client-side tag violations @@ -733,7 +737,9 @@ const ViolationsUtils = { } const hasTransactionTaxData = !!updatedTransaction.taxCode || !!updatedTransaction.taxValue || !!updatedTransaction.taxAmount; - const shouldAddTaxOutOfPolicy = !isTimeRequest && !isPerDiemRequest && (isPolicyTrackTaxEnabled ? !isTaxInPolicy : hasTransactionTaxData); + // When tax tracking is enabled, only a non-empty tax code that isn't a current policy rate is out of policy. + // A transaction with no tax code (e.g. its tax was deleted) must not be flagged. + const shouldAddTaxOutOfPolicy = !isTimeRequest && !isPerDiemRequest && (isPolicyTrackTaxEnabled ? !!updatedTransaction.taxCode && !isTaxInPolicy : hasTransactionTaxData); if (!hasTaxOutOfPolicyViolation && shouldAddTaxOutOfPolicy) { newTransactionViolations.push({name: CONST.VIOLATIONS.TAX_OUT_OF_POLICY, type: CONST.VIOLATION_TYPES.VIOLATION, showInReview: true}); diff --git a/src/libs/actions/IOU/UpdateMoneyRequest.ts b/src/libs/actions/IOU/UpdateMoneyRequest.ts index 3fb268123005..e3553cd6fb90 100644 --- a/src/libs/actions/IOU/UpdateMoneyRequest.ts +++ b/src/libs/actions/IOU/UpdateMoneyRequest.ts @@ -1638,6 +1638,10 @@ function getUpdateMoneyRequestParams(params: GetUpdateMoneyRequestParamsType): U hasModifiedCategory && transactionChanges.category === '' ? optimisticViolations.filter((violation) => violation.name !== CONST.VIOLATIONS.CATEGORY_OUT_OF_POLICY) : optimisticViolations; + // Clearing the tag can't be "out of policy"; strip it here since the recompute skips tag logic when tags + // aren't required and the tag is now empty, which would otherwise leave the stale violation offline. + optimisticViolations = + hasModifiedTag && transactionChanges.tag === '' ? optimisticViolations.filter((violation) => violation.name !== CONST.VIOLATIONS.TAG_OUT_OF_POLICY) : optimisticViolations; if (hasPendingWaypoints) { optimisticViolations = optimisticViolations.filter((violation) => violation.name !== CONST.VIOLATIONS.NO_ROUTE); } diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index 911096b2adb1..aecc9ddd2496 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -4942,9 +4942,9 @@ function enablePolicyReportFields(policyID: string, enabled: boolean) { API.writeWithNoDuplicatesEnableFeatureConflicts(WRITE_COMMANDS.ENABLE_POLICY_REPORT_FIELDS, parameters, onyxData); } -function enablePolicyTaxes(policyID: string, enabled: true, currentTaxRates: TaxRatesWithDefault | undefined): void; -function enablePolicyTaxes(policyID: string, enabled: false): void; -function enablePolicyTaxes(policyID: string, enabled: boolean, currentTaxRates?: TaxRatesWithDefault) { +function enablePolicyTaxes(policyID: string, enabled: true, currentTaxRates: TaxRatesWithDefault | undefined, policyData?: PolicyData): void; +function enablePolicyTaxes(policyID: string, enabled: false, currentTaxRates?: undefined, policyData?: PolicyData): void; +function enablePolicyTaxes(policyID: string, enabled: boolean, currentTaxRates?: TaxRatesWithDefault, policyData?: PolicyData) { const defaultTaxRates: TaxRatesWithDefault = CONST.DEFAULT_TAX; const taxRatesData: OnyxData = { optimisticData: [ @@ -5052,6 +5052,12 @@ function enablePolicyTaxes(policyID: string, enabled: boolean, currentTaxRates?: failureData, }; + // Recompute transaction violations so toggling tax tracking immediately clears/adds the tax violation, + // instead of leaving a stale one until the report reloads. + if (policyData) { + ReportUtils.pushTransactionViolationsOnyxData(onyxData, policyData, {tax: {trackingEnabled: enabled}}); + } + const parameters: EnablePolicyTaxesParams = {policyID, enabled}; if (shouldAddDefaultTaxRatesData) { parameters.taxFields = JSON.stringify(defaultTaxRates); diff --git a/src/pages/workspace/WorkspaceMoreFeaturesPage/index.tsx b/src/pages/workspace/WorkspaceMoreFeaturesPage/index.tsx index 4af0dd3129fb..34229ed906e1 100644 --- a/src/pages/workspace/WorkspaceMoreFeaturesPage/index.tsx +++ b/src/pages/workspace/WorkspaceMoreFeaturesPage/index.tsx @@ -405,10 +405,10 @@ function WorkspaceMoreFeaturesPage({policy, route}: WorkspaceMoreFeaturesPagePro return; } if (isEnabled) { - enablePolicyTaxes(policyID, true, policy?.taxRates); + enablePolicyTaxes(policyID, true, policy?.taxRates, policyData); return; } - enablePolicyTaxes(policyID, false); + enablePolicyTaxes(policyID, false, undefined, policyData); }} onPress={() => { if (!policyID) { diff --git a/tests/actions/IOUTest/UpdateMoneyRequestTest.ts b/tests/actions/IOUTest/UpdateMoneyRequestTest.ts index eef2e7cc7ed7..50b5efbcef2d 100644 --- a/tests/actions/IOUTest/UpdateMoneyRequestTest.ts +++ b/tests/actions/IOUTest/UpdateMoneyRequestTest.ts @@ -791,6 +791,64 @@ describe('actions/IOU/UpdateMoneyRequest', () => { expect(newPolicyRecentlyUsedTags[tagName].length).toBe(2); expect(newPolicyRecentlyUsedTags[tagName].at(0)).toBe(newTag); }); + + it('should remove the existing tagOutOfPolicy violation when the tag is unset and tags are not required', async () => { + const transactionID = '1'; + const policyID = '2'; + const transactionThreadReportID = '3'; + const transactionThreadReport = {reportID: transactionThreadReportID}; + const fakePolicy: Policy = { + ...createRandomPolicy(0, CONST.POLICY.TYPE.TEAM), + requiresTag: false, + requiresCategory: false, + }; + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, { + amount: 100, + transactionID, + }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`, [ + { + type: CONST.VIOLATION_TYPES.VIOLATION, + name: CONST.VIOLATIONS.TAG_OUT_OF_POLICY, + data: {}, + showInReview: true, + }, + ]); + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, fakePolicy); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`, transactionThreadReport); + + // When unsetting the tag + updateMoneyRequestTag({ + transactionID, + transactionThreadReport, + parentReport: undefined, + tag: '', + policy: fakePolicy, + policyTagList: undefined, + policyRecentlyUsedTags: undefined, + policyCategories: undefined, + currentUserAccountIDParam: 123, + currentUserEmailParam: 'existing@example.com', + isASAPSubmitBetaEnabled: false, + parentReportNextStep: undefined, + delegateAccountID: undefined, + }); + + await waitForBatchedUpdates(); + + // The stale tagOutOfPolicy is stripped optimistically even though the recompute skips tag logic when + // tags aren't required and the tag is now empty. + await new Promise((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`, + callback: (transactionViolations) => { + Onyx.disconnect(connection); + expect(transactionViolations?.some((violation) => violation.name === CONST.VIOLATIONS.TAG_OUT_OF_POLICY)).toBe(false); + resolve(); + }, + }); + }); + }); }); describe('updateMoneyRequestDate', () => { diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index 9eeaac76d680..90974441a81f 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -10716,6 +10716,261 @@ describe('ReportUtils', () => { const hasTransactionMerge = onyxData.optimisticData.some((update: {key: string}) => update.key === `${ONYXKEYS.COLLECTION.TRANSACTION}${mockTransaction.transactionID}`); expect(hasTransactionMerge).toBe(false); }); + + it('should clear the taxOutOfPolicy violation when tax tracking is re-enabled and the tax code is valid', async () => { + // A stale taxOutOfPolicy left over from when tax tracking was disabled is cleared optimistically once + // tracking is turned back on, instead of lingering until the report reloads from the server. + const fakePolicyID = '0'; + const taxCode = 'id_TAX_RATE_1'; + const fakePolicy = { + ...createRandomPolicy(0), + id: fakePolicyID, + requiresCategory: false, + requiresTag: false, + tax: {trackingEnabled: true}, + taxRates: { + taxes: {[taxCode]: {name: 'Tax Rate 1', value: '5'}}, + name: '', + defaultExternalID: '', + defaultValue: '', + foreignTaxDefault: '', + }, + }; + + const openReport: Report = { + ...mockIOUReport, + policyID: fakePolicyID, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + }; + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${openReport.reportID}`, openReport); + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${fakePolicyID}`, fakePolicy); + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${mockTransaction.transactionID}`, { + ...mockTransaction, + reportID: openReport.reportID, + taxCode, + category: '', + tag: '', + }); + // Stale violation left over from when tax tracking was disabled. + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${mockTransaction.transactionID}`, [ + {name: CONST.VIOLATIONS.TAX_OUT_OF_POLICY, type: CONST.VIOLATION_TYPES.VIOLATION, showInReview: true}, + ]); + + await waitForBatchedUpdates(); + const {result} = renderHook(() => usePolicyData(fakePolicyID), {wrapper: OnyxListItemProvider}); + await waitForBatchedUpdates(); + + const onyxData = {optimisticData: [], failureData: []}; + pushTransactionViolationsOnyxData(onyxData, result.current, {tax: {trackingEnabled: true}}, {}, {}); + + const violationsUpdate = onyxData.optimisticData.find( + (update: {key: string}) => update.key === `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${mockTransaction.transactionID}`, + ) as {value: TransactionViolation[]} | undefined; + expect(violationsUpdate?.value).not.toContainEqual(expect.objectContaining({name: CONST.VIOLATIONS.TAX_OUT_OF_POLICY})); + }); + + it('should add the taxOutOfPolicy violation when tax tracking is disabled and the transaction still has tax', async () => { + // Disabling tax tracking surfaces the violation so the user can remove the now-stale tax. + const fakePolicyID = '0'; + const taxCode = 'id_TAX_RATE_1'; + const fakePolicy = { + ...createRandomPolicy(0), + id: fakePolicyID, + requiresCategory: false, + requiresTag: false, + tax: {trackingEnabled: true}, + taxRates: { + taxes: {[taxCode]: {name: 'Tax Rate 1', value: '5'}}, + name: '', + defaultExternalID: '', + defaultValue: '', + foreignTaxDefault: '', + }, + }; + + const openReport: Report = { + ...mockIOUReport, + policyID: fakePolicyID, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + }; + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${openReport.reportID}`, openReport); + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${fakePolicyID}`, fakePolicy); + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${mockTransaction.transactionID}`, { + ...mockTransaction, + reportID: openReport.reportID, + taxCode, + category: '', + tag: '', + }); + // Start with no violation so the test demonstrates it being added when tracking is turned off. + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${mockTransaction.transactionID}`, []); + + await waitForBatchedUpdates(); + const {result} = renderHook(() => usePolicyData(fakePolicyID), {wrapper: OnyxListItemProvider}); + await waitForBatchedUpdates(); + + const onyxData = {optimisticData: [], failureData: []}; + pushTransactionViolationsOnyxData(onyxData, result.current, {tax: {trackingEnabled: false}}, {}, {}); + + const violationsUpdate = onyxData.optimisticData.find( + (update: {key: string}) => update.key === `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${mockTransaction.transactionID}`, + ) as {value: TransactionViolation[]} | undefined; + expect(violationsUpdate?.value).toContainEqual(expect.objectContaining({name: CONST.VIOLATIONS.TAX_OUT_OF_POLICY})); + }); + + it('should not add the taxOutOfPolicy violation when toggling categories while tax tracking is unchanged', async () => { + // A category/tag toggle leaves tax tracking and tax rates untouched, so it must never surface a tax + // violation, even when the client-side recompute would treat the transaction's tax code as out of policy. + const fakePolicyID = '0'; + const taxCode = 'id_TAX_RATE_1'; + const categoryName = 'Office'; + const fakePolicy = { + ...createRandomPolicy(0), + id: fakePolicyID, + requiresCategory: true, + requiresTag: false, + areCategoriesEnabled: true, + tax: {trackingEnabled: true}, + taxRates: { + taxes: {[taxCode]: {name: 'Tax Rate 1', value: '5'}}, + name: '', + defaultExternalID: '', + defaultValue: '', + foreignTaxDefault: '', + }, + }; + + const openReport: Report = { + ...mockIOUReport, + policyID: fakePolicyID, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + }; + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${openReport.reportID}`, openReport); + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${fakePolicyID}`, fakePolicy); + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${fakePolicyID}`, {[categoryName]: {name: categoryName, enabled: true}}); + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${mockTransaction.transactionID}`, { + ...mockTransaction, + reportID: openReport.reportID, + // A tax code the client recompute treats as out of policy even though tax tracking is enabled. + taxCode: 'UNKNOWN_TAX', + category: categoryName, + tag: '', + }); + // No tax violation before the toggle. + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${mockTransaction.transactionID}`, []); + + await waitForBatchedUpdates(); + const {result} = renderHook(() => usePolicyData(fakePolicyID), {wrapper: OnyxListItemProvider}); + await waitForBatchedUpdates(); + + const onyxData = {optimisticData: [], failureData: []}; + // Disable categories — the policy update does not touch tax tracking. + pushTransactionViolationsOnyxData(onyxData, result.current, {areCategoriesEnabled: false, requiresCategory: false}, {[categoryName]: {enabled: false}}, {}); + + const violationsUpdate = onyxData.optimisticData.find( + (update: {key: string}) => update.key === `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${mockTransaction.transactionID}`, + ) as {value: TransactionViolation[]} | undefined; + expect(violationsUpdate?.value).not.toContainEqual(expect.objectContaining({name: CONST.VIOLATIONS.TAX_OUT_OF_POLICY})); + }); + + it('should add the missingCategory violation when categories are enabled and the transaction has no category', async () => { + // Bug 6: enabling categories must immediately surface "Missing category" on the preview for an + // uncategorized expense, instead of only after the report detail loads. + const fakePolicyID = '0'; + const categoryName = 'Office'; + const fakePolicy = { + ...createRandomPolicy(0), + id: fakePolicyID, + requiresCategory: true, + requiresTag: false, + areCategoriesEnabled: true, + }; + + const openReport: Report = { + ...mockIOUReport, + policyID: fakePolicyID, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + }; + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${openReport.reportID}`, openReport); + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${fakePolicyID}`, fakePolicy); + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${fakePolicyID}`, {[categoryName]: {name: categoryName, enabled: true}}); + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${mockTransaction.transactionID}`, { + ...mockTransaction, + reportID: openReport.reportID, + category: '', + tag: '', + }); + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${mockTransaction.transactionID}`, []); + + await waitForBatchedUpdates(); + const {result} = renderHook(() => usePolicyData(fakePolicyID), {wrapper: OnyxListItemProvider}); + await waitForBatchedUpdates(); + + const onyxData = {optimisticData: [], failureData: []}; + // Enable categories — the policy update does not touch tax tracking. + pushTransactionViolationsOnyxData(onyxData, result.current, {areCategoriesEnabled: true, requiresCategory: true}, {[categoryName]: {enabled: true}}, {}); + + const violationsUpdate = onyxData.optimisticData.find( + (update: {key: string}) => update.key === `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${mockTransaction.transactionID}`, + ) as {value: TransactionViolation[]} | undefined; + expect(violationsUpdate?.value).toContainEqual(expect.objectContaining({name: CONST.VIOLATIONS.MISSING_CATEGORY})); + }); + + it('should remove the stale missingCategory violation when categories are disabled', async () => { + // Bug 5: disabling categories must immediately clear a stale "Missing category" from the preview, + // instead of leaving it until the report detail loads. + const fakePolicyID = '0'; + const categoryName = 'Office'; + const fakePolicy = { + ...createRandomPolicy(0), + id: fakePolicyID, + requiresCategory: true, + requiresTag: false, + areCategoriesEnabled: true, + }; + + const openReport: Report = { + ...mockIOUReport, + policyID: fakePolicyID, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + }; + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${openReport.reportID}`, openReport); + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${fakePolicyID}`, fakePolicy); + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${fakePolicyID}`, {[categoryName]: {name: categoryName, enabled: true}}); + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${mockTransaction.transactionID}`, { + ...mockTransaction, + reportID: openReport.reportID, + category: '', + tag: '', + }); + // Stale violation left over from when categories were still required. + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${mockTransaction.transactionID}`, [ + {name: CONST.VIOLATIONS.MISSING_CATEGORY, type: CONST.VIOLATION_TYPES.VIOLATION, showInReview: true}, + ]); + + await waitForBatchedUpdates(); + const {result} = renderHook(() => usePolicyData(fakePolicyID), {wrapper: OnyxListItemProvider}); + await waitForBatchedUpdates(); + + const onyxData = {optimisticData: [], failureData: []}; + // Disable categories — the policy update does not touch tax tracking. + pushTransactionViolationsOnyxData(onyxData, result.current, {areCategoriesEnabled: false, requiresCategory: false}, {[categoryName]: {enabled: false}}, {}); + + const violationsUpdate = onyxData.optimisticData.find( + (update: {key: string}) => update.key === `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${mockTransaction.transactionID}`, + ) as {value: TransactionViolation[]} | undefined; + expect(violationsUpdate?.value).not.toContainEqual(expect.objectContaining({name: CONST.VIOLATIONS.MISSING_CATEGORY})); + }); }); describe('canLeaveChat', () => { diff --git a/tests/unit/ViolationUtilsTest.ts b/tests/unit/ViolationUtilsTest.ts index 5b78823d3d89..eb1897a381cc 100644 --- a/tests/unit/ViolationUtilsTest.ts +++ b/tests/unit/ViolationUtilsTest.ts @@ -962,6 +962,21 @@ describe('getViolationsOnyxData', () => { expect(result.value).toContainEqual(categoryOutOfPolicyViolation); }); + + it('should remove a stale missingCategory violation when categories are not required', () => { + // e.g. after the workspace disables categories: a leftover missingCategory must clear optimistically. + const result = ViolationsUtils.getViolationsOnyxData({ + updatedTransaction: transaction, + transactionViolations: [missingCategoryViolation], + policy, + policyTagList: policyTags, + policyCategories, + hasDependentTags: false, + isInvoiceTransaction: false, + }); + + expect(result.value).not.toContainEqual(missingCategoryViolation); + }); }); describe('policyRequiresTags', () => { @@ -1817,6 +1832,38 @@ describe('getViolationsOnyxData', () => { }); expect(result.value).not.toContainEqual(taxOutOfPolicyViolation); }); + + it('should not add taxOutOfPolicy violation when the transaction has no tax code', () => { + // An expense whose tax was deleted has an empty tax code; re-enabling tax tracking must not flag it. + transaction.taxCode = ''; + policy.taxRates = {name: 'Taxes', defaultExternalID: 'TAX_10', defaultValue: '10%', foreignTaxDefault: 'TAX_10', taxes: {TAX_10: {name: '10%', value: '10%'}}}; + const result = ViolationsUtils.getViolationsOnyxData({ + updatedTransaction: transaction, + transactionViolations, + policy, + policyTagList: policyTags, + policyCategories, + hasDependentTags: false, + isInvoiceTransaction: false, + }); + expect(result.value).not.toContainEqual(taxOutOfPolicyViolation); + }); + + it('should remove a stale taxOutOfPolicy violation when the tax code has been cleared', () => { + transaction.taxCode = ''; + policy.taxRates = {name: 'Taxes', defaultExternalID: 'TAX_10', defaultValue: '10%', foreignTaxDefault: 'TAX_10', taxes: {TAX_10: {name: '10%', value: '10%'}}}; + transactionViolations = [taxOutOfPolicyViolation]; + const result = ViolationsUtils.getViolationsOnyxData({ + updatedTransaction: transaction, + transactionViolations, + policy, + policyTagList: policyTags, + policyCategories, + hasDependentTags: false, + isInvoiceTransaction: false, + }); + expect(result.value).not.toContainEqual(taxOutOfPolicyViolation); + }); }); describe('when tax tracking is disabled', () => { From 6f0af407ed81caa5d8e80d2f9720c6819097a215 Mon Sep 17 00:00:00 2001 From: Mukher Date: Tue, 9 Jun 2026 08:57:08 +0500 Subject: [PATCH 20/32] reverted Mobile-Expensify --- Mobile-Expensify | 1 + 1 file changed, 1 insertion(+) create mode 160000 Mobile-Expensify diff --git a/Mobile-Expensify b/Mobile-Expensify new file mode 160000 index 000000000000..59600af5aae5 --- /dev/null +++ b/Mobile-Expensify @@ -0,0 +1 @@ +Subproject commit 59600af5aae5c3d34b6aed67429426da297affde From bbe49eef81c40d13b79d87db74f2a66cb682bf98 Mon Sep 17 00:00:00 2001 From: Mukher Date: Tue, 9 Jun 2026 09:49:23 +0500 Subject: [PATCH 21/32] fixed wrong merge issues --- .../ReportActionItem/MoneyRequestView.tsx | 1 + src/languages/es.ts | 10 + src/libs/ReportUtils.ts | 237 +++++++++++++++--- .../WorkspaceMoreFeaturesPage/index.tsx | 4 +- .../actions/IOUTest/UpdateMoneyRequestTest.ts | 1 + 5 files changed, 211 insertions(+), 42 deletions(-) diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index e78140023fb0..f834e5eaa3c4 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -793,6 +793,7 @@ function MoneyRequestView({ currentUserEmailParam, isASAPSubmitBetaEnabled, parentReportNextStep, + isOffline, delegateAccountID, }); }); diff --git a/src/languages/es.ts b/src/languages/es.ts index b3c333b73923..d3fd401c82ef 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -1647,6 +1647,16 @@ const translations: TranslationDeepObject = { prompt: 'Habilita el seguimiento de impuestos en el espacio de trabajo para editar los detalles del gasto o eliminar el impuesto de este gasto.', confirmText: 'Eliminar impuesto', }, + categoryDisabledAlert: { + title: 'Categoría deshabilitada', + prompt: 'Habilita las categorías en el espacio de trabajo para editar los detalles del gasto o eliminar la categoría de este gasto.', + confirmText: 'Eliminar categoría', + }, + tagDisabledAlert: { + title: 'Etiqueta deshabilitada', + prompt: 'Habilita las etiquetas en el espacio de trabajo para editar los detalles del gasto o eliminar la etiqueta de este gasto.', + confirmText: 'Eliminar etiqueta', + }, }, transactionMerge: { listPage: { diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index b3bcbae3d67d..e750cf8d3870 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -2052,55 +2052,46 @@ function isAwaitingFirstLevelApproval(report: OnyxEntry): boolean { return isProcessingReport(report) && submitsToAccountID === report.managerID && !hasReportBeenForwardedSinceLastSubmit(report); } +type PolicyOptimisticOnyxData = OnyxData< + | typeof ONYXKEYS.COLLECTION.POLICY_CATEGORIES + | typeof ONYXKEYS.COLLECTION.POLICY + | typeof ONYXKEYS.COLLECTION.POLICY_TAGS + | typeof ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS + | typeof ONYXKEYS.COLLECTION.TRANSACTION +>; + /** - * Updates optimistic transaction violations to OnyxData for the given policy and categories onyx update. - * - * @param onyxData - The OnyxData object to push updates to - * @param policyData - The current policy Data - * @param policyUpdate - Changed policy properties, if none pass empty object - * @param categoriesUpdate - Changed categories properties, if none pass empty object - * @param tagListsUpdate - Changed tag properties, if none pass empty object + * Returns the list of policy reports (excluding invoice reports) that have transactions to evaluate. */ -function pushTransactionViolationsOnyxData( - onyxData: OnyxData< - typeof ONYXKEYS.COLLECTION.POLICY_CATEGORIES | typeof ONYXKEYS.COLLECTION.POLICY | typeof ONYXKEYS.COLLECTION.POLICY_TAGS | typeof ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS - >, - policyData: PolicyData, - policyUpdate: Partial = {}, - categoriesUpdate: Record> = {}, - tagListsUpdate: Record> = {}, -) { - const nonInvoiceReportTransactionsAndViolations = policyData.reports.reduce((acc, report) => { - // Skipping invoice reports since they should not have any category or tag violations +function getNonInvoiceReportItemsForPolicy(policyData: PolicyData): Array<{report: Report; transactionsAndViolations: ReportTransactionsAndViolations}> { + return policyData.reports.reduce>((acc, report) => { if (isInvoiceReport(report)) { return acc; } const reportTransactionsAndViolations = policyData.transactionsAndViolations[report.reportID]; if (!isEmptyObject(reportTransactionsAndViolations) && !isEmptyObject(reportTransactionsAndViolations.transactions)) { - acc.push(reportTransactionsAndViolations); + acc.push({report, transactionsAndViolations: reportTransactionsAndViolations}); } return acc; }, []); +} - if (nonInvoiceReportTransactionsAndViolations.length === 0) { - return; - } - - const updatedTagListsNames = Object.keys(tagListsUpdate); - const updatedCategoriesNames = Object.keys(categoriesUpdate); - - // If there are no updates to policy, categories or tags, return early +/** + * Merges the existing policy data with the optimistic update payloads to produce the post-update view + * used by both auto-selection and violation calculation. + */ +function getOptimisticPolicyState( + policyData: PolicyData, + policyUpdate: Partial, + categoriesUpdate: Record>, + tagListsUpdate: Record>, +) { const isPolicyUpdateEmpty = isEmptyObject(policyUpdate); - const isTagListsUpdateEmpty = updatedTagListsNames.length === 0; - const isCategoriesUpdateEmpty = updatedCategoriesNames.length === 0; - if (isPolicyUpdateEmpty && isTagListsUpdateEmpty && isCategoriesUpdateEmpty) { - return; - } + const isCategoriesUpdateEmpty = isEmptyObject(categoriesUpdate); + const isTagListsUpdateEmpty = isEmptyObject(tagListsUpdate); - // Merge the existing policy with the optimistic updates const optimisticPolicy = isPolicyUpdateEmpty ? policyData.policy : {...policyData.policy, ...policyUpdate}; - // Merge the existing categories with the optimistic updates const optimisticCategories = isCategoriesUpdateEmpty ? policyData.categories : { @@ -2117,7 +2108,6 @@ function pushTransactionViolationsOnyxData( }, {}), }; - // Merge the existing tag lists with the optimistic updates const optimisticTagLists = isTagListsUpdateEmpty ? policyData.tags : { @@ -2154,22 +2144,188 @@ function pushTransactionViolationsOnyxData( }, {}), }; - const hasDependentTags = hasDependentTagsPolicyUtils(optimisticPolicy, optimisticTagLists); + const hasDependentTagsValue = hasDependentTagsPolicyUtils(optimisticPolicy, optimisticTagLists); + + return { + optimisticPolicy, + optimisticCategories, + optimisticTagLists, + hasDependentTagsValue, + isPolicyUpdateEmpty, + isCategoriesUpdateEmpty, + isTagListsUpdateEmpty, + }; +} + +/** + * Auto-selects the sole remaining enabled category/tag for transactions on open or processing reports + * when a category/tag is being deleted or disabled. Pushes optimistic transaction merges and matching + * failure rollbacks to the provided OnyxData object, and returns the per-transaction updates so callers + * can hand them to `pushTransactionViolationsOnyxData` for violation recomputation. + * + * Call this BEFORE `pushTransactionViolationsOnyxData` so that violations are recomputed against + * the auto-selected values (suppressing the violation that would otherwise be created). + */ +function pushTransactionAutoSelectionsOnyxData( + onyxData: PolicyOptimisticOnyxData, + policyData: PolicyData, + policyUpdate: Partial = {}, + categoriesUpdate: Record> = {}, + tagListsUpdate: Record> = {}, +): Map> { + const autoSelections = new Map>(); + + // Auto-selection is only meaningful when categories or tag lists are being updated + if (isEmptyObject(categoriesUpdate) && isEmptyObject(tagListsUpdate)) { + return autoSelections; + } + + const nonInvoiceReportItems = getNonInvoiceReportItemsForPolicy(policyData); + if (nonInvoiceReportItems.length === 0) { + return autoSelections; + } + + const {optimisticCategories, optimisticTagLists, isCategoriesUpdateEmpty, isTagListsUpdateEmpty} = getOptimisticPolicyState(policyData, policyUpdate, categoriesUpdate, tagListsUpdate); + + const enabledCategoryKeys = Object.entries(optimisticCategories) + .filter(([, cat]) => cat.enabled && cat.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) + .map(([key]) => key); + const singleRemainingCategory = enabledCategoryKeys.length === 1 ? enabledCategoryKeys.at(0) : undefined; + + const tagListKeys = Object.keys(optimisticTagLists); + + // Auto-replace is scoped to single-level tags only. Web-E's removeTags() throws "NTagging not supported yet" for + // multi-level tag lists, and the auto-replace metadata we send to Auth doesn't carry tagListIndex/tagListName, + // so the backend can't know which level of the multi-level tag string should be replaced. Multi-level support + // is tracked as a separate NTag follow-up. + let singleRemainingTag: string | undefined; + if (tagListKeys.length === 1) { + const tagListName = tagListKeys.at(0) ?? ''; + const enabledTagKeys = Object.entries(optimisticTagLists[tagListName]?.tags ?? {}) + .filter(([, tag]) => tag.enabled && tag.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) + .map(([key]) => key); + singleRemainingTag = enabledTagKeys.length === 1 ? enabledTagKeys.at(0) : undefined; + } + + for (const { + report, + transactionsAndViolations: {transactions}, + } of nonInvoiceReportItems) { + if (!isOpenOrProcessingReport(report)) { + continue; + } - // Iterate through all policy reports to find transactions that need optimistic violations - for (const {transactions, violations} of nonInvoiceReportTransactionsAndViolations) { for (const transaction of Object.values(transactions)) { + const transactionUpdates: Partial = {}; + const transactionRollback: Partial = {}; + + // Category auto-select: gated to calls that include a category update (delete or disable) + // so toggling unrelated policy settings doesn't rewrite transaction category values. + if (!isCategoriesUpdateEmpty && singleRemainingCategory && transaction.category && !optimisticCategories[transaction.category]?.enabled) { + transactionUpdates.category = singleRemainingCategory; + transactionRollback.category = transaction.category; + } + + // Single-level tag auto-select: gated to calls that include a tag-list update for the same reason. + if (!isTagListsUpdateEmpty && tagListKeys.length === 1 && singleRemainingTag && transaction.tag) { + const tagListName = tagListKeys.at(0) ?? ''; + const isTagInPolicy = !!optimisticTagLists[tagListName]?.tags?.[transaction.tag]?.enabled; + if (!isTagInPolicy) { + transactionUpdates.tag = singleRemainingTag; + transactionRollback.tag = transaction.tag; + } + } + + if (Object.keys(transactionUpdates).length === 0) { + continue; + } + + autoSelections.set(transaction.transactionID, transactionUpdates); + + onyxData.optimisticData?.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, + value: transactionUpdates, + }); + onyxData.failureData?.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, + value: transactionRollback, + }); + } + } + + return autoSelections; +} + +/** + * Updates optimistic transaction violations to OnyxData for the given policy and categories onyx update. + * + * Pass the map returned by `pushTransactionAutoSelectionsOnyxData` as `transactionAutoSelections` so + * a transaction whose category/tag was just auto-replaced is evaluated against the new value (and does + * not get a stale `*OutOfPolicy` violation). + * + * @param onyxData - The OnyxData object to push updates to + * @param policyData - The current policy Data + * @param policyUpdate - Changed policy properties, if none pass empty object + * @param categoriesUpdate - Changed categories properties, if none pass empty object + * @param tagListsUpdate - Changed tag properties, if none pass empty object + * @param transactionAutoSelections - Auto-selected category/tag updates per transactionID (from `pushTransactionAutoSelectionsOnyxData`) + */ +function pushTransactionViolationsOnyxData( + onyxData: PolicyOptimisticOnyxData, + policyData: PolicyData, + policyUpdate: Partial = {}, + categoriesUpdate: Record> = {}, + tagListsUpdate: Record> = {}, + transactionAutoSelections: Map> = new Map>(), +) { + const nonInvoiceReportItems = getNonInvoiceReportItemsForPolicy(policyData); + if (nonInvoiceReportItems.length === 0) { + return; + } + + const {optimisticPolicy, optimisticCategories, optimisticTagLists, hasDependentTagsValue, isPolicyUpdateEmpty, isCategoriesUpdateEmpty, isTagListsUpdateEmpty} = getOptimisticPolicyState( + policyData, + policyUpdate, + categoriesUpdate, + tagListsUpdate, + ); + + if (isPolicyUpdateEmpty && isCategoriesUpdateEmpty && isTagListsUpdateEmpty) { + return; + } + + // taxOutOfPolicy depends only on tax tracking, tax rates and the transaction's own tax data — none of + // which a category/tag/rules toggle changes. Only let the recompute touch it when the update concerns + // tax tracking, otherwise an unrelated toggle would flash a spurious "tax no longer valid" violation. + const isTaxTrackingUpdate = policyUpdate.tax !== undefined; + + for (const { + transactionsAndViolations: {transactions, violations}, + } of nonInvoiceReportItems) { + for (const transaction of Object.values(transactions)) { + const pendingUpdate = transactionAutoSelections.get(transaction.transactionID); + const modifiedTransaction = pendingUpdate ? {...transaction, ...pendingUpdate} : transaction; + const existingViolations = violations[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transaction.transactionID}`]; const optimisticViolations = ViolationsUtils.getViolationsOnyxData({ - updatedTransaction: transaction, + updatedTransaction: modifiedTransaction, transactionViolations: existingViolations ?? [], policy: optimisticPolicy, policyTagList: optimisticTagLists, policyCategories: optimisticCategories, - hasDependentTags, + hasDependentTags: hasDependentTagsValue, isInvoiceTransaction: false, }); + // Keep the pre-toggle taxOutOfPolicy state when the update isn't about tax tracking. + const recomputedViolations = optimisticViolations.value; + if (!isTaxTrackingUpdate && Array.isArray(recomputedViolations)) { + const preservedTaxViolations = (existingViolations ?? []).filter((violation) => violation.name === CONST.VIOLATIONS.TAX_OUT_OF_POLICY); + optimisticViolations.value = [...recomputedViolations.filter((violation) => violation.name !== CONST.VIOLATIONS.TAX_OUT_OF_POLICY), ...preservedTaxViolations]; + } + if (!isEmptyObject(optimisticViolations)) { onyxData.optimisticData?.push(optimisticViolations); onyxData.failureData?.push({ @@ -13639,6 +13795,7 @@ export { getReportPersonalDetailsParticipants, isWorkspaceEligibleForReportChange, pushTransactionViolationsOnyxData, + pushTransactionAutoSelectionsOnyxData, navigateOnDeleteExpense, canRejectReportAction, hasReportBeenReopened, diff --git a/src/pages/workspace/WorkspaceMoreFeaturesPage/index.tsx b/src/pages/workspace/WorkspaceMoreFeaturesPage/index.tsx index 7bd5b9c2a1f7..f11dbbaa5819 100644 --- a/src/pages/workspace/WorkspaceMoreFeaturesPage/index.tsx +++ b/src/pages/workspace/WorkspaceMoreFeaturesPage/index.tsx @@ -422,10 +422,10 @@ function WorkspaceMoreFeaturesPage({policy, route}: WorkspaceMoreFeaturesPagePro return; } if (isEnabled) { - enablePolicyTaxes(policyID, true, policy?.taxRates); + enablePolicyTaxes(policyID, true, policy?.taxRates, policyData); return; } - enablePolicyTaxes(policyID, false); + enablePolicyTaxes(policyID, false, undefined, policyData); }} onPress={() => { if (!policyID) { diff --git a/tests/actions/IOUTest/UpdateMoneyRequestTest.ts b/tests/actions/IOUTest/UpdateMoneyRequestTest.ts index 8e3bc6fff2c1..700a24d75fbd 100644 --- a/tests/actions/IOUTest/UpdateMoneyRequestTest.ts +++ b/tests/actions/IOUTest/UpdateMoneyRequestTest.ts @@ -836,6 +836,7 @@ describe('actions/IOU/UpdateMoneyRequest', () => { currentUserEmailParam: 'existing@example.com', isASAPSubmitBetaEnabled: false, parentReportNextStep: undefined, + isOffline: false, delegateAccountID: undefined, }); From 1cd407097c6f6d405fcc238de685217c56325ba3 Mon Sep 17 00:00:00 2001 From: Mukher Date: Tue, 9 Jun 2026 14:51:58 +0500 Subject: [PATCH 22/32] added missing translations --- src/languages/de.ts | 43 ++++++++++++++++++++++++++-------------- src/languages/es.ts | 33 ++++++++++++++++-------------- src/languages/fr.ts | 43 ++++++++++++++++++++++++++-------------- src/languages/it.ts | 43 ++++++++++++++++++++++++++-------------- src/languages/ja.ts | 43 ++++++++++++++++++++++++++-------------- src/languages/nl.ts | 43 ++++++++++++++++++++++++++-------------- src/languages/pl.ts | 43 ++++++++++++++++++++++++++-------------- src/languages/pt-BR.ts | 39 ++++++++++++++++++++++-------------- src/languages/zh-hans.ts | 35 ++++++++++++++++++-------------- 9 files changed, 230 insertions(+), 135 deletions(-) diff --git a/src/languages/de.ts b/src/languages/de.ts index bc868ba02188..407ac86b1599 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -1691,6 +1691,16 @@ const translations: TranslationDeepObject = { }, bulkDuplicateLimit: `Sie können bis zu ${CONST.SEARCH.BULK_DUPLICATE_LIMIT} Ausgaben gleichzeitig duplizieren. Bitte wählen Sie weniger Ausgaben aus und versuchen Sie es erneut.`, deleted: 'Gelöscht', + categoryDisabledAlert: { + title: 'Kategorie deaktiviert', + prompt: 'Aktivieren Sie Kategorien im Arbeitsbereich, um die Ausgabendetails zu bearbeiten oder die Kategorie aus dieser Ausgabe zu löschen.', + confirmText: 'Kategorie löschen', + }, + tagDisabledAlert: { + title: 'Tag deaktiviert', + prompt: 'Aktivieren Sie Tags im Workspace, um die Ausgabendetails zu bearbeiten oder den Tag aus dieser Ausgabe zu löschen.', + confirmText: 'Tag löschen', + }, }, transactionMerge: { listPage: { @@ -4125,28 +4135,31 @@ ${amount} für ${merchant} – ${date}`, verificationFailed: 'Die Verifizierung ist fehlgeschlagen, daher benötigen wir zusätzliche Dokumente, um dich und dein Unternehmen zu überprüfen', taxIDVerification: 'Steuer-ID-Verifizierung', taxIDVerificationDescription: dedent(` - Bitte lade eine der folgenden Dateien hoch: - • IRS TIN/EIN-Zuweisungsschreiben - • IRS TIN/EIN-Antragsbestätigung (enthält normalerweise „Congratulations! The EIN has been successfully assigned“) - • IRS-Steuerbefreiungsschreiben mit Firmenname und EIN`), + Bitte lade eine der folgenden Dateien hoch: + • IRS TIN/EIN-Zuweisungsschreiben + • IRS TIN/EIN-Antragsbestätigung (enthält normalerweise „Congratulations! The EIN has been successfully assigned“) + • IRS-Steuerbefreiungsschreiben mit Firmenname und EIN + `), nameChangeDocument: 'Dokument zur Namensänderung', nameChangeDocumentDescription: 'Wenn sich der Name deines Unternehmens seit der Beantragung der TIN/EIN geändert hat, benötigen wir dieses Dokument zur Verifizierung der angegebenen Steuer-ID', companyAddressVerification: 'Verifizierung der Unternehmensadresse', companyAddressVerificationDescription: dedent(` - Bitte lade eine der folgenden Dateien hoch: - • Aktuelle Strom-, Wasser- oder Gasrechnung mit Firmenname und Adresse - • Kontoauszug mit Firmenname und Adresse - • Aktueller Miet- oder Leasingvertrag inkl. Unterschriftsseite mit Firmenname und aktueller Adresse - • Versicherungsnachweis mit Firmenname und Adresse - • TIN-Zuweisungsdokument mit Firmenname und Adresse`), + Bitte lade eine der folgenden Dateien hoch: + • Aktuelle Strom-, Wasser- oder Gasrechnung mit Firmenname und Adresse + • Kontoauszug mit Firmenname und Adresse + • Aktueller Miet- oder Leasingvertrag inkl. Unterschriftsseite mit Firmenname und aktueller Adresse + • Versicherungsnachweis mit Firmenname und Adresse + • TIN-Zuweisungsdokument mit Firmenname und Adresse + `), userAddressVerification: 'Adressverifizierung', userAddressVerificationDescription: dedent(` - Bitte lade eine der folgenden Dateien hoch: - • Wählerregistrierungskarte - • Führerschein - • Kontoauszug - • Versorgungsrechnung`), + Bitte lade eine der folgenden Dateien hoch: + • Wählerregistrierungskarte + • Führerschein + • Kontoauszug + • Versorgungsrechnung + `), userDOBVerification: 'Geburtsdatumsverifizierung', userDOBVerificationDescription: 'Bitte lade einen in den USA ausgestellten Ausweis hoch', finishViaChat: 'Über Chat abschließen', diff --git a/src/languages/es.ts b/src/languages/es.ts index d3fd401c82ef..a529893e87f0 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -4015,27 +4015,30 @@ ${amount} para ${merchant} - ${date}`, verificationFailed: 'La verificación falló, por lo que necesitaremos documentos adicionales para verificarte a ti y a tu empresa', taxIDVerification: 'Verificación del ID fiscal', taxIDVerificationDescription: dedent(` - Por favor, sube uno de los siguientes archivos: - • Carta de asignación de TIN/EIN del IRS - • Confirmación de solicitud de TIN/EIN del IRS (normalmente indica "Congratulations! The EIN has been successfully assigned") - • Carta de exención fiscal del IRS que incluya el nombre de la empresa y el EIN`), + Por favor, sube uno de los siguientes archivos: + • Carta de asignación de TIN/EIN del IRS + • Confirmación de solicitud de TIN/EIN del IRS (normalmente indica "Congratulations! The EIN has been successfully assigned") + • Carta de exención fiscal del IRS que incluya el nombre de la empresa y el EIN + `), nameChangeDocument: 'Documento de cambio de nombre', nameChangeDocumentDescription: 'Si el nombre de tu empresa cambió desde que solicitaste el TIN/EIN, necesitamos este documento para verificar el número de ID fiscal proporcionado', companyAddressVerification: 'Verificación de la dirección de la empresa', companyAddressVerificationDescription: dedent(` - Por favor, sube uno de los siguientes archivos: - • Factura reciente de servicios públicos con nombre y dirección de la empresa - • Estado de cuenta bancario con nombre y dirección de la empresa - • Contrato de arrendamiento vigente con página de firmas que muestre el nombre y la dirección actual de la empresa - • Estado de seguro con nombre y dirección de la empresa - • Documento de asignación de TIN con nombre y dirección de la empresa`), + Por favor, sube uno de los siguientes archivos: + • Factura reciente de servicios públicos con nombre y dirección de la empresa + • Estado de cuenta bancario con nombre y dirección de la empresa + • Contrato de arrendamiento vigente con página de firmas que muestre el nombre y la dirección actual de la empresa + • Estado de seguro con nombre y dirección de la empresa + • Documento de asignación de TIN con nombre y dirección de la empresa + `), userAddressVerification: 'Verificación de dirección', userAddressVerificationDescription: dedent(` - Por favor, sube uno de los siguientes archivos: - • Tarjeta de registro de votante - • Licencia de conducir - • Estado de cuenta bancario - • Factura de servicios públicos`), + Por favor, sube uno de los siguientes archivos: + • Tarjeta de registro de votante + • Licencia de conducir + • Estado de cuenta bancario + • Factura de servicios públicos + `), userDOBVerification: 'Verificación de fecha de nacimiento', userDOBVerificationDescription: 'Por favor, sube una identificación emitida en EE. UU.', finishViaChat: 'Finalizar por chat', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 7d2ca8212ae8..b4cbf6f1d48d 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -1697,6 +1697,16 @@ const translations: TranslationDeepObject = { }, bulkDuplicateLimit: `Vous pouvez dupliquer jusqu’à ${CONST.SEARCH.BULK_DUPLICATE_LIMIT} dépenses à la fois. Veuillez sélectionner moins de dépenses et réessayer.`, deleted: 'Supprimé', + categoryDisabledAlert: { + title: 'Catégorie désactivée', + prompt: 'Activez les catégories dans l’espace de travail pour modifier les détails de la dépense ou supprimer la catégorie de cette dépense.', + confirmText: 'Supprimer la catégorie', + }, + tagDisabledAlert: { + title: 'Tag désactivé', + prompt: 'Active les tags dans l’espace de travail pour modifier les détails de la dépense ou supprimer le tag de cette dépense.', + confirmText: 'Supprimer le tag', + }, }, transactionMerge: { listPage: { @@ -4138,28 +4148,31 @@ ${amount} pour ${merchant} - ${date}`, verificationFailed: 'La vérification a échoué, nous aurons donc besoin de documents supplémentaires pour te vérifier ainsi que ton entreprise', taxIDVerification: 'Vérification de l’identifiant fiscal', taxIDVerificationDescription: dedent(` - Veuillez téléverser l’un des fichiers suivants : - • Lettre d’attribution TIN/EIN de l’IRS - • Confirmation de demande TIN/EIN de l’IRS (indique généralement « Congratulations! The EIN has been successfully assigned ») - • Lettre d’exonération fiscale de l’IRS indiquant le nom de l’entreprise et l’EIN`), + Veuillez téléverser l’un des fichiers suivants : + • Lettre d’attribution TIN/EIN de l’IRS + • Confirmation de demande TIN/EIN de l’IRS (indique généralement « Congratulations! The EIN has been successfully assigned ») + • Lettre d’exonération fiscale de l’IRS indiquant le nom de l’entreprise et l’EIN + `), nameChangeDocument: 'Document de changement de nom', nameChangeDocumentDescription: 'Si le nom de ton entreprise a changé depuis la demande du TIN/EIN, ce document est nécessaire pour vérifier le numéro d’identification fiscale fourni', companyAddressVerification: 'Vérification de l’adresse de l’entreprise', companyAddressVerificationDescription: dedent(` - Veuillez téléverser l’un des fichiers suivants : - • Facture récente de services publics indiquant le nom et l’adresse de l’entreprise - • Relevé bancaire indiquant le nom et l’adresse de l’entreprise - • Contrat de location en cours incluant la page de signature avec le nom et l’adresse actuelle de l’entreprise - • Attestation d’assurance indiquant le nom et l’adresse de l’entreprise - • Document d’attribution TIN indiquant le nom et l’adresse de l’entreprise`), + Veuillez téléverser l’un des fichiers suivants : + • Facture récente de services publics indiquant le nom et l’adresse de l’entreprise + • Relevé bancaire indiquant le nom et l’adresse de l’entreprise + • Contrat de location en cours incluant la page de signature avec le nom et l’adresse actuelle de l’entreprise + • Attestation d’assurance indiquant le nom et l’adresse de l’entreprise + • Document d’attribution TIN indiquant le nom et l’adresse de l’entreprise + `), userAddressVerification: 'Vérification de l’adresse', userAddressVerificationDescription: dedent(` - Veuillez téléverser l’un des fichiers suivants : - • Carte d’inscription électorale - • Permis de conduire - • Relevé bancaire - • Facture de services publics`), + Veuillez téléverser l’un des fichiers suivants : + • Carte d’inscription électorale + • Permis de conduire + • Relevé bancaire + • Facture de services publics + `), userDOBVerification: 'Vérification de la date de naissance', userDOBVerificationDescription: 'Veuillez téléverser une pièce d’identité délivrée aux États-Unis', finishViaChat: 'Finaliser via le chat', diff --git a/src/languages/it.ts b/src/languages/it.ts index 2413f4e83145..2ab94f05bfce 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -1689,6 +1689,16 @@ const translations: TranslationDeepObject = { }, bulkDuplicateLimit: `Puoi duplicare fino a ${CONST.SEARCH.BULK_DUPLICATE_LIMIT} spese alla volta. Seleziona meno spese e riprova.`, deleted: 'Eliminato', + categoryDisabledAlert: { + title: 'Categoria disattivata', + prompt: 'Attiva le categorie nello spazio di lavoro per modificare i dettagli della spesa o eliminare la categoria da questa spesa.', + confirmText: 'Elimina categoria', + }, + tagDisabledAlert: { + title: 'Tag disattivato', + prompt: 'Abilita le etichette nello spazio di lavoro per modificare i dettagli della spesa o eliminare l’etichetta da questa spesa.', + confirmText: 'Elimina tag', + }, }, transactionMerge: { listPage: { @@ -4113,28 +4123,31 @@ ${amount} per ${merchant} - ${date}`, verificationFailed: 'La verifica non è riuscita, quindi avremo bisogno di documenti aggiuntivi per verificare te e la tua azienda', taxIDVerification: 'Verifica dell’ID fiscale', taxIDVerificationDescription: dedent(` - Carica uno dei seguenti file: - • Lettera di assegnazione TIN/EIN dell’IRS - • Conferma della richiesta TIN/EIN dell’IRS (di solito indica "Congratulations! The EIN has been successfully assigned") - • Lettera di esenzione fiscale dell’IRS con nome dell’azienda ed EIN`), + Carica uno dei seguenti file: + • Lettera di assegnazione TIN/EIN dell’IRS + • Conferma della richiesta TIN/EIN dell’IRS (di solito indica "Congratulations! The EIN has been successfully assigned") + • Lettera di esenzione fiscale dell’IRS con nome dell’azienda ed EIN + `), nameChangeDocument: 'Documento di cambio nome', nameChangeDocumentDescription: 'Se il nome della tua azienda è cambiato dopo la richiesta del TIN/EIN, abbiamo bisogno di questo documento per verificare il numero di ID fiscale fornito', companyAddressVerification: 'Verifica dell’indirizzo aziendale', companyAddressVerificationDescription: dedent(` - Carica uno dei seguenti file: - • Bolletta recente con nome e indirizzo dell’azienda - • Estratto conto bancario con nome e indirizzo dell’azienda - • Contratto di locazione attuale con pagina firme che mostri nome e indirizzo attuale dell’azienda - • Documento assicurativo con nome e indirizzo dell’azienda - • Documento di assegnazione TIN con nome e indirizzo dell’azienda`), + Carica uno dei seguenti file: + • Bolletta recente con nome e indirizzo dell’azienda + • Estratto conto bancario con nome e indirizzo dell’azienda + • Contratto di locazione attuale con pagina firme che mostri nome e indirizzo attuale dell’azienda + • Documento assicurativo con nome e indirizzo dell’azienda + • Documento di assegnazione TIN con nome e indirizzo dell’azienda + `), userAddressVerification: 'Verifica dell’indirizzo', userAddressVerificationDescription: dedent(` - Carica uno dei seguenti file: - • Tessera elettorale - • Patente di guida - • Estratto conto bancario - • Bolletta`), + Carica uno dei seguenti file: + • Tessera elettorale + • Patente di guida + • Estratto conto bancario + • Bolletta + `), userDOBVerification: 'Verifica della data di nascita', userDOBVerificationDescription: 'Carica un documento di identità rilasciato negli Stati Uniti', finishViaChat: 'Completa via chat', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 9d4e230c1ce8..dfa648be7d2c 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -1669,6 +1669,16 @@ const translations: TranslationDeepObject = { }, bulkDuplicateLimit: `一度に複製できる経費は最大で ${CONST.SEARCH.BULK_DUPLICATE_LIMIT} 件です。経費の数を減らして、もう一度お試しください。`, deleted: '削除済み', + categoryDisabledAlert: { + title: 'カテゴリは無効です', + prompt: 'ワークスペースでカテゴリを有効にすると、この経費の詳細を編集したり、この経費からカテゴリを削除したりできます。', + confirmText: 'カテゴリを削除', + }, + tagDisabledAlert: { + title: 'タグは無効です', + prompt: 'ワークスペースでタグを有効にすると、この経費の詳細を編集したり、この経費からタグを削除したりできます。', + confirmText: 'タグを削除', + }, }, transactionMerge: { listPage: { @@ -4082,27 +4092,30 @@ ${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'あなたの' verificationFailed: '確認に失敗したため、追加の書類で本人および事業の確認が必要です', taxIDVerification: '納税者番号の確認', taxIDVerificationDescription: dedent(` - 以下のいずれかの書類をアップロードしてください: - • IRS TIN/EIN 割当通知書 - • IRS TIN/EIN 申請確認書(通常「Congratulations! The EIN has been successfully assigned」と記載) - • 会社名と EIN が記載された IRS の免税通知書`), + 以下のいずれかの書類をアップロードしてください: + • IRS TIN/EIN 割当通知書 + • IRS TIN/EIN 申請確認書(通常「Congratulations! The EIN has been successfully assigned」と記載) + • 会社名と EIN が記載された IRS の免税通知書 + `), nameChangeDocument: '名称変更書類', nameChangeDocumentDescription: 'TIN/EIN 申請後に会社名が変更された場合、提供された納税者番号を確認するためにこの書類が必要です', companyAddressVerification: '会社住所の確認', companyAddressVerificationDescription: dedent(` - 以下のいずれかの書類をアップロードしてください: - • 会社名と住所が記載された最近の公共料金請求書 - • 会社名と住所が記載された銀行取引明細書 - • 署名ページを含む現行の賃貸契約書(会社名と現住所が記載されたもの) - • 会社名と住所が記載された保険証書 - • 会社名と住所が記載された TIN 割当書類`), + 以下のいずれかの書類をアップロードしてください: + • 会社名と住所が記載された最近の公共料金請求書 + • 会社名と住所が記載された銀行取引明細書 + • 署名ページを含む現行の賃貸契約書(会社名と現住所が記載されたもの) + • 会社名と住所が記載された保険証書 + • 会社名と住所が記載された TIN 割当書類 + `), userAddressVerification: '住所確認', userAddressVerificationDescription: dedent(` - 以下のいずれかの書類をアップロードしてください: - • 有権者登録カード - • 運転免許証 - • 銀行取引明細書 - • 公共料金請求書`), + 以下のいずれかの書類をアップロードしてください: + • 有権者登録カード + • 運転免許証 + • 銀行取引明細書 + • 公共料金請求書 + `), userDOBVerification: '生年月日の確認', userDOBVerificationDescription: '米国発行の身分証明書をアップロードしてください', finishViaChat: 'チャットで完了', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 0668794f9c5f..0de85bbd6649 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -1684,6 +1684,16 @@ const translations: TranslationDeepObject = { }, bulkDuplicateLimit: `Je kunt maximaal ${CONST.SEARCH.BULK_DUPLICATE_LIMIT} uitgaven tegelijk dupliceren. Selecteer minder uitgaven en probeer het opnieuw.`, deleted: 'Verwijderd', + categoryDisabledAlert: { + title: 'Categorie uitgeschakeld', + prompt: 'Schakel categorieën in de workspace in om de onkostendetails te bewerken of de categorie uit deze onkost te verwijderen.', + confirmText: 'Categorie verwijderen', + }, + tagDisabledAlert: { + title: 'Label uitgeschakeld', + prompt: 'Schakel tags in op de werkruimte om de onkostendetails te bewerken of de tag uit deze onkosten te verwijderen.', + confirmText: 'Label verwijderen', + }, }, transactionMerge: { listPage: { @@ -4109,27 +4119,30 @@ ${amount} voor ${merchant} - ${date}`, verificationFailed: 'De verificatie is mislukt, daarom hebben we extra documenten nodig om jou en je bedrijf te verifiëren', taxIDVerification: 'Belastingnummerverificatie', taxIDVerificationDescription: dedent(` - Upload een van de volgende bestanden: - • IRS TIN/EIN-toewijzingsbrief - • IRS TIN/EIN-aanvraagbevestiging (bevat meestal "Congratulations! The EIN has been successfully assigned") - • IRS-belastingvrijstellingsbrief met bedrijfsnaam en EIN`), + Upload een van de volgende bestanden: + • IRS TIN/EIN-toewijzingsbrief + • IRS TIN/EIN-aanvraagbevestiging (bevat meestal "Congratulations! The EIN has been successfully assigned") + • IRS-belastingvrijstellingsbrief met bedrijfsnaam en EIN + `), nameChangeDocument: 'Document naamswijziging', nameChangeDocumentDescription: 'Als de naam van je bedrijf is gewijzigd sinds de TIN/EIN-aanvraag, hebben we dit document nodig om het opgegeven belastingnummer te verifiëren', companyAddressVerification: 'Verificatie van bedrijfsadres', companyAddressVerificationDescription: dedent(` - Upload een van de volgende bestanden: - • Recente energierekening met bedrijfsnaam en adres - • Bankafschrift met bedrijfsnaam en adres - • Huidige huur- of leaseovereenkomst inclusief ondertekeningspagina met bedrijfsnaam en huidig adres - • Verzekeringsverklaring met bedrijfsnaam en adres - • TIN-toewijzingsdocument met bedrijfsnaam en adres`), + Upload een van de volgende bestanden: + • Recente energierekening met bedrijfsnaam en adres + • Bankafschrift met bedrijfsnaam en adres + • Huidige huur- of leaseovereenkomst inclusief ondertekeningspagina met bedrijfsnaam en huidig adres + • Verzekeringsverklaring met bedrijfsnaam en adres + • TIN-toewijzingsdocument met bedrijfsnaam en adres + `), userAddressVerification: 'Adresverificatie', userAddressVerificationDescription: dedent(` - Upload een van de volgende bestanden: - • Kiezersregistratiekaart - • Rijbewijs - • Bankafschrift - • Energierekening`), + Upload een van de volgende bestanden: + • Kiezersregistratiekaart + • Rijbewijs + • Bankafschrift + • Energierekening + `), userDOBVerification: 'Verificatie van geboortedatum', userDOBVerificationDescription: 'Upload een in de VS uitgegeven identiteitsbewijs', finishViaChat: 'Afronden via chat', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index a2aba628fd4a..807da53e2cec 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -1685,6 +1685,16 @@ const translations: TranslationDeepObject = { }, bulkDuplicateLimit: `Możesz jednocześnie zduplikować maksymalnie ${CONST.SEARCH.BULK_DUPLICATE_LIMIT} wydatków. Wybierz mniej wydatków i spróbuj ponownie.`, deleted: 'Usunięto', + categoryDisabledAlert: { + title: 'Kategoria wyłączona', + prompt: 'Włącz kategorie w przestrzeni roboczej, żeby edytować szczegóły wydatku lub usunąć kategorię z tego wydatku.', + confirmText: 'Usuń kategorię', + }, + tagDisabledAlert: { + title: 'Tag wyłączony', + prompt: 'Włącz tagi w przestrzeni roboczej, aby edytować szczegóły wydatku lub usunąć ten tag z tego wydatku.', + confirmText: 'Usuń znacznik', + }, }, transactionMerge: { listPage: { @@ -4101,27 +4111,30 @@ ${amount} dla ${merchant} - ${date}`, verificationFailed: 'Weryfikacja nie powiodła się, dlatego potrzebujemy dodatkowych dokumentów do potwierdzenia Twojej tożsamości i firmy', taxIDVerification: 'Weryfikacja numeru podatkowego', taxIDVerificationDescription: dedent(` - Prześlij jeden z poniższych plików: - • List przydziału TIN/EIN z IRS - • Potwierdzenie wniosku TIN/EIN z IRS (zwykle zawiera „Congratulations! The EIN has been successfully assigned”) - • Pismo o zwolnieniu podatkowym z IRS zawierające nazwę firmy i EIN`), + Prześlij jeden z poniższych plików: + • List przydziału TIN/EIN z IRS + • Potwierdzenie wniosku TIN/EIN z IRS (zwykle zawiera „Congratulations! The EIN has been successfully assigned”) + • Pismo o zwolnieniu podatkowym z IRS zawierające nazwę firmy i EIN + `), nameChangeDocument: 'Dokument zmiany nazwy', nameChangeDocumentDescription: 'Jeśli nazwa firmy zmieniła się od momentu złożenia wniosku o TIN/EIN, dokument ten jest wymagany do weryfikacji podanego numeru podatkowego', companyAddressVerification: 'Weryfikacja adresu firmy', companyAddressVerificationDescription: dedent(` - Prześlij jeden z poniższych plików: - • Aktualny rachunek za media z nazwą i adresem firmy - • Wyciąg bankowy z nazwą i adresem firmy - • Aktualna umowa najmu z podpisaną stroną zawierającą nazwę i adres firmy - • Dokument ubezpieczeniowy z nazwą i adresem firmy - • Dokument przydziału TIN z nazwą i adresem firmy`), + Prześlij jeden z poniższych plików: + • Aktualny rachunek za media z nazwą i adresem firmy + • Wyciąg bankowy z nazwą i adresem firmy + • Aktualna umowa najmu z podpisaną stroną zawierającą nazwę i adres firmy + • Dokument ubezpieczeniowy z nazwą i adresem firmy + • Dokument przydziału TIN z nazwą i adresem firmy + `), userAddressVerification: 'Weryfikacja adresu', userAddressVerificationDescription: dedent(` - Prześlij jeden z poniższych plików: - • Karta rejestracji wyborcy - • Prawo jazdy - • Wyciąg bankowy - • Rachunek za media`), + Prześlij jeden z poniższych plików: + • Karta rejestracji wyborcy + • Prawo jazdy + • Wyciąg bankowy + • Rachunek za media + `), userDOBVerification: 'Weryfikacja daty urodzenia', userDOBVerificationDescription: 'Prześlij dokument tożsamości wydany w USA', finishViaChat: 'Zakończ przez czat', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index b864338b0100..5e9b755a64d4 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -1682,6 +1682,12 @@ const translations: TranslationDeepObject = { }, bulkDuplicateLimit: `Você pode duplicar até ${CONST.SEARCH.BULK_DUPLICATE_LIMIT} despesas por vez. Selecione menos despesas e tente novamente.`, deleted: 'Excluído', + categoryDisabledAlert: { + title: 'Categoria desativada', + prompt: 'Ative as categorias no workspace para editar os detalhes da despesa ou excluir a categoria desta despesa.', + confirmText: 'Excluir categoria', + }, + tagDisabledAlert: {title: 'Tag desativada', prompt: 'Ative as tags no workspace para editar os detalhes da despesa ou excluir a tag desta despesa.', confirmText: 'Excluir tag'}, }, transactionMerge: { listPage: { @@ -4101,27 +4107,30 @@ ${amount} para ${merchant} - ${date}`, verificationFailed: 'A verificação falhou, então precisaremos de documentos adicionais para verificar você e sua empresa', taxIDVerification: 'Verificação de ID fiscal', taxIDVerificationDescription: dedent(` - Envie um dos seguintes arquivos: - • Carta de atribuição de TIN/EIN do IRS - • Confirmação de solicitação de TIN/EIN do IRS (normalmente contém "Congratulations! The EIN has been successfully assigned") - • Carta de isenção fiscal do IRS com o nome da empresa e o EIN`), + Envie um dos seguintes arquivos: + • Carta de atribuição de TIN/EIN do IRS + • Confirmação de solicitação de TIN/EIN do IRS (normalmente contém "Congratulations! The EIN has been successfully assigned") + • Carta de isenção fiscal do IRS com o nome da empresa e o EIN + `), nameChangeDocument: 'Documento de alteração de nome', nameChangeDocumentDescription: 'Se o nome da sua empresa mudou desde a solicitação do TIN/EIN, precisamos deste documento para verificar o número de identificação fiscal informado', companyAddressVerification: 'Verificação de endereço da empresa', companyAddressVerificationDescription: dedent(` - Envie um dos seguintes arquivos: - • Conta recente de serviços públicos com nome e endereço da empresa - • Extrato bancário com nome e endereço da empresa - • Contrato de locação atual incluindo a página de assinatura com nome e endereço atual da empresa - • Apólice ou declaração de seguro com nome e endereço da empresa - • Documento de atribuição de TIN com nome e endereço da empresa`), + Envie um dos seguintes arquivos: + • Conta recente de serviços públicos com nome e endereço da empresa + • Extrato bancário com nome e endereço da empresa + • Contrato de locação atual incluindo a página de assinatura com nome e endereço atual da empresa + • Apólice ou declaração de seguro com nome e endereço da empresa + • Documento de atribuição de TIN com nome e endereço da empresa + `), userAddressVerification: 'Verificação de endereço', userAddressVerificationDescription: dedent(` - Envie um dos seguintes arquivos: - • Título de eleitor - • Carteira de motorista - • Extrato bancário - • Conta de serviços públicos`), + Envie um dos seguintes arquivos: + • Título de eleitor + • Carteira de motorista + • Extrato bancário + • Conta de serviços públicos + `), userDOBVerification: 'Verificação de data de nascimento', userDOBVerificationDescription: 'Envie um documento de identidade emitido nos EUA', finishViaChat: 'Finalizar pelo chat', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index ef9726e719fb..6f98497ecbf7 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -1630,6 +1630,8 @@ const translations: TranslationDeepObject = { taxDisabledAlert: {title: '税费已禁用', prompt: '请在工作区中启用税费跟踪,以便编辑此报销的详细信息或从该报销中删除税费。', confirmText: '删除税费'}, bulkDuplicateLimit: `您一次最多可以复制 ${CONST.SEARCH.BULK_DUPLICATE_LIMIT} 笔报销。请减少选择的报销数量后重试。`, deleted: '已删除', + categoryDisabledAlert: {title: '类别已禁用', prompt: '在工作区中启用类别,以编辑报销详情或从此报销中删除该类别。', confirmText: '删除类别'}, + tagDisabledAlert: {title: '标签已停用', prompt: '请在工作区中启用标签,以便编辑该报销的详细信息或从此报销中删除该标签。', confirmText: '删除标签'}, }, transactionMerge: { listPage: { @@ -4010,27 +4012,30 @@ ${amount},商户:${merchant} - 日期:${date}`, verificationFailed: '验证失败,因此我们需要额外的文件来验证你及你的企业', taxIDVerification: '税务识别号验证', taxIDVerificationDescription: dedent(` - 请上传以下任一文件: - • IRS TIN/EIN 分配函 - • IRS TIN/EIN 申请确认函(通常包含“Congratulations! The EIN has been successfully assigned”) - • 显示公司名称和 EIN 的 IRS 免税函`), + 请上传以下任一文件: + • IRS TIN/EIN 分配函 + • IRS TIN/EIN 申请确认函(通常包含“Congratulations! The EIN has been successfully assigned”) + • 显示公司名称和 EIN 的 IRS 免税函 + `), nameChangeDocument: '名称变更文件', nameChangeDocumentDescription: '如果你的公司名称在申请 TIN/EIN 后发生更改,我们需要此文件来验证你提供的税务识别号', companyAddressVerification: '公司地址验证', companyAddressVerificationDescription: dedent(` - 请上传以下任一文件: - • 显示公司名称和地址的近期水电账单 - • 显示公司名称和地址的银行对账单 - • 包含签字页的有效租赁协议,显示公司名称和当前地址 - • 显示公司名称和地址的保险声明 - • 显示公司名称和地址的 TIN 分配文件`), + 请上传以下任一文件: + • 显示公司名称和地址的近期水电账单 + • 显示公司名称和地址的银行对账单 + • 包含签字页的有效租赁协议,显示公司名称和当前地址 + • 显示公司名称和地址的保险声明 + • 显示公司名称和地址的 TIN 分配文件 + `), userAddressVerification: '地址验证', userAddressVerificationDescription: dedent(` - 请上传以下任一文件: - • 选民登记卡 - • 驾驶证 - • 银行对账单 - • 水电账单`), + 请上传以下任一文件: + • 选民登记卡 + • 驾驶证 + • 银行对账单 + • 水电账单 + `), userDOBVerification: '出生日期验证', userDOBVerificationDescription: '请上传美国签发的身份证件', finishViaChat: '通过聊天完成', From 7d958775fce4226fcbe2269edb6c2875cbff351b Mon Sep 17 00:00:00 2001 From: Mukher Date: Wed, 10 Jun 2026 14:53:46 +0500 Subject: [PATCH 23/32] added first approver and first approved columns --- src/CONST/index.ts | 4 + .../ExpenseReportListItemRowWide.tsx | 21 ++++ .../Search/SearchList/ListItem/types.ts | 18 ++++ src/components/Search/SearchTableHeader.tsx | 9 ++ src/languages/en.ts | 2 + src/libs/SearchUIUtils.ts | 42 ++++++++ src/styles/utils/index.ts | 6 ++ tests/unit/Search/SearchUIUtilsTest.ts | 102 ++++++++++++++++++ 8 files changed, 204 insertions(+) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 32f91a47067a..0883df1c5295 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -7278,6 +7278,8 @@ const CONST = { DATE: this.TABLE_COLUMNS.DATE, SUBMITTED: this.TABLE_COLUMNS.SUBMITTED, APPROVED: this.TABLE_COLUMNS.APPROVED, + FIRST_APPROVER: this.TABLE_COLUMNS.FIRST_APPROVER, + FIRST_APPROVED: this.TABLE_COLUMNS.FIRST_APPROVED, EXPORTED: this.TABLE_COLUMNS.EXPORTED, STATUS: this.TABLE_COLUMNS.STATUS, TITLE: this.TABLE_COLUMNS.TITLE, @@ -7481,6 +7483,8 @@ const CONST = { DATE: 'date', SUBMITTED: 'submitted', APPROVED: 'approved', + FIRST_APPROVER: 'firstApprover', + FIRST_APPROVED: 'firstApproved', POSTED: 'posted', EXPORTED: 'exported', MERCHANT: 'merchant', diff --git a/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/ExpenseReportListItemRowWide.tsx b/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/ExpenseReportListItemRowWide.tsx index ab301d389d58..ef0af803a62b 100644 --- a/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/ExpenseReportListItemRowWide.tsx +++ b/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/ExpenseReportListItemRowWide.tsx @@ -84,6 +84,27 @@ function ExpenseReportListItemRowWide({ /> ), + [CONST.SEARCH.TABLE_COLUMNS.FIRST_APPROVER]: ( + + {!!item.firstApproverAccountID && ( + + )} + + ), + [CONST.SEARCH.TABLE_COLUMNS.FIRST_APPROVED]: ( + + + + ), [CONST.SEARCH.TABLE_COLUMNS.EXPORTED]: ( ; shouldShowMerchant: boolean; lastExportedActionByReportID: Map; + firstApprovedActionByReportID: Map; moneyRequestReportActionsByTransactionID: Map; holdReportActionsByTransactionID: Map; allHoldReportActions: Map; @@ -1748,6 +1750,7 @@ type PreprocessingContext = { shouldShowYearCreatedReport: boolean; shouldShowYearSubmittedReport: boolean; shouldShowYearApprovedReport: boolean; + shouldShowYearFirstApprovedReport: boolean; shouldShowYearExportedReport: boolean; shouldShowAmountInWideColumn: boolean; shouldShowTaxAmountInWideColumn: boolean; @@ -1762,6 +1765,7 @@ function createPreprocessingContext(): PreprocessingContext { violations: {}, shouldShowMerchant: false, lastExportedActionByReportID: new Map(), + firstApprovedActionByReportID: new Map(), moneyRequestReportActionsByTransactionID: new Map(), holdReportActionsByTransactionID: new Map(), allHoldReportActions: new Map(), @@ -1774,6 +1778,7 @@ function createPreprocessingContext(): PreprocessingContext { shouldShowYearCreatedReport: false, shouldShowYearSubmittedReport: false, shouldShowYearApprovedReport: false, + shouldShowYearFirstApprovedReport: false, shouldShowYearExportedReport: false, shouldShowAmountInWideColumn: false, shouldShowTaxAmountInWideColumn: false, @@ -1792,6 +1797,10 @@ function processReportActionEntry(ctx: PreprocessingContext, key: string, action let latestExportTime = -Infinity; let latestExportAction: OnyxTypes.ReportAction | undefined; + // Earliest approval action; its actor is the report's first approver. + let firstApprovalTime = Infinity; + let firstApprovalAction: OnyxTypes.ReportAction | undefined; + for (const action of Object.values(actions)) { if (action.actionName === CONST.REPORT.ACTIONS.TYPE.EXPORTED_TO_CSV || action.actionName === CONST.REPORT.ACTIONS.TYPE.EXPORTED_TO_INTEGRATION) { const currentTime = new Date(action.created).getTime(); @@ -1801,6 +1810,14 @@ function processReportActionEntry(ctx: PreprocessingContext, key: string, action } } + if (action.actionName === CONST.REPORT.ACTIONS.TYPE.APPROVED || action.actionName === CONST.REPORT.ACTIONS.TYPE.FORWARDED) { + const currentTime = new Date(action.created).getTime(); + if (currentTime < firstApprovalTime) { + firstApprovalTime = currentTime; + firstApprovalAction = action; + } + } + if (isMoneyRequestAction(action)) { const originalMessage = getOriginalMessage(action); const transactionID = originalMessage?.IOUTransactionID; @@ -1819,6 +1836,13 @@ function processReportActionEntry(ctx: PreprocessingContext, key: string, action if (latestExportAction) { ctx.lastExportedActionByReportID.set(reportID, latestExportAction); } + + if (firstApprovalAction) { + ctx.firstApprovedActionByReportID.set(reportID, firstApprovalAction); + if (!ctx.shouldShowYearFirstApprovedReport && DateUtils.doesDateBelongToAPastYear(firstApprovalAction.created)) { + ctx.shouldShowYearFirstApprovedReport = true; + } + } } /** @@ -2756,6 +2780,7 @@ function getReportSections({ violations: allViolations, shouldShowMerchant, lastExportedActionByReportID, + firstApprovedActionByReportID, moneyRequestReportActionsByTransactionID, holdReportActionsByTransactionID, transactionsByReportID, @@ -2767,6 +2792,7 @@ function getReportSections({ shouldShowYearCreatedReport, shouldShowYearSubmittedReport, shouldShowYearApprovedReport, + shouldShowYearFirstApprovedReport, shouldShowYearExportedReport, shouldShowAmountInWideColumn, shouldShowTaxAmountInWideColumn, @@ -2822,8 +2848,15 @@ function getReportSections({ emptyPersonalDetails; const toDetails = !shouldShowBlankTo && reportItem.managerID ? mergedPersonalDetails?.[reportItem.managerID] : emptyPersonalDetails; + // First approver/approved come from the earliest APPROVED/FORWARDED report action; blank when the report has no approval. + const firstApprovedAction = firstApprovedActionByReportID.get(reportItem.reportID); + const firstApproverAccountID = firstApprovedAction?.actorAccountID; + const firstApproverDetails = firstApproverAccountID ? (mergedPersonalDetails?.[firstApproverAccountID] ?? emptyPersonalDetails) : emptyPersonalDetails; + const firstApproved = firstApprovedAction?.created ?? ''; + const formattedFrom = formatPhoneNumber(getDisplayNameOrDefault(fromDetails)); const formattedTo = !shouldShowBlankTo ? formatPhoneNumber(getDisplayNameOrDefault(toDetails)) : ''; + const formattedFirstApprover = firstApproverAccountID ? formatPhoneNumber(getDisplayNameOrDefault(firstApproverDetails)) : ''; const formattedStatus = getReportStatusTranslation({stateNum: reportItem.stateNum, statusNum: reportItem.statusNum, translate}); const policyFromKey = getPolicyFromKey(data, reportItem); @@ -2864,6 +2897,10 @@ function getReportSections({ from: (fromDetails ?? emptyPersonalDetails) as OnyxTypes.PersonalDetails, to: (toDetails ?? emptyPersonalDetails) as OnyxTypes.PersonalDetails, exported: lastExportedActionByReportID.get(reportItem.reportID)?.created ?? '', + firstApproved, + firstApprover: (firstApproverDetails ?? emptyPersonalDetails) as OnyxTypes.PersonalDetails, + firstApproverAccountID, + formattedFirstApprover, formattedFrom, formattedTo, formattedStatus, @@ -2873,6 +2910,7 @@ function getReportSections({ shouldShowYear: shouldShowYearCreatedReport, shouldShowYearSubmitted: shouldShowYearSubmittedReport, shouldShowYearApproved: shouldShowYearApprovedReport, + shouldShowYearFirstApproved: shouldShowYearFirstApprovedReport, shouldShowYearExported: shouldShowYearExportedReport, hasVisibleViolations: hasVisibleViolationsForReport, isRejectedReport, @@ -4257,6 +4295,10 @@ function getSearchColumnTranslationKey(column: SearchSortBy): TranslationPaths { return 'common.submitted'; case CONST.SEARCH.TABLE_COLUMNS.APPROVED: return 'search.filters.approved'; + case CONST.SEARCH.TABLE_COLUMNS.FIRST_APPROVER: + return 'search.filters.firstApprover'; + case CONST.SEARCH.TABLE_COLUMNS.FIRST_APPROVED: + return 'search.filters.firstApproved'; case CONST.SEARCH.TABLE_COLUMNS.POSTED: return 'search.filters.posted'; case CONST.SEARCH.TABLE_COLUMNS.EXPORTED: diff --git a/src/styles/utils/index.ts b/src/styles/utils/index.ts index 466639a26892..0967c8c06552 100644 --- a/src/styles/utils/index.ts +++ b/src/styles/utils/index.ts @@ -58,6 +58,7 @@ type GetReportTableColumnStylesParams = { isTaxAmountColumnWide?: boolean; isSubmittedColumnWide?: boolean; isApprovedColumnWide?: boolean; + isFirstApprovedColumnWide?: boolean; isPostedColumnWide?: boolean; isExportedColumnWide?: boolean; shouldRemoveTotalColumnFlex?: boolean; @@ -1870,6 +1871,7 @@ const createStyleUtils = (theme: ThemeColors, styles: ThemeStyles) => ({ const { isSubmittedColumnWide, isApprovedColumnWide, + isFirstApprovedColumnWide, isPostedColumnWide, isExportedColumnWide, isDateColumnWide, @@ -1903,6 +1905,9 @@ const createStyleUtils = (theme: ThemeColors, styles: ThemeStyles) => ({ case CONST.SEARCH.TABLE_COLUMNS.APPROVED: columnWidth = {...getWidthStyle(isApprovedColumnWide ? variables.w92 : variables.w72)}; break; + case CONST.SEARCH.TABLE_COLUMNS.FIRST_APPROVED: + columnWidth = {...getWidthStyle(isFirstApprovedColumnWide ? variables.w92 : variables.w72)}; + break; case CONST.SEARCH.TABLE_COLUMNS.POSTED: columnWidth = {...getWidthStyle(isPostedColumnWide ? variables.w92 : variables.w72)}; break; @@ -1981,6 +1986,7 @@ const createStyleUtils = (theme: ThemeColors, styles: ThemeStyles) => ({ case CONST.SEARCH.TABLE_COLUMNS.MERCHANT: case CONST.SEARCH.TABLE_COLUMNS.FROM: case CONST.SEARCH.TABLE_COLUMNS.TO: + case CONST.SEARCH.TABLE_COLUMNS.FIRST_APPROVER: case CONST.SEARCH.TABLE_COLUMNS.ASSIGNEE: case CONST.SEARCH.TABLE_COLUMNS.TITLE: case CONST.SEARCH.TABLE_COLUMNS.DESCRIPTION: diff --git a/tests/unit/Search/SearchUIUtilsTest.ts b/tests/unit/Search/SearchUIUtilsTest.ts index dd0028022f2a..fb522ef13d7f 100644 --- a/tests/unit/Search/SearchUIUtilsTest.ts +++ b/tests/unit/Search/SearchUIUtilsTest.ts @@ -1143,6 +1143,11 @@ const transactionReportGroupListItems = [ shouldShowYearSubmitted: true, shouldShowYearApproved: false, shouldShowYearExported: false, + shouldShowYearFirstApproved: false, + firstApproved: '', + firstApprover: emptyPersonalDetails, + firstApproverAccountID: undefined, + formattedFirstApprover: '', stateNum: 0, statusNum: 0, to: emptyPersonalDetails, @@ -1263,6 +1268,11 @@ const transactionReportGroupListItems = [ shouldShowYearSubmitted: true, shouldShowYearApproved: false, shouldShowYearExported: false, + shouldShowYearFirstApproved: false, + firstApproved: '', + firstApprover: emptyPersonalDetails, + firstApproverAccountID: undefined, + formattedFirstApprover: '', stateNum: 1, statusNum: 1, to: { @@ -1389,6 +1399,11 @@ const transactionReportGroupListItems = [ shouldShowYearSubmitted: true, shouldShowYearApproved: false, shouldShowYearExported: false, + shouldShowYearFirstApproved: false, + firstApproved: '', + firstApprover: emptyPersonalDetails, + firstApproverAccountID: undefined, + formattedFirstApprover: '', stateNum: 1, statusNum: 1, total: 4400, @@ -1596,6 +1611,11 @@ const transactionReportGroupListItems = [ shouldShowYearSubmitted: true, shouldShowYearApproved: false, shouldShowYearExported: false, + shouldShowYearFirstApproved: false, + firstApproved: '', + firstApprover: emptyPersonalDetails, + firstApproverAccountID: undefined, + formattedFirstApprover: '', stateNum: 0, statusNum: 0, to: emptyPersonalDetails, @@ -5869,6 +5889,80 @@ describe('SearchUIUtils', () => { const item = sections.find((s) => s.keyForList === rptFilterReportID); expect(item?.pendingAction).toBeUndefined(); }); + + it('should populate firstApprover/firstApproved from the report APPROVED action', () => { + const approvedAt = '2024-12-22 09:30:00'; + const data = makeReportFilterTestData( + {type: CONST.REPORT.TYPE.EXPENSE}, + {}, + { + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${rptFilterReportID}`]: { + 'approved-1': { + reportActionID: 'approved-1', + actionName: CONST.REPORT.ACTIONS.TYPE.APPROVED, + actorAccountID: approverAccountID, + created: approvedAt, + }, + }, + }, + ); + const [sections] = callGetReportSections(data); + const item = sections.find((s) => s.keyForList === rptFilterReportID); + expect(item?.firstApproved).toBe(approvedAt); + expect(item?.firstApproverAccountID).toBe(approverAccountID); + expect(item?.formattedFirstApprover).toBeTruthy(); + }); + + it('should use the earliest approval action (FORWARDED before APPROVED) for the first approver', () => { + const forwardedAt = '2024-12-20 08:00:00'; + const approvedAt = '2024-12-22 09:30:00'; + const data = makeReportFilterTestData( + {type: CONST.REPORT.TYPE.EXPENSE}, + {}, + { + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${rptFilterReportID}`]: { + 'approved-1': { + reportActionID: 'approved-1', + actionName: CONST.REPORT.ACTIONS.TYPE.APPROVED, + actorAccountID: adminAccountID, + created: approvedAt, + }, + 'forwarded-1': { + reportActionID: 'forwarded-1', + actionName: CONST.REPORT.ACTIONS.TYPE.FORWARDED, + actorAccountID: approverAccountID, + created: forwardedAt, + }, + }, + }, + ); + const [sections] = callGetReportSections(data); + const item = sections.find((s) => s.keyForList === rptFilterReportID); + expect(item?.firstApproved).toBe(forwardedAt); + expect(item?.firstApproverAccountID).toBe(approverAccountID); + }); + + it('should leave firstApprover/firstApproved blank when there is no approval action (no FE fallback to managerID/approved)', () => { + const data = makeReportFilterTestData( + {type: CONST.REPORT.TYPE.EXPENSE, approved: '2024-12-22 09:30:00', managerID: adminAccountID}, + {}, + { + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${rptFilterReportID}`]: { + 'comment-1': { + reportActionID: 'comment-1', + actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, + actorAccountID: adminAccountID, + created: '2024-12-22 09:30:00', + }, + }, + }, + ); + const [sections] = callGetReportSections(data); + const item = sections.find((s) => s.keyForList === rptFilterReportID); + expect(item?.firstApproved).toBe(''); + expect(item?.firstApproverAccountID).toBeUndefined(); + expect(item?.formattedFirstApprover).toBe(''); + }); }); }); @@ -8252,6 +8346,14 @@ describe('SearchUIUtils', () => { ]); }); + test('Should include First approver/First approved columns when selected for expense reports', () => { + const visibleColumns = [CONST.SEARCH.TABLE_COLUMNS.DATE, CONST.SEARCH.TABLE_COLUMNS.FIRST_APPROVER, CONST.SEARCH.TABLE_COLUMNS.FIRST_APPROVED, CONST.SEARCH.TABLE_COLUMNS.TOTAL]; + + const result = SearchUIUtils.getColumnsToShow({currentAccountID: 1, data: [], visibleColumns, type: CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT}); + expect(result).toContain(CONST.SEARCH.TABLE_COLUMNS.FIRST_APPROVER); + expect(result).toContain(CONST.SEARCH.TABLE_COLUMNS.FIRST_APPROVED); + }); + test('Should include Avatar when user keeps it in visible columns for expense reports', () => { const visibleColumns = [CONST.SEARCH.TABLE_COLUMNS.AVATAR, CONST.SEARCH.TABLE_COLUMNS.DATE, CONST.SEARCH.TABLE_COLUMNS.TOTAL]; From dbc29f5741fd6a589c211fad580765540ffa9032 Mon Sep 17 00:00:00 2001 From: Mukher Date: Wed, 10 Jun 2026 15:33:26 +0500 Subject: [PATCH 24/32] fixed eslint failures --- tests/unit/Search/SearchUIUtilsTest.ts | 8 ++++---- tests/unit/hooks/useHoldRejectActionsTest.ts | 21 ++++++++++++++++---- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/tests/unit/Search/SearchUIUtilsTest.ts b/tests/unit/Search/SearchUIUtilsTest.ts index 123f913e8981..dd0028022f2a 100644 --- a/tests/unit/Search/SearchUIUtilsTest.ts +++ b/tests/unit/Search/SearchUIUtilsTest.ts @@ -5965,7 +5965,7 @@ describe('SearchUIUtils', () => { CONST.SEARCH.SORT_ORDER.ASC, undefined, {policyCategories: policyCategoriesForSort}, - ) as TransactionListItemType[]; + ); const descendingResult = SearchUIUtils.getSortedSections( CONST.SEARCH.DATA_TYPES.EXPENSE, '', @@ -5976,10 +5976,10 @@ describe('SearchUIUtils', () => { CONST.SEARCH.SORT_ORDER.DESC, undefined, {policyCategories: policyCategoriesForSort}, - ) as TransactionListItemType[]; + ); - expect(ascendingResult.map((transaction) => transaction.transactionID)).toEqual(['without-gl-code', 'gl-code-1010', 'gl-code-6100', 'gl-code-6200']); - expect(descendingResult.map((transaction) => transaction.transactionID)).toEqual(['gl-code-6200', 'gl-code-6100', 'gl-code-1010', 'without-gl-code']); + expect(ascendingResult.map((item) => ('transactionID' in item ? item.transactionID : undefined))).toEqual(['without-gl-code', 'gl-code-1010', 'gl-code-6100', 'gl-code-6200']); + expect(descendingResult.map((item) => ('transactionID' in item ? item.transactionID : undefined))).toEqual(['gl-code-6200', 'gl-code-6100', 'gl-code-1010', 'without-gl-code']); }); it('should return getSortedReportData result when type is expense-report', () => { diff --git a/tests/unit/hooks/useHoldRejectActionsTest.ts b/tests/unit/hooks/useHoldRejectActionsTest.ts index 45e2ba90b7fd..86813c38a567 100644 --- a/tests/unit/hooks/useHoldRejectActionsTest.ts +++ b/tests/unit/hooks/useHoldRejectActionsTest.ts @@ -1,5 +1,6 @@ import {renderHook} from '@testing-library/react-native'; import Onyx from 'react-native-onyx'; +import {useDelegateNoAccessActions, useDelegateNoAccessState} from '@components/DelegateNoAccessModalProvider'; import useHoldRejectActions from '@hooks/useHoldRejectActions'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -65,7 +66,19 @@ jest.mock('@libs/ReportUtils', () => ({ changeMoneyRequestHoldStatus: jest.fn(), })); -const DelegateProvider = require('@components/DelegateNoAccessModalProvider') as Record; +function getMockUseDelegateNoAccessState() { + if (!jest.isMockFunction(useDelegateNoAccessState)) { + throw new Error('useDelegateNoAccessState must be mocked'); + } + return useDelegateNoAccessState; +} + +function getMockUseDelegateNoAccessActions() { + if (!jest.isMockFunction(useDelegateNoAccessActions)) { + throw new Error('useDelegateNoAccessActions must be mocked'); + } + return useDelegateNoAccessActions; +} const mockReport: Report = { reportID: TEST_REPORT_ID, @@ -96,8 +109,8 @@ describe('useHoldRejectActions - report-level reject', () => { await Onyx.clear(); await waitForBatchedUpdates(); jest.clearAllMocks(); - DelegateProvider.useDelegateNoAccessState.mockReturnValue({isDelegateAccessRestricted: false}); - DelegateProvider.useDelegateNoAccessActions.mockReturnValue({showDelegateNoAccessModal: mockShowDelegateNoAccessModal}); + getMockUseDelegateNoAccessState().mockReturnValue({isDelegateAccessRestricted: false}); + getMockUseDelegateNoAccessActions().mockReturnValue({showDelegateNoAccessModal: mockShowDelegateNoAccessModal}); await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${TEST_REPORT_ID}`, mockReport); }); @@ -124,7 +137,7 @@ describe('useHoldRejectActions - report-level reject', () => { }); it('shows the delegate no-access modal and does nothing else when delegate access is restricted', async () => { - DelegateProvider.useDelegateNoAccessState.mockReturnValue({isDelegateAccessRestricted: true}); + getMockUseDelegateNoAccessState().mockReturnValue({isDelegateAccessRestricted: true}); await Onyx.merge(ONYXKEYS.NVP_DISMISSED_REJECT_USE_EXPLANATION, false); await waitForBatchedUpdates(); From 32779e95a289282dd15f4158379493cd8aac2950 Mon Sep 17 00:00:00 2001 From: Mukher Date: Wed, 10 Jun 2026 19:17:48 +0500 Subject: [PATCH 25/32] used existing isMultiLevelTags --- src/components/ReportActionItem/MoneyRequestView.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index f834e5eaa3c4..130f7e213c7d 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -66,6 +66,7 @@ import { getTagLists, hasDependentTags as hasDependentTagsPolicyUtils, isAttendeeTrackingEnabled, + isMultiLevelTags, isPolicyAccessible, isTaxTrackingEnabled, } from '@libs/PolicyUtils'; @@ -440,7 +441,7 @@ function MoneyRequestView({ // of navigating to edit. Categories need at least one, so they only hit this when disabled; tags can be fully // emptied, so also cover "no enabled tags remain". Scoped to single-level tag lists. const shouldShowCategoryDisabledAlert = !policy?.areCategoriesEnabled && !!category; - const shouldShowTagDisabledAlert = (!policy?.areTagsEnabled || !hasEnabledTags(policyTagLists)) && !!transactionTag && policyTagLists.length <= 1; + const shouldShowTagDisabledAlert = (!policy?.areTagsEnabled || !hasEnabledTags(policyTagLists)) && !!transactionTag && !isMultiLevelTags(policyTagList); const shouldShowBillable = (isPolicyExpenseChat || isExpenseUnreported) && (!!transactionBillable || isBillableEnabledOnPolicy(policy) || !!updatedTransaction?.billable); const isCurrentTransactionReimbursableDifferentFromPolicyDefault = policy?.defaultReimbursable !== undefined && !!(updatedTransaction?.reimbursable ?? transactionReimbursable) !== policy.defaultReimbursable; From 46f51164f5587bd0794b0fcf1766771f1419be69 Mon Sep 17 00:00:00 2001 From: Mukher Date: Wed, 10 Jun 2026 20:45:33 +0500 Subject: [PATCH 26/32] Merge origin/main into fix/86220 --- src/libs/ReportUtils.ts | 237 +++++++++++++++++++++++++++++++++------- 1 file changed, 197 insertions(+), 40 deletions(-) diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index a88ddc1e6995..ef6711155c17 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -2007,55 +2007,46 @@ function isAwaitingFirstLevelApproval(report: OnyxEntry): boolean { return isProcessingReport(report) && submitsToAccountID === report.managerID && !hasReportBeenForwardedSinceLastSubmit(report); } +type PolicyOptimisticOnyxData = OnyxData< + | typeof ONYXKEYS.COLLECTION.POLICY_CATEGORIES + | typeof ONYXKEYS.COLLECTION.POLICY + | typeof ONYXKEYS.COLLECTION.POLICY_TAGS + | typeof ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS + | typeof ONYXKEYS.COLLECTION.TRANSACTION +>; + /** - * Updates optimistic transaction violations to OnyxData for the given policy and categories onyx update. - * - * @param onyxData - The OnyxData object to push updates to - * @param policyData - The current policy Data - * @param policyUpdate - Changed policy properties, if none pass empty object - * @param categoriesUpdate - Changed categories properties, if none pass empty object - * @param tagListsUpdate - Changed tag properties, if none pass empty object + * Returns the list of policy reports (excluding invoice reports) that have transactions to evaluate. */ -function pushTransactionViolationsOnyxData( - onyxData: OnyxData< - typeof ONYXKEYS.COLLECTION.POLICY_CATEGORIES | typeof ONYXKEYS.COLLECTION.POLICY | typeof ONYXKEYS.COLLECTION.POLICY_TAGS | typeof ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS - >, - policyData: PolicyData, - policyUpdate: Partial = {}, - categoriesUpdate: Record> = {}, - tagListsUpdate: Record> = {}, -) { - const nonInvoiceReportTransactionsAndViolations = policyData.reports.reduce((acc, report) => { - // Skipping invoice reports since they should not have any category or tag violations +function getNonInvoiceReportItemsForPolicy(policyData: PolicyData): Array<{report: Report; transactionsAndViolations: ReportTransactionsAndViolations}> { + return policyData.reports.reduce>((acc, report) => { if (isInvoiceReport(report)) { return acc; } const reportTransactionsAndViolations = policyData.transactionsAndViolations[report.reportID]; if (!isEmptyObject(reportTransactionsAndViolations) && !isEmptyObject(reportTransactionsAndViolations.transactions)) { - acc.push(reportTransactionsAndViolations); + acc.push({report, transactionsAndViolations: reportTransactionsAndViolations}); } return acc; }, []); +} - if (nonInvoiceReportTransactionsAndViolations.length === 0) { - return; - } - - const updatedTagListsNames = Object.keys(tagListsUpdate); - const updatedCategoriesNames = Object.keys(categoriesUpdate); - - // If there are no updates to policy, categories or tags, return early +/** + * Merges the existing policy data with the optimistic update payloads to produce the post-update view + * used by both auto-selection and violation calculation. + */ +function getOptimisticPolicyState( + policyData: PolicyData, + policyUpdate: Partial, + categoriesUpdate: Record>, + tagListsUpdate: Record>, +) { const isPolicyUpdateEmpty = isEmptyObject(policyUpdate); - const isTagListsUpdateEmpty = updatedTagListsNames.length === 0; - const isCategoriesUpdateEmpty = updatedCategoriesNames.length === 0; - if (isPolicyUpdateEmpty && isTagListsUpdateEmpty && isCategoriesUpdateEmpty) { - return; - } + const isCategoriesUpdateEmpty = isEmptyObject(categoriesUpdate); + const isTagListsUpdateEmpty = isEmptyObject(tagListsUpdate); - // Merge the existing policy with the optimistic updates const optimisticPolicy = isPolicyUpdateEmpty ? policyData.policy : {...policyData.policy, ...policyUpdate}; - // Merge the existing categories with the optimistic updates const optimisticCategories = isCategoriesUpdateEmpty ? policyData.categories : { @@ -2072,7 +2063,6 @@ function pushTransactionViolationsOnyxData( }, {}), }; - // Merge the existing tag lists with the optimistic updates const optimisticTagLists = isTagListsUpdateEmpty ? policyData.tags : { @@ -2109,22 +2099,188 @@ function pushTransactionViolationsOnyxData( }, {}), }; - const hasDependentTags = hasDependentTagsPolicyUtils(optimisticPolicy, optimisticTagLists); + const hasDependentTagsValue = hasDependentTagsPolicyUtils(optimisticPolicy, optimisticTagLists); + + return { + optimisticPolicy, + optimisticCategories, + optimisticTagLists, + hasDependentTagsValue, + isPolicyUpdateEmpty, + isCategoriesUpdateEmpty, + isTagListsUpdateEmpty, + }; +} + +/** + * Auto-selects the sole remaining enabled category/tag for transactions on open or processing reports + * when a category/tag is being deleted or disabled. Pushes optimistic transaction merges and matching + * failure rollbacks to the provided OnyxData object, and returns the per-transaction updates so callers + * can hand them to `pushTransactionViolationsOnyxData` for violation recomputation. + * + * Call this BEFORE `pushTransactionViolationsOnyxData` so that violations are recomputed against + * the auto-selected values (suppressing the violation that would otherwise be created). + */ +function pushTransactionAutoSelectionsOnyxData( + onyxData: PolicyOptimisticOnyxData, + policyData: PolicyData, + policyUpdate: Partial = {}, + categoriesUpdate: Record> = {}, + tagListsUpdate: Record> = {}, +): Map> { + const autoSelections = new Map>(); + + // Auto-selection is only meaningful when categories or tag lists are being updated + if (isEmptyObject(categoriesUpdate) && isEmptyObject(tagListsUpdate)) { + return autoSelections; + } + + const nonInvoiceReportItems = getNonInvoiceReportItemsForPolicy(policyData); + if (nonInvoiceReportItems.length === 0) { + return autoSelections; + } + + const {optimisticCategories, optimisticTagLists, isCategoriesUpdateEmpty, isTagListsUpdateEmpty} = getOptimisticPolicyState(policyData, policyUpdate, categoriesUpdate, tagListsUpdate); + + const enabledCategoryKeys = Object.entries(optimisticCategories) + .filter(([, cat]) => cat.enabled && cat.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) + .map(([key]) => key); + const singleRemainingCategory = enabledCategoryKeys.length === 1 ? enabledCategoryKeys.at(0) : undefined; + + const tagListKeys = Object.keys(optimisticTagLists); + + // Auto-replace is scoped to single-level tags only. Web-E's removeTags() throws "NTagging not supported yet" for + // multi-level tag lists, and the auto-replace metadata we send to Auth doesn't carry tagListIndex/tagListName, + // so the backend can't know which level of the multi-level tag string should be replaced. Multi-level support + // is tracked as a separate NTag follow-up. + let singleRemainingTag: string | undefined; + if (tagListKeys.length === 1) { + const tagListName = tagListKeys.at(0) ?? ''; + const enabledTagKeys = Object.entries(optimisticTagLists[tagListName]?.tags ?? {}) + .filter(([, tag]) => tag.enabled && tag.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) + .map(([key]) => key); + singleRemainingTag = enabledTagKeys.length === 1 ? enabledTagKeys.at(0) : undefined; + } + + for (const { + report, + transactionsAndViolations: {transactions}, + } of nonInvoiceReportItems) { + if (!isOpenOrProcessingReport(report)) { + continue; + } - // Iterate through all policy reports to find transactions that need optimistic violations - for (const {transactions, violations} of nonInvoiceReportTransactionsAndViolations) { for (const transaction of Object.values(transactions)) { + const transactionUpdates: Partial = {}; + const transactionRollback: Partial = {}; + + // Category auto-select: gated to calls that include a category update (delete or disable) + // so toggling unrelated policy settings doesn't rewrite transaction category values. + if (!isCategoriesUpdateEmpty && singleRemainingCategory && transaction.category && !optimisticCategories[transaction.category]?.enabled) { + transactionUpdates.category = singleRemainingCategory; + transactionRollback.category = transaction.category; + } + + // Single-level tag auto-select: gated to calls that include a tag-list update for the same reason. + if (!isTagListsUpdateEmpty && tagListKeys.length === 1 && singleRemainingTag && transaction.tag) { + const tagListName = tagListKeys.at(0) ?? ''; + const isTagInPolicy = !!optimisticTagLists[tagListName]?.tags?.[transaction.tag]?.enabled; + if (!isTagInPolicy) { + transactionUpdates.tag = singleRemainingTag; + transactionRollback.tag = transaction.tag; + } + } + + if (Object.keys(transactionUpdates).length === 0) { + continue; + } + + autoSelections.set(transaction.transactionID, transactionUpdates); + + onyxData.optimisticData?.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, + value: transactionUpdates, + }); + onyxData.failureData?.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, + value: transactionRollback, + }); + } + } + + return autoSelections; +} + +/** + * Updates optimistic transaction violations to OnyxData for the given policy and categories onyx update. + * + * Pass the map returned by `pushTransactionAutoSelectionsOnyxData` as `transactionAutoSelections` so + * a transaction whose category/tag was just auto-replaced is evaluated against the new value (and does + * not get a stale `*OutOfPolicy` violation). + * + * @param onyxData - The OnyxData object to push updates to + * @param policyData - The current policy Data + * @param policyUpdate - Changed policy properties, if none pass empty object + * @param categoriesUpdate - Changed categories properties, if none pass empty object + * @param tagListsUpdate - Changed tag properties, if none pass empty object + * @param transactionAutoSelections - Auto-selected category/tag updates per transactionID (from `pushTransactionAutoSelectionsOnyxData`) + */ +function pushTransactionViolationsOnyxData( + onyxData: PolicyOptimisticOnyxData, + policyData: PolicyData, + policyUpdate: Partial = {}, + categoriesUpdate: Record> = {}, + tagListsUpdate: Record> = {}, + transactionAutoSelections: Map> = new Map>(), +) { + const nonInvoiceReportItems = getNonInvoiceReportItemsForPolicy(policyData); + if (nonInvoiceReportItems.length === 0) { + return; + } + + const {optimisticPolicy, optimisticCategories, optimisticTagLists, hasDependentTagsValue, isPolicyUpdateEmpty, isCategoriesUpdateEmpty, isTagListsUpdateEmpty} = getOptimisticPolicyState( + policyData, + policyUpdate, + categoriesUpdate, + tagListsUpdate, + ); + + if (isPolicyUpdateEmpty && isCategoriesUpdateEmpty && isTagListsUpdateEmpty) { + return; + } + + // taxOutOfPolicy depends only on tax tracking, tax rates and the transaction's own tax data — none of + // which a category/tag/rules toggle changes. Only let the recompute touch it when the update concerns + // tax tracking, otherwise an unrelated toggle would flash a spurious "tax no longer valid" violation. + const isTaxTrackingUpdate = policyUpdate.tax !== undefined; + + for (const { + transactionsAndViolations: {transactions, violations}, + } of nonInvoiceReportItems) { + for (const transaction of Object.values(transactions)) { + const pendingUpdate = transactionAutoSelections.get(transaction.transactionID); + const modifiedTransaction = pendingUpdate ? {...transaction, ...pendingUpdate} : transaction; + const existingViolations = violations[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transaction.transactionID}`]; const optimisticViolations = ViolationsUtils.getViolationsOnyxData({ - updatedTransaction: transaction, + updatedTransaction: modifiedTransaction, transactionViolations: existingViolations ?? [], policy: optimisticPolicy, policyTagList: optimisticTagLists, policyCategories: optimisticCategories, - hasDependentTags, + hasDependentTags: hasDependentTagsValue, isInvoiceTransaction: false, }); + // Keep the pre-toggle taxOutOfPolicy state when the update isn't about tax tracking. + const recomputedViolations = optimisticViolations.value; + if (!isTaxTrackingUpdate && Array.isArray(recomputedViolations)) { + const preservedTaxViolations = (existingViolations ?? []).filter((violation) => violation.name === CONST.VIOLATIONS.TAX_OUT_OF_POLICY); + optimisticViolations.value = [...recomputedViolations.filter((violation) => violation.name !== CONST.VIOLATIONS.TAX_OUT_OF_POLICY), ...preservedTaxViolations]; + } + if (!isEmptyObject(optimisticViolations)) { onyxData.optimisticData?.push(optimisticViolations); onyxData.failureData?.push({ @@ -13367,6 +13523,7 @@ export { getReportPersonalDetailsParticipants, isWorkspaceEligibleForReportChange, pushTransactionViolationsOnyxData, + pushTransactionAutoSelectionsOnyxData, navigateOnDeleteExpense, canRejectReportAction, hasReportBeenReopened, From 770617abc35281929e8cda70570905e483c1366e Mon Sep 17 00:00:00 2001 From: Mukher Date: Wed, 10 Jun 2026 21:35:37 +0500 Subject: [PATCH 27/32] reverted unrelated changes --- src/CONST/index.ts | 4 - .../ExpenseReportListItemRowWide.tsx | 21 ---- .../Search/SearchList/ListItem/types.ts | 18 ---- src/components/Search/SearchTableHeader.tsx | 9 -- src/languages/en.ts | 2 - src/libs/SearchUIUtils.ts | 42 -------- src/styles/utils/index.ts | 6 -- tests/unit/Search/SearchUIUtilsTest.ts | 102 ------------------ 8 files changed, 204 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index e1ae4ef303ec..c8483621b504 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -6611,8 +6611,6 @@ const CONST = { DATE: this.TABLE_COLUMNS.DATE, SUBMITTED: this.TABLE_COLUMNS.SUBMITTED, APPROVED: this.TABLE_COLUMNS.APPROVED, - FIRST_APPROVER: this.TABLE_COLUMNS.FIRST_APPROVER, - FIRST_APPROVED: this.TABLE_COLUMNS.FIRST_APPROVED, EXPORTED: this.TABLE_COLUMNS.EXPORTED, STATUS: this.TABLE_COLUMNS.STATUS, TITLE: this.TABLE_COLUMNS.TITLE, @@ -6816,8 +6814,6 @@ const CONST = { DATE: 'date', SUBMITTED: 'submitted', APPROVED: 'approved', - FIRST_APPROVER: 'firstApprover', - FIRST_APPROVED: 'firstApproved', POSTED: 'posted', EXPORTED: 'exported', MERCHANT: 'merchant', diff --git a/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/ExpenseReportListItemRowWide.tsx b/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/ExpenseReportListItemRowWide.tsx index ef0af803a62b..ab301d389d58 100644 --- a/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/ExpenseReportListItemRowWide.tsx +++ b/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/ExpenseReportListItemRowWide.tsx @@ -84,27 +84,6 @@ function ExpenseReportListItemRowWide({ /> ), - [CONST.SEARCH.TABLE_COLUMNS.FIRST_APPROVER]: ( - - {!!item.firstApproverAccountID && ( - - )} - - ), - [CONST.SEARCH.TABLE_COLUMNS.FIRST_APPROVED]: ( - - - - ), [CONST.SEARCH.TABLE_COLUMNS.EXPORTED]: ( ; shouldShowMerchant: boolean; lastExportedActionByReportID: Map; - firstApprovedActionByReportID: Map; moneyRequestReportActionsByTransactionID: Map; holdReportActionsByTransactionID: Map; allHoldReportActions: Map; @@ -1750,7 +1748,6 @@ type PreprocessingContext = { shouldShowYearCreatedReport: boolean; shouldShowYearSubmittedReport: boolean; shouldShowYearApprovedReport: boolean; - shouldShowYearFirstApprovedReport: boolean; shouldShowYearExportedReport: boolean; shouldShowAmountInWideColumn: boolean; shouldShowTaxAmountInWideColumn: boolean; @@ -1765,7 +1762,6 @@ function createPreprocessingContext(): PreprocessingContext { violations: {}, shouldShowMerchant: false, lastExportedActionByReportID: new Map(), - firstApprovedActionByReportID: new Map(), moneyRequestReportActionsByTransactionID: new Map(), holdReportActionsByTransactionID: new Map(), allHoldReportActions: new Map(), @@ -1778,7 +1774,6 @@ function createPreprocessingContext(): PreprocessingContext { shouldShowYearCreatedReport: false, shouldShowYearSubmittedReport: false, shouldShowYearApprovedReport: false, - shouldShowYearFirstApprovedReport: false, shouldShowYearExportedReport: false, shouldShowAmountInWideColumn: false, shouldShowTaxAmountInWideColumn: false, @@ -1797,10 +1792,6 @@ function processReportActionEntry(ctx: PreprocessingContext, key: string, action let latestExportTime = -Infinity; let latestExportAction: OnyxTypes.ReportAction | undefined; - // Earliest approval action; its actor is the report's first approver. - let firstApprovalTime = Infinity; - let firstApprovalAction: OnyxTypes.ReportAction | undefined; - for (const action of Object.values(actions)) { if (action.actionName === CONST.REPORT.ACTIONS.TYPE.EXPORTED_TO_CSV || action.actionName === CONST.REPORT.ACTIONS.TYPE.EXPORTED_TO_INTEGRATION) { const currentTime = new Date(action.created).getTime(); @@ -1810,14 +1801,6 @@ function processReportActionEntry(ctx: PreprocessingContext, key: string, action } } - if (action.actionName === CONST.REPORT.ACTIONS.TYPE.APPROVED || action.actionName === CONST.REPORT.ACTIONS.TYPE.FORWARDED) { - const currentTime = new Date(action.created).getTime(); - if (currentTime < firstApprovalTime) { - firstApprovalTime = currentTime; - firstApprovalAction = action; - } - } - if (isMoneyRequestAction(action)) { const originalMessage = getOriginalMessage(action); const transactionID = originalMessage?.IOUTransactionID; @@ -1836,13 +1819,6 @@ function processReportActionEntry(ctx: PreprocessingContext, key: string, action if (latestExportAction) { ctx.lastExportedActionByReportID.set(reportID, latestExportAction); } - - if (firstApprovalAction) { - ctx.firstApprovedActionByReportID.set(reportID, firstApprovalAction); - if (!ctx.shouldShowYearFirstApprovedReport && DateUtils.doesDateBelongToAPastYear(firstApprovalAction.created)) { - ctx.shouldShowYearFirstApprovedReport = true; - } - } } /** @@ -2780,7 +2756,6 @@ function getReportSections({ violations: allViolations, shouldShowMerchant, lastExportedActionByReportID, - firstApprovedActionByReportID, moneyRequestReportActionsByTransactionID, holdReportActionsByTransactionID, transactionsByReportID, @@ -2792,7 +2767,6 @@ function getReportSections({ shouldShowYearCreatedReport, shouldShowYearSubmittedReport, shouldShowYearApprovedReport, - shouldShowYearFirstApprovedReport, shouldShowYearExportedReport, shouldShowAmountInWideColumn, shouldShowTaxAmountInWideColumn, @@ -2848,15 +2822,8 @@ function getReportSections({ emptyPersonalDetails; const toDetails = !shouldShowBlankTo && reportItem.managerID ? mergedPersonalDetails?.[reportItem.managerID] : emptyPersonalDetails; - // First approver/approved come from the earliest APPROVED/FORWARDED report action; blank when the report has no approval. - const firstApprovedAction = firstApprovedActionByReportID.get(reportItem.reportID); - const firstApproverAccountID = firstApprovedAction?.actorAccountID; - const firstApproverDetails = firstApproverAccountID ? (mergedPersonalDetails?.[firstApproverAccountID] ?? emptyPersonalDetails) : emptyPersonalDetails; - const firstApproved = firstApprovedAction?.created ?? ''; - const formattedFrom = formatPhoneNumber(getDisplayNameOrDefault(fromDetails)); const formattedTo = !shouldShowBlankTo ? formatPhoneNumber(getDisplayNameOrDefault(toDetails)) : ''; - const formattedFirstApprover = firstApproverAccountID ? formatPhoneNumber(getDisplayNameOrDefault(firstApproverDetails)) : ''; const formattedStatus = getReportStatusTranslation({stateNum: reportItem.stateNum, statusNum: reportItem.statusNum, translate}); const policyFromKey = getPolicyFromKey(data, reportItem); @@ -2897,10 +2864,6 @@ function getReportSections({ from: (fromDetails ?? emptyPersonalDetails) as OnyxTypes.PersonalDetails, to: (toDetails ?? emptyPersonalDetails) as OnyxTypes.PersonalDetails, exported: lastExportedActionByReportID.get(reportItem.reportID)?.created ?? '', - firstApproved, - firstApprover: (firstApproverDetails ?? emptyPersonalDetails) as OnyxTypes.PersonalDetails, - firstApproverAccountID, - formattedFirstApprover, formattedFrom, formattedTo, formattedStatus, @@ -2910,7 +2873,6 @@ function getReportSections({ shouldShowYear: shouldShowYearCreatedReport, shouldShowYearSubmitted: shouldShowYearSubmittedReport, shouldShowYearApproved: shouldShowYearApprovedReport, - shouldShowYearFirstApproved: shouldShowYearFirstApprovedReport, shouldShowYearExported: shouldShowYearExportedReport, hasVisibleViolations: hasVisibleViolationsForReport, isRejectedReport, @@ -4295,10 +4257,6 @@ function getSearchColumnTranslationKey(column: SearchSortBy): TranslationPaths { return 'common.submitted'; case CONST.SEARCH.TABLE_COLUMNS.APPROVED: return 'search.filters.approved'; - case CONST.SEARCH.TABLE_COLUMNS.FIRST_APPROVER: - return 'search.filters.firstApprover'; - case CONST.SEARCH.TABLE_COLUMNS.FIRST_APPROVED: - return 'search.filters.firstApproved'; case CONST.SEARCH.TABLE_COLUMNS.POSTED: return 'search.filters.posted'; case CONST.SEARCH.TABLE_COLUMNS.EXPORTED: diff --git a/src/styles/utils/index.ts b/src/styles/utils/index.ts index 0967c8c06552..466639a26892 100644 --- a/src/styles/utils/index.ts +++ b/src/styles/utils/index.ts @@ -58,7 +58,6 @@ type GetReportTableColumnStylesParams = { isTaxAmountColumnWide?: boolean; isSubmittedColumnWide?: boolean; isApprovedColumnWide?: boolean; - isFirstApprovedColumnWide?: boolean; isPostedColumnWide?: boolean; isExportedColumnWide?: boolean; shouldRemoveTotalColumnFlex?: boolean; @@ -1871,7 +1870,6 @@ const createStyleUtils = (theme: ThemeColors, styles: ThemeStyles) => ({ const { isSubmittedColumnWide, isApprovedColumnWide, - isFirstApprovedColumnWide, isPostedColumnWide, isExportedColumnWide, isDateColumnWide, @@ -1905,9 +1903,6 @@ const createStyleUtils = (theme: ThemeColors, styles: ThemeStyles) => ({ case CONST.SEARCH.TABLE_COLUMNS.APPROVED: columnWidth = {...getWidthStyle(isApprovedColumnWide ? variables.w92 : variables.w72)}; break; - case CONST.SEARCH.TABLE_COLUMNS.FIRST_APPROVED: - columnWidth = {...getWidthStyle(isFirstApprovedColumnWide ? variables.w92 : variables.w72)}; - break; case CONST.SEARCH.TABLE_COLUMNS.POSTED: columnWidth = {...getWidthStyle(isPostedColumnWide ? variables.w92 : variables.w72)}; break; @@ -1986,7 +1981,6 @@ const createStyleUtils = (theme: ThemeColors, styles: ThemeStyles) => ({ case CONST.SEARCH.TABLE_COLUMNS.MERCHANT: case CONST.SEARCH.TABLE_COLUMNS.FROM: case CONST.SEARCH.TABLE_COLUMNS.TO: - case CONST.SEARCH.TABLE_COLUMNS.FIRST_APPROVER: case CONST.SEARCH.TABLE_COLUMNS.ASSIGNEE: case CONST.SEARCH.TABLE_COLUMNS.TITLE: case CONST.SEARCH.TABLE_COLUMNS.DESCRIPTION: diff --git a/tests/unit/Search/SearchUIUtilsTest.ts b/tests/unit/Search/SearchUIUtilsTest.ts index fb522ef13d7f..dd0028022f2a 100644 --- a/tests/unit/Search/SearchUIUtilsTest.ts +++ b/tests/unit/Search/SearchUIUtilsTest.ts @@ -1143,11 +1143,6 @@ const transactionReportGroupListItems = [ shouldShowYearSubmitted: true, shouldShowYearApproved: false, shouldShowYearExported: false, - shouldShowYearFirstApproved: false, - firstApproved: '', - firstApprover: emptyPersonalDetails, - firstApproverAccountID: undefined, - formattedFirstApprover: '', stateNum: 0, statusNum: 0, to: emptyPersonalDetails, @@ -1268,11 +1263,6 @@ const transactionReportGroupListItems = [ shouldShowYearSubmitted: true, shouldShowYearApproved: false, shouldShowYearExported: false, - shouldShowYearFirstApproved: false, - firstApproved: '', - firstApprover: emptyPersonalDetails, - firstApproverAccountID: undefined, - formattedFirstApprover: '', stateNum: 1, statusNum: 1, to: { @@ -1399,11 +1389,6 @@ const transactionReportGroupListItems = [ shouldShowYearSubmitted: true, shouldShowYearApproved: false, shouldShowYearExported: false, - shouldShowYearFirstApproved: false, - firstApproved: '', - firstApprover: emptyPersonalDetails, - firstApproverAccountID: undefined, - formattedFirstApprover: '', stateNum: 1, statusNum: 1, total: 4400, @@ -1611,11 +1596,6 @@ const transactionReportGroupListItems = [ shouldShowYearSubmitted: true, shouldShowYearApproved: false, shouldShowYearExported: false, - shouldShowYearFirstApproved: false, - firstApproved: '', - firstApprover: emptyPersonalDetails, - firstApproverAccountID: undefined, - formattedFirstApprover: '', stateNum: 0, statusNum: 0, to: emptyPersonalDetails, @@ -5889,80 +5869,6 @@ describe('SearchUIUtils', () => { const item = sections.find((s) => s.keyForList === rptFilterReportID); expect(item?.pendingAction).toBeUndefined(); }); - - it('should populate firstApprover/firstApproved from the report APPROVED action', () => { - const approvedAt = '2024-12-22 09:30:00'; - const data = makeReportFilterTestData( - {type: CONST.REPORT.TYPE.EXPENSE}, - {}, - { - [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${rptFilterReportID}`]: { - 'approved-1': { - reportActionID: 'approved-1', - actionName: CONST.REPORT.ACTIONS.TYPE.APPROVED, - actorAccountID: approverAccountID, - created: approvedAt, - }, - }, - }, - ); - const [sections] = callGetReportSections(data); - const item = sections.find((s) => s.keyForList === rptFilterReportID); - expect(item?.firstApproved).toBe(approvedAt); - expect(item?.firstApproverAccountID).toBe(approverAccountID); - expect(item?.formattedFirstApprover).toBeTruthy(); - }); - - it('should use the earliest approval action (FORWARDED before APPROVED) for the first approver', () => { - const forwardedAt = '2024-12-20 08:00:00'; - const approvedAt = '2024-12-22 09:30:00'; - const data = makeReportFilterTestData( - {type: CONST.REPORT.TYPE.EXPENSE}, - {}, - { - [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${rptFilterReportID}`]: { - 'approved-1': { - reportActionID: 'approved-1', - actionName: CONST.REPORT.ACTIONS.TYPE.APPROVED, - actorAccountID: adminAccountID, - created: approvedAt, - }, - 'forwarded-1': { - reportActionID: 'forwarded-1', - actionName: CONST.REPORT.ACTIONS.TYPE.FORWARDED, - actorAccountID: approverAccountID, - created: forwardedAt, - }, - }, - }, - ); - const [sections] = callGetReportSections(data); - const item = sections.find((s) => s.keyForList === rptFilterReportID); - expect(item?.firstApproved).toBe(forwardedAt); - expect(item?.firstApproverAccountID).toBe(approverAccountID); - }); - - it('should leave firstApprover/firstApproved blank when there is no approval action (no FE fallback to managerID/approved)', () => { - const data = makeReportFilterTestData( - {type: CONST.REPORT.TYPE.EXPENSE, approved: '2024-12-22 09:30:00', managerID: adminAccountID}, - {}, - { - [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${rptFilterReportID}`]: { - 'comment-1': { - reportActionID: 'comment-1', - actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, - actorAccountID: adminAccountID, - created: '2024-12-22 09:30:00', - }, - }, - }, - ); - const [sections] = callGetReportSections(data); - const item = sections.find((s) => s.keyForList === rptFilterReportID); - expect(item?.firstApproved).toBe(''); - expect(item?.firstApproverAccountID).toBeUndefined(); - expect(item?.formattedFirstApprover).toBe(''); - }); }); }); @@ -8346,14 +8252,6 @@ describe('SearchUIUtils', () => { ]); }); - test('Should include First approver/First approved columns when selected for expense reports', () => { - const visibleColumns = [CONST.SEARCH.TABLE_COLUMNS.DATE, CONST.SEARCH.TABLE_COLUMNS.FIRST_APPROVER, CONST.SEARCH.TABLE_COLUMNS.FIRST_APPROVED, CONST.SEARCH.TABLE_COLUMNS.TOTAL]; - - const result = SearchUIUtils.getColumnsToShow({currentAccountID: 1, data: [], visibleColumns, type: CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT}); - expect(result).toContain(CONST.SEARCH.TABLE_COLUMNS.FIRST_APPROVER); - expect(result).toContain(CONST.SEARCH.TABLE_COLUMNS.FIRST_APPROVED); - }); - test('Should include Avatar when user keeps it in visible columns for expense reports', () => { const visibleColumns = [CONST.SEARCH.TABLE_COLUMNS.AVATAR, CONST.SEARCH.TABLE_COLUMNS.DATE, CONST.SEARCH.TABLE_COLUMNS.TOTAL]; From a21dd648873b2934245c80e784260645d299c721 Mon Sep 17 00:00:00 2001 From: Mukher Date: Wed, 10 Jun 2026 22:06:19 +0500 Subject: [PATCH 28/32] prettier fix --- src/components/ReportActionItem/MoneyRequestView.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index 67cf412859cd..14e1f61adba3 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -68,8 +68,8 @@ import { getTagLists, hasDependentTags as hasDependentTagsPolicyUtils, isAttendeeTrackingEnabled, - isMultiLevelTags, isGroupPolicyByType, + isMultiLevelTags, isPolicyAccessible, isTaxTrackingEnabled, } from '@libs/PolicyUtils'; From 340bb2a20d0ac94d7799f8d9b3cc69a222384350 Mon Sep 17 00:00:00 2001 From: Mukher Date: Thu, 11 Jun 2026 23:18:03 +0500 Subject: [PATCH 29/32] reverted unrelated language changes and added tests for auto-selection --- src/languages/de.ts | 33 +++-- src/languages/es.ts | 33 +++-- src/languages/fr.ts | 33 +++-- src/languages/it.ts | 33 +++-- src/languages/ja.ts | 33 +++-- src/languages/nl.ts | 33 +++-- src/languages/pl.ts | 33 +++-- src/languages/pt-BR.ts | 33 +++-- src/languages/zh-hans.ts | 33 +++-- tests/unit/ReportUtilsTest.ts | 219 ++++++++++++++++++++++++++++++++++ 10 files changed, 354 insertions(+), 162 deletions(-) diff --git a/src/languages/de.ts b/src/languages/de.ts index ed4481b38716..8d2296cb8dce 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -4148,31 +4148,28 @@ ${amount} für ${merchant} – ${date}`, verificationFailed: 'Die Verifizierung ist fehlgeschlagen, daher benötigen wir zusätzliche Dokumente, um dich und dein Unternehmen zu überprüfen', taxIDVerification: 'Steuer-ID-Verifizierung', taxIDVerificationDescription: dedent(` - Bitte lade eine der folgenden Dateien hoch: - • IRS TIN/EIN-Zuweisungsschreiben - • IRS TIN/EIN-Antragsbestätigung (enthält normalerweise „Congratulations! The EIN has been successfully assigned“) - • IRS-Steuerbefreiungsschreiben mit Firmenname und EIN - `), + Bitte lade eine der folgenden Dateien hoch: + • IRS TIN/EIN-Zuweisungsschreiben + • IRS TIN/EIN-Antragsbestätigung (enthält normalerweise „Congratulations! The EIN has been successfully assigned“) + • IRS-Steuerbefreiungsschreiben mit Firmenname und EIN`), nameChangeDocument: 'Dokument zur Namensänderung', nameChangeDocumentDescription: 'Wenn sich der Name deines Unternehmens seit der Beantragung der TIN/EIN geändert hat, benötigen wir dieses Dokument zur Verifizierung der angegebenen Steuer-ID', companyAddressVerification: 'Verifizierung der Unternehmensadresse', companyAddressVerificationDescription: dedent(` - Bitte lade eine der folgenden Dateien hoch: - • Aktuelle Strom-, Wasser- oder Gasrechnung mit Firmenname und Adresse - • Kontoauszug mit Firmenname und Adresse - • Aktueller Miet- oder Leasingvertrag inkl. Unterschriftsseite mit Firmenname und aktueller Adresse - • Versicherungsnachweis mit Firmenname und Adresse - • TIN-Zuweisungsdokument mit Firmenname und Adresse - `), + Bitte lade eine der folgenden Dateien hoch: + • Aktuelle Strom-, Wasser- oder Gasrechnung mit Firmenname und Adresse + • Kontoauszug mit Firmenname und Adresse + • Aktueller Miet- oder Leasingvertrag inkl. Unterschriftsseite mit Firmenname und aktueller Adresse + • Versicherungsnachweis mit Firmenname und Adresse + • TIN-Zuweisungsdokument mit Firmenname und Adresse`), userAddressVerification: 'Adressverifizierung', userAddressVerificationDescription: dedent(` - Bitte lade eine der folgenden Dateien hoch: - • Wählerregistrierungskarte - • Führerschein - • Kontoauszug - • Versorgungsrechnung - `), + Bitte lade eine der folgenden Dateien hoch: + • Wählerregistrierungskarte + • Führerschein + • Kontoauszug + • Versorgungsrechnung`), userDOBVerification: 'Geburtsdatumsverifizierung', userDOBVerificationDescription: 'Bitte lade einen in den USA ausgestellten Ausweis hoch', finishViaChat: 'Über Chat abschließen', diff --git a/src/languages/es.ts b/src/languages/es.ts index 94bee8af2c85..b5754b5dcbba 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -4030,30 +4030,27 @@ ${amount} para ${merchant} - ${date}`, verificationFailed: 'La verificación falló, por lo que necesitaremos documentos adicionales para verificarte a ti y a tu empresa', taxIDVerification: 'Verificación del ID fiscal', taxIDVerificationDescription: dedent(` - Por favor, sube uno de los siguientes archivos: - • Carta de asignación de TIN/EIN del IRS - • Confirmación de solicitud de TIN/EIN del IRS (normalmente indica "Congratulations! The EIN has been successfully assigned") - • Carta de exención fiscal del IRS que incluya el nombre de la empresa y el EIN - `), + Por favor, sube uno de los siguientes archivos: + • Carta de asignación de TIN/EIN del IRS + • Confirmación de solicitud de TIN/EIN del IRS (normalmente indica "Congratulations! The EIN has been successfully assigned") + • Carta de exención fiscal del IRS que incluya el nombre de la empresa y el EIN`), nameChangeDocument: 'Documento de cambio de nombre', nameChangeDocumentDescription: 'Si el nombre de tu empresa cambió desde que solicitaste el TIN/EIN, necesitamos este documento para verificar el número de ID fiscal proporcionado', companyAddressVerification: 'Verificación de la dirección de la empresa', companyAddressVerificationDescription: dedent(` - Por favor, sube uno de los siguientes archivos: - • Factura reciente de servicios públicos con nombre y dirección de la empresa - • Estado de cuenta bancario con nombre y dirección de la empresa - • Contrato de arrendamiento vigente con página de firmas que muestre el nombre y la dirección actual de la empresa - • Estado de seguro con nombre y dirección de la empresa - • Documento de asignación de TIN con nombre y dirección de la empresa - `), + Por favor, sube uno de los siguientes archivos: + • Factura reciente de servicios públicos con nombre y dirección de la empresa + • Estado de cuenta bancario con nombre y dirección de la empresa + • Contrato de arrendamiento vigente con página de firmas que muestre el nombre y la dirección actual de la empresa + • Estado de seguro con nombre y dirección de la empresa + • Documento de asignación de TIN con nombre y dirección de la empresa`), userAddressVerification: 'Verificación de dirección', userAddressVerificationDescription: dedent(` - Por favor, sube uno de los siguientes archivos: - • Tarjeta de registro de votante - • Licencia de conducir - • Estado de cuenta bancario - • Factura de servicios públicos - `), + Por favor, sube uno de los siguientes archivos: + • Tarjeta de registro de votante + • Licencia de conducir + • Estado de cuenta bancario + • Factura de servicios públicos`), userDOBVerification: 'Verificación de fecha de nacimiento', userDOBVerificationDescription: 'Por favor, sube una identificación emitida en EE. UU.', finishViaChat: 'Finalizar por chat', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index d97870267d94..43cd6f82dc76 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -4161,31 +4161,28 @@ ${amount} pour ${merchant} - ${date}`, verificationFailed: 'La vérification a échoué, nous aurons donc besoin de documents supplémentaires pour te vérifier ainsi que ton entreprise', taxIDVerification: 'Vérification de l’identifiant fiscal', taxIDVerificationDescription: dedent(` - Veuillez téléverser l’un des fichiers suivants : - • Lettre d’attribution TIN/EIN de l’IRS - • Confirmation de demande TIN/EIN de l’IRS (indique généralement « Congratulations! The EIN has been successfully assigned ») - • Lettre d’exonération fiscale de l’IRS indiquant le nom de l’entreprise et l’EIN - `), + Veuillez téléverser l’un des fichiers suivants : + • Lettre d’attribution TIN/EIN de l’IRS + • Confirmation de demande TIN/EIN de l’IRS (indique généralement « Congratulations! The EIN has been successfully assigned ») + • Lettre d’exonération fiscale de l’IRS indiquant le nom de l’entreprise et l’EIN`), nameChangeDocument: 'Document de changement de nom', nameChangeDocumentDescription: 'Si le nom de ton entreprise a changé depuis la demande du TIN/EIN, ce document est nécessaire pour vérifier le numéro d’identification fiscale fourni', companyAddressVerification: 'Vérification de l’adresse de l’entreprise', companyAddressVerificationDescription: dedent(` - Veuillez téléverser l’un des fichiers suivants : - • Facture récente de services publics indiquant le nom et l’adresse de l’entreprise - • Relevé bancaire indiquant le nom et l’adresse de l’entreprise - • Contrat de location en cours incluant la page de signature avec le nom et l’adresse actuelle de l’entreprise - • Attestation d’assurance indiquant le nom et l’adresse de l’entreprise - • Document d’attribution TIN indiquant le nom et l’adresse de l’entreprise - `), + Veuillez téléverser l’un des fichiers suivants : + • Facture récente de services publics indiquant le nom et l’adresse de l’entreprise + • Relevé bancaire indiquant le nom et l’adresse de l’entreprise + • Contrat de location en cours incluant la page de signature avec le nom et l’adresse actuelle de l’entreprise + • Attestation d’assurance indiquant le nom et l’adresse de l’entreprise + • Document d’attribution TIN indiquant le nom et l’adresse de l’entreprise`), userAddressVerification: 'Vérification de l’adresse', userAddressVerificationDescription: dedent(` - Veuillez téléverser l’un des fichiers suivants : - • Carte d’inscription électorale - • Permis de conduire - • Relevé bancaire - • Facture de services publics - `), + Veuillez téléverser l’un des fichiers suivants : + • Carte d’inscription électorale + • Permis de conduire + • Relevé bancaire + • Facture de services publics`), userDOBVerification: 'Vérification de la date de naissance', userDOBVerificationDescription: 'Veuillez téléverser une pièce d’identité délivrée aux États-Unis', finishViaChat: 'Finaliser via le chat', diff --git a/src/languages/it.ts b/src/languages/it.ts index 6ea1f3452485..eb201a595409 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -4136,31 +4136,28 @@ ${amount} per ${merchant} - ${date}`, verificationFailed: 'La verifica non è riuscita, quindi avremo bisogno di documenti aggiuntivi per verificare te e la tua azienda', taxIDVerification: 'Verifica dell’ID fiscale', taxIDVerificationDescription: dedent(` - Carica uno dei seguenti file: - • Lettera di assegnazione TIN/EIN dell’IRS - • Conferma della richiesta TIN/EIN dell’IRS (di solito indica "Congratulations! The EIN has been successfully assigned") - • Lettera di esenzione fiscale dell’IRS con nome dell’azienda ed EIN - `), + Carica uno dei seguenti file: + • Lettera di assegnazione TIN/EIN dell’IRS + • Conferma della richiesta TIN/EIN dell’IRS (di solito indica "Congratulations! The EIN has been successfully assigned") + • Lettera di esenzione fiscale dell’IRS con nome dell’azienda ed EIN`), nameChangeDocument: 'Documento di cambio nome', nameChangeDocumentDescription: 'Se il nome della tua azienda è cambiato dopo la richiesta del TIN/EIN, abbiamo bisogno di questo documento per verificare il numero di ID fiscale fornito', companyAddressVerification: 'Verifica dell’indirizzo aziendale', companyAddressVerificationDescription: dedent(` - Carica uno dei seguenti file: - • Bolletta recente con nome e indirizzo dell’azienda - • Estratto conto bancario con nome e indirizzo dell’azienda - • Contratto di locazione attuale con pagina firme che mostri nome e indirizzo attuale dell’azienda - • Documento assicurativo con nome e indirizzo dell’azienda - • Documento di assegnazione TIN con nome e indirizzo dell’azienda - `), + Carica uno dei seguenti file: + • Bolletta recente con nome e indirizzo dell’azienda + • Estratto conto bancario con nome e indirizzo dell’azienda + • Contratto di locazione attuale con pagina firme che mostri nome e indirizzo attuale dell’azienda + • Documento assicurativo con nome e indirizzo dell’azienda + • Documento di assegnazione TIN con nome e indirizzo dell’azienda`), userAddressVerification: 'Verifica dell’indirizzo', userAddressVerificationDescription: dedent(` - Carica uno dei seguenti file: - • Tessera elettorale - • Patente di guida - • Estratto conto bancario - • Bolletta - `), + Carica uno dei seguenti file: + • Tessera elettorale + • Patente di guida + • Estratto conto bancario + • Bolletta`), userDOBVerification: 'Verifica della data di nascita', userDOBVerificationDescription: 'Carica un documento di identità rilasciato negli Stati Uniti', finishViaChat: 'Completa via chat', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index a041b09ec463..1cda13d0e285 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -4108,30 +4108,27 @@ ${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'あなたの' verificationFailed: '確認に失敗したため、追加の書類で本人および事業の確認が必要です', taxIDVerification: '納税者番号の確認', taxIDVerificationDescription: dedent(` - 以下のいずれかの書類をアップロードしてください: - • IRS TIN/EIN 割当通知書 - • IRS TIN/EIN 申請確認書(通常「Congratulations! The EIN has been successfully assigned」と記載) - • 会社名と EIN が記載された IRS の免税通知書 - `), + 以下のいずれかの書類をアップロードしてください: + • IRS TIN/EIN 割当通知書 + • IRS TIN/EIN 申請確認書(通常「Congratulations! The EIN has been successfully assigned」と記載) + • 会社名と EIN が記載された IRS の免税通知書`), nameChangeDocument: '名称変更書類', nameChangeDocumentDescription: 'TIN/EIN 申請後に会社名が変更された場合、提供された納税者番号を確認するためにこの書類が必要です', companyAddressVerification: '会社住所の確認', companyAddressVerificationDescription: dedent(` - 以下のいずれかの書類をアップロードしてください: - • 会社名と住所が記載された最近の公共料金請求書 - • 会社名と住所が記載された銀行取引明細書 - • 署名ページを含む現行の賃貸契約書(会社名と現住所が記載されたもの) - • 会社名と住所が記載された保険証書 - • 会社名と住所が記載された TIN 割当書類 - `), + 以下のいずれかの書類をアップロードしてください: + • 会社名と住所が記載された最近の公共料金請求書 + • 会社名と住所が記載された銀行取引明細書 + • 署名ページを含む現行の賃貸契約書(会社名と現住所が記載されたもの) + • 会社名と住所が記載された保険証書 + • 会社名と住所が記載された TIN 割当書類`), userAddressVerification: '住所確認', userAddressVerificationDescription: dedent(` - 以下のいずれかの書類をアップロードしてください: - • 有権者登録カード - • 運転免許証 - • 銀行取引明細書 - • 公共料金請求書 - `), + 以下のいずれかの書類をアップロードしてください: + • 有権者登録カード + • 運転免許証 + • 銀行取引明細書 + • 公共料金請求書`), userDOBVerification: '生年月日の確認', userDOBVerificationDescription: '米国発行の身分証明書をアップロードしてください', finishViaChat: 'チャットで完了', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index c9d69d657ec1..d29f68002ffe 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -4132,30 +4132,27 @@ ${amount} voor ${merchant} - ${date}`, verificationFailed: 'De verificatie is mislukt, daarom hebben we extra documenten nodig om jou en je bedrijf te verifiëren', taxIDVerification: 'Belastingnummerverificatie', taxIDVerificationDescription: dedent(` - Upload een van de volgende bestanden: - • IRS TIN/EIN-toewijzingsbrief - • IRS TIN/EIN-aanvraagbevestiging (bevat meestal "Congratulations! The EIN has been successfully assigned") - • IRS-belastingvrijstellingsbrief met bedrijfsnaam en EIN - `), + Upload een van de volgende bestanden: + • IRS TIN/EIN-toewijzingsbrief + • IRS TIN/EIN-aanvraagbevestiging (bevat meestal "Congratulations! The EIN has been successfully assigned") + • IRS-belastingvrijstellingsbrief met bedrijfsnaam en EIN`), nameChangeDocument: 'Document naamswijziging', nameChangeDocumentDescription: 'Als de naam van je bedrijf is gewijzigd sinds de TIN/EIN-aanvraag, hebben we dit document nodig om het opgegeven belastingnummer te verifiëren', companyAddressVerification: 'Verificatie van bedrijfsadres', companyAddressVerificationDescription: dedent(` - Upload een van de volgende bestanden: - • Recente energierekening met bedrijfsnaam en adres - • Bankafschrift met bedrijfsnaam en adres - • Huidige huur- of leaseovereenkomst inclusief ondertekeningspagina met bedrijfsnaam en huidig adres - • Verzekeringsverklaring met bedrijfsnaam en adres - • TIN-toewijzingsdocument met bedrijfsnaam en adres - `), + Upload een van de volgende bestanden: + • Recente energierekening met bedrijfsnaam en adres + • Bankafschrift met bedrijfsnaam en adres + • Huidige huur- of leaseovereenkomst inclusief ondertekeningspagina met bedrijfsnaam en huidig adres + • Verzekeringsverklaring met bedrijfsnaam en adres + • TIN-toewijzingsdocument met bedrijfsnaam en adres`), userAddressVerification: 'Adresverificatie', userAddressVerificationDescription: dedent(` - Upload een van de volgende bestanden: - • Kiezersregistratiekaart - • Rijbewijs - • Bankafschrift - • Energierekening - `), + Upload een van de volgende bestanden: + • Kiezersregistratiekaart + • Rijbewijs + • Bankafschrift + • Energierekening`), userDOBVerification: 'Verificatie van geboortedatum', userDOBVerificationDescription: 'Upload een in de VS uitgegeven identiteitsbewijs', finishViaChat: 'Afronden via chat', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 8a3add5572a6..84c822615d83 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -4124,30 +4124,27 @@ ${amount} dla ${merchant} - ${date}`, verificationFailed: 'Weryfikacja nie powiodła się, dlatego potrzebujemy dodatkowych dokumentów do potwierdzenia Twojej tożsamości i firmy', taxIDVerification: 'Weryfikacja numeru podatkowego', taxIDVerificationDescription: dedent(` - Prześlij jeden z poniższych plików: - • List przydziału TIN/EIN z IRS - • Potwierdzenie wniosku TIN/EIN z IRS (zwykle zawiera „Congratulations! The EIN has been successfully assigned”) - • Pismo o zwolnieniu podatkowym z IRS zawierające nazwę firmy i EIN - `), + Prześlij jeden z poniższych plików: + • List przydziału TIN/EIN z IRS + • Potwierdzenie wniosku TIN/EIN z IRS (zwykle zawiera „Congratulations! The EIN has been successfully assigned”) + • Pismo o zwolnieniu podatkowym z IRS zawierające nazwę firmy i EIN`), nameChangeDocument: 'Dokument zmiany nazwy', nameChangeDocumentDescription: 'Jeśli nazwa firmy zmieniła się od momentu złożenia wniosku o TIN/EIN, dokument ten jest wymagany do weryfikacji podanego numeru podatkowego', companyAddressVerification: 'Weryfikacja adresu firmy', companyAddressVerificationDescription: dedent(` - Prześlij jeden z poniższych plików: - • Aktualny rachunek za media z nazwą i adresem firmy - • Wyciąg bankowy z nazwą i adresem firmy - • Aktualna umowa najmu z podpisaną stroną zawierającą nazwę i adres firmy - • Dokument ubezpieczeniowy z nazwą i adresem firmy - • Dokument przydziału TIN z nazwą i adresem firmy - `), + Prześlij jeden z poniższych plików: + • Aktualny rachunek za media z nazwą i adresem firmy + • Wyciąg bankowy z nazwą i adresem firmy + • Aktualna umowa najmu z podpisaną stroną zawierającą nazwę i adres firmy + • Dokument ubezpieczeniowy z nazwą i adresem firmy + • Dokument przydziału TIN z nazwą i adresem firmy`), userAddressVerification: 'Weryfikacja adresu', userAddressVerificationDescription: dedent(` - Prześlij jeden z poniższych plików: - • Karta rejestracji wyborcy - • Prawo jazdy - • Wyciąg bankowy - • Rachunek za media - `), + Prześlij jeden z poniższych plików: + • Karta rejestracji wyborcy + • Prawo jazdy + • Wyciąg bankowy + • Rachunek za media`), userDOBVerification: 'Weryfikacja daty urodzenia', userDOBVerificationDescription: 'Prześlij dokument tożsamości wydany w USA', finishViaChat: 'Zakończ przez czat', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index ea722737f396..ad567dbef58a 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -4120,30 +4120,27 @@ ${amount} para ${merchant} - ${date}`, verificationFailed: 'A verificação falhou, então precisaremos de documentos adicionais para verificar você e sua empresa', taxIDVerification: 'Verificação de ID fiscal', taxIDVerificationDescription: dedent(` - Envie um dos seguintes arquivos: - • Carta de atribuição de TIN/EIN do IRS - • Confirmação de solicitação de TIN/EIN do IRS (normalmente contém "Congratulations! The EIN has been successfully assigned") - • Carta de isenção fiscal do IRS com o nome da empresa e o EIN - `), + Envie um dos seguintes arquivos: + • Carta de atribuição de TIN/EIN do IRS + • Confirmação de solicitação de TIN/EIN do IRS (normalmente contém "Congratulations! The EIN has been successfully assigned") + • Carta de isenção fiscal do IRS com o nome da empresa e o EIN`), nameChangeDocument: 'Documento de alteração de nome', nameChangeDocumentDescription: 'Se o nome da sua empresa mudou desde a solicitação do TIN/EIN, precisamos deste documento para verificar o número de identificação fiscal informado', companyAddressVerification: 'Verificação de endereço da empresa', companyAddressVerificationDescription: dedent(` - Envie um dos seguintes arquivos: - • Conta recente de serviços públicos com nome e endereço da empresa - • Extrato bancário com nome e endereço da empresa - • Contrato de locação atual incluindo a página de assinatura com nome e endereço atual da empresa - • Apólice ou declaração de seguro com nome e endereço da empresa - • Documento de atribuição de TIN com nome e endereço da empresa - `), + Envie um dos seguintes arquivos: + • Conta recente de serviços públicos com nome e endereço da empresa + • Extrato bancário com nome e endereço da empresa + • Contrato de locação atual incluindo a página de assinatura com nome e endereço atual da empresa + • Apólice ou declaração de seguro com nome e endereço da empresa + • Documento de atribuição de TIN com nome e endereço da empresa`), userAddressVerification: 'Verificação de endereço', userAddressVerificationDescription: dedent(` - Envie um dos seguintes arquivos: - • Título de eleitor - • Carteira de motorista - • Extrato bancário - • Conta de serviços públicos - `), + Envie um dos seguintes arquivos: + • Título de eleitor + • Carteira de motorista + • Extrato bancário + • Conta de serviços públicos`), userDOBVerification: 'Verificação de data de nascimento', userDOBVerificationDescription: 'Envie um documento de identidade emitido nos EUA', finishViaChat: 'Finalizar pelo chat', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index c01fdd0dadc6..4d19a5340785 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -4028,30 +4028,27 @@ ${amount},商户:${merchant} - 日期:${date}`, verificationFailed: '验证失败,因此我们需要额外的文件来验证你及你的企业', taxIDVerification: '税务识别号验证', taxIDVerificationDescription: dedent(` - 请上传以下任一文件: - • IRS TIN/EIN 分配函 - • IRS TIN/EIN 申请确认函(通常包含“Congratulations! The EIN has been successfully assigned”) - • 显示公司名称和 EIN 的 IRS 免税函 - `), + 请上传以下任一文件: + • IRS TIN/EIN 分配函 + • IRS TIN/EIN 申请确认函(通常包含“Congratulations! The EIN has been successfully assigned”) + • 显示公司名称和 EIN 的 IRS 免税函`), nameChangeDocument: '名称变更文件', nameChangeDocumentDescription: '如果你的公司名称在申请 TIN/EIN 后发生更改,我们需要此文件来验证你提供的税务识别号', companyAddressVerification: '公司地址验证', companyAddressVerificationDescription: dedent(` - 请上传以下任一文件: - • 显示公司名称和地址的近期水电账单 - • 显示公司名称和地址的银行对账单 - • 包含签字页的有效租赁协议,显示公司名称和当前地址 - • 显示公司名称和地址的保险声明 - • 显示公司名称和地址的 TIN 分配文件 - `), + 请上传以下任一文件: + • 显示公司名称和地址的近期水电账单 + • 显示公司名称和地址的银行对账单 + • 包含签字页的有效租赁协议,显示公司名称和当前地址 + • 显示公司名称和地址的保险声明 + • 显示公司名称和地址的 TIN 分配文件`), userAddressVerification: '地址验证', userAddressVerificationDescription: dedent(` - 请上传以下任一文件: - • 选民登记卡 - • 驾驶证 - • 银行对账单 - • 水电账单 - `), + 请上传以下任一文件: + • 选民登记卡 + • 驾驶证 + • 银行对账单 + • 水电账单`), userDOBVerification: '出生日期验证', userDOBVerificationDescription: '请上传美国签发的身份证件', finishViaChat: '通过聊天完成', diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index 8411688716d2..2f68f467cbcd 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -150,6 +150,7 @@ import { parseReportActionHtmlToText, parseReportRouteParams, prepareOnboardingOnyxData, + pushTransactionAutoSelectionsOnyxData, pushTransactionViolationsOnyxData, reasonForReportToBeInOptionList, requiresAttentionFromCurrentUser, @@ -181,6 +182,7 @@ import type { OnyxInputOrEntry, PersonalDetailsList, Policy, + PolicyCategories, PolicyEmployeeList, PolicyTag, Report, @@ -10213,6 +10215,223 @@ describe('ReportUtils', () => { }); }); + describe('pushTransactionAutoSelectionsOnyxData', () => { + const fakePolicyID = '0'; + + beforeAll(() => { + initOnyxDerivedValues(); + }); + + /** + * Builds a policy with N categories that are all enabled (the factory disables them by default). + */ + function buildEnabledCategories(numberOfCategories: number): PolicyCategories { + return Object.fromEntries(Object.entries(createRandomPolicyCategories(numberOfCategories)).map(([name, category]) => [name, {...category, enabled: true}])); + } + + /** + * Wraps a report in a keyed collection so it can be spread into Onyx.multiSet with a precise key type. + */ + function buildReportCollection(report: Report): Record<`${typeof ONYXKEYS.COLLECTION.REPORT}${string}`, Report> { + return {[`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`]: report}; + } + + it('auto-selects the sole remaining enabled category when the transaction category is disabled', async () => { + // Given a policy with two enabled categories, and an update that disables the first one + const fakePolicyCategories = buildEnabledCategories(2); + const categoryNames = Object.keys(fakePolicyCategories); + const categoryToDisable = categoryNames.at(0) ?? ''; + const remainingCategory = categoryNames.at(1) ?? ''; + const fakePolicyCategoriesUpdate = { + [categoryToDisable]: {pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, enabled: false}, + }; + + const fakePolicy = {...createRandomPolicy(0), id: fakePolicyID, requiresCategory: true, areCategoriesEnabled: true}; + + // Given an open report whose transaction uses the category about to be disabled + const openIOUReport: Report = {...mockIOUReport, policyID: fakePolicyID, stateNum: CONST.REPORT.STATE_NUM.OPEN, statusNum: CONST.REPORT.STATUS_NUM.OPEN}; + + await Onyx.multiSet({ + ...buildReportCollection(openIOUReport), + [`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${fakePolicyID}`]: fakePolicyCategories, + [`${ONYXKEYS.COLLECTION.POLICY}${fakePolicyID}`]: fakePolicy, + [`${ONYXKEYS.COLLECTION.TRANSACTION}${mockTransaction.transactionID}`]: { + ...mockTransaction, + reportID: openIOUReport.reportID, + policyID: fakePolicyID, + category: categoryToDisable, + }, + }); + await waitForBatchedUpdates(); + + const {result} = renderHook(() => usePolicyData(fakePolicyID), {wrapper: OnyxListItemProvider}); + await waitForBatchedUpdates(); + + const onyxData = {optimisticData: [], failureData: []}; + + // When auto-selecting after the category update + const autoSelections = pushTransactionAutoSelectionsOnyxData(onyxData, result.current, {}, fakePolicyCategoriesUpdate, {}); + + // Then the transaction is moved to the sole remaining enabled category, with a matching rollback + expect(autoSelections.get(mockTransaction.transactionID)).toEqual({category: remainingCategory}); + expect(onyxData).toMatchObject({ + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${mockTransaction.transactionID}`, + value: {category: remainingCategory}, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${mockTransaction.transactionID}`, + value: {category: categoryToDisable}, + }, + ], + }); + }); + + it('auto-selects the sole remaining enabled tag when the transaction tag is disabled', async () => { + // Given a single-level tag list with two enabled tags, and an update that disables the first one + const fakePolicyTagListName = 'Tag List'; + const fakePolicyTagsLists = createRandomPolicyTags(fakePolicyTagListName, 2); + const tagNames = Object.keys(fakePolicyTagsLists[fakePolicyTagListName]?.tags ?? {}); + const tagToDisable = tagNames.at(0) ?? ''; + const remainingTag = tagNames.at(1) ?? ''; + const fakePolicyTagListsUpdate: Record>>> = { + [fakePolicyTagListName]: { + tags: { + [tagToDisable]: {pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, enabled: false}, + }, + }, + }; + + const fakePolicy = {...createRandomPolicy(0), id: fakePolicyID, requiresTag: true, areTagsEnabled: true}; + + // Given an open report whose transaction uses the tag about to be disabled + const openIOUReport: Report = {...mockIOUReport, policyID: fakePolicyID, stateNum: CONST.REPORT.STATE_NUM.OPEN, statusNum: CONST.REPORT.STATUS_NUM.OPEN}; + + await Onyx.multiSet({ + ...buildReportCollection(openIOUReport), + [`${ONYXKEYS.COLLECTION.POLICY_TAGS}${fakePolicyID}`]: fakePolicyTagsLists, + [`${ONYXKEYS.COLLECTION.POLICY}${fakePolicyID}`]: fakePolicy, + [`${ONYXKEYS.COLLECTION.TRANSACTION}${mockTransaction.transactionID}`]: { + ...mockTransaction, + reportID: openIOUReport.reportID, + policyID: fakePolicyID, + tag: tagToDisable, + }, + }); + await waitForBatchedUpdates(); + + const {result} = renderHook(() => usePolicyData(fakePolicyID), {wrapper: OnyxListItemProvider}); + await waitForBatchedUpdates(); + + const onyxData = {optimisticData: [], failureData: []}; + + // When auto-selecting after the tag update + const autoSelections = pushTransactionAutoSelectionsOnyxData(onyxData, result.current, {}, {}, fakePolicyTagListsUpdate); + + // Then the transaction is moved to the sole remaining enabled tag, with a matching rollback + expect(autoSelections.get(mockTransaction.transactionID)).toEqual({tag: remainingTag}); + expect(onyxData).toMatchObject({ + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${mockTransaction.transactionID}`, + value: {tag: remainingTag}, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${mockTransaction.transactionID}`, + value: {tag: tagToDisable}, + }, + ], + }); + }); + + it('does not auto-select when more than one enabled category remains', async () => { + // Given a policy with three enabled categories, and an update that disables only one (two remain) + const fakePolicyCategories = buildEnabledCategories(3); + const categoryToDisable = Object.keys(fakePolicyCategories).at(0) ?? ''; + const fakePolicyCategoriesUpdate = { + [categoryToDisable]: {pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, enabled: false}, + }; + + const fakePolicy = {...createRandomPolicy(0), id: fakePolicyID, requiresCategory: true, areCategoriesEnabled: true}; + const openIOUReport: Report = {...mockIOUReport, policyID: fakePolicyID, stateNum: CONST.REPORT.STATE_NUM.OPEN, statusNum: CONST.REPORT.STATUS_NUM.OPEN}; + + await Onyx.multiSet({ + ...buildReportCollection(openIOUReport), + [`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${fakePolicyID}`]: fakePolicyCategories, + [`${ONYXKEYS.COLLECTION.POLICY}${fakePolicyID}`]: fakePolicy, + [`${ONYXKEYS.COLLECTION.TRANSACTION}${mockTransaction.transactionID}`]: { + ...mockTransaction, + reportID: openIOUReport.reportID, + policyID: fakePolicyID, + category: categoryToDisable, + }, + }); + await waitForBatchedUpdates(); + + const {result} = renderHook(() => usePolicyData(fakePolicyID), {wrapper: OnyxListItemProvider}); + await waitForBatchedUpdates(); + + const onyxData = {optimisticData: [], failureData: []}; + + // When auto-selecting after the category update + const autoSelections = pushTransactionAutoSelectionsOnyxData(onyxData, result.current, {}, fakePolicyCategoriesUpdate, {}); + + // Then nothing is auto-selected because the remaining choice is ambiguous + expect(autoSelections.size).toBe(0); + expect(onyxData.optimisticData).toHaveLength(0); + expect(onyxData.failureData).toHaveLength(0); + }); + + it('does not auto-select on a non-open report even when a single category remains', async () => { + // Given a policy with two enabled categories, and an update that disables the first one + const fakePolicyCategories = buildEnabledCategories(2); + const categoryToDisable = Object.keys(fakePolicyCategories).at(0) ?? ''; + const fakePolicyCategoriesUpdate = { + [categoryToDisable]: {pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, enabled: false}, + }; + + const fakePolicy = {...createRandomPolicy(0), id: fakePolicyID, requiresCategory: true, areCategoriesEnabled: true}; + + // Given an approved (non-open, non-processing) report + const approvedIOUReport: Report = {...mockIOUReport, policyID: fakePolicyID, stateNum: CONST.REPORT.STATE_NUM.APPROVED, statusNum: CONST.REPORT.STATUS_NUM.APPROVED}; + + await Onyx.multiSet({ + ...buildReportCollection(approvedIOUReport), + [`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${fakePolicyID}`]: fakePolicyCategories, + [`${ONYXKEYS.COLLECTION.POLICY}${fakePolicyID}`]: fakePolicy, + [`${ONYXKEYS.COLLECTION.TRANSACTION}${mockTransaction.transactionID}`]: { + ...mockTransaction, + reportID: approvedIOUReport.reportID, + policyID: fakePolicyID, + category: categoryToDisable, + }, + }); + await waitForBatchedUpdates(); + + const {result} = renderHook(() => usePolicyData(fakePolicyID), {wrapper: OnyxListItemProvider}); + await waitForBatchedUpdates(); + + const onyxData = {optimisticData: [], failureData: []}; + + // When auto-selecting after the category update + const autoSelections = pushTransactionAutoSelectionsOnyxData(onyxData, result.current, {}, fakePolicyCategoriesUpdate, {}); + + // Then nothing is auto-selected because auto-selection is scoped to open/processing reports + expect(autoSelections.size).toBe(0); + expect(onyxData.optimisticData).toHaveLength(0); + expect(onyxData.failureData).toHaveLength(0); + }); + }); + describe('canLeaveChat', () => { beforeEach(async () => { jest.clearAllMocks(); From af394468c31b1bd7d24383ec1fa876965d3e98d2 Mon Sep 17 00:00:00 2001 From: Mukher Date: Thu, 11 Jun 2026 23:27:38 +0500 Subject: [PATCH 30/32] auto-select only on dissable/delete actions --- src/libs/ReportUtils.ts | 37 +++++++++++++++-------- src/libs/Violations/ViolationsUtils.ts | 3 +- tests/unit/ReportUtilsTest.ts | 42 ++++++++++++++++++++++++++ 3 files changed, 69 insertions(+), 13 deletions(-) diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 76207b5b8385..93a1c6dc122e 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -2144,13 +2144,22 @@ function pushTransactionAutoSelectionsOnyxData( return autoSelections; } - const {optimisticCategories, optimisticTagLists, isCategoriesUpdateEmpty, isTagListsUpdateEmpty} = getOptimisticPolicyState(policyData, policyUpdate, categoriesUpdate, tagListsUpdate); + const {optimisticCategories, optimisticTagLists} = getOptimisticPolicyState(policyData, policyUpdate, categoriesUpdate, tagListsUpdate); const enabledCategoryKeys = Object.entries(optimisticCategories) .filter(([, cat]) => cat.enabled && cat.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) .map(([key]) => key); const singleRemainingCategory = enabledCategoryKeys.length === 1 ? enabledCategoryKeys.at(0) : undefined; + // A category counts as "removed" by this update only when it's deleted or flipped disabled. The backend + // auto-replaces transactions solely on a delete/disable, so an enable-only update (or toggling an unrelated + // category) must not rewrite any transaction client-side. + const removedCategoryKeys = new Set( + Object.entries(categoriesUpdate) + .filter(([, categoryUpdate]) => !categoryUpdate || categoryUpdate.enabled === false || categoryUpdate.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) + .map(([key]) => key), + ); + const tagListKeys = Object.keys(optimisticTagLists); // Auto-replace is scoped to single-level tags only. Web-E's removeTags() throws "NTagging not supported yet" for @@ -2158,12 +2167,18 @@ function pushTransactionAutoSelectionsOnyxData( // so the backend can't know which level of the multi-level tag string should be replaced. Multi-level support // is tracked as a separate NTag follow-up. let singleRemainingTag: string | undefined; + let removedTagKeys = new Set(); if (tagListKeys.length === 1) { const tagListName = tagListKeys.at(0) ?? ''; const enabledTagKeys = Object.entries(optimisticTagLists[tagListName]?.tags ?? {}) .filter(([, tag]) => tag.enabled && tag.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) .map(([key]) => key); singleRemainingTag = enabledTagKeys.length === 1 ? enabledTagKeys.at(0) : undefined; + removedTagKeys = new Set( + Object.entries(tagListsUpdate[tagListName]?.tags ?? {}) + .filter(([, tagUpdate]) => !tagUpdate || tagUpdate.enabled === false || tagUpdate.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) + .map(([key]) => key), + ); } for (const { @@ -2178,21 +2193,19 @@ function pushTransactionAutoSelectionsOnyxData( const transactionUpdates: Partial = {}; const transactionRollback: Partial = {}; - // Category auto-select: gated to calls that include a category update (delete or disable) - // so toggling unrelated policy settings doesn't rewrite transaction category values. - if (!isCategoriesUpdateEmpty && singleRemainingCategory && transaction.category && !optimisticCategories[transaction.category]?.enabled) { + // Category auto-select: only when this update deletes/disables the transaction's own category and a + // single enabled category remains to move it to. Matches the backend, which auto-replaces on a + // delete/disable — never on an enable-only update. + if (singleRemainingCategory && transaction.category && removedCategoryKeys.has(transaction.category)) { transactionUpdates.category = singleRemainingCategory; transactionRollback.category = transaction.category; } - // Single-level tag auto-select: gated to calls that include a tag-list update for the same reason. - if (!isTagListsUpdateEmpty && tagListKeys.length === 1 && singleRemainingTag && transaction.tag) { - const tagListName = tagListKeys.at(0) ?? ''; - const isTagInPolicy = !!optimisticTagLists[tagListName]?.tags?.[transaction.tag]?.enabled; - if (!isTagInPolicy) { - transactionUpdates.tag = singleRemainingTag; - transactionRollback.tag = transaction.tag; - } + // Single-level tag auto-select: same rule — only when this update deletes/disables the transaction's + // own tag and a single enabled tag remains. + if (singleRemainingTag && tagListKeys.length === 1 && transaction.tag && removedTagKeys.has(transaction.tag)) { + transactionUpdates.tag = singleRemainingTag; + transactionRollback.tag = transaction.tag; } if (Object.keys(transactionUpdates).length === 0) { diff --git a/src/libs/Violations/ViolationsUtils.ts b/src/libs/Violations/ViolationsUtils.ts index e81f3810f5b3..e08c1324b99c 100644 --- a/src/libs/Violations/ViolationsUtils.ts +++ b/src/libs/Violations/ViolationsUtils.ts @@ -419,7 +419,7 @@ const ViolationsUtils = { // Calculate client-side category violations. Also run when the transaction has a category (not just // when the policy requires one) so disabling that category flags it optimistically. Mirrors tags below. - const policyRequiresCategories = !!policy.requiresCategory || !!updatedTransaction.category; + const policyRequiresCategories = (!!policy.requiresCategory || !!updatedTransaction.category) && !isSelfDM; if (policyRequiresCategories) { const hasCategoryOutOfPolicyViolation = transactionViolations.some((violation) => violation.name === 'categoryOutOfPolicy'); const hasMissingCategoryViolation = transactionViolations.some((violation) => violation.name === 'missingCategory'); @@ -737,6 +737,7 @@ const ViolationsUtils = { } const hasTransactionTaxData = !!updatedTransaction.taxCode || !!updatedTransaction.taxValue || !!updatedTransaction.taxAmount; + // When tax tracking is enabled, only a non-empty tax code that isn't a current policy rate is out of policy. // A transaction with no tax code (e.g. its tax was deleted) must not be flagged. const shouldAddTaxOutOfPolicy = !isTimeRequest && !isPerDiemRequest && (isPolicyTrackTaxEnabled ? !!updatedTransaction.taxCode && !isTaxInPolicy : hasTransactionTaxData); diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index 2f68f467cbcd..8b126e0c7e7f 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -10391,6 +10391,48 @@ describe('ReportUtils', () => { expect(onyxData.failureData).toHaveLength(0); }); + it('does not auto-select on an enable-only update that removes nothing', async () => { + // Given a policy with two disabled categories (the factory disables them) and a transaction on the first + const fakePolicyCategories = createRandomPolicyCategories(2); + const categoryNames = Object.keys(fakePolicyCategories); + const transactionCategory = categoryNames.at(0) ?? ''; + const categoryToEnable = categoryNames.at(1) ?? ''; + + // ... and an update that only ENABLES the second category (it deletes/disables nothing) + const fakePolicyCategoriesUpdate = { + [categoryToEnable]: {enabled: true}, + }; + + const fakePolicy = {...createRandomPolicy(0), id: fakePolicyID, requiresCategory: true, areCategoriesEnabled: true}; + const openIOUReport: Report = {...mockIOUReport, policyID: fakePolicyID, stateNum: CONST.REPORT.STATE_NUM.OPEN, statusNum: CONST.REPORT.STATUS_NUM.OPEN}; + + await Onyx.multiSet({ + ...buildReportCollection(openIOUReport), + [`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${fakePolicyID}`]: fakePolicyCategories, + [`${ONYXKEYS.COLLECTION.POLICY}${fakePolicyID}`]: fakePolicy, + [`${ONYXKEYS.COLLECTION.TRANSACTION}${mockTransaction.transactionID}`]: { + ...mockTransaction, + reportID: openIOUReport.reportID, + policyID: fakePolicyID, + category: transactionCategory, + }, + }); + await waitForBatchedUpdates(); + + const {result} = renderHook(() => usePolicyData(fakePolicyID), {wrapper: OnyxListItemProvider}); + await waitForBatchedUpdates(); + + const onyxData = {optimisticData: [], failureData: []}; + + // When auto-selecting after an enable-only update that leaves exactly one enabled category + const autoSelections = pushTransactionAutoSelectionsOnyxData(onyxData, result.current, {}, fakePolicyCategoriesUpdate, {}); + + // Then nothing is auto-selected: the backend only auto-replaces on a delete/disable, never an enable + expect(autoSelections.size).toBe(0); + expect(onyxData.optimisticData).toHaveLength(0); + expect(onyxData.failureData).toHaveLength(0); + }); + it('does not auto-select on a non-open report even when a single category remains', async () => { // Given a policy with two enabled categories, and an update that disables the first one const fakePolicyCategories = buildEnabledCategories(2); From a0f45628fd2761a0a6af68087addc814de4a9c03 Mon Sep 17 00:00:00 2001 From: Mukher Date: Mon, 15 Jun 2026 16:35:46 +0500 Subject: [PATCH 31/32] Now the recompute uses the same optimistic taxRates that gets written to Onyx, so isTaxInPolicy is evaluated correctly. --- src/components/Search/index.tsx | 46 ++++++++++--------------------- src/libs/actions/Policy/Policy.ts | 10 +++++-- 2 files changed, 23 insertions(+), 33 deletions(-) diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 2068734773bc..1192b9c20073 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -1,4 +1,4 @@ -import {findFocusedRoute, useFocusEffect, useIsFocused, useNavigation} from '@react-navigation/native'; +import {useFocusEffect, useIsFocused, useNavigation} from '@react-navigation/native'; import * as Sentry from '@sentry/react-native'; import {deepEqual} from 'fast-equals'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; @@ -79,14 +79,12 @@ import { import {cancelSubmitFollowUpActionSpan, getPendingSubmitFollowUpAction} from '@libs/telemetry/submitFollowUpAction'; import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan'; import {getOriginalTransactionWithSplitInfo, hasValidModifiedAmount, isOnHold, isTransactionPendingDelete, shouldShowAttendees} from '@libs/TransactionUtils'; -import Navigation, {navigationRef} from '@navigation/Navigation'; +import Navigation from '@navigation/Navigation'; import type {SearchFullscreenNavigatorParamList} from '@navigation/types'; import EmptySearchView from '@pages/Search/EmptySearchView'; import CONST from '@src/CONST'; -import NAVIGATORS from '@src/NAVIGATORS'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import SCREENS from '@src/SCREENS'; import {columnsSelector} from '@src/selectors/AdvancedSearchFiltersForm'; import {hasCompletedGuidedSetupFlowSelector, hasSeenTourSelector} from '@src/selectors/Onboarding'; import type {OutstandingReportsByPolicyIDDerivedValue, Report, ReportAction, SaveSearch, Transaction} from '@src/types/onyx'; @@ -358,7 +356,6 @@ function Search({ shouldDeferHeavySearchWork, setShouldDeferHeavySearchWork, searchDataWithOptimisticTransaction, - hasPendingWriteOnMountRef, skipDeferralOnFocusRef, rearmTracking, trackingState: optimisticTrackingState, @@ -738,27 +735,7 @@ function Search({ const shouldRetrySearchWithTotalsOrGroupedRef = useRef(false); useEffect(() => { - const focusedRoute = findFocusedRoute(navigationRef.getRootState()); - const isMigratedModalDisplayed = focusedRoute?.name === NAVIGATORS.MIGRATED_USER_MODAL_NAVIGATOR || focusedRoute?.name === SCREENS.MIGRATED_USER_WELCOME_MODAL.ROOT; - - const comingBackOnlineWithNoResults = prevIsOffline && !isOffline && isEmptyObject(searchResults?.data); - if (!comingBackOnlineWithNoResults && ((!isFocused && !isMigratedModalDisplayed) || isOffline)) { - return; - } - - // When mounting after the pre-insert fast path, the deferred write hasn't - // been flushed yet. Triggering a search now would race with the CREATE - // API call and return stale results that overwrite the optimistic row. - // Skip this call; the optimistic data from flushDeferredWrite will populate - // the list, and the next user-driven search will refresh from the server. - if (hasPendingWriteOnMountRef.current.hasPendingWriteOnMount && hasDeferredWrite(CONST.DEFERRED_LAYOUT_WRITE_KEYS.SEARCH)) { - return; - } - - if (searchResults?.search?.isLoading) { - if (validGroupBy || (shouldCalculateTotals && searchResults?.search?.count === undefined)) { - shouldRetrySearchWithTotalsOrGroupedRef.current = true; - } + if (offset === 0 || offset === searchResults?.search?.offset || !isFocused || isOffline || searchResults?.search?.isLoading) { return; } @@ -766,14 +743,21 @@ function Search({ queryJSON, searchKey: currentSearchKey, offset, - shouldCalculateTotals, + shouldCalculateTotals: false, prevReportsLength: filteredDataLength, - isLoading: !!searchResults?.search?.isLoading, + isLoading: false, }); + }, [currentSearchKey, filteredDataLength, handleSearch, isFocused, isOffline, offset, queryJSON, searchResults?.search?.isLoading, searchResults?.search?.offset]); - // We don't need to run the effect on change of isFocused. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [handleSearch, isOffline, offset, queryJSON, currentSearchKey, shouldCalculateTotals, validGroupBy]); + useEffect(() => { + if (!searchResults?.search?.isLoading) { + return; + } + + if (validGroupBy || (shouldCalculateTotals && searchResults?.search?.count === undefined)) { + shouldRetrySearchWithTotalsOrGroupedRef.current = true; + } + }, [searchResults?.search?.isLoading, searchResults?.search?.count, shouldCalculateTotals, validGroupBy]); useEffect(() => { if (!shouldRetrySearchWithTotalsOrGroupedRef.current || searchResults?.search?.isLoading || (!shouldCalculateTotals && !validGroupBy)) { diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index fe203d36e40d..e9d0302eabcd 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -5056,9 +5056,15 @@ function enablePolicyTaxes(policyID: string, enabled: boolean, currentTaxRates?: }; // Recompute transaction violations so toggling tax tracking immediately clears/adds the tax violation, - // instead of leaving a stale one until the report reloads. + // instead of leaving a stale one until the report reloads. Include the optimistic default tax rates when + // they're being added, so the recompute matches the policy we just wrote and doesn't flag a transaction + // whose tax code is one of those new rates as out of policy. if (policyData) { - ReportUtils.pushTransactionViolationsOnyxData(onyxData, policyData, {tax: {trackingEnabled: enabled}}); + const taxPolicyUpdate: Partial = {tax: {trackingEnabled: enabled}}; + if (shouldAddDefaultTaxRatesData) { + taxPolicyUpdate.taxRates = defaultTaxRates; + } + ReportUtils.pushTransactionViolationsOnyxData(onyxData, policyData, taxPolicyUpdate); } const parameters: EnablePolicyTaxesParams = {policyID, enabled}; From 42ca66a70e493dc1fa6c9680d5c17ba252573ad0 Mon Sep 17 00:00:00 2001 From: Mukher Date: Tue, 16 Jun 2026 16:33:58 +0500 Subject: [PATCH 32/32] reverted unrelated changes --- src/components/Search/index.tsx | 46 ++++++++++++++++++++++----------- 1 file changed, 31 insertions(+), 15 deletions(-) diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 1192b9c20073..2068734773bc 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -1,4 +1,4 @@ -import {useFocusEffect, useIsFocused, useNavigation} from '@react-navigation/native'; +import {findFocusedRoute, useFocusEffect, useIsFocused, useNavigation} from '@react-navigation/native'; import * as Sentry from '@sentry/react-native'; import {deepEqual} from 'fast-equals'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; @@ -79,12 +79,14 @@ import { import {cancelSubmitFollowUpActionSpan, getPendingSubmitFollowUpAction} from '@libs/telemetry/submitFollowUpAction'; import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan'; import {getOriginalTransactionWithSplitInfo, hasValidModifiedAmount, isOnHold, isTransactionPendingDelete, shouldShowAttendees} from '@libs/TransactionUtils'; -import Navigation from '@navigation/Navigation'; +import Navigation, {navigationRef} from '@navigation/Navigation'; import type {SearchFullscreenNavigatorParamList} from '@navigation/types'; import EmptySearchView from '@pages/Search/EmptySearchView'; import CONST from '@src/CONST'; +import NAVIGATORS from '@src/NAVIGATORS'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import SCREENS from '@src/SCREENS'; import {columnsSelector} from '@src/selectors/AdvancedSearchFiltersForm'; import {hasCompletedGuidedSetupFlowSelector, hasSeenTourSelector} from '@src/selectors/Onboarding'; import type {OutstandingReportsByPolicyIDDerivedValue, Report, ReportAction, SaveSearch, Transaction} from '@src/types/onyx'; @@ -356,6 +358,7 @@ function Search({ shouldDeferHeavySearchWork, setShouldDeferHeavySearchWork, searchDataWithOptimisticTransaction, + hasPendingWriteOnMountRef, skipDeferralOnFocusRef, rearmTracking, trackingState: optimisticTrackingState, @@ -735,7 +738,27 @@ function Search({ const shouldRetrySearchWithTotalsOrGroupedRef = useRef(false); useEffect(() => { - if (offset === 0 || offset === searchResults?.search?.offset || !isFocused || isOffline || searchResults?.search?.isLoading) { + const focusedRoute = findFocusedRoute(navigationRef.getRootState()); + const isMigratedModalDisplayed = focusedRoute?.name === NAVIGATORS.MIGRATED_USER_MODAL_NAVIGATOR || focusedRoute?.name === SCREENS.MIGRATED_USER_WELCOME_MODAL.ROOT; + + const comingBackOnlineWithNoResults = prevIsOffline && !isOffline && isEmptyObject(searchResults?.data); + if (!comingBackOnlineWithNoResults && ((!isFocused && !isMigratedModalDisplayed) || isOffline)) { + return; + } + + // When mounting after the pre-insert fast path, the deferred write hasn't + // been flushed yet. Triggering a search now would race with the CREATE + // API call and return stale results that overwrite the optimistic row. + // Skip this call; the optimistic data from flushDeferredWrite will populate + // the list, and the next user-driven search will refresh from the server. + if (hasPendingWriteOnMountRef.current.hasPendingWriteOnMount && hasDeferredWrite(CONST.DEFERRED_LAYOUT_WRITE_KEYS.SEARCH)) { + return; + } + + if (searchResults?.search?.isLoading) { + if (validGroupBy || (shouldCalculateTotals && searchResults?.search?.count === undefined)) { + shouldRetrySearchWithTotalsOrGroupedRef.current = true; + } return; } @@ -743,21 +766,14 @@ function Search({ queryJSON, searchKey: currentSearchKey, offset, - shouldCalculateTotals: false, + shouldCalculateTotals, prevReportsLength: filteredDataLength, - isLoading: false, + isLoading: !!searchResults?.search?.isLoading, }); - }, [currentSearchKey, filteredDataLength, handleSearch, isFocused, isOffline, offset, queryJSON, searchResults?.search?.isLoading, searchResults?.search?.offset]); - useEffect(() => { - if (!searchResults?.search?.isLoading) { - return; - } - - if (validGroupBy || (shouldCalculateTotals && searchResults?.search?.count === undefined)) { - shouldRetrySearchWithTotalsOrGroupedRef.current = true; - } - }, [searchResults?.search?.isLoading, searchResults?.search?.count, shouldCalculateTotals, validGroupBy]); + // We don't need to run the effect on change of isFocused. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [handleSearch, isOffline, offset, queryJSON, currentSearchKey, shouldCalculateTotals, validGroupBy]); useEffect(() => { if (!shouldRetrySearchWithTotalsOrGroupedRef.current || searchResults?.search?.isLoading || (!shouldCalculateTotals && !validGroupBy)) {