diff --git a/packages/features/ee/organizations/components/CreateANewOrganizationForm.tsx b/packages/features/ee/organizations/components/CreateANewOrganizationForm.tsx
index 89796aa5fb..5e590f4e2b 100644
--- a/packages/features/ee/organizations/components/CreateANewOrganizationForm.tsx
+++ b/packages/features/ee/organizations/components/CreateANewOrganizationForm.tsx
@@ -13,7 +13,7 @@ import { telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
import { UserPermissionRole } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc/react";
import type { Ensure } from "@calcom/types/utils";
-import { Alert, Button, Form, RadioGroup as RadioArea, TextField } from "@calcom/ui";
+import { Alert, Button, Form, Label, RadioGroup as RadioArea, TextField, ToggleGroup } from "@calcom/ui";
function extractDomainFromEmail(email: string) {
let out = "";
@@ -32,6 +32,11 @@ export const CreateANewOrganizationForm = () => {
return ;
};
+enum BillingPeriod {
+ MONTHLY = "MONTHLY",
+ ANNUALLY = "ANNUALLY",
+}
+
const CreateANewOrganizationFormChild = ({
session,
}: {
@@ -47,11 +52,13 @@ const CreateANewOrganizationFormChild = ({
const newOrganizationFormMethods = useForm<{
name: string;
seats: number;
+ billingPeriod: BillingPeriod;
pricePerSeat: number;
slug: string;
orgOwnerEmail: string;
}>({
defaultValues: {
+ billingPeriod: BillingPeriod.MONTHLY,
slug: !isAdmin ? deriveSlugFromEmail(defaultOrgOwnerEmail) : undefined,
orgOwnerEmail: !isAdmin ? defaultOrgOwnerEmail : undefined,
name: !isAdmin ? deriveOrgNameFromEmail(defaultOrgOwnerEmail) : undefined,
@@ -107,6 +114,39 @@ const CreateANewOrganizationFormChild = ({
)}
+ {isAdmin && (
+
+ (
+ <>
+
+ {
+ if ([BillingPeriod.ANNUALLY, BillingPeriod.MONTHLY].includes(e)) {
+ onChange(e);
+ }
+ }}
+ options={[
+ {
+ value: "MONTHLY",
+ label: "Monthly",
+ },
+ {
+ value: "ANNUALLY",
+ label: "Annually",
+ },
+ ]}
+ />
+ >
+ )}
+ />
+
+ )}
{
})
);
});
+ it("Should create a monthly subscription if billing period is set to monthly", async () => {
+ const FAKE_PAYMENT_ID = "FAKE_PAYMENT_ID";
+ const user = await prismock.user.create({
+ data: {
+ name: "test",
+ email: "test@email.com",
+ },
+ });
+
+ const checkoutSessionsCreate = mockStripeCheckoutSessionsCreate({
+ url: "SESSION_URL",
+ });
+
+ mockStripeCheckoutSessionRetrieve(
+ {
+ currency: "USD",
+ product: {
+ id: "PRODUCT_ID",
+ },
+ },
+ [FAKE_PAYMENT_ID]
+ );
+
+ mockStripeCheckoutPricesRetrieve({
+ id: "PRICE_ID",
+ product: {
+ id: "PRODUCT_ID",
+ },
+ });
+
+ const checkoutPricesCreate = mockStripePricesCreate({
+ id: "PRICE_ID",
+ });
+
+ const team = await prismock.team.create({
+ data: {
+ name: "test",
+ metadata: {
+ paymentId: FAKE_PAYMENT_ID,
+ },
+ },
+ });
+
+ const seatsToChargeFor = 1000;
+ expect(
+ await purchaseTeamOrOrgSubscription({
+ teamId: team.id,
+ seatsUsed: 10,
+ seatsToChargeFor,
+ userId: user.id,
+ isOrg: true,
+ pricePerSeat: 100,
+ billingPeriod: "MONTHLY",
+ })
+ ).toEqual({ url: "SESSION_URL" });
+
+ expect(checkoutPricesCreate).toHaveBeenCalledWith(
+ expect.objectContaining({ recurring: { interval: "month" } })
+ );
+
+ expect(checkoutSessionsCreate).toHaveBeenCalledWith(
+ expect.objectContaining({
+ line_items: [
+ {
+ price: "PRICE_ID",
+ quantity: seatsToChargeFor,
+ },
+ ],
+ })
+ );
+ });
+ it("Should create a annual subscription if billing period is set to annual", async () => {
+ const FAKE_PAYMENT_ID = "FAKE_PAYMENT_ID";
+ const user = await prismock.user.create({
+ data: {
+ name: "test",
+ email: "test@email.com",
+ },
+ });
+
+ const checkoutSessionsCreate = mockStripeCheckoutSessionsCreate({
+ url: "SESSION_URL",
+ });
+
+ mockStripeCheckoutSessionRetrieve(
+ {
+ currency: "USD",
+ product: {
+ id: "PRODUCT_ID",
+ },
+ },
+ [FAKE_PAYMENT_ID]
+ );
+
+ mockStripeCheckoutPricesRetrieve({
+ id: "PRICE_ID",
+ product: {
+ id: "PRODUCT_ID",
+ },
+ });
+
+ const checkoutPricesCreate = mockStripePricesCreate({
+ id: "PRICE_ID",
+ });
+
+ const team = await prismock.team.create({
+ data: {
+ name: "test",
+ metadata: {
+ paymentId: FAKE_PAYMENT_ID,
+ },
+ },
+ });
+
+ const seatsToChargeFor = 1000;
+ expect(
+ await purchaseTeamOrOrgSubscription({
+ teamId: team.id,
+ seatsUsed: 10,
+ seatsToChargeFor,
+ userId: user.id,
+ isOrg: true,
+ pricePerSeat: 100,
+ billingPeriod: "ANNUALLY",
+ })
+ ).toEqual({ url: "SESSION_URL" });
+
+ expect(checkoutPricesCreate).toHaveBeenCalledWith(
+ expect.objectContaining({ recurring: { interval: "year" } })
+ );
+
+ expect(checkoutSessionsCreate).toHaveBeenCalledWith(
+ expect.objectContaining({
+ line_items: [
+ {
+ price: "PRICE_ID",
+ quantity: seatsToChargeFor,
+ },
+ ],
+ })
+ );
+ });
+
+ it("It should not create a custom price if price_per_seat is not set", async () => {
+ const FAKE_PAYMENT_ID = "FAKE_PAYMENT_ID";
+ const user = await prismock.user.create({
+ data: {
+ name: "test",
+ email: "test@email.com",
+ },
+ });
+
+ mockStripeCheckoutSessionsCreate({
+ url: "SESSION_URL",
+ });
+
+ mockStripeCheckoutSessionRetrieve(
+ {
+ currency: "USD",
+ product: {
+ id: "PRODUCT_ID",
+ },
+ },
+ [FAKE_PAYMENT_ID]
+ );
+
+ mockStripeCheckoutPricesRetrieve({
+ id: "PRICE_ID",
+ product: {
+ id: "PRODUCT_ID",
+ },
+ });
+
+ const checkoutPricesCreate = mockStripePricesCreate({
+ id: "PRICE_ID",
+ });
+
+ const team = await prismock.team.create({
+ data: {
+ name: "test",
+ metadata: {
+ paymentId: FAKE_PAYMENT_ID,
+ },
+ },
+ });
+
+ const seatsToChargeFor = 1000;
+ expect(
+ await purchaseTeamOrOrgSubscription({
+ teamId: team.id,
+ seatsUsed: 10,
+ seatsToChargeFor,
+ userId: user.id,
+ isOrg: true,
+ billingPeriod: "ANNUALLY",
+ })
+ ).toEqual({ url: "SESSION_URL" });
+
+ expect(checkoutPricesCreate).not.toHaveBeenCalled();
+ });
});
describe("updateQuantitySubscriptionFromStripe", () => {
diff --git a/packages/features/ee/teams/lib/payments.ts b/packages/features/ee/teams/lib/payments.ts
index 125e05e82f..3b02233860 100644
--- a/packages/features/ee/teams/lib/payments.ts
+++ b/packages/features/ee/teams/lib/payments.ts
@@ -1,12 +1,13 @@
+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 } from "@calcom/lib/constants";
-import { MINIMUM_NUMBER_OF_ORG_SEATS, WEBAPP_URL } from "@calcom/lib/constants";
+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"] });
@@ -95,8 +96,17 @@ export const purchaseTeamOrOrgSubscription = async (input: {
userId: number;
isOrg?: boolean;
pricePerSeat: number | null;
+ billingPeriod?: BillingPeriod;
}) => {
- const { teamId, seatsToChargeFor, seatsUsed, userId, isOrg, pricePerSeat } = input;
+ const {
+ teamId,
+ seatsToChargeFor,
+ seatsUsed,
+ userId,
+ isOrg,
+ pricePerSeat,
+ billingPeriod = BillingPeriod.MONTHLY,
+ } = input;
const { url } = await checkIfTeamPaymentRequired({ teamId });
if (url) return { url };
@@ -106,6 +116,24 @@ export const purchaseTeamOrOrgSubscription = async (input: {
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",
@@ -114,7 +142,7 @@ export const purchaseTeamOrOrgSubscription = async (input: {
cancel_url: `${WEBAPP_URL}/settings/my-account/profile`,
line_items: [
{
- price: await getPriceId(),
+ price: priceId,
quantity: quantity,
},
],
@@ -136,37 +164,34 @@ export const purchaseTeamOrOrgSubscription = async (input: {
});
return { url: session.url };
- /**
- * 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 getPriceId() {
- 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 }));
-
- if (!pricePerSeat) {
- return fixedPriceId;
- }
-
- const priceObj = await stripe.prices.retrieve(fixedPriceId);
- if (!priceObj) throw new Error(`No price found for ID ${fixedPriceId}`);
+ 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: pricePerSeat * 100, // Stripe expects the amount in cents
+ unit_amount: yearlyPrice, // Stripe expects the amount in cents
// Use the same currency as in the fixed price to avoid hardcoding it.
- currency: priceObj.currency,
- recurring: { interval: "month" }, // Define your subscription interval
- product: typeof priceObj.product === "string" ? priceObj.product : priceObj.product.id,
+ 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;
@@ -179,8 +204,35 @@ export const purchaseTeamOrOrgSubscription = async (input: {
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 },
diff --git a/packages/lib/server/repository/organization.ts b/packages/lib/server/repository/organization.ts
index df8b5f52a4..a4929a95f2 100644
--- a/packages/lib/server/repository/organization.ts
+++ b/packages/lib/server/repository/organization.ts
@@ -14,6 +14,7 @@ const orgSelect = {
slug: true,
logoUrl: true,
};
+
export class OrganizationRepository {
static async createWithExistingUserAsOwner({
orgData,
@@ -28,6 +29,7 @@ export class OrganizationRepository {
seats: number | null;
pricePerSeat: number | null;
isPlatform: boolean;
+ billingPeriod?: "MONTHLY" | "ANNUALLY";
};
owner: {
id: number;
@@ -68,6 +70,7 @@ export class OrganizationRepository {
isOrganizationAdminReviewed: boolean;
autoAcceptEmail: string;
seats: number | null;
+ billingPeriod?: "MONTHLY" | "ANNUALLY";
pricePerSeat: number | null;
isPlatform: boolean;
};
@@ -109,6 +112,7 @@ export class OrganizationRepository {
isOrganizationAdminReviewed: boolean;
autoAcceptEmail: string;
seats: number | null;
+ billingPeriod?: "MONTHLY" | "ANNUALLY";
pricePerSeat: number | null;
isPlatform: boolean;
}) {
@@ -130,6 +134,7 @@ export class OrganizationRepository {
orgSeats: orgData.seats,
orgPricePerSeat: orgData.pricePerSeat,
isPlatform: orgData.isPlatform,
+ billingPeriod: orgData.billingPeriod,
},
isPlatform: orgData.isPlatform,
},
diff --git a/packages/prisma/zod-utils.ts b/packages/prisma/zod-utils.ts
index f5c6f37344..0ef548f7c0 100644
--- a/packages/prisma/zod-utils.ts
+++ b/packages/prisma/zod-utils.ts
@@ -364,6 +364,11 @@ export const orgSettingsSchema = z
.nullable();
export type userMetadataType = z.infer;
+export enum BillingPeriod {
+ MONTHLY = "MONTHLY",
+ ANNUALLY = "ANNUALLY",
+}
+
export const teamMetadataSchema = z
.object({
requestedSlug: z.string().or(z.null()),
@@ -380,6 +385,7 @@ export const teamMetadataSchema = z
lastRevertTime: z.string().optional(),
})
.optional(),
+ billingPeriod: z.nativeEnum(BillingPeriod).optional(),
})
.partial()
.nullable();
diff --git a/packages/trpc/server/routers/viewer/organizations/create.handler.ts b/packages/trpc/server/routers/viewer/organizations/create.handler.ts
index 1ee3db8e04..5d45c57a36 100644
--- a/packages/trpc/server/routers/viewer/organizations/create.handler.ts
+++ b/packages/trpc/server/routers/viewer/organizations/create.handler.ts
@@ -20,6 +20,7 @@ import { UserPermissionRole } from "@calcom/prisma/enums";
import { TRPCError } from "@trpc/server";
import type { TrpcSessionUser } from "../../../trpc";
+import { BillingPeriod } from "./create.schema";
import type { TCreateInputSchema } from "./create.schema";
type CreateOptions = {
@@ -39,7 +40,16 @@ const getIPAddress = async (url: string): Promise => {
};
export const createHandler = async ({ input, ctx }: CreateOptions) => {
- const { slug, name, orgOwnerEmail, seats, pricePerSeat, isPlatform } = input;
+ const {
+ slug,
+ name,
+ orgOwnerEmail,
+ seats,
+ pricePerSeat,
+ isPlatform,
+ billingPeriod: billingPeriodRaw,
+ } = input;
+
const loggedInUser = await prisma.user.findUnique({
where: {
id: ctx.user.id,
@@ -63,6 +73,9 @@ export const createHandler = async ({ input, ctx }: CreateOptions) => {
const IS_USER_ADMIN = loggedInUser.role === UserPermissionRole.ADMIN;
+ // We only allow creating an annual billing period if you are a system admin
+ const billingPeriod = (IS_USER_ADMIN ? billingPeriodRaw : BillingPeriod.MONTHLY) ?? BillingPeriod.MONTHLY;
+
if (!ORG_SELF_SERVE_ENABLED && !IS_USER_ADMIN && !isPlatform) {
throw new TRPCError({ code: "FORBIDDEN", message: "Only admins can create organizations" });
}
@@ -140,6 +153,7 @@ export const createHandler = async ({ input, ctx }: CreateOptions) => {
seats: seats ?? null,
pricePerSeat: pricePerSeat ?? null,
isPlatform,
+ billingPeriod,
};
// Create a new user and invite them as the owner of the organization
diff --git a/packages/trpc/server/routers/viewer/organizations/create.schema.ts b/packages/trpc/server/routers/viewer/organizations/create.schema.ts
index 024b989ecd..c9e4dc9691 100644
--- a/packages/trpc/server/routers/viewer/organizations/create.schema.ts
+++ b/packages/trpc/server/routers/viewer/organizations/create.schema.ts
@@ -2,6 +2,11 @@ import { z } from "zod";
import slugify from "@calcom/lib/slugify";
+export enum BillingPeriod {
+ MONTHLY = "MONTHLY",
+ ANNUALLY = "ANNUALLY",
+}
+
export const ZCreateInputSchema = z.object({
name: z.string(),
slug: z.string().transform((val) => slugify(val.trim())),
@@ -10,6 +15,7 @@ export const ZCreateInputSchema = z.object({
seats: z.number().optional(),
pricePerSeat: z.number().optional(),
isPlatform: z.boolean().default(false),
+ billingPeriod: z.nativeEnum(BillingPeriod).default(BillingPeriod.MONTHLY).optional(),
});
export type TCreateInputSchema = z.infer;
diff --git a/packages/trpc/server/routers/viewer/organizations/publish.handler.ts b/packages/trpc/server/routers/viewer/organizations/publish.handler.ts
index 2c0fc9991b..d55ee14720 100644
--- a/packages/trpc/server/routers/viewer/organizations/publish.handler.ts
+++ b/packages/trpc/server/routers/viewer/organizations/publish.handler.ts
@@ -46,6 +46,7 @@ export const publishHandler = async ({ ctx }: PublishOptions) => {
userId: ctx.user.id,
isOrg: true,
pricePerSeat: metadata.data?.orgPricePerSeat ?? null,
+ billingPeriod: metadata.data?.billingPeriod ?? undefined,
});
if (!checkoutSession.url)