feat(premium): give failed renewals a billing-cycle grace period (#3066)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-30 18:48:01 +02:00
1 parent f5e32aed31
commit dfdfffe5de
53 files changed
+1974 -1206

No files matched your search

@@ -463,6 +463,22 @@ export const SubscriptionCard: React.FC<SubscriptionCardProps> = observer(
</Trans>
);
})()
) : gracePeriodInfo.isPaymentRecovery ? (
(() => {
const graceDate = graceEndDate ? getFormattedLongDate(graceEndDate, locale) : undefined;
return (
<Trans comment="Plutonium subscription card text shown while a failed renewal payment is being retried. {graceDate} is a date already formatted and localized by code; never write a date into the translation.">
Your renewal payment failed but{' '}
<PerksButton
onClick={scrollToPerks}
data-flx="app.plutonium.subscription-card.perks-button.scroll-to-perks--9"
/>{' '}
stay active until{' '}
<strong data-flx="app.plutonium.subscription-card.strong--16">{graceDate}</strong>. Update your
payment method before then to keep your subscription.
</Trans>
);
})()
) : isInGracePeriod ? (
(() => {
const graceDate = graceEndDate ? getFormattedLongDate(graceEndDate, locale) : undefined;
@@ -844,7 +860,7 @@ export const SubscriptionCard: React.FC<SubscriptionCardProps> = observer(
<Trans comment="Billing button that starts subscription cancellation.">Cancel subscription</Trans>
</Button>
)}
{isInGracePeriod && (
{isInGracePeriod && !gracePeriodInfo.isPaymentRecovery && (
<Button
variant="danger"
onClick={handleEndPremiumGracePeriod}
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import DeveloperOptions from '@app/features/devtools/state/DeveloperOptions';
import {getPremiumGraceEndDate} from '@app/features/premium/utils/PremiumGrace';
import type {User} from '@app/features/user/models/User';
import {UserPremiumTypes} from '@fluxer/constants/src/UserConstants';
import {MS_PER_DAY} from '@fluxer/date_utils/src/DateConstants';
@@ -12,6 +13,7 @@ export interface GracePeriodInfo {
isExpired: boolean;
graceEndDate: Date | null;
showExpiredState: boolean;
isPaymentRecovery: boolean;
}
export interface SubscriptionStatusInfo {
@@ -82,13 +84,25 @@ export const useSubscriptionStatus = (
const isGiftSubscription = Boolean(!billingCycle && hasPaidPremium && !isVisionary && premiumUntil);
const gracePeriodInfo = useMemo((): GracePeriodInfo => {
if (isVisionary) {
return {isInGracePeriod: false, isExpired: false, graceEndDate: null, showExpiredState: false};
return {
isInGracePeriod: false,
isExpired: false,
graceEndDate: null,
showExpiredState: false,
isPaymentRecovery: false,
};
}
const explicitGraceEnd = premiumGraceEndsAt ?? null;
const expiryDate = premiumUntil ? new Date(premiumUntil) : null;
const graceEndDate = explicitGraceEnd ?? (expiryDate ? new Date(expiryDate.getTime() + 3 * MS_PER_DAY) : null);
const graceEndDate = expiryDate ? getPremiumGraceEndDate(expiryDate, explicitGraceEnd) : explicitGraceEnd;
if (!graceEndDate) {
return {isInGracePeriod: false, isExpired: false, graceEndDate: null, showExpiredState: false};
return {
isInGracePeriod: false,
isExpired: false,
graceEndDate: null,
showExpiredState: false,
isPaymentRecovery: false,
};
}
const now = new Date();
const anchorDate = expiryDate ?? graceEndDate;
@@ -96,8 +110,9 @@ export const useSubscriptionStatus = (
const isInGracePeriod = (!expiryDate || now > expiryDate) && now <= graceEndDate;
const isExpired = now > graceEndDate;
const showExpiredState = isExpired && now <= expiredStateEndDate;
return {isInGracePeriod, isExpired, graceEndDate, showExpiredState};
}, [premiumUntil, premiumGraceEndsAt, isVisionary]);
const isPaymentRecovery = isInGracePeriod && explicitGraceEnd != null && billingCycle != null;
return {isInGracePeriod, isExpired, graceEndDate, showExpiredState, isPaymentRecovery};
}, [premiumUntil, premiumGraceEndsAt, isVisionary, billingCycle]);
const {isInGracePeriod, isExpired: isFullyExpired, showExpiredState} = gracePeriodInfo;
const isPremium = useDeveloperOverride
? hasPaidPremium && !isFullyExpired && !perksDisabled
@@ -21,6 +21,7 @@ import * as NotificationUtils from '@app/features/notification/utils/Notificatio
import NativePermission from '@app/features/permissions/system/state/NativePermission';
import {resolvePriceAnnouncementCampaign} from '@app/features/premium/config/PriceAnnouncementCampaign';
import PremiumState from '@app/features/premium/state/PremiumState';
import {getPremiumGraceEndDate} from '@app/features/premium/utils/PremiumGrace';
import {canServiceStripeSubscriptions, shouldShowPremiumFeatures} from '@app/features/premium/utils/PremiumUtils';
import StreamerMode from '@app/features/streamer_mode/state/StreamerMode';
import Nagbar from '@app/features/ui/state/Nagbar';
@@ -117,14 +118,11 @@ export const useNagbarConditions = (): NagbarConditions => {
if (!showPremium || !canServiceSubscription) return false;
if (nagbarState.forceHidePremiumGracePeriod) return false;
if (nagbarState.forcePremiumGracePeriod) return true;
if (!user?.premiumUntil || user.premiumType === 2 || premiumWillCancel) return false;
if (!user?.premiumUntil || !user.premiumGraceEndsAt || !user.premiumBillingCycle || user.premiumType === 2) {
return false;
}
const now = new Date();
const expiryDate = new Date(user.premiumUntil);
const gracePeriodMs = 3 * MS_PER_DAY;
const graceEndDate = user.premiumGraceEndsAt
? new Date(user.premiumGraceEndsAt)
: new Date(expiryDate.getTime() + gracePeriodMs);
const isInGracePeriod = now > expiryDate && now <= graceEndDate;
const isInGracePeriod = now > new Date(user.premiumUntil) && now <= new Date(user.premiumGraceEndsAt);
return isInGracePeriod && !nagbarState.premiumGracePeriodDismissed;
})();
const canShowPremiumExpired = (() => {
@@ -134,11 +132,8 @@ export const useNagbarConditions = (): NagbarConditions => {
if (!user?.premiumUntil || user.premiumType === 2 || premiumWillCancel) return false;
const now = new Date();
const expiryDate = new Date(user.premiumUntil);
const gracePeriodMs = 3 * MS_PER_DAY;
const expiredStateDurationMs = 30 * MS_PER_DAY;
const graceEndDate = user.premiumGraceEndsAt
? new Date(user.premiumGraceEndsAt)
: new Date(expiryDate.getTime() + gracePeriodMs);
const graceEndDate = getPremiumGraceEndDate(expiryDate, user.premiumGraceEndsAt);
const expiredStateEndDate = new Date(graceEndDate.getTime() + expiredStateDurationMs);
const isExpired = now > graceEndDate;
const showExpiredState = isExpired && now <= expiredStateEndDate;
@@ -8,12 +8,12 @@ import {NAGBAR_TONES, NagbarToneKind} from '@app/features/app/components/layout/
import {PREMIUM_PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
import {Logger} from '@app/features/platform/utils/AppLogger';
import * as PremiumCommands from '@app/features/premium/commands/PremiumCommands';
import {getPremiumGraceEndDate} from '@app/features/premium/utils/PremiumGrace';
import {MANAGE_SUBSCRIPTION_DESCRIPTOR} from '@app/features/premium/utils/PremiumMessageDescriptors';
import * as NagbarCommands from '@app/features/ui/commands/NagbarCommands';
import {openExternalUrl} from '@app/features/ui/utils/NativeUtils';
import Users from '@app/features/user/state/Users';
import * as LocaleUtils from '@app/features/user/utils/LocaleUtils';
import {MS_PER_DAY} from '@fluxer/date_utils/src/DateConstants';
import {getFormattedLongDate} from '@fluxer/date_utils/src/DateFormatting';
import {msg} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro';
@@ -62,10 +62,8 @@ export const PremiumGracePeriodNagbar = observer(({isMobile}: {isMobile: boolean
const handleDismiss = () => {
NagbarCommands.dismissNagbar('premiumGracePeriodDismissed');
};
if (!user?.premiumUntil || user?.premiumWillCancel) return null;
const expiryDate = new Date(user.premiumUntil);
const gracePeriodMs = 3 * MS_PER_DAY;
const graceEndDate = new Date(expiryDate.getTime() + gracePeriodMs);
if (!user?.premiumUntil) return null;
const graceEndDate = getPremiumGraceEndDate(new Date(user.premiumUntil), user.premiumGraceEndsAt);
const locale = LocaleUtils.getCurrentLocale();
const formattedGraceDate = getFormattedLongDate(graceEndDate, locale);
return (