diff --git a/apiserver/plane/ee/migrations/0005_workspacelicense_has_activated_free_trial_and_more.py b/apiserver/plane/ee/migrations/0005_workspacelicense_has_activated_free_trial_and_more.py new file mode 100644 index 0000000000..245552c09e --- /dev/null +++ b/apiserver/plane/ee/migrations/0005_workspacelicense_has_activated_free_trial_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.14 on 2024-08-14 10:38 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("ee", "0004_workspacelicense_trial_end_date"), + ] + + operations = [ + migrations.AddField( + model_name="workspacelicense", + name="has_activated_free_trial", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="workspacelicense", + name="has_added_payment_method", + field=models.BooleanField(default=False), + ), + ] diff --git a/apiserver/plane/ee/models/workspace.py b/apiserver/plane/ee/models/workspace.py index 323c0aa082..3529d53a59 100644 --- a/apiserver/plane/ee/models/workspace.py +++ b/apiserver/plane/ee/models/workspace.py @@ -51,6 +51,10 @@ class WorkspaceLicense(BaseModel): last_synced_at = models.DateTimeField(default=timezone.now) # trial end date trial_end_date = models.DateTimeField(null=True, blank=True) + # has activated free trial + has_activated_free_trial = models.BooleanField(default=False) + # is payment method added + has_added_payment_method = models.BooleanField(default=False) class Meta: verbose_name = "Workspace License" diff --git a/apiserver/plane/payment/bgtasks/member_sync_task.py b/apiserver/plane/payment/bgtasks/member_sync_task.py index 9ce5a728de..6e8a91822f 100644 --- a/apiserver/plane/payment/bgtasks/member_sync_task.py +++ b/apiserver/plane/payment/bgtasks/member_sync_task.py @@ -102,6 +102,12 @@ def member_sync_task(slug): workspace_license.trial_end_date = updated_workspace_license[ "trial_end_date" ] + workspace_license.has_activated_free_trial = ( + updated_workspace_license["has_activated_free_trial"] + ) + workspace_license.has_added_payment_method = ( + updated_workspace_license["has_added_payment_method"] + ) workspace_license.last_synced_at = timezone.now() workspace_license.save() @@ -120,6 +126,12 @@ def member_sync_task(slug): "is_offline_payment" ], last_synced_at=timezone.now(), + has_activated_free_trial=updated_workspace_license[ + "has_activated_free_trial" + ], + has_added_payment_method=updated_workspace_license[ + "has_added_payment_method" + ], ) else: return diff --git a/apiserver/plane/payment/views/product.py b/apiserver/plane/payment/views/product.py index 77cfe952ca..0a30c894b7 100644 --- a/apiserver/plane/payment/views/product.py +++ b/apiserver/plane/payment/views/product.py @@ -204,6 +204,15 @@ class WorkspaceProductEndpoint(BaseAPIView): workspace_license.is_offline_payment = response.get( "is_offline_payment", False ) + workspace_license.trial_end_date = response.get( + "trial_end_date" + ) + workspace_license.has_activated_free_trial = ( + response.get("has_activated_free_trial", False) + ) + workspace_license.has_added_payment_method = ( + response.get("has_added_payment_method", False) + ) workspace_license.save() return Response( @@ -215,6 +224,8 @@ class WorkspaceProductEndpoint(BaseAPIView): "product": workspace_license.plan, "is_offline_payment": workspace_license.is_offline_payment, "trial_end_date": workspace_license.trial_end_date, + "has_activated_free_trial": workspace_license.has_activated_free_trial, + "has_added_payment_method": workspace_license.has_added_payment_method, }, status=status.HTTP_200_OK, ) @@ -228,6 +239,8 @@ class WorkspaceProductEndpoint(BaseAPIView): "product": workspace_license.plan, "is_offline_payment": workspace_license.is_offline_payment, "trial_end_date": workspace_license.trial_end_date, + "has_activated_free_trial": workspace_license.has_activated_free_trial, + "has_added_payment_method": workspace_license.has_added_payment_method, }, status=status.HTTP_200_OK, ) @@ -256,6 +269,12 @@ class WorkspaceProductEndpoint(BaseAPIView): plan=response.get("plan"), last_synced_at=timezone.now(), trial_end_date=response.get("trial_end_date"), + has_activated_free_trial=response.get( + "has_activated_free_trial", False + ), + has_added_payment_method=response.get( + "has_added_payment_method", False + ), ) # Return the workspace license return Response( @@ -267,6 +286,8 @@ class WorkspaceProductEndpoint(BaseAPIView): "product": workspace_license.plan, "is_offline_payment": workspace_license.is_offline_payment, "trial_end_date": workspace_license.trial_end_date, + "has_activated_free_trial": workspace_license.has_activated_free_trial, + "has_added_payment_method": workspace_license.has_added_payment_method, }, status=status.HTTP_200_OK, ) @@ -318,6 +339,12 @@ class WorkspaceLicenseRefreshEndpoint(BaseAPIView): workspace_license.recurring_interval = response.get("interval") workspace_license.plan = response.get("plan") workspace_license.trial_end_date = response.get("trial_end_date") + workspace_license.has_activated_free_trial = response.get( + "has_activated_free_trial", False + ) + workspace_license.has_added_payment_method = response.get( + "has_added_payment_method", False + ) workspace_license.last_synced_at = timezone.now() workspace_license.save() # If the license is not present, then fetch the license from the payment server and create it @@ -345,6 +372,12 @@ class WorkspaceLicenseRefreshEndpoint(BaseAPIView): plan=response.get("plan"), last_synced_at=timezone.now(), trial_end_date=response.get("trial_end_date"), + has_activated_free_trial=response.get( + "has_activated_free_trial", False + ), + has_added_payment_method=response.get( + "has_added_payment_method", False + ), ) # Return the response @@ -401,6 +434,12 @@ class WorkspaceLicenseSyncEndpoint(BaseAPIView): workspace_license.trial_end_date = request.data.get( "trial_end_date" ) + workspace_license.has_activated_free_trial = request.data.get( + "has_activated_free_trial", False + ) + workspace_license.has_added_payment_method = request.data.get( + "has_added_payment_method", False + ) workspace_license.save() # If the workspace license is not present, then fetch the license from the payment server and create it else: @@ -417,6 +456,12 @@ class WorkspaceLicenseSyncEndpoint(BaseAPIView): plan=request.data.get("plan"), last_synced_at=timezone.now(), trial_end_date=request.data.get("trial_end_date"), + has_activated_free_trial=request.data.get( + "has_activated_free_trial", False + ), + has_added_payment_method=request.data.get( + "has_added_payment_method", False + ), ) # Return the response diff --git a/apiserver/plane/payment/views/subscription.py b/apiserver/plane/payment/views/subscription.py index 174c7eeca3..b47e27ef7c 100644 --- a/apiserver/plane/payment/views/subscription.py +++ b/apiserver/plane/payment/views/subscription.py @@ -143,6 +143,16 @@ class UpgradeSubscriptionEndpoint(BaseAPIView): workspace_license.trial_end_date = ( workspace_license_response.get("trial_end_date") ) + workspace_license.has_activated_free_trial = ( + workspace_license_response.get( + "has_activated_free_trial" + ) + ) + workspace_license.has_added_payment_method = ( + workspace_license_response.get( + "has_added_payment_method" + ) + ) workspace_license.save() else: # Create a new workspace license @@ -168,6 +178,12 @@ class UpgradeSubscriptionEndpoint(BaseAPIView): "trial_end_date" ), plan=workspace_license_response.get("plan"), + has_activated_free_trial=workspace_license_response.get( + "has_activated_free_trial" + ), + has_added_payment_method=workspace_license_response.get( + "has_added_payment_method" + ), ) # Return the response diff --git a/packages/types/src/payment.d.ts b/packages/types/src/payment.d.ts index 462b5773cb..ff80a5a484 100644 --- a/packages/types/src/payment.d.ts +++ b/packages/types/src/payment.d.ts @@ -23,4 +23,8 @@ export type IWorkspaceProductSubscription = { interval?: "month" | "year" | null; current_period_end_date: string | null; is_offline_payment: boolean; + trial_end_date: string | undefined; + purchased_seats: number | undefined; + has_activated_free_trial: boolean; + has_added_payment_method: boolean; }; diff --git a/web/app/[workspaceSlug]/(projects)/layout.tsx b/web/app/[workspaceSlug]/(projects)/layout.tsx index f8fe0f8f9e..9207b9b49d 100644 --- a/web/app/[workspaceSlug]/(projects)/layout.tsx +++ b/web/app/[workspaceSlug]/(projects)/layout.tsx @@ -10,7 +10,7 @@ export default function WorkspaceLayout({ children }: { children: React.ReactNod -
+
{children} diff --git a/web/app/layout.tsx b/web/app/layout.tsx index 50b7b37995..394bb26e2b 100644 --- a/web/app/layout.tsx +++ b/web/app/layout.tsx @@ -9,6 +9,8 @@ import "@/styles/react-day-picker.css"; import { SITE_NAME, SITE_DESCRIPTION } from "@/constants/meta"; // helpers import { API_BASE_URL } from "@/helpers/common.helper"; +// plane web components +import { FreeTrialBanner } from "@/plane-web/components/license/free-trial-banner"; // local import { AppProvider } from "./provider"; @@ -70,7 +72,13 @@ export default function RootLayout({ children }: { children: React.ReactNode })
-
{children}
+
+
+ {/* free trial banner */} + +
+
{children}
+
{process.env.NEXT_PUBLIC_PLAUSIBLE_DOMAIN && ( diff --git a/web/ee/components/license/cloud-badge.tsx b/web/ee/components/license/cloud-badge.tsx index 2119657338..b84ba93e05 100644 --- a/web/ee/components/license/cloud-badge.tsx +++ b/web/ee/components/license/cloud-badge.tsx @@ -67,6 +67,62 @@ export const CloudEditionBadge = observer(() => { ); + // derived values + const isInTrialPeriod = + subscriptionDetail?.has_activated_free_trial && subscriptionDetail?.trial_end_date ? true : false; + const isTrialCompleted = + subscriptionDetail?.has_activated_free_trial && !subscriptionDetail?.trial_end_date ? true : false; + + const renderButtonText = () => { + switch (subscriptionDetail.product) { + case "FREE": { + if (subscriptionDetail?.has_activated_free_trial) { + return "Buy Pro"; + } else { + return "Try Pro"; + } + } + case "PRO": + if (subscriptionDetail?.has_activated_free_trial && subscriptionDetail?.has_added_payment_method) { + return "Purchased Pro"; + } else { + if (subscriptionDetail?.has_activated_free_trial && !subscriptionDetail?.has_added_payment_method) { + return "Buy Pro"; + } else { + return "Try Pro"; + } + } + default: + return "Try Pro"; + } + }; + + const showPaymentButton = () => { + switch (subscriptionDetail.product) { + case "FREE": { + if (subscriptionDetail?.has_activated_free_trial) { + return true; + } else { + return true; + } + } + case "PRO": + if (subscriptionDetail?.has_activated_free_trial && subscriptionDetail?.has_added_payment_method) { + return false; + } else { + if (subscriptionDetail?.has_activated_free_trial && !subscriptionDetail?.has_added_payment_method) { + return true; + } else { + return false; + } + } + default: + return false; + } + }; + + const showPlaneProButton = showPaymentButton() ? false : subscriptionDetail.product === "PRO" ? true : false; + return ( <> { isOpen={isProPlanSuccessModalOpen} handleClose={() => setProPlanSuccessModalOpen(false)} /> - {subscriptionDetail.product === "FREE" && ( - <> - {/* This modal is intentionally placed inside the condition to avoid unnecessary calls to list product endpoint. */} - toggleProPlanModal(false)} - yearlyPlan={false} - handleSuccessModal={() => setProPlanSuccessModalOpen(true)} - /> - - + + toggleProPlanModal(false)} + yearlyPlan={false} + handleSuccessModal={() => setProPlanSuccessModalOpen(true)} + canFetchProducts={subscriptionDetail.product === "FREE" || isInTrialPeriod || isTrialCompleted} + /> + + {showPaymentButton() && ( + )} - {subscriptionDetail.product === "PRO" && ( - <> - - + + {showPlaneProButton && ( + )} ); diff --git a/web/ee/components/license/free-trial-banner.tsx b/web/ee/components/license/free-trial-banner.tsx new file mode 100644 index 0000000000..a6120f37e5 --- /dev/null +++ b/web/ee/components/license/free-trial-banner.tsx @@ -0,0 +1,69 @@ +"use client"; + +import { FC, useState } from "react"; +import { observer } from "mobx-react"; +import { Button } from "@plane/ui"; +// helpers +import { findHowManyDaysLeft } from "@/helpers/date-time.helper"; +// hooks +import { useInstance, useWorkspace } from "@/hooks/store"; +// plane web components +import { ProPlanCloudUpgradeModal } from "@/plane-web/components/license"; +// plane web hooks +import { useWorkspaceSubscription } from "@/plane-web/hooks/store"; + +// constants +const MAX_DAYS_LEFT = 4; + +export const FreeTrialBanner: FC = observer(() => { + // hooks + const { config } = useInstance(); + const { currentWorkspace } = useWorkspace(); + const { currentWorkspaceSubscribedPlanDetail: subscriptionDetail } = useWorkspaceSubscription(); + + // states + const [pricingModalOpen, setPricingModalOpen] = useState(false); + + // derived values + const daysLeft = findHowManyDaysLeft(subscriptionDetail?.trial_end_date) || 0; + + // validate weather to show the banner or not for the current workspace and subscription details + if (!currentWorkspace || !subscriptionDetail || !config?.payment_server_base_url) return <>; + // validating for offline payment + if (subscriptionDetail.is_offline_payment || !subscriptionDetail.trial_end_date) return <>; + // if the days left is more than the max days left then don't show the banner + if (daysLeft > MAX_DAYS_LEFT) return <>; + + return ( + <> + {/* This modal is intentionally placed inside the condition to avoid unnecessary calls to list product endpoint. */} + setPricingModalOpen(false)} + yearlyPlan={false} + /> + +
+
+
+ Your free trial is ending in {daysLeft} days. When your trial ends, your workspace will downgrade to Free + and you will lose access to  + + Pro features. + +
+
+ +
+
+
+ + ); +}); diff --git a/web/ee/components/license/pro-plan-cloud-upgrade-modal.tsx b/web/ee/components/license/pro-plan-cloud-upgrade-modal.tsx index bcf3db6688..0cff0a22e3 100644 --- a/web/ee/components/license/pro-plan-cloud-upgrade-modal.tsx +++ b/web/ee/components/license/pro-plan-cloud-upgrade-modal.tsx @@ -24,6 +24,7 @@ export type ProPlanCloudUpgradeModalProps = { handleClose: () => void; yearlyPlan?: boolean; handleSuccessModal?: () => void; + canFetchProducts?: boolean; }; // constants @@ -36,7 +37,7 @@ export const calculateYearlyDiscount = (monthlyPrice: number, yearlyPricePerMont }; export const ProPlanCloudUpgradeModal: FC = (props) => { - const { isOpen, handleClose, yearlyPlan, handleSuccessModal } = props; + const { isOpen, handleClose, yearlyPlan, handleSuccessModal, canFetchProducts = true } = props; // params const { workspaceSlug } = useParams(); // states @@ -47,13 +48,17 @@ export const ProPlanCloudUpgradeModal: FC = (prop const { membership: { currentWorkspaceRole }, } = useUser(); - const { fetchWorkspaceSubscribedPlan, freeTrialSubscription } = useWorkspaceSubscription(); + const { + currentWorkspaceSubscribedPlanDetail: subscriptionDetail, + fetchWorkspaceSubscribedPlan, + freeTrialSubscription, + } = useWorkspaceSubscription(); // derived values const isAdmin = currentWorkspaceRole === EUserWorkspaceRoles.ADMIN; // fetch products const { isLoading: isProductsAPILoading, data } = useSWR( - workspaceSlug ? "CLOUD_PAYMENT_PRODUCTS" : null, - workspaceSlug ? () => paymentService.listProducts(workspaceSlug.toString()) : null, + workspaceSlug && canFetchProducts ? "CLOUD_PAYMENT_PRODUCTS" : null, + workspaceSlug && canFetchProducts ? () => paymentService.listProducts(workspaceSlug.toString()) : null, { errorRetryCount: 2, revalidateIfStale: false, @@ -115,6 +120,7 @@ export const ProPlanCloudUpgradeModal: FC = (prop }); }; + // handling the payment link when the free trial is disabled const handlePaymentLink = (priceId: string) => { if (!workspaceSlug) return; @@ -145,6 +151,40 @@ export const ProPlanCloudUpgradeModal: FC = (prop } }; + // handling the payment link when the free trial is enabled + const handleSubscriptionPageRedirection = () => { + if (!workspaceSlug) return; + + if (!isAdmin) { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Unauthorized!", + message: "You don't have permission to perform this action.", + }); + return; + } + + setLoading(true); + paymentService + .getWorkspaceSubscriptionPageLink(workspaceSlug.toString()) + .then((response) => { + if (response.url) { + window.open(response.url, "_blank"); + } + }) + .catch(() => { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: "Failed to redirect to subscription page. Please try again.", + }); + }) + .finally(() => { + setLoading(false); + }); + }; + + // handle trial const handleTrial = async (productId: string, priceId: string) => { try { setTrialLoader(true); @@ -165,18 +205,29 @@ export const ProPlanCloudUpgradeModal: FC = (prop } }; + // derived values const monthlyPrice = (orderBy(proProduct?.prices || [], ["recurring"], ["desc"])?.find((price) => price.recurring === "month") ?.unit_amount || 0) / 100; const yearlyPrice = (orderBy(proProduct?.prices || [], ["recurring"], ["desc"])?.find((price) => price.recurring === "year") ?.unit_amount || 0) / 1000; - // derived values const yearlyDiscount = calculateYearlyDiscount(monthlyPrice, yearlyPrice); + const isInTrailPeriod = + subscriptionDetail?.has_activated_free_trial && subscriptionDetail?.trial_end_date ? true : false; + const isTrialCompleted = + subscriptionDetail?.has_activated_free_trial && !subscriptionDetail?.trial_end_date ? true : false; return (
+ {isInTrailPeriod && ( +
+
+ Free Trial +
+
+ )} Upgrade to Pro, yearly for flat {yearlyDiscount}% off. @@ -207,17 +258,19 @@ export const ProPlanCloudUpgradeModal: FC = (prop )} + {proProduct && ( )}
diff --git a/web/ee/components/license/pro-plan-upgrade.tsx b/web/ee/components/license/pro-plan-upgrade.tsx index 2fabd373a9..f443381a73 100644 --- a/web/ee/components/license/pro-plan-upgrade.tsx +++ b/web/ee/components/license/pro-plan-upgrade.tsx @@ -19,6 +19,7 @@ export type ProPlanUpgradeProps = { trialLoader?: boolean; handleTrial?: (productId: string, priceId: string) => void; yearlyDiscount?: number; + showTrialButton?: boolean; }; export const ProPlanUpgrade: FC = (props) => { @@ -34,6 +35,7 @@ export const ProPlanUpgrade: FC = (props) => { trialLoader = false, handleTrial, yearlyDiscount, + showTrialButton = false, } = props; // derived values const yearlyPrice = orderBy(proProduct?.prices || [], ["recurring"], ["desc"]).find( @@ -108,15 +110,18 @@ export const ProPlanUpgrade: FC = (props) => { : "Redirecting to Stripe..." : "Upgrade to Pro"} - {/* free trail button */} - + + {/* trail button */} + {showTrialButton && ( + + )} {yearlyPlanOnly && (
We will charge your card on file at $5 per user per month for the total number of users in diff --git a/web/ee/store/subscription/subscription.store.ts b/web/ee/store/subscription/subscription.store.ts index 70cfc7054f..9bfbb10c88 100644 --- a/web/ee/store/subscription/subscription.store.ts +++ b/web/ee/store/subscription/subscription.store.ts @@ -57,6 +57,11 @@ export class WorkspaceSubscriptionStore implements IWorkspaceSubscriptionStore { is_canceled: response?.is_canceled || false, interval: response?.interval || null, current_period_end_date: response?.current_period_end_date, + is_offline_payment: response?.is_offline_payment || false, + trial_end_date: response?.trial_end_date || undefined, + purchased_seats: response?.purchased_seats || 0, + has_activated_free_trial: response?.has_activated_free_trial || false, + has_added_payment_method: response?.has_added_payment_method || false, }); }); return response;