diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index bc785fb5e4d5..286f24a40405 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -48,7 +48,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'; @@ -70,6 +70,7 @@ import { hasVendorFeature, isAttendeeTrackingEnabled, isGroupPolicyByType, + isMultiLevelTags, isPolicyAccessible, isTaxTrackingEnabled, } from '@libs/PolicyUtils'; @@ -444,6 +445,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 && !isMultiLevelTags(policyTagList); const shouldShowBillable = (isPolicyExpenseChat || isExpenseUnreported) && (!!transactionBillable || isBillableEnabledOnPolicy(policy) || !!updatedTransaction?.billable); const isCurrentTransactionReimbursableDifferentFromPolicyDefault = policy?.defaultReimbursable !== undefined && !!(updatedTransaction?.reimbursable ?? transactionReimbursable) !== policy.defaultReimbursable; @@ -745,6 +751,73 @@ 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, + isOffline, + delegateAccountID, + }); + }); + }; + const distanceCopyValue = !canEditDistance ? distanceToDisplay : undefined; const distanceRateCopyValue = !canEditDistanceRate ? rateToDisplay : undefined; const amountCopyValue = !canEditAmount ? amountTitle : undefined; @@ -931,6 +1004,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, @@ -1130,6 +1207,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/de.ts b/src/languages/de.ts index 26cc46df3ece..20c7cd1c8782 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -1706,6 +1706,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: { diff --git a/src/languages/en.ts b/src/languages/en.ts index 9891b75792a3..8cdbb2175361 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1760,6 +1760,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 04218e54a32e..abfcd9b580a7 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -1664,6 +1664,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/languages/fr.ts b/src/languages/fr.ts index ae3d14bf279c..4ebc19c47f2e 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -1711,6 +1711,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: { diff --git a/src/languages/it.ts b/src/languages/it.ts index d4cacd2c6f4f..c596811a96d4 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -1704,6 +1704,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: { diff --git a/src/languages/ja.ts b/src/languages/ja.ts index acdd5cbe9ec5..28317b744442 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -1687,6 +1687,16 @@ const translations: TranslationDeepObject = { }, bulkDuplicateLimit: `一度に複製できる経費は最大で ${CONST.SEARCH.BULK_DUPLICATE_LIMIT} 件です。経費の数を減らして、もう一度お試しください。`, deleted: '削除済み', + categoryDisabledAlert: { + title: 'カテゴリは無効です', + prompt: 'ワークスペースでカテゴリを有効にすると、この経費の詳細を編集したり、この経費からカテゴリを削除したりできます。', + confirmText: 'カテゴリを削除', + }, + tagDisabledAlert: { + title: 'タグは無効です', + prompt: 'ワークスペースでタグを有効にすると、この経費の詳細を編集したり、この経費からタグを削除したりできます。', + confirmText: 'タグを削除', + }, }, transactionMerge: { listPage: { diff --git a/src/languages/nl.ts b/src/languages/nl.ts index f31da409cf0c..29df2b5208ac 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -1699,6 +1699,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: { diff --git a/src/languages/pl.ts b/src/languages/pl.ts index f0859865dec5..be1bd78eec1f 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -1700,6 +1700,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: { diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 4478865ed681..60a657877c32 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -1697,6 +1697,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: { diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index ebcb5a666820..fb5144364048 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -1648,6 +1648,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: { diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index f4224cb25fdb..8744bb8c4e14 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -2011,55 +2011,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 : { @@ -2076,7 +2067,6 @@ function pushTransactionViolationsOnyxData( }, {}), }; - // Merge the existing tag lists with the optimistic updates const optimisticTagLists = isTagListsUpdateEmpty ? policyData.tags : { @@ -2113,22 +2103,201 @@ 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} = 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 + // 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; + 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 { + report, + transactionsAndViolations: {transactions}, + } of nonInvoiceReportItems) { + if (!isOpenOrProcessingReport(report)) { + continue; + } + + for (const transaction of Object.values(transactions)) { + const transactionUpdates: Partial = {}; + const transactionRollback: Partial = {}; + + // 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: 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) { + 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; - // Iterate through all policy reports to find transactions that need optimistic violations - for (const {transactions, violations} of nonInvoiceReportTransactionsAndViolations) { + 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({ @@ -13355,6 +13524,7 @@ export { getReportPersonalDetailsParticipants, isWorkspaceEligibleForReportChange, pushTransactionViolationsOnyxData, + pushTransactionAutoSelectionsOnyxData, navigateOnDeleteExpense, canRejectReportAction, hasReportBeenReopened, diff --git a/src/libs/Violations/ViolationsUtils.ts b/src/libs/Violations/ViolationsUtils.ts index 60c432020bb6..e08c1324b99c 100644 --- a/src/libs/Violations/ViolationsUtils.ts +++ b/src/libs/Violations/ViolationsUtils.ts @@ -98,15 +98,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}); } @@ -416,8 +417,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) && !isSelfDM; if (policyRequiresCategories) { const hasCategoryOutOfPolicyViolation = transactionViolations.some((violation) => violation.name === 'categoryOutOfPolicy'); const hasMissingCategoryViolation = transactionViolations.some((violation) => violation.name === 'missingCategory'); @@ -446,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 @@ -731,7 +737,10 @@ 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 df874b2fe5b3..437cc3c9513b 100644 --- a/src/libs/actions/IOU/UpdateMoneyRequest.ts +++ b/src/libs/actions/IOU/UpdateMoneyRequest.ts @@ -1743,6 +1743,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/Category.ts b/src/libs/actions/Policy/Category.ts index 9667a873f081..bee4bc049458 100644 --- a/src/libs/actions/Policy/Category.ts +++ b/src/libs/actions/Policy/Category.ts @@ -34,7 +34,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'; @@ -80,7 +80,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, @@ -427,7 +432,9 @@ function setWorkspaceCategoryEnabled({ ], }; - pushTransactionViolationsOnyxData(onyxData, policyData, {}, policyCategoriesOptimisticData); + const autoSelections = pushTransactionAutoSelectionsOnyxData(onyxData, policyData, {}, policyCategoriesOptimisticData); + + pushTransactionViolationsOnyxData(onyxData, policyData, {}, policyCategoriesOptimisticData, {}, autoSelections); appendSetupCategoriesOnboardingData( onyxData, setupCategoryTaskReport, @@ -1344,7 +1351,7 @@ function deleteWorkspaceCategories( } : {}; - const onyxData: OnyxData = { + const onyxData: OnyxData = { optimisticData: [ { onyxMethod: Onyx.METHOD.MERGE, @@ -1378,7 +1385,9 @@ function deleteWorkspaceCategories( ], }; - pushTransactionViolationsOnyxData(onyxData, policyData, optimisticPolicyData, optimisticPolicyCategoriesData); + const autoSelections = pushTransactionAutoSelectionsOnyxData(onyxData, policyData, optimisticPolicyData, optimisticPolicyCategoriesData); + + pushTransactionViolationsOnyxData(onyxData, policyData, optimisticPolicyData, optimisticPolicyCategoriesData, {}, autoSelections); appendSetupCategoriesOnboardingData( onyxData, setupCategoryTaskReport, @@ -1465,7 +1474,9 @@ function enablePolicyCategories(policyData: PolicyData, enabled: boolean, should ], }; - pushTransactionViolationsOnyxData(onyxData, policyData, policyUpdate, policyCategoriesUpdate); + const autoSelections = pushTransactionAutoSelectionsOnyxData(onyxData, policyData, policyUpdate, policyCategoriesUpdate); + + pushTransactionViolationsOnyxData(onyxData, policyData, policyUpdate, policyCategoriesUpdate, {}, autoSelections); const parameters: EnablePolicyCategoriesParams = {policyID, enabled}; diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index ad7bfe87022b..e9d0302eabcd 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -4945,9 +4945,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: [ @@ -5055,6 +5055,18 @@ 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. 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) { + const taxPolicyUpdate: Partial = {tax: {trackingEnabled: enabled}}; + if (shouldAddDefaultTaxRatesData) { + taxPolicyUpdate.taxRates = defaultTaxRates; + } + ReportUtils.pushTransactionViolationsOnyxData(onyxData, policyData, taxPolicyUpdate); + } + const parameters: EnablePolicyTaxesParams = {policyID, enabled}; if (shouldAddDefaultTaxRatesData) { parameters.taxFields = JSON.stringify(defaultTaxRates); diff --git a/src/libs/actions/Policy/Tag.ts b/src/libs/actions/Policy/Tag.ts index 3e61a58154ba..9aa63adc2403 100644 --- a/src/libs/actions/Policy/Tag.ts +++ b/src/libs/actions/Policy/Tag.ts @@ -27,7 +27,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'; @@ -187,7 +187,6 @@ function createPolicyTag({ }; pushTransactionViolationsOnyxData(onyxData, policyData, {}, {}, tagListsOptimisticData); - const parameters = { policyID, tags: JSON.stringify([{name: newTagName}]), @@ -338,6 +337,17 @@ function setWorkspaceTagEnabled(policyData: PolicyData, tagsToUpdate: Record = { + const onyxData: OnyxData = { optimisticData: [ { onyxMethod: Onyx.METHOD.MERGE, @@ -513,8 +523,9 @@ function deletePolicyTags(policyData: PolicyData, tagsToDelete: string[]) { ], }; - pushTransactionViolationsOnyxData(onyxData, policyData, {}, {}, policyTagsOptimisticData); + const autoSelections = pushTransactionAutoSelectionsOnyxData(onyxData, policyData, {}, {}, policyTagsOptimisticData); + pushTransactionViolationsOnyxData(onyxData, policyData, {}, {}, policyTagsOptimisticData, autoSelections); const parameters = { policyID, tags: JSON.stringify(tagsToDelete), @@ -723,7 +734,6 @@ function renamePolicyTag(policyData: PolicyData, policyTag: {oldName: string; ne }; pushTransactionViolationsOnyxData(onyxData, policyData, policyOptimisticData, {}, policyTagsOptimisticData); - const parameters: RenamePolicyTagsParams = { policyID, oldName: oldTagName, @@ -831,7 +841,9 @@ function enablePolicyTags(policyData: PolicyData, enabled: boolean) { }, ); - pushTransactionViolationsOnyxData(onyxData, policyData, {...policyOptimisticData, requiresTag: false}, {}, policyTagsOptimisticData); + const autoSelections = pushTransactionAutoSelectionsOnyxData(onyxData, policyData, {...policyOptimisticData, requiresTag: false}, {}, policyTagsOptimisticData); + + pushTransactionViolationsOnyxData(onyxData, policyData, {...policyOptimisticData, requiresTag: false}, {}, policyTagsOptimisticData, autoSelections); } else { pushTransactionViolationsOnyxData(onyxData, policyData, policyOptimisticData); } @@ -1046,7 +1058,6 @@ function setPolicyRequiresTag(policyData: PolicyData, requiresTag: boolean) { onyxData.successData?.push(getUpdatedTagsOnyxData(requiresTag)); pushTransactionViolationsOnyxData(onyxData, policyData, policyOptimisticData, {}, getUpdatedTagsData(requiresTag)); - const parameters = { policyID, requiresTag, @@ -1107,7 +1118,6 @@ function setPolicyTagsRequired(policyData: PolicyData, requiresTag: boolean, tag }; pushTransactionViolationsOnyxData(onyxData, policyData, {}, {}, policyTagsOptimisticData); - const parameters: SetPolicyTagsRequired = { policyID, tagListIndex, 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 d0e4b4024198..c0cb6cca1c40 100644 --- a/tests/actions/IOUTest/UpdateMoneyRequestTest.ts +++ b/tests/actions/IOUTest/UpdateMoneyRequestTest.ts @@ -599,6 +599,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 = { @@ -794,6 +796,65 @@ 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, + isOffline: false, + 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 5bd936757b47..820f7653a08d 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -151,6 +151,7 @@ import { parseReportActionHtmlToText, parseReportRouteParams, prepareOnboardingOnyxData, + pushTransactionAutoSelectionsOnyxData, pushTransactionViolationsOnyxData, reasonForReportToBeInOptionList, requiresAttentionFromCurrentUser, @@ -183,6 +184,7 @@ import type { OnyxInputOrEntry, PersonalDetailsList, Policy, + PolicyCategories, PolicyEmployeeList, PolicyTag, Report, @@ -10216,6 +10218,265 @@ 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 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); + 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(); diff --git a/tests/unit/ViolationUtilsTest.ts b/tests/unit/ViolationUtilsTest.ts index ffba5310f99d..eb1897a381cc 100644 --- a/tests/unit/ViolationUtilsTest.ts +++ b/tests/unit/ViolationUtilsTest.ts @@ -945,6 +945,38 @@ 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); + }); + + 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', () => { @@ -1141,7 +1173,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', @@ -1165,10 +1197,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', @@ -1193,7 +1225,7 @@ describe('getViolationsOnyxData', () => { isInvoiceTransaction: false, }); - expect(result.value).not.toContainEqual(tagOutOfPolicyViolation); + expect(result.value).toContainEqual(tagOutOfPolicyViolation); expect(result.value).toContainEqual(duplicatedTransactionViolation); }); }); @@ -1800,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', () => {