Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
181f464
Block report submission when strict policy rules are enabled with vio…
abzokhattab Oct 10, 2025
0c58450
Merge remote-tracking branch 'origin/main' into strict-policy-submit-…
abzokhattab Oct 15, 2025
d96f09c
Merge remote-tracking branch 'origin/main' into strict-policy-submit-…
abzokhattab Oct 18, 2025
590e322
Remove violation tooltip
abzokhattab Oct 18, 2025
3a0dee3
minor edit
abzokhattab Oct 18, 2025
da37814
fix ts
abzokhattab Oct 18, 2025
7f8d6bd
fixing eslint
abzokhattab Oct 18, 2025
27f2d91
fixing eslint errs
abzokhattab Oct 18, 2025
3b56f69
fixing ts err
abzokhattab Oct 18, 2025
ceaa89d
fix eslint
abzokhattab Oct 18, 2025
902ba2a
fixing eslint
abzokhattab Oct 18, 2025
cad8617
Minor refactor
abzokhattab Oct 21, 2025
b3db34c
Remove parentheses from issue(s) in waitingForSubmitterToFixViolation…
abzokhattab Oct 22, 2025
d48987b
Move strict policy next step to NextStepUtils, keep English hardcoded…
abzokhattab Oct 22, 2025
6f38615
Add unit tests
abzokhattab Oct 22, 2025
43de8dd
minor edit with test title to follow existing pattern
abzokhattab Oct 22, 2025
6d940f9
chore: trigger pipeline
abzokhattab Oct 22, 2025
0a9ec6c
Merge branch 'main' into strict-policy-submit-block
abzokhattab Oct 23, 2025
a3ea30f
Merge remote-tracking branch 'origin/main' into strict-policy-submit-…
abzokhattab Oct 23, 2025
77e0e14
merge into main
abzokhattab Oct 23, 2025
197d9c7
fixing eslint
abzokhattab Oct 23, 2025
12fb7e4
Merge remote-tracking branch 'origin/main' into strict-policy-submit-…
abzokhattab Oct 23, 2025
09efc42
Merge remote-tracking branch 'origin/main' into strict-policy-submit-…
abzokhattab Oct 23, 2025
0ada7a8
Merge remote-tracking branch 'origin/main' into strict-policy-submit-…
abzokhattab Oct 23, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion src/components/AnimatedSubmitButton/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,12 @@ type AnimatedSubmitButtonProps = {

// Function to call when the animation finishes
onAnimationFinish: () => void;

// Whether the button should be disabled
isDisabled?: boolean;
};

