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:
Prateek Shourya
2024-09-03 17:38:07 +05:30
committed by GitHub
parent bf4aa53816
commit 437535a9a6
10 changed files with 97 additions and 182 deletions

View File

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

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

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

View File

@@ -64,7 +64,6 @@ export const CloudEditionBadge = observer(() => {
<ProPlanCloudUpgradeModal
isOpen={isProPlanModalOpen}
handleClose={() => toggleProPlanModal(false)}
yearlyPlan={false}
handleSuccessModal={() => handleSuccessModalToggle(true)}
/>

View File

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

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

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

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