Skip to content

[HOLD for payment 2025-02-05] [Early Adoption Discount] - Implement the early discount countdown banner #54817

Description

@youssef-lr

Part of the Early Adoption Discount project

Main issue: https://github.com/Expensify/Expensify/issues/457641
Doc section: https://docs.google.com/document/d/1C8EIyxvoZGhYJOLA8J1QO2EqUkAX0LBFmkVSJaUeGF8/edit?tab=t.0#bookmark=id.cr9hpaweltm6
Project: https://github.com/orgs/Expensify/projects/177/views/7

Feature Description

In order to display the banner, we need to check first that the free trial is ongoing. If yes, we can use the same logic used in the backend to check if there is still time to display the banner. We’ll code a utility function in SubscriptionUtils that does the following (note that this only controls whether or not we show the banner, it doesn’t control which discount is shown):

Utility function to control the display of the banner

SubscriptionUtils

function shouldShowDiscountBanner(): boolean {
    if (!isUserOnFreeTrial()) {
        return false;
    }

    if (doesUserHavePaymentCardAdded()) {
        return false;
    }

    // We'll use comparisons in seconds in this project
    const dateNow = Date.now() / 1000;

    // As well as UTC comparisons
    const firstDayTimestamp = new Date(`${firstDayFreeTrial} UTC`).getTime() / 1000;
    const lastDayTimestamp = new Date(`${lastDayFreeTrial} UTC`).getTime() / 1000;
    if (dateNow > lastDayTimestamp) {
        return false;
    }

    return dateNow <= firstDayTimestamp + 8 * CONST.DATE.SECONDS_PER_DAY * 1000;
}

Utility function to calculate the time remaining

SubscriptionUtils

function getEarlyDiscountInfo(firstDayFreeTrial: string) {
    const dateNow = Date.now() / 1000;
    const firstDayTimestamp = new Date(`${firstDayFreeTrial} UTC`).getTime() / 1000;

    let timeLeftInSeconds;
    const timeLeft24 = CONST.DATE.SECONDS_PER_DAY - (dateNow - firstDayTimestamp);
    if (timeLeft24 > 0) {
        timeLeftInSeconds = timeLeft24;
    } else {
        timeLeftInSeconds = firstDayTimestamp + 8 * CONST.DATE.SECONDS_PER_DAY - dateNow;
    }

    if (timeLeftInSeconds <= 0) {
        return null;
    }

    return {
        days: Math.floor(timeLeftInSeconds / CONST.DATE.SECONDS_PER_DAY),
        hours: Math.floor((timeLeftInSeconds % CONST.DATE.SECONDS_PER_DAY) / 3600),
        minutes: Math.floor((timeLeftInSeconds % 3600) / 60),
        seconds: timeLeftInSeconds % 60,
        discountType: timeLeft24 > 0 ? 50 : 25,
    };
}

New countdown component

Now that we have everything we need to show a countdown, let’s create a new component that will use the functions above. We’ll simply create a new wrapper around BillingBanner which already has the same styling we need. We’ll also need to modify BilingBanner to take a right component which should host the buttons.

