Merge pull request #1021 from makeplane/uat

release: v1.3.0-rc46
This commit is contained in:
sriram veeraghanta
2024-09-03 20:51:56 +05:30
committed by GitHub
15 changed files with 309 additions and 387 deletions

View File

@@ -13,7 +13,6 @@ from plane.ee.models import WorkspaceLicense
def fetch_workspace_license(workspace_id, workspace_slug, free_seats=12):
# If the number of free seats is less than 12, set it to 12
workspace_free_seats = 12 if free_seats <= 12 else free_seats
owner_email = Workspace.objects.get(slug=workspace_slug).owner.email
@@ -54,6 +53,89 @@ def fetch_workspace_license(workspace_id, workspace_slug, free_seats=12):
return response
def is_trial_allowed(workspace_license):
"""The free trial is only allowed if the workspace has not activated the free trial and the workspace does not have a subscription"""
if (
not workspace_license.subscription
and not workspace_license.has_activated_free_trial
):
return True
return False
def has_upgraded(workspace_license):
"""Check if the workspace has upgraded from the trial"""
if (
workspace_license.subscription
and workspace_license.has_added_payment_method
):
return True
return False
def is_on_trial(workspace_license):
"Check if the workspace is on a trial"
if (
workspace_license.subscription
and not has_upgraded(workspace_license)
and workspace_license.trial_end_date
and workspace_license.trial_end_date >= timezone.now()
):
return True
return False
def trial_remaining_days(workspace_license):
"""Calculate the remaining days of the trial"""
if (
workspace_license.subscription
and not has_upgraded(workspace_license)
and workspace_license.trial_end_date
and workspace_license.trial_end_date >= timezone.now()
):
return (workspace_license.trial_end_date - timezone.now()).days
return None
def is_billing_active(workspace_license):
"""Check if the billing is active"""
if workspace_license.plan == WorkspaceLicense.PlanChoice.FREE:
return False
if (
workspace_license.plan == WorkspaceLicense.PlanChoice.PRO
and is_on_trial(workspace_license)
):
return False
return True
def show_payment_button(workspace_license):
"""Check if the workspace is allowed to show the payment button"""
# If the workspace is on free product then show the payment button
if workspace_license.plan == WorkspaceLicense.PlanChoice.FREE:
return True
# If the workspace is on pro product and is on trial then show the payment button
if (
workspace_license.plan == WorkspaceLicense.PlanChoice.PRO
and is_on_trial(workspace_license)
and not has_upgraded(workspace_license)
):
return True
# If the workspace is on pro product and has not upgraded then show the payment button
return False
def show_trial_banner(workspace_license):
"""Determine if the trial banner should be shown"""
if is_on_trial(workspace_license) and not has_upgraded(workspace_license):
return trial_remaining_days(workspace_license) <= 4
return False
def resync_workspace_license(workspace_slug, force=False):
# Fetch the workspace
workspace = Workspace.objects.get(slug=workspace_slug)
@@ -69,7 +151,6 @@ def resync_workspace_license(workspace_slug, force=False):
if (
workspace_license.last_synced_at - timezone.now()
).total_seconds() > 3600 or force:
# Fetch the workspace license
response = fetch_workspace_license(
workspace_id=str(workspace.id),
@@ -106,6 +187,10 @@ def resync_workspace_license(workspace_slug, force=False):
)
workspace_license.subscription = response.get("subscription")
workspace_license.save()
workspace_license = WorkspaceLicense.objects.get(
workspace=workspace
)
return {
"is_cancelled": workspace_license.is_cancelled,
"purchased_seats": workspace_license.purchased_seats,
@@ -119,6 +204,14 @@ def resync_workspace_license(workspace_slug, force=False):
"subscription": workspace_license.subscription,
"is_self_managed": os.environ.get("IS_MULTI_TENANT", "0")
== "0",
"is_on_trial": is_on_trial(workspace_license),
"is_trial_allowed": is_trial_allowed(workspace_license),
"remaining_trial_days": trial_remaining_days(
workspace_license
),
"has_upgraded": has_upgraded(workspace_license),
"show_payment_button": show_payment_button(workspace_license),
"show_trial_banner": show_trial_banner(workspace_license),
}
else:
return {
@@ -134,6 +227,14 @@ def resync_workspace_license(workspace_slug, force=False):
"subscription": workspace_license.subscription,
"is_self_managed": os.environ.get("IS_MULTI_TENANT", "0")
== "0",
"is_on_trial": is_on_trial(workspace_license),
"is_trial_allowed": is_trial_allowed(workspace_license),
"remaining_trial_days": trial_remaining_days(
workspace_license
),
"has_upgraded": has_upgraded(workspace_license),
"show_payment_button": show_payment_button(workspace_license),
"show_trial_banner": show_trial_banner(workspace_license),
}
# If the license is not present, then fetch the license from the payment server and create it
else:
@@ -148,7 +249,7 @@ def resync_workspace_license(workspace_slug, force=False):
).count(),
)
# Create the workspace license
workspace_license = WorkspaceLicense.objects.create(
_ = WorkspaceLicense.objects.create(
workspace=workspace,
is_cancelled=response.get("is_cancelled", False),
purchased_seats=response.get("purchased_seats", 0),
@@ -166,6 +267,8 @@ def resync_workspace_license(workspace_slug, force=False):
),
subscription=response.get("subscription"),
)
workspace_license = WorkspaceLicense.objects.get(workspace=workspace)
# Return the workspace license
return {
"is_cancelled": workspace_license.is_cancelled,
@@ -179,4 +282,10 @@ def resync_workspace_license(workspace_slug, force=False):
"has_added_payment_method": workspace_license.has_added_payment_method,
"subscription": workspace_license.subscription,
"is_self_managed": os.environ.get("IS_MULTI_TENANT", "0") == "0",
"is_on_trial": is_on_trial(workspace_license),
"is_trial_allowed": is_trial_allowed(workspace_license),
"remaining_trial_days": trial_remaining_days(workspace_license),
"has_upgraded": has_upgraded(workspace_license),
"show_payment_button": show_payment_button(workspace_license),
"show_trial_banner": show_trial_banner(workspace_license),
}

