From 963a160fa9d7bb2e38f8fd35b0cdb82dde9a189f Mon Sep 17 00:00:00 2001 From: dmkt9 Date: Thu, 19 Jun 2025 16:02:32 +0700 Subject: [PATCH 1/4] Fix - After moving distance expense to Self DM, "Distance" field is shown as "Pending" --- .../ReportActionItem/MoneyRequestView.tsx | 7 ++++- src/components/TransactionItemRow/index.tsx | 6 ++++- src/libs/DistanceRequestUtils.ts | 2 +- src/libs/SearchUIUtils.ts | 7 ++++- src/libs/TransactionPreviewUtils.ts | 11 +++++--- src/libs/TransactionUtils/index.ts | 27 ++++++++++++++++--- 6 files changed, 50 insertions(+), 10 deletions(-) diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index 891809b77e94f..b592db84ac92d 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -250,7 +250,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) || !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; @@ -345,6 +345,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); @@ -373,6 +377,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 f59a58f6e25c4..aa8c80c2a501c 100644 --- a/src/components/TransactionItemRow/index.tsx +++ b/src/components/TransactionItemRow/index.tsx @@ -30,6 +30,7 @@ import { isMerchantMissing, isReceiptBeingScanned, isTransactionPendingDelete, + isUnreportedAndHasInvalidDistanceRateTransaction, } from '@libs/TransactionUtils'; import variables from '@styles/variables'; import CONST from '@src/CONST'; @@ -176,7 +177,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); @@ -188,6 +190,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 1bba91e69ca9a..8987f6e59e489 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -71,6 +71,7 @@ import { getCreated as getTransactionCreatedDate, getMerchant as getTransactionMerchant, isPendingCardOrScanningTransaction, + isUnreportedAndHasInvalidDistanceRateTransaction, isViolationDismissed, } from './TransactionUtils'; import shouldShowTransactionYear from './TransactionUtils/shouldShowTransactionYear'; @@ -647,6 +648,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; @@ -656,7 +662,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 b4ba1e71f8667..05b7653395d8f 100644 --- a/src/libs/TransactionPreviewUtils.ts +++ b/src/libs/TransactionPreviewUtils.ts @@ -33,6 +33,7 @@ import { isPending, isPerDiemRequest, isScanning, + isUnreportedAndHasInvalidDistanceRateTransaction, } from './TransactionUtils'; const emptyPersonalDetails: OnyxTypes.PersonalDetails = { @@ -204,12 +205,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) { @@ -218,6 +221,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); @@ -310,7 +315,7 @@ function createTransactionPreviewConditionals({ const shouldShowCategory = !!category && isReportAPolicyExpenseChat; // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - const hasAnyViolations = hasViolationsOfTypeNotice || hasWarningTypeViolation(transaction?.transactionID, violations, true) || hasViolation(transaction, violations, true); + const hasAnyViolations = isUnreportedAndHasInvalidDistanceRateTransaction(transaction) || hasViolationsOfTypeNotice || hasWarningTypeViolation(transaction?.transactionID, 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 6f5605a87cfe1..476d9ff0f6594 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -656,6 +656,24 @@ function isFetchingWaypointsFromServer(transaction: OnyxInputOrEntry, policyParam: OnyxEntry = undefined) { + if (transaction && isDistanceRequest(transaction)) { + const report = getReportOrDraftReport(transaction.reportID); + 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. */ @@ -668,9 +686,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(TranslationStore.getCurrentLocale(), digit), - ); + if (!isUnreportedAndHasInvalidDistanceRateTransaction(transaction, policy)) { + return DistanceRequestUtils.getDistanceMerchant(true, distanceInMeters, unit, rate, transaction.currency, translateLocal, (digit) => + toLocaleDigit(TranslationStore.getCurrentLocale(), digit), + ); + } } return transaction?.modifiedMerchant ? transaction.modifiedMerchant : (transaction?.merchant ?? ''); } @@ -1728,6 +1748,7 @@ export { getTransactionPendingAction, isTransactionPendingDelete, createUnreportedExpenseSections, + isUnreportedAndHasInvalidDistanceRateTransaction, }; export type {TransactionChanges}; From 2c04419230347639e9ff1bb1b88bf54582037aac Mon Sep 17 00:00:00 2001 From: dmkt9 Date: Wed, 2 Jul 2025 08:09:47 +0700 Subject: [PATCH 2/4] update condition check for isCustomUnitOutOfPolicy --- 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 b592db84ac92d..182262ebceb2f 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -250,7 +250,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) || !rate; + 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; From d9b869e098dac8898abe120410fadf11bf7fb8ee Mon Sep 17 00:00:00 2001 From: dmkt9 Date: Wed, 2 Jul 2025 09:22:24 +0700 Subject: [PATCH 3/4] Fix lint and prettier --- src/libs/SearchUIUtils.ts | 2 +- src/libs/TransactionPreviewUtils.ts | 8 ++++++-- src/libs/TransactionUtils/index.ts | 3 ++- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index 090f921450f05..0df7b232829f7 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -681,7 +681,7 @@ 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; diff --git a/src/libs/TransactionPreviewUtils.ts b/src/libs/TransactionPreviewUtils.ts index d4b1f1093a9b0..0461399aa9b57 100644 --- a/src/libs/TransactionPreviewUtils.ts +++ b/src/libs/TransactionPreviewUtils.ts @@ -320,8 +320,12 @@ function createTransactionPreviewConditionals({ const shouldShowCategory = !!categoryForDisplay && isReportAPolicyExpenseChat; - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - const hasAnyViolations = isUnreportedAndHasInvalidDistanceRateTransaction(transaction) || hasViolationsOfTypeNotice || hasWarningTypeViolation(transaction?.transactionID, violations, true) || hasViolation(transaction, violations, true); + const hasAnyViolations = + isUnreportedAndHasInvalidDistanceRateTransaction(transaction) || + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + hasViolationsOfTypeNotice || + hasWarningTypeViolation(transaction?.transactionID, 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 353435ec335ac..06a2320e11ae5 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -663,10 +663,11 @@ 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; } From 4e48e889f260b09eaccb89c49745f32bdb575cac Mon Sep 17 00:00:00 2001 From: dmkt9 Date: Fri, 4 Jul 2025 11:08:55 +0700 Subject: [PATCH 4/4] add unit tests for isUnreportedAndHasInvalidDistanceRateTransaction --- tests/unit/TransactionUtilsTest.ts | 126 +++++++++++++++++++++++++++++ 1 file changed, 126 insertions(+) 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); + }); + }); });