diff --git a/src/CONST.ts b/src/CONST.ts index 0937310220e9..7fb0d320c43a 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -4015,6 +4015,15 @@ const CONST = { WARNING: 'warning', }, + /** + * Constants with different types for the modifiedAmount violation + */ + MODIFIED_AMOUNT_VIOLATION_DATA: { + DISTANCE: 'distance', + CARD: 'card', + SMARTSCAN: 'smartscan', + }, + /** * Constants for types of violation names. * Defined here because they need to be referenced by the type system to generate the diff --git a/src/components/ReceiptAudit.tsx b/src/components/ReceiptAudit.tsx index ac1b36c6bf32..bb704def1836 100644 --- a/src/components/ReceiptAudit.tsx +++ b/src/components/ReceiptAudit.tsx @@ -7,17 +7,31 @@ import Icon from './Icon'; import * as Expensicons from './Icon/Expensicons'; import Text from './Text'; -function ReceiptAuditHeader({notes, shouldShowAuditMessage}: {notes: string[]; shouldShowAuditMessage: boolean}) { +type ReceiptAuditProps = { + /** List of audit notes */ + notes: string[]; + + /** Whether to show audit result or not (e.g.`Verified`, `Issue Found`) */ + shouldShowAuditResult: boolean; +}; + +function ReceiptAudit({notes, shouldShowAuditResult}: ReceiptAuditProps) { const styles = useThemeStyles(); const theme = useTheme(); const {translate} = useLocalize(); - const auditText = notes.length > 0 ? translate('iou.receiptIssuesFound', notes.length) : translate('common.verified'); + let auditText = ''; + if (notes.length > 0 && shouldShowAuditResult) { + auditText = translate('iou.receiptIssuesFound', notes.length); + } else if (!notes.length && shouldShowAuditResult) { + auditText = translate('common.verified'); + } + return ( {translate('common.receipt')} - {shouldShowAuditMessage && ( + {!!auditText && ( <> {` • ${auditText}`} {notes.length > 0 && notes.map((message) => {message})}; } -export {ReceiptAuditHeader, ReceiptAuditMessages}; +export {ReceiptAuditMessages}; +export default ReceiptAudit; diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index 29c480dcce7d..635d7e05ecf4 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -7,7 +7,7 @@ import MenuItem from '@components/MenuItem'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import {useSession} from '@components/OnyxProvider'; -import {ReceiptAuditHeader, ReceiptAuditMessages} from '@components/ReceiptAudit'; +import ReceiptAudit, {ReceiptAuditMessages} from '@components/ReceiptAudit'; import ReceiptEmptyState from '@components/ReceiptEmptyState'; import Switch from '@components/Switch'; import Text from '@components/Text'; @@ -167,7 +167,7 @@ function MoneyRequestView({ const canEditReceipt = ReportUtils.canEditFieldOfMoneyRequest(parentReportAction, CONST.EDIT_REQUEST_FIELD.RECEIPT); const hasReceipt = TransactionUtils.hasReceipt(transaction); const isReceiptBeingScanned = hasReceipt && TransactionUtils.isReceiptBeingScanned(transaction); - const didRceiptScanSucceed = hasReceipt && TransactionUtils.didRceiptScanSucceed(transaction); + const didReceiptScanSucceed = hasReceipt && TransactionUtils.didReceiptScanSucceed(transaction); const canEditDistance = ReportUtils.canEditFieldOfMoneyRequest(parentReportAction, CONST.EDIT_REQUEST_FIELD.DISTANCE); const isAdmin = policy?.role === 'admin'; @@ -193,7 +193,7 @@ function MoneyRequestView({ const tripID = ReportUtils.getTripIDFromTransactionParentReport(parentReport); const shouldShowViewTripDetails = TransactionUtils.hasReservationList(transaction) && !!tripID; - const {getViolationsForField} = useViolations(transactionViolations ?? []); + const {getViolationsForField} = useViolations(transactionViolations ?? [], isReceiptBeingScanned || !ReportUtils.isPaidGroupPolicy(report)); const hasViolations = useCallback( (field: ViolationField, data?: OnyxTypes.TransactionViolation['data'], policyHasDependentTags = false, tagValue?: string): boolean => !!canUseViolations && getViolationsForField(field, data, policyHasDependentTags, tagValue).length > 0, @@ -343,14 +343,17 @@ function MoneyRequestView({ const receiptViolationNames: OnyxTypes.ViolationName[] = [ CONST.VIOLATIONS.RECEIPT_REQUIRED, CONST.VIOLATIONS.RECEIPT_NOT_SMART_SCANNED, - CONST.VIOLATIONS.MODIFIED_DATE, CONST.VIOLATIONS.CASH_EXPENSE_WITH_NO_RECEIPT, CONST.VIOLATIONS.SMARTSCAN_FAILED, ]; const receiptViolations = transactionViolations?.filter((violation) => receiptViolationNames.includes(violation.name)).map((violation) => ViolationsUtils.getViolationTranslation(violation, translate)) ?? []; - const shouldShowNotesViolations = !isReceiptBeingScanned && canUseViolations && ReportUtils.isPaidGroupPolicy(report); - const shouldShowReceiptHeader = isReceiptAllowed && (shouldShowReceiptEmptyState || hasReceipt); + + // Whether to show receipt audit result (e.g.`Verified`, `Issue Found`) and messages (e.g. `Receipt not verified. Please confirm accuracy.`) + // `!!(receiptViolations.length || didReceiptScanSucceed)` is for not showing `Verified` when `receiptViolations` is empty and `didReceiptScanSucceed` is false. + const shouldShowAuditMessage = + !isReceiptBeingScanned && hasReceipt && !!(receiptViolations.length || didReceiptScanSucceed) && !!canUseViolations && ReportUtils.isPaidGroupPolicy(report); + const shouldShowReceiptAudit = isReceiptAllowed && (shouldShowReceiptEmptyState || hasReceipt); const errors = { ...(transaction?.errorFields?.route ?? transaction?.errors), @@ -392,10 +395,10 @@ function MoneyRequestView({ {shouldShowAnimatedBackground && } <> - {shouldShowReceiptHeader && ( - )} {(hasReceipt || errors) && ( @@ -454,7 +457,7 @@ function MoneyRequestView({ /> )} {!shouldShowReceiptEmptyState && !hasReceipt && } - {shouldShowNotesViolations && } + {shouldShowAuditMessage && } )} - {shouldShowTax && ( = { smartscanFailed: 'receipt', someTagLevelsRequired: 'tag', tagOutOfPolicy: 'tag', + taxRateChanged: 'tax', taxAmountChanged: 'tax', taxOutOfPolicy: 'tax', - taxRateChanged: 'tax', taxRequired: 'tax', hold: 'none', }; type ViolationsMap = Map; -function useViolations(violations: TransactionViolation[]) { +// We don't want to show these violations on NewDot +const excludedViolationsName = ['taxAmountChanged', 'taxRateChanged']; + +/** + * @param violations – List of transaction violations + * @param shouldShowOnlyViolations – Whether we should only show violations of type 'violation' + */ +function useViolations(violations: TransactionViolation[], shouldShowOnlyViolations: boolean) { const violationsByField = useMemo((): ViolationsMap => { - const filteredViolations = violations.filter((violation) => violation.type === CONST.VIOLATION_TYPES.VIOLATION); + const filteredViolations = violations.filter((violation) => { + if (excludedViolationsName.includes(violation.name)) { + return false; + } + if (shouldShowOnlyViolations) { + return violation.type === CONST.VIOLATION_TYPES.VIOLATION; + } + return true; + }); + const violationGroups = new Map(); for (const violation of filteredViolations) { const field = violationFields[violation.name]; @@ -59,7 +75,7 @@ function useViolations(violations: TransactionViolation[]) { violationGroups.set(field, [...existingViolations, violation]); } return violationGroups ?? new Map(); - }, [violations]); + }, [violations, shouldShowOnlyViolations]); const getViolationsForField = useCallback( (field: ViolationField, data?: TransactionViolation['data'], policyHasDependentTags = false, tagValue?: string) => { diff --git a/src/languages/en.ts b/src/languages/en.ts index e1ab700b558b..15efb9058669 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -103,6 +103,7 @@ import type { ViolationsInvoiceMarkupParams, ViolationsMaxAgeParams, ViolationsMissingTagParams, + ViolationsModifiedAmountParams, ViolationsOverCategoryLimitParams, ViolationsOverLimitParams, ViolationsPerDayLimitParams, @@ -3836,7 +3837,19 @@ export default { missingCategory: 'Missing category', missingComment: 'Description required for selected category', missingTag: ({tagName}: ViolationsMissingTagParams) => `Missing ${tagName ?? 'tag'}`, - modifiedAmount: 'Amount greater than scanned receipt', + modifiedAmount: ({type, displayPercentVariance}: ViolationsModifiedAmountParams): string => { + switch (type) { + case 'distance': + return 'Amount differs from calculated distance'; + case 'card': + return 'Amount greater than card transaction'; + default: + if (displayPercentVariance) { + return `Amount ${displayPercentVariance}% greater than scanned receipt`; + } + return 'Amount greater than scanned receipt'; + } + }, modifiedDate: 'Date differs from scanned receipt', nonExpensiworksExpense: 'Non-Expensiworks expense', overAutoApprovalLimit: ({formattedLimit}: ViolationsOverLimitParams) => `Expense exceeds auto approval limit of ${formattedLimit}`, diff --git a/src/languages/es.ts b/src/languages/es.ts index 2b3898babfb9..419e9a4f94c3 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -101,6 +101,7 @@ import type { ViolationsInvoiceMarkupParams, ViolationsMaxAgeParams, ViolationsMissingTagParams, + ViolationsModifiedAmountParams, ViolationsOverAutoApprovalLimitParams, ViolationsOverCategoryLimitParams, ViolationsOverLimitParams, @@ -4350,7 +4351,19 @@ export default { missingCategory: 'Falta categoría', missingComment: 'Descripción obligatoria para la categoría seleccionada', missingTag: ({tagName}: ViolationsMissingTagParams) => `Falta ${tagName ?? 'etiqueta'}`, - modifiedAmount: 'Importe superior al del recibo escaneado', + modifiedAmount: ({type, displayPercentVariance}: ViolationsModifiedAmountParams) => { + switch (type) { + case 'distance': + return 'Importe difiere del calculado basado en distancia'; + case 'card': + return 'Importe mayor al de la transacción de la tarjeta'; + default: + if (displayPercentVariance) { + return `Importe ${displayPercentVariance}% mayor al del recibo escaneado`; + } + return 'Importe mayor al del recibo escaneado'; + } + }, modifiedDate: 'Fecha difiere del recibo escaneado', nonExpensiworksExpense: 'Gasto no proviene de Expensiworks', overAutoApprovalLimit: ({formattedLimit}: ViolationsOverAutoApprovalLimitParams) => diff --git a/src/languages/types.ts b/src/languages/types.ts index ae21a804f06f..24117f257d8f 100644 --- a/src/languages/types.ts +++ b/src/languages/types.ts @@ -1,5 +1,6 @@ import type {OnyxInputOrEntry, ReportAction} from '@src/types/onyx'; import type {Unit} from '@src/types/onyx/Policy'; +import type {ViolationDataType} from '@src/types/onyx/TransactionViolation'; import type en from './en'; type AddressLineParams = { @@ -222,6 +223,8 @@ type ViolationsMaxAgeParams = {maxAge: number}; type ViolationsMissingTagParams = {tagName?: string}; +type ViolationsModifiedAmountParams = {type?: ViolationDataType; displayPercentVariance?: number}; + type ViolationsOverAutoApprovalLimitParams = {formattedLimit?: string}; type ViolationsOverCategoryLimitParams = {formattedLimit?: string}; @@ -435,6 +438,7 @@ export type { ViolationsInvoiceMarkupParams, ViolationsMaxAgeParams, ViolationsMissingTagParams, + ViolationsModifiedAmountParams, ViolationsOverAutoApprovalLimitParams, ViolationsOverCategoryLimitParams, ViolationsOverLimitParams, diff --git a/src/libs/TransactionUtils.ts b/src/libs/TransactionUtils.ts index 473cd47b9939..06465212733c 100644 --- a/src/libs/TransactionUtils.ts +++ b/src/libs/TransactionUtils.ts @@ -527,7 +527,7 @@ function isReceiptBeingScanned(transaction: OnyxInputOrEntry): bool return [CONST.IOU.RECEIPT_STATE.SCANREADY, CONST.IOU.RECEIPT_STATE.SCANNING].some((value) => value === transaction?.receipt?.state); } -function didRceiptScanSucceed(transaction: OnyxEntry): boolean { +function didReceiptScanSucceed(transaction: OnyxEntry): boolean { return [CONST.IOU.RECEIPT_STATE.SCANCOMPLETE].some((value) => value === transaction?.receipt?.state); } @@ -950,7 +950,7 @@ export { hasEReceipt, hasRoute, isReceiptBeingScanned, - didRceiptScanSucceed, + didReceiptScanSucceed, getValidWaypoints, isDistanceRequest, isFetchingWaypointsFromServer, diff --git a/src/libs/Violations/ViolationsUtils.ts b/src/libs/Violations/ViolationsUtils.ts index 686db5e6a6c5..4db21993043f 100644 --- a/src/libs/Violations/ViolationsUtils.ts +++ b/src/libs/Violations/ViolationsUtils.ts @@ -251,6 +251,7 @@ const ViolationsUtils = { maxAge = 0, tagName, taxName, + type, } = violation.data ?? {}; switch (violation.name) { @@ -288,7 +289,7 @@ const ViolationsUtils = { case 'missingTag': return translate('violations.missingTag', {tagName}); case 'modifiedAmount': - return translate('violations.modifiedAmount'); + return translate('violations.modifiedAmount', {type, displayPercentVariance: violation.data?.displayPercentVariance}); case 'modifiedDate': return translate('violations.modifiedDate'); case 'nonExpensiworksExpense': diff --git a/src/types/onyx/TransactionViolation.ts b/src/types/onyx/TransactionViolation.ts index 71223713c06c..67497edd2f3a 100644 --- a/src/types/onyx/TransactionViolation.ts +++ b/src/types/onyx/TransactionViolation.ts @@ -7,6 +7,12 @@ import type CONST from '@src/CONST'; */ type ViolationName = ValueOf; +/** + * Types for the data in the modifiedAmount violation + * Derived from CONST.VIOLATION_DATA_TYPES to maintain a single source of truth. + */ +type ViolationDataType = ValueOf; + /** Model of transaction violation data */ type TransactionViolationData = { /** Who rejected the transaction */ @@ -63,6 +69,12 @@ type TransactionViolationData = { /** Whether the current violation is `pending RTER` */ pendingPattern?: boolean; + /** modifiedAmount violation type (eg, 'distance', 'card') */ + type?: ViolationDataType; + + /** Percent Variance for modified amount violations */ + displayPercentVariance?: number; + /** List of duplicate transactions */ duplicates?: string[]; }; @@ -82,5 +94,5 @@ type TransactionViolation = { /** Collection of transaction violations */ type TransactionViolations = TransactionViolation[]; -export type {TransactionViolation, ViolationName}; +export type {TransactionViolation, ViolationName, ViolationDataType}; export default TransactionViolations;