diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index 91bcc07f4cca2..8ac4e9f9bed90 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -254,7 +254,7 @@ function MoneyRequestView({report, shouldShowAnimatedBackground, readonly = fals const {unit, rate} = DistanceRequestUtils.getRate({transaction, policy}); const distance = getDistanceInMeters(transactionBackup ?? transaction, unit); const currency = transactionCurrency ?? CONST.CURRENCY.USD; - const isCustomUnitOutOfPolicy = transactionViolations.some((violation) => violation.name === CONST.VIOLATIONS.CUSTOM_UNIT_OUT_OF_POLICY); + const isCustomUnitOutOfPolicy = transactionViolations.some((violation) => violation.name === CONST.VIOLATIONS.CUSTOM_UNIT_OUT_OF_POLICY) || (isDistanceRequest && !rate); const rateToDisplay = isCustomUnitOutOfPolicy ? translate('common.rateOutOfPolicy') : DistanceRequestUtils.getRateForDisplay(unit, rate, currency, translate, toLocaleDigit, isOffline); const distanceToDisplay = DistanceRequestUtils.getDistanceForDisplay(hasRoute, distance, unit, rate, translate); let merchantTitle = isEmptyMerchant ? '' : transactionMerchant; @@ -349,6 +349,10 @@ function MoneyRequestView({report, shouldShowAnimatedBackground, readonly = fals return translate(translationPath); } + if (isCustomUnitOutOfPolicy && field === 'customUnitRateID') { + return translate('violations.customUnitOutOfPolicy'); + } + // Return violations if there are any if (field !== 'merchant' && hasViolations(field, data, policyHasDependentTags, tagValue)) { const violations = getViolationsForField(field, data, policyHasDependentTags, tagValue); @@ -377,6 +381,7 @@ function MoneyRequestView({report, shouldShowAnimatedBackground, readonly = fals canEditDate, canEditMerchant, canEdit, + isCustomUnitOutOfPolicy, ], ); diff --git a/src/components/TransactionItemRow/index.tsx b/src/components/TransactionItemRow/index.tsx index 0582ef4e93053..aec7002ad4ebe 100644 --- a/src/components/TransactionItemRow/index.tsx +++ b/src/components/TransactionItemRow/index.tsx @@ -30,6 +30,7 @@ import { isMerchantMissing, isScanning, isTransactionPendingDelete, + isUnreportedAndHasInvalidDistanceRateTransaction, } from '@libs/TransactionUtils'; import variables from '@styles/variables'; import CONST from '@src/CONST'; @@ -178,7 +179,8 @@ function TransactionItemRow({ const merchantOrDescriptionName = useMemo(() => getMerchantNameWithFallback(transactionItem, translate, shouldUseNarrowLayout), [shouldUseNarrowLayout, transactionItem, translate]); const missingFieldError = useMemo(() => { - const hasFieldErrors = hasMissingSmartscanFields(transactionItem); + const isCustomUnitOutOfPolicy = isUnreportedAndHasInvalidDistanceRateTransaction(transactionItem); + const hasFieldErrors = hasMissingSmartscanFields(transactionItem) || isCustomUnitOutOfPolicy; if (hasFieldErrors) { const amountMissing = isAmountMissing(transactionItem); const merchantMissing = isMerchantMissing(transactionItem); @@ -190,6 +192,8 @@ function TransactionItemRow({ error = translate('iou.missingAmount'); } else if (merchantMissing) { error = translate('iou.missingMerchant'); + } else if (isCustomUnitOutOfPolicy) { + error = translate('violations.customUnitOutOfPolicy'); } return error; } diff --git a/src/libs/DistanceRequestUtils.ts b/src/libs/DistanceRequestUtils.ts index f1de938c492b6..e121a752ebcbf 100644 --- a/src/libs/DistanceRequestUtils.ts +++ b/src/libs/DistanceRequestUtils.ts @@ -186,7 +186,7 @@ function getDistanceForDisplay( translate: LocaleContextProps['translate'], useShortFormUnit?: boolean, ): string { - if (!hasRoute || !rate || !unit || !distanceInMeters) { + if (!hasRoute || !unit || !distanceInMeters) { return translate('iou.fieldPending'); } diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index 5048c5b416123..0cec55c42c446 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -83,6 +83,7 @@ import { getCreated as getTransactionCreatedDate, getMerchant as getTransactionMerchant, isPendingCardOrScanningTransaction, + isUnreportedAndHasInvalidDistanceRateTransaction, isViolationDismissed, } from './TransactionUtils'; import shouldShowTransactionYear from './TransactionUtils/shouldShowTransactionYear'; @@ -680,6 +681,11 @@ function getAction(data: OnyxTypes.SearchResults['data'], allViolations: OnyxCol if (!isTransaction && !isReportEntry(key)) { return CONST.SEARCH.ACTION_TYPES.VIEW; } + + const transaction = isTransaction ? data[key] : undefined; + if (isUnreportedAndHasInvalidDistanceRateTransaction(transaction)) { + return CONST.SEARCH.ACTION_TYPES.REVIEW; + } // Tracked and unreported expenses don't have a report, so we return early. if (!report) { return CONST.SEARCH.ACTION_TYPES.VIEW; @@ -689,7 +695,6 @@ function getAction(data: OnyxTypes.SearchResults['data'], allViolations: OnyxCol return CONST.SEARCH.ACTION_TYPES.PAID; } - const transaction = isTransaction ? data[key] : undefined; // We need to check both options for a falsy value since the transaction might not have an error but the report associated with it might. We return early if there are any errors for performance reasons, so we don't need to compute any other possible actions. // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing if (transaction?.errors || report?.errors) { diff --git a/src/libs/TransactionPreviewUtils.ts b/src/libs/TransactionPreviewUtils.ts index b184ba5ead18a..7100cfe35ed21 100644 --- a/src/libs/TransactionPreviewUtils.ts +++ b/src/libs/TransactionPreviewUtils.ts @@ -35,6 +35,7 @@ import { isPending, isPerDiemRequest, isScanning, + isUnreportedAndHasInvalidDistanceRateTransaction, } from './TransactionUtils'; const emptyPersonalDetails: OnyxTypes.PersonalDetails = { @@ -209,12 +210,14 @@ function getTransactionPreviewTextAndTranslationPaths({ RBRMessage = actionsWithErrors.length > 1 ? {translationPath: 'violations.reviewRequired'} : {text: actionsWithErrors.at(0)}; } - RBRMessage ??= {text: ''}; - let previewHeaderText: TranslationPathOrText[] = [showCashOrCard]; if (isDistanceRequest(transaction)) { previewHeaderText = [{translationPath: 'common.distance'}]; + + if (RBRMessage === undefined && isUnreportedAndHasInvalidDistanceRateTransaction(transaction)) { + RBRMessage = {translationPath: 'violations.customUnitOutOfPolicy'}; + } } else if (isPerDiemRequest(transaction)) { previewHeaderText = [{translationPath: 'common.perDiem'}]; } else if (isTransactionScanning) { @@ -223,6 +226,8 @@ function getTransactionPreviewTextAndTranslationPaths({ previewHeaderText = [{translationPath: 'iou.split'}]; } + RBRMessage ??= {text: ''}; + if (!isCreatedMissing(transaction)) { const created = getFormattedCreated(transaction); const date = DateUtils.formatWithUTCTimeZone(created, DateUtils.doesDateBelongToAPastYear(created) ? CONST.DATE.MONTH_DAY_YEAR_ABBR_FORMAT : CONST.DATE.MONTH_DAY_ABBR_FORMAT); @@ -317,8 +322,12 @@ function createTransactionPreviewConditionals({ const shouldShowCategory = !!categoryForDisplay && isReportAPolicyExpenseChat; - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - const hasAnyViolations = hasViolationsOfTypeNotice || hasWarningTypeViolation(transaction, violations, true) || hasViolation(transaction, violations, true); + const hasAnyViolations = + isUnreportedAndHasInvalidDistanceRateTransaction(transaction) || + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + hasViolationsOfTypeNotice || + hasWarningTypeViolation(transaction, violations, true) || + hasViolation(transaction, violations, true); const hasErrorOrOnHold = hasFieldErrors || (!isFullySettled && !isFullyApproved && isTransactionOnHold); const hasReportViolationsOrActionErrors = (isReportOwner(iouReport) && hasReportViolations(iouReport?.reportID)) || hasActionsWithErrors(iouReport?.reportID); const shouldShowRBR = hasAnyViolations || hasErrorOrOnHold || hasReportViolationsOrActionErrors; diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index b65c84c8db5ee..0b3c9df21b05f 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -657,6 +657,25 @@ function isFetchingWaypointsFromServer(transaction: OnyxInputOrEntry, policyParam: OnyxEntry = undefined) { + if (transaction && isDistanceRequest(transaction)) { + const report = getReportOrDraftReport(transaction.reportID); + // eslint-disable-next-line deprecation/deprecation + const policy = policyParam ?? getPolicy(report?.policyID); + const {rate} = DistanceRequestUtils.getRate({transaction, policy}); + const isUnreported = !transaction.reportID || transaction.reportID === CONST.REPORT.UNREPORTED_REPORT_ID; + + if (isUnreported && !rate) { + return true; + } + } + + return false; +} + /** * Return the merchant field from the transaction, return the modifiedMerchant if present. */ @@ -669,9 +688,11 @@ function getMerchant(transaction: OnyxInputOrEntry, policyParam: On const mileageRate = DistanceRequestUtils.getRate({transaction, policy}); const {unit, rate} = mileageRate; const distanceInMeters = getDistanceInMeters(transaction, unit); - return DistanceRequestUtils.getDistanceMerchant(true, distanceInMeters, unit, rate, transaction.currency, translateLocal, (digit) => - toLocaleDigit(IntlStore.getCurrentLocale(), digit), - ); + if (!isUnreportedAndHasInvalidDistanceRateTransaction(transaction, policy)) { + return DistanceRequestUtils.getDistanceMerchant(true, distanceInMeters, unit, rate, transaction.currency, translateLocal, (digit) => + toLocaleDigit(IntlStore.getCurrentLocale(), digit), + ); + } } return transaction?.modifiedMerchant ? transaction.modifiedMerchant : (transaction?.merchant ?? ''); } @@ -1740,6 +1761,7 @@ export { getTransactionPendingAction, isTransactionPendingDelete, createUnreportedExpenseSections, + isUnreportedAndHasInvalidDistanceRateTransaction, }; export type {TransactionChanges}; diff --git a/tests/unit/TransactionUtilsTest.ts b/tests/unit/TransactionUtilsTest.ts index 9a2c93408de90..96e7432b85ff7 100644 --- a/tests/unit/TransactionUtilsTest.ts +++ b/tests/unit/TransactionUtilsTest.ts @@ -4,7 +4,9 @@ import CONST from '@src/CONST'; import IntlStore from '@src/languages/IntlStore'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Attendee} from '@src/types/onyx/IOU'; +import type {CustomUnit, Rate} from '@src/types/onyx/Policy'; import type {ReportCollectionDataSet} from '@src/types/onyx/Report'; +import type {TransactionCustomUnit} from '@src/types/onyx/Transaction'; import * as TransactionUtils from '../../src/libs/TransactionUtils'; import type {Policy, Transaction} from '../../src/types/onyx'; import createRandomPolicy, {createCategoryTaxExpenseRules} from '../utils/collections/policies'; @@ -69,6 +71,40 @@ const reportCollectionDataSet: ReportCollectionDataSet = { [`${ONYXKEYS.COLLECTION.REPORT}${FAKE_APPROVED_REPORT_ID}`]: approvedReport, [`${ONYXKEYS.COLLECTION.REPORT}${FAKE_OPEN_REPORT_SECOND_USER_ID}`]: secondUserOpenReport, }; +const defaultDistanceRatePolicyID1: Record = { + customUnitRateID1: { + currency: 'USD', + customUnitRateID: 'customUnitRateID1', + enabled: true, + name: 'Default Rate', + rate: 70, + subRates: [], + }, +}; +const distanceRateTransactionID1: TransactionCustomUnit = { + customUnitID: 'customUnitID1', + customUnitRateID: 'customUnitRateID1', + distanceUnit: 'mi', + name: 'Distance', +}; +const distanceRateTransactionID2: TransactionCustomUnit = { + customUnitID: 'customUnitID2', + customUnitRateID: 'customUnitRateID2', + distanceUnit: 'mi', + name: 'Distance', +}; +const defaultCustomUnitPolicyID1: Record = { + customUnitID1: { + attributes: { + unit: 'mi', + }, + customUnitID: 'customUnitID1', + defaultCategory: 'Car', + enabled: true, + name: 'Distance', + rates: defaultDistanceRatePolicyID1, + }, +}; describe('TransactionUtils', () => { beforeAll(() => { @@ -520,4 +556,94 @@ describe('TransactionUtils', () => { expect(result).toEqual(expected); }); }); + + describe('isUnreportedAndHasInvalidDistanceRateTransaction', () => { + it('should be false when transaction is null', () => { + const fakePolicy: Policy = { + ...createRandomPolicy(0), + customUnits: defaultCustomUnitPolicyID1, + }; + const result = TransactionUtils.isUnreportedAndHasInvalidDistanceRateTransaction(null, fakePolicy); + expect(result).toBe(false); + }); + it('should be false when transaction is not distance type transaction', () => { + const fakePolicy: Policy = { + ...createRandomPolicy(0), + customUnits: defaultCustomUnitPolicyID1, + }; + const transaction: Transaction = { + ...generateTransaction(), + iouRequestType: CONST.IOU.REQUEST_TYPE.MANUAL, + }; + const result = TransactionUtils.isUnreportedAndHasInvalidDistanceRateTransaction(transaction, fakePolicy); + expect(result).toBe(false); + }); + it('should be false when transaction is reported', () => { + const fakePolicy: Policy = { + ...createRandomPolicy(0), + customUnits: defaultCustomUnitPolicyID1, + }; + const transaction: Transaction = { + ...generateTransaction(), + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE, + reportID: '1', + }; + const result = TransactionUtils.isUnreportedAndHasInvalidDistanceRateTransaction(transaction, fakePolicy); + expect(result).toBe(false); + }); + it('should be false when transaction is unreported and has valid rate', () => { + const fakePolicy: Policy = { + ...createRandomPolicy(0), + customUnits: defaultCustomUnitPolicyID1, + }; + const transaction: Transaction = { + ...generateTransaction(), + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE, + reportID: '0', + comment: { + customUnit: distanceRateTransactionID1, + type: 'customUnit', + }, + }; + + const result = TransactionUtils.isUnreportedAndHasInvalidDistanceRateTransaction(transaction, fakePolicy); + expect(result).toBe(false); + }); + it('should be false when transaction is unreported, has invalid rate but policy has default rate', () => { + const fakePolicy: Policy = { + ...createRandomPolicy(0), + customUnits: defaultCustomUnitPolicyID1, + }; + const transaction: Transaction = { + ...generateTransaction(), + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE, + reportID: '0', + comment: { + customUnit: distanceRateTransactionID2, + type: 'customUnit', + }, + }; + + const result = TransactionUtils.isUnreportedAndHasInvalidDistanceRateTransaction(transaction, fakePolicy); + expect(result).toBe(false); + }); + it('should be true when transaction is unreported, has invalid rate and policy has no default rate', () => { + const fakePolicy: Policy = { + ...createRandomPolicy(0), + customUnits: {}, + }; + const transaction: Transaction = { + ...generateTransaction(), + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE, + reportID: '0', + comment: { + customUnit: distanceRateTransactionID2, + type: 'customUnit', + }, + }; + + const result = TransactionUtils.isUnreportedAndHasInvalidDistanceRateTransaction(transaction, fakePolicy); + expect(result).toBe(true); + }); + }); });