feat: add annual plans for teams/organizations (#27896)

* wip: add annual plan priceId support

* fix type errors

* type cast
This commit is contained in:
sean-brydon
2026-02-12 14:05:24 +00:00
committed by GitHub
parent d52e2d9fad
commit 98ec38aeaa
9 changed files with 297 additions and 11 deletions
+2
View File
@@ -209,11 +209,13 @@ NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE=
NEXT_PUBLIC_IS_PREMIUM_NEW_PLAN=0
NEXT_PUBLIC_STRIPE_PREMIUM_NEW_PLAN_PRICE=
STRIPE_TEAM_MONTHLY_PRICE_ID=
STRIPE_TEAM_ANNUAL_PRICE_ID=
NEXT_PUBLIC_STRIPE_CREDITS_PRICE_ID=
ORG_MONTHLY_CREDITS=
STRIPE_TEAM_PRODUCT_ID=
# It is a price ID in the product with id STRIPE_ORG_PRODUCT_ID
STRIPE_ORG_MONTHLY_PRICE_ID=
STRIPE_ORG_ANNUAL_PRICE_ID=
STRIPE_ORG_PRODUCT_ID=
# Used to pass trial days for orgs during the Stripe checkout session
STRIPE_ORG_TRIAL_DAYS=
@@ -8,6 +8,7 @@ import type { IOrganizationPermissionService } from "./OrganizationPermissionSer
vi.stubEnv("STRIPE_ORG_PRODUCT_ID", "STRIPE_ORG_PRODUCT_ID");
vi.stubEnv("STRIPE_ORG_MONTHLY_PRICE_ID", "STRIPE_ORG_MONTHLY_PRICE_ID");
vi.stubEnv("STRIPE_ORG_ANNUAL_PRICE_ID", "STRIPE_ORG_ANNUAL_PRICE_ID");
const defaultOrgOnboarding = {
id: "onboard-id-1",
name: "Test Org",
@@ -209,6 +210,50 @@ describe("OrganizationPaymentService", () => {
});
});
it("should use annual fixed price when billingPeriod is ANNUALLY and no custom price", async () => {
vi.mocked(prisma.membership.findMany).mockResolvedValue([]);
await service.createPaymentIntent(
{
bio: "BIO",
logo: "LOGO",
teams: [],
},
{
...defaultOrgOnboarding,
billingPeriod: "ANNUALLY",
}
);
expect(mockBillingService.createSubscriptionCheckout).toHaveBeenCalledWith(
expect.objectContaining({
priceId: "STRIPE_ORG_ANNUAL_PRICE_ID",
})
);
});
it("should use monthly fixed price when billingPeriod is MONTHLY and no custom price", async () => {
vi.mocked(prisma.membership.findMany).mockResolvedValue([]);
await service.createPaymentIntent(
{
bio: "BIO",
logo: "LOGO",
teams: [],
},
{
...defaultOrgOnboarding,
billingPeriod: "MONTHLY",
}
);
expect(mockBillingService.createSubscriptionCheckout).toHaveBeenCalledWith(
expect.objectContaining({
priceId: "STRIPE_ORG_MONTHLY_PRICE_ID",
})
);
});
describe("admin overrides", () => {
beforeEach(() => {
mockPermissionService.hasPermissionToModifyDefaultPayment.mockReturnValue(true);
@@ -219,11 +219,21 @@ export class OrganizationPaymentService {
})
);
if (!process.env.STRIPE_ORG_PRODUCT_ID || !process.env.STRIPE_ORG_MONTHLY_PRICE_ID) {
throw new Error("STRIPE_ORG_PRODUCT_ID or STRIPE_ORG_MONTHLY_PRICE_ID is not set");
if (!process.env.STRIPE_ORG_PRODUCT_ID) {
throw new Error("STRIPE_ORG_PRODUCT_ID is not set");
}
const fixedPriceId =
config.billingPeriod === "ANNUALLY"
? process.env.STRIPE_ORG_ANNUAL_PRICE_ID
: process.env.STRIPE_ORG_MONTHLY_PRICE_ID;
if (!fixedPriceId) {
const envVar =
config.billingPeriod === "ANNUALLY" ? "STRIPE_ORG_ANNUAL_PRICE_ID" : "STRIPE_ORG_MONTHLY_PRICE_ID";
throw new Error(`${envVar} is not set`);
}
const fixedPriceId = process.env.STRIPE_ORG_MONTHLY_PRICE_ID;
if (!shouldCreateCustomPrice) {
return {
priceId: fixedPriceId,
@@ -6,6 +6,7 @@ import stripe from "@calcom/features/ee/payments/server/stripe";
import { BillingPeriod } from "@calcom/prisma/zod-utils";
import {
generateTeamCheckoutSession,
getTeamWithPaymentMetadata,
purchaseTeamOrOrgSubscription,
updateQuantitySubscriptionFromStripe,
@@ -13,7 +14,9 @@ import {
beforeEach(async () => {
vi.stubEnv("STRIPE_ORG_MONTHLY_PRICE_ID", "STRIPE_ORG_MONTHLY_PRICE_ID");
vi.stubEnv("STRIPE_ORG_ANNUAL_PRICE_ID", "STRIPE_ORG_ANNUAL_PRICE_ID");
vi.stubEnv("STRIPE_TEAM_MONTHLY_PRICE_ID", "STRIPE_TEAM_MONTHLY_PRICE_ID");
vi.stubEnv("STRIPE_TEAM_ANNUAL_PRICE_ID", "STRIPE_TEAM_ANNUAL_PRICE_ID");
vi.resetAllMocks();
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
@@ -36,6 +39,12 @@ vi.mock("@calcom/app-store/stripepayment/lib/customer", () => {
};
});
vi.mock("@calcom/features/auth/lib/dub", () => {
return {
getDubCustomer: vi.fn().mockResolvedValue(null),
};
});
vi.mock("@calcom/features/ee/payments/server/stripe", () => {
return {
default: {
@@ -58,6 +67,74 @@ vi.mock("@calcom/features/ee/payments/server/stripe", () => {
};
});
describe("generateTeamCheckoutSession", () => {
it("should use monthly price by default", async () => {
const checkoutSessionsCreate = mockStripeCheckoutSessionsCreate({
url: "SESSION_URL",
});
await generateTeamCheckoutSession({
teamName: "Test Team",
teamSlug: "test-team",
userId: 1,
});
expect(checkoutSessionsCreate).toHaveBeenCalledWith(
expect.objectContaining({
line_items: [
expect.objectContaining({
price: "STRIPE_TEAM_MONTHLY_PRICE_ID",
}),
],
})
);
});
it("should use annual price when billingPeriod is ANNUALLY", async () => {
const checkoutSessionsCreate = mockStripeCheckoutSessionsCreate({
url: "SESSION_URL",
});
await generateTeamCheckoutSession({
teamName: "Test Team",
teamSlug: "test-team",
userId: 1,
billingPeriod: BillingPeriod.ANNUALLY,
});
expect(checkoutSessionsCreate).toHaveBeenCalledWith(
expect.objectContaining({
line_items: [
expect.objectContaining({
price: "STRIPE_TEAM_ANNUAL_PRICE_ID",
}),
],
})
);
});
it("should store billingPeriod in checkout session metadata", async () => {
const checkoutSessionsCreate = mockStripeCheckoutSessionsCreate({
url: "SESSION_URL",
});
await generateTeamCheckoutSession({
teamName: "Test Team",
teamSlug: "test-team",
userId: 1,
billingPeriod: BillingPeriod.ANNUALLY,
});
expect(checkoutSessionsCreate).toHaveBeenCalledWith(
expect.objectContaining({
metadata: expect.objectContaining({
billingPeriod: "ANNUALLY",
}),
})
);
});
});
describe("purchaseTeamOrOrgSubscription", () => {
it("should use `seatsToChargeFor` to create price", async () => {
const FAKE_PAYMENT_ID = "FAKE_PAYMENT_ID";
@@ -268,6 +345,116 @@ describe("purchaseTeamOrOrgSubscription", () => {
);
});
it("should use annual fixed price when billingPeriod is ANNUALLY and no custom price", 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]
);
const team = await prismock.team.create({
data: {
name: "test",
metadata: {
paymentId: FAKE_PAYMENT_ID,
},
},
});
expect(
await purchaseTeamOrOrgSubscription({
teamId: team.id,
seatsUsed: 10,
userId: user.id,
isOrg: false,
pricePerSeat: null,
billingPeriod: BillingPeriod.ANNUALLY,
})
).toEqual({ url: "SESSION_URL" });
expect(checkoutSessionsCreate).toHaveBeenCalledWith(
expect.objectContaining({
line_items: [
{
price: "STRIPE_TEAM_ANNUAL_PRICE_ID",
quantity: 10,
},
],
})
);
});
it("should use annual fixed price for org when billingPeriod is ANNUALLY and no custom price", 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]
);
const team = await prismock.team.create({
data: {
name: "test",
metadata: {
paymentId: FAKE_PAYMENT_ID,
},
},
});
expect(
await purchaseTeamOrOrgSubscription({
teamId: team.id,
seatsUsed: 5,
userId: user.id,
isOrg: true,
pricePerSeat: null,
billingPeriod: BillingPeriod.ANNUALLY,
})
).toEqual({ url: "SESSION_URL" });
expect(checkoutSessionsCreate).toHaveBeenCalledWith(
expect.objectContaining({
line_items: [
{
price: "STRIPE_ORG_ANNUAL_PRICE_ID",
quantity: 5,
},
],
})
);
});
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({
+36 -7
View File
@@ -43,6 +43,21 @@ export const checkIfTeamPaymentRequired = async ({ teamId = -1 }) => {
return { url: `${WEBAPP_URL}/api/teams/${teamId}/upgrade?session_id=${metadata.paymentId}` };
};
function getTeamPriceId(billingPeriod: BillingPeriod): string {
const priceId =
billingPeriod === "ANNUALLY"
? process.env.STRIPE_TEAM_ANNUAL_PRICE_ID
: process.env.STRIPE_TEAM_MONTHLY_PRICE_ID;
if (!priceId) {
throw new Error(
`Missing env var: ${billingPeriod === "ANNUALLY" ? "STRIPE_TEAM_ANNUAL_PRICE_ID" : "STRIPE_TEAM_MONTHLY_PRICE_ID"}`
);
}
return priceId;
}
/**
* Used to generate a checkout session when trying to create a team
*/
@@ -51,18 +66,21 @@ export const generateTeamCheckoutSession = async ({
teamSlug,
userId,
isOnboarding,
billingPeriod = BillingPeriod.MONTHLY,
tracking,
}: {
teamName: string;
teamSlug: string;
userId: number;
isOnboarding?: boolean;
billingPeriod?: BillingPeriod;
tracking?: TrackingData;
}) => {
const [customer, dubCustomer] = await Promise.all([
getStripeCustomerIdFromUserId(userId),
getDubCustomer(userId.toString()),
]);
const priceId = getTeamPriceId(billingPeriod);
const session = await stripe.checkout.sessions.create({
customer,
mode: "subscription",
@@ -83,7 +101,7 @@ export const generateTeamCheckoutSession = async ({
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,
price: priceId,
/**Initially it will be just the team owner */
quantity: 1,
},
@@ -106,6 +124,7 @@ export const generateTeamCheckoutSession = async ({
teamName,
teamSlug,
userId,
billingPeriod,
dubCustomerId: userId, // pass the userId during checkout creation for sales conversion tracking: https://d.to/conversions/stripe
...(isOnboarding !== undefined && { isOnboarding: isOnboarding.toString() }),
...(tracking?.googleAds?.gclid && {
@@ -269,17 +288,27 @@ export const purchaseTeamOrOrgSubscription = async (input: {
* 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 isAnnual = billingPeriod === "ANNUALLY";
const fixedPriceId = isOrg
? process.env.STRIPE_ORG_MONTHLY_PRICE_ID
: process.env.STRIPE_TEAM_MONTHLY_PRICE_ID;
? isAnnual
? process.env.STRIPE_ORG_ANNUAL_PRICE_ID
: process.env.STRIPE_ORG_MONTHLY_PRICE_ID
: isAnnual
? process.env.STRIPE_TEAM_ANNUAL_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"
);
const envVar = isOrg
? isAnnual
? "STRIPE_ORG_ANNUAL_PRICE_ID"
: "STRIPE_ORG_MONTHLY_PRICE_ID"
: isAnnual
? "STRIPE_TEAM_ANNUAL_PRICE_ID"
: "STRIPE_TEAM_MONTHLY_PRICE_ID";
throw new Error(`Missing env var: ${envVar}`);
}
log.debug("Getting price ID", safeStringify({ fixedPriceId, isOrg, teamId, pricePerSeat }));
log.debug("Getting price ID", safeStringify({ fixedPriceId, isOrg, teamId, pricePerSeat, billingPeriod }));
return fixedPriceId;
}
@@ -13,6 +13,7 @@ import { createHandler } from "./createWithPaymentIntent.handler";
vi.stubEnv("STRIPE_PRIVATE_KEY", "test-stripe-private-key");
vi.stubEnv("STRIPE_ORG_MONTHLY_PRICE_ID", "test-stripe-org-monthly-price-id");
vi.stubEnv("STRIPE_ORG_ANNUAL_PRICE_ID", "test-stripe-org-annual-price-id");
vi.stubEnv("STRIPE_ORG_PRODUCT_ID", "test-stripe-org-product-id");
vi.stubEnv("NEXT_PUBLIC_ORGANIZATIONS_SELF_SERVE_PRICE_NEW", "37");
@@ -9,6 +9,7 @@ import { getTrackingFromCookies } from "@calcom/lib/tracking";
import type { TrackingData } from "@calcom/lib/tracking";
import { prisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import type { BillingPeriod as BillingPeriodEnum } from "@calcom/prisma/zod-utils";
import { TRPCError } from "@trpc/server";
@@ -28,12 +29,14 @@ const generateCheckoutSession = async ({
teamName,
userId,
isOnboarding,
billingPeriod,
tracking,
}: {
teamSlug: string;
teamName: string;
userId: number;
isOnboarding?: boolean;
billingPeriod?: "MONTHLY" | "ANNUALLY";
tracking?: TrackingData;
}) => {
if (!IS_TEAM_BILLING_ENABLED) {
@@ -46,6 +49,7 @@ const generateCheckoutSession = async ({
teamName,
userId,
isOnboarding,
billingPeriod: billingPeriod as BillingPeriodEnum | undefined,
tracking,
});
@@ -59,7 +63,7 @@ const generateCheckoutSession = async ({
export const createHandler = async ({ ctx, input }: CreateOptions) => {
const { user } = ctx;
const { slug, name, bio, isOnboarding } = input;
const { slug, name, bio, isOnboarding, billingPeriod } = input;
const isOrgChildTeam = !!user.profile?.organizationId;
// For orgs we want to create teams under the org
@@ -94,6 +98,7 @@ export const createHandler = async ({ ctx, input }: CreateOptions) => {
teamName: name,
userId: user.id,
isOnboarding,
billingPeriod,
tracking,
});
@@ -1,6 +1,7 @@
import { z } from "zod";
import slugify from "@calcom/lib/slugify";
import type { BillingPeriod } from "@calcom/prisma/enums";
export type TCreateInputSchema = {
name: string;
@@ -8,6 +9,7 @@ export type TCreateInputSchema = {
logo?: string | null;
bio?: string;
isOnboarding?: boolean;
billingPeriod?: BillingPeriod;
};
export const ZCreateInputSchema: z.ZodType<TCreateInputSchema> = z.object({
@@ -20,4 +22,5 @@ export const ZCreateInputSchema: z.ZodType<TCreateInputSchema> = z.object({
.transform((v) => v || null),
bio: z.string().optional(),
isOnboarding: z.boolean().optional(),
billingPeriod: z.enum(["MONTHLY", "ANNUALLY"]).optional(),
});
+4
View File
@@ -175,8 +175,10 @@
"STRIPE_WEBHOOK_SECRET_APPS",
"STRIPE_WEBHOOK_SECRET_BILLING",
"STRIPE_TEAM_MONTHLY_PRICE_ID",
"STRIPE_TEAM_ANNUAL_PRICE_ID",
"STRIPE_TEAM_PRODUCT_ID",
"STRIPE_ORG_MONTHLY_PRICE_ID",
"STRIPE_ORG_ANNUAL_PRICE_ID",
"STRIPE_ORG_PRODUCT_ID",
"STRIPE_ORG_TRIAL_DAYS",
"TANDEM_BASE_URL",
@@ -372,10 +374,12 @@
"SENTRY_REPLAYS_ON_ERROR_SAMPLE_RATE",
"STRIPE_PREMIUM_PLAN_PRODUCT_ID",
"STRIPE_TEAM_MONTHLY_PRICE_ID",
"STRIPE_TEAM_ANNUAL_PRICE_ID",
"NEXT_PUBLIC_STRIPE_CREDITS_PRICE_ID",
"STRIPE_TEAM_PRODUCT_ID",
"ORG_MONTHLY_CREDITS",
"STRIPE_ORG_MONTHLY_PRICE_ID",
"STRIPE_ORG_ANNUAL_PRICE_ID",
"STRIPE_ORG_PRODUCT_ID",
"STRIPE_ORG_TRIAL_DAYS",
"NEXT_PUBLIC_API_V2_URL",