function EarlyDiscountBanner({isSubscriptionPage}) {
    const {translate} = useLocalize();
    const styles = useThemeStyles();

    const [firstDayFreeTrial] = useOnyx(ONYXKEYS.NVP_FIRST_DAY_FREE_TRIAL);
    const [lastDayFreeTrial] = useOnyx(ONYXKEYS.NVP_LAST_DAY_FREE_TRIAL);

    const initialDiscountInfo = getEarlyDiscountInfo();
    const [discountInfo, setDiscountInfo] = useState(initialDiscountInfo);
    const [isDismissed, setIsDismissed] = useState(false);

    useEffect(() => {
        const intervalID = setInterval(() => {
            setDiscountInfo(getEarlyDiscountInfo());
        }, 1000);

        return () => clearInterval(intervalID);
    }, [firstDayFreeTrial]);

    const title = isSubscriptionPage ? (
        <Text style={styles.textStrong}>
            {discountInfo?.discountType}% off your first year!&nbsp;
            <Text>Just add payment card and start an annual subscription!</Text>
        </Text>
    ) : (
        <Text style={styles.textStrong}>
            Limited time offer:&nbsp;
            <Text>{discountInfo?.discountType}% off your first year!</Text>
        </Text>
    );

    const formatTimeRemaining = useCallback(() => {
        if (discountInfo?.days === 0) {
            return `Claim within ${discountInfo?.hours}h : ${discountInfo?.minutes}m : ${discountInfo?.seconds}s`;
        }
        return `Claim within ${discountInfo?.days}d : ${discountInfo?.hours}h : ${discountInfo?.minutes}m : ${discountInfo?.seconds}s`;
    }, [discountInfo]);

    const {shouldUseNarrowLayout} = useResponsiveLayout();
    const rightComponent = useMemo(() => {
        const smallScreenStyle = shouldUseNarrowLayout ? [styles.flex0, styles.flexBasis100, styles.flexRow, styles.justifyContentCenter] : [];
        return (
            <View style={[styles.flexRow, styles.gap2, smallScreenStyle]}>
                <Button
                    success
                    text="Claim offer"
                    onPress={() => Navigation.navigate(ROUTES.SETTINGS_SUBSCRIPTION)}
                />
                // Only allow dismissing the 25% discount
                {discountInfo?.discountType === 25 && (
                    <Button
                        text="No thanks"
                        onPress={() => setIsDismissed(true)}
                    />
                )}
            </View>
        );
    }, [shouldUseNarrowLayout, styles.flex0, styles.flexRow, styles.flexBasis100, styles.gap2, styles.justifyContentCenter, discountInfo]);

    if (!firstDayFreeTrial || !lastDayFreeTrial || !discountInfo) {
        return null;
    }


    // Banner is not dismissible in the subscription page
    if (isDismissed && !isSubscriptionPage) {
        return null;
    }

    return (
        <BillingBanner
            title={title}
            subtitle={formatTimeRemaining()}
            subtitleStyle={[styles.mt1, styles.mutedNormalTextLabel]}
            icon={Illustrations.TreasureChest}
            rightComponent={!isSubscriptionPage && rightComponent}
        />
    );
}

And to display it, we can add the following to HeaderView:

function HeaderView({report, parentReportAction, reportID, onNavigationMenuButtonClicked, ...)
    return (
        <>
            <View
                style={[shouldShowBorderBottom && styles.borderBottom]}
                dataSet={{dragArea: true}}
            >
                ...
            </View>
            // The component will be displayed either in the #admins room or concierge.
            // Depnding on which chat was used for onboarding
            {shouldShowDiscountBanner() && ReportUtils.isChatUsedForOnboarding(report) && <EarlyDiscountBanner />}
        </>
    );
}

As well as add it to CardSection so we can display it in the Subscriptions page:

if (SubscriptionUtils.shouldShowDiscountBanner()) {
    BillingBanner = <EarlyDiscountBanner isSubscriptionPage />;
} else if (SubscriptionUtils.shouldShowPreTrialBillingBanner()) {
    BillingBanner = <PreTrialBillingBanner />;
} else if (SubscriptionUtils.isUserOnFreeTrial()) {
    BillingBanner = <TrialStartedBillingBanner />;
} else if (SubscriptionUtils.hasUserFreeTrialEnded()) {
    BillingBanner = <TrialEndedBillingBanner />;
}

Warning modal when choosing PPU

In the SubscriptionDetails, we’ll create a modal that is displayed whenever the user tries to choose PPU when the discount is applied. We’ll also display this modal if the user is already on a PPU plan and they attempt to claim the offer. The modal should explain that the discount is only applicable to yearly subscriptions.

Manual Test Steps

Claiming the first discount

  • Sign up as a new user
  • Create a workspace
  • Go to your onboarding chat
  • Verify you can see a countdown timer starting from 24h.
  • Verify the banner is not dismissable.
  • Verify click on “Claim offer” takes you to the subscription page.
  • Verify the subscription page shows the same timer.
  • Verify that after you add a card, a promoCode of 50% has been applied to your account. Verify

Claiming the second discount

  • Sign up as a new user
  • Create a workspace
  • Modify the NVP firstDayFreeTrial such that it's earlier than 24h. Run this in the console: Onyx.merge("nvp_private_firstDayFreeTrial", "2024-12-21 23:36:00");
  • Go to your onboarding chat
  • Verify you can see a countdown showing days, hours, minutes, and seconds of the time remaining, which should be max 7 days when it starts.
  • Verify the banner is dismissable.
  • Verify click on “Claim offer” takes you to the subscription page.
  • Verify the subscription page shows the same timer.

