From d7d84535b1fcce35abcd36a9c092d05fc4a6ce75 Mon Sep 17 00:00:00 2001 From: guru_sainath Date: Fri, 16 Aug 2024 13:19:13 +0530 Subject: [PATCH] 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 --- apiserver/plane/payment/views/payment.py | 32 ++++- apiserver/plane/payment/views/product.py | 65 ++++------ .../pro/cloud/[selectedWorkspace]/page.tsx | 111 +++++++++++++----- web/app/upgrade/pro/cloud/page.tsx | 42 ++++++- web/ee/components/license/cloud-badge.tsx | 10 +- .../license/pro-plan-cloud-upgrade-modal.tsx | 1 - web/ee/types/workspace.d.ts | 6 +- 7 files changed, 186 insertions(+), 81 deletions(-) diff --git a/apiserver/plane/payment/views/payment.py b/apiserver/plane/payment/views/payment.py index 665600ac21..c1784e10e9 100644 --- a/apiserver/plane/payment/views/payment.py +++ b/apiserver/plane/payment/views/payment.py @@ -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, ) diff --git a/apiserver/plane/payment/views/product.py b/apiserver/plane/payment/views/product.py index 0a30c894b7..0ccc32501e 100644 --- a/apiserver/plane/payment/views/product.py +++ b/apiserver/plane/payment/views/product.py @@ -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, ] diff --git a/web/app/upgrade/pro/cloud/[selectedWorkspace]/page.tsx b/web/app/upgrade/pro/cloud/[selectedWorkspace]/page.tsx index b7b91c7f6b..b6b2462d2d 100644 --- a/web/app/upgrade/pro/cloud/[selectedWorkspace]/page.tsx +++ b/web/app/upgrade/pro/cloud/[selectedWorkspace]/page.tsx @@ -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(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 (
Choose your billing frequency
- {discountedPrice && ( -
{`Upgrade to our yearly plan and get ${Math.round(discountedPrice)}% off.`}
+ {yearlyDiscountedPrice && ( +
{`Upgrade to our yearly plan and get ${Math.round(yearlyDiscountedPrice)}% off.`}
)}
- {isFetching || isLoadingProduct ? ( + {isFetching || isLoadingProduct || workspaceSubscriptionLoading ? (
@@ -132,7 +180,9 @@ const CloudUpgradePlanPage = observer(() => {
{totalWorkspaceMembers && (
- {`$${plan.unit_amount / 100}`} + + ${renderPlanPricing(plan.unit_amount, 1, plan.recurring)} + {` per user per ${plan.recurring} x ${totalWorkspaceMembers}`} @@ -143,10 +193,11 @@ const CloudUpgradePlanPage = observer(() => {
- -
+
{totalWorkspaceMembers && ( - {`$${(plan.unit_amount / 100) * totalWorkspaceMembers}`} + + ${renderPlanPricing(plan.unit_amount, totalWorkspaceMembers, plan.recurring)} + )} {plan.recurring === "month" ? "per month" : "per year"} @@ -156,12 +207,18 @@ const CloudUpgradePlanPage = observer(() => { ))}
)} + {selectedPlan?.recurring && selectedPlan.unit_amount ? ( <> - ) : ( diff --git a/web/app/upgrade/pro/cloud/page.tsx b/web/app/upgrade/pro/cloud/page.tsx index 8ae0a33697..5945045156 100644 --- a/web/app/upgrade/pro/cloud/page.tsx +++ b/web/app/upgrade/pro/cloud/page.tsx @@ -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 (
{
{truncateText(workspace?.name, 40)}
- {workspace.product === "PRO" && ( + + {/* pro */} + {workspaceSubscription(workspace) === "PRO" && (
{
)} + + {/* trial */} + {workspaceSubscription(workspace) === "TRIAL" && ( +
+ +
+ Trial +
+
+
+ )}
), value: workspace.slug, - disabled: workspace.product !== "FREE", + disabled: ["PRO"].includes(workspaceSubscription(workspace)), })) : [] } diff --git a/web/ee/components/license/cloud-badge.tsx b/web/ee/components/license/cloud-badge.tsx index 4bb146b3e5..87eaba9ee5 100644 --- a/web/ee/components/license/cloud-badge.tsx +++ b/web/ee/components/license/cloud-badge.tsx @@ -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) { 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 0869502848..7fe30f033e 100644 --- a/web/ee/components/license/pro-plan-cloud-upgrade-modal.tsx +++ b/web/ee/components/license/pro-plan-cloud-upgrade-modal.tsx @@ -213,7 +213,6 @@ export const ProPlanCloudUpgradeModal: FC = (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 = diff --git a/web/ee/types/workspace.d.ts b/web/ee/types/workspace.d.ts index a61eb36770..cc9476252c 100644 --- a/web/ee/types/workspace.d.ts +++ b/web/ee/types/workspace.d.ts @@ -8,4 +8,8 @@ export type TWorkspaceWithProductDetails = { logo: string; product: TProductSubscriptionType; current_period_end_date: string; -}; \ No newline at end of file + has_activated_free_trial: boolean; + has_added_payment_method: boolean; + trial_end_date: string; + is_offline_payment: boolean; +};