function AnimatedSubmitButton({success, text, onPress, isSubmittingAnimationRunning, onAnimationFinish}: AnimatedSubmitButtonProps) {
function AnimatedSubmitButton({success, text, onPress, isSubmittingAnimationRunning, onAnimationFinish, isDisabled}: AnimatedSubmitButtonProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();
const isAnimationRunning = isSubmittingAnimationRunning;
Expand Down Expand Up @@ -128,6 +131,7 @@ function AnimatedSubmitButton({success, text, onPress, isSubmittingAnimationRunn
text={text}
onPress={onPress}
icon={icon}
isDisabled={isDisabled}
/>
)}
</Animated.View>
Expand Down
20 changes: 16 additions & 4 deletions src/components/MoneyReportHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import useReportIsArchived from '@hooks/useReportIsArchived';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useSearchShouldCalculateTotals from '@hooks/useSearchShouldCalculateTotals';
import useSelectedTransactionsActions from '@hooks/useSelectedTransactionsActions';
import useStrictPolicyRules from '@hooks/useStrictPolicyRules';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import useTransactionsAndViolationsForReport from '@hooks/useTransactionsAndViolationsForReport';
Expand All @@ -38,9 +39,9 @@ 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 {selectPaymentType} from '@libs/PaymentUtils';
import {buildOptimisticNextStepForPreventSelfApprovalsEnabled, buildOptimisticNextStepForStrictPolicyRuleViolations} from '@libs/NextStepUtils';
import type {KYCFlowEvent, TriggerKYCFlow} from '@libs/PaymentUtils';
import {selectPaymentType} from '@libs/PaymentUtils';
import Permissions from '@libs/Permissions';
import {getConnectedIntegration, getValidConnectedIntegration} from '@libs/PolicyUtils';
import {getIOUActionForReportID, getOriginalMessage, getReportAction, isMoneyRequestAction} from '@libs/ReportActionsUtils';
Expand All @@ -66,6 +67,7 @@ import {
navigateOnDeleteExpense,
navigateToDetailsPage,
rejectMoneyRequestReason,
shouldBlockSubmitDueToStrictPolicyRules,
} from '@libs/ReportUtils';
import {shouldRestrictUserBillableActions} from '@libs/SubscriptionUtils';
import {
Expand Down Expand Up @@ -199,6 +201,7 @@ function MoneyReportHeader({
const expensifyIcons = useMemoizedLazyExpensifyIcons(['Buildings'] as const);
const [lastDistanceExpenseType] = useOnyx(ONYXKEYS.NVP_LAST_DISTANCE_EXPENSE_TYPE, {canBeMissing: true});
const exportTemplates = useMemo(() => getExportTemplates(integrationsExportTemplates ?? [], csvExportLayouts ?? {}, policy), [integrationsExportTemplates, csvExportLayouts, policy]);
const {areStrictPolicyRulesEnabled} = useStrictPolicyRules();
const [allTransactions] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION, {canBeMissing: false});
const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT, {canBeMissing: false});

Expand All @@ -217,6 +220,10 @@ function MoneyReportHeader({
return Object.values(reportTransactions);
}, [reportTransactions]);

const shouldBlockSubmit = useMemo(() => {
return shouldBlockSubmitDueToStrictPolicyRules(moneyRequestReport?.reportID, violations, areStrictPolicyRulesEnabled, transactions);
}, [moneyRequestReport?.reportID, violations, areStrictPolicyRulesEnabled, transactions]);

const iouTransactionID = isMoneyRequestAction(requestParentReportAction) ? getOriginalMessage(requestParentReportAction)?.IOUTransactionID : undefined;
const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(iouTransactionID)}`, {
canBeMissing: true,
Expand Down Expand Up @@ -391,7 +398,11 @@ function MoneyReportHeader({
// 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;
let optimisticNextStep = isSubmitterSameAsNextApprover && policy?.preventSelfApproval ? buildOptimisticNextStepForPreventSelfApprovalsEnabled() : nextStep;

if (shouldBlockSubmit && isReportOwner(moneyRequestReport)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In #73426, we decided to only show the violation with open expense report.

More details in:
#73426 (comment)

optimisticNextStep = buildOptimisticNextStepForStrictPolicyRuleViolations();
}

const shouldShowNextStep = isFromPaidPolicy && !isInvoiceReport && !shouldShowStatusBar;
const {nonHeldAmount, fullAmount, hasValidNonHeldAmount} = getNonHeldAndFullAmount(moneyRequestReport, shouldShowPayButton);
Expand Down Expand Up @@ -708,7 +719,7 @@ function MoneyReportHeader({
success
text={translate('common.submit')}
onPress={() => {
if (!moneyRequestReport) {
if (!moneyRequestReport || shouldBlockSubmit) {
return;
}
startSubmittingAnimation();
Expand All @@ -725,6 +736,7 @@ function MoneyReportHeader({
}}
isSubmittingAnimationRunning={isSubmittingAnimationRunning}
onAnimationFinish={stopAnimation}
isDisabled={shouldBlockSubmit}
/>
),
[CONST.REPORT.PRIMARY_ACTIONS.APPROVE]: (
Expand Down
12 changes: 6 additions & 6 deletions src/components/MoneyReportHeaderStatusBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {View} from 'react-native';
import type {ValueOf} from 'type-fest';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import * as NextStepUtils from '@libs/NextStepUtils';
import {parseMessage} from '@libs/NextStepUtils';
import variables from '@styles/variables';
import CONST from '@src/CONST';
import type ReportNextStep from '@src/types/onyx/ReportNextStep';
Expand All @@ -14,7 +14,7 @@ import RenderHTML from './RenderHTML';

type MoneyReportHeaderStatusBarProps = {
/** The next step for the report */
nextStep: ReportNextStep;
nextStep: ReportNextStep | undefined;
Comment thread
mountiny marked this conversation as resolved.
};

type IconName = ValueOf<typeof CONST.NEXT_STEP.ICONS>;
Expand All @@ -29,15 +29,15 @@ function MoneyReportHeaderStatusBar({nextStep}: MoneyReportHeaderStatusBarProps)
const styles = useThemeStyles();
const theme = useTheme();
const messageContent = useMemo(() => {
const messageArray = nextStep.message;
return NextStepUtils.parseMessage(messageArray);
}, [nextStep.message]);
const messageArray = nextStep?.message;
return parseMessage(messageArray);
}, [nextStep?.message]);

return (
<View style={[styles.dFlex, styles.flexRow, styles.alignItemsCenter, styles.overflowHidden, styles.w100, styles.headerStatusBarContainer]}>
<View style={[styles.mr3]}>
<Icon
src={iconMap[nextStep.icon] || Expensicons.Hourglass}
src={(nextStep?.icon && iconMap?.[nextStep.icon]) ?? Expensicons.Hourglass}
height={variables.iconSizeSmall}
width={variables.iconSizeSmall}
fill={theme.icon}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import usePaymentAnimations from '@hooks/usePaymentAnimations';
import usePolicy from '@hooks/usePolicy';
import useReportIsArchived from '@hooks/useReportIsArchived';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useStrictPolicyRules from '@hooks/useStrictPolicyRules';
import useStyleUtils from '@hooks/useStyleUtils';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
Expand Down Expand Up @@ -135,6 +136,7 @@ function MoneyRequestReportPreviewContent({
const StyleUtils = useStyleUtils();
const {translate} = useLocalize();
const {isOffline} = useNetwork();
const {areStrictPolicyRulesEnabled} = useStrictPolicyRules();
const {shouldUseNarrowLayout} = useResponsiveLayout();
const currentUserDetails = useCurrentUserPersonalDetails();

Expand Down Expand Up @@ -481,6 +483,7 @@ function MoneyRequestReportPreviewContent({
isPaidAnimationRunning,
isApprovedAnimationRunning,
isSubmittingAnimationRunning,
areStrictPolicyRulesEnabled,
);
}, [
isPaidAnimationRunning,
Expand All @@ -493,6 +496,7 @@ function MoneyRequestReportPreviewContent({
isIouReportArchived,
invoiceReceiverPolicy,
isChatReportArchived,
areStrictPolicyRulesEnabled,
currentUserDetails.email,
]);

Expand Down
37 changes: 37 additions & 0 deletions src/hooks/useStrictPolicyRules.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import {Str} from 'expensify-common';
import ONYXKEYS from '@src/ONYXKEYS';
import useOnyx from './useOnyx';

type UseStrictPolicyRulesResult = {
/** Whether the user's domain has strict policy rules enabled (strictly enforce workspace rules) */
areStrictPolicyRulesEnabled: boolean;
};

/**
* Hook to check if strict policy rules are enabled for the user's domain security group.
* When enabled, users cannot submit reports that have policy violations.
*/
function useStrictPolicyRules(): UseStrictPolicyRulesResult {
Comment thread
mountiny marked this conversation as resolved.
const [myDomainSecurityGroups] = useOnyx(ONYXKEYS.MY_DOMAIN_SECURITY_GROUPS, {canBeMissing: true});
const [securityGroups] = useOnyx(ONYXKEYS.COLLECTION.SECURITY_GROUP, {canBeMissing: true});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder how we could incorporate the selector for the groupID here already since it's dependent on the other useOnyx @TMisiukiewicz

const [session] = useOnyx(ONYXKEYS.SESSION, {canBeMissing: true});

// Get the user's domain from their email
const userDomain = session?.email ? Str.extractEmailDomain(session.email) : undefined;

// Get the security group ID for the user's domain
const securityGroupID = userDomain && myDomainSecurityGroups?.[userDomain];
Comment thread
mountiny marked this conversation as resolved.

// Get the security group details
const securityGroupKey = `${ONYXKEYS.COLLECTION.SECURITY_GROUP}${securityGroupID}`;
const securityGroup = securityGroupID ? securityGroups?.[securityGroupKey] : null;

// Check if strict policy rules are enabled
const areStrictPolicyRulesEnabled = securityGroup?.enableStrictPolicyRules === true;

return {
areStrictPolicyRulesEnabled,
};
}

export default useStrictPolicyRules;
1 change: 0 additions & 1 deletion src/languages/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1414,7 +1414,6 @@ const translations = {
dates: 'Fechas',
rates: 'Tasas',
submitsTo: ({name}: SubmitsToParams) => `Se envía a ${name}`,

reject: {
educationalTitle: '¿Debes retener o rechazar?',
educationalText: 'Si no estás listo para aprobar o pagar un gasto, puedes retenerlo o rechazarlo.',
Expand Down
15 changes: 15 additions & 0 deletions src/libs/NextStepUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,20 @@ function buildOptimisticNextStepForPreventSelfApprovalsEnabled() {
return optimisticNextStep;
}

function buildOptimisticNextStepForStrictPolicyRuleViolations() {
const optimisticNextStep: ReportNextStep = {
type: 'alert',
icon: CONST.NEXT_STEP.ICONS.HOURGLASS,
message: [
{
text: 'Waiting for you to fix the issues. Your admins have restricted submission of expenses with violations.',
},
],
};

return optimisticNextStep;
}

/**
* Please don't use this function anymore, let's use buildNextStepNew instead
*
Expand Down Expand Up @@ -882,5 +896,6 @@ export {
// eslint-disable-next-line @typescript-eslint/no-deprecated
buildNextStep,
buildOptimisticNextStepForPreventSelfApprovalsEnabled,
buildOptimisticNextStepForStrictPolicyRuleViolations,
buildNextStepNew,
};
10 changes: 10 additions & 0 deletions src/libs/ReportPreviewActionUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
isReportManuallyReimbursed,
isSettled,
requiresManualSubmission,
shouldBlockSubmitDueToStrictPolicyRules,
} from './ReportUtils';
import {getSession} from './SessionUtils';
import {allHavePendingRTERViolation, isPending, isScanning, shouldShowBrokenConnectionViolationForMultipleTransactions} from './TransactionUtils';
Expand Down Expand Up @@ -214,6 +215,7 @@ function canReview(report: Report, violations: OnyxCollection<TransactionViolati
return true;
}

// eslint-disable-next-line @typescript-eslint/max-params
function getReportPreviewAction(
violations: OnyxCollection<TransactionViolation[]>,
isReportArchived: boolean,
Expand All @@ -225,6 +227,7 @@ function getReportPreviewAction(
isPaidAnimationRunning?: boolean,
isApprovedAnimationRunning?: boolean,
isSubmittingAnimationRunning?: boolean,
areStrictPolicyRulesEnabled?: boolean,
): ValueOf<typeof CONST.REPORT.REPORT_PREVIEW_ACTIONS> {
if (!report) {
return CONST.REPORT.REPORT_PREVIEW_ACTIONS.VIEW;
Expand All @@ -242,6 +245,13 @@ function getReportPreviewAction(
if (isAddExpenseAction(report, transactions ?? [], isReportArchived)) {
return CONST.REPORT.REPORT_PREVIEW_ACTIONS.ADD_EXPENSE;
}

// When strict policy rules are enabled and there are violations, show REVIEW button instead of SUBMIT
const shouldBlockSubmit = shouldBlockSubmitDueToStrictPolicyRules(report.reportID, violations, areStrictPolicyRulesEnabled ?? false, transactions);
if (shouldBlockSubmit && canReview(report, violations, isReportArchived, currentUserEmail, policy, transactions)) {
return CONST.REPORT.REPORT_PREVIEW_ACTIONS.REVIEW;
}
Comment thread
mountiny marked this conversation as resolved.

if (canSubmit(report, violations, isReportArchived, policy, transactions)) {
return CONST.REPORT.REPORT_PREVIEW_ACTIONS.SUBMIT;
}
Expand Down
17 changes: 17 additions & 0 deletions src/libs/ReportUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8670,6 +8670,22 @@ function hasReportViolations(reportID: string | undefined) {
return Object.values(reportViolations ?? {}).some((violations) => !isEmptyObject(violations));
}

/**
* Checks if submission should be blocked due to strict policy rules being enabled and violations present.
* When a user's domain has "strictly enforce workspace rules" enabled, they cannot submit reports with violations.
*/
function shouldBlockSubmitDueToStrictPolicyRules(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@abzokhattab Please add unit tests for this method

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

reportID: string | undefined,
transactionViolations: OnyxCollection<TransactionViolation[]>,
areStrictPolicyRulesEnabled: boolean,
reportTransactions?: Transaction[] | SearchTransaction[],
) {
if (!areStrictPolicyRulesEnabled) {
return false;
}
return hasAnyViolations(reportID, transactionViolations, reportTransactions as SearchTransaction[]);
}

type ReportErrorsAndReportActionThatRequiresAttention = {
errors: ErrorFields;
reportAction?: OnyxEntry<ReportAction>;
Expand Down Expand Up @@ -12545,6 +12561,7 @@ export {
doesReportContainRequestsFromMultipleUsers,
hasUnresolvedCardFraudAlert,
getUnresolvedCardFraudAlertAction,
shouldBlockSubmitDueToStrictPolicyRules,
};
export type {
Ancestor,
Expand Down
42 changes: 42 additions & 0 deletions tests/actions/ReportPreviewActionUtilsTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -558,6 +558,48 @@ describe('getReportPreviewAction', () => {
expect(getReportPreviewAction(violations, false, CURRENT_USER_EMAIL, report, policy, [transaction])).toBe(CONST.REPORT.REPORT_PREVIEW_ACTIONS.REVIEW);
});

it('canReview should return true when strict policy rules are enabled and report has violations', async () => {
const hasAnyViolationsMock = ReportUtils.hasAnyViolations as jest.Mock;
hasAnyViolationsMock.mockReturnValue(true);

const report: Report = {
...createRandomReport(REPORT_ID),
statusNum: 0,
stateNum: 0,
type: CONST.REPORT.TYPE.EXPENSE,
ownerAccountID: CURRENT_USER_ACCOUNT_ID,
isWaitingOnBankAccount: false,
};

const policy = createRandomPolicy(0);
policy.areWorkflowsEnabled = true;
policy.type = CONST.POLICY.TYPE.CORPORATE;

await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, report);

const violations: OnyxCollection<TransactionViolation[]> = {
[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${TRANSACTION_ID}`]: [
{
name: CONST.VIOLATIONS.MISSING_CATEGORY,
type: CONST.VIOLATION_TYPES.VIOLATION,
} as TransactionViolation,
],
};

