diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 0d22d3714fe6..5267cd1fdf55 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -169,6 +169,21 @@ const ONYXKEYS = { /** The NVP with the last action taken (for the Quick Action Button) */ NVP_QUICK_ACTION_GLOBAL_CREATE: 'nvp_quickActionGlobalCreate', + /** The start date (yyyy-MM-dd HH:mm:ss) of the workspace owner’s free trial period. */ + NVP_FIRST_DAY_FREE_TRIAL: 'nvp_private_firstDayFreeTrial', + + /** The end date (yyyy-MM-dd HH:mm:ss) of the workspace owner’s free trial period. */ + NVP_LAST_DAY_FREE_TRIAL: 'nvp_private_lastDayFreeTrial', + + /** ID associated with the payment card added by the user. */ + NVP_BILLING_FUND_ID: 'nvp_expensify_billingFundID', + + /** The amount owed by the workspace’s owner. */ + NVP_PRIVATE_AMOUNT_OWNED: 'nvp_private_amountOwed', + + /** The end date (epoch timestamp) of the workspace owner’s grace period after the free trial ends. */ + NVP_PRIVATE_OWNER_BILLING_GRACE_PERIOD_END: 'nvp_private_billingGracePeriodEnd', + /** Does this user have push notifications enabled for this device? */ PUSH_NOTIFICATIONS_ENABLED: 'pushNotificationsEnabled', @@ -376,6 +391,10 @@ const ONYXKEYS = { // Search Page related SNAPSHOT: 'snapshot_', + + // Shared NVPs + /** Collection of objects where each object represents the owner of the workspace that is past due billing AND the user is a member of. */ + SHARED_NVP_PRIVATE_USER_BILLING_GRACE_PERIOD_END: 'sharedNVP_private_billingGracePeriodEnd_', }, /** List of Form ids */ @@ -589,6 +608,7 @@ type OnyxCollectionValuesMapping = { [ONYXKEYS.COLLECTION.POLICY_JOIN_MEMBER]: OnyxTypes.PolicyJoinMember; [ONYXKEYS.COLLECTION.POLICY_CONNECTION_SYNC_PROGRESS]: OnyxTypes.PolicyConnectionSyncProgress; [ONYXKEYS.COLLECTION.SNAPSHOT]: OnyxTypes.SearchResults; + [ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_USER_BILLING_GRACE_PERIOD_END]: OnyxTypes.BillingGraceEndPeriod; }; type OnyxValuesMapping = { @@ -699,6 +719,11 @@ type OnyxValuesMapping = { [ONYXKEYS.CACHED_PDF_PATHS]: Record; [ONYXKEYS.POLICY_OWNERSHIP_CHANGE_CHECKS]: Record; [ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE]: OnyxTypes.QuickAction; + [ONYXKEYS.NVP_FIRST_DAY_FREE_TRIAL]: string; + [ONYXKEYS.NVP_LAST_DAY_FREE_TRIAL]: string; + [ONYXKEYS.NVP_BILLING_FUND_ID]: number; + [ONYXKEYS.NVP_PRIVATE_AMOUNT_OWNED]: number; + [ONYXKEYS.NVP_PRIVATE_OWNER_BILLING_GRACE_PERIOD_END]: number; }; type OnyxValues = OnyxValuesMapping & OnyxCollectionValuesMapping & OnyxFormValuesMapping & OnyxFormDraftValuesMapping; diff --git a/src/libs/AccountUtils.ts b/src/libs/AccountUtils.ts index 8bc7037e9682..b926e20ca59c 100644 --- a/src/libs/AccountUtils.ts +++ b/src/libs/AccountUtils.ts @@ -5,6 +5,7 @@ import type {Account} from '@src/types/onyx'; const isValidateCodeFormSubmitting = (account: OnyxEntry) => !!account?.isLoading && account.loadingForm === (account.requiresTwoFactorAuth ? CONST.FORMS.VALIDATE_TFA_CODE_FORM : CONST.FORMS.VALIDATE_CODE_FORM); +/** Whether the accound ID is an odd number, useful for A/B testing. */ const isAccountIDOddNumber = (accountID: number) => accountID % 2 === 1; export default {isValidateCodeFormSubmitting, isAccountIDOddNumber}; diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 7a613c35ea22..7b87d7ecc041 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -58,6 +58,7 @@ import type {Comment, Receipt, TransactionChanges, WaypointCollection} from '@sr import type {EmptyObject} from '@src/types/utils/EmptyObject'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type IconAsset from '@src/types/utils/IconAsset'; +import AccountUtils from './AccountUtils'; import * as IOU from './actions/IOU'; import * as PolicyActions from './actions/Policy/Policy'; import * as store from './actions/ReimbursementAccount/store'; @@ -82,6 +83,7 @@ import * as PolicyUtils from './PolicyUtils'; import type {LastVisibleMessage} from './ReportActionsUtils'; import * as ReportActionsUtils from './ReportActionsUtils'; import StringUtils from './StringUtils'; +import * as SubscriptionUtils from './SubscriptionUtils'; import * as TransactionUtils from './TransactionUtils'; import * as Url from './Url'; import type {AvatarSource} from './UserUtils'; @@ -1035,12 +1037,15 @@ function isGroupChat(report: OnyxEntry | Partial): boolean { return getChatType(report) === CONST.REPORT.CHAT_TYPE.GROUP; } +/** + * Only returns true if this is the Expensify DM report. + */ function isSystemChat(report: OnyxEntry): boolean { return getChatType(report) === CONST.REPORT.CHAT_TYPE.SYSTEM; } /** - * Only returns true if this is our main 1:1 DM report with Concierge + * Only returns true if this is our main 1:1 DM report with Concierge. */ function isConciergeChatReport(report: OnyxInputOrEntry): boolean { const participantAccountIDs = Object.keys(report?.participants ?? {}) @@ -2285,6 +2290,7 @@ function isUnreadWithMention(reportOrOption: OnyxEntry | OptionData): bo * - is unread and the user was mentioned in one of the unread comments * - is for an outstanding task waiting on the user * - has an outstanding child expense that is waiting for an action from the current user (e.g. pay, approve, add bank account) + * - is either the system or concierge chat, the user free trial has ended and it didn't add a payment card yet * * @param option (report or optionItem) * @param parentReportAction (the report action the current report is a thread of) @@ -2315,6 +2321,10 @@ function requiresAttentionFromCurrentUser(optionOrReport: OnyxEntry | Op return true; } + if (isChatUsedForOnboarding(optionOrReport) && SubscriptionUtils.hasUserFreeTrialEnded() && !SubscriptionUtils.doesUserHavePaymentCardAdded()) { + return true; + } + return false; } @@ -6964,6 +6974,20 @@ function shouldShowMerchantColumn(transactions: Transaction[]) { return transactions.some((transaction) => isExpenseReport(allReports?.[transaction.reportID] ?? {})); } +/** + * Whether the report is a system chat or concierge chat, depending on the user's account ID. + */ +function isChatUsedForOnboarding(report: OnyxEntry): boolean { + return AccountUtils.isAccountIDOddNumber(currentUserAccountID ?? -1) ? isSystemChat(report) : isConciergeChatReport(report); +} + +/** + * Get the report (system or concierge chat) used for the user's onboarding process. + */ +function getChatUsedForOnboarding(): OnyxEntry { + return Object.values(allReports ?? {}).find(isChatUsedForOnboarding); +} + export { addDomainToShortMention, areAllRequestsBeingSmartScanned, @@ -7236,6 +7260,8 @@ export { isDraftReport, changeMoneyRequestHoldStatus, createDraftWorkspaceAndNavigateToConfirmationScreen, + isChatUsedForOnboarding, + getChatUsedForOnboarding, }; export type { diff --git a/src/libs/SubscriptionUtils.ts b/src/libs/SubscriptionUtils.ts new file mode 100644 index 000000000000..50dc4f99eec0 --- /dev/null +++ b/src/libs/SubscriptionUtils.ts @@ -0,0 +1,135 @@ +import {differenceInSeconds, fromUnixTime, isAfter, isBefore, parse as parseDate} from 'date-fns'; +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; +import Onyx from 'react-native-onyx'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {BillingGraceEndPeriod, Policy} from '@src/types/onyx'; + +let firstDayFreeTrial: OnyxEntry; +Onyx.connect({ + key: ONYXKEYS.NVP_FIRST_DAY_FREE_TRIAL, + callback: (value) => (firstDayFreeTrial = value), +}); + +let lastDayFreeTrial: OnyxEntry; +Onyx.connect({ + key: ONYXKEYS.NVP_LAST_DAY_FREE_TRIAL, + callback: (value) => (lastDayFreeTrial = value), +}); + +let userBillingFundID: OnyxEntry; +Onyx.connect({ + key: ONYXKEYS.NVP_BILLING_FUND_ID, + callback: (value) => (userBillingFundID = value), +}); + +let userBillingGraceEndPeriodCollection: OnyxCollection; +Onyx.connect({ + key: ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_USER_BILLING_GRACE_PERIOD_END, + callback: (value) => (userBillingGraceEndPeriodCollection = value), + waitForCollectionCallback: true, +}); + +let ownerBillingGraceEndPeriod: OnyxEntry; +Onyx.connect({ + key: ONYXKEYS.NVP_PRIVATE_OWNER_BILLING_GRACE_PERIOD_END, + callback: (value) => (ownerBillingGraceEndPeriod = value), +}); + +let amountOwed: OnyxEntry; +Onyx.connect({ + key: ONYXKEYS.NVP_PRIVATE_AMOUNT_OWNED, + callback: (value) => (amountOwed = value), +}); + +let allPolicies: OnyxCollection; +Onyx.connect({ + key: ONYXKEYS.COLLECTION.POLICY, + callback: (value) => (allPolicies = value), + waitForCollectionCallback: true, +}); + +/** + * Calculates the remaining number of days of the workspace owner's free trial before it ends. + */ +function calculateRemainingFreeTrialDays(): number { + if (!lastDayFreeTrial) { + return 0; + } + + const currentDate = new Date(); + const diffInSeconds = differenceInSeconds(parseDate(lastDayFreeTrial, CONST.DATE.FNS_DATE_TIME_FORMAT_STRING, currentDate), currentDate); + const diffInDays = Math.ceil(diffInSeconds / 86400); + + return diffInDays < 0 ? 0 : diffInDays; +} + +/** + * Whether the workspace's owner is on its free trial period. + */ +function isUserOnFreeTrial(): boolean { + if (!firstDayFreeTrial || !lastDayFreeTrial) { + return false; + } + + const currentDate = new Date(); + const firstDayFreeTrialDate = parseDate(firstDayFreeTrial, CONST.DATE.FNS_DATE_TIME_FORMAT_STRING, currentDate); + const lastDayFreeTrialDate = parseDate(lastDayFreeTrial, CONST.DATE.FNS_DATE_TIME_FORMAT_STRING, currentDate); + + return isAfter(currentDate, firstDayFreeTrialDate) && isBefore(currentDate, lastDayFreeTrialDate); +} + +/** + * Whether the workspace owner's free trial period has ended. + */ +function hasUserFreeTrialEnded(): boolean { + if (!lastDayFreeTrial) { + return false; + } + + const currentDate = new Date(); + const lastDayFreeTrialDate = parseDate(lastDayFreeTrial, CONST.DATE.FNS_DATE_TIME_FORMAT_STRING, currentDate); + + return isAfter(currentDate, lastDayFreeTrialDate); +} + +/** + * Whether the user has a payment card added to its account. + */ +function doesUserHavePaymentCardAdded(): boolean { + return userBillingFundID !== undefined; +} + +/** + * Whether the user's billable actions should be restricted. + */ +function shouldRestrictUserBillableActions(policyID: string): boolean { + const currentDate = new Date(); + + // This logic will be executed if the user is a workspace's non-owner (normal user or admin). + // We should restrict the workspace's non-owner actions if it's member of a workspace where the owner is + // past due and is past its grace period end. + for (const userBillingGraceEndPeriodEntry of Object.entries(userBillingGraceEndPeriodCollection ?? {})) { + const [entryKey, userBillingGracePeriodEnd] = userBillingGraceEndPeriodEntry; + + if (userBillingGracePeriodEnd && isAfter(currentDate, fromUnixTime(userBillingGracePeriodEnd.value))) { + // Extracts the owner account ID from the collection member key. + const ownerAccountID = entryKey.slice(ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_USER_BILLING_GRACE_PERIOD_END.length); + + const ownerPolicy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]; + if (String(ownerPolicy?.ownerAccountID ?? -1) === ownerAccountID) { + return true; + } + } + } + + // If it reached here it means that the user is actually the workspace's owner. + // We should restrict the workspace's owner actions if it's past its grace period end date and it's owing some amount. + if (ownerBillingGraceEndPeriod && amountOwed !== undefined && amountOwed > 0 && isAfter(currentDate, fromUnixTime(ownerBillingGraceEndPeriod))) { + return true; + } + + return false; +} + +export {calculateRemainingFreeTrialDays, doesUserHavePaymentCardAdded, hasUserFreeTrialEnded, isUserOnFreeTrial, shouldRestrictUserBillableActions}; diff --git a/src/libs/actions/Policy/Member.ts b/src/libs/actions/Policy/Member.ts index e1c48e4d0522..0da122a16bae 100644 --- a/src/libs/actions/Policy/Member.ts +++ b/src/libs/actions/Policy/Member.ts @@ -247,8 +247,8 @@ function removeMembers(accountIDs: number[], policyID: string) { key: policyKey, value: {employeeList: optimisticMembersState}, }, - ...announceRoomMembers.onyxOptimisticData, ]; + optimisticData.push(...announceRoomMembers.onyxOptimisticData); const successData: OnyxUpdate[] = [ { @@ -256,8 +256,8 @@ function removeMembers(accountIDs: number[], policyID: string) { key: policyKey, value: {employeeList: successMembersState}, }, - ...announceRoomMembers.onyxSuccessData, ]; + successData.push(...announceRoomMembers.onyxSuccessData); const failureData: OnyxUpdate[] = [ { @@ -265,8 +265,8 @@ function removeMembers(accountIDs: number[], policyID: string) { key: policyKey, value: {employeeList: failureMembersState}, }, - ...announceRoomMembers.onyxFailureData, ]; + failureData.push(...announceRoomMembers.onyxFailureData); const pendingChatMembers = ReportUtils.getPendingChatMembers(accountIDs, [], CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); @@ -551,10 +551,8 @@ function addMembersToWorkspace(invitedEmailsToAccountIDs: InvitedEmailsToAccount employeeList: optimisticMembersState, }, }, - ...newPersonalDetailsOnyxData.optimisticData, - ...membersChats.onyxOptimisticData, - ...announceRoomMembers.onyxOptimisticData, ]; + optimisticData.push(...newPersonalDetailsOnyxData.optimisticData, ...membersChats.onyxOptimisticData, ...announceRoomMembers.onyxOptimisticData); const successData: OnyxUpdate[] = [ { @@ -564,10 +562,8 @@ function addMembersToWorkspace(invitedEmailsToAccountIDs: InvitedEmailsToAccount employeeList: successMembersState, }, }, - ...newPersonalDetailsOnyxData.finallyData, - ...membersChats.onyxSuccessData, - ...announceRoomMembers.onyxSuccessData, ]; + successData.push(...newPersonalDetailsOnyxData.finallyData, ...membersChats.onyxSuccessData, ...announceRoomMembers.onyxSuccessData); const failureData: OnyxUpdate[] = [ { @@ -580,9 +576,8 @@ function addMembersToWorkspace(invitedEmailsToAccountIDs: InvitedEmailsToAccount employeeList: failureMembersState, }, }, - ...membersChats.onyxFailureData, - ...announceRoomMembers.onyxFailureData, ]; + failureData.push(...membersChats.onyxFailureData, ...announceRoomMembers.onyxFailureData); const params: AddMembersToWorkspaceParams = { employees: JSON.stringify(logins.map((login) => ({email: login}))), diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index b04536915b6c..b15bcc93a6f5 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -2168,8 +2168,8 @@ function createWorkspaceFromIOUPayment(iouReport: Report | EmptyObject): string pendingAction: null, }, }, - ...employeeWorkspaceChat.onyxOptimisticData, ]; + optimisticData.push(...employeeWorkspaceChat.onyxOptimisticData); const successData: OnyxUpdate[] = [ { @@ -2606,49 +2606,56 @@ function enablePolicyTaxes(policyID: string, enabled: boolean) { }; const policy = getPolicy(policyID); const shouldAddDefaultTaxRatesData = (!policy?.taxRates || isEmptyObject(policy.taxRates)) && enabled; - const onyxData: OnyxData = { - optimisticData: [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, - value: { - tax: { - trackingEnabled: enabled, - }, - pendingFields: { - tax: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, - }, + + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + tax: { + trackingEnabled: enabled, + }, + pendingFields: { + tax: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, }, }, - ...(shouldAddDefaultTaxRatesData ? taxRatesData.optimisticData ?? [] : []), - ], - successData: [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, - value: { - pendingFields: { - tax: null, - }, + }, + ]; + optimisticData.push(...(shouldAddDefaultTaxRatesData ? taxRatesData.optimisticData ?? [] : [])); + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + pendingFields: { + tax: null, }, }, - ...(shouldAddDefaultTaxRatesData ? taxRatesData.successData ?? [] : []), - ], - failureData: [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, - value: { - tax: { - trackingEnabled: !enabled, - }, - pendingFields: { - tax: null, - }, + }, + ]; + successData.push(...(shouldAddDefaultTaxRatesData ? taxRatesData.successData ?? [] : [])); + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + tax: { + trackingEnabled: !enabled, + }, + pendingFields: { + tax: null, }, }, - ...(shouldAddDefaultTaxRatesData ? taxRatesData.failureData ?? [] : []), - ], + }, + ]; + failureData.push(...(shouldAddDefaultTaxRatesData ? taxRatesData.failureData ?? [] : [])); + + const onyxData: OnyxData = { + optimisticData, + successData, + failureData, }; const parameters: EnablePolicyTaxesParams = {policyID, enabled}; diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index 192c6f720b5f..9a4ed9dc711b 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -2733,8 +2733,8 @@ function inviteToRoom(reportID: string, inviteeEmailsToAccountIDs: InvitedEmails pendingChatMembers, }, }, - ...newPersonalDetailsOnyxData.optimisticData, ]; + optimisticData.push(...newPersonalDetailsOnyxData.optimisticData); const successPendingChatMembers = report?.pendingChatMembers ? report?.pendingChatMembers?.filter( @@ -2749,8 +2749,9 @@ function inviteToRoom(reportID: string, inviteeEmailsToAccountIDs: InvitedEmails pendingChatMembers: successPendingChatMembers, }, }, - ...newPersonalDetailsOnyxData.finallyData, ]; + successData.push(...newPersonalDetailsOnyxData.finallyData); + const failureData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, @@ -3341,8 +3342,8 @@ function completeOnboarding( return acc; }, []); - const optimisticData: OnyxUpdate[] = [ - ...tasksForOptimisticData, + const optimisticData: OnyxUpdate[] = [...tasksForOptimisticData]; + optimisticData.push( { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${targetChatReportID}`, @@ -3363,18 +3364,18 @@ function completeOnboarding( key: ONYXKEYS.NVP_INTRO_SELECTED, value: {choice: engagementChoice}, }, - ]; - const successData: OnyxUpdate[] = [ - ...tasksForSuccessData, - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${targetChatReportID}`, - value: { - [introductionCommentAction.reportActionID]: {pendingAction: null}, - [textCommentAction.reportActionID]: {pendingAction: null}, - }, + ); + + const successData: OnyxUpdate[] = [...tasksForSuccessData]; + successData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${targetChatReportID}`, + value: { + [introductionCommentAction.reportActionID]: {pendingAction: null}, + [textCommentAction.reportActionID]: {pendingAction: null}, }, - ]; + }); + let failureReport: Partial = { lastMessageTranslationKey: '', lastMessageText: '', @@ -3393,8 +3394,8 @@ function completeOnboarding( }; } - const failureData: OnyxUpdate[] = [ - ...tasksForFailureData, + const failureData: OnyxUpdate[] = [...tasksForFailureData]; + failureData.push( { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${targetChatReportID}`, @@ -3417,7 +3418,7 @@ function completeOnboarding( key: ONYXKEYS.NVP_INTRO_SELECTED, value: {choice: null}, }, - ]; + ); const guidedSetupData: GuidedSetupData = [ {type: 'message', ...introductionMessage}, diff --git a/src/types/onyx/BillingGraceEndPeriod.ts b/src/types/onyx/BillingGraceEndPeriod.ts new file mode 100644 index 000000000000..44235694adb9 --- /dev/null +++ b/src/types/onyx/BillingGraceEndPeriod.ts @@ -0,0 +1,13 @@ +/** Model of BillingGraceEndPeriod's Shared NVP record */ +type BillingGraceEndPeriod = { + /** The name of the NVP key. */ + name: string; + + /** The permission associated with the NVP key. */ + permissions: string; + + /** The grace period end date (epoch timestamp) of the workspace's owner where the user is a member of. */ + value: number; +}; + +export default BillingGraceEndPeriod; diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts index 5a70ddd48d9e..47e0f34045ec 100644 --- a/src/types/onyx/index.ts +++ b/src/types/onyx/index.ts @@ -3,6 +3,7 @@ import type AccountData from './AccountData'; import type {BankAccountList} from './BankAccount'; import type BankAccount from './BankAccount'; import type Beta from './Beta'; +import type BillingGraceEndPeriod from './BillingGraceEndPeriod'; import type BlockedFromConcierge from './BlockedFromConcierge'; import type Card from './Card'; import type {CardList} from './Card'; @@ -187,4 +188,5 @@ export type { CapturedLogs, SearchResults, PrivateSubscription, + BillingGraceEndPeriod, }; diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index 326e020464e0..98b92c77663b 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -1,4 +1,5 @@ /* eslint-disable @typescript-eslint/naming-convention */ +import {addDays, format as formatDate, subDays} from 'date-fns'; import type {OnyxEntry} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import * as ReportUtils from '@libs/ReportUtils'; @@ -266,9 +267,15 @@ describe('ReportUtils', () => { }); describe('requiresAttentionFromCurrentUser', () => { + afterEach(async () => { + await Onyx.clear(); + await Onyx.set(ONYXKEYS.SESSION, {email: currentUserEmail, accountID: currentUserAccountID}); + }); + it('returns false when there is no report', () => { expect(ReportUtils.requiresAttentionFromCurrentUser(undefined)).toBe(false); }); + it('returns false when the matched IOU report does not have an owner accountID', () => { const report = { ...LHNTestUtils.getFakeReport(), @@ -276,6 +283,7 @@ describe('ReportUtils', () => { }; expect(ReportUtils.requiresAttentionFromCurrentUser(report)).toBe(false); }); + it('returns false when the linked iou report has an oustanding IOU', () => { const report = { ...LHNTestUtils.getFakeReport(), @@ -288,6 +296,7 @@ describe('ReportUtils', () => { expect(ReportUtils.requiresAttentionFromCurrentUser(report)).toBe(false); }); }); + it('returns false when the report has no outstanding IOU but is waiting for a bank account and the logged user is the report owner', () => { const report = { ...LHNTestUtils.getFakeReport(), @@ -296,6 +305,7 @@ describe('ReportUtils', () => { }; expect(ReportUtils.requiresAttentionFromCurrentUser(report)).toBe(false); }); + it('returns false when the report has outstanding IOU and is not waiting for a bank account and the logged user is the report owner', () => { const report = { ...LHNTestUtils.getFakeReport(), @@ -304,6 +314,7 @@ describe('ReportUtils', () => { }; expect(ReportUtils.requiresAttentionFromCurrentUser(report)).toBe(false); }); + it('returns false when the report has no oustanding IOU but is waiting for a bank account and the logged user is not the report owner', () => { const report = { ...LHNTestUtils.getFakeReport(), @@ -312,6 +323,7 @@ describe('ReportUtils', () => { }; expect(ReportUtils.requiresAttentionFromCurrentUser(report)).toBe(false); }); + it('returns true when the report has an unread mention', () => { const report = { ...LHNTestUtils.getFakeReport(), @@ -319,6 +331,7 @@ describe('ReportUtils', () => { }; expect(ReportUtils.requiresAttentionFromCurrentUser(report)).toBe(true); }); + it('returns true when the report is an outstanding task', () => { const report = { ...LHNTestUtils.getFakeReport(), @@ -330,6 +343,7 @@ describe('ReportUtils', () => { }; expect(ReportUtils.requiresAttentionFromCurrentUser(report)).toBe(true); }); + it('returns true when the report has oustanding child expense', () => { const report = { ...LHNTestUtils.getFakeReport(), @@ -339,6 +353,76 @@ describe('ReportUtils', () => { }; expect(ReportUtils.requiresAttentionFromCurrentUser(report)).toBe(true); }); + + it('returns false if the user is not on free trial', async () => { + await Onyx.multiSet({ + [ONYXKEYS.NVP_LAST_DAY_FREE_TRIAL]: null, // not on free trial + [ONYXKEYS.NVP_BILLING_FUND_ID]: null, // no payment card added + }); + + const report: Report = { + ...LHNTestUtils.getFakeReport(), + chatType: CONST.REPORT.CHAT_TYPE.SYSTEM, + }; + + expect(ReportUtils.requiresAttentionFromCurrentUser(report)).toBe(false); + }); + + it("returns false if the user free trial hasn't ended yet", async () => { + await Onyx.multiSet({ + [ONYXKEYS.NVP_LAST_DAY_FREE_TRIAL]: formatDate(addDays(new Date(), 1), CONST.DATE.FNS_DATE_TIME_FORMAT_STRING), // trial not ended + [ONYXKEYS.NVP_BILLING_FUND_ID]: null, // no payment card added + }); + + const report: Report = { + ...LHNTestUtils.getFakeReport(), + chatType: CONST.REPORT.CHAT_TYPE.SYSTEM, + }; + + expect(ReportUtils.requiresAttentionFromCurrentUser(report)).toBe(false); + }); + + it('returns false if the user free trial has ended and it added a payment card', async () => { + await Onyx.multiSet({ + [ONYXKEYS.NVP_LAST_DAY_FREE_TRIAL]: formatDate(addDays(new Date(), 1), CONST.DATE.FNS_DATE_TIME_FORMAT_STRING), // trial not ended + [ONYXKEYS.NVP_BILLING_FUND_ID]: 8010, // payment card added + }); + + const report: Report = { + ...LHNTestUtils.getFakeReport(), + chatType: CONST.REPORT.CHAT_TYPE.SYSTEM, + }; + + expect(ReportUtils.requiresAttentionFromCurrentUser(report)).toBe(false); + }); + + it("returns true if the report is the system chat, the user free trial has ended and it didn't add a payment card yet", async () => { + await Onyx.multiSet({ + [ONYXKEYS.NVP_LAST_DAY_FREE_TRIAL]: formatDate(subDays(new Date(), 1), CONST.DATE.FNS_DATE_TIME_FORMAT_STRING), // trial ended + [ONYXKEYS.NVP_BILLING_FUND_ID]: null, // no payment card added + }); + + const report: Report = { + ...LHNTestUtils.getFakeReport(), + chatType: CONST.REPORT.CHAT_TYPE.SYSTEM, + }; + + expect(ReportUtils.requiresAttentionFromCurrentUser(report)).toBe(true); + }); + + it("returns true if the report is the concierge chat, the user free trial has ended and it didn't add a payment card yet", async () => { + await Onyx.multiSet({ + [ONYXKEYS.NVP_LAST_DAY_FREE_TRIAL]: formatDate(subDays(new Date(), 1), CONST.DATE.FNS_DATE_TIME_FORMAT_STRING), // trial ended + [ONYXKEYS.NVP_BILLING_FUND_ID]: null, // no payment card added + [ONYXKEYS.SESSION]: {email: currentUserEmail, accountID: 8}, // even account id + }); + + const report: Report = { + ...LHNTestUtils.getFakeReport([CONST.ACCOUNT_ID.CONCIERGE]), + }; + + expect(ReportUtils.requiresAttentionFromCurrentUser(report)).toBe(true); + }); }); describe('getMoneyRequestOptions', () => { @@ -821,4 +905,54 @@ describe('ReportUtils', () => { expect(ReportUtils.getAllAncestorReportActions(reports[4])).toEqual(resultAncestors); }); }); + + describe('isChatUsedForOnboarding', () => { + afterEach(async () => { + await Onyx.clear(); + await Onyx.set(ONYXKEYS.SESSION, {email: currentUserEmail, accountID: currentUserAccountID}); + }); + + it('should return false if the report is neither the system or concierge chat', () => { + expect(ReportUtils.isChatUsedForOnboarding(LHNTestUtils.getFakeReport())).toBeFalsy(); + }); + + it('should return true if the user account ID is odd and report is the system chat', async () => { + const accountID = 1; + + await Onyx.multiSet({ + [ONYXKEYS.PERSONAL_DETAILS_LIST]: { + [accountID]: { + accountID, + }, + }, + [ONYXKEYS.SESSION]: {email: currentUserEmail, accountID}, + }); + + const report: Report = { + ...LHNTestUtils.getFakeReport(), + chatType: CONST.REPORT.CHAT_TYPE.SYSTEM, + }; + + expect(ReportUtils.isChatUsedForOnboarding(report)).toBeTruthy(); + }); + + it('should return true if the user account ID is even and report is the concierge chat', async () => { + const accountID = 2; + + await Onyx.multiSet({ + [ONYXKEYS.PERSONAL_DETAILS_LIST]: { + [accountID]: { + accountID, + }, + }, + [ONYXKEYS.SESSION]: {email: currentUserEmail, accountID}, + }); + + const report: Report = { + ...LHNTestUtils.getFakeReport([CONST.ACCOUNT_ID.CONCIERGE]), + }; + + expect(ReportUtils.isChatUsedForOnboarding(report)).toBeTruthy(); + }); + }); }); diff --git a/tests/unit/SubscriptionUtilsTest.ts b/tests/unit/SubscriptionUtilsTest.ts new file mode 100644 index 000000000000..7767ae9f387b --- /dev/null +++ b/tests/unit/SubscriptionUtilsTest.ts @@ -0,0 +1,248 @@ +import {addDays, addMinutes, format as formatDate, getUnixTime, subDays} from 'date-fns'; +import Onyx from 'react-native-onyx'; +import * as SubscriptionUtils from '@libs/SubscriptionUtils'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {BillingGraceEndPeriod} from '@src/types/onyx'; +import createRandomPolicy from '../utils/collections/policies'; + +const billingGraceEndPeriod: BillingGraceEndPeriod = { + name: 'owner@email.com', + permissions: 'read', + value: 0, +}; + +Onyx.init({keys: ONYXKEYS}); + +describe('SubscriptionUtils', () => { + describe('calculateRemainingFreeTrialDays', () => { + afterEach(async () => { + await Onyx.clear(); + await Onyx.multiSet({ + [ONYXKEYS.NVP_FIRST_DAY_FREE_TRIAL]: null, + }); + }); + + it('should return 0 if the Onyx key is not set', () => { + expect(SubscriptionUtils.calculateRemainingFreeTrialDays()).toBe(0); + }); + + it('should return 0 if the current date is after the free trial end date', async () => { + await Onyx.set(ONYXKEYS.NVP_LAST_DAY_FREE_TRIAL, formatDate(subDays(new Date(), 8), CONST.DATE.FNS_DATE_TIME_FORMAT_STRING)); + expect(SubscriptionUtils.calculateRemainingFreeTrialDays()).toBe(0); + }); + + it('should return 1 if the current date is on the same day of the free trial end date, but some minutes earlier', async () => { + await Onyx.set(ONYXKEYS.NVP_LAST_DAY_FREE_TRIAL, formatDate(addMinutes(new Date(), 30), CONST.DATE.FNS_DATE_TIME_FORMAT_STRING)); + expect(SubscriptionUtils.calculateRemainingFreeTrialDays()).toBe(1); + }); + + it('should return the remaining days if the current date is before the free trial end date', async () => { + await Onyx.set(ONYXKEYS.NVP_LAST_DAY_FREE_TRIAL, formatDate(addDays(new Date(), 5), CONST.DATE.FNS_DATE_TIME_FORMAT_STRING)); + expect(SubscriptionUtils.calculateRemainingFreeTrialDays()).toBe(5); + }); + }); + + describe('isUserOnFreeTrial', () => { + afterEach(async () => { + await Onyx.clear(); + await Onyx.multiSet({ + [ONYXKEYS.NVP_FIRST_DAY_FREE_TRIAL]: null, + [ONYXKEYS.NVP_LAST_DAY_FREE_TRIAL]: null, + }); + }); + + it('should return false if the Onyx keys are not set', () => { + expect(SubscriptionUtils.isUserOnFreeTrial()).toBeFalsy(); + }); + + it('should return false if the current date is before the free trial start date', async () => { + await Onyx.multiSet({ + [ONYXKEYS.NVP_FIRST_DAY_FREE_TRIAL]: formatDate(addDays(new Date(), 2), CONST.DATE.FNS_DATE_TIME_FORMAT_STRING), + [ONYXKEYS.NVP_LAST_DAY_FREE_TRIAL]: formatDate(addDays(new Date(), 4), CONST.DATE.FNS_DATE_TIME_FORMAT_STRING), + }); + + expect(SubscriptionUtils.isUserOnFreeTrial()).toBeFalsy(); + }); + + it('should return false if the current date is after the free trial end date', async () => { + await Onyx.multiSet({ + [ONYXKEYS.NVP_FIRST_DAY_FREE_TRIAL]: formatDate(subDays(new Date(), 4), CONST.DATE.FNS_DATE_TIME_FORMAT_STRING), + [ONYXKEYS.NVP_LAST_DAY_FREE_TRIAL]: formatDate(subDays(new Date(), 2), CONST.DATE.FNS_DATE_TIME_FORMAT_STRING), + }); + + expect(SubscriptionUtils.isUserOnFreeTrial()).toBeFalsy(); + }); + + it('should return true if the current date is on the same date of free trial start date', async () => { + await Onyx.multiSet({ + [ONYXKEYS.NVP_FIRST_DAY_FREE_TRIAL]: formatDate(new Date(), CONST.DATE.FNS_DATE_TIME_FORMAT_STRING), + [ONYXKEYS.NVP_LAST_DAY_FREE_TRIAL]: formatDate(addDays(new Date(), 3), CONST.DATE.FNS_DATE_TIME_FORMAT_STRING), + }); + + expect(SubscriptionUtils.isUserOnFreeTrial()).toBeTruthy(); + }); + + it('should return true if the current date is on the same date of free trial end date, but some minutes earlier', async () => { + await Onyx.multiSet({ + [ONYXKEYS.NVP_FIRST_DAY_FREE_TRIAL]: formatDate(subDays(new Date(), 2), CONST.DATE.FNS_DATE_TIME_FORMAT_STRING), + [ONYXKEYS.NVP_LAST_DAY_FREE_TRIAL]: formatDate(addMinutes(new Date(), 30), CONST.DATE.FNS_DATE_TIME_FORMAT_STRING), + }); + + expect(SubscriptionUtils.isUserOnFreeTrial()).toBeTruthy(); + }); + + it('should return true if the current date is between the free trial start and end dates', async () => { + await Onyx.multiSet({ + [ONYXKEYS.NVP_FIRST_DAY_FREE_TRIAL]: formatDate(subDays(new Date(), 1), CONST.DATE.FNS_DATE_TIME_FORMAT_STRING), + [ONYXKEYS.NVP_LAST_DAY_FREE_TRIAL]: formatDate(addDays(new Date(), 3), CONST.DATE.FNS_DATE_TIME_FORMAT_STRING), + }); + + expect(SubscriptionUtils.isUserOnFreeTrial()).toBeTruthy(); + }); + }); + + describe('hasUserFreeTrialEnded', () => { + afterEach(async () => { + await Onyx.clear(); + await Onyx.multiSet({ + [ONYXKEYS.NVP_FIRST_DAY_FREE_TRIAL]: null, + [ONYXKEYS.NVP_LAST_DAY_FREE_TRIAL]: null, + }); + }); + + it('should return false if the Onyx key is not set', () => { + expect(SubscriptionUtils.hasUserFreeTrialEnded()).toBeFalsy(); + }); + + it('should return false if the current date is before the free trial end date', async () => { + await Onyx.set(ONYXKEYS.NVP_LAST_DAY_FREE_TRIAL, formatDate(addDays(new Date(), 1), CONST.DATE.FNS_DATE_TIME_FORMAT_STRING)); + expect(SubscriptionUtils.hasUserFreeTrialEnded()).toBeFalsy(); + }); + + it('should return true if the current date is after the free trial end date', async () => { + await Onyx.set(ONYXKEYS.NVP_LAST_DAY_FREE_TRIAL, formatDate(subDays(new Date(), 2), CONST.DATE.FNS_DATE_TIME_FORMAT_STRING)); + expect(SubscriptionUtils.hasUserFreeTrialEnded()).toBeTruthy(); + }); + }); + + describe('doesUserHavePaymentCardAdded', () => { + afterEach(async () => { + await Onyx.clear(); + await Onyx.multiSet({ + [ONYXKEYS.NVP_BILLING_FUND_ID]: null, + }); + }); + + it('should return false if the Onyx key is not set', () => { + expect(SubscriptionUtils.doesUserHavePaymentCardAdded()).toBeFalsy(); + }); + + it('should return true if the Onyx key is set', async () => { + await Onyx.set(ONYXKEYS.NVP_BILLING_FUND_ID, 8010); + expect(SubscriptionUtils.doesUserHavePaymentCardAdded()).toBeTruthy(); + }); + }); + + describe('shouldRestrictUserBillableActions', () => { + afterEach(async () => { + await Onyx.clear(); + await Onyx.multiSet({ + [ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_USER_BILLING_GRACE_PERIOD_END]: null, + [ONYXKEYS.NVP_PRIVATE_OWNER_BILLING_GRACE_PERIOD_END]: null, + [ONYXKEYS.NVP_PRIVATE_AMOUNT_OWNED]: null, + [ONYXKEYS.COLLECTION.POLICY]: null, + }); + }); + + it("should return false if the user isn't a workspace's owner or isn't a member of any past due billing workspace", () => { + expect(SubscriptionUtils.shouldRestrictUserBillableActions('1')).toBeFalsy(); + }); + + it('should return false if the user is a non-owner of a workspace that is not in the shared NVP collection', async () => { + const policyID = '1001'; + const ownerAccountID = 2001; + + await Onyx.multiSet({ + [`${ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_USER_BILLING_GRACE_PERIOD_END}${ownerAccountID}` as const]: { + ...billingGraceEndPeriod, + value: getUnixTime(subDays(new Date(), 3)), // past due + }, + [`${ONYXKEYS.COLLECTION.POLICY}${policyID}` as const]: { + ...createRandomPolicy(Number(policyID)), + ownerAccountID: 2002, // owner not in the shared NVP collection + }, + }); + + expect(SubscriptionUtils.shouldRestrictUserBillableActions(policyID)).toBeFalsy(); + }); + + it("should return false if the user is a workspace's non-owner that is not past due billing", async () => { + const policyID = '1001'; + const ownerAccountID = 2001; + + await Onyx.multiSet({ + [`${ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_USER_BILLING_GRACE_PERIOD_END}${ownerAccountID}` as const]: { + ...billingGraceEndPeriod, + value: getUnixTime(addDays(new Date(), 3)), // not past due + }, + [`${ONYXKEYS.COLLECTION.POLICY}${policyID}` as const]: { + ...createRandomPolicy(Number(policyID)), + ownerAccountID, // owner in the shared NVP collection + }, + }); + + expect(SubscriptionUtils.shouldRestrictUserBillableActions(policyID)).toBeFalsy(); + }); + + it("should return true if the user is a workspace's non-owner that is past due billing", async () => { + const policyID = '1001'; + const ownerAccountID = 2001; + + await Onyx.multiSet({ + [`${ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_USER_BILLING_GRACE_PERIOD_END}${ownerAccountID}` as const]: { + ...billingGraceEndPeriod, + value: getUnixTime(subDays(new Date(), 3)), // past due + }, + [`${ONYXKEYS.COLLECTION.POLICY}${policyID}` as const]: { + ...createRandomPolicy(Number(policyID)), + ownerAccountID, // owner in the shared NVP collection + }, + }); + + expect(SubscriptionUtils.shouldRestrictUserBillableActions(policyID)).toBeTruthy(); + }); + + it('should return false if the user is a workspace owner but is not past due billing', async () => { + const policyID = '1001'; + + await Onyx.multiSet({ + [ONYXKEYS.NVP_PRIVATE_OWNER_BILLING_GRACE_PERIOD_END]: getUnixTime(addDays(new Date(), 3)), // not past due + }); + + expect(SubscriptionUtils.shouldRestrictUserBillableActions(policyID)).toBeFalsy(); + }); + + it("should return false if the user is a workspace owner but is past due billing but isn't owning any amount", async () => { + const policyID = '1001'; + + await Onyx.multiSet({ + [ONYXKEYS.NVP_PRIVATE_OWNER_BILLING_GRACE_PERIOD_END]: getUnixTime(subDays(new Date(), 3)), // past due + [ONYXKEYS.NVP_PRIVATE_AMOUNT_OWNED]: 0, + }); + + expect(SubscriptionUtils.shouldRestrictUserBillableActions(policyID)).toBeFalsy(); + }); + + it('should return true if the user is a workspace owner but is past due billing and is owning some amount', async () => { + const policyID = '1001'; + + await Onyx.multiSet({ + [ONYXKEYS.NVP_PRIVATE_OWNER_BILLING_GRACE_PERIOD_END]: getUnixTime(subDays(new Date(), 3)), // past due + [ONYXKEYS.NVP_PRIVATE_AMOUNT_OWNED]: 8010, + }); + + expect(SubscriptionUtils.shouldRestrictUserBillableActions(policyID)).toBeTruthy(); + }); + }); +});