diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index 459ad2bed56e..9c8d2907687b 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -219,6 +219,7 @@ function MoneyReportHeader({ const personalDetails = usePersonalDetails(); const defaultExpensePolicy = useDefaultExpensePolicy(); const activePolicyExpenseChat = getPolicyExpenseChat(accountID, defaultExpensePolicy?.id); + const [ownerBillingGraceEndPeriod] = useOnyx(ONYXKEYS.NVP_PRIVATE_OWNER_BILLING_GRACE_PERIOD_END); const [userBillingGraceEndPeriods] = useOnyx(ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_USER_BILLING_GRACE_PERIOD_END); const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${moneyRequestReport?.chatReportID}`); const [nextStep] = useOnyx(`${ONYXKEYS.COLLECTION.NEXT_STEP}${moneyRequestReport?.reportID}`); @@ -995,11 +996,12 @@ function MoneyReportHeader({ policy, userBillingGraceEndPeriods, amountOwed, + ownerBillingGraceEndPeriod, undefined, undefined, lastDistanceExpenseType, ), - [moneyRequestReport?.reportID, policy, userBillingGraceEndPeriods, amountOwed, lastDistanceExpenseType, expensifyIcons, translate], + [moneyRequestReport?.reportID, policy, userBillingGraceEndPeriods, amountOwed, lastDistanceExpenseType, expensifyIcons, translate, ownerBillingGraceEndPeriod], ); const exportSubmenuOptions: Record> = useMemo(() => { @@ -1716,7 +1718,7 @@ function MoneyReportHeader({ if (!moneyRequestReport?.reportID) { return; } - if (policy && shouldRestrictUserBillableActions(policy.id, userBillingGraceEndPeriods)) { + if (policy && shouldRestrictUserBillableActions(policy.id, userBillingGraceEndPeriods, ownerBillingGraceEndPeriod)) { Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(policy.id)); return; } diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx index affb65301b93..28870c34c92c 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx @@ -185,6 +185,7 @@ function MoneyRequestReportTransactionList({ const isReportArchived = useReportIsArchived(report?.reportID); const shouldShowAddExpenseButton = canAddTransaction(report, isReportArchived) && isCurrentUserSubmitter(report); const [userBillingGraceEndPeriodCollection] = useOnyx(ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_USER_BILLING_GRACE_PERIOD_END); + const [ownerBillingGraceEndPeriod] = useOnyx(ONYXKEYS.NVP_PRIVATE_OWNER_BILLING_GRACE_PERIOD_END); const [lastDistanceExpenseType] = useOnyx(ONYXKEYS.NVP_LAST_DISTANCE_EXPENSE_TYPE); const [reportLayoutGroupBy] = useOnyx(ONYXKEYS.NVP_REPORT_LAYOUT_GROUP_BY); const [amountOwed] = useOnyx(ONYXKEYS.NVP_PRIVATE_AMOUNT_OWED); @@ -195,8 +196,19 @@ function MoneyRequestReportTransactionList({ const addExpenseDropdownOptions = useMemo( () => - getAddExpenseDropdownOptions(translate, expensifyIcons, report?.reportID, policy, userBillingGraceEndPeriodCollection, amountOwed, undefined, undefined, lastDistanceExpenseType), - [translate, expensifyIcons, report?.reportID, policy, userBillingGraceEndPeriodCollection, amountOwed, lastDistanceExpenseType], + getAddExpenseDropdownOptions( + translate, + expensifyIcons, + report?.reportID, + policy, + userBillingGraceEndPeriodCollection, + amountOwed, + ownerBillingGraceEndPeriod, + undefined, + undefined, + lastDistanceExpenseType, + ), + [translate, expensifyIcons, report?.reportID, policy, userBillingGraceEndPeriodCollection, amountOwed, lastDistanceExpenseType, ownerBillingGraceEndPeriod], ); const hasPendingAction = useMemo(() => { diff --git a/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContent.tsx b/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContent.tsx index b4f8634c6a0b..814c68b5ddc1 100644 --- a/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContent.tsx +++ b/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContent.tsx @@ -137,6 +137,7 @@ function MoneyRequestReportPreviewContent({ const [userBillingGraceEndPeriods] = useOnyx(ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_USER_BILLING_GRACE_PERIOD_END); const [chatReportMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${chatReportID}`); const [iouReportMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${iouReportID}`); + const [ownerBillingGraceEndPeriod] = useOnyx(ONYXKEYS.NVP_PRIVATE_OWNER_BILLING_GRACE_PERIOD_END); const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID); const [iouReportNextStep] = useOnyx(`${ONYXKEYS.COLLECTION.NEXT_STEP}${iouReportID}`); const [amountOwed] = useOnyx(ONYXKEYS.NVP_PRIVATE_AMOUNT_OWED); @@ -692,11 +693,23 @@ function MoneyRequestReportPreviewContent({ policy, userBillingGraceEndPeriods, amountOwed, + ownerBillingGraceEndPeriod, chatReportID, iouReport?.parentReportID, lastDistanceExpenseType, ), - [translate, expensifyIcons, iouReport?.reportID, iouReport?.parentReportID, policy, userBillingGraceEndPeriods, amountOwed, chatReportID, lastDistanceExpenseType], + [ + translate, + expensifyIcons, + iouReport?.reportID, + iouReport?.parentReportID, + policy, + userBillingGraceEndPeriods, + amountOwed, + chatReportID, + lastDistanceExpenseType, + ownerBillingGraceEndPeriod, + ], ); const isReportDeleted = action?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index c7f157d65287..8faaaef07429 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -2962,6 +2962,7 @@ function getAddExpenseDropdownOptions( policy: OnyxEntry, userBillingGraceEndPeriodCollection: OnyxCollection, amountOwed: OnyxEntry, + ownerBillingGraceEndPeriod: OnyxEntry, iouRequestBackToReport?: string, unreportedExpenseBackToReport?: string, lastDistanceExpenseType?: IOURequestType, @@ -2976,7 +2977,11 @@ function getAddExpenseDropdownOptions( if (!iouReportID) { return; } - if (policy && policy.type !== CONST.POLICY.TYPE.PERSONAL && shouldRestrictUserBillableActions(policy.id, userBillingGraceEndPeriodCollection, amountOwed)) { + if ( + policy && + policy.type !== CONST.POLICY.TYPE.PERSONAL && + shouldRestrictUserBillableActions(policy.id, userBillingGraceEndPeriodCollection, amountOwed, ownerBillingGraceEndPeriod) + ) { Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(policy.id)); return; } @@ -2992,7 +2997,7 @@ function getAddExpenseDropdownOptions( if (!iouReportID) { return; } - if (policy && shouldRestrictUserBillableActions(policy.id, userBillingGraceEndPeriodCollection, amountOwed)) { + if (policy && shouldRestrictUserBillableActions(policy.id, userBillingGraceEndPeriodCollection, amountOwed, ownerBillingGraceEndPeriod)) { Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(policy.id)); return; } @@ -3005,7 +3010,7 @@ function getAddExpenseDropdownOptions( icon: icons.ReceiptPlus, sentryLabel: CONST.SENTRY_LABEL.MORE_MENU.ADD_EXPENSE_UNREPORTED, onSelected: () => { - if (policy && shouldRestrictUserBillableActions(policy.id, userBillingGraceEndPeriodCollection, amountOwed)) { + if (policy && shouldRestrictUserBillableActions(policy.id, userBillingGraceEndPeriodCollection, amountOwed, ownerBillingGraceEndPeriod)) { Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(policy.id)); return; } diff --git a/src/libs/SubscriptionUtils.ts b/src/libs/SubscriptionUtils.ts index 956cc39e056e..1a4a45358932 100644 --- a/src/libs/SubscriptionUtils.ts +++ b/src/libs/SubscriptionUtils.ts @@ -66,10 +66,10 @@ Onyx.connect({ callback: (value) => (privateAmountOwed = value), }); -let ownerBillingGraceEndPeriod: OnyxEntry; +let ownerBillingGraceEndPeriodDeprecated: OnyxEntry; Onyx.connect({ key: ONYXKEYS.NVP_PRIVATE_OWNER_BILLING_GRACE_PERIOD_END, - callback: (value) => (ownerBillingGraceEndPeriod = value), + callback: (value) => (ownerBillingGraceEndPeriodDeprecated = value), }); let deprecatedUserBillingGraceEndPeriodCollection: OnyxCollection; @@ -86,30 +86,11 @@ Onyx.connect({ waitForCollectionCallback: true, }); -/** - * @returns The date when the grace period ends. - * @param gracePeriodEnd - Optional parameter to use instead of module-level value (for pure function usage). - */ -function getOverdueGracePeriodDate(gracePeriodEnd?: OnyxEntry): OnyxEntry { - return gracePeriodEnd ?? ownerBillingGraceEndPeriod; -} - -/** - * @returns Whether the workspace owner has an overdue grace period. - * @param gracePeriodEnd - Optional parameter to use instead of module-level value (for pure function usage). - */ -function hasOverdueGracePeriod(gracePeriodEnd?: OnyxEntry): boolean { - const value = gracePeriodEnd ?? ownerBillingGraceEndPeriod; - return !!value; -} - /** * @returns Whether the workspace owner's grace period is overdue. - * @param gracePeriodEnd - Optional parameter to use instead of module-level value (for pure function usage). */ -function hasGracePeriodOverdue(gracePeriodEnd?: OnyxEntry): boolean { - const value = gracePeriodEnd ?? ownerBillingGraceEndPeriod; - return !!value && Date.now() > new Date(value).getTime(); +function hasGracePeriodOverdue(gracePeriodEnd: OnyxEntry): boolean { + return !!gracePeriodEnd && Date.now() > new Date(gracePeriodEnd).getTime(); } /** @@ -261,7 +242,6 @@ type SubscriptionStatus = { /** * @returns The subscription status. - * @param ownerBillingGraceEndPeriodParam - Optional parameter to use instead of module-level value (for pure function usage). */ function getSubscriptionStatus( stripeCustomerId: OnyxEntry, @@ -271,12 +251,12 @@ function getSubscriptionStatus( fundList: OnyxEntry, billingStatus: OnyxEntry, amountOwed: number, - ownerBillingGraceEndPeriodParam?: OnyxEntry, + ownerBillingGraceEndPeriod: OnyxEntry, ): SubscriptionStatus | undefined { - if (hasOverdueGracePeriod(ownerBillingGraceEndPeriodParam)) { + if (ownerBillingGraceEndPeriod) { if (amountOwed !== 0) { // 1. Policy owner with amount owed, within grace period - if (!hasGracePeriodOverdue(ownerBillingGraceEndPeriodParam)) { + if (!hasGracePeriodOverdue(ownerBillingGraceEndPeriod)) { return { status: PAYMENT_STATUS.POLICY_OWNER_WITH_AMOUNT_OWED, isError: true, @@ -284,7 +264,7 @@ function getSubscriptionStatus( } // 2. Policy owner with amount owed, overdue (past grace period) - if (hasGracePeriodOverdue(ownerBillingGraceEndPeriodParam)) { + if (hasGracePeriodOverdue(ownerBillingGraceEndPeriod)) { return { isError: true, status: PAYMENT_STATUS.POLICY_OWNER_WITH_AMOUNT_OWED_OVERDUE, @@ -292,7 +272,7 @@ function getSubscriptionStatus( } } else { // 3. Owner of policy under invoicing, within grace period - if (!hasGracePeriodOverdue(ownerBillingGraceEndPeriodParam)) { + if (!hasGracePeriodOverdue(ownerBillingGraceEndPeriod)) { return { status: PAYMENT_STATUS.OWNER_OF_POLICY_UNDER_INVOICING, isError: true, @@ -300,7 +280,7 @@ function getSubscriptionStatus( } // 4. Owner of policy under invoicing, overdue (past grace period) - if (hasGracePeriodOverdue(ownerBillingGraceEndPeriodParam)) { + if (hasGracePeriodOverdue(ownerBillingGraceEndPeriod)) { return { status: PAYMENT_STATUS.OWNER_OF_POLICY_UNDER_INVOICING_OVERDUE, isError: true, @@ -368,7 +348,6 @@ function getSubscriptionStatus( /** * @returns Whether there is a subscription red dot error. - * @param ownerBillingGraceEndPeriodParam - Optional parameter to use instead of module-level value (for pure function usage). */ function hasSubscriptionRedDotError( stripeCustomerId: OnyxEntry, @@ -378,17 +357,16 @@ function hasSubscriptionRedDotError( fundList: OnyxEntry, billingStatus: OnyxEntry, amountOwed: number, - ownerBillingGraceEndPeriodParam?: OnyxEntry, + ownerBillingGraceEndPeriod: OnyxEntry, ): boolean { return ( - getSubscriptionStatus(stripeCustomerId, retryBillingSuccessful, billingDisputePending, retryBillingFailed, fundList, billingStatus, amountOwed, ownerBillingGraceEndPeriodParam) + getSubscriptionStatus(stripeCustomerId, retryBillingSuccessful, billingDisputePending, retryBillingFailed, fundList, billingStatus, amountOwed, ownerBillingGraceEndPeriod) ?.isError ?? false ); } /** * @returns Whether there is a subscription green dot info. - * @param ownerBillingGraceEndPeriodParam - Optional parameter to use instead of module-level value (for pure function usage). */ function hasSubscriptionGreenDotInfo( stripeCustomerId: OnyxEntry, @@ -398,10 +376,10 @@ function hasSubscriptionGreenDotInfo( fundList: OnyxEntry, billingStatus: OnyxEntry, amountOwed: number, - ownerBillingGraceEndPeriodParam?: OnyxEntry, + ownerBillingGraceEndPeriod: OnyxEntry, ): boolean { return ( - getSubscriptionStatus(stripeCustomerId, retryBillingSuccessful, billingDisputePending, retryBillingFailed, fundList, billingStatus, amountOwed, ownerBillingGraceEndPeriodParam) + getSubscriptionStatus(stripeCustomerId, retryBillingSuccessful, billingDisputePending, retryBillingFailed, fundList, billingStatus, amountOwed, ownerBillingGraceEndPeriod) ?.isError === false ); } @@ -493,6 +471,7 @@ function shouldRestrictUserBillableActions( policyID: string, userBillingGraceEndPeriodCollection: OnyxCollection = deprecatedUserBillingGraceEndPeriodCollection, amountOwed: OnyxEntry = privateAmountOwed, + ownerBillingGraceEndPeriod: OnyxEntry = ownerBillingGraceEndPeriodDeprecated, ): boolean { const currentDate = new Date(); @@ -623,11 +602,9 @@ export { doesUserHavePaymentCardAdded, getCardForSubscriptionBilling, getFreeTrialText, - getOverdueGracePeriodDate, getSubscriptionStatus, hasCardAuthenticatedError, hasGracePeriodOverdue, - hasOverdueGracePeriod, hasRetryBillingError, hasSubscriptionGreenDotInfo, hasSubscriptionRedDotError, diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index 45e2fee80b09..f072c0054f58 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -13263,26 +13263,26 @@ describe('ReportUtils', () => { const mockIouReportID = '12345'; it('should return exactly 3 dropdown options', () => { - const result = getAddExpenseDropdownOptions(mockTranslate, mockIcons, mockIouReportID, undefined, undefined, 0); + const result = getAddExpenseDropdownOptions(mockTranslate, mockIcons, mockIouReportID, undefined, undefined, 0, undefined); expect(result).toHaveLength(3); }); it('should return options with correct values', () => { - const result = getAddExpenseDropdownOptions(mockTranslate, mockIcons, mockIouReportID, undefined, undefined, 0); + const result = getAddExpenseDropdownOptions(mockTranslate, mockIcons, mockIouReportID, undefined, undefined, undefined, undefined); expect(result.at(0)?.value).toBe(CONST.REPORT.ADD_EXPENSE_OPTIONS.CREATE_NEW_EXPENSE); expect(result.at(1)?.value).toBe(CONST.REPORT.ADD_EXPENSE_OPTIONS.TRACK_DISTANCE_EXPENSE); expect(result.at(2)?.value).toBe(CONST.REPORT.ADD_EXPENSE_OPTIONS.ADD_UNREPORTED_EXPENSE); }); it('should return options with correct translated text', () => { - const result = getAddExpenseDropdownOptions(mockTranslate, mockIcons, mockIouReportID, undefined, undefined, 0); + const result = getAddExpenseDropdownOptions(mockTranslate, mockIcons, mockIouReportID, undefined, undefined, 0, undefined); expect(result.at(0)?.text).toBe(translate(CONST.LOCALES.EN, 'iou.createExpense')); expect(result.at(1)?.text).toBe(translate(CONST.LOCALES.EN, 'iou.trackDistance')); expect(result.at(2)?.text).toBe(translate(CONST.LOCALES.EN, 'iou.addUnreportedExpense')); }); it('should return options with correct sentry labels', () => { - const result = getAddExpenseDropdownOptions(mockTranslate, mockIcons, mockIouReportID, undefined, undefined, 0); + const result = getAddExpenseDropdownOptions(mockTranslate, mockIcons, mockIouReportID, undefined, undefined, 0, undefined); expect(result.at(0)?.sentryLabel).toBe(CONST.SENTRY_LABEL.MORE_MENU.ADD_EXPENSE_CREATE); expect(result.at(1)?.sentryLabel).toBe(CONST.SENTRY_LABEL.MORE_MENU.ADD_EXPENSE_TRACK_DISTANCE); expect(result.at(2)?.sentryLabel).toBe(CONST.SENTRY_LABEL.MORE_MENU.ADD_EXPENSE_UNREPORTED); @@ -13292,7 +13292,7 @@ describe('ReportUtils', () => { const mockPolicy = createRandomPolicy(0); mockPolicy.type = CONST.POLICY.TYPE.CORPORATE; const amountOwed = 100; - const result = getAddExpenseDropdownOptions(mockTranslate, mockIcons, mockIouReportID, mockPolicy, undefined, amountOwed); + const result = getAddExpenseDropdownOptions(mockTranslate, mockIcons, mockIouReportID, mockPolicy, undefined, amountOwed, undefined); // Trigger each onSelected - the function should not throw for (const option of result) { @@ -13303,7 +13303,7 @@ describe('ReportUtils', () => { it('should return options with zero amountOwed without triggering billing restrictions', () => { const mockPolicy = createRandomPolicy(0); mockPolicy.type = CONST.POLICY.TYPE.CORPORATE; - const result = getAddExpenseDropdownOptions(mockTranslate, mockIcons, mockIouReportID, mockPolicy, undefined, 0); + const result = getAddExpenseDropdownOptions(mockTranslate, mockIcons, mockIouReportID, mockPolicy, undefined, 0, undefined); expect(result).toHaveLength(3); // Each onSelected should work without issues when amountOwed is 0 @@ -13313,7 +13313,7 @@ describe('ReportUtils', () => { }); it('should handle undefined iouReportID in CREATE_NEW_EXPENSE and TRACK_DISTANCE_EXPENSE onSelected', () => { - const result = getAddExpenseDropdownOptions(mockTranslate, mockIcons, undefined, undefined, undefined, 0); + const result = getAddExpenseDropdownOptions(mockTranslate, mockIcons, undefined, undefined, undefined, 0, undefined); // CREATE_NEW_EXPENSE and TRACK_DISTANCE_EXPENSE should early-return when iouReportID is undefined expect(() => result.at(0)?.onSelected?.()).not.toThrow(); @@ -13323,7 +13323,7 @@ describe('ReportUtils', () => { }); it('should handle undefined policy gracefully', () => { - const result = getAddExpenseDropdownOptions(mockTranslate, mockIcons, mockIouReportID, undefined, undefined, 0); + const result = getAddExpenseDropdownOptions(mockTranslate, mockIcons, mockIouReportID, undefined, undefined, 0, undefined); expect(result).toHaveLength(3); for (const option of result) { @@ -13333,13 +13333,13 @@ describe('ReportUtils', () => { it('should accept amountOwed as a number parameter', () => { const amountOwed = 500; - const result = getAddExpenseDropdownOptions(mockTranslate, mockIcons, mockIouReportID, undefined, undefined, amountOwed); + const result = getAddExpenseDropdownOptions(mockTranslate, mockIcons, mockIouReportID, undefined, undefined, amountOwed, undefined); expect(result).toHaveLength(3); }); it('should use the provided icons for Location and ReceiptPlus', () => { - const result = getAddExpenseDropdownOptions(mockTranslate, mockIcons, mockIouReportID, undefined, undefined, 0); + const result = getAddExpenseDropdownOptions(mockTranslate, mockIcons, mockIouReportID, undefined, undefined, 0, undefined); expect(result.at(1)?.icon).toBe(mockIcons.Location); expect(result.at(2)?.icon).toBe(mockIcons.ReceiptPlus); }); @@ -13481,4 +13481,97 @@ describe('ReportUtils', () => { await Onyx.clear(); }); }); + describe('getAddExpenseDropdownOptions', () => { + const mockTranslate: LocaleContextProps['translate'] = (path, ...params) => translate(CONST.LOCALES.EN, path, ...params); + const mockIcons = {Location: jest.fn(), ReceiptPlus: jest.fn()} as unknown as Record<'Location' | 'ReceiptPlus', IconAsset>; + const policyID = '5001'; + const iouReportID = 'reportABC'; + const ownerAccountID = 999; + + beforeEach(async () => { + jest.clearAllMocks(); + await Onyx.clear(); + await Onyx.set(ONYXKEYS.SESSION, {email: currentUserEmail, accountID: currentUserAccountID}); + }); + + it('should return 3 dropdown options with correct values', () => { + const options = getAddExpenseDropdownOptions(mockTranslate, mockIcons, iouReportID, undefined, undefined, undefined, undefined); + + expect(options).toHaveLength(3); + expect(options.at(0)?.value).toBe(CONST.REPORT.ADD_EXPENSE_OPTIONS.CREATE_NEW_EXPENSE); + expect(options.at(1)?.value).toBe(CONST.REPORT.ADD_EXPENSE_OPTIONS.TRACK_DISTANCE_EXPENSE); + expect(options.at(2)?.value).toBe(CONST.REPORT.ADD_EXPENSE_OPTIONS.ADD_UNREPORTED_EXPENSE); + }); + + describe('CREATE_NEW_EXPENSE', () => { + it('should return early when iouReportID is undefined', () => { + const options = getAddExpenseDropdownOptions(mockTranslate, mockIcons, undefined, undefined, undefined, undefined, undefined); + options.at(0)?.onSelected?.(); + + expect(Navigation.navigate).not.toHaveBeenCalled(); + }); + + it('should navigate to restricted action when non-personal policy owner is past due', async () => { + const testPolicy = { + ...createRandomPolicy(Number(policyID)), + id: policyID, + ownerAccountID, + type: CONST.POLICY.TYPE.CORPORATE, + } as Policy; + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, testPolicy); + await waitForBatchedUpdates(); + + const pastDueCollection = { + [`${ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_USER_BILLING_GRACE_PERIOD_END}${ownerAccountID}`]: { + value: Math.floor(Date.now() / 1000) - 3600, + }, + }; + + const options = getAddExpenseDropdownOptions(mockTranslate, mockIcons, iouReportID, testPolicy, pastDueCollection, undefined, undefined); + options.at(0)?.onSelected?.(); + + expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.RESTRICTED_ACTION.getRoute(policyID)); + }); + }); + + describe('ADD_UNREPORTED_EXPENSE', () => { + it('should navigate to restricted action when policy owner is past due', async () => { + const testPolicy = { + ...createRandomPolicy(Number(policyID)), + id: policyID, + ownerAccountID, + } as Policy; + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, testPolicy); + await waitForBatchedUpdates(); + + const pastDueCollection = { + [`${ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_USER_BILLING_GRACE_PERIOD_END}${ownerAccountID}`]: { + value: Math.floor(Date.now() / 1000) - 3600, + }, + }; + + const options = getAddExpenseDropdownOptions(mockTranslate, mockIcons, iouReportID, testPolicy, pastDueCollection, undefined, undefined); + options.at(2)?.onSelected?.(); + + expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.RESTRICTED_ACTION.getRoute(policyID)); + }); + + it('should pass ownerBillingGraceEndPeriod to restrict owner with past-due billing', async () => { + const gracePeriodEnd = Math.floor(Date.now() / 1000) - 3600; + const testPolicy = { + ...createRandomPolicy(Number(policyID)), + id: policyID, + ownerAccountID: currentUserAccountID, + } as Policy; + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, testPolicy); + await Onyx.merge(ONYXKEYS.NVP_PRIVATE_AMOUNT_OWED, 100); + await waitForBatchedUpdates(); + + const options = getAddExpenseDropdownOptions(mockTranslate, mockIcons, iouReportID, testPolicy, undefined, undefined, gracePeriodEnd); + options.at(2)?.onSelected?.(); + + expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.RESTRICTED_ACTION.getRoute(policyID)); + }); + }); + }); }); diff --git a/tests/unit/SubscriptionUtilsTest.ts b/tests/unit/SubscriptionUtilsTest.ts index cdda9e680a1f..1b5ac6108b5c 100644 --- a/tests/unit/SubscriptionUtilsTest.ts +++ b/tests/unit/SubscriptionUtilsTest.ts @@ -9,7 +9,6 @@ import { getSubscriptionStatus, hasCardAuthenticatedError, hasGracePeriodOverdue, - hasOverdueGracePeriod, hasSubscriptionGreenDotInfo, hasSubscriptionRedDotError, hasUserFreeTrialEnded, @@ -348,6 +347,54 @@ describe('SubscriptionUtils', () => { expect(shouldRestrictUserBillableActions(policyID, undefined)).toBeFalsy(); }); + + it('should restrict when ownerBillingGraceEndPeriod is passed directly as 3rd param and is past due', async () => { + const accountID = 1; + const policyID = '1001'; + + await Onyx.multiSet({ + [ONYXKEYS.SESSION]: {email: '', accountID}, + [ONYXKEYS.NVP_PRIVATE_AMOUNT_OWED]: 8010, + [`${ONYXKEYS.COLLECTION.POLICY}${policyID}` as const]: { + ...createRandomPolicy(Number(policyID)), + ownerAccountID: accountID, + }, + }); + + expect(shouldRestrictUserBillableActions(policyID, undefined, undefined, getUnixTime(subDays(new Date(), 3)))).toBeTruthy(); + }); + + it('should not restrict when ownerBillingGraceEndPeriod is passed directly as 3rd param but is not past due', async () => { + const accountID = 1; + const policyID = '1001'; + + await Onyx.multiSet({ + [ONYXKEYS.SESSION]: {email: '', accountID}, + [ONYXKEYS.NVP_PRIVATE_AMOUNT_OWED]: 8010, + [`${ONYXKEYS.COLLECTION.POLICY}${policyID}` as const]: { + ...createRandomPolicy(Number(policyID)), + ownerAccountID: accountID, + }, + }); + + expect(shouldRestrictUserBillableActions(policyID, undefined, getUnixTime(addDays(new Date(), 3)))).toBeFalsy(); + }); + + it('should not restrict when ownerBillingGraceEndPeriod is passed directly as 3rd param but amount owed is 0', async () => { + const accountID = 1; + const policyID = '1001'; + + await Onyx.multiSet({ + [ONYXKEYS.SESSION]: {email: '', accountID}, + [ONYXKEYS.NVP_PRIVATE_AMOUNT_OWED]: 0, + [`${ONYXKEYS.COLLECTION.POLICY}${policyID}` as const]: { + ...createRandomPolicy(Number(policyID)), + ownerAccountID: accountID, + }, + }); + + expect(shouldRestrictUserBillableActions(policyID, undefined, getUnixTime(subDays(new Date(), 3)))).toBeFalsy(); + }); }); describe('getSubscriptionStatus', () => { @@ -368,7 +415,7 @@ describe('SubscriptionUtils', () => { it('should return undefined by default', () => { const stripeCustomerIdForDefault: Partial> = {}; // @ts-expect-error - This is a test case - expect(getSubscriptionStatus(stripeCustomerIdForDefault, false, undefined, undefined, undefined, undefined, 0)).toBeUndefined(); + expect(getSubscriptionStatus(stripeCustomerIdForDefault, false, undefined, undefined, undefined, undefined, 0, undefined)).toBeUndefined(); }); it('should return POLICY_OWNER_WITH_AMOUNT_OWED status', async () => { @@ -420,7 +467,7 @@ describe('SubscriptionUtils', () => { [ONYXKEYS.NVP_PRIVATE_BILLING_DISPUTE_PENDING]: 1, }); - expect(getSubscriptionStatus(stripeCustomerId, false, 1, undefined, undefined, undefined, 0)).toEqual({ + expect(getSubscriptionStatus(stripeCustomerId, false, 1, undefined, undefined, undefined, 0, undefined)).toEqual({ status: PAYMENT_STATUS.BILLING_DISPUTE_PENDING, isError: true, }); @@ -433,7 +480,7 @@ describe('SubscriptionUtils', () => { [ONYXKEYS.NVP_PRIVATE_STRIPE_CUSTOMER_ID]: STRIPE_CUSTOMER_ID, }); - expect(getSubscriptionStatus(stripeCustomerId, false, 0, undefined, undefined, undefined, 0)).toEqual({ + expect(getSubscriptionStatus(stripeCustomerId, false, 0, undefined, undefined, undefined, 0, undefined)).toEqual({ status: PAYMENT_STATUS.CARD_AUTHENTICATION_REQUIRED, isError: true, }); @@ -446,7 +493,7 @@ describe('SubscriptionUtils', () => { [ONYXKEYS.NVP_PRIVATE_BILLING_STATUS]: BILLING_STATUS_INSUFFICIENT_FUNDS, }); - expect(getSubscriptionStatus(stripeCustomerId, false, undefined, undefined, undefined, BILLING_STATUS_INSUFFICIENT_FUNDS, AMOUNT_OWED)).toEqual({ + expect(getSubscriptionStatus(stripeCustomerId, false, undefined, undefined, undefined, BILLING_STATUS_INSUFFICIENT_FUNDS, AMOUNT_OWED, undefined)).toEqual({ status: PAYMENT_STATUS.INSUFFICIENT_FUNDS, isError: true, }); @@ -465,7 +512,7 @@ describe('SubscriptionUtils', () => { }; // @ts-expect-error - This is a test case - expect(getSubscriptionStatus(stripeCustomerIdForCardExpired, false, undefined, undefined, undefined, BILLING_STATUS_EXPIRED_CARD, AMOUNT_OWED)).toEqual({ + expect(getSubscriptionStatus(stripeCustomerIdForCardExpired, false, undefined, undefined, undefined, BILLING_STATUS_EXPIRED_CARD, AMOUNT_OWED, undefined)).toEqual({ status: PAYMENT_STATUS.CARD_EXPIRED, isError: true, }); @@ -485,7 +532,7 @@ describe('SubscriptionUtils', () => { }; // @ts-expect-error - This is a test case - expect(getSubscriptionStatus(stripeCustomerIdForCardExpireSoon, false, undefined, undefined, FUND_LIST, {}, 0)).toEqual({ + expect(getSubscriptionStatus(stripeCustomerIdForCardExpireSoon, false, undefined, undefined, FUND_LIST, {}, 0, undefined)).toEqual({ status: PAYMENT_STATUS.CARD_EXPIRE_SOON, }); }); @@ -502,7 +549,7 @@ describe('SubscriptionUtils', () => { currency: 'USD', }; // @ts-expect-error - This is a test case - expect(getSubscriptionStatus(stripeCustomerIdForRetryBillingSuccess, true, undefined, undefined, {}, undefined, 0)).toEqual({ + expect(getSubscriptionStatus(stripeCustomerIdForRetryBillingSuccess, true, undefined, undefined, {}, undefined, 0, undefined)).toEqual({ status: PAYMENT_STATUS.RETRY_BILLING_SUCCESS, isError: false, }); @@ -521,7 +568,7 @@ describe('SubscriptionUtils', () => { currency: 'USD', }; // @ts-expect-error - This is a test case - expect(getSubscriptionStatus(stripeCustomerIdForRetryBillingError, false, undefined, true, {}, undefined, 0)).toEqual({ + expect(getSubscriptionStatus(stripeCustomerIdForRetryBillingError, false, undefined, true, {}, undefined, 0, undefined)).toEqual({ status: PAYMENT_STATUS.RETRY_BILLING_ERROR, isError: true, }); @@ -563,11 +610,10 @@ describe('SubscriptionUtils', () => { it('should return true when there is a subscription status with isError true', async () => { await Onyx.multiSet({ - [ONYXKEYS.NVP_PRIVATE_OWNER_BILLING_GRACE_PERIOD_END]: GRACE_PERIOD_DATE, [ONYXKEYS.NVP_PRIVATE_AMOUNT_OWED]: AMOUNT_OWED, }); - expect(hasSubscriptionRedDotError(stripeCustomerId, false, undefined, undefined, undefined, undefined, AMOUNT_OWED)).toBeTruthy(); + expect(hasSubscriptionRedDotError(stripeCustomerId, false, undefined, undefined, undefined, undefined, AMOUNT_OWED, GRACE_PERIOD_DATE)).toBeTruthy(); }); it('should return false when there is no subscription status error', () => { @@ -577,7 +623,7 @@ describe('SubscriptionUtils', () => { currency: 'USD', status: 'succeeded', }; - expect(hasSubscriptionRedDotError(stripeCustomerIdWithNoError, false, undefined, undefined, undefined, undefined, 0)).toBeFalsy(); + expect(hasSubscriptionRedDotError(stripeCustomerIdWithNoError, false, undefined, undefined, undefined, undefined, 0, undefined)).toBeFalsy(); }); }); @@ -602,11 +648,11 @@ describe('SubscriptionUtils', () => { currency: 'USD', }; // @ts-expect-error - This is a test case - expect(hasSubscriptionGreenDotInfo(stripeCustomerIdForRetryBillingSuccess, true, undefined, undefined, {}, undefined, 0)).toBeTruthy(); + expect(hasSubscriptionGreenDotInfo(stripeCustomerIdForRetryBillingSuccess, true, undefined, undefined, {}, undefined, 0, undefined)).toBeTruthy(); }); it('should return false when there is no subscription status or isError is true', () => { - expect(hasSubscriptionGreenDotInfo(stripeCustomerId, false, undefined, undefined, undefined, undefined, 0)).toBeFalsy(); + expect(hasSubscriptionGreenDotInfo(stripeCustomerId, false, undefined, undefined, undefined, undefined, 0, undefined)).toBeFalsy(); }); }); @@ -653,7 +699,7 @@ describe('SubscriptionUtils', () => { // No Onyx value set, no parameter passed const stripeCustomerIdForDefault: Partial> = {}; // @ts-expect-error - This is a test case - expect(getSubscriptionStatus(stripeCustomerIdForDefault, false, undefined, undefined, undefined, undefined, 0)).toBeUndefined(); + expect(getSubscriptionStatus(stripeCustomerIdForDefault, false, undefined, undefined, undefined, undefined, 0, undefined)).toBeUndefined(); }); }); @@ -691,12 +737,12 @@ describe('SubscriptionUtils', () => { // No Onyx value set, no parameter passed const stripeCustomerIdForDefault: Partial> = {}; // @ts-expect-error - This is a test case - expect(hasSubscriptionRedDotError(stripeCustomerIdForDefault, false, undefined, undefined, undefined, undefined, 0)).toBe(false); + expect(hasSubscriptionRedDotError(stripeCustomerIdForDefault, false, undefined, undefined, undefined, undefined, 0, undefined)).toBe(false); }); it('should return true when billing dispute is pending regardless of grace period', async () => { // Billing dispute pending should return error - expect(hasSubscriptionRedDotError(stripeCustomerId, false, 1, undefined, undefined, undefined, 0)).toBe(true); + expect(hasSubscriptionRedDotError(stripeCustomerId, false, 1, undefined, undefined, undefined, 0, undefined)).toBe(true); }); }); @@ -718,7 +764,7 @@ describe('SubscriptionUtils', () => { // No Onyx value set, no parameter passed - undefined status means not green dot const stripeCustomerIdForDefault: Partial> = {}; // @ts-expect-error - This is a test case - expect(hasSubscriptionGreenDotInfo(stripeCustomerIdForDefault, false, undefined, undefined, undefined, undefined, 0)).toBe(false); + expect(hasSubscriptionGreenDotInfo(stripeCustomerIdForDefault, false, undefined, undefined, undefined, undefined, 0, undefined)).toBe(false); }); it('should return true when retry billing is successful (non-error state)', async () => { @@ -733,34 +779,12 @@ describe('SubscriptionUtils', () => { currency: 'USD', }; // @ts-expect-error - This is a test case - expect(hasSubscriptionGreenDotInfo(stripeCustomerIdForSuccess, true, undefined, undefined, {}, undefined, 0)).toBe(true); + expect(hasSubscriptionGreenDotInfo(stripeCustomerIdForSuccess, true, undefined, undefined, {}, undefined, 0, undefined)).toBe(true); }); it('should return false when billing dispute is pending (error state)', async () => { // Billing dispute pending should return error, not green dot - expect(hasSubscriptionGreenDotInfo(stripeCustomerId, false, 1, undefined, undefined, undefined, 0)).toBe(false); - }); - }); - - describe('hasOverdueGracePeriod - pure function behavior', () => { - beforeEach(async () => { - // Clear all Onyx state to ensure we're testing pure function behavior - await Onyx.clear(); - }); - - it('should return true when grace period is passed as parameter', () => { - // Pass grace period as parameter - should return true - expect(hasOverdueGracePeriod(GRACE_PERIOD_DATE)).toBe(true); - }); - - it('should return true when overdue grace period is passed as parameter', () => { - // Pass overdue grace period as parameter - should return true - expect(hasOverdueGracePeriod(GRACE_PERIOD_DATE_OVERDUE)).toBe(true); - }); - - it('should return false when no grace period is passed and none is set in Onyx', () => { - // No Onyx value set, no parameter passed - expect(hasOverdueGracePeriod(undefined)).toBe(false); + expect(hasSubscriptionGreenDotInfo(stripeCustomerId, false, 1, undefined, undefined, undefined, 0, undefined)).toBe(false); }); });