diff --git a/.env.example b/.env.example index 971dc8a620..cef10a0cf0 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/packages/features/ee/organizations/lib/OrganizationPaymentService.test.ts b/packages/features/ee/organizations/lib/OrganizationPaymentService.test.ts index 77e801c687..09ca77a8d7 100644 --- a/packages/features/ee/organizations/lib/OrganizationPaymentService.test.ts +++ b/packages/features/ee/organizations/lib/OrganizationPaymentService.test.ts @@ -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); diff --git a/packages/features/ee/organizations/lib/OrganizationPaymentService.ts b/packages/features/ee/organizations/lib/OrganizationPaymentService.ts index 5a10aadcd3..96fc631bf4 100644 --- a/packages/features/ee/organizations/lib/OrganizationPaymentService.ts +++ b/packages/features/ee/organizations/lib/OrganizationPaymentService.ts @@ -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, diff --git a/packages/features/ee/teams/lib/payments.test.ts b/packages/features/ee/teams/lib/payments.test.ts index b98990ae5b..4b4d0af7ab 100644 --- a/packages/features/ee/teams/lib/payments.test.ts +++ b/packages/features/ee/teams/lib/payments.test.ts @@ -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({ diff --git a/packages/features/ee/teams/lib/payments.ts b/packages/features/ee/teams/lib/payments.ts index 4d59578ecc..ab31e48fab 100644 --- a/packages/features/ee/teams/lib/payments.ts +++ b/packages/features/ee/teams/lib/payments.ts @@ -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; } diff --git a/packages/trpc/server/routers/viewer/organizations/createWithPaymentIntent.handler.test.ts b/packages/trpc/server/routers/viewer/organizations/createWithPaymentIntent.handler.test.ts index 7927f8fca8..909dcc7e2e 100644 --- a/packages/trpc/server/routers/viewer/organizations/createWithPaymentIntent.handler.test.ts +++ b/packages/trpc/server/routers/viewer/organizations/createWithPaymentIntent.handler.test.ts @@ -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"); diff --git a/packages/trpc/server/routers/viewer/teams/create.handler.ts b/packages/trpc/server/routers/viewer/teams/create.handler.ts index df2150a1e1..79fb2be812 100644 --- a/packages/trpc/server/routers/viewer/teams/create.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/create.handler.ts @@ -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, }); diff --git a/packages/trpc/server/routers/viewer/teams/create.schema.ts b/packages/trpc/server/routers/viewer/teams/create.schema.ts index 7dd088e0a2..41e4fc8011 100644 --- a/packages/trpc/server/routers/viewer/teams/create.schema.ts +++ b/packages/trpc/server/routers/viewer/teams/create.schema.ts @@ -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 = z.object({ @@ -20,4 +22,5 @@ export const ZCreateInputSchema: z.ZodType = z.object({ .transform((v) => v || null), bio: z.string().optional(), isOnboarding: z.boolean().optional(), + billingPeriod: z.enum(["MONTHLY", "ANNUALLY"]).optional(), }); diff --git a/turbo.json b/turbo.json index bdf2a80aa1..7a7c2ddb56 100644 --- a/turbo.json +++ b/turbo.json @@ -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",