View File

@@ -15,6 +15,9 @@ from plane.app.permissions.workspace import WorkspaceOwnerPermission
from plane.db.models import WorkspaceMember, Workspace
from plane.authentication.utils.host import base_host
from plane.utils.exception_logger import log_exception
from plane.payment.utils.workspace_license_request import (
resync_workspace_license,
)
class PaymentLinkEndpoint(BaseAPIView):
@@ -54,25 +57,53 @@ class PaymentLinkEndpoint(BaseAPIView):
)
if settings.PAYMENT_SERVER_BASE_URL:
response = requests.post(
f"{settings.PAYMENT_SERVER_BASE_URL}/api/payment-link/",
headers={
"content-type": "application/json",
"x-api-key": settings.PAYMENT_SERVER_AUTH_TOKEN,
},
json={
"workspace_id": str(workspace.id),
"slug": slug,
"stripe_product_id": product_id,
"stripe_price_id": price_id,
"customer_email": request.user.email,
"members_list": list(workspace_members),
"host": base_host(request=request, is_app=True),
},
# Fetch the workspace license
workspace_license_response = resync_workspace_license(
workspace_slug=slug
)
response.raise_for_status()
response = response.json()
return Response(response, status=status.HTTP_200_OK)
# Check if the workspace is on trial
if workspace_license_response.get("is_on_trial"):
response = requests.post(
f"{settings.PAYMENT_SERVER_BASE_URL}/api/trial-subscriptions/upgrade/",
headers={
"content-type": "application/json",
"x-api-key": settings.PAYMENT_SERVER_AUTH_TOKEN,
},
json={
"workspace_id": str(workspace.id),
"stripe_price_id": price_id,
"members_list": list(workspace_members),
"slug": slug,
},
)
# Check if the response is successful
response.raise_for_status()
# Convert the response to json
response = response.json()
return Response(response, status=status.HTTP_200_OK)
# Check if the workspace is on a paid plan
else:
# Create the payment link
response = requests.post(
f"{settings.PAYMENT_SERVER_BASE_URL}/api/payment-link/",
headers={
"content-type": "application/json",
"x-api-key": settings.PAYMENT_SERVER_AUTH_TOKEN,
},
json={
"workspace_id": str(workspace.id),
"slug": slug,
"stripe_product_id": product_id,
"stripe_price_id": price_id,
"customer_email": request.user.email,
"members_list": list(workspace_members),
},
)
response.raise_for_status()
# Convert the response to json
response = response.json()
return Response(response, status=status.HTTP_200_OK)
else:
return Response(
{"error": "error fetching payment link"},

View File

@@ -21,6 +21,8 @@ from plane.ee.models import WorkspaceLicense
from plane.utils.exception_logger import log_exception
from plane.payment.utils.workspace_license_request import (
resync_workspace_license,
is_billing_active,
is_on_trial,
)
@@ -93,36 +95,55 @@ class WebsiteUserWorkspaceEndpoint(BaseAPIView):
def get(self, request):
try:
# Get all the workspaces where the user is admin
workspace_ids = WorkspaceMember.objects.filter(
member=request.user,
is_active=True,
role=20,
).values_list("workspace_id", flat=True)
# Fetch the workspaces from the workspace license
workspace_licenses = (
WorkspaceLicense.objects.filter(workspace_id__in=workspace_ids)
.annotate(slug=F("workspace__slug"))
.annotate(name=F("workspace__name"))
.annotate(logo=F("workspace__logo"))
.annotate(product=F("plan"))
workspaces = (
WorkspaceMember.objects.filter(
member=request.user,
is_active=True,
role=20,
)
.annotate(
slug=F("workspace__slug"),
logo=F("workspace__logo"),
name=F("workspace__name"),
)
.values(
"workspace_id",
"slug",
"name",
"logo",
"product",
"trial_end_date",
"has_activated_free_trial",
"has_added_payment_method",
"current_period_end_date",
"is_offline_payment",
"subscription",
)
)
workspace_ids = [workspace["workspace_id"] for workspace in workspaces]
# Fetch the workspaces from the workspace license
workspace_licenses = WorkspaceLicense.objects.filter(
workspace_id__in=workspace_ids
)
for workspace in workspaces:
licenses = [
license
for license in workspace_licenses
if str(license.workspace_id) == str(workspace["workspace_id"])
]
if licenses:
workspace["product"] = licenses[0].plan
workspace["is_on_trial"] = is_on_trial(
licenses[0]
)
workspace["is_billing_active"] = is_billing_active(
licenses[0]
)
else:
workspace["product"] = "FREE"
workspace["is_on_trial"] = False
workspace["is_billing_active"] = False
# Get the workspace details
return Response(workspace_licenses, status=status.HTTP_200_OK)
return Response(workspaces, status=status.HTTP_200_OK)
except requests.exceptions.RequestException as e:
if e.response.status_code == 400:

View File

@@ -14,12 +14,13 @@ export type IPaymentProduct = {
id: string;
name: string;
type: Omit<TProductSubscriptionType, "FREE">;
payment_quantity: number;
prices: IPaymentProductPrice[];
};
export type IWorkspaceProductSubscription = {
product: TProductSubscriptionType;
is_canceled?: boolean;
is_cancelled?: boolean;
is_self_managed: boolean;
interval?: "month" | "year" | null;
current_period_end_date: string | null;
@@ -29,4 +30,10 @@ export type IWorkspaceProductSubscription = {
has_activated_free_trial: boolean;
has_added_payment_method: boolean;
subscription: string | undefined;
is_on_trial: boolean;
is_trial_allowed: boolean;
remaining_trial_days: number | null;
has_upgraded: boolean;
show_payment_button: boolean;
show_trial_banner: boolean;
};

View File

@@ -12,11 +12,9 @@ import { Button, Loader, setToast, TOAST_TYPE } from "@plane/ui";
// helpers
import { cn } from "@/helpers/common.helper";
// services
import { WorkspaceService } from "@/plane-web/services";
import { PaymentService } from "@/plane-web/services/payment.service";
const paymentService = new PaymentService();
const workspaceService = new WorkspaceService();
const calculateYearlyDiscount = (monthlyPrice: number, yearlyPricePerMonth: number): number => {
const monthlyCost = monthlyPrice * 12;
@@ -39,13 +37,6 @@ const CloudUpgradePlanPage = observer(() => {
const { selectedWorkspace: workspaceSlug } = useParams();
// fetch workspace members
const { data: workspaceMembers, isLoading: isFetching } = useSWR(
workspaceSlug ? `CLOUD_PRO_WORKSPACE_MEMBER_DETAILS` : null,
workspaceSlug ? () => workspaceService.fetchWorkspaceMembers(workspaceSlug.toString()) : null,
{ revalidateIfStale: false, revalidateOnFocus: false }
);
// fetch products
const { data: products, isLoading: isLoadingProduct } = useSWR(
workspaceSlug ? `CLOUD_PRO_PRODUCTS_${workspaceSlug?.toString()}` : null,
@@ -57,27 +48,16 @@ const CloudUpgradePlanPage = observer(() => {
}
);
// fetch workspace current plan
const { data: workspaceSubscription, isLoading: workspaceSubscriptionLoading } = useSWR(
workspaceSlug ? `CLOUD_PRO_WORKSPACE_SUBSCRIPTION_PLAN_${workspaceSlug}` : null,
workspaceSlug ? () => paymentService.getWorkspaceCurrentPlan(workspaceSlug.toString()) : null,
{ revalidateIfStale: false, revalidateOnFocus: false }
);
// derived values
const totalWorkspaceMembers = (workspaceMembers || [])?.filter((member) => member.role >= 15)?.length;
const proProduct = (products || [])?.find((product: IPaymentProduct) => product?.type === "PRO");
const monthlyPlan = proProduct?.prices?.find((price) => price.recurring === "month");
const yearlyPlan = proProduct?.prices?.find((price) => price.recurring === "year");
const totalPaidMembers = proProduct?.payment_quantity ?? 1;
const monthlyPlanUnitPrice = (monthlyPlan?.unit_amount || 0) / 100;
const yearlyPlanUnitPrice = (yearlyPlan?.unit_amount || 0) / 1200;
const yearlyDiscountedPrice = calculateYearlyDiscount(monthlyPlanUnitPrice, yearlyPlanUnitPrice);
const workspaceOnTrial =
workspaceSubscription?.has_activated_free_trial && workspaceSubscription?.trial_end_date ? true : false;
const handleStripeCheckout = (priceId: string) => {
if (!workspaceSlug) {
setToast({
@@ -93,38 +73,8 @@ const CloudUpgradePlanPage = observer(() => {
product_id: proProduct?.id,
})
.then((response) => {
if (response.payment_link) {
window.open(response.payment_link, "_self");
}
})
.catch((error) => {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: error?.detail ?? "Failed to generate payment link. Please try again.",
});
})
.finally(() => {
setLoading(false);
});
};
const handleTrialStripeCheckout = (priceId: string) => {
if (!workspaceSlug) {
setToast({
type: TOAST_TYPE.INFO,
title: "Please select a workspace to continue",
});
return;
}
setLoading(true);
paymentService
.modifyTrailSubscription(workspaceSlug.toString(), {
price_id: priceId,
})
.then((response) => {
if (response.session_url) {
window.open(response.session_url, "_self");
if (response.url) {
window.open(response.url, "_self");
}
})
.catch((error) => {
@@ -143,12 +93,12 @@ const CloudUpgradePlanPage = observer(() => {
<div className="w-full h-full flex flex-col items-center justify-center gap-2">
<div className="flex flex-col items-center gap-2 pb-4">
<div className="text-3xl font-semibold">Choose your billing frequency</div>
{yearlyDiscountedPrice && (
{!!yearlyDiscountedPrice && (
<div className="text-center text-base text-custom-text-300">{`Upgrade to our yearly plan and get ${Math.round(yearlyDiscountedPrice)}% off.`}</div>
)}
</div>
{isFetching || isLoadingProduct || workspaceSubscriptionLoading ? (
{isLoadingProduct ? (
<div className="flex flex-col rounded gap-1 w-full">
<Loader.Item height="90px" />
<Loader.Item height="90px" />
@@ -177,13 +127,13 @@ const CloudUpgradePlanPage = observer(() => {
{plan.recurring === selectedPlan?.recurring && <Check className="size-4 stroke-2" />}
</span>
<div className="flex flex-col">
{totalWorkspaceMembers && (
{totalPaidMembers && (
<div className="flex items-center gap-2">
<span className="text-2xl font-semibold leading-7">
${renderPlanPricing(plan.unit_amount, 1, plan.recurring)}
</span>
<span className="text-sm text-custom-text-300">
{` per user per ${plan.recurring} x ${totalWorkspaceMembers}`}
{` per user per ${plan.recurring} x ${totalPaidMembers}`}
</span>
</div>
)}
@@ -193,9 +143,9 @@ const CloudUpgradePlanPage = observer(() => {
</div>
</div>
<div className="flex flex-col text-right">
{totalWorkspaceMembers && (
{totalPaidMembers && (
<span className="text-2xl font-semibold leading-7">
${renderPlanPricing(plan.unit_amount, totalWorkspaceMembers, plan.recurring)}
${renderPlanPricing(plan.unit_amount, totalPaidMembers, plan.recurring)}
</span>
)}
<span className="text-sm text-custom-text-300">
@@ -209,15 +159,10 @@ const CloudUpgradePlanPage = observer(() => {
{selectedPlan?.recurring && selectedPlan.unit_amount ? (
<>
<Button
className="w-full px-2 my-4"
onClick={() =>
workspaceOnTrial ? handleTrialStripeCheckout(selectedPlan.id) : handleStripeCheckout(selectedPlan.id)
}
>
<Button className="w-full px-2 my-4" onClick={() => handleStripeCheckout(selectedPlan.id)}>
{isLoading
? "Redirecting to Stripe..."
: `Pay $${renderPlanPricing(selectedPlan.unit_amount, totalWorkspaceMembers, selectedPlan.recurring)} every ${selectedPlan?.recurring}`}
: `Pay $${renderPlanPricing(selectedPlan.unit_amount, totalPaidMembers, selectedPlan.recurring)} every ${selectedPlan?.recurring}`}
</Button>
</>
) : (

View File

@@ -72,16 +72,11 @@ const CloudUpgradePage = observer(() => {
};
const workspaceSubscription = (workspace: TWorkspaceWithProductDetails): "FREE" | "PRO" | "TRIAL" => {
if (!workspace.subscription) return "FREE";
switch (workspace.product) {
case "PRO":
if (workspace.is_offline_payment) {
return "PRO";
} else if (workspace.trial_end_date && workspace.has_added_payment_method) {
return "PRO";
} else if (workspace.trial_end_date && !workspace.has_added_payment_method) {
if (workspace.is_on_trial) {
return "TRIAL";
}
} else return "PRO";
case "FREE":
return "FREE";
default:
@@ -95,7 +90,7 @@ const CloudUpgradePage = observer(() => {
name="workspace-upgrade-radio-input"
label={
<div className="flex flex-col items-center gap-2 pb-4">
<div className="text-3xl font-semibold">
<div className="text-center text-3xl font-semibold">
{isAnyWorkspaceAvailable
? "Choose your workspace"
: "We didn't find an eligible workspace for this upgrade. Try another email address."}
@@ -164,7 +159,7 @@ const CloudUpgradePage = observer(() => {
</div>
),
value: workspace.slug,
disabled: ["PRO"].includes(workspaceSubscription(workspace)),
disabled: workspace.is_billing_active,
}))
: []
}

View File

@@ -49,97 +49,46 @@ export const CloudEditionBadge = observer(() => {
if (!subscriptionDetail) return null;
const renderButtonText = () => {
if (!subscriptionDetail.subscription) {
return "Upgrade to Pro";
}
switch (subscriptionDetail.product) {
case "FREE": {
if (subscriptionDetail?.has_activated_free_trial) {
return "Buy Pro";
} else {
return "Upgrade to 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 "Upgrade to Pro";
}
}
default:
case "FREE":
return "Upgrade to Pro";
}
};
const showPaymentButton = () => {
if (!subscriptionDetail.subscription) {
return true;
}
switch (subscriptionDetail.product) {
case "FREE": {
return true;
}
case "PRO":
if (subscriptionDetail.is_offline_payment) {
return false;
} else 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;
}
}
return `Pro trial ends ${subscriptionDetail.remaining_trial_days}d`;
default:
return false;
return "Upgrade";
}
};
const showPlaneProButton = showPaymentButton() ? false : subscriptionDetail.product === "PRO" ? true : false;
return (
<>
<ProPlanCloudUpgradeModal
isOpen={isProPlanModalOpen}
handleClose={() => toggleProPlanModal(false)}
yearlyPlan={false}
handleSuccessModal={() => handleSuccessModalToggle(true)}
/>
{showPaymentButton() && (
{subscriptionDetail.show_payment_button && (
<Button
tabIndex={-1}
variant="accent-primary"
className="w-fit min-w-24 cursor-pointer rounded-2xl px-4 py-1 text-center text-sm font-medium outline-none"
className="w-full cursor-pointer rounded-2xl px-4 py-1.5 text-center text-sm font-medium outline-none"
onClick={handleProPlanPurchaseModalOpen}
>
{renderButtonText()}
</Button>
)}
{showPlaneProButton && (
{!subscriptionDetail.show_payment_button && (
<Button
tabIndex={-1}
variant="accent-primary"
className="w-fit cursor-pointer rounded-2xl px-4 py-1 text-center text-sm font-medium outline-none"
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={12} height={12} />
<Image src={PlaneLogo} alt="Plane Pro" width={14} height={14} />
Plane Pro
</Button>
)}
{/* Fallback text */}
{!showPaymentButton() && !showPlaneProButton && (
<div className="w-full cursor-default rounded-md bg-green-500/10 px-2 py-1 text-center text-xs font-medium text-green-500 outline-none leading-6">
Enterprise Edition
</div>
)}
</>
);
});

View File

@@ -3,8 +3,6 @@
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
@@ -12,42 +10,29 @@ 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 || daysLeft < 0) return <></>;
// if the trial banner is not allowed to show then don't show the banner
if (!subscriptionDetail.show_trial_banner) 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}
/>
<ProPlanCloudUpgradeModal isOpen={pricingModalOpen} handleClose={() => setPricingModalOpen(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;
Your free trial is ending in {subscriptionDetail.remaining_trial_days} 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"

View File

@@ -25,10 +25,9 @@ export const PlaneCloudBilling: React.FC = observer(() => {
const { currentWorkspaceSubscribedPlanDetail, toggleProPlanModal } = useWorkspaceSubscription();
// derived values
const endDate = currentWorkspaceSubscribedPlanDetail?.current_period_end_date;
const isSubscriptionCancelled = currentWorkspaceSubscribedPlanDetail?.is_canceled;
const isSubscriptionCancelled = currentWorkspaceSubscribedPlanDetail?.is_cancelled;
const isInTrialPeriod =
!currentWorkspaceSubscribedPlanDetail?.has_added_payment_method &&
!!currentWorkspaceSubscribedPlanDetail?.trial_end_date;
currentWorkspaceSubscribedPlanDetail?.is_on_trial && !currentWorkspaceSubscribedPlanDetail.has_upgraded;
const handleSubscriptionPageRedirection = () => {
setIsLoading(true);
@@ -109,11 +108,11 @@ export const PlaneCloudBilling: React.FC = observer(() => {
<>
{isInTrialPeriod && (
<>
<div className="flex-shrink-0 p-1 px-2 uppercase bg-custom-primary-100/20 text-custom-primary-100 text-xs rounded-full font-medium">
Free Trial
<div className="flex-shrink-0 p-1 px-2 bg-custom-primary-100/20 text-custom-primary-100 text-xs rounded-full font-medium">
Pro Trial
</div>
<div className="text-center text-sm text-custom-text-200 font-medium">
(Free trial ends on:{" "}
(Pro trial ends on:{" "}
{renderFormattedDate(currentWorkspaceSubscribedPlanDetail?.trial_end_date)})
</div>
</>

View File

@@ -22,7 +22,6 @@ const paymentService = new PaymentService();
export type ProPlanCloudUpgradeModalProps = {
isOpen: boolean;
handleClose: () => void;
yearlyPlan?: boolean;
handleSuccessModal?: () => void;
canFetchProducts?: boolean;
};
@@ -37,7 +36,7 @@ export const calculateYearlyDiscount = (monthlyPrice: number, yearlyPricePerMont
};
export const ProPlanCloudUpgradeModal: FC<ProPlanCloudUpgradeModalProps> = (props) => {
const { isOpen, handleClose, yearlyPlan, handleSuccessModal, canFetchProducts = true } = props;
const { isOpen, handleClose, handleSuccessModal, canFetchProducts = true } = props;
// params
const { workspaceSlug } = useParams();
// states
@@ -48,11 +47,8 @@ export const ProPlanCloudUpgradeModal: FC<ProPlanCloudUpgradeModalProps> = (prop
const {
membership: { currentWorkspaceRole },
} = useUser();
const {
currentWorkspaceSubscribedPlanDetail: subscriptionDetail,
fetchWorkspaceSubscribedPlan,
freeTrialSubscription,
} = useWorkspaceSubscription();
const { currentWorkspaceSubscribedPlanDetail: subscriptionDetail, freeTrialSubscription } =
useWorkspaceSubscription();
// derived values
const isAdmin = currentWorkspaceRole === EUserWorkspaceRoles.ADMIN;
// fetch products
@@ -75,8 +71,8 @@ export const ProPlanCloudUpgradeModal: FC<ProPlanCloudUpgradeModalProps> = (prop
product_id: proProduct?.id,
})
.then((response) => {
if (response.payment_link) {
window.open(response.payment_link, "_self");
if (response.url) {
window.open(response.url, "_self");
}
})
.catch((error) => {
@@ -92,34 +88,6 @@ export const ProPlanCloudUpgradeModal: FC<ProPlanCloudUpgradeModalProps> = (prop
});
};
const handlePlanUpgrade = (priceId: string) => {
setLoading(true);
paymentService
.upgradeCurrentWorkspaceSubscription(workspaceSlug.toString(), {
price_id: priceId,
product_id: proProduct?.id,
})
.then(async () => {
await fetchWorkspaceSubscribedPlan(workspaceSlug.toString());
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Your workspace has been upgraded to Pro yearly plan.",
});
})
.catch((error) => {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: error?.detail ?? "Failed to upgrade. Please try again.",
});
})
.finally(() => {
setLoading(false);
handleClose();
});
};
// handling the payment link when the free trial is disabled
const handlePaymentLink = (priceId: string) => {
if (!workspaceSlug) return;
@@ -144,44 +112,7 @@ export const ProPlanCloudUpgradeModal: FC<ProPlanCloudUpgradeModalProps> = (prop
captureEvent("pro_plan_payment_link_clicked", { workspaceSlug });
if (yearlyPlan) {
handlePlanUpgrade(priceId);
} else {
handleStripeCheckout(priceId);
}
};
// handling the payment link when the free trial is enabled
const handleSubscriptionPageRedirection = (priceId: string) => {
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
.modifyTrailSubscription(workspaceSlug.toString(), { price_id: priceId })
.then((response) => {
if (response.session_url) {
window.open(response.session_url, "_blank");
}
})
.catch(() => {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Failed to redirect to subscription page. Please try again.",
});
})
.finally(() => {
setLoading(false);
});
handleStripeCheckout(priceId);
};
// handle trial
@@ -213,35 +144,26 @@ export const ProPlanCloudUpgradeModal: FC<ProPlanCloudUpgradeModalProps> = (prop
(orderBy(proProduct?.prices || [], ["recurring"], ["desc"])?.find((price) => price.recurring === "year")
?.unit_amount || 0) / 1200;
const yearlyDiscount = calculateYearlyDiscount(monthlyPrice, yearlyPrice);
const isInTrailPeriod =
subscriptionDetail?.has_activated_free_trial &&
subscriptionDetail?.subscription &&
subscriptionDetail?.trial_end_date &&
new Date(subscriptionDetail.trial_end_date) >= new Date();
const isTrialCompleted =
subscriptionDetail &&
!subscriptionDetail.subscription &&
subscriptionDetail.has_activated_free_trial &&
subscriptionDetail.product === "FREE"
? 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 && (
{subscriptionDetail?.is_on_trial && (
<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 className="p-1 px-2 bg-custom-primary-100/20 text-custom-primary-100 text-xs rounded-full font-medium">
Pro trial in progress
</div>
</div>
)}
<Dialog.Title as="h2" className="text-2xl font-bold leading-6 mt-6 flex justify-center items-center">
<Dialog.Title
as="h2"
className="text-2xl font-bold leading-6 mt-4 flex justify-center items-center text-center"
>
Upgrade to Pro, yearly for flat {yearlyDiscount}% off.
</Dialog.Title>
<div className="mt-3 mb-12">
<div className="mt-3 mb-8">
<p className="text-center text-sm mb-4 px-10 text-custom-text-100">
Pro unlocks everything that teams need to make progress and move work forward.Upgrade today and get&nbsp;
Pro unlocks everything that teams need to make progress and move work forward. Upgrade today and get&nbsp;
{yearlyDiscount}% off on your yearly bill.
</p>
<p className="text-center text-sm text-custom-primary-200 font-semibold underline">
@@ -273,16 +195,11 @@ export const ProPlanCloudUpgradeModal: FC<ProPlanCloudUpgradeModalProps> = (prop
basePlan="Free"
features={PRO_PLAN_FEATURES_MAP}
isLoading={isLoading}
handlePaymentLink={(priceId: string) =>
isInTrailPeriod && !isTrialCompleted
? handleSubscriptionPageRedirection(priceId)
: handlePaymentLink(priceId)
}
yearlyPlanOnly={yearlyPlan}
handlePaymentLink={handlePaymentLink}
trialLoader={trialLoader}
handleTrial={handleTrial}
yearlyDiscount={yearlyDiscount}
showTrialButton={!isInTrailPeriod && !isTrialCompleted}
showTrialButton={subscriptionDetail?.is_trial_allowed}
/>
)}
</div>

View File

@@ -37,6 +37,7 @@ export const ProPlanSelfHostUpgradeModal: FC<ProPlanSelfHostUpgradeModalProps> =
name: "Pro",
type: "PRO",
description: "Pro plan details for self-hosted users",
payment_quantity: 1,
prices: [
{
id: "pro_monthly",

View File

@@ -13,7 +13,6 @@ export type ProPlanUpgradeProps = {
features: { label: string; comingSoon: boolean }[];
handlePaymentLink: (priceId: string) => void;
isLoading?: boolean;
yearlyPlanOnly?: boolean;
verticalFeatureList?: boolean;
extraFeatures?: string | React.ReactNode;
trialLoader?: boolean;
@@ -29,7 +28,6 @@ export const ProPlanUpgrade: FC<ProPlanUpgradeProps> = (props) => {
features,
handlePaymentLink,
isLoading = false,
yearlyPlanOnly = false,
verticalFeatureList = false,
extraFeatures,
trialLoader = false,
@@ -38,14 +36,7 @@ export const ProPlanUpgrade: FC<ProPlanUpgradeProps> = (props) => {
showTrialButton = false,
} = props;
// derived values
const yearlyPrice = orderBy(proProduct?.prices || [], ["recurring"], ["desc"]).find(
(price) => price.recurring === "year"
);
const proProductPrices = yearlyPlanOnly
? yearlyPrice
? [yearlyPrice]
: []
: orderBy(proProduct?.prices || [], ["recurring"], ["asc"]);
const proProductPrices = orderBy(proProduct?.prices || [], ["recurring"], ["asc"]);
const renderPricing = (unitAmount: number, recurring: string): number => {
let price = 0;
@@ -58,33 +49,31 @@ export const ProPlanUpgrade: FC<ProPlanUpgradeProps> = (props) => {
<div className="py-4 px-2 border border-custom-primary-200/30 rounded-xl bg-custom-primary-200/5">
<Tab.Group defaultIndex={1}>
<div className="flex w-full justify-center">
{!yearlyPlanOnly && (
<Tab.List className="flex space-x-1 rounded-lg bg-custom-primary-200/10 p-1 w-60">
{proProductPrices.map((price: IPaymentProductPrice) => (
<Tab
key={price?.id}
className={({ selected }) =>
cn(
"w-full rounded-lg py-1.5 text-sm font-medium leading-5",
selected
? "bg-custom-background-100 text-custom-primary-300 shadow"
: "hover:bg-custom-primary-100/5 text-custom-text-300 hover:text-custom-text-200"
)
}
>
<>
{price.recurring === "month" && ("Monthly" as string)}
{price.recurring === "year" && ("Yearly" as string)}
{price.recurring === "year" && yearlyDiscount && (
<span className="bg-gradient-to-r from-[#C78401] to-[#896828] text-white rounded-full px-2 py-1 ml-1 text-xs">
-{yearlyDiscount}%
</span>
)}
</>
</Tab>
))}
</Tab.List>
)}
<Tab.List className="flex space-x-1 rounded-lg bg-custom-primary-200/10 p-1 w-60">
{proProductPrices.map((price: IPaymentProductPrice) => (
<Tab
key={price?.id}
className={({ selected }) =>
cn(
"w-full rounded-lg py-1.5 text-sm font-medium leading-5",
selected
? "bg-custom-background-100 text-custom-primary-300 shadow"
: "hover:bg-custom-primary-100/5 text-custom-text-300 hover:text-custom-text-200"
)
}
>
<>
{price.recurring === "month" && ("Monthly" as string)}
{price.recurring === "year" && ("Yearly" as string)}
{price.recurring === "year" && yearlyDiscount && (
<span className="bg-gradient-to-r from-[#C78401] to-[#896828] text-white rounded-full px-2 py-1 ml-1 text-xs">
-{yearlyDiscount}%
</span>
)}
</>
</Tab>
))}
</Tab.List>
</div>
<Tab.Panels>
{proProductPrices?.map((price: IPaymentProductPrice) => (
@@ -104,11 +93,7 @@ export const ProPlanUpgrade: FC<ProPlanUpgradeProps> = (props) => {
onClick={() => handlePaymentLink(price.id)}
disabled={isLoading}
>
{isLoading
? yearlyPlanOnly
? "Upgrading your plan..."
: "Redirecting to Stripe..."
: "Upgrade to Pro"}
{isLoading ? "Redirecting to Stripe..." : "Upgrade to Pro"}
</button>
{/* trail button */}
@@ -122,12 +107,6 @@ export const ProPlanUpgrade: FC<ProPlanUpgradeProps> = (props) => {
<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
your workspace and update your subscription from <b>Pro, monthly</b> or <b>Pro, yearly.</b>
</div>
)}
</div>
<div className="px-2 pt-6 pb-2">
<div className="p-2 text-sm font-semibold">{`Everything in ${basePlan} +`}</div>

View File

@@ -25,14 +25,6 @@ export class PaymentService extends APIService {
});
}
async upgradeCurrentWorkspaceSubscription(workspaceSlug: string, data = {}) {
return this.post(`/api/payments/workspaces/${workspaceSlug}/subscriptions/upgrade/`, data)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async getWorkspaceCurrentPlan(workspaceSlug: string): Promise<IWorkspaceProductSubscription> {
return this.get(`/api/payments/workspaces/${workspaceSlug}/current-plan/`)
.then((response) => response?.data)
@@ -72,15 +64,4 @@ export class PaymentService extends APIService {
throw error?.response?.data;
});
}
async modifyTrailSubscription(
workspaceSlug: string,
payload: { price_id: string }
): Promise<{ session_url: string }> {
return this.post(`/api/payments/workspaces/${workspaceSlug}/trial-subscriptions/upgrade/`, payload)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
}

View File

@@ -62,17 +62,23 @@ export class WorkspaceSubscriptionStore implements IWorkspaceSubscriptionStore {
const response = await paymentService.getWorkspaceCurrentPlan(workspaceSlug);
runInAction(() => {
set(this.subscribedPlan, workspaceSlug, {
product: response?.product || "FREE",
is_canceled: response?.is_canceled || false,
is_self_managed: response?.is_self_managed || false,
interval: response?.interval || null,
product: response?.product ?? "FREE",
is_cancelled: response?.is_cancelled ?? false,
is_self_managed: response?.is_self_managed ?? 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,
subscription: response?.subscription || undefined,
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,
subscription: response?.subscription ?? undefined,
is_on_trial: response?.is_on_trial ?? false,
is_trial_allowed: response?.is_trial_allowed ?? false,
remaining_trial_days: response?.remaining_trial_days ?? null,
has_upgraded: response?.has_upgraded ?? false,
show_payment_button: response?.show_payment_button ?? true,
show_trial_banner: response?.show_trial_banner ?? false,
});
});
return response;
@@ -80,10 +86,11 @@ export class WorkspaceSubscriptionStore implements IWorkspaceSubscriptionStore {
runInAction(() => {
set(this.subscribedPlan, workspaceSlug, {
product: "FREE",
is_canceled: false,
is_cancelled: false,
is_self_managed: false,
interval: null,
current_period_end_date: null,
show_payment_button: true,
});
});
throw error;

View File

@@ -7,10 +7,6 @@ export type TWorkspaceWithProductDetails = {
name: string;
logo: string;
product: TProductSubscriptionType;
current_period_end_date: string;
has_activated_free_trial: boolean;
has_added_payment_method: boolean;
trial_end_date: string;
is_offline_payment: boolean;
subscription: string;
is_on_trial: boolean;
is_billing_active: boolean;
};