mirror of
https://github.com/makeplane/plane.git
synced 2026-07-12 05:21:14 +02:00
dev: merge payment endpoint. (#1023)
* fix: payment link endpoint * fix: trial remaining days * chore: merge payment endpoint and code cleanup. --------- Co-authored-by: pablohashescobar <nikhilschacko@gmail.com>
This commit is contained in:
@@ -77,7 +77,8 @@ def has_upgraded(workspace_license):
|
||||
def is_on_trial(workspace_license):
|
||||
"Check if the workspace is on a trial"
|
||||
if (
|
||||
workspace_license.trial_end_date
|
||||
workspace_license.subscription
|
||||
and workspace_license.trial_end_date
|
||||
and workspace_license.trial_end_date >= timezone.now()
|
||||
):
|
||||
return True
|
||||
@@ -87,8 +88,9 @@ def is_on_trial(workspace_license):
|
||||
def trial_remaining_days(workspace_license):
|
||||
"""Calculate the remaining days of the trial"""
|
||||
if (
|
||||
workspace_license.trial_end_date
|
||||
and workspace_license.trial_end_date > timezone.now()
|
||||
workspace_license.subscription
|
||||
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
|
||||
|
||||
@@ -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"},
|
||||
|
||||
2
packages/types/src/payment.d.ts
vendored
2
packages/types/src/payment.d.ts
vendored
@@ -19,7 +19,7 @@ export type IPaymentProduct = {
|
||||
|
||||
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;
|
||||
|
||||
@@ -64,7 +64,6 @@ export const CloudEditionBadge = observer(() => {
|
||||
<ProPlanCloudUpgradeModal
|
||||
isOpen={isProPlanModalOpen}
|
||||
handleClose={() => toggleProPlanModal(false)}
|
||||
yearlyPlan={false}
|
||||
handleSuccessModal={() => handleSuccessModalToggle(true)}
|
||||
/>
|
||||
|
||||
|
||||
@@ -26,11 +26,7 @@ export const FreeTrialBanner: FC = observer(() => {
|
||||
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">
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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
|
||||
@@ -264,10 +195,7 @@ export const ProPlanCloudUpgradeModal: FC<ProPlanCloudUpgradeModalProps> = (prop
|
||||
basePlan="Free"
|
||||
features={PRO_PLAN_FEATURES_MAP}
|
||||
isLoading={isLoading}
|
||||
handlePaymentLink={(priceId: string) =>
|
||||
subscriptionDetail?.is_on_trial ? handleSubscriptionPageRedirection(priceId) : handlePaymentLink(priceId)
|
||||
}
|
||||
yearlyPlanOnly={yearlyPlan}
|
||||
handlePaymentLink={handlePaymentLink}
|
||||
trialLoader={trialLoader}
|
||||
handleTrial={handleTrial}
|
||||
yearlyDiscount={yearlyDiscount}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ export class WorkspaceSubscriptionStore implements IWorkspaceSubscriptionStore {
|
||||
runInAction(() => {
|
||||
set(this.subscribedPlan, workspaceSlug, {
|
||||
product: response?.product ?? "FREE",
|
||||
is_canceled: response?.is_canceled ?? false,
|
||||
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,
|
||||
@@ -86,7 +86,7 @@ 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,
|
||||
|
||||
Reference in New Issue
Block a user