[mobile][web] Redirect to payment portal if subscription is past due (#1222)

## Description

When a customer whose Stripe subscription is past due (within the 30 day
window after expiry time and has not been cancelled) clicks on the
subscription modal, take them to the payment portal to complete the
subscription.

## Tests

- [x] Tested web
- [x] Tested mobile
This commit is contained in:
Vishnu Mohandas 2024-03-27 16:29:58 +05:30 committed by GitHub
commit eef33e9c0c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 58 additions and 7 deletions

View file

@ -30,6 +30,19 @@ class Subscription {
return expiryTime > DateTime.now().microsecondsSinceEpoch;
}
bool isCancelled() {
return attributes?.isCancelled ?? false;
}
bool isPastDue() {
return !isCancelled() &&
expiryTime < DateTime.now().microsecondsSinceEpoch &&
expiryTime >=
DateTime.now()
.subtract(const Duration(days: 30))
.microsecondsSinceEpoch;
}
bool isYearlyPlan() {
return 'year' == period;
}

View file

@ -81,6 +81,11 @@ class _StripeSubscriptionPageState extends State<StripeSubscriptionPage> {
userDetails.hasPaidAddon();
_hasActiveSubscription = _currentSubscription!.isValid();
_isStripeSubscriber = _currentSubscription!.paymentProvider == stripe;
if (_isStripeSubscriber && _currentSubscription!.isPastDue()) {
_redirectToPaymentPortal();
}
return _filterStripeForUI().then((value) {
_hasLoadedData = true;
setState(() {});
@ -254,7 +259,7 @@ class _StripeSubscriptionPageState extends State<StripeSubscriptionPage> {
singleBorderRadius: 4,
alignCaptionedTextToLeft: true,
onTap: () async {
_onStripSupportedPaymentDetailsTap();
_redirectToPaymentPortal();
},
),
),
@ -295,9 +300,9 @@ class _StripeSubscriptionPageState extends State<StripeSubscriptionPage> {
);
}
// _onStripSupportedPaymentDetailsTap action allows the user to update
// _redirectToPaymentPortal action allows the user to update
// their stripe payment details
void _onStripSupportedPaymentDetailsTap() async {
void _redirectToPaymentPortal() async {
final String paymentProvider = _currentSubscription!.paymentProvider;
switch (_currentSubscription!.paymentProvider) {
case stripe:

View file

@ -406,6 +406,12 @@ func (c *StripeController) handlePaymentIntentFailed(event stripe.Event, country
if err != nil {
return ente.StripeEventLog{}, stacktrace.Propagate(err, "")
}
err = c.BillingRepo.UpdateSubscriptionCancellationStatus(userID, true)
if err != nil {
return ente.StripeEventLog{}, stacktrace.Propagate(err, "")
}
err = c.sendAccountOnHoldEmail(userID)
if err != nil {
return ente.StripeEventLog{}, stacktrace.Propagate(err, "")

View file

@ -12,6 +12,7 @@ import {
isOnFreePlan,
isSubscriptionActive,
isSubscriptionCancelled,
isSubscriptionPastDue,
} from "utils/billing";
import { Typography } from "@mui/material";
@ -54,7 +55,10 @@ export default function SubscriptionStatus({
showPlanSelectorModal();
}
} else {
if (hasStripeSubscription(userDetails.subscription)) {
if (
hasStripeSubscription(userDetails.subscription) &&
isSubscriptionPastDue(userDetails.subscription)
) {
billingService.redirectToCustomerPortal();
} else {
showPlanSelectorModal();

View file

@ -4,8 +4,10 @@ import { Box, Skeleton } from "@mui/material";
import Typography from "@mui/material/Typography";
import { GalleryContext } from "pages/gallery";
import { useContext, useEffect, useMemo, useState } from "react";
import billingService from "services/billingService";
import { getUserDetailsV2 } from "services/userService";
import { UserDetails } from "types/user";
import { hasStripeSubscription, isSubscriptionPastDue } from "utils/billing";
import { isFamilyAdmin, isPartOfFamily } from "utils/user/family";
import { MemberSubscriptionManage } from "../MemberSubscriptionManage";
import SubscriptionCard from "./SubscriptionCard";
@ -50,9 +52,20 @@ export default function UserDetailsSection({ sidebarView }) {
[userDetails],
);
const handleSubscriptionCardClick = isMemberSubscription
? openMemberSubscriptionManage
: galleryContext.showPlanSelectorModal;
const handleSubscriptionCardClick = () => {
if (isMemberSubscription) {
openMemberSubscriptionManage();
} else {
if (
hasStripeSubscription(userDetails.subscription) &&
isSubscriptionPastDue(userDetails.subscription)
) {
billingService.redirectToCustomerPortal();
} else {
galleryContext.showPlanSelectorModal();
}
}
};
return (
<>

View file

@ -17,6 +17,7 @@ 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;
enum FAILURE_REASON {
AUTHENTICATION_FAILED = "authentication_failed",
@ -151,6 +152,15 @@ export function hasExceededStorageQuota(userDetails: UserDetails) {
}
}
export function isSubscriptionPastDue(subscription: Subscription) {
const currentTime = Date.now() * 1000;
return (
!isSubscriptionCancelled(subscription) &&
subscription.expiryTime < currentTime &&
subscription.expiryTime >= currentTime - THIRTY_DAYS_IN_MICROSECONDS
);
}
export function isPopularPlan(plan: Plan) {
return plan.storage === 100 * ONE_GB;
}