[web] Improve handling of cancelled sub updates (#2635)

This fixes an issue where a user with a cancelled _and_ expired
subscription would try to purchase a plan, and would instead get
redirected to the updated subscription flow in stripe (instead of the
buy flow).

Smoke tested a few scenarios locally.
This commit is contained in:
Manav Rathi
2024-08-08 14:15:55 +05:30
committed by GitHub
2 changed files with 100 additions and 61 deletions

View File

@@ -39,7 +39,6 @@ import {
cancelSubscription,
getLocalUserSubscription,
hasAddOnBonus,
hasMobileSubscription,
hasPaidSubscription,
hasStripeSubscription,
isOnFreePlan,
@@ -49,6 +48,7 @@ import {
isUserSubscribedPlan,
manageFamilyMethod,
planForSubscription,
planSelectionOutcome,
updatePaymentMethod,
updateSubscription,
} from "utils/billing";
@@ -177,58 +177,63 @@ function PlanSelectorCard(props: PlanSelectorCardProps) {
}, []);
async function onPlanSelect(plan: Plan) {
if (
!hasPaidSubscription(subscription) &&
!isSubscriptionCancelled(subscription)
) {
try {
props.setLoading(true);
await billingService.buySubscription(plan.stripeID);
} catch (e) {
props.setLoading(false);
switch (planSelectionOutcome(subscription)) {
case "buyPlan":
try {
props.setLoading(true);
await billingService.buySubscription(plan.stripeID);
} catch (e) {
props.setLoading(false);
appContext.setDialogMessage({
title: t("ERROR"),
content: t("SUBSCRIPTION_PURCHASE_FAILED"),
close: { variant: "critical" },
});
}
break;
case "updateSubscriptionToPlan":
appContext.setDialogMessage({
title: t("ERROR"),
content: t("SUBSCRIPTION_PURCHASE_FAILED"),
close: { variant: "critical" },
title: t("update_subscription_title"),
content: t("UPDATE_SUBSCRIPTION_MESSAGE"),
proceed: {
text: t("UPDATE_SUBSCRIPTION"),
action: updateSubscription.bind(
null,
plan,
appContext.setDialogMessage,
props.setLoading,
props.closeModal,
),
variant: "accent",
},
close: { text: t("cancel") },
});
}
} else if (hasStripeSubscription(subscription)) {
appContext.setDialogMessage({
title: t("update_subscription_title"),
content: t("UPDATE_SUBSCRIPTION_MESSAGE"),
proceed: {
text: t("UPDATE_SUBSCRIPTION"),
action: updateSubscription.bind(
null,
plan,
appContext.setDialogMessage,
props.setLoading,
props.closeModal,
break;
case "cancelOnMobile":
appContext.setDialogMessage({
title: t("CANCEL_SUBSCRIPTION_ON_MOBILE"),
content: t("CANCEL_SUBSCRIPTION_ON_MOBILE_MESSAGE"),
close: { variant: "secondary" },
});
break;
case "contactSupport":
appContext.setDialogMessage({
title: t("MANAGE_PLAN"),
content: (
<Trans
i18nKey={"MAIL_TO_MANAGE_SUBSCRIPTION"}
components={{
a: <Link href="mailto:support@ente.io" />,
}}
values={{ emailID: "support@ente.io" }}
/>
),
variant: "accent",
},
close: { text: t("cancel") },
});
} else if (hasMobileSubscription(subscription)) {
appContext.setDialogMessage({
title: t("CANCEL_SUBSCRIPTION_ON_MOBILE"),
content: t("CANCEL_SUBSCRIPTION_ON_MOBILE_MESSAGE"),
close: { variant: "secondary" },
});
} else {
appContext.setDialogMessage({
title: t("MANAGE_PLAN"),
content: (
<Trans
i18nKey={"MAIL_TO_MANAGE_SUBSCRIPTION"}
components={{
a: <Link href="mailto:support@ente.io" />,
}}
values={{ emailID: "support@ente.io" }}
/>
),
close: { variant: "secondary" },
});
close: { variant: "secondary" },
});
break;
}
}

View File

@@ -13,8 +13,6 @@ import { getSubscriptionPurchaseSuccessMessage } from "utils/ui";
import { getTotalFamilyUsage, isPartOfFamily } from "utils/user/family";
const PAYMENT_PROVIDER_STRIPE = "stripe";
const PAYMENT_PROVIDER_APPSTORE = "appstore";
const PAYMENT_PROVIDER_PLAYSTORE = "playstore";
const FREE_PLAN = "free";
const THIRTY_DAYS_IN_MICROSECONDS = 30 * 24 * 60 * 60 * 1000 * 1000;
@@ -31,6 +29,51 @@ enum RESPONSE_STATUS {
fail = "fail",
}
export type PlanSelectionOutcome =
| "buyPlan"
| "updateSubscriptionToPlan"
| "cancelOnMobile"
| "contactSupport";
/**
* Return the outcome that should happen when the user selects a paid plan on
* the plan selection screen.
*
* @param subscription Their current subscription details.
*/
export const planSelectionOutcome = (
subscription: Subscription | undefined,
) => {
// This shouldn't happen, but we need this case to handle missing types.
if (!subscription) return "buyPlan";
// The user is a on a free plan and can buy the plan they selected.
if (subscription.productID == "free") return "buyPlan";
// Their existing subscription has expired. They can buy a new plan.
if (subscription.expiryTime < Date.now() * 1000) return "buyPlan";
// -- The user already has an active subscription to a paid plan.
// Using stripe
if (subscription.paymentProvider == "stripe") {
// Update their existing subscription to the new plan.
return "updateSubscriptionToPlan";
}
// Using one of the mobile app stores
if (
subscription.paymentProvider == "appstore" ||
subscription.paymentProvider == "playstore"
) {
// They need to cancel first on the mobile app stores.
return "cancelOnMobile";
}
// Some other bespoke case. They should contact support.
return "contactSupport";
};
export function hasPaidSubscription(subscription: Subscription) {
return (
subscription &&
@@ -92,15 +135,6 @@ export function hasStripeSubscription(subscription: Subscription) {
);
}
export function hasMobileSubscription(subscription: Subscription) {
return (
hasPaidSubscription(subscription) &&
subscription.paymentProvider.length > 0 &&
(subscription.paymentProvider === PAYMENT_PROVIDER_APPSTORE ||
subscription.paymentProvider === PAYMENT_PROVIDER_PLAYSTORE)
);
}
export function hasExceededStorageQuota(userDetails: UserDetails) {
const bonusStorage = userDetails.storageBonus ?? 0;
if (isPartOfFamily(userDetails.familyData)) {