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:
guru_sainath
2024-08-16 13:19:13 +05:30
committed by GitHub
parent ff034cb5cf
commit d7d84535b1
7 changed files with 186 additions and 81 deletions

View File

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

View File

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

View File

@@ -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>
</>
) : (

View File

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

View File

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

View File

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

View File

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