Period past 8 days

  • Sign up as a new user
  • Create a workspace
  • Modify the NVP firstDayFreeTrial such that it's later than 8 days. Run this in the console: Onyx.merge("nvp_private_firstDayFreeTrial", "2024-12-21 23:36:00");
  • Verify no banner is shown in the onboarding chat.
  • Verify no banner is shown in the subscription page.
  • Verify that in the subscription, we’re showing the usual free trial banner instead.

Automated Tests

The same tests as above coded implemented in unit tests.

Issue OwnerCurrent Issue Owner: @stephanieelliott

Activity

  1. added
    ExternalAdded to denote the issue can be worked on by a contributor
    on Jan 6, 2025
  2. added
    Help WantedApply this label when an issue is open to proposals by contributors
    on Jan 6, 2025
  3. melvin-bot commented on Jan 6, 2025

    @melvin-bot

    Triggered auto assignment to Contributor-plus team member for initial proposal review - @eVoloshchak (External)

  4. self-assigned this
    on Jan 6, 2025
  5. added
    BugSomething is broken. Auto assigns a BugZero manager.
    NewFeatureSomething to build that is a new item.
    on Jan 6, 2025
  6. melvin-bot commented on Jan 6, 2025

    @melvin-bot

    Triggered auto assignment to @stephanieelliott (Bug), see https://stackoverflow.com/c/expensify/questions/14418 for more details. Please add this bug to a GH project, as outlined in the SO.

  7. melvin-bot commented on Jan 6, 2025

    @melvin-bot

    Triggered auto assignment to @strepanier03 (NewFeature), see https://stackoverflowteams.com/c/expensify/questions/14418#:~:text=BugZero%20process%20steps%20for%20feature%20requests for more details. Please add this Feature request to a GH project, as outlined in the SO.

  8. melvin-bot commented on Jan 6, 2025

    @melvin-bot

    ⚠️ It looks like this issue is labelled as a New Feature but not tied to any GitHub Project. Keep in mind that all new features should be tied to GitHub Projects in order to properly track external CAP software time ⚠️

  9. 35 remaining items

  10. melvin-bot commented on Jan 29, 2025

    @melvin-bot

    The solution for this issue has been 🚀 deployed to production 🚀 in version 9.0.90-6 and is now subject to a 7-day regression period 📆. Here is the list of pull requests that resolve this issue:

    If no regressions arise, payment will be issued on 2025-02-05. 🎊

    For reference, here are some details about the assignees on this issue:

  11. melvin-bot commented on Jan 29, 2025

    @melvin-bot

    @eVoloshchak / @parasharrajat @stephanieelliott @eVoloshchak / @parasharrajat The PR fixing this issue has been merged! The following checklist (instructions) will need to be completed before the issue can be closed. Please copy/paste the BugZero Checklist from here into a new comment on this GH and complete it. If you have the K2 extension, you can simply click: [this button]

  12. melvin-bot commented on Feb 5, 2025

    @melvin-bot

    Payment Summary

    Upwork Job

    BugZero Checklist (@stephanieelliott)

    • I have verified the correct assignees and roles are listed above and updated the neccesary manual offers
    • I have verified that there are no duplicate or incorrect contracts on Upwork for this job (https://www.upwork.com/ab/applicants//hired)
    • I have paid out the Upwork contracts or cancelled the ones that are incorrect
    • I have verified the payment summary above is correct
  13. melvin-bot commented on Feb 7, 2025

    @melvin-bot

    @eVoloshchak, @youssef-lr, @parasharrajat, @stephanieelliott Whoops! This issue is 2 days overdue. Let's get this updated quick!

  14. parasharrajat commented on Feb 10, 2025

    @parasharrajat
    Member

    Payment requested as per #54817 (comment)

  15. stephanieelliott commented on Feb 11, 2025

    @stephanieelliott
    Contributor

    Summarizing payment on this issue:

    • Contributor: N/A internal
    • Contributor+: @parasharrajat: $250 via ND - please request!
    • Contributor+: @eVoloshchak: $250 via ND - please request!
  16. JmillsExpensify commented on Feb 15, 2025

    @JmillsExpensify
    Contributor

    $250 approved for @parasharrajat

  17. JmillsExpensify commented on Mar 3, 2025

    @JmillsExpensify
    Contributor

    $250 approved for @eVoloshchak

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

Awaiting PaymentAuto-added when associated PR is deployed to productionBugSomething is broken. Auto assigns a BugZero manager.DailyKSv2ExternalAdded to denote the issue can be worked on by a contributorNewFeatureSomething to build that is a new item.

Type

No type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions