import type Stripe from "stripe"; import { z } from "zod"; import { getStripeCustomerIdFromUserId } from "@calcom/app-store/stripepayment/lib/customer"; import stripe from "@calcom/app-store/stripepayment/lib/server"; import { IS_PRODUCTION, MINIMUM_NUMBER_OF_ORG_SEATS, WEBAPP_URL } from "@calcom/lib/constants"; import logger from "@calcom/lib/logger"; import { safeStringify } from "@calcom/lib/safeStringify"; import prisma from "@calcom/prisma"; import { BillingPeriod } from "@calcom/prisma/zod-utils"; import { teamMetadataSchema } from "@calcom/prisma/zod-utils"; const log = logger.getSubLogger({ prefix: ["teams/lib/payments"] }); const teamPaymentMetadataSchema = z.object({ // Redefine paymentId, subscriptionId and subscriptionItemId to ensure that they are present and nonNullable paymentId: z.string(), subscriptionId: z.string(), subscriptionItemId: z.string(), orgSeats: teamMetadataSchema.unwrap().shape.orgSeats, }); /** Used to prevent double charges for the same team */ export const checkIfTeamPaymentRequired = async ({ teamId = -1 }) => { const team = await prisma.team.findUniqueOrThrow({ where: { id: teamId }, select: { metadata: true }, }); const metadata = teamMetadataSchema.parse(team.metadata); /** If there's no paymentId, we need to pay this team */ if (!metadata?.paymentId) return { url: null }; const checkoutSession = await stripe.checkout.sessions.retrieve(metadata.paymentId); /** If there's a pending session but it isn't paid, we need to pay this team */ if (checkoutSession.payment_status !== "paid") return { url: null }; /** If the session is already paid we return the upgrade URL so team is updated. */ return { url: `${WEBAPP_URL}/api/teams/${teamId}/upgrade?session_id=${metadata.paymentId}` }; }; /** * Used to generate a checkout session when trying to create a team */ export const generateTeamCheckoutSession = async ({ teamName, teamSlug, userId, }: { teamName: string; teamSlug: string; userId: number; }) => { const customer = await getStripeCustomerIdFromUserId(userId); const session = await stripe.checkout.sessions.create({ customer, mode: "subscription", allow_promotion_codes: true, success_url: `${WEBAPP_URL}/api/teams/create?session_id={CHECKOUT_SESSION_ID}`, cancel_url: `${WEBAPP_URL}/settings/my-account/profile`, line_items: [ { /** We only need to set the base price and we can upsell it directly on Stripe's checkout */ price: process.env.STRIPE_TEAM_MONTHLY_PRICE_ID, /**Initially it will be just the team owner */ quantity: 1, }, ], customer_update: { address: "auto", }, // Disabled when testing locally as usually developer doesn't setup Tax in Stripe Test mode automatic_tax: { enabled: IS_PRODUCTION, }, metadata: { teamName, teamSlug, userId, }, }); return session; }; /** * Used to generate a checkout session when creating a new org (parent team) or backwards compatibility for old teams */ export const purchaseTeamOrOrgSubscription = async (input: { teamId: number; /** * The actual number of seats in the team. * The seats that we would charge for could be more than this depending on the MINIMUM_NUMBER_OF_ORG_SEATS in case of an organization * For a team it would be the same as this value */ seatsUsed: number; /** * If provided, this is the exact number we would charge for. */ seatsToChargeFor?: number | null; userId: number; isOrg?: boolean; pricePerSeat: number | null; billingPeriod?: BillingPeriod; }) => { const { teamId, seatsToChargeFor, seatsUsed, userId, isOrg, pricePerSeat, billingPeriod = BillingPeriod.MONTHLY, } = input; const { url } = await checkIfTeamPaymentRequired({ teamId }); if (url) return { url }; // For orgs, enforce minimum of MINIMUM_NUMBER_OF_ORG_SEATS seats if `seatsToChargeFor` not set const seats = isOrg ? Math.max(seatsUsed, MINIMUM_NUMBER_OF_ORG_SEATS) : seatsUsed; const quantity = seatsToChargeFor ? seatsToChargeFor : seats; const customer = await getStripeCustomerIdFromUserId(userId); const fixedPrice = await getFixedPrice(); let priceId: string | undefined; if (pricePerSeat) { const customPriceObj = await getPriceObject(fixedPrice); priceId = await createPrice({ isOrg: !!isOrg, teamId, pricePerSeat, billingPeriod, product: customPriceObj.product as string, // We don't expand the object from stripe so just use the product as ID currency: customPriceObj.currency, }); } else { priceId = fixedPrice as string; } const session = await stripe.checkout.sessions.create({ customer, mode: "subscription", allow_promotion_codes: true, success_url: `${WEBAPP_URL}/api/teams/${teamId}/upgrade?session_id={CHECKOUT_SESSION_ID}`, cancel_url: `${WEBAPP_URL}/settings/my-account/profile`, line_items: [ { price: priceId, quantity: quantity, }, ], customer_update: { address: "auto", }, // Disabled when testing locally as usually developer doesn't setup Tax in Stripe Test mode automatic_tax: { enabled: IS_PRODUCTION, }, metadata: { teamId, }, subscription_data: { metadata: { teamId, }, }, }); return { url: session.url }; async function createPrice({ isOrg, teamId, pricePerSeat, billingPeriod, product, currency, }: { isOrg: boolean; teamId: number; pricePerSeat: number; billingPeriod: BillingPeriod; product: Stripe.Product | string; currency: string; }) { try { const pricePerSeatInCents = pricePerSeat * 100; // Price comes in monthly so we need to convert it to a monthly/yearly price const occurrence = billingPeriod === "MONTHLY" ? 1 : 12; const yearlyPrice = pricePerSeatInCents * occurrence; const customPriceObj = await stripe.prices.create({ nickname: `Custom price for ${isOrg ? "Organization" : "Team"} ID: ${teamId}`, unit_amount: yearlyPrice, // Stripe expects the amount in cents // Use the same currency as in the fixed price to avoid hardcoding it. currency: currency, recurring: { interval: billingPeriod === "MONTHLY" ? "month" : "year" }, // Define your subscription interval product: typeof product === "string" ? product : product.id, tax_behavior: "exclusive", }); return customPriceObj.id; } catch (e) { log.error( `Error creating custom price for ${isOrg ? "Organization" : "Team"} ID: ${teamId}`, safeStringify(e) ); throw new Error("Error in creation of custom price"); } } /** * Determines the priceId depending on if a custom price is required or not. * If the organization has a custom price per seat, it will create a new price in stripe and return its ID. */ async function getFixedPrice() { const fixedPriceId = isOrg ? process.env.STRIPE_ORG_MONTHLY_PRICE_ID : process.env.STRIPE_TEAM_MONTHLY_PRICE_ID; if (!fixedPriceId) { throw new Error( "You need to have STRIPE_ORG_MONTHLY_PRICE_ID and STRIPE_TEAM_MONTHLY_PRICE_ID env variables set" ); } log.debug("Getting price ID", safeStringify({ fixedPriceId, isOrg, teamId, pricePerSeat })); return fixedPriceId; } }; async function getPriceObject(priceId: string) { const priceObj = await stripe.prices.retrieve(priceId); if (!priceObj) throw new Error(`No price found for ID ${priceId}`); return priceObj; } export const getTeamWithPaymentMetadata = async (teamId: number) => { const team = await prisma.team.findUniqueOrThrow({ where: { id: teamId }, select: { metadata: true, members: true, isOrganization: true }, }); const metadata = teamPaymentMetadataSchema.parse(team.metadata); return { ...team, metadata }; }; export const cancelTeamSubscriptionFromStripe = async (teamId: number) => { try { const team = await getTeamWithPaymentMetadata(teamId); const { subscriptionId } = team.metadata; return await stripe.subscriptions.cancel(subscriptionId); } catch (error) { let message = "Unknown error on cancelTeamSubscriptionFromStripe"; if (error instanceof Error) message = error.message; console.error(message); } }; export const updateQuantitySubscriptionFromStripe = async (teamId: number) => { try { const { url } = await checkIfTeamPaymentRequired({ teamId }); /** * If there's no pending checkout URL it means that this team has not been paid. * We cannot update the subscription yet, this will be handled on publish/checkout. **/ if (!url) return; const team = await getTeamWithPaymentMetadata(teamId); const { subscriptionId, subscriptionItemId, orgSeats } = team.metadata; // Either it would be custom pricing where minimum number of seats are changed(available in orgSeats) or it would be default MINIMUM_NUMBER_OF_ORG_SEATS // We can't go below this quantity for subscription const orgMinimumSubscriptionQuantity = orgSeats || MINIMUM_NUMBER_OF_ORG_SEATS; const membershipCount = team.members.length; const subscription = await stripe.subscriptions.retrieve(subscriptionId); const subscriptionQuantity = subscription.items.data.find( (sub) => sub.id === subscriptionItemId )?.quantity; if (!subscriptionQuantity) throw new Error("Subscription not found"); if (team.isOrganization && membershipCount < orgMinimumSubscriptionQuantity) { console.info( `Org ${teamId} has less members than the min required ${orgMinimumSubscriptionQuantity}, skipping updating subscription.` ); return; } await stripe.subscriptions.update(subscriptionId, { items: [{ quantity: membershipCount, id: subscriptionItemId }], }); console.info( `Updated subscription ${subscriptionId} for team ${teamId} to ${team.members.length} seats.` ); } catch (error) { let message = "Unknown error on updateQuantitySubscriptionFromStripe"; if (error instanceof Error) message = error.message; console.error(message); } };