Chore workspace license trial (#834)

* chore: workspace trial and payment method subscription

* dev: updated trial validation

* dev: payment button validation

* chore: updated typo

* chore: removed logs

---------

Co-authored-by: gurusainath <gurusainath007@gmail.com>
This commit is contained in:
Nikhil
2024-08-14 19:29:05 +05:30
committed by GitHub
parent a78758b814
commit 89deba4591
13 changed files with 346 additions and 47 deletions

View File

@@ -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),
),
]

View File

@@ -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"

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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;
};

View File

@@ -10,7 +10,7 @@ export default function WorkspaceLayout({ children }: { children: React.ReactNod
<AuthenticationWrapper>
<CommandPalette />
<WorkspaceAuthWrapper>
<div className="relative flex h-screen w-full overflow-hidden">
<div className="relative flex h-full w-full overflow-hidden">
<AppSidebar />
<main className="relative flex h-full w-full flex-col overflow-hidden bg-custom-background-100">
{children}

View File

@@ -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 })
<body>
<div id="context-menu-portal" />
<AppProvider>
<div className={`h-screen w-full overflow-hidden bg-custom-background-100`}>{children}</div>
<div className={`h-screen w-full overflow-hidden bg-custom-background-100 relative flex flex-col`}>
<div className="flex-shrink-0">
{/* free trial banner */}
<FreeTrialBanner />
</div>
<div className="w-full h-full overflow-hidden">{children}</div>
</div>
</AppProvider>
</body>
{process.env.NEXT_PUBLIC_PLAUSIBLE_DOMAIN && (

View File

@@ -67,6 +67,62 @@ export const CloudEditionBadge = observer(() => {
</Loader>
);
// 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 (
<>
<PaidPlanSuccessModal
@@ -74,37 +130,36 @@ export const CloudEditionBadge = observer(() => {
isOpen={isProPlanSuccessModalOpen}
handleClose={() => setProPlanSuccessModalOpen(false)}
/>
{subscriptionDetail.product === "FREE" && (
<>
{/* This modal is intentionally placed inside the condition to avoid unnecessary calls to list product endpoint. */}
<ProPlanCloudUpgradeModal
isOpen={isProPlanModalOpen}
handleClose={() => toggleProPlanModal(false)}
yearlyPlan={false}
handleSuccessModal={() => setProPlanSuccessModalOpen(true)}
/>
<Button
tabIndex={-1}
variant="accent-primary"
className="w-full cursor-pointer rounded-2xl px-4 py-1.5 text-center text-sm font-medium outline-none"
onClick={handleProPlanPurchaseModalOpen}
>
{subscriptionDetail.product === "FREE" ? "Upgrade to Pro" : "Get Pro yearly"}
</Button>
</>
<ProPlanCloudUpgradeModal
isOpen={isProPlanModalOpen}
handleClose={() => toggleProPlanModal(false)}
yearlyPlan={false}
handleSuccessModal={() => setProPlanSuccessModalOpen(true)}
canFetchProducts={subscriptionDetail.product === "FREE" || isInTrialPeriod || isTrialCompleted}
/>
{showPaymentButton() && (
<Button
tabIndex={-1}
variant="accent-primary"
className="w-full cursor-pointer rounded-2xl px-4 py-1.5 text-center text-sm font-medium outline-none"
onClick={handleProPlanPurchaseModalOpen}
>
{renderButtonText()}
</Button>
)}
{subscriptionDetail.product === "PRO" && (
<>
<Button
tabIndex={-1}
variant="accent-primary"
className="w-full cursor-pointer rounded-2xl px-3 py-1.5 text-center text-sm font-medium outline-none"
onClick={handlePaidPlanSuccessModalOpen}
>
<Image src={PlaneLogo} alt="Plane Pro" width={14} height={14} />
{"Plane Pro"}
</Button>
</>
{showPlaneProButton && (
<Button
tabIndex={-1}
variant="accent-primary"
className="w-full cursor-pointer rounded-2xl px-3 py-1.5 text-center text-sm font-medium outline-none"
onClick={handlePaidPlanSuccessModalOpen}
>
<Image src={PlaneLogo} alt="Plane Pro" width={14} height={14} />
Plane Pro
</Button>
)}
</>
);

View File

@@ -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. */}
<ProPlanCloudUpgradeModal
isOpen={pricingModalOpen}
handleClose={() => setPricingModalOpen(false)}
yearlyPlan={false}
/>
<div className="bg-custom-primary-100/10 text-custom-primary-100 py-2 px-5">
<div className="relative container mx-auto flex justify-center items-center gap-2">
<div className="text-sm font-medium">
Your free trial is ending in {daysLeft} days. When your trial ends, your workspace will downgrade to Free
and you will lose access to&nbsp;
<a
href="https://plane.so/pro"
target="_blank"
rel="noopener noreferrer"
className="underline underline-offset-4 hover:font-bold transition-all"
>
Pro features.
</a>
</div>
<div className="flex-shrink-0">
<Button variant="outline-primary" size="sm" onClick={() => setPricingModalOpen(true)}>
Upgrade to Pro
</Button>
</div>
</div>
</div>
</>
);
});

View File

@@ -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<ProPlanCloudUpgradeModalProps> = (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<ProPlanCloudUpgradeModalProps> = (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<ProPlanCloudUpgradeModalProps> = (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<ProPlanCloudUpgradeModalProps> = (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<ProPlanCloudUpgradeModalProps> = (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 (
<ModalCore isOpen={isOpen} handleClose={handleClose} width={EModalWidth.XXL} className="rounded-xl">
<div className="py-6 px-10 max-h-[90vh] sm:max-h-[95vh] overflow-auto">
{isInTrailPeriod && (
<div className="relative flex justify-center items-center">
<div className="p-1 px-2 uppercase bg-custom-primary-100/20 text-custom-primary-100 text-xs rounded-full font-medium">
Free Trial
</div>
</div>
)}
<Dialog.Title as="h2" className="text-2xl font-bold leading-6 mt-6 flex justify-center items-center">
Upgrade to Pro, yearly for flat {yearlyDiscount}% off.
</Dialog.Title>
@@ -207,17 +258,19 @@ export const ProPlanCloudUpgradeModal: FC<ProPlanCloudUpgradeModalProps> = (prop
</Loader>
</Loader>
)}
{proProduct && (
<ProPlanUpgrade
proProduct={proProduct}
basePlan="Free"
features={PRO_PLAN_FEATURES_MAP}
isLoading={isLoading}
handlePaymentLink={handlePaymentLink}
handlePaymentLink={isInTrailPeriod ? handleSubscriptionPageRedirection : handlePaymentLink}
yearlyPlanOnly={yearlyPlan}
trialLoader={trialLoader}
handleTrial={handleTrial}
yearlyDiscount={yearlyDiscount}
showTrialButton={!isInTrailPeriod && !isTrialCompleted}
/>
)}
</div>

View File

@@ -19,6 +19,7 @@ export type ProPlanUpgradeProps = {
trialLoader?: boolean;
handleTrial?: (productId: string, priceId: string) => void;
yearlyDiscount?: number;
showTrialButton?: boolean;
};
export const ProPlanUpgrade: FC<ProPlanUpgradeProps> = (props) => {
@@ -34,6 +35,7 @@ export const ProPlanUpgrade: FC<ProPlanUpgradeProps> = (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<ProPlanUpgradeProps> = (props) => {
: "Redirecting to Stripe..."
: "Upgrade to Pro"}
</button>
{/* free trail button */}
<button
disabled={trialLoader}
className="mt-4 text-center text-sm text-custom-text-300 hover:text-custom-text-100 font-medium transition-all flex justify-center items-center gap-2"
onClick={() => handleTrial && handleTrial(proProduct?.id, price.id)}
>
<span>Start free trial</span>
<div className="w-3 h-3">{trialLoader && <Loader size={12} className="animate-spin" />}</div>
</button>
{/* trail button */}
{showTrialButton && (
<button
disabled={trialLoader}
className="mt-4 text-center text-sm text-custom-text-300 hover:text-custom-text-100 font-medium transition-all flex justify-center items-center gap-2"
onClick={() => handleTrial && handleTrial(proProduct?.id, price.id)}
>
<span>Start free trial</span>
<div className="w-3 h-3">{trialLoader && <Loader size={12} className="animate-spin" />}</div>
</button>
)}
{yearlyPlanOnly && (
<div className="text-[9px] text-custom-text-300 w-64 pt-1">
We will charge your card on file at <b>$5 per user per month</b> for the total number of users in

View File

@@ -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;