Skip to content

Commit 917c7b0

Browse files
fix: Allow less than 30 seats for orgs (#14995)
* fix: Allow less than 30 seats for orgs * Add test * Stub env variables
1 parent fd64b0f commit 917c7b0

File tree

5 files changed

+157
-7
lines changed

5 files changed

+157
-7
lines changed

apps/api/v1/pages/api/teams/[teamId]/_patch.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ export async function patchHandler(req: NextApiRequest) {
107107
if (IS_TEAM_BILLING_ENABLED) {
108108
const checkoutSession = await purchaseTeamOrOrgSubscription({
109109
teamId: _team.id,
110-
seats: _team.members.length,
110+
seatsUsed: _team.members.length,
111111
userId,
112112
pricePerSeat: null,
113113
});
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import prismock from "../../../../../tests/libs/__mocks__/prisma";
2+
3+
import { describe, expect, it, vi, beforeAll, afterAll } from "vitest";
4+
5+
import stripe from "@calcom/app-store/stripepayment/lib/server";
6+
7+
import { purchaseTeamOrOrgSubscription } from "./payments";
8+
9+
beforeAll(() => {
10+
vi.stubEnv("STRIPE_ORG_MONTHLY_PRICE_ID", "STRIPE_ORG_MONTHLY_PRICE_ID");
11+
vi.stubEnv("STRIPE_TEAM_MONTHLY_PRICE_ID", "STRIPE_TEAM_MONTHLY_PRICE_ID");
12+
});
13+
14+
afterAll(() => {
15+
vi.unstubAllEnvs();
16+
});
17+
vi.mock("@calcom/app-store/stripepayment/lib/customer", () => {
18+
return {
19+
getStripeCustomerIdFromUserId: function () {
20+
return "CUSTOMER_ID";
21+
},
22+
};
23+
});
24+
25+
vi.mock("@calcom/app-store/stripepayment/lib/server", () => {
26+
return {
27+
default: {
28+
checkout: {
29+
sessions: {
30+
create: vi.fn(),
31+
retrieve: vi.fn(),
32+
},
33+
},
34+
prices: {
35+
retrieve: vi.fn(),
36+
create: vi.fn(),
37+
},
38+
},
39+
};
40+
});
41+
42+
describe("purchaseTeamOrOrgSubscription", () => {
43+
it("should use `seatsToChargeFor` to create price", async () => {
44+
const user = await prismock.user.create({
45+
data: {
46+
name: "test",
47+
48+
},
49+
});
50+
51+
const checkoutSessionsCreate = mockStripeCheckoutSessionsCreate({
52+
url: "SESSION_URL",
53+
});
54+
55+
mockStripeCheckoutSessionRetrieve({
56+
currency: "USD",
57+
product: {
58+
id: "PRODUCT_ID",
59+
},
60+
});
61+
62+
mockStripeCheckoutPricesRetrieve({
63+
id: "PRICE_ID",
64+
product: {
65+
id: "PRODUCT_ID",
66+
},
67+
});
68+
69+
mockStripePricesCreate({
70+
id: "PRICE_ID",
71+
});
72+
73+
const team = await prismock.team.create({
74+
data: {
75+
name: "test",
76+
},
77+
});
78+
79+
const seatsToChargeFor = 1000;
80+
expect(
81+
await purchaseTeamOrOrgSubscription({
82+
teamId: team.id,
83+
seatsUsed: 10,
84+
seatsToChargeFor,
85+
userId: user.id,
86+
isOrg: true,
87+
pricePerSeat: 100,
88+
})
89+
).toEqual({ url: "SESSION_URL" });
90+
91+
expect(checkoutSessionsCreate).toHaveBeenCalledWith(
92+
expect.objectContaining({
93+
line_items: [
94+
{
95+
price: "PRICE_ID",
96+
quantity: seatsToChargeFor,
97+
},
98+
],
99+
})
100+
);
101+
});
102+
});
103+
104+
function mockStripePricesCreate(data) {
105+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
106+
//@ts-ignore
107+
return vi.mocked(stripe.prices.create).mockImplementation(() => new Promise((resolve) => resolve(data)));
108+
}
109+
110+
function mockStripeCheckoutPricesRetrieve(data) {
111+
return vi.mocked(stripe.prices.retrieve).mockImplementation(
112+
async () =>
113+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
114+
//@ts-ignore
115+
new Promise((resolve) => {
116+
resolve(data);
117+
})
118+
);
119+
}
120+
121+
function mockStripeCheckoutSessionRetrieve(data) {
122+
return vi.mocked(stripe.checkout.sessions.retrieve).mockImplementation(
123+
async () =>
124+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
125+
//@ts-ignore
126+
new Promise((resolve) => resolve(data))
127+
);
128+
}
129+
130+
function mockStripeCheckoutSessionsCreate(data) {
131+
return vi.mocked(stripe.checkout.sessions.create).mockImplementation(
132+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
133+
//@ts-ignore
134+
async () => new Promise((resolve) => resolve(data))
135+
);
136+
}

packages/features/ee/teams/lib/payments.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -81,17 +81,28 @@ export const generateTeamCheckoutSession = async ({
8181
*/
8282
export const purchaseTeamOrOrgSubscription = async (input: {
8383
teamId: number;
84-
seats: number;
84+
/**
85+
* The actual number of seats in the team.
86+
* The seats that we would charge for could be more than this depending on the MINIMUM_NUMBER_OF_ORG_SEATS in case of an organization
87+
* For a team it would be the same as this value
88+
*/
89+
seatsUsed: number;
90+
/**
91+
* If provided, this is the exact number we would charge for.
92+
*/
93+
seatsToChargeFor?: number | null;
8594
userId: number;
8695
isOrg?: boolean;
8796
pricePerSeat: number | null;
8897
}) => {
89-
const { teamId, seats, userId, isOrg, pricePerSeat } = input;
98+
const { teamId, seatsToChargeFor, seatsUsed, userId, isOrg, pricePerSeat } = input;
9099
const { url } = await checkIfTeamPaymentRequired({ teamId });
91100
if (url) return { url };
92101

93-
// For orgs, enforce minimum of 30 seats
94-
const quantity = isOrg ? Math.max(seats, MINIMUM_NUMBER_OF_ORG_SEATS) : seats;
102+
// For orgs, enforce minimum of MINIMUM_NUMBER_OF_ORG_SEATS seats if `seatsToChargeFor` not set
103+
const seats = isOrg ? Math.max(seatsUsed, MINIMUM_NUMBER_OF_ORG_SEATS) : seatsUsed;
104+
const quantity = seatsToChargeFor ? seatsToChargeFor : seats;
105+
95106
const customer = await getStripeCustomerIdFromUserId(userId);
96107

97108
const session = await stripe.checkout.sessions.create({

packages/trpc/server/routers/viewer/organizations/publish.handler.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,10 @@ export const publishHandler = async ({ ctx }: PublishOptions) => {
3939
if (IS_TEAM_BILLING_ENABLED) {
4040
const checkoutSession = await purchaseTeamOrOrgSubscription({
4141
teamId: prevTeam.id,
42-
seats: Math.max(prevTeam.members.length, metadata.data?.orgSeats ?? 0),
42+
seatsUsed: prevTeam.members.length,
43+
seatsToChargeFor: metadata.data?.orgSeats
44+
? Math.max(prevTeam.members.length, metadata.data?.orgSeats ?? 0)
45+
: null,
4346
userId: ctx.user.id,
4447
isOrg: true,
4548
pricePerSeat: metadata.data?.orgPricePerSeat ?? null,

packages/trpc/server/routers/viewer/teams/publish.handler.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ const generateCheckoutSession = async ({
4545

4646
const checkoutSession = await purchaseTeamOrOrgSubscription({
4747
teamId,
48-
seats,
48+
seatsUsed: seats,
4949
userId,
5050
pricePerSeat: null,
5151
});

0 commit comments

Comments
 (0)