Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: integrate retry billing with backend #44268

Merged
merged 24 commits into from
Jul 3, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
c42fcf5
feat: integrate retry billing button
MrMuzyk Jun 21, 2024
cb8ff38
Merge branch 'feature/billing-banner-logic-api-integration' of https:…
MrMuzyk Jun 21, 2024
68fcbcd
before rebase
MrMuzyk Jun 21, 2024
a17098b
Merge branch 'main' of https://github.com/Expensify/App into feat/int…
MrMuzyk Jun 21, 2024
3088fc7
Merge branch 'main' of https://github.com/Expensify/App into feat/int…
MrMuzyk Jun 24, 2024
0244145
Merge branch 'feature/billing-banner-logic-api-integration' of https:…
MrMuzyk Jun 24, 2024
0a926e2
feat: rebased with dependant pr
MrMuzyk Jun 24, 2024
24683fb
fix: corrections
MrMuzyk Jun 25, 2024
cf58319
fix: linter
MrMuzyk Jun 25, 2024
a012abd
Merge branch 'feature/billing-banner-logic-api-integration' of https:…
MrMuzyk Jun 26, 2024
ede1df8
feat: after rebase
MrMuzyk Jun 26, 2024
8d64503
Merge branch 'feature/billing-banner-logic-api-integration' of https:…
MrMuzyk Jun 26, 2024
1b29ec4
Merge branch 'feature/billing-banner-logic-api-integration' of https:…
MrMuzyk Jun 26, 2024
083cc5f
feat: small refactor
MrMuzyk Jun 26, 2024
b526306
Merge branch 'feature/billing-banner-logic-api-integration' of https:…
MrMuzyk Jul 1, 2024
0ba0a60
fix: checks fix
MrMuzyk Jul 1, 2024
025af1c
fix: move retry payment button
MrMuzyk Jul 1, 2024
0ac4188
Merge branch 'main' of https://github.com/Expensify/App into feat/int…
MrMuzyk Jul 2, 2024
962f925
fix: small corrections
MrMuzyk Jul 2, 2024
ee8aeba
fix: unit test
MrMuzyk Jul 2, 2024
0ee9313
feat: handle new card when card expired scenario
MrMuzyk Jul 2, 2024
56974f4
fix: cr fixes
MrMuzyk Jul 2, 2024
c860dc6
fix: more cr fixes
MrMuzyk Jul 3, 2024
21e99b2
Merge branch 'main' of https://github.com/Expensify/App into feat/int…
MrMuzyk Jul 3, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 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 @@ -781,6 +786,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 @@ -252,6 +252,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 @@ -456,6 +457,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 {
MrMuzyk marked this conversation as resolved.
Show resolved Hide resolved
MrMuzyk marked this conversation as resolved.
Show resolved Hide resolved
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,
MrMuzyk marked this conversation as resolved.
Show resolved Hide resolved
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,
MrMuzyk marked this conversation as resolved.
Show resolved Hide resolved
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 />}

MrMuzyk marked this conversation as resolved.
Show resolved Hide resolved
{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
Loading