From d766346c1f9277ae39d0f454b6ce8d438ec3430a Mon Sep 17 00:00:00 2001 From: mkzie2 Date: Tue, 5 Aug 2025 23:26:53 +0700 Subject: [PATCH 1/4] fix: approve button is present after submitting a scan expense with missing amount --- src/components/MoneyReportHeader.tsx | 11 +- src/libs/NextStepUtils.ts | 858 +++++++++------------ src/libs/ReportPrimaryActionUtils.ts | 7 +- src/libs/TransactionUtils/index.ts | 5 + tests/unit/ReportPrimaryActionUtilsTest.ts | 36 +- 5 files changed, 419 insertions(+), 498 deletions(-) diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index bf1cdd57365c..76ba65dc6dbe 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -24,7 +24,7 @@ import {getThreadReportIDsForTransactions, getTotalAmountForIOUReportPreviewButt import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; import type {ReportsSplitNavigatorParamList, SearchFullscreenNavigatorParamList, SearchReportParamList} from '@libs/Navigation/types'; -import {buildOptimisticNextStepForPreventSelfApprovalsEnabled} from '@libs/NextStepUtils'; +import {getReportNextStep} from '@libs/NextStepUtils'; import {isSecondaryActionAPaymentOption, selectPaymentType} from '@libs/PaymentUtils'; import type {KYCFlowEvent, TriggerKYCFlow} from '@libs/PaymentUtils'; import {getConnectedIntegration, getValidConnectedIntegration} from '@libs/PolicyUtils'; @@ -37,7 +37,6 @@ import { getArchiveReason, getIntegrationExportIcon, getIntegrationNameFromExportMessage as getIntegrationNameFromExportMessageUtils, - getNextApproverAccountID, getNonHeldAndFullAmount, getTransactionsWithReceipts, hasHeldExpenses as hasHeldExpensesReportUtils, @@ -47,7 +46,6 @@ import { isExported as isExportedUtils, isInvoiceReport as isInvoiceReportUtil, isProcessingReport, - isReportOwner, navigateOnDeleteExpense, navigateToDetailsPage, reportTransactionsSelector, @@ -306,12 +304,7 @@ function MoneyReportHeader({ const shouldShowStatusBar = hasAllPendingRTERViolations || shouldShowBrokenConnectionViolation || hasOnlyHeldExpenses || hasScanningReceipt || isPayAtEndExpense || hasOnlyPendingTransactions || hasDuplicates; - // When prevent self-approval is enabled & the current user is submitter AND they're submitting to themselves, we need to show the optimistic next step - // We should always show this optimistic message for policies with preventSelfApproval - // to avoid any flicker during transitions between online/offline states - const nextApproverAccountID = getNextApproverAccountID(moneyRequestReport); - const isSubmitterSameAsNextApprover = isReportOwner(moneyRequestReport) && nextApproverAccountID === moneyRequestReport?.ownerAccountID; - const optimisticNextStep = isSubmitterSameAsNextApprover && policy?.preventSelfApproval ? buildOptimisticNextStepForPreventSelfApprovalsEnabled() : nextStep; + const optimisticNextStep = getReportNextStep(nextStep, moneyRequestReport, transactions, policy); const shouldShowNextStep = isFromPaidPolicy && !isInvoiceReport && !shouldShowStatusBar; const {nonHeldAmount, fullAmount, hasValidNonHeldAmount} = getNonHeldAndFullAmount(moneyRequestReport, shouldShowPayButton); diff --git a/src/libs/NextStepUtils.ts b/src/libs/NextStepUtils.ts index 118deb8f5092..d1c4df08fcb7 100644 --- a/src/libs/NextStepUtils.ts +++ b/src/libs/NextStepUtils.ts @@ -1,498 +1,392 @@ -import {format, setDate} from 'date-fns'; -import {Str} from 'expensify-common'; -import Onyx from 'react-native-onyx'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import type {Beta, Policy, Report, ReportNextStep, TransactionViolations} from '@src/types/onyx'; -import type {Message} from '@src/types/onyx/ReportNextStep'; -import type DeepValueOf from '@src/types/utils/DeepValueOf'; -import EmailUtils from './EmailUtils'; -import Permissions from './Permissions'; -import {getLoginsByAccountIDs, getPersonalDetailsByIDs} from './PersonalDetailsUtils'; -import {getApprovalWorkflow, getCorrectedAutoReportingFrequency, getReimburserAccountID} from './PolicyUtils'; +import type {Policy, Report, ReportAction, ReportNameValuePairs, Transaction, TransactionViolation} from '@src/types/onyx'; +import {isApprover as isApproverUtils} from './actions/Policy/Member'; +import {getCurrentUserAccountID} from './actions/Report'; import { - getDisplayNameForParticipant, - getNextApproverAccountID, - getPersonalDetailsForAccountID, - hasViolations as hasViolationsReportUtils, - isExpenseReport, - isInvoiceReport, + arePaymentsEnabled as arePaymentsEnabledUtils, + getCorrectedAutoReportingFrequency, + getSubmitToAccountID, + getValidConnectedIntegration, + hasIntegrationAutoSync, + isPreferredExporter, +} from './PolicyUtils'; +import {getAllReportActions, getOneTransactionThreadReportID, isMoneyRequestAction} from './ReportActionsUtils'; +import { + canAddTransaction as canAddTransactionUtil, + canHoldUnholdReportAction, + getMoneyRequestSpendBreakdown, + getParentReport, + hasExportError as hasExportErrorUtil, + hasOnlyHeldExpenses, + hasReportBeenReopened as hasReportBeenReopenedUtils, + isArchivedReport, + isClosedReport as isClosedReportUtils, + isCurrentUserSubmitter, + isExpenseReport as isExpenseReportUtils, + isExported as isExportedUtil, + isHoldCreator, + isInvoiceReport as isInvoiceReportUtils, + isIOUReport as isIOUReportUtils, + isOpenReport as isOpenReportUtils, isPayer, + isProcessingReport as isProcessingReportUtils, + isReportApproved as isReportApprovedUtils, + isReportManager, + isSettled, } from './ReportUtils'; +import {getSession} from './SessionUtils'; +import { + allHavePendingRTERViolation, + hasPendingRTERViolation as hasPendingRTERViolationTransactionUtils, + isDuplicate, + isOnHold as isOnHoldTransactionUtils, + isPendingCardOrScanningTransaction, + isPendingCardOrUncompleteTransaction, + isScanning, + shouldShowBrokenConnectionViolationForMultipleTransactions, + shouldShowBrokenConnectionViolation as shouldShowBrokenConnectionViolationTransactionUtils, +} from './TransactionUtils'; + +type GetReportPrimaryActionParams = { + report: Report; + chatReport: OnyxEntry; + reportTransactions: Transaction[]; + violations: OnyxCollection; + policy?: Policy; + reportNameValuePairs?: ReportNameValuePairs; + reportActions?: ReportAction[]; + isChatReportArchived: boolean; + invoiceReceiverPolicy?: Policy; +}; + +function isAddExpenseAction(report: Report, reportTransactions: Transaction[], isChatReportArchived: boolean) { + if (isChatReportArchived) { + return false; + } + + const isExpenseReport = isExpenseReportUtils(report); + const canAddTransaction = canAddTransactionUtil(report); + + return isExpenseReport && canAddTransaction && reportTransactions.length === 0; +} + +function isSubmitAction(report: Report, reportTransactions: Transaction[], policy?: Policy, reportNameValuePairs?: ReportNameValuePairs, reportActions?: ReportAction[]) { + if (isArchivedReport(reportNameValuePairs)) { + return false; + } + + const isExpenseReport = isExpenseReportUtils(report); + const isReportSubmitter = isCurrentUserSubmitter(report); + const isOpenReport = isOpenReportUtils(report); + const isManualSubmitEnabled = getCorrectedAutoReportingFrequency(policy) === CONST.POLICY.AUTO_REPORTING_FREQUENCIES.MANUAL; + const transactionAreComplete = reportTransactions.every((transaction) => transaction.amount !== 0 || transaction.modifiedAmount !== 0); + + if (reportTransactions.length > 0 && reportTransactions.every((transaction) => isPendingCardOrUncompleteTransaction(transaction))) { + return false; + } + + const isAnyReceiptBeingScanned = reportTransactions?.some((transaction) => isScanning(transaction)); + const hasReportBeenReopened = hasReportBeenReopenedUtils(reportActions); + + if (isAnyReceiptBeingScanned) { + return false; + } + + const submitToAccountID = getSubmitToAccountID(policy, report); + + if (submitToAccountID === report.ownerAccountID && policy?.preventSelfApproval) { + return false; + } + + const baseIsSubmit = isExpenseReport && isReportSubmitter && isOpenReport && reportTransactions.length !== 0 && transactionAreComplete; + if (hasReportBeenReopened && baseIsSubmit) { + return true; + } + + return isManualSubmitEnabled && baseIsSubmit; +} + +function isApproveAction(report: Report, reportTransactions: Transaction[], policy?: Policy) { + const isAnyReceiptBeingScanned = reportTransactions?.some((transaction) => isScanning(transaction)); + + if (isAnyReceiptBeingScanned) { + return false; + } + + const currentUserAccountID = getCurrentUserAccountID(); + const managerID = report?.managerID ?? CONST.DEFAULT_NUMBER_ID; + const isCurrentUserManager = managerID === currentUserAccountID; + if (!isCurrentUserManager) { + return false; + } + const isExpenseReport = isExpenseReportUtils(report); + const isApprovalEnabled = policy?.approvalMode && policy.approvalMode !== CONST.POLICY.APPROVAL_MODE.OPTIONAL; + + if (!isExpenseReport || !isApprovalEnabled || reportTransactions.length === 0) { + return false; + } + + if (reportTransactions.length > 0 && reportTransactions.every((transaction) => isPendingCardOrScanningTransaction(transaction))) { + return false; + } + + const isPreventSelfApprovalEnabled = policy?.preventSelfApproval; + const isReportSubmitter = isCurrentUserSubmitter(report); + + if (isPreventSelfApprovalEnabled && isReportSubmitter) { + return false; + } + + return isProcessingReportUtils(report); +} + +function isPrimaryPayAction(report: Report, policy?: Policy, reportNameValuePairs?: ReportNameValuePairs, isChatReportArchived?: boolean, invoiceReceiverPolicy?: Policy) { + if (isArchivedReport(reportNameValuePairs) || isChatReportArchived) { + return false; + } + const isExpenseReport = isExpenseReportUtils(report); + const isReportPayer = isPayer(getSession(), report, false, policy); + const arePaymentsEnabled = arePaymentsEnabledUtils(policy); + const isReportApproved = isReportApprovedUtils({report}); + const isReportClosed = isClosedReportUtils(report); + const isProcessingReport = isProcessingReportUtils(report); + + const isApprovalEnabled = policy ? policy.approvalMode && policy.approvalMode !== CONST.POLICY.APPROVAL_MODE.OPTIONAL : false; + const isSubmittedWithoutApprovalsEnabled = !isApprovalEnabled && isProcessingReport; + + const isReportFinished = (isReportApproved && !report.isWaitingOnBankAccount) || isSubmittedWithoutApprovalsEnabled || isReportClosed; + const {reimbursableSpend} = getMoneyRequestSpendBreakdown(report); + + if (isReportPayer && isExpenseReport && arePaymentsEnabled && isReportFinished && reimbursableSpend > 0) { + return true; + } + + if (!isProcessingReport) { + return false; + } + + const isIOUReport = isIOUReportUtils(report); + + if (isIOUReport && isReportPayer && reimbursableSpend > 0) { + return true; + } + + const isInvoiceReport = isInvoiceReportUtils(report); + + if (!isInvoiceReport) { + return false; + } + + const parentReport = getParentReport(report); + if (parentReport?.invoiceReceiver?.type === CONST.REPORT.INVOICE_RECEIVER_TYPE.INDIVIDUAL && reimbursableSpend > 0) { + return parentReport?.invoiceReceiver?.accountID === getCurrentUserAccountID(); + } + + return invoiceReceiverPolicy?.role === CONST.POLICY.ROLE.ADMIN && reimbursableSpend > 0; +} + +function isExportAction(report: Report, policy?: Policy, reportActions?: ReportAction[]) { + if (!policy) { + return false; + } + + const connectedIntegration = getValidConnectedIntegration(policy); + const isInvoiceReport = isInvoiceReportUtils(report); + + if (!connectedIntegration || isInvoiceReport) { + return false; + } + + const isReportExporter = isPreferredExporter(policy); + if (!isReportExporter) { + return false; + } + + const syncEnabled = hasIntegrationAutoSync(policy, connectedIntegration); + const isExported = isExportedUtil(reportActions); + if (isExported) { + return false; + } + + const hasExportError = hasExportErrorUtil(reportActions); + if (syncEnabled && !hasExportError) { + return false; + } + + if (report.isWaitingOnBankAccount) { + return false; + } -let currentUserAccountID = -1; -let currentUserEmail = ''; -Onyx.connect({ - key: ONYXKEYS.SESSION, - callback: (value) => { - if (!value) { - return; - } - - currentUserAccountID = value?.accountID ?? CONST.DEFAULT_NUMBER_ID; - currentUserEmail = value?.email ?? ''; - }, -}); - -let allPolicies: OnyxCollection; -Onyx.connect({ - key: ONYXKEYS.COLLECTION.POLICY, - waitForCollectionCallback: true, - callback: (value) => (allPolicies = value), -}); - -let allBetas: OnyxEntry; -Onyx.connect({ - key: ONYXKEYS.BETAS, - callback: (value) => (allBetas = value), -}); - -let transactionViolations: OnyxCollection; -Onyx.connect({ - key: ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS, - waitForCollectionCallback: true, - callback: (value) => { - transactionViolations = value; - }, -}); - -function parseMessage(messages: Message[] | undefined) { - let nextStepHTML = ''; - messages?.forEach((part, index) => { - const isEmail = Str.isValidEmail(part.text); - let tagType = part.type ?? 'span'; - let content = Str.safeEscape(part.text); - - const previousPart = index !== 0 ? messages.at(index - 1) : undefined; - const nextPart = messages.at(index + 1); - - if (currentUserEmail === part.text || part.clickToCopyText === currentUserEmail) { - tagType = 'strong'; - content = nextPart?.text === `'s` ? 'your' : 'you'; - } else if (part.text === `'s` && (previousPart?.text === currentUserEmail || previousPart?.clickToCopyText === currentUserEmail)) { - content = ''; - } else if (isEmail) { - tagType = 'next-step-email'; - content = EmailUtils.prefixMailSeparatorsWithBreakOpportunities(content); - } - - nextStepHTML += `<${tagType}>${content}`; - }); - - const formattedHtml = nextStepHTML - .replace(/%expenses/g, 'expense(s)') - .replace(/%Expenses/g, 'Expense(s)') - .replace(/%tobe/g, 'are'); - - return `${formattedHtml}`; + const isReportReimbursed = isSettled(report); + const isReportApproved = isReportApprovedUtils({report}); + const isReportClosed = isClosedReportUtils(report); + + if (isReportApproved || isReportReimbursed || isReportClosed) { + return true; + } + + return false; +} + +function isRemoveHoldAction(report: Report, chatReport: OnyxEntry, reportTransactions: Transaction[]) { + const isReportOnHold = reportTransactions.some(isOnHoldTransactionUtils); + + if (!isReportOnHold) { + return false; + } + + const reportActions = getAllReportActions(report.reportID); + const transactionThreadReportID = getOneTransactionThreadReportID(report, chatReport, reportActions); + + if (!transactionThreadReportID) { + return false; + } + + // Transaction is attached to expense report but hold action is attached to transaction thread report + const isHolder = reportTransactions.some((transaction) => isHoldCreator(transaction, transactionThreadReportID)); + + return isHolder; +} + +function isReviewDuplicatesAction(report: Report, reportTransactions: Transaction[]) { + const hasDuplicates = reportTransactions.some((transaction) => isDuplicate(transaction)); + + if (!hasDuplicates) { + return false; + } + + const isReportApprover = isReportManager(report); + const isReportSubmitter = isCurrentUserSubmitter(report); + const isProcessingReport = isProcessingReportUtils(report); + const isReportOpen = isOpenReportUtils(report); + + const isSubmitterOrApprover = isReportSubmitter || isReportApprover; + const isReportActive = isReportOpen || isProcessingReport; + + if (isSubmitterOrApprover && isReportActive) { + return true; + } + + return false; +} + +function isMarkAsCashAction(report: Report, reportTransactions: Transaction[], violations: OnyxCollection, policy?: Policy) { + const isOneExpenseReport = isExpenseReportUtils(report) && reportTransactions.length === 1; + + if (!isOneExpenseReport) { + return false; + } + + const transactionIDs = reportTransactions.map((t) => t.transactionID); + const hasAllPendingRTERViolations = allHavePendingRTERViolation(reportTransactions, violations); + + if (hasAllPendingRTERViolations) { + return true; + } + + const isReportSubmitter = isCurrentUserSubmitter(report); + const isReportApprover = isApproverUtils(policy, getCurrentUserAccountID()); + const isAdmin = policy?.role === CONST.POLICY.ROLE.ADMIN; + + const shouldShowBrokenConnectionViolation = shouldShowBrokenConnectionViolationForMultipleTransactions(transactionIDs, report, policy, violations); + const userControlsReport = isReportSubmitter || isReportApprover || isAdmin; + return userControlsReport && shouldShowBrokenConnectionViolation; +} + +function getAllExpensesToHoldIfApplicable(report?: Report, reportActions?: ReportAction[]) { + if (!report || !reportActions || !hasOnlyHeldExpenses(report?.reportID)) { + return []; + } + + return reportActions?.filter((action) => isMoneyRequestAction(action) && action.childType === CONST.REPORT.TYPE.CHAT && canHoldUnholdReportAction(action).canUnholdRequest); } -function getNextApproverDisplayName(report: OnyxEntry, isUnapprove?: boolean) { - const approverAccountID = getNextApproverAccountID(report, isUnapprove); +function getReportPrimaryAction(params: GetReportPrimaryActionParams): ValueOf | '' { + const {report, reportTransactions, violations, policy, reportNameValuePairs, reportActions, isChatReportArchived, chatReport, invoiceReceiverPolicy} = params; + + const isPayActionWithAllExpensesHeld = isPrimaryPayAction(report, policy, reportNameValuePairs, isChatReportArchived) && hasOnlyHeldExpenses(report?.reportID); - return getDisplayNameForParticipant({accountID: approverAccountID}) ?? getPersonalDetailsForAccountID(approverAccountID).login; + if (isAddExpenseAction(report, reportTransactions, isChatReportArchived)) { + return CONST.REPORT.PRIMARY_ACTIONS.ADD_EXPENSE; + } + + if (isMarkAsCashAction(report, reportTransactions, violations, policy)) { + return CONST.REPORT.PRIMARY_ACTIONS.MARK_AS_CASH; + } + + if (isReviewDuplicatesAction(report, reportTransactions)) { + return CONST.REPORT.PRIMARY_ACTIONS.REVIEW_DUPLICATES; + } + + if (isRemoveHoldAction(report, chatReport, reportTransactions) || isPayActionWithAllExpensesHeld) { + return CONST.REPORT.PRIMARY_ACTIONS.REMOVE_HOLD; + } + + if (isSubmitAction(report, reportTransactions, policy, reportNameValuePairs, reportActions)) { + return CONST.REPORT.PRIMARY_ACTIONS.SUBMIT; + } + + if (isApproveAction(report, reportTransactions, policy)) { + return CONST.REPORT.PRIMARY_ACTIONS.APPROVE; + } + + if (isPrimaryPayAction(report, policy, reportNameValuePairs, isChatReportArchived, invoiceReceiverPolicy)) { + return CONST.REPORT.PRIMARY_ACTIONS.PAY; + } + + if (isExportAction(report, policy, reportActions)) { + return CONST.REPORT.PRIMARY_ACTIONS.EXPORT_TO_ACCOUNTING; + } + + if (getAllExpensesToHoldIfApplicable(report, reportActions).length) { + return CONST.REPORT.PRIMARY_ACTIONS.REMOVE_HOLD; + } + + return ''; } -function buildOptimisticNextStepForPreventSelfApprovalsEnabled() { - const optimisticNextStep: ReportNextStep = { - type: 'alert', - icon: CONST.NEXT_STEP.ICONS.HOURGLASS, - message: [ - { - text: "Oops! Looks like you're submitting to ", - }, - { - text: 'yourself', - type: 'next-step-email', - }, - { - text: '. Approving your own reports is ', - }, - { - text: 'forbidden', - type: 'next-step-email', - }, - { - text: ' by your workspace. Please submit this report to someone else or contact your admin to change the person you submit to.', - }, - ], - }; - - return optimisticNextStep; +function isMarkAsCashActionForTransaction(parentReport: Report, violations: TransactionViolation[], policy?: Policy): boolean { + const hasPendingRTERViolation = hasPendingRTERViolationTransactionUtils(violations); + + if (hasPendingRTERViolation) { + return true; + } + + const shouldShowBrokenConnectionViolation = shouldShowBrokenConnectionViolationTransactionUtils(parentReport, policy, violations); + + if (!shouldShowBrokenConnectionViolation) { + return false; + } + + const isReportSubmitter = isCurrentUserSubmitter(parentReport); + const isReportApprover = isApproverUtils(policy, getCurrentUserAccountID()); + const isAdmin = policy?.role === CONST.POLICY.ROLE.ADMIN; + + return isReportSubmitter || isReportApprover || isAdmin; } -/** - * Generates an optimistic nextStep based on a current report status and other properties. - * - * @param report - * @param predictedNextStatus - a next expected status of the report - * @param shouldFixViolations - whether to show `fix the issue` next step - * @param isUnapprove - whether a report is being unapproved - * @param isReopen - whether a report is being reopened - * @returns nextStep - */ -function buildNextStep( - report: OnyxEntry, - predictedNextStatus: ValueOf, - shouldFixViolations?: boolean, - isUnapprove?: boolean, - isReopen?: boolean, -): ReportNextStep | null { - if (!isExpenseReport(report)) { - return null; - } - - const {policyID = '', ownerAccountID = -1} = report ?? {}; - const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`] ?? ({} as Policy); - const {harvesting, autoReportingOffset} = policy; - const autoReportingFrequency = getCorrectedAutoReportingFrequency(policy); - const hasViolations = hasViolationsReportUtils(report?.reportID, transactionViolations); - const isASAPSubmitBetaEnabled = Permissions.isBetaEnabled(CONST.BETAS.ASAP_SUBMIT, allBetas); - const isInstantSubmitEnabled = autoReportingFrequency === CONST.POLICY.AUTO_REPORTING_FREQUENCIES.INSTANT; - const shouldShowFixMessage = hasViolations && isInstantSubmitEnabled && !isASAPSubmitBetaEnabled; - const [policyOwnerPersonalDetails, ownerPersonalDetails] = getPersonalDetailsByIDs({ - accountIDs: [policy.ownerAccountID ?? CONST.DEFAULT_NUMBER_ID, ownerAccountID], - currentUserAccountID, - shouldChangeUserDisplayName: true, - }); - const isReportContainingTransactions = - report && - ((report.total !== 0 && report.total !== undefined) || - (report.unheldTotal !== 0 && report.unheldTotal !== undefined) || - (report.unheldNonReimbursableTotal !== 0 && report.unheldNonReimbursableTotal !== undefined)); - - const ownerDisplayName = ownerPersonalDetails?.displayName ?? ownerPersonalDetails?.login ?? getDisplayNameForParticipant({accountID: ownerAccountID}); - const policyOwnerDisplayName = policyOwnerPersonalDetails?.displayName ?? policyOwnerPersonalDetails?.login ?? getDisplayNameForParticipant({accountID: policy.ownerAccountID}); - const nextApproverDisplayName = getNextApproverDisplayName(report, isUnapprove); - const approverAccountID = getNextApproverAccountID(report, isUnapprove); - const approvers = getLoginsByAccountIDs([approverAccountID ?? CONST.DEFAULT_NUMBER_ID]); - - const reimburserAccountID = getReimburserAccountID(policy); - const hasValidAccount = !!policy?.achAccount?.accountNumber || policy.reimbursementChoice !== CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES; - const type: ReportNextStep['type'] = 'neutral'; - let optimisticNextStep: ReportNextStep | null; - - const nextStepPayExpense = { - type, - icon: CONST.NEXT_STEP.ICONS.HOURGLASS, - message: [ - { - text: 'Waiting for ', - }, - ownerAccountID === -1 || !policy.ownerAccountID - ? { - text: 'an admin', - } - : { - text: shouldShowFixMessage ? ownerDisplayName : policyOwnerDisplayName, - type: 'strong', - }, - { - text: ' to ', - }, - ...(shouldShowFixMessage ? [{text: 'fix the issue(s)'}] : [{text: 'pay'}, {text: ' %expenses.'}]), - ], - }; - - const noActionRequired = { - icon: CONST.NEXT_STEP.ICONS.CHECKMARK, - type, - message: [ - { - text: 'No further action required!', - }, - ], - }; - - switch (predictedNextStatus) { - // Generates an optimistic nextStep once a report has been opened - case CONST.REPORT.STATUS_NUM.OPEN: - if ((isASAPSubmitBetaEnabled && hasViolations && isInstantSubmitEnabled) || shouldFixViolations) { - optimisticNextStep = { - type, - icon: CONST.NEXT_STEP.ICONS.HOURGLASS, - message: [ - { - text: 'Waiting for ', - }, - { - text: `${ownerDisplayName}`, - type: 'strong', - clickToCopyText: ownerAccountID === currentUserAccountID ? currentUserEmail : '', - }, - { - text: ' to ', - }, - { - text: 'fix the issue(s)', - }, - ], - }; - break; - } - if (isReopen) { - optimisticNextStep = { - type, - icon: CONST.NEXT_STEP.ICONS.HOURGLASS, - message: [ - { - text: 'Waiting for ', - }, - { - text: `${ownerDisplayName}`, - type: 'strong', - clickToCopyText: ownerAccountID === currentUserAccountID ? currentUserEmail : '', - }, - { - text: ' to ', - }, - { - text: 'submit', - }, - { - text: ' %expenses.', - }, - ], - }; - break; - } - - // Self review - optimisticNextStep = { - type, - icon: CONST.NEXT_STEP.ICONS.HOURGLASS, - message: [ - { - text: 'Waiting for ', - }, - { - text: `${ownerDisplayName}`, - type: 'strong', - clickToCopyText: ownerAccountID === currentUserAccountID ? currentUserEmail : '', - }, - { - text: ' to ', - }, - { - text: 'add', - }, - { - text: ' %expenses.', - }, - ], - }; - - // Scheduled submit enabled - if (harvesting?.enabled && autoReportingFrequency !== CONST.POLICY.AUTO_REPORTING_FREQUENCIES.MANUAL && isReportContainingTransactions) { - optimisticNextStep.message = [ - { - text: 'Waiting for ', - }, - { - text: `${ownerDisplayName}`, - type: 'strong', - clickToCopyText: ownerAccountID === currentUserAccountID ? currentUserEmail : '', - }, - { - text: `'s`, - type: 'strong', - }, - { - text: ' %expenses to automatically submit', - }, - ]; - let harvestingSuffix = ''; - - if (autoReportingFrequency) { - const currentDate = new Date(); - let autoSubmissionDate = ''; - let monthlyText = ''; - - if (autoReportingOffset === CONST.POLICY.AUTO_REPORTING_OFFSET.LAST_DAY_OF_MONTH) { - monthlyText = 'on the last day of the month'; - } else if (autoReportingOffset === CONST.POLICY.AUTO_REPORTING_OFFSET.LAST_BUSINESS_DAY_OF_MONTH) { - monthlyText = 'on the last business day of the month'; - } else if (autoReportingOffset !== undefined) { - autoSubmissionDate = format(setDate(currentDate, autoReportingOffset), CONST.DATE.ORDINAL_DAY_OF_MONTH); - } - - const harvestingSuffixes: Record, string> = { - [CONST.POLICY.AUTO_REPORTING_FREQUENCIES.IMMEDIATE]: 'later today', - [CONST.POLICY.AUTO_REPORTING_FREQUENCIES.WEEKLY]: 'on Sunday', - [CONST.POLICY.AUTO_REPORTING_FREQUENCIES.SEMI_MONTHLY]: 'on the 1st and 16th of each month', - [CONST.POLICY.AUTO_REPORTING_FREQUENCIES.MONTHLY]: autoSubmissionDate ? `on the ${autoSubmissionDate} of each month` : monthlyText, - [CONST.POLICY.AUTO_REPORTING_FREQUENCIES.TRIP]: 'at the end of their trip', - [CONST.POLICY.AUTO_REPORTING_FREQUENCIES.INSTANT]: '', - [CONST.POLICY.AUTO_REPORTING_FREQUENCIES.MANUAL]: '', - }; - - if (harvestingSuffixes[autoReportingFrequency]) { - harvestingSuffix = `${harvestingSuffixes[autoReportingFrequency]}`; - } - } - - optimisticNextStep.message.push({ - text: ` ${harvestingSuffix}`, - }); - } - - // Manual submission - if (report?.total !== 0 && !harvesting?.enabled && autoReportingFrequency === CONST.POLICY.AUTO_REPORTING_FREQUENCIES.MANUAL) { - optimisticNextStep.message = [ - { - text: 'Waiting for ', - }, - { - text: `${ownerDisplayName}`, - type: 'strong', - clickToCopyText: ownerAccountID === currentUserAccountID ? currentUserEmail : '', - }, - { - text: ' to ', - }, - { - text: 'submit', - }, - { - text: ' %expenses.', - }, - ]; - } - - break; - - // Generates an optimistic nextStep once a report has been submitted - case CONST.REPORT.STATUS_NUM.SUBMITTED: { - if (policy.approvalMode === CONST.POLICY.APPROVAL_MODE.OPTIONAL) { - optimisticNextStep = nextStepPayExpense; - break; - } - // Another owner - optimisticNextStep = { - type, - icon: CONST.NEXT_STEP.ICONS.HOURGLASS, - }; - // We want to show pending approval next step for cases where the policy has approvals enabled - const policyApprovalMode = getApprovalWorkflow(policy); - if ([CONST.POLICY.APPROVAL_MODE.BASIC, CONST.POLICY.APPROVAL_MODE.ADVANCED].some((approvalMode) => approvalMode === policyApprovalMode)) { - optimisticNextStep.message = [ - { - text: 'Waiting for ', - }, - { - text: nextApproverDisplayName, - type: 'strong', - clickToCopyText: approvers.at(0), - }, - { - text: ' to ', - }, - { - text: 'approve', - }, - { - text: ' %expenses.', - }, - ]; - } else { - optimisticNextStep.message = [ - { - text: 'Waiting for ', - }, - isPayer( - { - accountID: currentUserAccountID, - email: currentUserEmail, - }, - report, - ) - ? { - text: `you`, - type: 'strong', - } - : { - text: `an admin`, - }, - { - text: ' to ', - }, - { - text: 'pay', - }, - { - text: ' %expenses.', - }, - ]; - } - - break; - } - - // Generates an optimistic nextStep once a report has been closed for example in the case of Submit and Close approval flow - case CONST.REPORT.STATUS_NUM.CLOSED: - optimisticNextStep = noActionRequired; - - break; - - // Generates an optimistic nextStep once a report has been paid - case CONST.REPORT.STATUS_NUM.REIMBURSED: - optimisticNextStep = noActionRequired; - - break; - - // Generates an optimistic nextStep once a report has been approved - case CONST.REPORT.STATUS_NUM.APPROVED: - if ( - isInvoiceReport(report) || - !isPayer( - { - accountID: currentUserAccountID, - email: currentUserEmail, - }, - report, - ) - ) { - optimisticNextStep = noActionRequired; - - break; - } - // Self review - optimisticNextStep = { - type, - icon: CONST.NEXT_STEP.ICONS.HOURGLASS, - message: [ - { - text: 'Waiting for ', - }, - reimburserAccountID === -1 - ? { - text: 'an admin', - } - : { - text: getDisplayNameForParticipant({accountID: reimburserAccountID}), - type: 'strong', - }, - { - text: ' to ', - }, - { - text: hasValidAccount ? 'pay' : 'finish setting up', - }, - { - text: hasValidAccount ? ' %expenses.' : ' a business bank account.', - }, - ], - }; - break; - - // Resets a nextStep - default: - optimisticNextStep = null; - } - - return optimisticNextStep; +function getTransactionThreadPrimaryAction( + transactionThreadReport: Report, + parentReport: Report, + reportTransaction: Transaction, + violations: TransactionViolation[], + policy?: Policy, +): ValueOf | '' { + if (isHoldCreator(reportTransaction, transactionThreadReport.reportID)) { + return CONST.REPORT.TRANSACTION_PRIMARY_ACTIONS.REMOVE_HOLD; + } + + if (isReviewDuplicatesAction(parentReport, [reportTransaction])) { + return CONST.REPORT.TRANSACTION_PRIMARY_ACTIONS.REVIEW_DUPLICATES; + } + + if (isMarkAsCashActionForTransaction(parentReport, violations, policy)) { + return CONST.REPORT.TRANSACTION_PRIMARY_ACTIONS.MARK_AS_CASH; + } + + return ''; } -export {parseMessage, buildNextStep, buildOptimisticNextStepForPreventSelfApprovalsEnabled}; +export {getReportPrimaryAction, getTransactionThreadPrimaryAction, isAddExpenseAction, isPrimaryPayAction, isExportAction, getAllExpensesToHoldIfApplicable, isReviewDuplicatesAction}; diff --git a/src/libs/ReportPrimaryActionUtils.ts b/src/libs/ReportPrimaryActionUtils.ts index a36eec096746..d1c4df08fcb7 100644 --- a/src/libs/ReportPrimaryActionUtils.ts +++ b/src/libs/ReportPrimaryActionUtils.ts @@ -42,7 +42,8 @@ import { hasPendingRTERViolation as hasPendingRTERViolationTransactionUtils, isDuplicate, isOnHold as isOnHoldTransactionUtils, - isPending, + isPendingCardOrScanningTransaction, + isPendingCardOrUncompleteTransaction, isScanning, shouldShowBrokenConnectionViolationForMultipleTransactions, shouldShowBrokenConnectionViolation as shouldShowBrokenConnectionViolationTransactionUtils, @@ -82,7 +83,7 @@ function isSubmitAction(report: Report, reportTransactions: Transaction[], polic const isManualSubmitEnabled = getCorrectedAutoReportingFrequency(policy) === CONST.POLICY.AUTO_REPORTING_FREQUENCIES.MANUAL; const transactionAreComplete = reportTransactions.every((transaction) => transaction.amount !== 0 || transaction.modifiedAmount !== 0); - if (reportTransactions.length > 0 && reportTransactions.every((transaction) => isPending(transaction))) { + if (reportTransactions.length > 0 && reportTransactions.every((transaction) => isPendingCardOrUncompleteTransaction(transaction))) { return false; } @@ -127,7 +128,7 @@ function isApproveAction(report: Report, reportTransactions: Transaction[], poli return false; } - if (reportTransactions.length > 0 && reportTransactions.every((transaction) => isPending(transaction))) { + if (reportTransactions.length > 0 && reportTransactions.every((transaction) => isPendingCardOrScanningTransaction(transaction))) { return false; } diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index bc7a5ad0f002..16e8ab7d82ee 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -242,6 +242,10 @@ function isPendingCardOrScanningTransaction(transaction: OnyxEntry) return (isExpensifyCardTransaction(transaction) && isPending(transaction)) || isPartialTransaction(transaction) || (isScanRequest(transaction) && isScanning(transaction)); } +function isPendingCardOrUncompleteTransaction(transaction: OnyxEntry): boolean { + return (isExpensifyCardTransaction(transaction) && isPending(transaction)) || (isAmountMissing(transaction) && isMerchantMissing(transaction)); +} + /** * Optimistically generate a transaction. * @@ -1970,6 +1974,7 @@ export { isDemoTransaction, shouldShowViolation, isUnreportedAndHasInvalidDistanceRateTransaction, + isPendingCardOrUncompleteTransaction, }; export type {TransactionChanges}; diff --git a/tests/unit/ReportPrimaryActionUtilsTest.ts b/tests/unit/ReportPrimaryActionUtilsTest.ts index bfbbdc618c84..9481c7d4a2be 100644 --- a/tests/unit/ReportPrimaryActionUtilsTest.ts +++ b/tests/unit/ReportPrimaryActionUtilsTest.ts @@ -80,7 +80,7 @@ describe('getPrimaryAction', () => { ); }); - it('should not return SUBMIT option for admin with only pending transactions', async () => { + it('should not return SUBMIT option for admin with only pending/uncomplete transactions', async () => { const report = { reportID: REPORT_ID, type: CONST.REPORT.TYPE.EXPENSE, @@ -99,9 +99,22 @@ describe('getPrimaryAction', () => { amount: 10, merchant: 'Merchant', date: '2025-01-01', + bank: CONST.EXPENSIFY_CARD.BANK, } as unknown as Transaction; - expect(getReportPrimaryAction({report, chatReport, reportTransactions: [transaction], violations: {}, policy: policy as Policy, isChatReportArchived: false})).toBe(''); + const transaction1 = { + reportID: `${REPORT_ID}`, + amount: 0, + modifiedAmount: 0, + receipt: { + source: 'test', + state: CONST.IOU.RECEIPT_STATE.SCAN_FAILED, + }, + merchant: CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT, + modifiedMerchant: undefined, + } as unknown as Transaction; + + expect(getReportPrimaryAction({report, chatReport, reportTransactions: [transaction, transaction1], violations: {}, policy: policy as Policy, isChatReportArchived: false})).toBe(''); }); it('should return Approve for report being processed', async () => { @@ -123,6 +136,8 @@ describe('getPrimaryAction', () => { comment: { hold: 'Hold', }, + amount: 10, + merchant: 'merchant', } as unknown as Transaction; expect(getReportPrimaryAction({report, chatReport, reportTransactions: [transaction], violations: {}, policy: policy as Policy, isChatReportArchived: false})).toBe( @@ -157,7 +172,7 @@ describe('getPrimaryAction', () => { expect(getReportPrimaryAction({report, chatReport, reportTransactions: [transaction], violations: {}, policy: policy as Policy, isChatReportArchived: false})).toBe(''); }); - it('should return empty for report being processed but transactions are pending', async () => { + it('should return empty for report being processed but transactions are pending/partial', async () => { const report = { reportID: REPORT_ID, type: CONST.REPORT.TYPE.EXPENSE, @@ -177,9 +192,22 @@ describe('getPrimaryAction', () => { amount: 10, merchant: 'Merchant', date: '2025-01-01', + bank: CONST.EXPENSIFY_CARD.BANK, } as unknown as Transaction; - expect(getReportPrimaryAction({report, chatReport, reportTransactions: [transaction], violations: {}, policy: policy as Policy, isChatReportArchived: false})).toBe(''); + const transaction1 = { + reportID: `${REPORT_ID}`, + amount: 0, + modifiedAmount: 0, + receipt: { + source: 'test', + state: CONST.IOU.RECEIPT_STATE.SCAN_FAILED, + }, + merchant: CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT, + modifiedMerchant: undefined, + } as unknown as Transaction; + + expect(getReportPrimaryAction({report, chatReport, reportTransactions: [transaction, transaction1], violations: {}, policy: policy as Policy, isChatReportArchived: false})).toBe(''); }); it('should return PAY for submitted invoice report if paid as personal', async () => { From 78eeddef11472ae028e19e9d58ae8c411174f838 Mon Sep 17 00:00:00 2001 From: mkzie2 Date: Tue, 5 Aug 2025 23:41:53 +0700 Subject: [PATCH 2/4] revert unchange --- src/libs/NextStepUtils.ts | 889 ++++++++++++++++++++++---------------- 1 file changed, 523 insertions(+), 366 deletions(-) diff --git a/src/libs/NextStepUtils.ts b/src/libs/NextStepUtils.ts index d1c4df08fcb7..e133fb3d2e2b 100644 --- a/src/libs/NextStepUtils.ts +++ b/src/libs/NextStepUtils.ts @@ -1,392 +1,549 @@ +import {format, setDate} from 'date-fns'; +import {Str} from 'expensify-common'; +import Onyx from 'react-native-onyx'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import CONST from '@src/CONST'; -import type {Policy, Report, ReportAction, ReportNameValuePairs, Transaction, TransactionViolation} from '@src/types/onyx'; -import {isApprover as isApproverUtils} from './actions/Policy/Member'; -import {getCurrentUserAccountID} from './actions/Report'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Beta, Policy, Report, ReportNextStep, Transaction, TransactionViolations} from '@src/types/onyx'; +import type {Message} from '@src/types/onyx/ReportNextStep'; +import type DeepValueOf from '@src/types/utils/DeepValueOf'; +import EmailUtils from './EmailUtils'; +import Permissions from './Permissions'; +import {getLoginsByAccountIDs, getPersonalDetailsByIDs} from './PersonalDetailsUtils'; +import {getApprovalWorkflow, getCorrectedAutoReportingFrequency, getReimburserAccountID} from './PolicyUtils'; import { - arePaymentsEnabled as arePaymentsEnabledUtils, - getCorrectedAutoReportingFrequency, - getSubmitToAccountID, - getValidConnectedIntegration, - hasIntegrationAutoSync, - isPreferredExporter, -} from './PolicyUtils'; -import {getAllReportActions, getOneTransactionThreadReportID, isMoneyRequestAction} from './ReportActionsUtils'; -import { - canAddTransaction as canAddTransactionUtil, - canHoldUnholdReportAction, - getMoneyRequestSpendBreakdown, - getParentReport, - hasExportError as hasExportErrorUtil, - hasOnlyHeldExpenses, - hasReportBeenReopened as hasReportBeenReopenedUtils, - isArchivedReport, - isClosedReport as isClosedReportUtils, - isCurrentUserSubmitter, - isExpenseReport as isExpenseReportUtils, - isExported as isExportedUtil, - isHoldCreator, - isInvoiceReport as isInvoiceReportUtils, - isIOUReport as isIOUReportUtils, - isOpenReport as isOpenReportUtils, + getDisplayNameForParticipant, + getNextApproverAccountID, + getPersonalDetailsForAccountID, + hasViolations as hasViolationsReportUtils, + isExpenseReport, + isInvoiceReport, + isOpenExpenseReport, isPayer, - isProcessingReport as isProcessingReportUtils, - isReportApproved as isReportApprovedUtils, - isReportManager, - isSettled, + isProcessingReport, + isReportOwner, } from './ReportUtils'; -import {getSession} from './SessionUtils'; -import { - allHavePendingRTERViolation, - hasPendingRTERViolation as hasPendingRTERViolationTransactionUtils, - isDuplicate, - isOnHold as isOnHoldTransactionUtils, - isPendingCardOrScanningTransaction, - isPendingCardOrUncompleteTransaction, - isScanning, - shouldShowBrokenConnectionViolationForMultipleTransactions, - shouldShowBrokenConnectionViolation as shouldShowBrokenConnectionViolationTransactionUtils, -} from './TransactionUtils'; - -type GetReportPrimaryActionParams = { - report: Report; - chatReport: OnyxEntry; - reportTransactions: Transaction[]; - violations: OnyxCollection; - policy?: Policy; - reportNameValuePairs?: ReportNameValuePairs; - reportActions?: ReportAction[]; - isChatReportArchived: boolean; - invoiceReceiverPolicy?: Policy; -}; - -function isAddExpenseAction(report: Report, reportTransactions: Transaction[], isChatReportArchived: boolean) { - if (isChatReportArchived) { - return false; - } - - const isExpenseReport = isExpenseReportUtils(report); - const canAddTransaction = canAddTransactionUtil(report); - - return isExpenseReport && canAddTransaction && reportTransactions.length === 0; -} - -function isSubmitAction(report: Report, reportTransactions: Transaction[], policy?: Policy, reportNameValuePairs?: ReportNameValuePairs, reportActions?: ReportAction[]) { - if (isArchivedReport(reportNameValuePairs)) { - return false; - } - - const isExpenseReport = isExpenseReportUtils(report); - const isReportSubmitter = isCurrentUserSubmitter(report); - const isOpenReport = isOpenReportUtils(report); - const isManualSubmitEnabled = getCorrectedAutoReportingFrequency(policy) === CONST.POLICY.AUTO_REPORTING_FREQUENCIES.MANUAL; - const transactionAreComplete = reportTransactions.every((transaction) => transaction.amount !== 0 || transaction.modifiedAmount !== 0); - - if (reportTransactions.length > 0 && reportTransactions.every((transaction) => isPendingCardOrUncompleteTransaction(transaction))) { - return false; - } - - const isAnyReceiptBeingScanned = reportTransactions?.some((transaction) => isScanning(transaction)); - const hasReportBeenReopened = hasReportBeenReopenedUtils(reportActions); - - if (isAnyReceiptBeingScanned) { - return false; - } - - const submitToAccountID = getSubmitToAccountID(policy, report); - - if (submitToAccountID === report.ownerAccountID && policy?.preventSelfApproval) { - return false; - } - - const baseIsSubmit = isExpenseReport && isReportSubmitter && isOpenReport && reportTransactions.length !== 0 && transactionAreComplete; - if (hasReportBeenReopened && baseIsSubmit) { - return true; - } - - return isManualSubmitEnabled && baseIsSubmit; -} - -function isApproveAction(report: Report, reportTransactions: Transaction[], policy?: Policy) { - const isAnyReceiptBeingScanned = reportTransactions?.some((transaction) => isScanning(transaction)); - - if (isAnyReceiptBeingScanned) { - return false; - } - - const currentUserAccountID = getCurrentUserAccountID(); - const managerID = report?.managerID ?? CONST.DEFAULT_NUMBER_ID; - const isCurrentUserManager = managerID === currentUserAccountID; - if (!isCurrentUserManager) { - return false; - } - const isExpenseReport = isExpenseReportUtils(report); - const isApprovalEnabled = policy?.approvalMode && policy.approvalMode !== CONST.POLICY.APPROVAL_MODE.OPTIONAL; - - if (!isExpenseReport || !isApprovalEnabled || reportTransactions.length === 0) { - return false; - } - - if (reportTransactions.length > 0 && reportTransactions.every((transaction) => isPendingCardOrScanningTransaction(transaction))) { - return false; - } - - const isPreventSelfApprovalEnabled = policy?.preventSelfApproval; - const isReportSubmitter = isCurrentUserSubmitter(report); - - if (isPreventSelfApprovalEnabled && isReportSubmitter) { - return false; - } - - return isProcessingReportUtils(report); -} - -function isPrimaryPayAction(report: Report, policy?: Policy, reportNameValuePairs?: ReportNameValuePairs, isChatReportArchived?: boolean, invoiceReceiverPolicy?: Policy) { - if (isArchivedReport(reportNameValuePairs) || isChatReportArchived) { - return false; - } - const isExpenseReport = isExpenseReportUtils(report); - const isReportPayer = isPayer(getSession(), report, false, policy); - const arePaymentsEnabled = arePaymentsEnabledUtils(policy); - const isReportApproved = isReportApprovedUtils({report}); - const isReportClosed = isClosedReportUtils(report); - const isProcessingReport = isProcessingReportUtils(report); - - const isApprovalEnabled = policy ? policy.approvalMode && policy.approvalMode !== CONST.POLICY.APPROVAL_MODE.OPTIONAL : false; - const isSubmittedWithoutApprovalsEnabled = !isApprovalEnabled && isProcessingReport; - - const isReportFinished = (isReportApproved && !report.isWaitingOnBankAccount) || isSubmittedWithoutApprovalsEnabled || isReportClosed; - const {reimbursableSpend} = getMoneyRequestSpendBreakdown(report); - - if (isReportPayer && isExpenseReport && arePaymentsEnabled && isReportFinished && reimbursableSpend > 0) { - return true; - } - - if (!isProcessingReport) { - return false; - } - - const isIOUReport = isIOUReportUtils(report); - - if (isIOUReport && isReportPayer && reimbursableSpend > 0) { - return true; - } - - const isInvoiceReport = isInvoiceReportUtils(report); - - if (!isInvoiceReport) { - return false; - } - - const parentReport = getParentReport(report); - if (parentReport?.invoiceReceiver?.type === CONST.REPORT.INVOICE_RECEIVER_TYPE.INDIVIDUAL && reimbursableSpend > 0) { - return parentReport?.invoiceReceiver?.accountID === getCurrentUserAccountID(); - } - - return invoiceReceiverPolicy?.role === CONST.POLICY.ROLE.ADMIN && reimbursableSpend > 0; -} - -function isExportAction(report: Report, policy?: Policy, reportActions?: ReportAction[]) { - if (!policy) { - return false; - } - - const connectedIntegration = getValidConnectedIntegration(policy); - const isInvoiceReport = isInvoiceReportUtils(report); - - if (!connectedIntegration || isInvoiceReport) { - return false; - } - - const isReportExporter = isPreferredExporter(policy); - if (!isReportExporter) { - return false; - } - - const syncEnabled = hasIntegrationAutoSync(policy, connectedIntegration); - const isExported = isExportedUtil(reportActions); - if (isExported) { - return false; - } - - const hasExportError = hasExportErrorUtil(reportActions); - if (syncEnabled && !hasExportError) { - return false; - } - - if (report.isWaitingOnBankAccount) { - return false; - } - - const isReportReimbursed = isSettled(report); - const isReportApproved = isReportApprovedUtils({report}); - const isReportClosed = isClosedReportUtils(report); - - if (isReportApproved || isReportReimbursed || isReportClosed) { - return true; - } - - return false; +import { isPendingCardOrScanningTransaction, isPendingCardOrUncompleteTransaction } from './TransactionUtils'; + +let currentUserAccountID = -1; +let currentUserEmail = ''; +Onyx.connect({ + key: ONYXKEYS.SESSION, + callback: (value) => { + if (!value) { + return; + } + + currentUserAccountID = value?.accountID ?? CONST.DEFAULT_NUMBER_ID; + currentUserEmail = value?.email ?? ''; + }, +}); + +let allPolicies: OnyxCollection; +Onyx.connect({ + key: ONYXKEYS.COLLECTION.POLICY, + waitForCollectionCallback: true, + callback: (value) => (allPolicies = value), +}); + +let allBetas: OnyxEntry; +Onyx.connect({ + key: ONYXKEYS.BETAS, + callback: (value) => (allBetas = value), +}); + +let transactionViolations: OnyxCollection; +Onyx.connect({ + key: ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS, + waitForCollectionCallback: true, + callback: (value) => { + transactionViolations = value; + }, +}); + +function parseMessage(messages: Message[] | undefined) { + let nextStepHTML = ''; + messages?.forEach((part, index) => { + const isEmail = Str.isValidEmail(part.text); + let tagType = part.type ?? 'span'; + let content = Str.safeEscape(part.text); + + const previousPart = index !== 0 ? messages.at(index - 1) : undefined; + const nextPart = messages.at(index + 1); + + if (currentUserEmail === part.text || part.clickToCopyText === currentUserEmail) { + tagType = 'strong'; + content = nextPart?.text === `'s` ? 'your' : 'you'; + } else if (part.text === `'s` && (previousPart?.text === currentUserEmail || previousPart?.clickToCopyText === currentUserEmail)) { + content = ''; + } else if (isEmail) { + tagType = 'next-step-email'; + content = EmailUtils.prefixMailSeparatorsWithBreakOpportunities(content); + } + + nextStepHTML += `<${tagType}>${content}`; + }); + + const formattedHtml = nextStepHTML + .replace(/%expenses/g, 'expense(s)') + .replace(/%Expenses/g, 'Expense(s)') + .replace(/%tobe/g, 'are'); + + return `${formattedHtml}`; } -function isRemoveHoldAction(report: Report, chatReport: OnyxEntry, reportTransactions: Transaction[]) { - const isReportOnHold = reportTransactions.some(isOnHoldTransactionUtils); - - if (!isReportOnHold) { - return false; - } - - const reportActions = getAllReportActions(report.reportID); - const transactionThreadReportID = getOneTransactionThreadReportID(report, chatReport, reportActions); +function getNextApproverDisplayName(report: OnyxEntry, isUnapprove?: boolean) { + const approverAccountID = getNextApproverAccountID(report, isUnapprove); - if (!transactionThreadReportID) { - return false; - } - - // Transaction is attached to expense report but hold action is attached to transaction thread report - const isHolder = reportTransactions.some((transaction) => isHoldCreator(transaction, transactionThreadReportID)); - - return isHolder; + return getDisplayNameForParticipant({accountID: approverAccountID}) ?? getPersonalDetailsForAccountID(approverAccountID).login; } -function isReviewDuplicatesAction(report: Report, reportTransactions: Transaction[]) { - const hasDuplicates = reportTransactions.some((transaction) => isDuplicate(transaction)); - - if (!hasDuplicates) { - return false; - } - - const isReportApprover = isReportManager(report); - const isReportSubmitter = isCurrentUserSubmitter(report); - const isProcessingReport = isProcessingReportUtils(report); - const isReportOpen = isOpenReportUtils(report); - - const isSubmitterOrApprover = isReportSubmitter || isReportApprover; - const isReportActive = isReportOpen || isProcessingReport; - - if (isSubmitterOrApprover && isReportActive) { - return true; - } - - return false; +function buildOptimisticNextStepForPreventSelfApprovalsEnabled() { + const optimisticNextStep: ReportNextStep = { + type: 'alert', + icon: CONST.NEXT_STEP.ICONS.HOURGLASS, + message: [ + { + text: "Oops! Looks like you're submitting to ", + }, + { + text: 'yourself', + type: 'next-step-email', + }, + { + text: '. Approving your own reports is ', + }, + { + text: 'forbidden', + type: 'next-step-email', + }, + { + text: ' by your workspace. Please submit this report to someone else or contact your admin to change the person you submit to.', + }, + ], + }; + + return optimisticNextStep; } -function isMarkAsCashAction(report: Report, reportTransactions: Transaction[], violations: OnyxCollection, policy?: Policy) { - const isOneExpenseReport = isExpenseReportUtils(report) && reportTransactions.length === 1; - - if (!isOneExpenseReport) { - return false; - } - - const transactionIDs = reportTransactions.map((t) => t.transactionID); - const hasAllPendingRTERViolations = allHavePendingRTERViolation(reportTransactions, violations); - - if (hasAllPendingRTERViolations) { - return true; - } - - const isReportSubmitter = isCurrentUserSubmitter(report); - const isReportApprover = isApproverUtils(policy, getCurrentUserAccountID()); - const isAdmin = policy?.role === CONST.POLICY.ROLE.ADMIN; - - const shouldShowBrokenConnectionViolation = shouldShowBrokenConnectionViolationForMultipleTransactions(transactionIDs, report, policy, violations); - const userControlsReport = isReportSubmitter || isReportApprover || isAdmin; - return userControlsReport && shouldShowBrokenConnectionViolation; +function buildOptimisticFixIssueNextStep() { + const optimisticNextStep: ReportNextStep = { + type: 'neutral', + icon: CONST.NEXT_STEP.ICONS.HOURGLASS, + message: [ + { + text: "Waiting for ", + }, + { + text: `you`, + type: 'strong', + }, + { + text: ' to ', + }, + { + text: 'fix the issue(s)', + }, + ], + }; + + return optimisticNextStep; } -function getAllExpensesToHoldIfApplicable(report?: Report, reportActions?: ReportAction[]) { - if (!report || !reportActions || !hasOnlyHeldExpenses(report?.reportID)) { - return []; - } +function getReportNextStep(currentNextStep: ReportNextStep | undefined, moneyRequestReport: OnyxEntry, transactions: Array>, policy: OnyxEntry) { + const nextApproverAccountID = getNextApproverAccountID(moneyRequestReport); - return reportActions?.filter((action) => isMoneyRequestAction(action) && action.childType === CONST.REPORT.TYPE.CHAT && canHoldUnholdReportAction(action).canUnholdRequest); -} - -function getReportPrimaryAction(params: GetReportPrimaryActionParams): ValueOf | '' { - const {report, reportTransactions, violations, policy, reportNameValuePairs, reportActions, isChatReportArchived, chatReport, invoiceReceiverPolicy} = params; - - const isPayActionWithAllExpensesHeld = isPrimaryPayAction(report, policy, reportNameValuePairs, isChatReportArchived) && hasOnlyHeldExpenses(report?.reportID); - - if (isAddExpenseAction(report, reportTransactions, isChatReportArchived)) { - return CONST.REPORT.PRIMARY_ACTIONS.ADD_EXPENSE; - } - - if (isMarkAsCashAction(report, reportTransactions, violations, policy)) { - return CONST.REPORT.PRIMARY_ACTIONS.MARK_AS_CASH; - } - - if (isReviewDuplicatesAction(report, reportTransactions)) { - return CONST.REPORT.PRIMARY_ACTIONS.REVIEW_DUPLICATES; - } - - if (isRemoveHoldAction(report, chatReport, reportTransactions) || isPayActionWithAllExpensesHeld) { - return CONST.REPORT.PRIMARY_ACTIONS.REMOVE_HOLD; - } - - if (isSubmitAction(report, reportTransactions, policy, reportNameValuePairs, reportActions)) { - return CONST.REPORT.PRIMARY_ACTIONS.SUBMIT; - } - - if (isApproveAction(report, reportTransactions, policy)) { - return CONST.REPORT.PRIMARY_ACTIONS.APPROVE; - } - - if (isPrimaryPayAction(report, policy, reportNameValuePairs, isChatReportArchived, invoiceReceiverPolicy)) { - return CONST.REPORT.PRIMARY_ACTIONS.PAY; + if (isOpenExpenseReport(moneyRequestReport) && transactions.length > 0 && transactions.every((transaction) => isPendingCardOrUncompleteTransaction(transaction))) { + return buildOptimisticFixIssueNextStep(); } - if (isExportAction(report, policy, reportActions)) { - return CONST.REPORT.PRIMARY_ACTIONS.EXPORT_TO_ACCOUNTING; + if (isProcessingReport(moneyRequestReport) && transactions.length > 0 && transactions.every((transaction) => isPendingCardOrScanningTransaction(transaction))) { + return buildOptimisticFixIssueNextStep(); } - if (getAllExpensesToHoldIfApplicable(report, reportActions).length) { - return CONST.REPORT.PRIMARY_ACTIONS.REMOVE_HOLD; + const isSubmitterSameAsNextApprover = isReportOwner(moneyRequestReport) && nextApproverAccountID === moneyRequestReport?.ownerAccountID; + + // When prevent self-approval is enabled & the current user is submitter AND they're submitting to themselves, we need to show the optimistic next step + // We should always show this optimistic message for policies with preventSelfApproval + // to avoid any flicker during transitions between online/offline states + if (isSubmitterSameAsNextApprover && policy?.preventSelfApproval) { + return buildOptimisticNextStepForPreventSelfApprovalsEnabled(); } - return ''; + return currentNextStep; } -function isMarkAsCashActionForTransaction(parentReport: Report, violations: TransactionViolation[], policy?: Policy): boolean { - const hasPendingRTERViolation = hasPendingRTERViolationTransactionUtils(violations); - - if (hasPendingRTERViolation) { - return true; - } - - const shouldShowBrokenConnectionViolation = shouldShowBrokenConnectionViolationTransactionUtils(parentReport, policy, violations); - - if (!shouldShowBrokenConnectionViolation) { - return false; - } - - const isReportSubmitter = isCurrentUserSubmitter(parentReport); - const isReportApprover = isApproverUtils(policy, getCurrentUserAccountID()); - const isAdmin = policy?.role === CONST.POLICY.ROLE.ADMIN; - - return isReportSubmitter || isReportApprover || isAdmin; -} - -function getTransactionThreadPrimaryAction( - transactionThreadReport: Report, - parentReport: Report, - reportTransaction: Transaction, - violations: TransactionViolation[], - policy?: Policy, -): ValueOf | '' { - if (isHoldCreator(reportTransaction, transactionThreadReport.reportID)) { - return CONST.REPORT.TRANSACTION_PRIMARY_ACTIONS.REMOVE_HOLD; - } - - if (isReviewDuplicatesAction(parentReport, [reportTransaction])) { - return CONST.REPORT.TRANSACTION_PRIMARY_ACTIONS.REVIEW_DUPLICATES; - } - - if (isMarkAsCashActionForTransaction(parentReport, violations, policy)) { - return CONST.REPORT.TRANSACTION_PRIMARY_ACTIONS.MARK_AS_CASH; - } - - return ''; +/** + * Generates an optimistic nextStep based on a current report status and other properties. + * + * @param report + * @param predictedNextStatus - a next expected status of the report + * @param shouldFixViolations - whether to show `fix the issue` next step + * @param isUnapprove - whether a report is being unapproved + * @param isReopen - whether a report is being reopened + * @returns nextStep + */ +function buildNextStep( + report: OnyxEntry, + predictedNextStatus: ValueOf, + shouldFixViolations?: boolean, + isUnapprove?: boolean, + isReopen?: boolean, +): ReportNextStep | null { + if (!isExpenseReport(report)) { + return null; + } + + const {policyID = '', ownerAccountID = -1} = report ?? {}; + const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`] ?? ({} as Policy); + const {harvesting, autoReportingOffset} = policy; + const autoReportingFrequency = getCorrectedAutoReportingFrequency(policy); + const hasViolations = hasViolationsReportUtils(report?.reportID, transactionViolations); + const isASAPSubmitBetaEnabled = Permissions.isBetaEnabled(CONST.BETAS.ASAP_SUBMIT, allBetas); + const isInstantSubmitEnabled = autoReportingFrequency === CONST.POLICY.AUTO_REPORTING_FREQUENCIES.INSTANT; + const shouldShowFixMessage = hasViolations && isInstantSubmitEnabled && !isASAPSubmitBetaEnabled; + const [policyOwnerPersonalDetails, ownerPersonalDetails] = getPersonalDetailsByIDs({ + accountIDs: [policy.ownerAccountID ?? CONST.DEFAULT_NUMBER_ID, ownerAccountID], + currentUserAccountID, + shouldChangeUserDisplayName: true, + }); + const isReportContainingTransactions = + report && + ((report.total !== 0 && report.total !== undefined) || + (report.unheldTotal !== 0 && report.unheldTotal !== undefined) || + (report.unheldNonReimbursableTotal !== 0 && report.unheldNonReimbursableTotal !== undefined)); + + const ownerDisplayName = ownerPersonalDetails?.displayName ?? ownerPersonalDetails?.login ?? getDisplayNameForParticipant({accountID: ownerAccountID}); + const policyOwnerDisplayName = policyOwnerPersonalDetails?.displayName ?? policyOwnerPersonalDetails?.login ?? getDisplayNameForParticipant({accountID: policy.ownerAccountID}); + const nextApproverDisplayName = getNextApproverDisplayName(report, isUnapprove); + const approverAccountID = getNextApproverAccountID(report, isUnapprove); + const approvers = getLoginsByAccountIDs([approverAccountID ?? CONST.DEFAULT_NUMBER_ID]); + + const reimburserAccountID = getReimburserAccountID(policy); + const hasValidAccount = !!policy?.achAccount?.accountNumber || policy.reimbursementChoice !== CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES; + const type: ReportNextStep['type'] = 'neutral'; + let optimisticNextStep: ReportNextStep | null; + + const nextStepPayExpense = { + type, + icon: CONST.NEXT_STEP.ICONS.HOURGLASS, + message: [ + { + text: 'Waiting for ', + }, + ownerAccountID === -1 || !policy.ownerAccountID + ? { + text: 'an admin', + } + : { + text: shouldShowFixMessage ? ownerDisplayName : policyOwnerDisplayName, + type: 'strong', + }, + { + text: ' to ', + }, + ...(shouldShowFixMessage ? [{text: 'fix the issue(s)'}] : [{text: 'pay'}, {text: ' %expenses.'}]), + ], + }; + + const noActionRequired = { + icon: CONST.NEXT_STEP.ICONS.CHECKMARK, + type, + message: [ + { + text: 'No further action required!', + }, + ], + }; + + switch (predictedNextStatus) { + // Generates an optimistic nextStep once a report has been opened + case CONST.REPORT.STATUS_NUM.OPEN: + if ((isASAPSubmitBetaEnabled && hasViolations && isInstantSubmitEnabled) || shouldFixViolations) { + optimisticNextStep = { + type, + icon: CONST.NEXT_STEP.ICONS.HOURGLASS, + message: [ + { + text: 'Waiting for ', + }, + { + text: `${ownerDisplayName}`, + type: 'strong', + clickToCopyText: ownerAccountID === currentUserAccountID ? currentUserEmail : '', + }, + { + text: ' to ', + }, + { + text: 'fix the issue(s)', + }, + ], + }; + break; + } + if (isReopen) { + optimisticNextStep = { + type, + icon: CONST.NEXT_STEP.ICONS.HOURGLASS, + message: [ + { + text: 'Waiting for ', + }, + { + text: `${ownerDisplayName}`, + type: 'strong', + clickToCopyText: ownerAccountID === currentUserAccountID ? currentUserEmail : '', + }, + { + text: ' to ', + }, + { + text: 'submit', + }, + { + text: ' %expenses.', + }, + ], + }; + break; + } + + // Self review + optimisticNextStep = { + type, + icon: CONST.NEXT_STEP.ICONS.HOURGLASS, + message: [ + { + text: 'Waiting for ', + }, + { + text: `${ownerDisplayName}`, + type: 'strong', + clickToCopyText: ownerAccountID === currentUserAccountID ? currentUserEmail : '', + }, + { + text: ' to ', + }, + { + text: 'add', + }, + { + text: ' %expenses.', + }, + ], + }; + + // Scheduled submit enabled + if (harvesting?.enabled && autoReportingFrequency !== CONST.POLICY.AUTO_REPORTING_FREQUENCIES.MANUAL && isReportContainingTransactions) { + optimisticNextStep.message = [ + { + text: 'Waiting for ', + }, + { + text: `${ownerDisplayName}`, + type: 'strong', + clickToCopyText: ownerAccountID === currentUserAccountID ? currentUserEmail : '', + }, + { + text: `'s`, + type: 'strong', + }, + { + text: ' %expenses to automatically submit', + }, + ]; + let harvestingSuffix = ''; + + if (autoReportingFrequency) { + const currentDate = new Date(); + let autoSubmissionDate = ''; + let monthlyText = ''; + + if (autoReportingOffset === CONST.POLICY.AUTO_REPORTING_OFFSET.LAST_DAY_OF_MONTH) { + monthlyText = 'on the last day of the month'; + } else if (autoReportingOffset === CONST.POLICY.AUTO_REPORTING_OFFSET.LAST_BUSINESS_DAY_OF_MONTH) { + monthlyText = 'on the last business day of the month'; + } else if (autoReportingOffset !== undefined) { + autoSubmissionDate = format(setDate(currentDate, autoReportingOffset), CONST.DATE.ORDINAL_DAY_OF_MONTH); + } + + const harvestingSuffixes: Record, string> = { + [CONST.POLICY.AUTO_REPORTING_FREQUENCIES.IMMEDIATE]: 'later today', + [CONST.POLICY.AUTO_REPORTING_FREQUENCIES.WEEKLY]: 'on Sunday', + [CONST.POLICY.AUTO_REPORTING_FREQUENCIES.SEMI_MONTHLY]: 'on the 1st and 16th of each month', + [CONST.POLICY.AUTO_REPORTING_FREQUENCIES.MONTHLY]: autoSubmissionDate ? `on the ${autoSubmissionDate} of each month` : monthlyText, + [CONST.POLICY.AUTO_REPORTING_FREQUENCIES.TRIP]: 'at the end of their trip', + [CONST.POLICY.AUTO_REPORTING_FREQUENCIES.INSTANT]: '', + [CONST.POLICY.AUTO_REPORTING_FREQUENCIES.MANUAL]: '', + }; + + if (harvestingSuffixes[autoReportingFrequency]) { + harvestingSuffix = `${harvestingSuffixes[autoReportingFrequency]}`; + } + } + + optimisticNextStep.message.push({ + text: ` ${harvestingSuffix}`, + }); + } + + // Manual submission + if (report?.total !== 0 && !harvesting?.enabled && autoReportingFrequency === CONST.POLICY.AUTO_REPORTING_FREQUENCIES.MANUAL) { + optimisticNextStep.message = [ + { + text: 'Waiting for ', + }, + { + text: `${ownerDisplayName}`, + type: 'strong', + clickToCopyText: ownerAccountID === currentUserAccountID ? currentUserEmail : '', + }, + { + text: ' to ', + }, + { + text: 'submit', + }, + { + text: ' %expenses.', + }, + ]; + } + + break; + + // Generates an optimistic nextStep once a report has been submitted + case CONST.REPORT.STATUS_NUM.SUBMITTED: { + if (policy.approvalMode === CONST.POLICY.APPROVAL_MODE.OPTIONAL) { + optimisticNextStep = nextStepPayExpense; + break; + } + // Another owner + optimisticNextStep = { + type, + icon: CONST.NEXT_STEP.ICONS.HOURGLASS, + }; + // We want to show pending approval next step for cases where the policy has approvals enabled + const policyApprovalMode = getApprovalWorkflow(policy); + if ([CONST.POLICY.APPROVAL_MODE.BASIC, CONST.POLICY.APPROVAL_MODE.ADVANCED].some((approvalMode) => approvalMode === policyApprovalMode)) { + optimisticNextStep.message = [ + { + text: 'Waiting for ', + }, + { + text: nextApproverDisplayName, + type: 'strong', + clickToCopyText: approvers.at(0), + }, + { + text: ' to ', + }, + { + text: 'approve', + }, + { + text: ' %expenses.', + }, + ]; + } else { + optimisticNextStep.message = [ + { + text: 'Waiting for ', + }, + isPayer( + { + accountID: currentUserAccountID, + email: currentUserEmail, + }, + report, + ) + ? { + text: `you`, + type: 'strong', + } + : { + text: `an admin`, + }, + { + text: ' to ', + }, + { + text: 'pay', + }, + { + text: ' %expenses.', + }, + ]; + } + + break; + } + + // Generates an optimistic nextStep once a report has been closed for example in the case of Submit and Close approval flow + case CONST.REPORT.STATUS_NUM.CLOSED: + optimisticNextStep = noActionRequired; + + break; + + // Generates an optimistic nextStep once a report has been paid + case CONST.REPORT.STATUS_NUM.REIMBURSED: + optimisticNextStep = noActionRequired; + + break; + + // Generates an optimistic nextStep once a report has been approved + case CONST.REPORT.STATUS_NUM.APPROVED: + if ( + isInvoiceReport(report) || + !isPayer( + { + accountID: currentUserAccountID, + email: currentUserEmail, + }, + report, + ) + ) { + optimisticNextStep = noActionRequired; + + break; + } + // Self review + optimisticNextStep = { + type, + icon: CONST.NEXT_STEP.ICONS.HOURGLASS, + message: [ + { + text: 'Waiting for ', + }, + reimburserAccountID === -1 + ? { + text: 'an admin', + } + : { + text: getDisplayNameForParticipant({accountID: reimburserAccountID}), + type: 'strong', + }, + { + text: ' to ', + }, + { + text: hasValidAccount ? 'pay' : 'finish setting up', + }, + { + text: hasValidAccount ? ' %expenses.' : ' a business bank account.', + }, + ], + }; + break; + + // Resets a nextStep + default: + optimisticNextStep = null; + } + + return optimisticNextStep; } -export {getReportPrimaryAction, getTransactionThreadPrimaryAction, isAddExpenseAction, isPrimaryPayAction, isExportAction, getAllExpensesToHoldIfApplicable, isReviewDuplicatesAction}; +export {parseMessage, buildNextStep, buildOptimisticNextStepForPreventSelfApprovalsEnabled, getReportNextStep}; From 92e75c7d1b8891193937411a1682e1ed7cf1cf25 Mon Sep 17 00:00:00 2001 From: mkzie2 Date: Tue, 5 Aug 2025 23:47:05 +0700 Subject: [PATCH 3/4] fix spell check --- src/libs/NextStepUtils.ts | 4 ++-- src/libs/ReportPrimaryActionUtils.ts | 4 ++-- src/libs/TransactionUtils/index.ts | 4 ++-- tests/unit/ReportPrimaryActionUtilsTest.ts | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/libs/NextStepUtils.ts b/src/libs/NextStepUtils.ts index e133fb3d2e2b..c9b3bc54c4ef 100644 --- a/src/libs/NextStepUtils.ts +++ b/src/libs/NextStepUtils.ts @@ -24,7 +24,7 @@ import { isProcessingReport, isReportOwner, } from './ReportUtils'; -import { isPendingCardOrScanningTransaction, isPendingCardOrUncompleteTransaction } from './TransactionUtils'; +import { isPendingCardOrScanningTransaction, isPendingCardOrIncompleteTransaction } from './TransactionUtils'; let currentUserAccountID = -1; let currentUserEmail = ''; @@ -154,7 +154,7 @@ function buildOptimisticFixIssueNextStep() { function getReportNextStep(currentNextStep: ReportNextStep | undefined, moneyRequestReport: OnyxEntry, transactions: Array>, policy: OnyxEntry) { const nextApproverAccountID = getNextApproverAccountID(moneyRequestReport); - if (isOpenExpenseReport(moneyRequestReport) && transactions.length > 0 && transactions.every((transaction) => isPendingCardOrUncompleteTransaction(transaction))) { + if (isOpenExpenseReport(moneyRequestReport) && transactions.length > 0 && transactions.every((transaction) => isPendingCardOrIncompleteTransaction(transaction))) { return buildOptimisticFixIssueNextStep(); } diff --git a/src/libs/ReportPrimaryActionUtils.ts b/src/libs/ReportPrimaryActionUtils.ts index d1c4df08fcb7..503a3615595f 100644 --- a/src/libs/ReportPrimaryActionUtils.ts +++ b/src/libs/ReportPrimaryActionUtils.ts @@ -43,7 +43,7 @@ import { isDuplicate, isOnHold as isOnHoldTransactionUtils, isPendingCardOrScanningTransaction, - isPendingCardOrUncompleteTransaction, + isPendingCardOrIncompleteTransaction, isScanning, shouldShowBrokenConnectionViolationForMultipleTransactions, shouldShowBrokenConnectionViolation as shouldShowBrokenConnectionViolationTransactionUtils, @@ -83,7 +83,7 @@ function isSubmitAction(report: Report, reportTransactions: Transaction[], polic const isManualSubmitEnabled = getCorrectedAutoReportingFrequency(policy) === CONST.POLICY.AUTO_REPORTING_FREQUENCIES.MANUAL; const transactionAreComplete = reportTransactions.every((transaction) => transaction.amount !== 0 || transaction.modifiedAmount !== 0); - if (reportTransactions.length > 0 && reportTransactions.every((transaction) => isPendingCardOrUncompleteTransaction(transaction))) { + if (reportTransactions.length > 0 && reportTransactions.every((transaction) => isPendingCardOrIncompleteTransaction(transaction))) { return false; } diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index 16e8ab7d82ee..8bbe1654eeba 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -242,7 +242,7 @@ function isPendingCardOrScanningTransaction(transaction: OnyxEntry) return (isExpensifyCardTransaction(transaction) && isPending(transaction)) || isPartialTransaction(transaction) || (isScanRequest(transaction) && isScanning(transaction)); } -function isPendingCardOrUncompleteTransaction(transaction: OnyxEntry): boolean { +function isPendingCardOrIncompleteTransaction(transaction: OnyxEntry): boolean { return (isExpensifyCardTransaction(transaction) && isPending(transaction)) || (isAmountMissing(transaction) && isMerchantMissing(transaction)); } @@ -1974,7 +1974,7 @@ export { isDemoTransaction, shouldShowViolation, isUnreportedAndHasInvalidDistanceRateTransaction, - isPendingCardOrUncompleteTransaction, + isPendingCardOrIncompleteTransaction, }; export type {TransactionChanges}; diff --git a/tests/unit/ReportPrimaryActionUtilsTest.ts b/tests/unit/ReportPrimaryActionUtilsTest.ts index 9481c7d4a2be..a2f0c231db6e 100644 --- a/tests/unit/ReportPrimaryActionUtilsTest.ts +++ b/tests/unit/ReportPrimaryActionUtilsTest.ts @@ -80,7 +80,7 @@ describe('getPrimaryAction', () => { ); }); - it('should not return SUBMIT option for admin with only pending/uncomplete transactions', async () => { + it('should not return SUBMIT option for admin with only pending/incomplete transactions', async () => { const report = { reportID: REPORT_ID, type: CONST.REPORT.TYPE.EXPENSE, From d750737e36467a0241f103f4b50aa390dd35ee54 Mon Sep 17 00:00:00 2001 From: mkzie2 Date: Tue, 5 Aug 2025 23:57:27 +0700 Subject: [PATCH 4/4] fix prettier error --- src/libs/NextStepUtils.ts | 6 +++--- src/libs/ReportPrimaryActionUtils.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/libs/NextStepUtils.ts b/src/libs/NextStepUtils.ts index c9b3bc54c4ef..ee64fed1f9ec 100644 --- a/src/libs/NextStepUtils.ts +++ b/src/libs/NextStepUtils.ts @@ -24,7 +24,7 @@ import { isProcessingReport, isReportOwner, } from './ReportUtils'; -import { isPendingCardOrScanningTransaction, isPendingCardOrIncompleteTransaction } from './TransactionUtils'; +import {isPendingCardOrIncompleteTransaction, isPendingCardOrScanningTransaction} from './TransactionUtils'; let currentUserAccountID = -1; let currentUserEmail = ''; @@ -133,7 +133,7 @@ function buildOptimisticFixIssueNextStep() { icon: CONST.NEXT_STEP.ICONS.HOURGLASS, message: [ { - text: "Waiting for ", + text: 'Waiting for ', }, { text: `you`, @@ -163,7 +163,7 @@ function getReportNextStep(currentNextStep: ReportNextStep | undefined, moneyReq } const isSubmitterSameAsNextApprover = isReportOwner(moneyRequestReport) && nextApproverAccountID === moneyRequestReport?.ownerAccountID; - + // When prevent self-approval is enabled & the current user is submitter AND they're submitting to themselves, we need to show the optimistic next step // We should always show this optimistic message for policies with preventSelfApproval // to avoid any flicker during transitions between online/offline states diff --git a/src/libs/ReportPrimaryActionUtils.ts b/src/libs/ReportPrimaryActionUtils.ts index 503a3615595f..5767ec0dd46f 100644 --- a/src/libs/ReportPrimaryActionUtils.ts +++ b/src/libs/ReportPrimaryActionUtils.ts @@ -42,8 +42,8 @@ import { hasPendingRTERViolation as hasPendingRTERViolationTransactionUtils, isDuplicate, isOnHold as isOnHoldTransactionUtils, - isPendingCardOrScanningTransaction, isPendingCardOrIncompleteTransaction, + isPendingCardOrScanningTransaction, isScanning, shouldShowBrokenConnectionViolationForMultipleTransactions, shouldShowBrokenConnectionViolation as shouldShowBrokenConnectionViolationTransactionUtils,