Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 40 additions & 13 deletions src/components/ReportActionItem/MoneyRequestView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import {getBrokenConnectionUrlToFixPersonalCard, getCompanyCardDescription} from
import {getDecodedLeafCategoryName, isCategoryMissing} from '@libs/CategoryUtils';
import DistanceRequestUtils from '@libs/DistanceRequestUtils';
import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID';
import {insertTagIntoTransactionTagsString} from '@libs/IOUUtils';
import {getRateFromMerchant} from '@libs/MergeTransactionUtils';
import {isBillableEnabledOnPolicy, isSingleTransactionReport} from '@libs/MoneyRequestReportUtils';
import {hasEnabledOptions} from '@libs/OptionsListUtils';
Expand All @@ -68,7 +69,6 @@ import {
hasVendorFeature,
isAttendeeTrackingEnabled,
isGroupPolicyByType,
isMultiLevelTags,
isPerDiemEnabled,
isPolicyAccessible,
isTaxTrackingEnabled,
Expand Down Expand Up @@ -393,15 +393,34 @@ function MoneyRequestView({
const canEditAmount =
!isGPSDistanceRequest &&
isEditable &&
(canEditFieldOfMoneyRequest({reportAction: parentReportAction, fieldToEdit: CONST.EDIT_REQUEST_FIELD.AMOUNT, isChatReportArchived, transaction}) ||
(canEditFieldOfMoneyRequest({
reportAction: parentReportAction,
fieldToEdit: CONST.EDIT_REQUEST_FIELD.AMOUNT,
isChatReportArchived,
transaction,
}) ||
(shouldShowSplitIndicator && isSplitAvailable));
const canEditMerchant =
isEditable &&
canEditFieldOfMoneyRequest({reportAction: parentReportAction, fieldToEdit: CONST.EDIT_REQUEST_FIELD.MERCHANT, isChatReportArchived, transaction, report: moneyRequestReport, policy});
canEditFieldOfMoneyRequest({
reportAction: parentReportAction,
fieldToEdit: CONST.EDIT_REQUEST_FIELD.MERCHANT,
isChatReportArchived,
transaction,
report: moneyRequestReport,
policy,
});

const canEditDate =
isEditable &&
canEditFieldOfMoneyRequest({reportAction: parentReportAction, fieldToEdit: CONST.EDIT_REQUEST_FIELD.DATE, isChatReportArchived, transaction, report: moneyRequestReport, policy});
canEditFieldOfMoneyRequest({
reportAction: parentReportAction,
fieldToEdit: CONST.EDIT_REQUEST_FIELD.DATE,
isChatReportArchived,
transaction,
report: moneyRequestReport,
policy,
});

const canEditDistanceOrRate = isPolicyAccessible(policy, currentUserEmailParam) || isTrackExpense || isP2PDistanceRequest;

Expand Down Expand Up @@ -463,11 +482,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.
// 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". Applies to multi-level tags too - deleting clears the whole tag value.
const shouldShowCategoryDisabledAlert = !policy?.areCategoriesEnabled && !!category;
const shouldShowTagDisabledAlert = (!policy?.areTagsEnabled || !hasEnabledTags(policyTagLists)) && !!transactionTag && !isMultiLevelTags(policyTagList);
const shouldShowTagDisabledAlert = (!policy?.areTagsEnabled || !hasEnabledTags(policyTagLists)) && !!transactionTag;
const shouldShowBillable = (isPolicyExpenseChat || isExpenseUnreported) && (!!transactionBillable || isBillableEnabledOnPolicy(policy) || !!updatedTransaction?.billable);
const isCurrentTransactionReimbursableDifferentFromPolicyDefault =
policy?.defaultReimbursable !== undefined && !!(updatedTransaction?.reimbursable ?? transactionReimbursable) !== policy.defaultReimbursable;
Expand Down Expand Up @@ -593,7 +612,9 @@ function MoneyRequestView({

const renderGoogleMerchantSearchLink = () => (
<PressableWithoutFeedback
accessibilityLabel={translate('common.searchOnGoogle', {merchant: originalMerchantForGoogleSearch})}
accessibilityLabel={translate('common.searchOnGoogle', {
merchant: originalMerchantForGoogleSearch,
})}
role={CONST.ROLE.BUTTON}
sentryLabel={CONST.SENTRY_LABEL.MONEY_REQUEST.GOOGLE_MERCHANT_SEARCH_BUTTON}
onPress={(event) => {
Expand All @@ -602,7 +623,11 @@ function MoneyRequestView({
}}
style={[styles.flexRow, styles.alignItemsCenter, styles.mt1, styles.alignSelfStart]}
>
<Text style={styles.textLabelSupporting}>{translate('common.googleThisMerchant', {merchant: originalMerchantForGoogleSearch})}</Text>
<Text style={styles.textLabelSupporting}>
{translate('common.googleThisMerchant', {
merchant: originalMerchantForGoogleSearch,
})}
</Text>
<Icon
src={icons.NewWindow}
height={variables.iconSizeExtraSmall}
Expand Down Expand Up @@ -840,7 +865,7 @@ function MoneyRequestView({
});
};

const showTagDisabledAlert = () => {
const showTagDisabledAlert = (tagListIndex: number) => {
const transactionID = transaction?.transactionID;
if (!transactionID) {
return;
Expand All @@ -855,11 +880,13 @@ function MoneyRequestView({
return;
}

// Clear only the pressed level so the other levels of a multi-level tag are kept.
const updatedTag = insertTagIntoTransactionTagsString(transactionTag ?? '', '', tagListIndex, policy?.hasMultipleTagLists ?? false);
updateMoneyRequestTag({
transactionID,
transactionThreadReport,
parentReport,
tag: '',
tag: updatedTag,
policy,
policyTagList,
policyRecentlyUsedTags: undefined,
Expand Down Expand Up @@ -1061,7 +1088,7 @@ function MoneyRequestView({
return;
}
if (shouldShowTagDisabledAlert) {
showTagDisabledAlert();
showTagDisabledAlert(index);
return;
}
Navigation.navigate(
Expand Down
28 changes: 18 additions & 10 deletions src/libs/Violations/ViolationsUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,13 +210,12 @@ function getTagViolationForIndependentTags(policyTagList: PolicyTagLists, transa
let hasInvalidTag = false;
for (let i = 0; i < policyTagKeys.length; i++) {
const selectedTag = selectedTags.at(i);
const tags = policyTagList[policyTagKeys[i]].tags;
const listHasEnabledTags = hasEnabledTags(tags);
if (!listHasEnabledTags) {
if (!selectedTag) {
continue;
}
const isTagInPolicy = !!selectedTag && !!tags[selectedTag]?.enabled;
if (!isTagInPolicy && selectedTag) {
const tags = policyTagList[policyTagKeys[i]].tags;
const isTagInPolicy = !!tags[selectedTag]?.enabled;
if (!isTagInPolicy) {
newTransactionViolations.push({
name: CONST.VIOLATIONS.TAG_OUT_OF_POLICY,
type: CONST.VIOLATION_TYPES.VIOLATION,
Expand All @@ -226,6 +225,10 @@ function getTagViolationForIndependentTags(policyTagList: PolicyTagLists, transa
},
});
hasInvalidTag = true;
// The backend emits a single tagOutOfPolicy for the whole multi-level tag (labeled with the first
// out-of-policy level), so stop here to match it - flagging every level would flash extra errors on
// the other level rows that vanish once the backend response syncs.
break;
}
}
if (!hasInvalidTag) {
Expand Down Expand Up @@ -362,7 +365,7 @@ function getIsViolationFixed(violationError: string, params: ViolationFixParams)
return !taxCode;
}
const matchingTaxRate = policyTaxRates[taxCode];
if (!matchingTaxRate) {
if (!matchingTaxRate || matchingTaxRate.isDisabled) {
return false;
}
// If taxValue is provided, check that it matches the policy tax rate. If taxValue is not provided, just check that the tax code exists in the policy.
Expand Down Expand Up @@ -544,8 +547,9 @@ const ViolationsUtils = {
newTransactionViolations = reject(newTransactionViolations, {name: 'missingCategory'});
}

// Add 'missingCategory' violation if category is required and not set
if (!hasMissingCategoryViolation && policyRequiresCategories && !categoryKey && !isSelfDM) {
// Add 'missingCategory' violation when categories are required and none is set. isCategoryMissing also
// covers the 'Uncategorized'/'none' sentinel, mirroring the categoryOutOfPolicy check above.
if (!hasMissingCategoryViolation && !!policy.requiresCategory && isCategoryMissing(categoryKey) && !isSelfDM) {
newTransactionViolations.push({name: 'missingCategory', type: CONST.VIOLATION_TYPES.VIOLATION, showInReview: true});
}
} else if (transactionViolations.some((violation) => violation.name === 'missingCategory')) {
Expand Down Expand Up @@ -650,7 +654,11 @@ const ViolationsUtils = {
const isPerDiemRequest = TransactionUtils.isPerDiemRequest(updatedTransaction);
const isTimeRequest = TransactionUtils.isTimeRequest(updatedTransaction);
const isPolicyTrackTaxEnabled = isTaxTrackingEnabled(true, policy, isDistanceRequest, isPerDiemRequest, isTimeRequest);
const isTaxInPolicy = Object.keys(policy.taxRates?.taxes ?? {}).some((key) => key === updatedTransaction.taxCode);

// A disabled tax rate keeps its key (just `isDisabled: true`) but isn't valid, so it stays out of policy. A
Comment thread
mukhrr marked this conversation as resolved.
// key-only check would treat it as in-policy and drop the violation on any unrelated recompute (e.g. tag delete).
const taxRate = updatedTransaction.taxCode ? policy.taxRates?.taxes?.[updatedTransaction.taxCode] : undefined;
const isTaxRateValid = !!taxRate && !taxRate.isDisabled;

const amount = hasValidModifiedAmount(updatedTransaction) ? Number(updatedTransaction.modifiedAmount) : updatedTransaction.amount;
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
Expand Down Expand Up @@ -874,7 +882,7 @@ const ViolationsUtils = {

// 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);
const shouldAddTaxOutOfPolicy = !isTimeRequest && !isPerDiemRequest && (isPolicyTrackTaxEnabled ? !!updatedTransaction.taxCode && !isTaxRateValid : hasTransactionTaxData);

if (!hasTaxOutOfPolicyViolation && shouldAddTaxOutOfPolicy) {
newTransactionViolations.push({name: CONST.VIOLATIONS.TAX_OUT_OF_POLICY, type: CONST.VIOLATION_TYPES.VIOLATION, showInReview: true});
Expand Down
59 changes: 41 additions & 18 deletions src/libs/actions/Policy/Tag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -751,6 +751,21 @@ function renamePolicyTag(policyData: PolicyData, policyTag: {oldName: string; ne
API.write(WRITE_COMMANDS.RENAME_POLICY_TAG, parameters, onyxData);
}

// Builds a POLICY_TAGS update that sets every tag in the given lists to `enabled`.
function buildPolicyTagsEnabledData(policyTags: PolicyTagLists, enabled: boolean): Record<string, Partial<PolicyTagList>> {
return Object.fromEntries(
Object.entries(policyTags).map(([listName, tagList]): [string, Partial<PolicyTagList>] => [
listName,
{tags: Object.fromEntries(Object.entries(tagList.tags).map(([tagName, tag]): [string, PolicyTag] => [tagName, {...tag, enabled}]))},
]),
);
}

// Builds a POLICY_TAGS update that restores every list's tags to their current state (used as failure data).
function buildPolicyTagsRestoreData(policyTags: PolicyTagLists): Record<string, Partial<PolicyTagList>> {
return Object.fromEntries(Object.entries(policyTags).map(([listName, tagList]): [string, Partial<PolicyTagList>] => [listName, {tags: tagList.tags}]));
}

function enablePolicyTags(policyData: PolicyData, enabled: boolean) {
const policyID = policyData.policy?.id;
const policyOptimisticData = {
Expand Down Expand Up @@ -793,6 +808,11 @@ function enablePolicyTags(policyData: PolicyData, enabled: boolean) {
],
};

// BE flags only the first tag level out of policy when the Tags feature is toggled, so scope the optimistic
// enable/disable to that list to mirror it.
const firstTagList = PolicyUtils.getTagLists(policyData.tags).at(0);
const firstTagListData: PolicyTagLists = firstTagList ? {[firstTagList.name]: firstTagList} : {};

if (Object.keys(policyData.tags).length === 0) {
const defaultTagList: PolicyTagLists = {
Tag: {
Expand All @@ -814,24 +834,7 @@ function enablePolicyTags(policyData: PolicyData, enabled: boolean) {
});
pushTransactionViolationsOnyxData(onyxData, policyData, policyOptimisticData, {}, defaultTagList);
} else if (!enabled) {
const policyTag = PolicyUtils.getTagLists(policyData.tags).at(0);

if (!policyTag) {
return;
}

const policyTagsOptimisticData: Record<string, Partial<PolicyTagList>> = {
[policyTag.name]: {
tags: Object.fromEntries(
Object.keys(policyTag.tags).map((tagName) => [
tagName,
{
enabled: false,
},
]),
),
} as Partial<PolicyTagList>,
};
const policyTagsOptimisticData = buildPolicyTagsEnabledData(firstTagListData, false);

onyxData.optimisticData?.push(
{
Expand All @@ -847,10 +850,30 @@ function enablePolicyTags(policyData: PolicyData, enabled: boolean) {
},
},
);
onyxData.failureData?.push({
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`,
value: buildPolicyTagsRestoreData(firstTagListData),
});

const autoSelections = pushTransactionAutoSelectionsOnyxData(onyxData, policyData, {...policyOptimisticData, requiresTag: false}, {}, policyTagsOptimisticData);

pushTransactionViolationsOnyxData(onyxData, policyData, {...policyOptimisticData, requiresTag: false}, {}, policyTagsOptimisticData, autoSelections);
} else if (firstTagList && Object.keys(firstTagList.tags).length > 0) {
const policyTagsOptimisticData = buildPolicyTagsEnabledData(firstTagListData, true);

onyxData.optimisticData?.push({
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`,
value: policyTagsOptimisticData,
});
onyxData.failureData?.push({
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`,
value: buildPolicyTagsRestoreData(firstTagListData),
});

pushTransactionViolationsOnyxData(onyxData, policyData, policyOptimisticData, {}, policyTagsOptimisticData);
} else {
pushTransactionViolationsOnyxData(onyxData, policyData, policyOptimisticData);
}
Expand Down
Loading
Loading