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 <peeroke@gmail.com>
Co-authored-by: Hariom <hariombalhara@gmail.com>
This commit is contained in:
sean-brydon
2024-06-24 14:03:27 +00:00
committed by GitHub
co-authored by Peer Richelsen Hariom
parent f897c680a4
commit 7b69a6ab9c
8 changed files with 357 additions and 33 deletions
@@ -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 <CreateANewOrganizationFormChild session={session} />;
};
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 = ({
<Alert severity="error" message={serverErrorMessage} />
</div>
)}
{isAdmin && (
<div className="mb-5">
<Controller
name="billingPeriod"
control={newOrganizationFormMethods.control}
render={({ field: { value, onChange } }) => (
<>
<Label htmlFor="billingPeriod">Billing Period</Label>
<ToggleGroup
isFullWidth
id="billingPeriod"
value={value}
onValueChange={(e: BillingPeriod) => {
if ([BillingPeriod.ANNUALLY, BillingPeriod.MONTHLY].includes(e)) {
onChange(e);
}
}}
options={[
{
value: "MONTHLY",
label: "Monthly",
},
{
value: "ANNUALLY",
label: "Annually",
},
]}
/>
</>
)}
/>
</div>
)}
<Controller
name="orgOwnerEmail"
control={newOrganizationFormMethods.control}
@@ -130,6 +130,206 @@ describe("purchaseTeamOrOrgSubscription", () => {
})
);
});
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", () => {
+83 -31
View File
@@ -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 },
@@ -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,
},
+6
View File
@@ -364,6 +364,11 @@ export const orgSettingsSchema = z
.nullable();
export type userMetadataType = z.infer<typeof userMetadata>;
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();
@@ -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<string> => {
};
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
@@ -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<typeof ZCreateInputSchema>;
@@ -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)