From 7b69a6ab9c7a4b0286d9b07c21a968340d4a413c Mon Sep 17 00:00:00 2001 From: sean-brydon <55134778+sean-brydon@users.noreply.github.com> Date: Mon, 24 Jun 2024 15:03:27 +0100 Subject: [PATCH] feat: add monthly annual billing org form (#15520) * Add annuall billing options * added payment tests * Add conitional + tests * Fix types + use correct ID * Fix type error * fix type check * Assign default to monthly billing period * cleanup * chore: rename fn and params * Prevent ability to deselect toggle group * Calculate yearly price * Fix TS error * revese billingPeriod * reformat how we handle billingPeriod * Improve type --------- Co-authored-by: Peer Richelsen Co-authored-by: Hariom --- .../components/CreateANewOrganizationForm.tsx | 42 +++- .../features/ee/teams/lib/payments.test.ts | 200 ++++++++++++++++++ packages/features/ee/teams/lib/payments.ts | 114 +++++++--- .../lib/server/repository/organization.ts | 5 + packages/prisma/zod-utils.ts | 6 + .../viewer/organizations/create.handler.ts | 16 +- .../viewer/organizations/create.schema.ts | 6 + .../viewer/organizations/publish.handler.ts | 1 + 8 files changed, 357 insertions(+), 33 deletions(-) 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)