import { MembershipRole, Prisma, UserPlan } from "@prisma/client"; import Stripe from "stripe"; import { HOSTED_CAL_FEATURES } from "@calcom/lib/constants"; import { HttpError } from "@calcom/lib/http-error"; import prisma from "@calcom/prisma"; import stripe from "@calcom/stripe/server"; import { getStripeCustomerIdFromUserId } from "./customer"; 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, }; } // a helper for the upgrade dialog export async function getTeamSeatStats(teamId: number) { const { membersMissingSeats, members, ownerIsMissingSeat } = await getMembersMissingSeats(teamId); return { totalMembers: members.length, // members we need not pay for freeSeats: members.length - membersMissingSeats.length, // members we need to pay for (if not hosted cal, team billing is disabled) missingSeats: HOSTED_CAL_FEATURES ? membersMissingSeats.length : 0, // members who have been hidden from view hiddenMembers: members.filter((m) => m.user.plan === UserPlan.FREE).length, ownerIsMissingSeat: HOSTED_CAL_FEATURES ? ownerIsMissingSeat : false, }; } 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 = { 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); } // verifies that the subscription's quantity is correct for the number of members the team has // this is a function is a dev util, but could be utilized as a sync technique in the future export async function ensureSubscriptionQuantityCorrectness(userId: number, teamId: number) { const subscription = await getProPlanSubscription(userId); const stripeQuantity = subscription?.items.data.find((item) => item.plan.id === getPerSeatProPlanPrice())?.quantity ?? 0; const { membersMissingSeats } = await getMembersMissingSeats(teamId); // correct the quantity if missing seats is out of sync with subscription quantity if (subscription && membersMissingSeats.length !== stripeQuantity) { await updatePerSeatQuantity(subscription, membersMissingSeats.length); } }