Skip to content

Commit

Permalink
feat: add monthly annual billing org form (calcom#15520)
Browse files Browse the repository at this point in the history
* 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>
  • Loading branch information
3 people authored Jun 24, 2024
1 parent f897c68 commit 7b69a6a
Show file tree
Hide file tree
Showing 8 changed files with 356 additions and 32 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "";
Expand All @@ -32,6 +32,11 @@ export const CreateANewOrganizationForm = () => {
return <CreateANewOrganizationFormChild session={session} />;
};

enum BillingPeriod {
MONTHLY = "MONTHLY",
ANNUALLY = "ANNUALLY",
}

const CreateANewOrganizationFormChild = ({
session,
}: {
Expand All @@ -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,
Expand Down Expand Up @@ -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}
Expand Down
200 changes: 200 additions & 0 deletions packages/features/ee/teams/lib/payments.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
Loading

0 comments on commit 7b69a6a

Please sign in to comment.