diff --git a/src/components/MoneyRequestHeader.tsx b/src/components/MoneyRequestHeader.tsx
index 1d2579169c22..ac6870c0692c 100644
--- a/src/components/MoneyRequestHeader.tsx
+++ b/src/components/MoneyRequestHeader.tsx
@@ -64,6 +64,7 @@ function MoneyRequestHeader({report, parentReportAction, policy, shouldUseNarrow
const isApproved = ReportUtils.isReportApproved(moneyRequestReport);
const isDraft = ReportUtils.isOpenExpenseReport(moneyRequestReport);
const isOnHold = TransactionUtils.isOnHold(transaction);
+ const isDuplicate = TransactionUtils.isDuplicate(transaction?.transactionID ?? '');
const {isSmallScreenWidth, windowWidth} = useWindowDimensions();
// Only the requestor can take delete the expense, admins can only edit it.
@@ -120,7 +121,7 @@ function MoneyRequestHeader({report, parentReportAction, policy, shouldUseNarrow
const getStatusBarProps: () => MoneyRequestHeaderStatusBarProps | undefined = () => {
if (isOnHold) {
- return {title: translate('iou.hold'), description: translate('iou.expenseOnHold'), danger: true};
+ return {title: translate('iou.hold'), description: isDuplicate ? translate('iou.expenseDuplicate') : translate('iou.expenseOnHold'), danger: true};
}
if (TransactionUtils.isExpensifyCardTransaction(transaction) && TransactionUtils.isPending(transaction)) {
@@ -150,7 +151,7 @@ function MoneyRequestHeader({report, parentReportAction, policy, shouldUseNarrow
const isHoldCreator = ReportUtils.isHoldCreator(transaction, report?.reportID) && isRequestIOU;
const isTrackExpenseReport = ReportUtils.isTrackExpenseReport(report);
const canModifyStatus = !isTrackExpenseReport && (isPolicyAdmin || isActionOwner || isApprover);
- if (isOnHold && (isHoldCreator || (!isRequestIOU && canModifyStatus))) {
+ if (isOnHold && !isDuplicate && (isHoldCreator || (!isRequestIOU && canModifyStatus))) {
threeDotsMenuItems.push({
icon: Expensicons.Stopwatch,
text: translate('iou.unholdExpense'),
@@ -224,6 +225,14 @@ function MoneyRequestHeader({report, parentReportAction, policy, shouldUseNarrow
onPress={markAsCash}
/>
)}
+ {isDuplicate && !shouldUseNarrowLayout && (
+
+ )}
{shouldShowMarkAsCashButton && shouldUseNarrowLayout && (
@@ -236,6 +245,16 @@ function MoneyRequestHeader({report, parentReportAction, policy, shouldUseNarrow
/>
)}
+ {isDuplicate && shouldUseNarrowLayout && (
+
+
+
+ )}
{statusBarProps && (
1 || violationMessage.length > 15;
const hasViolationsAndFieldErrors = violationsCount > 0 && hasFieldErrors;
- return `${message} ${CONST.DOT_SEPARATOR} ${isTooLong || hasViolationsAndFieldErrors ? translate('violations.reviewRequired') : violationMessage}`;
+ message += ` ${CONST.DOT_SEPARATOR} ${isTooLong || hasViolationsAndFieldErrors ? translate('violations.reviewRequired') : violationMessage}`;
+ if (shouldShowHoldMessage) {
+ message += ` ${CONST.DOT_SEPARATOR} ${translate('iou.hold')}`;
+ }
+ return message;
}
const isMerchantMissing = TransactionUtils.isMerchantMissing(transaction);
@@ -175,7 +180,7 @@ function MoneyRequestPreviewContent({
message += ` ${CONST.DOT_SEPARATOR} ${translate('iou.missingAmount')}`;
} else if (isMerchantMissing) {
message += ` ${CONST.DOT_SEPARATOR} ${translate('iou.missingMerchant')}`;
- } else if (!(isSettled && !isSettlementOrApprovalPartial) && isOnHold) {
+ } else if (shouldShowHoldMessage) {
message += ` ${CONST.DOT_SEPARATOR} ${translate('iou.hold')}`;
}
} else if (hasNoticeTypeViolations && transaction && !ReportUtils.isReportApproved(iouReport) && !ReportUtils.isSettled(iouReport?.reportID)) {
@@ -184,7 +189,7 @@ function MoneyRequestPreviewContent({
message += ` ${CONST.DOT_SEPARATOR} ${translate('iou.approved')}`;
} else if (iouReport?.isCancelledIOU) {
message += ` ${CONST.DOT_SEPARATOR} ${translate('iou.canceled')}`;
- } else if (!(isSettled && !isSettlementOrApprovalPartial) && isOnHold) {
+ } else if (shouldShowHoldMessage) {
message += ` ${CONST.DOT_SEPARATOR} ${translate('iou.hold')}`;
}
return message;
diff --git a/src/components/ReportActionItem/ReportPreview.tsx b/src/components/ReportActionItem/ReportPreview.tsx
index 4677563d204f..b7a54733742f 100644
--- a/src/components/ReportActionItem/ReportPreview.tsx
+++ b/src/components/ReportActionItem/ReportPreview.tsx
@@ -137,9 +137,11 @@ function ReportPreview({
const hasReceipts = transactionsWithReceipts.length > 0;
const isScanning = hasReceipts && areAllRequestsBeingSmartScanned;
-
- // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
- const hasErrors = hasMissingSmartscanFields || (canUseViolations && ReportUtils.hasViolations(iouReportID, transactionViolations)) || ReportUtils.hasActionsWithErrors(iouReportID);
+ const hasErrors =
+ hasMissingSmartscanFields ||
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
+ (canUseViolations && (ReportUtils.hasViolations(iouReportID, transactionViolations) || ReportUtils.hasWarningTypeViolations(iouReportID, transactionViolations))) ||
+ ReportUtils.hasActionsWithErrors(iouReportID);
const lastThreeTransactionsWithReceipts = transactionsWithReceipts.slice(-3);
const lastThreeReceipts = lastThreeTransactionsWithReceipts.map((transaction) => ReceiptUtils.getThumbnailAndImageURIs(transaction));
const showRTERViolationMessage =
diff --git a/src/languages/en.ts b/src/languages/en.ts
index 1395be3f26c0..549aef18639d 100755
--- a/src/languages/en.ts
+++ b/src/languages/en.ts
@@ -763,6 +763,8 @@ export default {
holdReasonRequired: 'A reason is required when holding.',
expenseOnHold: 'This expense was put on hold. Review the comments for next steps.',
expensesOnHold: 'All expenses were put on hold. Review the comments for next steps.',
+ expenseDuplicate: 'This expense has the same details as another one. Review the duplicates to remove the hold.',
+ reviewDuplicates: 'Review duplicates',
confirmApprove: 'Confirm approval amount',
confirmApprovalAmount: "Approve what's not on hold, or approve the entire report.",
confirmPay: 'Confirm payment amount',
@@ -3116,7 +3118,7 @@ export default {
categoryOutOfPolicy: 'Category no longer valid',
conversionSurcharge: ({surcharge}: ViolationsConversionSurchargeParams) => `Applied ${surcharge}% conversion surcharge`,
customUnitOutOfPolicy: 'Unit no longer valid',
- duplicatedTransaction: 'Potential duplicate',
+ duplicatedTransaction: 'Duplicate',
fieldRequired: 'Report fields are required',
futureDate: 'Future date not allowed',
invoiceMarkup: ({invoiceMarkup}: ViolationsInvoiceMarkupParams) => `Marked up by ${invoiceMarkup}%`,
diff --git a/src/languages/es.ts b/src/languages/es.ts
index 7566ca7b7642..b2395428c43d 100644
--- a/src/languages/es.ts
+++ b/src/languages/es.ts
@@ -758,6 +758,8 @@ export default {
holdReasonRequired: 'Se requiere una razón para bloquear.',
expenseOnHold: 'Este gasto está bloqueado. Revisa los comentarios para saber como proceder.',
expensesOnHold: 'Todos los gastos quedaron bloqueado. Revisa los comentarios para saber como proceder.',
+ expenseDuplicate: 'Esta solicitud tiene los mismos detalles que otra. Revise los duplicados para eliminar la retención.',
+ reviewDuplicates: 'Revisar duplicados',
confirmApprove: 'Confirmar importe a aprobar',
confirmApprovalAmount: 'Aprueba lo que no está bloqueado, o aprueba todo el informe.',
confirmPay: 'Confirmar importe de pago',
@@ -3620,7 +3622,7 @@ export default {
categoryOutOfPolicy: 'La categoría ya no es válida',
conversionSurcharge: ({surcharge}: ViolationsConversionSurchargeParams = {}) => `${surcharge}% de recargo aplicado`,
customUnitOutOfPolicy: 'La unidad ya no es válida',
- duplicatedTransaction: 'Posible duplicado',
+ duplicatedTransaction: 'Duplicado',
fieldRequired: 'Los campos del informe son obligatorios',
futureDate: 'Fecha futura no permitida',
invoiceMarkup: ({invoiceMarkup}: ViolationsInvoiceMarkupParams) => `Incrementado un ${invoiceMarkup}%`,
diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts
index 826a869ec983..894284ee7489 100644
--- a/src/libs/ReportUtils.ts
+++ b/src/libs/ReportUtils.ts
@@ -5160,7 +5160,7 @@ function shouldHideReport(report: OnyxEntry, currentReportId: string): b
}
/**
- * Checks to see if a report's parentAction is an expense that contains a violation
+ * Checks to see if a report's parentAction is an expense that contains a violation type of either violation or warning
*/
function doesTransactionThreadHaveViolations(report: OnyxEntry, transactionViolations: OnyxCollection, parentReportAction: OnyxEntry): boolean {
if (parentReportAction?.actionName !== CONST.REPORT.ACTIONS.TYPE.IOU) {
@@ -5176,7 +5176,7 @@ function doesTransactionThreadHaveViolations(report: OnyxEntry, transact
if (report?.stateNum !== CONST.REPORT.STATE_NUM.OPEN && report?.stateNum !== CONST.REPORT.STATE_NUM.SUBMITTED) {
return false;
}
- return TransactionUtils.hasViolation(IOUTransactionID, transactionViolations);
+ return TransactionUtils.hasViolation(IOUTransactionID, transactionViolations) || TransactionUtils.hasWarningTypeViolation(IOUTransactionID, transactionViolations);
}
/**
@@ -5202,6 +5202,14 @@ function hasViolations(reportID: string, transactionViolations: OnyxCollection TransactionUtils.hasViolation(transaction.transactionID, transactionViolations));
}
+/**
+ * Checks to see if a report contains a violation of type `warning`
+ */
+function hasWarningTypeViolations(reportID: string, transactionViolations: OnyxCollection): boolean {
+ const transactions = TransactionUtils.getAllReportTransactions(reportID);
+ return transactions.some((transaction) => TransactionUtils.hasWarningTypeViolation(transaction.transactionID, transactionViolations));
+}
+
/**
* Takes several pieces of data from Onyx and evaluates if a report should be shown in the option list (either when searching
* for reports or the reports shown in the LHN).
@@ -6970,6 +6978,7 @@ export {
hasSmartscanError,
hasUpdatedTotal,
hasViolations,
+ hasWarningTypeViolations,
isActionCreator,
isAdminRoom,
isAdminsOnlyPostingRoom,
diff --git a/src/libs/TransactionUtils.ts b/src/libs/TransactionUtils.ts
index 594d47f2d8dd..7be90fc68192 100644
--- a/src/libs/TransactionUtils.ts
+++ b/src/libs/TransactionUtils.ts
@@ -40,6 +40,16 @@ Onyx.connect({
callback: (value) => (allReports = value),
});
+let currentUserEmail = '';
+let currentUserAccountID = -1;
+Onyx.connect({
+ key: ONYXKEYS.SESSION,
+ callback: (val) => {
+ currentUserEmail = val?.email ?? '';
+ currentUserAccountID = val?.accountID ?? -1;
+ },
+});
+
function isDistanceRequest(transaction: OnyxEntry): boolean {
// This is used during the expense creation flow before the transaction has been saved to the server
if (lodashHas(transaction, 'iouRequestType')) {
@@ -623,6 +633,23 @@ function getRecentTransactions(transactions: Record, size = 2):
.slice(0, size);
}
+/**
+ * Check if transaction has duplicatedTransaction violation.
+ * @param transactionID - the transaction to check
+ * @param checkDismissed - whether to check if the violation has already been dismissed as well
+ */
+function isDuplicate(transactionID: string, checkDismissed = false): boolean {
+ const hasDuplicatedViolation = !!allTransactionViolations?.[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`]?.some(
+ (violation: TransactionViolation) => violation.name === CONST.VIOLATIONS.DUPLICATED_TRANSACTION,
+ );
+ if (!checkDismissed) {
+ return hasDuplicatedViolation;
+ }
+ const didDismissedViolation =
+ allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]?.comment?.dismissedViolations?.duplicatedTransaction?.[currentUserEmail] === `${currentUserAccountID}`;
+ return hasDuplicatedViolation && !didDismissedViolation;
+}
+
/**
* Check if transaction is on hold
*/
@@ -631,7 +658,7 @@ function isOnHold(transaction: OnyxEntry): boolean {
return false;
}
- return !!transaction.comment?.hold;
+ return !!transaction.comment?.hold || isDuplicate(transaction.transactionID, true);
}
/**
@@ -661,6 +688,15 @@ function hasNoticeTypeViolation(transactionID: string, transactionViolations: On
return Boolean(transactionViolations?.[ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS + transactionID]?.some((violation: TransactionViolation) => violation.type === 'notice'));
}
+/**
+ * Checks if any violations for the provided transaction are of type 'warning'
+ */
+function hasWarningTypeViolation(transactionID: string, transactionViolations: OnyxCollection): boolean {
+ return Boolean(
+ transactionViolations?.[ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS + transactionID]?.some((violation: TransactionViolation) => violation.type === CONST.VIOLATION_TYPES.WARNING),
+ );
+}
+
/**
* Calculates tax amount from the given expense amount and tax percentage
*/
@@ -798,6 +834,7 @@ export {
isFetchingWaypointsFromServer,
isExpensifyCardTransaction,
isCardTransaction,
+ isDuplicate,
isPending,
isPosted,
isOnHold,
@@ -818,6 +855,7 @@ export {
hasReservationList,
hasViolation,
hasNoticeTypeViolation,
+ hasWarningTypeViolation,
isCustomUnitRateIDForP2P,
getRateID,
};
diff --git a/src/types/onyx/Transaction.ts b/src/types/onyx/Transaction.ts
index 1c2c32a9a640..23b2be2d5de8 100644
--- a/src/types/onyx/Transaction.ts
+++ b/src/types/onyx/Transaction.ts
@@ -7,6 +7,7 @@ import type {Participant, Split} from './IOU';
import type * as OnyxCommon from './OnyxCommon';
import type RecentWaypoint from './RecentWaypoint';
import type ReportAction from './ReportAction';
+import type {ViolationName} from './TransactionViolation';
type Waypoint = {
/** The name associated with the address of the waypoint */
@@ -55,6 +56,7 @@ type Comment = {
source?: string;
originalTransactionID?: string;
splits?: Split[];
+ dismissedViolations?: Record>;
};
type TransactionCustomUnit = {