diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index aabeff718305..709347fa71cd 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -359,12 +359,17 @@ const ONYXKEYS = { /** Holds the checks used while transferring the ownership of the workspace */ POLICY_OWNERSHIP_CHANGE_CHECKS: 'policyOwnershipChangeChecks', + // These statuses below are in separate keys on purpose - it allows us to have different behaviours of the banner based on the status + /** Indicates whether ClearOutstandingBalance failed */ SUBSCRIPTION_RETRY_BILLING_STATUS_FAILED: 'subscriptionRetryBillingStatusFailed', /** Indicates whether ClearOutstandingBalance was successful */ SUBSCRIPTION_RETRY_BILLING_STATUS_SUCCESSFUL: 'subscriptionRetryBillingStatusSuccessful', + /** Indicates whether ClearOutstandingBalance is pending */ + SUBSCRIPTION_RETRY_BILLING_STATUS_PENDING: 'subscriptionRetryBillingStatusPending', + /** Stores info during review duplicates flow */ REVIEW_DUPLICATES: 'reviewDuplicates', @@ -784,6 +789,7 @@ type OnyxValuesMapping = { [ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE]: OnyxTypes.QuickAction; [ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_FAILED]: boolean; [ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_SUCCESSFUL]: boolean; + [ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_PENDING]: boolean; [ONYXKEYS.NVP_TRAVEL_SETTINGS]: OnyxTypes.TravelSettings; [ONYXKEYS.REVIEW_DUPLICATES]: OnyxTypes.ReviewDuplicates; [ONYXKEYS.ISSUE_NEW_EXPENSIFY_CARD]: OnyxTypes.IssueNewCard; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index 9caff0ec1452..fdee63b086a9 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -253,6 +253,7 @@ const WRITE_COMMANDS = { UPDATE_NETSUITE_EXPORT_TO_NEXT_OPEN_PERIOD: 'UpdateNetSuiteExportToNextOpenPeriod', REQUEST_EXPENSIFY_CARD_LIMIT_INCREASE: 'RequestExpensifyCardLimitIncrease', CONNECT_POLICY_TO_SAGE_INTACCT: 'ConnectPolicyToSageIntacct', + CLEAR_OUTSTANDING_BALANCE: 'ClearOutstandingBalance', } as const; type WriteCommand = ValueOf; @@ -457,6 +458,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.SET_POLICY_DISTANCE_RATES_DEFAULT_CATEGORY]: Parameters.SetPolicyDistanceRatesDefaultCategoryParams; [WRITE_COMMANDS.ENABLE_DISTANCE_REQUEST_TAX]: Parameters.SetPolicyDistanceRatesDefaultCategoryParams; [WRITE_COMMANDS.REQUEST_EXPENSIFY_CARD_LIMIT_INCREASE]: Parameters.RequestExpensifyCardLimitIncreaseParams; + [WRITE_COMMANDS.CLEAR_OUTSTANDING_BALANCE]: null; [WRITE_COMMANDS.UPDATE_POLICY_CONNECTION_CONFIG]: Parameters.UpdatePolicyConnectionConfigParams; [WRITE_COMMANDS.UPDATE_MANY_POLICY_CONNECTION_CONFIGS]: Parameters.UpdateManyPolicyConnectionConfigurationsParams; diff --git a/src/libs/DateUtils.ts b/src/libs/DateUtils.ts index f538e5e719e2..8a8888902e92 100644 --- a/src/libs/DateUtils.ts +++ b/src/libs/DateUtils.ts @@ -806,6 +806,19 @@ function doesDateBelongToAPastYear(date: string): boolean { return transactionYear !== new Date().getFullYear(); } +/** + * Returns a boolean value indicating whether the card has expired. + * @param expiryMonth month when card expires (starts from 1 so can be any number between 1 and 12) + * @param expiryYear year when card expires + */ + +function isCardExpired(expiryMonth: number, expiryYear: number): boolean { + const currentYear = new Date().getFullYear(); + const currentMonth = new Date().getMonth() + 1; + + return expiryYear < currentYear || (expiryYear === currentYear && expiryMonth < currentMonth); +} + const DateUtils = { isDate, formatToDayOfWeek, @@ -850,6 +863,7 @@ const DateUtils = { getFormattedReservationRangeDate, getFormattedTransportDate, doesDateBelongToAPastYear, + isCardExpired, }; export default DateUtils; diff --git a/src/libs/SubscriptionUtils.ts b/src/libs/SubscriptionUtils.ts index 8569a3f03128..c807d0ca4a7e 100644 --- a/src/libs/SubscriptionUtils.ts +++ b/src/libs/SubscriptionUtils.ts @@ -81,6 +81,7 @@ Onyx.connect({ let retryBillingSuccessful: OnyxEntry; Onyx.connect({ key: ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_SUCCESSFUL, + initWithStoredValues: false, callback: (value) => { if (value === undefined) { return; diff --git a/src/libs/actions/Subscription.ts b/src/libs/actions/Subscription.ts index 19a3bf0c547e..beed2b1b2962 100644 --- a/src/libs/actions/Subscription.ts +++ b/src/libs/actions/Subscription.ts @@ -231,4 +231,60 @@ function clearUpdateSubscriptionSizeError() { }); } -export {openSubscriptionPage, updateSubscriptionAutoRenew, updateSubscriptionAddNewUsersAutomatically, updateSubscriptionSize, clearUpdateSubscriptionSizeError, updateSubscriptionType}; +function clearOutstandingBalance() { + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_PENDING, + value: true, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_PENDING, + value: false, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_SUCCESSFUL, + value: true, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_FAILED, + value: false, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_PENDING, + value: false, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_SUCCESSFUL, + value: false, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_FAILED, + value: true, + }, + ], + }; + + API.write(WRITE_COMMANDS.CLEAR_OUTSTANDING_BALANCE, null, onyxData); +} + +export { + openSubscriptionPage, + updateSubscriptionAutoRenew, + updateSubscriptionAddNewUsersAutomatically, + updateSubscriptionSize, + clearUpdateSubscriptionSizeError, + updateSubscriptionType, + clearOutstandingBalance, +}; diff --git a/src/pages/settings/Subscription/CardSection/BillingBanner/BillingBanner.tsx b/src/pages/settings/Subscription/CardSection/BillingBanner/BillingBanner.tsx index 4587dfee2fe6..bbb06dac4549 100644 --- a/src/pages/settings/Subscription/CardSection/BillingBanner/BillingBanner.tsx +++ b/src/pages/settings/Subscription/CardSection/BillingBanner/BillingBanner.tsx @@ -1,9 +1,10 @@ -import React from 'react'; +import React, {useMemo} from 'react'; import type {StyleProp, TextStyle, ViewStyle} from 'react-native'; import {View} from 'react-native'; import type {ValueOf} from 'type-fest'; import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; +import {PressableWithoutFeedback} from '@components/Pressable'; import Text from '@components/Text'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -35,12 +36,50 @@ type BillingBannerProps = { /** An icon to be rendered instead of the RBR / GBR indicator. */ rightIcon?: IconAsset; + + /** Callback to be called when the right icon is pressed. */ + onRightIconPress?: () => void; + + /** Accessibility label for the right icon. */ + rightIconAccessibilityLabel?: string; }; -function BillingBanner({title, subtitle, icon, brickRoadIndicator, style, titleStyle, subtitleStyle, rightIcon}: BillingBannerProps) { +function BillingBanner({title, subtitle, icon, brickRoadIndicator, style, titleStyle, subtitleStyle, rightIcon, onRightIconPress, rightIconAccessibilityLabel}: BillingBannerProps) { const styles = useThemeStyles(); const theme = useTheme(); + const rightIconComponent = useMemo(() => { + if (rightIcon) { + return onRightIconPress && rightIconAccessibilityLabel ? ( + + + + ) : ( + + ); + } + + return ( + !!brickRoadIndicator && ( + + ) + ); + }, [brickRoadIndicator, onRightIconPress, rightIcon, rightIconAccessibilityLabel, styles.touchableButtonImage, theme.danger, theme.icon, theme.success]); + return ( {title} : title} {typeof subtitle === 'string' ? {subtitle} : subtitle} - {rightIcon ? ( - - ) : ( - !!brickRoadIndicator && ( - - ) - )} + {rightIconComponent} ); } diff --git a/src/pages/settings/Subscription/CardSection/BillingBanner/SubscriptionBillingBanner.tsx b/src/pages/settings/Subscription/CardSection/BillingBanner/SubscriptionBillingBanner.tsx index dce215e7dbbc..d949e2699e44 100644 --- a/src/pages/settings/Subscription/CardSection/BillingBanner/SubscriptionBillingBanner.tsx +++ b/src/pages/settings/Subscription/CardSection/BillingBanner/SubscriptionBillingBanner.tsx @@ -14,7 +14,7 @@ type SubscriptionBillingBannerProps = Omit ); } diff --git a/src/pages/settings/Subscription/CardSection/CardSection.tsx b/src/pages/settings/Subscription/CardSection/CardSection.tsx index e873569e4583..f3b78b3f2b95 100644 --- a/src/pages/settings/Subscription/CardSection/CardSection.tsx +++ b/src/pages/settings/Subscription/CardSection/CardSection.tsx @@ -1,6 +1,7 @@ -import React, {useCallback, useMemo, useState} from 'react'; +import React, {useCallback, useEffect, useMemo, useState} from 'react'; import {View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; +import Button from '@components/Button'; import ConfirmModal from '@components/ConfirmModal'; import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; @@ -8,6 +9,7 @@ import MenuItem from '@components/MenuItem'; import Section from '@components/Section'; import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; import useSubscriptionPlan from '@hooks/useSubscriptionPlan'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -15,6 +17,7 @@ import * as User from '@libs/actions/User'; import DateUtils from '@libs/DateUtils'; import Navigation from '@libs/Navigation/Navigation'; import * as SubscriptionUtils from '@libs/SubscriptionUtils'; +import * as Subscription from '@userActions/Subscription'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -24,6 +27,7 @@ import SubscriptionBillingBanner from './BillingBanner/SubscriptionBillingBanner import TrialStartedBillingBanner from './BillingBanner/TrialStartedBillingBanner'; import CardSectionActions from './CardSectionActions'; import CardSectionDataEmpty from './CardSectionDataEmpty'; +import type {BillingStatusResult} from './utils'; import CardSectionUtils from './utils'; function CardSection() { @@ -35,8 +39,10 @@ function CardSection() { const [privateSubscription] = useOnyx(ONYXKEYS.NVP_PRIVATE_SUBSCRIPTION); const [fundList] = useOnyx(ONYXKEYS.FUND_LIST); const subscriptionPlan = useSubscriptionPlan(); - const [network] = useOnyx(ONYXKEYS.NETWORK); - + const [subscriptionRetryBillingStatusPending] = useOnyx(ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_PENDING); + const [subscriptionRetryBillingStatusSuccessful] = useOnyx(ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_SUCCESSFUL); + const [subscriptionRetryBillingStatusFailed] = useOnyx(ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_FAILED); + const {isOffline} = useNetwork(); const defaultCard = useMemo(() => Object.values(fundList ?? {}).find((card) => card.accountData?.additionalData?.isBillingCard), [fundList]); const cardMonth = useMemo(() => DateUtils.getMonthNames(preferredLocale)[(defaultCard?.accountData?.cardMonth ?? 1) - 1], [defaultCard?.accountData?.cardMonth, preferredLocale]); @@ -47,12 +53,24 @@ function CardSection() { Navigation.resetToHome(); }, []); - const billingStatus = CardSectionUtils.getBillingStatus(translate, defaultCard?.accountData?.cardNumber ?? ''); + const [billingStatus, setBillingStatus] = useState(CardSectionUtils.getBillingStatus(translate, defaultCard?.accountData ?? {})); const nextPaymentDate = !isEmptyObject(privateSubscription) ? CardSectionUtils.getNextBillingDate() : undefined; const sectionSubtitle = defaultCard && !!nextPaymentDate ? translate('subscription.cardSection.cardNextPayment', {nextPaymentDate}) : translate('subscription.cardSection.subtitle'); + useEffect(() => { + setBillingStatus(CardSectionUtils.getBillingStatus(translate, defaultCard?.accountData ?? {})); + }, [subscriptionRetryBillingStatusPending, subscriptionRetryBillingStatusSuccessful, subscriptionRetryBillingStatusFailed, translate, defaultCard?.accountData]); + + const handleRetryPayment = () => { + Subscription.clearOutstandingBalance(); + }; + + const handleBillingBannerClose = () => { + setBillingStatus(undefined); + }; + let BillingBanner: React.ReactNode | undefined; if (CardSectionUtils.shouldShowPreTrialBillingBanner()) { BillingBanner = ; @@ -66,6 +84,8 @@ function CardSection() { isError={billingStatus.isError} icon={billingStatus.icon} rightIcon={billingStatus.rightIcon} + onRightIconPress={handleBillingBannerClose} + rightIconAccessibilityLabel={translate('common.close')} /> ); } @@ -105,6 +125,18 @@ function CardSection() { {isEmptyObject(defaultCard?.accountData) && } + + {billingStatus?.isRetryAvailable !== undefined && ( +