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:
alannnc
2022-12-07 12:55:47 -07:00
committed by GitHub
co-authored by Alex van Andel kodiakhq[bot] Peer Richelsen
parent 5360cea3bf
commit 6e351a4b20
17 changed files with 142 additions and 644 deletions
@@ -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}
-68
View File
@@ -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" });
}
+2 -2
View File
@@ -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,
},
],
+5 -67
View File
@@ -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`;
+12 -36
View File
@@ -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";
}
-2
View File
@@ -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();
+38 -22
View File
@@ -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({
+13 -10
View File
@@ -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
View File
@@ -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",