1 import { c, msgid } from 'ttag';
3 import { usePlans } from '@proton/account/plans/hooks';
4 import { useSubscription } from '@proton/account/subscription/hooks';
5 import { useUser } from '@proton/account/user/hooks';
6 import type { DropdownActionProps } from '@proton/components/components/dropdown/DropdownActions';
7 import DropdownActions from '@proton/components/components/dropdown/DropdownActions';
8 import Icon from '@proton/components/components/icon/Icon';
9 import Loader from '@proton/components/components/loader/Loader';
10 import Price from '@proton/components/components/price/Price';
11 import Table from '@proton/components/components/table/Table';
12 import TableBody from '@proton/components/components/table/TableBody';
13 import TableCell from '@proton/components/components/table/TableCell';
14 import TableHeader from '@proton/components/components/table/TableHeader';
15 import TableRow from '@proton/components/components/table/TableRow';
16 import Time from '@proton/components/components/time/Time';
17 import Tooltip from '@proton/components/components/tooltip/Tooltip';
18 import MozillaInfoPanel from '@proton/components/containers/account/MozillaInfoPanel';
19 import SettingsSectionWide from '@proton/components/containers/account/SettingsSectionWide';
20 import useCancellationTelemetry from '@proton/components/containers/payments/subscription/cancellationFlow/useCancellationTelemetry';
21 import useApi from '@proton/components/hooks/useApi';
22 import useEventManager from '@proton/components/hooks/useEventManager';
23 import { usePreferredPlansMap } from '@proton/components/hooks/usePreferredPlansMap';
24 import { useLoading } from '@proton/hooks';
25 import { PLANS, onSessionMigrationPaymentsVersion } from '@proton/payments';
26 import { changeRenewState } from '@proton/shared/lib/api/payments';
27 import { getCheckout, getOptimisticCheckResult } from '@proton/shared/lib/helpers/checkout';
28 import { getOptimisticRenewCycleAndPrice } from '@proton/shared/lib/helpers/renew';
30 getHas2023OfferCoupon,
31 getNormalCycleFromCustomCycle,
34 } from '@proton/shared/lib/helpers/subscription';
35 import { Renew } from '@proton/shared/lib/interfaces';
36 import isTruthy from '@proton/utils/isTruthy';
37 import noop from '@proton/utils/noop';
39 import type { BadgeType } from '../../components/badge/Badge';
40 import { default as Badge } from '../../components/badge/Badge';
41 import { subscriptionExpires } from './subscription/helpers';
43 export const getMonths = (n: number) => c('Billing cycle').ngettext(msgid`${n} month`, `${n} months`, n);
45 const SubscriptionsSection = () => {
46 const [plansResult, loadingPlans] = usePlans();
47 const plans = plansResult?.plans;
48 const [current, loadingSubscription] = useSubscription();
49 const upcoming = current?.UpcomingSubscription ?? undefined;
51 const eventManager = useEventManager();
52 const [reactivating, withReactivating] = useLoading();
53 const [user] = useUser();
55 const { sendDashboardReactivateReport } = useCancellationTelemetry();
56 const searchParams = new URLSearchParams(location.search);
57 const reactivationSource = searchParams.get('source');
59 const { plansMap, plansMapLoading } = usePreferredPlansMap();
61 if (!current || !plans || loadingSubscription || loadingPlans || plansMapLoading) {
65 if (current.isManagedByMozilla) {
66 return <MozillaInfoPanel />;
69 const planTitle = getPlanTitle(current);
71 const { renewEnabled, subscriptionExpiresSoon } = subscriptionExpires(current);
73 const reactivateAction: DropdownActionProps[] = [
75 text: c('Action subscription').t`Reactivate`,
76 loading: reactivating,
78 sendDashboardReactivateReport(reactivationSource || 'default');
80 withReactivating(async () => {
84 RenewalState: Renew.Enabled,
86 onSessionMigrationPaymentsVersion(user, current)
90 await eventManager.call();
96 const latestSubscription = upcoming ?? current;
97 // That's the case for AddonDowngrade subscription mode. If user with addons decreases the number of addons
98 // then in might fall under the AddonDowngrade subscription mode. In this case, user doesn't immediately.
99 // The upcoming subscription will be created, it will have the same cycle as the current subscription
100 // and user will be charged at the beginning of the upcoming subscription.
101 // see PAY-2060 and PAY-2080
102 const isUpcomingSubscriptionUnpaid = !!current && !!upcoming && current.Cycle === upcoming.Cycle;
104 const { renewPrice, renewalLength } = (() => {
105 const latestPlanIDs = getPlanIDs(latestSubscription);
107 getHas2023OfferCoupon(latestSubscription.CouponCode) &&
108 (latestPlanIDs[PLANS.VPN] || latestPlanIDs[PLANS.VPN_PASS_BUNDLE])
110 const nextCycle = getNormalCycleFromCustomCycle(latestSubscription.Cycle);
111 const latestCheckout = getCheckout({
113 planIDs: latestPlanIDs,
114 checkResult: getOptimisticCheckResult({
115 planIDs: latestPlanIDs,
118 currency: latestSubscription.Currency,
122 // The API doesn't return the correct next cycle or RenewAmount for the VPN or VPN+Pass bundle plan since we don't have chargebee
123 // So we calculate it with the cycle discount here
125 <Price key="renewal-price" currency={latestSubscription.Currency}>
126 {latestCheckout.withDiscountPerCycle}
129 renewalLength: getMonths(nextCycle),
133 if (latestPlanIDs[PLANS.VPN2024]) {
134 const result = getOptimisticRenewCycleAndPrice({
136 planIDs: latestPlanIDs,
137 cycle: latestSubscription.Cycle,
138 currency: latestSubscription.Currency,
142 <Price key="renewal-price" currency={latestSubscription.Currency}>
146 renewalLength: getMonths(result.renewalLength),
150 if (isUpcomingSubscriptionUnpaid) {
153 <Price key="renewal-price" currency={upcoming.Currency}>
157 renewalLength: getMonths(upcoming.Cycle),
163 <Price key="renewal-price" currency={latestSubscription.Currency}>
164 {latestSubscription.RenewAmount}
167 renewalLength: getMonths(latestSubscription.Cycle),
171 const renewalText = (
172 <span data-testid="renewalNotice">{c('Billing cycle')
173 .jt`Renews automatically at ${renewPrice}, for ${renewalLength}`}</span>
176 const status = subscriptionExpiresSoon
178 type: 'error' as BadgeType,
179 label: c('Subscription status').t`Expiring`,
181 : { type: 'success' as BadgeType, label: c('Subscription status').t`Active` };
183 const renewalDate = isUpcomingSubscriptionUnpaid ? upcoming.PeriodStart : latestSubscription.PeriodEnd;
186 <SettingsSectionWide>
187 <div style={{ overflow: 'auto' }}>
188 <Table className="table-auto" responsive="cards">
191 <TableCell type="header">{c('Title subscription').t`Plan`}</TableCell>
192 <TableCell type="header">{c('Title subscription').t`Status`}</TableCell>
193 <TableCell type="header">{c('Title subscription').t`End date`}</TableCell>
194 <TableCell type="header"> </TableCell>
197 <TableBody colSpan={4}>
199 <TableCell label={c('Title subscription').t`Plan`}>
200 <span data-testid="planNameId">{planTitle}</span>
202 <TableCell data-testid="subscriptionStatusId">
203 <Badge type={status.type}>{status.label}</Badge>
205 <TableCell label={c('Title subscription').t`End date`}>
206 <Time format="PP" sameDayFormat={false} data-testid="planEndTimeId">
209 {subscriptionExpiresSoon && (
211 title={c('Info subscription')
212 .t`You can prevent expiry by reactivating the subscription`}
213 data-testid="periodEndWarning"
216 name="exclamation-circle-filled"
217 className="color-danger ml-1"
223 <TableCell data-testid="subscriptionActionsId">
224 {subscriptionExpiresSoon ? (
225 <DropdownActions size="small" list={reactivateAction} />
234 </SettingsSectionWide>
237 export default SubscriptionsSection;