fix/premium-username-flow (#5815)
* Removes user plan logic for stripe sub * Update hardcoded price * Removed checkoutSessionId metadata and fix for trpc query * Remove or comment team billing unused code * Add todo note * Fix type checks * Change function that was renamed * Remove duplicated test * remove plan when creating user on test Co-authored-by: Alex van Andel <me@alexvanandel.com> Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com> Co-authored-by: Peer Richelsen <peeroke@gmail.com>
This commit is contained in:
co-authored by
Alex van Andel
kodiakhq[bot]
Peer Richelsen
parent
5360cea3bf
commit
6e351a4b20
@@ -42,12 +42,8 @@ const UserSettings = (props: IUserSettingsProps) => {
|
||||
const mutation = trpc.viewer.updateProfile.useMutation({
|
||||
onSuccess: onSuccess,
|
||||
});
|
||||
const { data: stripeCustomer } = trpc.viewer.stripeCustomer.useQuery();
|
||||
const paymentRequired = stripeCustomer?.isPremium ? !stripeCustomer?.paidForPremium : false;
|
||||
|
||||
const onSubmit = handleSubmit((data) => {
|
||||
if (paymentRequired) {
|
||||
return;
|
||||
}
|
||||
mutation.mutate({
|
||||
name: data.name,
|
||||
timeZone: selectedTimeZone,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { debounce, noop } from "lodash";
|
||||
import { useRouter } from "next/router";
|
||||
import { RefCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { getPremiumPlanMode, getPremiumPlanPriceValue } from "@calcom/app-store/stripepayment/lib/utils";
|
||||
import { getPremiumPlanPriceValue } from "@calcom/app-store/stripepayment/lib/utils";
|
||||
import { fetchUsername } from "@calcom/lib/fetchUsername";
|
||||
import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
@@ -24,9 +24,7 @@ import {
|
||||
} from "@calcom/ui";
|
||||
|
||||
export enum UsernameChangeStatusEnum {
|
||||
NORMAL = "NORMAL",
|
||||
UPGRADE = "UPGRADE",
|
||||
DOWNGRADE = "DOWNGRADE",
|
||||
}
|
||||
|
||||
interface ICustomUsernameProps {
|
||||
@@ -60,20 +58,14 @@ interface ICustomUsernameProps {
|
||||
const obtainNewUsernameChangeCondition = ({
|
||||
userIsPremium,
|
||||
isNewUsernamePremium,
|
||||
stripeCustomer,
|
||||
}: {
|
||||
userIsPremium: boolean;
|
||||
isNewUsernamePremium: boolean;
|
||||
stripeCustomer: RouterOutputs["viewer"]["stripeCustomer"] | undefined;
|
||||
}) => {
|
||||
if (!userIsPremium && isNewUsernamePremium && !stripeCustomer?.paidForPremium) {
|
||||
if (!userIsPremium && isNewUsernamePremium) {
|
||||
return UsernameChangeStatusEnum.UPGRADE;
|
||||
}
|
||||
|
||||
if (userIsPremium && !isNewUsernamePremium && getPremiumPlanMode() === "subscription") {
|
||||
return UsernameChangeStatusEnum.DOWNGRADE;
|
||||
}
|
||||
return UsernameChangeStatusEnum.NORMAL;
|
||||
};
|
||||
|
||||
const PremiumTextfield = (props: ICustomUsernameProps) => {
|
||||
@@ -106,7 +98,7 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
|
||||
setIsInputUsernamePremium(data.premium);
|
||||
setUsernameIsAvailable(data.available);
|
||||
}, 150),
|
||||
[]
|
||||
[currentUsername]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -137,7 +129,7 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
|
||||
});
|
||||
|
||||
// when current username isn't set - Go to stripe to check what username he wanted to buy and was it a premium and was it paid for
|
||||
const paymentRequired = !currentUsername && stripeCustomer?.isPremium && !stripeCustomer?.paidForPremium;
|
||||
const paymentRequired = !currentUsername && stripeCustomer?.isPremium;
|
||||
|
||||
const usernameChangeCondition = obtainNewUsernameChangeCondition({
|
||||
userIsPremium: isCurrentUsernamePremium,
|
||||
@@ -194,7 +186,7 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
|
||||
};
|
||||
|
||||
const saveUsername = () => {
|
||||
if (usernameChangeCondition === UsernameChangeStatusEnum.NORMAL) {
|
||||
if (usernameChangeCondition !== UsernameChangeStatusEnum.UPGRADE) {
|
||||
updateUsername.mutate({
|
||||
username: inputUsernameValue,
|
||||
});
|
||||
@@ -279,14 +271,6 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
|
||||
{paymentMsg}
|
||||
{markAsError && <p className="mt-1 text-xs text-red-500">Username is already taken</p>}
|
||||
|
||||
{usernameIsAvailable && (
|
||||
<p className={classNames("mt-1 text-xs text-gray-900")}>
|
||||
{usernameChangeCondition === UsernameChangeStatusEnum.DOWNGRADE && (
|
||||
<>{t("premium_to_standard_username_description")}</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Dialog open={openDialogSaveUsername}>
|
||||
<DialogContent>
|
||||
<div className="flex flex-row">
|
||||
@@ -295,13 +279,8 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
|
||||
</div>
|
||||
<div className="mb-4 w-full px-4 pt-1">
|
||||
<DialogHeader title={t("confirm_username_change_dialog_title")} />
|
||||
{usernameChangeCondition && usernameChangeCondition !== UsernameChangeStatusEnum.NORMAL && (
|
||||
<p className="-mt-4 mb-4 text-sm text-gray-800">
|
||||
{usernameChangeCondition === UsernameChangeStatusEnum.UPGRADE &&
|
||||
t("change_username_standard_to_premium")}
|
||||
{usernameChangeCondition === UsernameChangeStatusEnum.DOWNGRADE &&
|
||||
t("change_username_premium_to_standard")}
|
||||
</p>
|
||||
{usernameChangeCondition && usernameChangeCondition === UsernameChangeStatusEnum.UPGRADE && (
|
||||
<p className="mb-4 text-sm text-gray-800">{t("change_username_standard_to_premium")}</p>
|
||||
)}
|
||||
|
||||
<div className="flex w-full flex-wrap rounded-sm bg-gray-100 py-3 text-sm">
|
||||
@@ -323,8 +302,7 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
|
||||
|
||||
<div className="mt-4 flex flex-row-reverse gap-x-2">
|
||||
{/* redirect to checkout */}
|
||||
{(usernameChangeCondition === UsernameChangeStatusEnum.UPGRADE ||
|
||||
usernameChangeCondition === UsernameChangeStatusEnum.DOWNGRADE) && (
|
||||
{usernameChangeCondition === UsernameChangeStatusEnum.UPGRADE && (
|
||||
<Button
|
||||
type="button"
|
||||
loading={updateUsername.isLoading}
|
||||
@@ -336,7 +314,7 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
|
||||
</Button>
|
||||
)}
|
||||
{/* Normal save */}
|
||||
{usernameChangeCondition === UsernameChangeStatusEnum.NORMAL && (
|
||||
{usernameChangeCondition !== UsernameChangeStatusEnum.UPGRADE && (
|
||||
<Button
|
||||
type="button"
|
||||
loading={updateUsername.isLoading}
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
import { MembershipRole } from "@prisma/client";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { closeComUpsertTeamUser } from "@calcom/lib/sync/SyncServiceManager";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import { getSession } from "@lib/auth";
|
||||
import slugify from "@lib/slugify";
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const session = await getSession({ req: req });
|
||||
|
||||
if (!session?.user?.id) {
|
||||
res.status(401).json({ message: "Not authenticated" });
|
||||
return;
|
||||
}
|
||||
|
||||
const ownerUser = await prisma.user.findFirst({
|
||||
where: {
|
||||
id: session.user.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
createdDate: true,
|
||||
name: true,
|
||||
plan: true,
|
||||
email: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (req.method === "POST") {
|
||||
const slug = slugify(req.body.name);
|
||||
|
||||
const nameCollisions = await prisma.team.count({
|
||||
where: {
|
||||
OR: [{ name: req.body.name }, { slug: slug }],
|
||||
},
|
||||
});
|
||||
|
||||
if (nameCollisions > 0) {
|
||||
return res.status(409).json({ errorCode: "TeamNameCollision", message: "Team name already taken." });
|
||||
}
|
||||
|
||||
const createTeam = await prisma.team.create({
|
||||
data: {
|
||||
name: req.body.name,
|
||||
slug: slug,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.membership.create({
|
||||
data: {
|
||||
teamId: createTeam.id,
|
||||
userId: session.user.id,
|
||||
role: MembershipRole.OWNER,
|
||||
accepted: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Sync Services: Close.com
|
||||
closeComUpsertTeamUser(createTeam, ownerUser, MembershipRole.OWNER);
|
||||
|
||||
return res.status(201).json({ message: "Team created" });
|
||||
}
|
||||
|
||||
res.status(404).json({ message: "Team not found" });
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { signIn } from "next-auth/react";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { getPremiumPlanPrice } from "@calcom/app-store/stripepayment/lib/utils";
|
||||
import { getPremiumMonthlyPlanPriceId } from "@calcom/app-store/stripepayment/lib/utils";
|
||||
import stripe from "@calcom/features/ee/payments/server/stripe";
|
||||
import {
|
||||
hostedCal,
|
||||
@@ -161,7 +161,7 @@ const getStripePremiumUsernameUrl = async ({
|
||||
customer: customer.id,
|
||||
line_items: [
|
||||
{
|
||||
price: getPremiumPlanPrice(),
|
||||
price: getPremiumMonthlyPlanPriceId(),
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { expect } from "@playwright/test";
|
||||
import { UserPlan } from "@prisma/client";
|
||||
|
||||
import { getFreePlanPrice, getProPlanPrice } from "@calcom/app-store/stripepayment/lib/utils";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { getPremiumMonthlyPlanPriceId } from "@calcom/app-store/stripepayment/lib/utils";
|
||||
import stripe from "@calcom/features/ee/payments/server/stripe";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
|
||||
@@ -27,7 +26,7 @@ test.describe("Change username on settings", () => {
|
||||
|
||||
/** TODO: Find out why it's timing out */
|
||||
test.fixme("User can change username", async ({ page, users, prisma }) => {
|
||||
const user = await users.create({ plan: UserPlan.TRIAL });
|
||||
const user = await users.create();
|
||||
|
||||
await user.login();
|
||||
// Try to go homepage
|
||||
@@ -50,28 +49,13 @@ test.describe("Change username on settings", () => {
|
||||
expect(newUpdatedUser.username).toBe("demousernamex");
|
||||
});
|
||||
|
||||
test("User trial can update to PREMIUM username", async ({ page, users }, testInfo) => {
|
||||
test("User can update to PREMIUM username", async ({ page, users }, testInfo) => {
|
||||
// eslint-disable-next-line playwright/no-skipped-test
|
||||
test.skip(!IS_STRIPE_ENABLED, "It should only run if Stripe is installed");
|
||||
test.skip(IS_SELF_HOSTED, "It shouldn't run on self hosted");
|
||||
|
||||
const user = await users.create({ plan: UserPlan.TRIAL });
|
||||
const customer = await stripe.customers.create({ email: `${user?.username}@example.com` });
|
||||
await stripe.subscriptionSchedules.create({
|
||||
customer: customer.id,
|
||||
start_date: "now",
|
||||
end_behavior: "release",
|
||||
phases: [
|
||||
{
|
||||
items: [{ price: getProPlanPrice() }],
|
||||
trial_end: dayjs().add(14, "day").unix(),
|
||||
end_date: dayjs().add(14, "day").unix(),
|
||||
},
|
||||
{
|
||||
items: [{ price: getFreePlanPrice() }],
|
||||
},
|
||||
],
|
||||
});
|
||||
const user = await users.create();
|
||||
await stripe.customers.create({ email: `${user?.username}@example.com` });
|
||||
|
||||
await user.login();
|
||||
await page.goto("/settings/my-account/profile");
|
||||
@@ -97,50 +81,4 @@ test.describe("Change username on settings", () => {
|
||||
|
||||
await expect(page).toHaveURL(/.*checkout.stripe.com/);
|
||||
});
|
||||
|
||||
test("User PRO can update to PREMIUM username", async ({ page, users }, testInfo) => {
|
||||
// eslint-disable-next-line playwright/no-skipped-test
|
||||
test.skip(!IS_STRIPE_ENABLED, "It should only run if Stripe is installed");
|
||||
test.skip(IS_SELF_HOSTED, "It shouldn't run on self hosted");
|
||||
const user = await users.create({ plan: UserPlan.PRO });
|
||||
const customer = await stripe.customers.create({ email: `${user?.username}@example.com` });
|
||||
const paymentMethod = await stripe.paymentMethods.create({
|
||||
type: "card",
|
||||
card: {
|
||||
number: "4242424242424242",
|
||||
cvc: "123",
|
||||
exp_month: 12,
|
||||
exp_year: 2040,
|
||||
},
|
||||
});
|
||||
await stripe.paymentMethods.attach(paymentMethod.id, { customer: customer.id });
|
||||
await stripe.subscriptions.create({
|
||||
customer: customer.id,
|
||||
items: [{ price: getProPlanPrice() }],
|
||||
});
|
||||
|
||||
await user.login();
|
||||
await page.goto("/settings/profile");
|
||||
|
||||
// Change username from normal to premium
|
||||
const usernameInput = page.locator("[data-testid=username-input]");
|
||||
|
||||
await usernameInput.fill(`xx${testInfo.workerIndex}`);
|
||||
|
||||
// Click on save button
|
||||
await page.click('button[type="submit"]');
|
||||
|
||||
// Validate modal text fields
|
||||
const currentUsernameText = page.locator("[data-testid=current-username]").innerText();
|
||||
const newUsernameText = page.locator("[data-testid=new-username]").innerText();
|
||||
|
||||
expect(currentUsernameText).not.toBe(newUsernameText);
|
||||
|
||||
// Click on Go to billing
|
||||
await page.click("[data-testid=go-to-billing]", { timeout: 300 });
|
||||
|
||||
await page.waitForLoadState();
|
||||
|
||||
await expect(page).toHaveURL(/.*billing.stripe.com/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import { getCustomerAndCheckoutSession } from "@calcom/app-store/stripepayment/l
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { defaultHandler, defaultResponder } from "@calcom/lib/server";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { Prisma } from "@calcom/prisma/client";
|
||||
|
||||
const querySchema = z.object({
|
||||
callbackUrl: z.string().transform((url) => {
|
||||
@@ -16,22 +17,46 @@ const querySchema = z.object({
|
||||
checkoutSessionId: z.string(),
|
||||
});
|
||||
|
||||
// It handles premium user payment success/failure. Can be modified to handle other PRO upgrade payment as well.
|
||||
// It handles premium user payment success/failure
|
||||
async function getHandler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const { callbackUrl, checkoutSessionId } = querySchema.parse(req.query);
|
||||
const { stripeCustomer, checkoutSession } = await getCustomerAndCheckoutSession(checkoutSessionId);
|
||||
|
||||
if (!stripeCustomer) return { message: "Stripe customer not found or deleted" };
|
||||
|
||||
if (checkoutSession.payment_status === "paid") {
|
||||
console.log("Found payment ");
|
||||
// first let's try to find user by metadata stripeCustomerId
|
||||
let user = await prisma.user.findFirst({
|
||||
where: {
|
||||
metadata: {
|
||||
path: ["stripeCustomerId"],
|
||||
equals: stripeCustomer.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!user && stripeCustomer.email) {
|
||||
// if user not found, let's try to find user by email
|
||||
user = await prisma.user.findFirst({
|
||||
where: {
|
||||
email: stripeCustomer.email,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (checkoutSession.payment_status === "paid" && stripeCustomer.metadata.username) {
|
||||
try {
|
||||
if (!user) return { message: "User not found" };
|
||||
|
||||
await prisma.user.update({
|
||||
data: {
|
||||
username: stripeCustomer.metadata.username,
|
||||
metadata: {
|
||||
...(user.metadata as Prisma.JsonObject),
|
||||
isPremium: true,
|
||||
},
|
||||
},
|
||||
where: {
|
||||
email: stripeCustomer.metadata.email,
|
||||
id: user.id,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,32 +1,19 @@
|
||||
import { UserPlan } from "@prisma/client";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import Stripe from "stripe";
|
||||
|
||||
import {
|
||||
getPremiumPlanMode,
|
||||
getPremiumPlanPrice,
|
||||
getPremiumPlanProductId,
|
||||
} from "@calcom/app-store/stripepayment/lib/utils";
|
||||
import { getPremiumMonthlyPlanPriceId } from "@calcom/app-store/stripepayment/lib/utils";
|
||||
import { checkPremiumUsername } from "@calcom/features/ee/common/lib/checkPremiumUsername";
|
||||
import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { Prisma } from "@calcom/prisma/client";
|
||||
|
||||
import { PRO_PLAN_PRICE, PRO_PLAN_PRODUCT_ID } from "../lib/constants";
|
||||
import { getStripeCustomerIdFromUserId } from "../lib/customer";
|
||||
import stripe from "../lib/server";
|
||||
|
||||
export enum UsernameChangeStatusEnum {
|
||||
NORMAL = "NORMAL",
|
||||
UPGRADE = "UPGRADE",
|
||||
DOWNGRADE = "DOWNGRADE",
|
||||
}
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method === "GET") {
|
||||
const userId = req.session?.user.id;
|
||||
let { intentUsername = null } = req.query;
|
||||
const { action, callbackUrl } = req.query;
|
||||
const { callbackUrl } = req.query;
|
||||
if (!userId || !intentUsername) {
|
||||
res.status(404).end();
|
||||
return;
|
||||
@@ -49,12 +36,20 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
return;
|
||||
}
|
||||
|
||||
const isCurrentlyPremium = hasKeyInMetadata(userData, "isPremium") && !!userData.metadata.isPremium;
|
||||
|
||||
const return_url = `${process.env.NEXT_PUBLIC_WEBAPP_URL}/api/integrations/stripepayment/paymentCallback?checkoutSessionId={CHECKOUT_SESSION_ID}&callbackUrl=${callbackUrl}`;
|
||||
const createSessionParams: Stripe.BillingPortal.SessionCreateParams = {
|
||||
const createSessionParams: Stripe.Checkout.SessionCreateParams = {
|
||||
mode: "subscription",
|
||||
payment_method_types: ["card"],
|
||||
line_items: [
|
||||
{
|
||||
quantity: 1,
|
||||
price: getPremiumMonthlyPlanPriceId(),
|
||||
},
|
||||
],
|
||||
allow_promotion_codes: true,
|
||||
customer: customerId,
|
||||
return_url,
|
||||
success_url: return_url,
|
||||
cancel_url: return_url,
|
||||
};
|
||||
|
||||
const checkPremiumResult = await checkPremiumUsername(intentUsername);
|
||||
@@ -72,87 +67,21 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
},
|
||||
});
|
||||
|
||||
if (userData && (userData.plan === UserPlan.FREE || userData.plan === UserPlan.TRIAL)) {
|
||||
const subscriptionPrice = checkPremiumResult.premium ? getPremiumPlanPrice() : PRO_PLAN_PRICE;
|
||||
const checkoutSession = await stripe.checkout.sessions.create({
|
||||
mode: getPremiumPlanMode(),
|
||||
payment_method_types: ["card"],
|
||||
customer: customerId,
|
||||
line_items: [
|
||||
{
|
||||
price: subscriptionPrice,
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
success_url: return_url,
|
||||
cancel_url: return_url,
|
||||
allow_promotion_codes: true,
|
||||
});
|
||||
// Save the intentUsername in the metadata
|
||||
if (userData) {
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
metadata: {
|
||||
...(userData.metadata as Prisma.JsonObject),
|
||||
checkoutSessionId: checkoutSession.id,
|
||||
intentUsername,
|
||||
...((userData.metadata as Prisma.JsonObject) || {}),
|
||||
isPremium: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (checkoutSession && checkoutSession.url) {
|
||||
return res.redirect(checkoutSession.url).end();
|
||||
}
|
||||
return res.status(404).json({ message: "Couldn't redirect to stripe checkout session" });
|
||||
}
|
||||
|
||||
if (action && userData) {
|
||||
let actionText = "";
|
||||
const customProductsSession = [];
|
||||
if (action === UsernameChangeStatusEnum.UPGRADE) {
|
||||
actionText = "Upgrade your plan account";
|
||||
if (checkPremiumResult.premium) {
|
||||
customProductsSession.push({ prices: [getPremiumPlanPrice()], product: getPremiumPlanProductId() });
|
||||
} else {
|
||||
customProductsSession.push({ prices: [PRO_PLAN_PRICE], product: PRO_PLAN_PRODUCT_ID });
|
||||
}
|
||||
} else if (action === UsernameChangeStatusEnum.DOWNGRADE) {
|
||||
actionText = "Downgrade your plan account";
|
||||
if (isCurrentlyPremium) {
|
||||
customProductsSession.push({ prices: [PRO_PLAN_PRICE], product: PRO_PLAN_PRODUCT_ID });
|
||||
}
|
||||
}
|
||||
|
||||
const configuration = await stripe.billingPortal.configurations.create({
|
||||
business_profile: {
|
||||
headline: actionText,
|
||||
},
|
||||
features: {
|
||||
payment_method_update: {
|
||||
enabled: true,
|
||||
},
|
||||
subscription_update: {
|
||||
enabled: true,
|
||||
proration_behavior: "always_invoice",
|
||||
default_allowed_updates: ["price"],
|
||||
products: customProductsSession,
|
||||
},
|
||||
},
|
||||
});
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
metadata: {
|
||||
...(userData.metadata as Prisma.JsonObject),
|
||||
intentUsername,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (configuration) {
|
||||
createSessionParams.configuration = configuration.id;
|
||||
}
|
||||
const checkoutSession = await stripe.checkout.sessions.create(createSessionParams);
|
||||
if (checkoutSession && checkoutSession.url) {
|
||||
return res.redirect(checkoutSession.url).end();
|
||||
}
|
||||
const stripeSession = await stripe.billingPortal.sessions.create(createSessionParams);
|
||||
|
||||
res.redirect(stripeSession.url).end();
|
||||
return res.status(404).json({ message: "Couldn't redirect to stripe checkout session" });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
export const FREE_PLAN_PRICE = process.env.NEXT_PUBLIC_STRIPE_FREE_PLAN_PRICE || "";
|
||||
export const PREMIUM_PLAN_PRICE = process.env.NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE || "";
|
||||
export const IS_PREMIUM_NEW_PLAN = process.env.NEXT_PUBLIC_IS_PREMIUM_NEW_PLAN === "1" ? true : false;
|
||||
export const PREMIUM_NEW_PLAN_PRICE = process.env.NEXT_PUBLIC_STRIPE_PREMIUM_NEW_PLAN_PRICE || "";
|
||||
export const PRO_PLAN_PRICE = process.env.NEXT_PUBLIC_STRIPE_PRO_PLAN_PRICE || "";
|
||||
export const FREE_PLAN_PRODUCT_ID = process.env.STRIPE_FREE_PLAN_PRODUCT_ID || "";
|
||||
export const PRO_PLAN_PRODUCT_ID = process.env.STRIPE_PRO_PLAN_PRODUCT_ID || "";
|
||||
export const PREMIUM_PLAN_PRODUCT_ID = process.env.STRIPE_PREMIUM_PLAN_PRODUCT_ID || "";
|
||||
export const PREMIUM_MONTHLY_PLAN_PRICE = process.env.NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE_MONTHLY || "";
|
||||
export const PREMIUM_PLAN_PRODUCT_ID = process.env.NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRODUCT_ID || "";
|
||||
export const STRIPE_TEAM_MONTHLY_PRICE_ID = process.env.NEXT_PUBLIC_STRIPE_TEAM_MONTHLY_PRICE_ID || "";
|
||||
|
||||
@@ -6,11 +6,12 @@ import dayjs from "@calcom/dayjs";
|
||||
import { TRIAL_LIMIT_DAYS } from "@calcom/lib/constants";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
// import { isPremiumUserName } from "../../apps/website/lib/username";
|
||||
import { getStripeCustomerIdFromUserId } from "./customer";
|
||||
import stripe from "./server";
|
||||
import { getPremiumPlanPrice, getProPlanPrice, getProPlanProduct } from "./utils";
|
||||
|
||||
/**
|
||||
* Deprecated or should be updated
|
||||
*/
|
||||
export async function downgradeIllegalProUsers() {
|
||||
const illegalProUsers = await prisma.user.findMany({
|
||||
where: {
|
||||
@@ -65,14 +66,6 @@ export async function downgradeIllegalProUsers() {
|
||||
continue;
|
||||
}
|
||||
|
||||
const hasProPlan = !!subscription.items.data.find(
|
||||
(item) =>
|
||||
item.plan.product === getProPlanProduct() ||
|
||||
[getProPlanPrice(), getPremiumPlanPrice()].includes(item.plan.id)
|
||||
);
|
||||
// if they're pro, do not downgrade
|
||||
if (hasProPlan) continue;
|
||||
|
||||
// If they already have a premium username, do not downgrade
|
||||
// if (suspectUser.username && isPremiumUserName(suspectUser.username)) continue;
|
||||
|
||||
|
||||
@@ -6,19 +6,19 @@ export async function getCustomerAndCheckoutSession(checkoutSessionId: string) {
|
||||
let customerId = null;
|
||||
|
||||
if (!customerOrCustomerId) {
|
||||
return { checkoutSession, customer: null };
|
||||
return { checkoutSession, stripeCustomer: null };
|
||||
}
|
||||
|
||||
if (typeof customerOrCustomerId === "string") {
|
||||
customerId = customerOrCustomerId;
|
||||
} else if (customerOrCustomerId.deleted) {
|
||||
return { checkoutSession, customer: null };
|
||||
return { checkoutSession, stripeCustomer: null };
|
||||
} else {
|
||||
customerId = customerOrCustomerId.id;
|
||||
}
|
||||
const stripeCustomer = await stripe.customers.retrieve(customerId);
|
||||
if (stripeCustomer.deleted) {
|
||||
return { checkoutSession, customer: null };
|
||||
return { checkoutSession, stripeCustomer: null };
|
||||
}
|
||||
return { stripeCustomer, checkoutSession };
|
||||
}
|
||||
|
||||
@@ -1,15 +1,4 @@
|
||||
import { UserPlan } from "@prisma/client";
|
||||
import Stripe from "stripe";
|
||||
|
||||
import stripe from "./server";
|
||||
import {
|
||||
getFreePlanPrice,
|
||||
getPremiumPlanPrice,
|
||||
getProPlanPrice,
|
||||
getFreePlanProductId,
|
||||
getPremiumPlanProductId,
|
||||
getProPlanProductId,
|
||||
} from "./utils";
|
||||
|
||||
interface IRetrieveSubscriptionIdResponse {
|
||||
message?: string;
|
||||
@@ -38,35 +27,3 @@ export async function retrieveSubscriptionIdFromStripeCustomerId(
|
||||
subscriptionId: subscription.id,
|
||||
};
|
||||
}
|
||||
|
||||
// @NOTE: Remove when user subscription plan id is saved on db and not on stripe only
|
||||
export function obtainUserPlanDetails(subscription: Stripe.Subscription) {
|
||||
const proPlanProductId = getProPlanProductId();
|
||||
const premiumPlanProductId = getPremiumPlanProductId();
|
||||
const freePlanProductId = getFreePlanProductId();
|
||||
let priceId = "";
|
||||
const hasProPlan = !!subscription.items.data.find((item) => item.plan.product === proPlanProductId);
|
||||
const hasPremiumPlan = !!subscription.items.data.find((item) => item.plan.product === premiumPlanProductId);
|
||||
const hasFreePlan = !!subscription.items.data.find((item) => item.plan.product === freePlanProductId);
|
||||
let userPlan: UserPlan;
|
||||
if (hasPremiumPlan) {
|
||||
priceId = getPremiumPlanPrice();
|
||||
userPlan = UserPlan.PRO;
|
||||
} else if (hasProPlan) {
|
||||
priceId = getProPlanPrice();
|
||||
userPlan = UserPlan.PRO;
|
||||
} else if (hasFreePlan) {
|
||||
priceId = getFreePlanPrice();
|
||||
userPlan = UserPlan.FREE;
|
||||
} else {
|
||||
userPlan = UserPlan.TRIAL;
|
||||
}
|
||||
|
||||
return {
|
||||
userPlan,
|
||||
priceId,
|
||||
isProPlan: hasProPlan,
|
||||
isPremiumPlan: hasPremiumPlan,
|
||||
isFreePlan: hasFreePlan,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,238 +1,4 @@
|
||||
import { MembershipRole, Prisma, UserPlan } from "@prisma/client";
|
||||
import { PrismaClientKnownRequestError } from "@prisma/client/runtime";
|
||||
import Stripe from "stripe";
|
||||
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import { getStripeCustomerIdFromUserId } from "./customer";
|
||||
import stripe from "./server";
|
||||
import { getPerSeatProPlanPrice, getPremiumPlanPrice, getProPlanPrice } from "./utils";
|
||||
|
||||
// get team owner's Pro Plan subscription from Cal userId
|
||||
export async function getProPlanSubscription(userId: number) {
|
||||
const stripeCustomerId = await getStripeCustomerIdFromUserId(userId);
|
||||
if (!stripeCustomerId) return null;
|
||||
|
||||
const customer = await stripe.customers.retrieve(stripeCustomerId, {
|
||||
expand: ["subscriptions.data.plan"],
|
||||
});
|
||||
if (customer.deleted) throw new HttpError({ statusCode: 404, message: "Stripe customer not found" });
|
||||
// get the first subscription item which is the Pro Plan TODO: change to find()
|
||||
return customer.subscriptions?.data[0];
|
||||
}
|
||||
|
||||
async function getMembersMissingSeats(teamId: number) {
|
||||
const members = await prisma.membership.findMany({
|
||||
where: { teamId },
|
||||
select: { role: true, accepted: true, user: { select: { id: true, plan: true, metadata: true } } },
|
||||
});
|
||||
// any member that is not Pro is missing a seat excluding the owner
|
||||
const membersMissingSeats = members.filter(
|
||||
(m) => m.role !== MembershipRole.OWNER || m.user.plan !== UserPlan.PRO
|
||||
);
|
||||
// as owner's billing is handled by a different Price, we count this separately
|
||||
const ownerIsMissingSeat = !!members.find(
|
||||
(m) => m.role === MembershipRole.OWNER && m.user.plan === UserPlan.FREE
|
||||
);
|
||||
return {
|
||||
members,
|
||||
membersMissingSeats,
|
||||
ownerIsMissingSeat,
|
||||
};
|
||||
}
|
||||
|
||||
async function updatePerSeatQuantity(subscription: Stripe.Subscription, quantity: number) {
|
||||
const perSeatProPlan = subscription.items.data.find((item) => item.plan.id === getPerSeatProPlanPrice());
|
||||
// if their subscription does not contain Per Seat Pro, add it—otherwise, update the existing one
|
||||
return await stripe.subscriptions.update(subscription.id, {
|
||||
items: [
|
||||
perSeatProPlan ? { id: perSeatProPlan.id, quantity } : { plan: getPerSeatProPlanPrice(), quantity },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// called by the team owner when they are ready to upgrade their team to Per Seat Pro
|
||||
// if user has no subscription, this will be called again after successful stripe checkout callback, with subscription now present
|
||||
export async function upgradeTeam(userId: number, teamId: number) {
|
||||
const ownerUser = await prisma.membership.findFirst({
|
||||
where: { userId, teamId },
|
||||
select: { role: true, user: true },
|
||||
});
|
||||
|
||||
if (ownerUser?.role !== MembershipRole.OWNER)
|
||||
throw new HttpError({ statusCode: 400, message: "User is not an owner" });
|
||||
|
||||
const subscription = await getProPlanSubscription(userId);
|
||||
const { membersMissingSeats, ownerIsMissingSeat } = await getMembersMissingSeats(teamId);
|
||||
|
||||
if (!subscription) {
|
||||
let customerId = await getStripeCustomerIdFromUserId(userId);
|
||||
if (!customerId) {
|
||||
// create stripe customer if it doesn't already exist
|
||||
const res = await stripe.customers.create({
|
||||
email: ownerUser.user.email,
|
||||
});
|
||||
customerId = res.id;
|
||||
}
|
||||
// create a checkout session with the quantity of missing seats
|
||||
const session = await createCheckoutSession(
|
||||
customerId,
|
||||
membersMissingSeats.length,
|
||||
teamId,
|
||||
ownerIsMissingSeat
|
||||
);
|
||||
// return checkout session url for redirect
|
||||
return { url: session.url };
|
||||
}
|
||||
|
||||
// if the owner has a subscription but does not have an individual Pro account
|
||||
if (ownerIsMissingSeat) {
|
||||
const ownerHasProPlan = !!subscription.items.data.find(
|
||||
(item) => item.plan.id === getProPlanPrice() || item.plan.id === getPremiumPlanPrice()
|
||||
);
|
||||
if (!ownerHasProPlan)
|
||||
await stripe.subscriptions.update(subscription.id, {
|
||||
items: [
|
||||
{
|
||||
price: getProPlanPrice(),
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { plan: UserPlan.PRO },
|
||||
});
|
||||
}
|
||||
|
||||
// update the subscription with Stripe
|
||||
await updatePerSeatQuantity(subscription, membersMissingSeats.length);
|
||||
|
||||
// loop through all members and update their account to Pro
|
||||
for (const member of membersMissingSeats) {
|
||||
await prisma.user.update({
|
||||
where: { id: member.user.id },
|
||||
data: {
|
||||
plan: UserPlan.PRO,
|
||||
// declare which team is sponsoring their Pro membership
|
||||
metadata: { proPaidForByTeamId: teamId, ...((member.user.metadata as Prisma.JsonObject) ?? {}) },
|
||||
},
|
||||
});
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// shared logic for add/removing members, called on member invite and member removal/leave
|
||||
async function addOrRemoveSeat(remove: boolean, userId: number, teamId: number, memberUserId?: number) {
|
||||
console.log(remove ? "removing member" : "adding member", { userId, teamId, memberUserId });
|
||||
|
||||
const subscription = await getProPlanSubscription(userId);
|
||||
if (!subscription) return;
|
||||
|
||||
// get the per seat plan from the subscription
|
||||
const perSeatProPlanPrice = subscription?.items.data.find(
|
||||
(item) => item.plan.id === getPerSeatProPlanPrice()
|
||||
);
|
||||
|
||||
// find the member's local user account
|
||||
const memberUser = await prisma.user.findUnique({
|
||||
where: { id: memberUserId },
|
||||
select: { id: true, plan: true, metadata: true },
|
||||
});
|
||||
// in the rare event there is no account, return
|
||||
if (!memberUser) return;
|
||||
|
||||
// check if this user is paying for their own Pro account, if so return.
|
||||
const memberSubscription = await getProPlanSubscription(memberUser.id);
|
||||
const proPlanPrice = memberSubscription?.items.data.find((item) => item.plan.id === getProPlanPrice());
|
||||
if (proPlanPrice) return;
|
||||
|
||||
// takes care of either adding per seat pricing, or updating the existing one's quantity
|
||||
await updatePerSeatQuantity(
|
||||
subscription,
|
||||
remove ? (perSeatProPlanPrice?.quantity ?? 1) - 1 : (perSeatProPlanPrice?.quantity ?? 0) + 1
|
||||
);
|
||||
|
||||
// add or remove proPaidForByTeamId from metadata
|
||||
const metadata: Record<string, unknown> = {
|
||||
proPaidForByTeamId: teamId,
|
||||
...((memberUser.metadata as Prisma.JsonObject) ?? {}),
|
||||
};
|
||||
// entirely remove property if removing member from team and proPaidForByTeamId is this team
|
||||
if (remove && metadata.proPaidForByTeamId === teamId) delete metadata.proPaidForByTeamId;
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: memberUserId },
|
||||
data: { plan: remove ? UserPlan.FREE : UserPlan.PRO, metadata: metadata as Prisma.JsonObject },
|
||||
});
|
||||
}
|
||||
|
||||
// aliased helpers for more verbose usage
|
||||
export async function addSeat(userId: number, teamId: number, memberUserId?: number) {
|
||||
return await addOrRemoveSeat(false, userId, teamId, memberUserId);
|
||||
}
|
||||
|
||||
export async function removeSeat(userId: number, teamId: number, memberUserId?: number) {
|
||||
return await addOrRemoveSeat(true, userId, teamId, memberUserId);
|
||||
}
|
||||
|
||||
// if a team has failed to pay for the pro plan, downgrade all team members to free
|
||||
export async function downgradeTeamMembers(teamId: number) {
|
||||
const members = await prisma.membership.findMany({
|
||||
where: { teamId, user: { plan: UserPlan.PRO } },
|
||||
select: { role: true, accepted: true, user: { select: { id: true, plan: true, metadata: true } } },
|
||||
});
|
||||
|
||||
for (const member of members) {
|
||||
// skip if user had their own Pro subscription
|
||||
const subscription = await getProPlanSubscription(member.user.id);
|
||||
if (subscription?.items.data.length) continue;
|
||||
|
||||
// skip if Pro is paid for by another team
|
||||
const metadata = (member.user.metadata as Prisma.JsonObject) ?? {};
|
||||
if (metadata.proPaidForByTeamId !== teamId) continue;
|
||||
|
||||
// downgrade only if their pro plan was paid for by this team
|
||||
delete metadata.proPaidForByTeamId;
|
||||
await prisma.user.update({
|
||||
where: { id: member.user.id },
|
||||
data: { plan: UserPlan.FREE, metadata },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function createCheckoutSession(
|
||||
customerId: string,
|
||||
quantity: number,
|
||||
teamId: number,
|
||||
includeBaseProPlan?: boolean
|
||||
) {
|
||||
// if the user is missing the base plan, we should include it agnostic of the seat quantity
|
||||
const line_items: Stripe.Checkout.SessionCreateParams["line_items"] =
|
||||
quantity === 0
|
||||
? []
|
||||
: [
|
||||
{
|
||||
price: getPerSeatProPlanPrice(),
|
||||
quantity: quantity ?? 1,
|
||||
},
|
||||
];
|
||||
if (includeBaseProPlan) line_items.push({ price: getProPlanPrice(), quantity: 1 });
|
||||
|
||||
const params: Stripe.Checkout.SessionCreateParams = {
|
||||
mode: "subscription",
|
||||
payment_method_types: ["card"],
|
||||
customer: customerId,
|
||||
line_items,
|
||||
success_url: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/api/teams/${teamId}/upgrade?session_id={CHECKOUT_SESSION_ID}`,
|
||||
cancel_url: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/`,
|
||||
allow_promotion_codes: true,
|
||||
};
|
||||
|
||||
return await stripe.checkout.sessions.create(params);
|
||||
}
|
||||
|
||||
export function getRequestedSlugError(error: unknown, requestedSlug: string) {
|
||||
let message = `Unknown error`;
|
||||
|
||||
@@ -1,46 +1,22 @@
|
||||
// @NOTE: should we remove this? It's just a wrapper of env vars
|
||||
import {
|
||||
FREE_PLAN_PRICE,
|
||||
FREE_PLAN_PRODUCT_ID,
|
||||
PREMIUM_PLAN_PRICE,
|
||||
PREMIUM_PLAN_PRODUCT_ID,
|
||||
PRO_PLAN_PRICE,
|
||||
PREMIUM_NEW_PLAN_PRICE,
|
||||
IS_PREMIUM_NEW_PLAN,
|
||||
PRO_PLAN_PRODUCT_ID,
|
||||
STRIPE_TEAM_MONTHLY_PRICE_ID,
|
||||
PREMIUM_MONTHLY_PLAN_PRICE,
|
||||
} from "./constants";
|
||||
|
||||
export function getPremiumPlanMode() {
|
||||
return IS_PREMIUM_NEW_PLAN ? "payment" : "subscription";
|
||||
}
|
||||
|
||||
export function getPremiumPlanPriceValue() {
|
||||
return IS_PREMIUM_NEW_PLAN ? "$499" : "$29/mo";
|
||||
}
|
||||
|
||||
export function getPremiumPlanPrice(): string {
|
||||
return IS_PREMIUM_NEW_PLAN ? PREMIUM_NEW_PLAN_PRICE : PREMIUM_PLAN_PRICE;
|
||||
}
|
||||
|
||||
export function getPerSeatProPlanPrice(): string {
|
||||
return PRO_PLAN_PRICE;
|
||||
}
|
||||
|
||||
export function getProPlanPrice(): string {
|
||||
return PRO_PLAN_PRICE;
|
||||
}
|
||||
|
||||
export function getFreePlanPrice(): string {
|
||||
return FREE_PLAN_PRICE;
|
||||
}
|
||||
|
||||
export function getProPlanProductId(): string {
|
||||
return PRO_PLAN_PRODUCT_ID;
|
||||
}
|
||||
export const getPremiumMonthlyPlanPriceId = (): string => {
|
||||
return PREMIUM_MONTHLY_PLAN_PRICE;
|
||||
};
|
||||
|
||||
export function getPremiumPlanProductId(): string {
|
||||
return PREMIUM_PLAN_PRODUCT_ID;
|
||||
}
|
||||
|
||||
export function getFreePlanProductId(): string {
|
||||
return FREE_PLAN_PRODUCT_ID;
|
||||
export function getPerSeatPlanPrice(): string {
|
||||
return STRIPE_TEAM_MONTHLY_PRICE_ID;
|
||||
}
|
||||
|
||||
export function getPremiumPlanPriceValue() {
|
||||
return "$29/mo";
|
||||
}
|
||||
|
||||
@@ -207,8 +207,6 @@ export const userMetadata = z
|
||||
stripeCustomerId: z.string().optional(),
|
||||
vitalSettings: vitalSettingsUpdateSchema.optional(),
|
||||
isPremium: z.boolean().optional(),
|
||||
intentUsername: z.string().optional(),
|
||||
checkoutSessionId: z.string().nullable().optional(),
|
||||
})
|
||||
.nullable();
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import ethRouter from "@calcom/app-store/rainbow/trpc/router";
|
||||
import { deleteStripeCustomer } from "@calcom/app-store/stripepayment/lib/customer";
|
||||
import { getCustomerAndCheckoutSession } from "@calcom/app-store/stripepayment/lib/getCustomerAndCheckoutSession";
|
||||
import stripe, { closePayments } from "@calcom/app-store/stripepayment/lib/server";
|
||||
import { getPremiumPlanProductId } from "@calcom/app-store/stripepayment/lib/utils";
|
||||
import getApps, { getLocationGroupedOptions } from "@calcom/app-store/utils";
|
||||
import { cancelScheduledJobs } from "@calcom/app-store/zapier/lib/nodeScheduler";
|
||||
import { getCalendarCredentials, getConnectedCalendars } from "@calcom/core/CalendarManager";
|
||||
@@ -515,19 +516,22 @@ const loggedInViewerRouter = router({
|
||||
}
|
||||
|
||||
const metadata = userMetadata.parse(user.metadata);
|
||||
const checkoutSessionId = metadata?.checkoutSessionId;
|
||||
//TODO: Rename checkoutSessionId to premiumUsernameCheckoutSessionId
|
||||
if (!checkoutSessionId) return { isPremium: false };
|
||||
|
||||
const { stripeCustomer, checkoutSession } = await getCustomerAndCheckoutSession(checkoutSessionId);
|
||||
if (!stripeCustomer) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Stripe User not found" });
|
||||
if (!metadata?.stripeCustomerId) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "No stripe customer id" });
|
||||
}
|
||||
// Fetch stripe customer
|
||||
const stripeCustomerId = metadata?.stripeCustomerId;
|
||||
const customer = await stripe.customers.retrieve(stripeCustomerId);
|
||||
if (customer.deleted) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "No stripe customer found" });
|
||||
}
|
||||
|
||||
const username = customer?.metadata?.username || null;
|
||||
|
||||
return {
|
||||
isPremium: true,
|
||||
paidForPremium: checkoutSession.payment_status === "paid",
|
||||
username: stripeCustomer.metadata.username,
|
||||
isPremium: !!metadata?.isPremium,
|
||||
username,
|
||||
};
|
||||
}),
|
||||
updateProfile: authedProcedure
|
||||
@@ -582,25 +586,37 @@ const loggedInViewerRouter = router({
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "User not found" });
|
||||
}
|
||||
const metadata = userMetadata.parse(userToUpdate.metadata);
|
||||
// Checking the status of payment directly from stripe allows to avoid the situation where the user has got the refund or maybe something else happened asyncly at stripe but our DB thinks it's still paid for
|
||||
// TODO: Test the case where one time payment is refunded.
|
||||
const premiumUsernameCheckoutSessionId = metadata?.checkoutSessionId;
|
||||
|
||||
const isPremium = metadata?.isPremium;
|
||||
if (isPremiumUsername) {
|
||||
// You can't have premium username without every going to a checkout session
|
||||
if (!premiumUsernameCheckoutSessionId) {
|
||||
const stripeCustomerId = metadata?.stripeCustomerId;
|
||||
if (!isPremium || !stripeCustomerId) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "User is not premium" });
|
||||
}
|
||||
|
||||
const stripeSubscriptions = await stripe.subscriptions.list({ customer: stripeCustomerId });
|
||||
|
||||
if (!stripeSubscriptions || !stripeSubscriptions.data.length) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "No stripeSubscription found",
|
||||
});
|
||||
}
|
||||
|
||||
// Iterate over subscriptions and look for premium product id and status active
|
||||
// @TODO: iterate if stripeSubscriptions.hasMore is true
|
||||
const isPremiumUsernameSubscriptionActive = stripeSubscriptions.data.some(
|
||||
(subscription) =>
|
||||
subscription.items.data[0].price.product === getPremiumPlanProductId() &&
|
||||
subscription.status === "active"
|
||||
);
|
||||
|
||||
if (!isPremiumUsernameSubscriptionActive) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "You need to pay for premium username",
|
||||
});
|
||||
}
|
||||
const checkoutSession = await stripe.checkout.sessions.retrieve(premiumUsernameCheckoutSessionId);
|
||||
const canUserHavePremiumUsername = checkoutSession.payment_status == "paid";
|
||||
if (!canUserHavePremiumUsername) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Your last checkout session for premium username is not paid",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const updatedUser = await prisma.user.update({
|
||||
|
||||
@@ -2,7 +2,7 @@ import { MembershipRole, Prisma, UserPlan } from "@prisma/client";
|
||||
import { randomBytes } from "crypto";
|
||||
import { z } from "zod";
|
||||
|
||||
import { addSeat, getRequestedSlugError, removeSeat } from "@calcom/app-store/stripepayment/lib/team-billing";
|
||||
import { getRequestedSlugError } from "@calcom/app-store/stripepayment/lib/team-billing";
|
||||
import { getUserAvailability } from "@calcom/core/getUserAvailability";
|
||||
import { sendTeamInviteEmail } from "@calcom/emails";
|
||||
import {
|
||||
@@ -244,8 +244,8 @@ export const viewerTeamsRouter = router({
|
||||
|
||||
// Sync Services
|
||||
closeComDeleteTeamMembership(membership.user);
|
||||
|
||||
if (HOSTED_CAL_FEATURES) await removeSeat(ctx.user.id, input.teamId, input.memberId);
|
||||
// @TODO: Update with new logic
|
||||
// if (HOSTED_CAL_FEATURES) await removeSeat(ctx.user.id, input.teamId, input.memberId);
|
||||
}),
|
||||
inviteMember: authedProcedure
|
||||
.input(
|
||||
@@ -356,11 +356,13 @@ export const viewerTeamsRouter = router({
|
||||
});
|
||||
}
|
||||
}
|
||||
try {
|
||||
if (HOSTED_CAL_FEATURES) await addSeat(ctx.user.id, team.id, inviteeUserId);
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
|
||||
// @TODO: Update with new logic
|
||||
// try {
|
||||
// if (HOSTED_CAL_FEATURES) await addSeat(ctx.user.id, team.id, inviteeUserId);
|
||||
// } catch (e) {
|
||||
// console.log(e);
|
||||
// }
|
||||
}),
|
||||
acceptOrLeave: authedProcedure
|
||||
.input(
|
||||
@@ -393,7 +395,8 @@ export const viewerTeamsRouter = router({
|
||||
});
|
||||
|
||||
// TODO: disable if not hosted by Cal
|
||||
if (teamOwner) await removeSeat(teamOwner.userId, input.teamId, ctx.user.id);
|
||||
// @TODO: Update with new logic
|
||||
// if (teamOwner) await removeSeat(teamOwner.userId, input.teamId, ctx.user.id);
|
||||
|
||||
const membership = await ctx.prisma.membership.delete({
|
||||
where: {
|
||||
@@ -633,7 +636,7 @@ export const viewerTeamsRouter = router({
|
||||
|
||||
return {
|
||||
url: `${WEBAPP_URL}/settings/teams/${updatedTeam.id}/profile`,
|
||||
message: "Team published succesfully",
|
||||
message: "Team published successfully",
|
||||
};
|
||||
}),
|
||||
/** This is a temporal endpoint so we can progressively upgrade teams to the new billing system. */
|
||||
|
||||
+3
-7
@@ -31,14 +31,10 @@
|
||||
"^build",
|
||||
"$NEXT_PUBLIC_IS_E2E",
|
||||
"$NEXT_PUBLIC_LICENSE_CONSENT",
|
||||
"$NEXT_PUBLIC_STRIPE_FREE_PLAN_PRICE",
|
||||
"$NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE",
|
||||
"$NEXT_PUBLIC_STRIPE_PREMIUM_NEW_PLAN_PRICE",
|
||||
"$NEXT_PUBLIC_STRIPE_PRO_PLAN_PRICE",
|
||||
"$NEXT_PUBLIC_IS_PREMIUM_NEW_PLAN",
|
||||
"$STRIPE_PRO_PLAN_PRODUCT_ID",
|
||||
"$NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE_MONTHLY",
|
||||
"$NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRODUCT_ID",
|
||||
"$NEXT_PUBLIC_STRIPE_TEAM_MONTHLY_PRICE_ID",
|
||||
"$STRIPE_PREMIUM_PLAN_PRODUCT_ID",
|
||||
"$STRIPE_FREE_PLAN_PRODUCT_ID",
|
||||
"$STRIPE_TEAM_MONTHLY_PRICE_ID",
|
||||
"$NEXT_PUBLIC_STRIPE_PUBLIC_KEY",
|
||||
"$NEXT_PUBLIC_WEBAPP_URL",
|
||||
|
||||
Reference in New Issue
Block a user