Skip to content

Commit

Permalink
Merge pull request #44268 from MrMuzyk/feat/integrate-retry-billing-w…
Browse files Browse the repository at this point in the history
…ith-be
  • Loading branch information
blimpich committed Jul 3, 2024
2 parents 2fa1734 + 21e99b2 commit 208eb02
Show file tree
Hide file tree
Showing 11 changed files with 220 additions and 38 deletions.
6 changes: 6 additions & 0 deletions src/ONYXKEYS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',

Expand Down Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions src/libs/API/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof WRITE_COMMANDS>;
Expand Down Expand Up @@ -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;
Expand Down
14 changes: 14 additions & 0 deletions src/libs/DateUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -850,6 +863,7 @@ const DateUtils = {
getFormattedReservationRangeDate,
getFormattedTransportDate,
doesDateBelongToAPastYear,
isCardExpired,
};

export default DateUtils;
1 change: 1 addition & 0 deletions src/libs/SubscriptionUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ Onyx.connect({
let retryBillingSuccessful: OnyxEntry<boolean>;
Onyx.connect({
key: ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_SUCCESSFUL,
initWithStoredValues: false,
callback: (value) => {
if (value === undefined) {
return;
Expand Down
58 changes: 57 additions & 1 deletion src/libs/actions/Subscription.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 ? (
<PressableWithoutFeedback
onPress={onRightIconPress}
style={[styles.touchableButtonImage]}
role={CONST.ROLE.BUTTON}
accessibilityLabel={rightIconAccessibilityLabel}
>
<Icon
src={rightIcon}
fill={theme.icon}
/>
</PressableWithoutFeedback>
) : (
<Icon
src={rightIcon}
fill={theme.icon}
/>
);
}

return (
!!brickRoadIndicator && (
<Icon
src={Expensicons.DotIndicator}
fill={brickRoadIndicator === CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR ? theme.danger : theme.success}
/>
)
);
}, [brickRoadIndicator, onRightIconPress, rightIcon, rightIconAccessibilityLabel, styles.touchableButtonImage, theme.danger, theme.icon, theme.success]);

return (
<View style={[styles.pv4, styles.ph5, styles.flexRow, styles.gap3, styles.w100, styles.alignItemsCenter, styles.trialBannerBackgroundColor, style]}>
<Icon
Expand All @@ -53,19 +92,7 @@ function BillingBanner({title, subtitle, icon, brickRoadIndicator, style, titleS
{typeof title === 'string' ? <Text style={[styles.textStrong, titleStyle]}>{title}</Text> : title}
{typeof subtitle === 'string' ? <Text style={subtitleStyle}>{subtitle}</Text> : subtitle}
</View>
{rightIcon ? (
<Icon
src={rightIcon}
fill={theme.icon}
/>
) : (
!!brickRoadIndicator && (
<Icon
src={Expensicons.DotIndicator}
fill={brickRoadIndicator === CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR ? theme.danger : theme.success}
/>
)
)}
{rightIconComponent}
</View>
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ type SubscriptionBillingBannerProps = Omit<BillingBannerProps, 'titleStyle' | 's
icon?: IconAsset;
};

function SubscriptionBillingBanner({title, subtitle, rightIcon, icon, isError = false}: SubscriptionBillingBannerProps) {
function SubscriptionBillingBanner({title, subtitle, rightIcon, icon, isError = false, onRightIconPress, rightIconAccessibilityLabel}: SubscriptionBillingBannerProps) {
const styles = useThemeStyles();

const iconAsset = icon ?? isError ? Illustrations.CreditCardEyes : Illustrations.CheckmarkCircle;
Expand All @@ -28,6 +28,8 @@ function SubscriptionBillingBanner({title, subtitle, rightIcon, icon, isError =
subtitleStyle={styles.textSupporting}
style={styles.hoveredComponentBG}
rightIcon={rightIcon}
onRightIconPress={onRightIconPress}
rightIconAccessibilityLabel={rightIconAccessibilityLabel}
/>
);
}
Expand Down
43 changes: 38 additions & 5 deletions src/pages/settings/Subscription/CardSection/CardSection.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,23 @@
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';
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';
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';
Expand All @@ -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() {
Expand All @@ -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]);
Expand All @@ -47,12 +53,24 @@ function CardSection() {
Navigation.resetToHome();
}, []);

const billingStatus = CardSectionUtils.getBillingStatus(translate, defaultCard?.accountData?.cardNumber ?? '');
const [billingStatus, setBillingStatus] = useState<BillingStatusResult | undefined>(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 = <PreTrialBillingBanner />;
Expand All @@ -66,6 +84,8 @@ function CardSection() {
isError={billingStatus.isError}
icon={billingStatus.icon}
rightIcon={billingStatus.rightIcon}
onRightIconPress={handleBillingBannerClose}
rightIconAccessibilityLabel={translate('common.close')}
/>
);
}
Expand Down Expand Up @@ -105,6 +125,18 @@ function CardSection() {
</View>

{isEmptyObject(defaultCard?.accountData) && <CardSectionDataEmpty />}

{billingStatus?.isRetryAvailable !== undefined && (
<Button
text={translate('subscription.cardSection.retryPaymentButton')}
isDisabled={isOffline || !billingStatus?.isRetryAvailable}
isLoading={subscriptionRetryBillingStatusPending}
onPress={handleRetryPayment}
style={[styles.w100, styles.mt5]}
large
/>
)}

{!!account?.hasPurchases && (
<MenuItem
shouldShowRightIcon
Expand All @@ -117,14 +149,15 @@ function CardSection() {
hoverAndPressStyle={styles.hoveredComponentBG}
/>
)}

{!!(subscriptionPlan && account?.isEligibleForRefund) && (
<MenuItem
shouldShowRightIcon
icon={Expensicons.Bill}
wrapperStyle={styles.sectionMenuItemTopDescription}
title={translate('subscription.cardSection.requestRefund')}
titleStyle={styles.textStrong}
disabled={network?.isOffline}
disabled={isOffline}
onPress={() => setIsRequestRefundModalVisible(true)}
/>
)}
Expand Down
Loading

0 comments on commit 208eb02

Please sign in to comment.