mirror of
https://github.com/makeplane/plane.git
synced 2026-07-13 05:49:40 +02:00
chore: pricing and trial upgrade in cloud (#842)
* fix: updated payment validation for pro-cloud * fix: removed logs * fix: ui and calculated pricing on the button * dev: trial upgrades * chore: fix workspace licenses * chore: upgrade free trial subscriptions * fix: updated and handled pro and trial for pro workspaces list * chore: handled offline payment in pro cloud workspaces * chore: handled offline payment in cloud badge * chore: updated the offline_payment logic * fix: error messages --------- Co-authored-by: pablohashescobar <nikhilschacko@gmail.com>
This commit is contained in:
@@ -246,7 +246,7 @@ class WorkspaceFreeTrialEndpoint(BaseAPIView):
|
||||
return Response(response, status=status.HTTP_200_OK)
|
||||
else:
|
||||
return Response(
|
||||
{"error": "error fetching product details"},
|
||||
{"error": "error upgrading trial subscriptions"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
except requests.exceptions.RequestException as e:
|
||||
@@ -257,7 +257,7 @@ class WorkspaceFreeTrialEndpoint(BaseAPIView):
|
||||
)
|
||||
log_exception(e)
|
||||
return Response(
|
||||
{"error": "error fetching payment link"},
|
||||
{"error": "error creating trial subscriptions"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
@@ -280,6 +280,28 @@ class WorkspaceTrialUpgradeEndpoint(BaseAPIView):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Get the workspace members
|
||||
workspace_members = (
|
||||
WorkspaceMember.objects.filter(
|
||||
workspace__slug=slug, is_active=True, member__is_bot=False
|
||||
)
|
||||
.annotate(
|
||||
user_email=F("member__email"),
|
||||
user_id=F("member__id"),
|
||||
user_role=F("role"),
|
||||
)
|
||||
.values(
|
||||
"user_email",
|
||||
"user_id",
|
||||
"user_role",
|
||||
)
|
||||
.values("user_email", "user_id", "user_role")
|
||||
)
|
||||
|
||||
# Convert the user_id to string
|
||||
for member in workspace_members:
|
||||
member["user_id"] = str(member["user_id"])
|
||||
|
||||
# Get the workspace
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
|
||||
@@ -294,6 +316,8 @@ class WorkspaceTrialUpgradeEndpoint(BaseAPIView):
|
||||
json={
|
||||
"workspace_id": str(workspace.id),
|
||||
"stripe_price_id": price_id,
|
||||
"members_list": list(workspace_members),
|
||||
"slug": slug,
|
||||
},
|
||||
)
|
||||
# Check if the response is successful
|
||||
@@ -303,7 +327,7 @@ class WorkspaceTrialUpgradeEndpoint(BaseAPIView):
|
||||
return Response(response, status=status.HTTP_200_OK)
|
||||
else:
|
||||
return Response(
|
||||
{"error": "error fetching product details"},
|
||||
{"error": "error upgrading trial subscriptions"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
except requests.exceptions.RequestException as e:
|
||||
@@ -314,6 +338,6 @@ class WorkspaceTrialUpgradeEndpoint(BaseAPIView):
|
||||
)
|
||||
log_exception(e)
|
||||
return Response(
|
||||
{"error": "error fetching payment link"},
|
||||
{"error": "error upgrading trial subscriptions"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
@@ -6,6 +6,7 @@ from django.conf import settings
|
||||
from django.db.models import CharField
|
||||
from django.db.models.functions import Cast
|
||||
from django.utils import timezone
|
||||
from django.db.models import F
|
||||
|
||||
# Third party imports
|
||||
from rest_framework import status
|
||||
@@ -94,48 +95,36 @@ class WebsiteUserWorkspaceEndpoint(BaseAPIView):
|
||||
def get(self, request):
|
||||
try:
|
||||
# Get all the workspaces where the user is admin
|
||||
workspace_query = (
|
||||
WorkspaceMember.objects.filter(
|
||||
member=request.user,
|
||||
is_active=True,
|
||||
role=20,
|
||||
)
|
||||
.annotate(uuid_str=Cast("workspace_id", CharField()))
|
||||
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"))
|
||||
.values(
|
||||
"uuid_str",
|
||||
"workspace__slug",
|
||||
"workspace__name",
|
||||
"workspace__logo",
|
||||
"workspace_id",
|
||||
"slug",
|
||||
"name",
|
||||
"logo",
|
||||
"product",
|
||||
"trial_end_date",
|
||||
"has_activated_free_trial",
|
||||
"has_added_payment_method",
|
||||
"current_period_end_date",
|
||||
"is_offline_payment",
|
||||
)
|
||||
)
|
||||
|
||||
workspaces = [
|
||||
{
|
||||
"workspace_id": workspace["uuid_str"],
|
||||
"slug": workspace["workspace__slug"],
|
||||
"name": workspace["workspace__name"],
|
||||
"logo": workspace["workspace__logo"],
|
||||
}
|
||||
for workspace in workspace_query
|
||||
]
|
||||
# Get the workspace details
|
||||
return Response(workspace_licenses, status=status.HTTP_200_OK)
|
||||
|
||||
if settings.PAYMENT_SERVER_BASE_URL:
|
||||
response = requests.post(
|
||||
f"{settings.PAYMENT_SERVER_BASE_URL}/api/user-workspace-products/",
|
||||
headers={
|
||||
"content-type": "application/json",
|
||||
"x-api-key": settings.PAYMENT_SERVER_AUTH_TOKEN,
|
||||
},
|
||||
json={"workspaces": workspaces},
|
||||
)
|
||||
response.raise_for_status()
|
||||
response = response.json()
|
||||
return Response(response, status=status.HTTP_200_OK)
|
||||
else:
|
||||
return Response(
|
||||
{"error": "error fetching product details"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
except requests.exceptions.RequestException as e:
|
||||
if e.response.status_code == 400:
|
||||
return Response(
|
||||
@@ -305,7 +294,6 @@ class WorkspaceProductEndpoint(BaseAPIView):
|
||||
|
||||
|
||||
class WorkspaceLicenseRefreshEndpoint(BaseAPIView):
|
||||
|
||||
def post(self, request, slug):
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
|
||||
@@ -385,7 +373,6 @@ class WorkspaceLicenseRefreshEndpoint(BaseAPIView):
|
||||
|
||||
|
||||
class WorkspaceLicenseSyncEndpoint(BaseAPIView):
|
||||
|
||||
permission_classes = [
|
||||
AllowAny,
|
||||
]
|
||||
|
||||
@@ -18,24 +18,38 @@ 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;
|
||||
const yearlyCost = yearlyPricePerMonth * 12;
|
||||
const amountSaved = monthlyCost - yearlyCost;
|
||||
const discountPercentage = (amountSaved / monthlyCost) * 100;
|
||||
return Math.floor(discountPercentage);
|
||||
};
|
||||
|
||||
const renderPlanPricing = (price: number, members: number = 1, recurring: string) => {
|
||||
if (recurring === "month") return ((price / 100) * members).toFixed(0);
|
||||
if (recurring === "year") return ((price / 1200) * members).toFixed(0);
|
||||
};
|
||||
|
||||
const CloudUpgradePlanPage = observer(() => {
|
||||
// states
|
||||
const [selectedPlan, setSelectedPlan] = useState<IPaymentProductPrice | null>(null);
|
||||
const [isLoading, setLoading] = useState(false);
|
||||
// router
|
||||
const { selectedWorkspace } = useParams();
|
||||
|
||||
const { selectedWorkspace: workspaceSlug } = useParams();
|
||||
|
||||
// fetch workspace members
|
||||
const { data: workspaceMembers, isLoading: isFetching } = useSWR(
|
||||
selectedWorkspace ? `WORKSPACES_MEMBER_DETAILS` : null,
|
||||
selectedWorkspace ? () => workspaceService.fetchWorkspaceMembers(selectedWorkspace.toString()) : null,
|
||||
workspaceSlug ? `CLOUD_PRO_WORKSPACE_MEMBER_DETAILS` : null,
|
||||
workspaceSlug ? () => workspaceService.fetchWorkspaceMembers(workspaceSlug.toString()) : null,
|
||||
{ revalidateIfStale: false, revalidateOnFocus: false }
|
||||
);
|
||||
|
||||
// fetch products
|
||||
const { data, isLoading: isLoadingProduct } = useSWR(
|
||||
selectedWorkspace ? "CLOUD_PAYMENT_PRODUCTS" : null,
|
||||
selectedWorkspace ? () => paymentService.listProducts(selectedWorkspace.toString()) : null,
|
||||
const { data: products, isLoading: isLoadingProduct } = useSWR(
|
||||
workspaceSlug ? "CLOUD_PRO_PRODUCTS" : null,
|
||||
workspaceSlug ? () => paymentService.listProducts(workspaceSlug.toString()) : null,
|
||||
{
|
||||
errorRetryCount: 2,
|
||||
revalidateIfStale: false,
|
||||
@@ -43,26 +57,30 @@ const CloudUpgradePlanPage = observer(() => {
|
||||
}
|
||||
);
|
||||
|
||||
const totalWorkspaceMembers = (workspaceMembers || [])?.filter((member) => member.role >= 15)?.length;
|
||||
// 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 }
|
||||
);
|
||||
|
||||
const proProduct = (data || [])?.find((product: IPaymentProduct) => product?.type === "PRO");
|
||||
// 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 calculateYearlyDiscount = (monthlyAmount: number | undefined, yearlyAmount: number | undefined) => {
|
||||
if (!monthlyAmount || !yearlyAmount) return undefined;
|
||||
const yearlyCostIfPaidMonthly = monthlyAmount * 12;
|
||||
const discountAmount = yearlyCostIfPaidMonthly - yearlyAmount;
|
||||
const discountPercentage = (discountAmount / yearlyCostIfPaidMonthly) * 100;
|
||||
const monthlyPlanUnitPrice = (monthlyPlan?.unit_amount || 0) / 100;
|
||||
const yearlyPlanUnitPrice = (yearlyPlan?.unit_amount || 0) / 1200;
|
||||
|
||||
return discountPercentage;
|
||||
};
|
||||
const yearlyDiscountedPrice = calculateYearlyDiscount(monthlyPlanUnitPrice, yearlyPlanUnitPrice);
|
||||
|
||||
const discountedPrice = calculateYearlyDiscount(monthlyPlan?.unit_amount, yearlyPlan?.unit_amount);
|
||||
const workspaceOnTrial =
|
||||
workspaceSubscription?.has_activated_free_trial && workspaceSubscription?.trial_end_date ? true : false;
|
||||
|
||||
const handleStripeCheckout = (priceId: string) => {
|
||||
if (!selectedWorkspace) {
|
||||
if (!workspaceSlug) {
|
||||
setToast({
|
||||
type: TOAST_TYPE.INFO,
|
||||
title: "Please select a workspace to continue",
|
||||
@@ -71,7 +89,7 @@ const CloudUpgradePlanPage = observer(() => {
|
||||
}
|
||||
setLoading(true);
|
||||
paymentService
|
||||
.getCurrentWorkspacePaymentLink(selectedWorkspace.toString(), {
|
||||
.getCurrentWorkspacePaymentLink(workspaceSlug.toString(), {
|
||||
price_id: priceId,
|
||||
product_id: proProduct?.id,
|
||||
})
|
||||
@@ -92,16 +110,46 @@ const CloudUpgradePlanPage = observer(() => {
|
||||
});
|
||||
};
|
||||
|
||||
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");
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: error?.detail ?? "Failed to generate payment link. Please try again.",
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<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>
|
||||
{discountedPrice && (
|
||||
<div className="text-center text-base text-custom-text-300">{`Upgrade to our yearly plan and get ${Math.round(discountedPrice)}% off.`}</div>
|
||||
{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 ? (
|
||||
{isFetching || isLoadingProduct || workspaceSubscriptionLoading ? (
|
||||
<div className="flex flex-col rounded gap-1 w-full">
|
||||
<Loader.Item height="90px" />
|
||||
<Loader.Item height="90px" />
|
||||
@@ -132,7 +180,9 @@ const CloudUpgradePlanPage = observer(() => {
|
||||
<div className="flex flex-col">
|
||||
{totalWorkspaceMembers && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-2xl font-semibold leading-7">{`$${plan.unit_amount / 100}`}</span>
|
||||
<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}`}
|
||||
</span>
|
||||
@@ -143,10 +193,11 @@ const CloudUpgradePlanPage = observer(() => {
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col">
|
||||
<div className="flex flex-col text-right">
|
||||
{totalWorkspaceMembers && (
|
||||
<span className="text-2xl font-semibold leading-7">{`$${(plan.unit_amount / 100) * totalWorkspaceMembers}`}</span>
|
||||
<span className="text-2xl font-semibold leading-7">
|
||||
${renderPlanPricing(plan.unit_amount, totalWorkspaceMembers, plan.recurring)}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-sm text-custom-text-300">
|
||||
{plan.recurring === "month" ? "per month" : "per year"}
|
||||
@@ -156,12 +207,18 @@ const CloudUpgradePlanPage = observer(() => {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedPlan?.recurring && selectedPlan.unit_amount ? (
|
||||
<>
|
||||
<Button className="w-full px-2 my-4" onClick={() => handleStripeCheckout(selectedPlan.id)}>
|
||||
<Button
|
||||
className="w-full px-2 my-4"
|
||||
onClick={() =>
|
||||
workspaceOnTrial ? handleTrialStripeCheckout(selectedPlan.id) : handleStripeCheckout(selectedPlan.id)
|
||||
}
|
||||
>
|
||||
{isLoading
|
||||
? "Redirecting to Stripe..."
|
||||
: `Pay $${(selectedPlan?.unit_amount / 100) * totalWorkspaceMembers} every ${selectedPlan?.recurring}`}
|
||||
: `Pay $${renderPlanPricing(selectedPlan.unit_amount, totalWorkspaceMembers, selectedPlan.recurring)} every ${selectedPlan?.recurring}`}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -18,6 +18,8 @@ import { useUser } from "@/hooks/store";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
// services
|
||||
import { WorkspaceService } from "@/plane-web/services";
|
||||
// plane web types
|
||||
import { TWorkspaceWithProductDetails } from "@/plane-web/types/workspace";
|
||||
|
||||
const workspaceService = new WorkspaceService();
|
||||
|
||||
@@ -33,7 +35,7 @@ const CloudUpgradePage = observer(() => {
|
||||
const { setTheme } = useTheme();
|
||||
|
||||
const { data: workspacesList, isLoading: isFetching } = useSWR(
|
||||
currentUser ? `WORKSPACES_WITH_PLAN_DETAILS` : null,
|
||||
currentUser ? `CLOUD_PRO_WORKSPACES_LIST` : null,
|
||||
currentUser ? () => workspaceService.getWorkspacesWithPlanDetails() : null,
|
||||
{ revalidateIfStale: false, revalidateOnFocus: false }
|
||||
);
|
||||
@@ -69,6 +71,23 @@ const CloudUpgradePage = observer(() => {
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
|
||||
const workspaceSubscription = (workspace: TWorkspaceWithProductDetails): "FREE" | "PRO" | "TRIAL" => {
|
||||
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) {
|
||||
return "TRIAL";
|
||||
}
|
||||
case "FREE":
|
||||
return "FREE";
|
||||
default:
|
||||
return "FREE";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col items-center justify-center gap-2">
|
||||
<RadioInput
|
||||
@@ -111,7 +130,9 @@ const CloudUpgradePage = observer(() => {
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium">{truncateText(workspace?.name, 40)}</div>
|
||||
</div>
|
||||
{workspace.product === "PRO" && (
|
||||
|
||||
{/* pro */}
|
||||
{workspaceSubscription(workspace) === "PRO" && (
|
||||
<div className="flex-shrink-0">
|
||||
<Tooltip position="right" tooltipContent="This workspace is already subscribed to Pro.">
|
||||
<div
|
||||
@@ -124,10 +145,25 @@ const CloudUpgradePage = observer(() => {
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* trial */}
|
||||
{workspaceSubscription(workspace) === "TRIAL" && (
|
||||
<div className="flex-shrink-0">
|
||||
<Tooltip position="right" tooltipContent="This workspace is in Trial please upgrade to Pro.">
|
||||
<div
|
||||
className={cn(
|
||||
"text-custom-primary-100 bg-custom-primary-100/20 rounded-md px-2 py-0 text-center text-xs font-medium outline-none"
|
||||
)}
|
||||
>
|
||||
Trial
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
value: workspace.slug,
|
||||
disabled: workspace.product !== "FREE",
|
||||
disabled: ["PRO"].includes(workspaceSubscription(workspace)),
|
||||
}))
|
||||
: []
|
||||
}
|
||||
|
||||
@@ -100,14 +100,12 @@ export const CloudEditionBadge = observer(() => {
|
||||
const showPaymentButton = () => {
|
||||
switch (subscriptionDetail.product) {
|
||||
case "FREE": {
|
||||
if (subscriptionDetail?.has_activated_free_trial) {
|
||||
return true;
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
case "PRO":
|
||||
if (subscriptionDetail?.has_activated_free_trial && subscriptionDetail?.has_added_payment_method) {
|
||||
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) {
|
||||
|
||||
@@ -213,7 +213,6 @@ 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);
|
||||
console.log("yearlyDiscount", yearlyDiscount);
|
||||
const isInTrailPeriod =
|
||||
subscriptionDetail?.has_activated_free_trial && subscriptionDetail?.trial_end_date ? true : false;
|
||||
const isTrialCompleted =
|
||||
|
||||
6
web/ee/types/workspace.d.ts
vendored
6
web/ee/types/workspace.d.ts
vendored
@@ -8,4 +8,8 @@ export type TWorkspaceWithProductDetails = {
|
||||
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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user