const transaction = {
transactionID: `${TRANSACTION_ID}`,
reportID: `${REPORT_ID}`,
} as unknown as Transaction;

const {result: isReportArchived} = renderHook(() => useReportIsArchived(report?.parentReportID));
await waitForBatchedUpdatesWithAct();

// When strict policy rules are enabled, REVIEW should be shown instead of SUBMIT
expect(getReportPreviewAction(violations, isReportArchived.current, CURRENT_USER_EMAIL, report, policy, [transaction], undefined, false, false, false, true)).toBe(
CONST.REPORT.REPORT_PREVIEW_ACTIONS.REVIEW,
);
});

it('canView should return true for reports in which we are waiting for user to add a bank account', async () => {
const report = {
...createRandomReport(REPORT_ID),
Expand Down
18 changes: 17 additions & 1 deletion tests/unit/NextStepUtilsTest.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import Onyx from 'react-native-onyx';
// TODO: Replace onyx.connect with useOnyx hook (https://github.com/Expensify/App/issues/66365)
// eslint-disable-next-line @typescript-eslint/no-deprecated
import {buildNextStep} from '@libs/NextStepUtils';
import {buildNextStep, buildOptimisticNextStepForStrictPolicyRuleViolations} from '@libs/NextStepUtils';
import {buildOptimisticEmptyReport, buildOptimisticExpenseReport} from '@libs/ReportUtils';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
Expand Down Expand Up @@ -817,4 +817,20 @@ describe('libs/NextStepUtils', () => {
});
});
});

describe('buildOptimisticNextStepForStrictPolicyRuleViolations', () => {
test('returns correct next step message for strict policy rule violations', () => {
const result = buildOptimisticNextStepForStrictPolicyRuleViolations();

expect(result).toEqual({
type: 'alert',
icon: CONST.NEXT_STEP.ICONS.HOURGLASS,
message: [
{
text: 'Waiting for you to fix the issues. Your admins have restricted submission of expenses with violations.',
},
],
});
});
});
});
Loading
Loading