Skip to content

Commit 7b69a6a

Browse files
sean-brydonPeerRichhariombalhara
authored
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>
1 parent f897c68 commit 7b69a6a

File tree

8 files changed

+356
-32
lines changed

8 files changed

+356
-32
lines changed

packages/features/ee/organizations/components/CreateANewOrganizationForm.tsx

+41-1
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
1313
import { UserPermissionRole } from "@calcom/prisma/enums";
1414
import { trpc } from "@calcom/trpc/react";
1515
import type { Ensure } from "@calcom/types/utils";
16-
import { Alert, Button, Form, RadioGroup as RadioArea, TextField } from "@calcom/ui";
16+
import { Alert, Button, Form, Label, RadioGroup as RadioArea, TextField, ToggleGroup } from "@calcom/ui";
1717

1818
function extractDomainFromEmail(email: string) {
1919
let out = "";
@@ -32,6 +32,11 @@ export const CreateANewOrganizationForm = () => {
3232
return <CreateANewOrganizationFormChild session={session} />;
3333
};
3434

35+
enum BillingPeriod {
36+
MONTHLY = "MONTHLY",
37+
ANNUALLY = "ANNUALLY",
38+
}
39+
3540
const CreateANewOrganizationFormChild = ({
3641
session,
3742
}: {
@@ -47,11 +52,13 @@ const CreateANewOrganizationFormChild = ({
4752
const newOrganizationFormMethods = useForm<{
4853
name: string;
4954
seats: number;
55+
billingPeriod: BillingPeriod;
5056
pricePerSeat: number;
5157
slug: string;
5258
orgOwnerEmail: string;
5359
}>({
5460
defaultValues: {
61+
billingPeriod: BillingPeriod.MONTHLY,
5562
slug: !isAdmin ? deriveSlugFromEmail(defaultOrgOwnerEmail) : undefined,
5663
orgOwnerEmail: !isAdmin ? defaultOrgOwnerEmail : undefined,
5764
name: !isAdmin ? deriveOrgNameFromEmail(defaultOrgOwnerEmail) : undefined,
@@ -107,6 +114,39 @@ const CreateANewOrganizationFormChild = ({
107114
<Alert severity="error" message={serverErrorMessage} />
108115
</div>
109116
)}
117+
{isAdmin && (
118+
<div className="mb-5">
119+
<Controller
120+
name="billingPeriod"
121+
control={newOrganizationFormMethods.control}
122+
render={({ field: { value, onChange } }) => (
123+
<>
124+
<Label htmlFor="billingPeriod">Billing Period</Label>
125+
<ToggleGroup
126+
isFullWidth
127+
id="billingPeriod"
128+
value={value}
129+
onValueChange={(e: BillingPeriod) => {
130+
if ([BillingPeriod.ANNUALLY, BillingPeriod.MONTHLY].includes(e)) {
131+
onChange(e);
132+
}
133+
}}
134+
options={[
135+
{
136+
value: "MONTHLY",
137+
label: "Monthly",
138+
},
139+
{
140+
value: "ANNUALLY",
141+
label: "Annually",
142+
},
143+
]}
144+
/>
145+
</>
146+
)}
147+
/>
148+
</div>
149+
)}
110150
<Controller
111151
name="orgOwnerEmail"
112152
control={newOrganizationFormMethods.control}

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

+200
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,206 @@ describe("purchaseTeamOrOrgSubscription", () => {
130130
})
131131
);
132132
});
133+
it("Should create a monthly subscription if billing period is set to monthly", async () => {
134+
const FAKE_PAYMENT_ID = "FAKE_PAYMENT_ID";
135+
const user = await prismock.user.create({
136+
data: {
137+
name: "test",
138+
email: "test@email.com",
139+
},
140+
});
141+
142+
const checkoutSessionsCreate = mockStripeCheckoutSessionsCreate({
143+
url: "SESSION_URL",
144+
});
145+
146+
mockStripeCheckoutSessionRetrieve(
147+
{
148+
currency: "USD",
149+
product: {
150+
id: "PRODUCT_ID",
151+
},
152+
},
153+
[FAKE_PAYMENT_ID]
154+
);
155+
156+
mockStripeCheckoutPricesRetrieve({
157+
id: "PRICE_ID",
158+
product: {
159+
id: "PRODUCT_ID",
160+
},
161+
});
162+
163+
const checkoutPricesCreate = mockStripePricesCreate({
164+
id: "PRICE_ID",
165+
});
166+
167+
const team = await prismock.team.create({
168+
data: {
169+
name: "test",
170+
metadata: {
171+
paymentId: FAKE_PAYMENT_ID,
172+
},
173+
},
174+
});
175+
176+
const seatsToChargeFor = 1000;
177+
expect(
178+
await purchaseTeamOrOrgSubscription({
179+
teamId: team.id,
180+
seatsUsed: 10,
181+
seatsToChargeFor,
182+
userId: user.id,
183+
isOrg: true,
184+
pricePerSeat: 100,
185+
billingPeriod: "MONTHLY",
186+
})
187+
).toEqual({ url: "SESSION_URL" });
188+
189+
expect(checkoutPricesCreate).toHaveBeenCalledWith(
190+
expect.objectContaining({ recurring: { interval: "month" } })
191+
);
192+
193+
expect(checkoutSessionsCreate).toHaveBeenCalledWith(
194+
expect.objectContaining({
195+
line_items: [
196+
{
197+
price: "PRICE_ID",
198+
quantity: seatsToChargeFor,
199+
},
200+
],
201+
})
202+
);
203+
});
204+
it("Should create a annual subscription if billing period is set to annual", async () => {
205+
const FAKE_PAYMENT_ID = "FAKE_PAYMENT_ID";
206+
const user = await prismock.user.create({
207+
data: {
208+
name: "test",
209+
email: "test@email.com",
210+
},
211+
});
212+
213+
const checkoutSessionsCreate = mockStripeCheckoutSessionsCreate({
214+
url: "SESSION_URL",
215+
});
216+
217+
mockStripeCheckoutSessionRetrieve(
218+
{
219+
currency: "USD",
220+
product: {
221+
id: "PRODUCT_ID",
222+
},
223+
},
224+
[FAKE_PAYMENT_ID]
225+
);
226+
227+
mockStripeCheckoutPricesRetrieve({
228+
id: "PRICE_ID",
229+
product: {
230+
id: "PRODUCT_ID",
231+
},
232+
});
233+
234+
const checkoutPricesCreate = mockStripePricesCreate({
235+
id: "PRICE_ID",
236+
});
237+
238+
const team = await prismock.team.create({
239+
data: {
240+
name: "test",
241+
metadata: {
242+
paymentId: FAKE_PAYMENT_ID,
243+
},
244+
},
245+
});
246+
247+
const seatsToChargeFor = 1000;
248+
expect(
249+
await purchaseTeamOrOrgSubscription({
250+
teamId: team.id,
251+
seatsUsed: 10,
252+
seatsToChargeFor,
253+
userId: user.id,
254+
isOrg: true,
255+
pricePerSeat: 100,
256+
billingPeriod: "ANNUALLY",
257+
})
258+
).toEqual({ url: "SESSION_URL" });
259+
260+
expect(checkoutPricesCreate).toHaveBeenCalledWith(
261+
expect.objectContaining({ recurring: { interval: "year" } })
262+
);
263+
264+
expect(checkoutSessionsCreate).toHaveBeenCalledWith(
265+
expect.objectContaining({
266+
line_items: [
267+
{
268+
price: "PRICE_ID",
269+
quantity: seatsToChargeFor,
270+
},
271+
],
272+
})
273+
);
274+
});
275+
276+
it("It should not create a custom price if price_per_seat is not set", async () => {
277+
const FAKE_PAYMENT_ID = "FAKE_PAYMENT_ID";
278+
const user = await prismock.user.create({
279+
data: {
280+
name: "test",
281+
email: "test@email.com",
282+
},
283+
});
284+
285+
mockStripeCheckoutSessionsCreate({
286+
url: "SESSION_URL",
287+
});
288+
289+
mockStripeCheckoutSessionRetrieve(
290+
{
291+
currency: "USD",
292+
product: {
293+
id: "PRODUCT_ID",
294+
},
295+
},
296+
[FAKE_PAYMENT_ID]
297+
);
298+
299+
mockStripeCheckoutPricesRetrieve({
300+
id: "PRICE_ID",
301+
product: {
302+
id: "PRODUCT_ID",
303+
},
304+
});
305+
306+
const checkoutPricesCreate = mockStripePricesCreate({
307+
id: "PRICE_ID",
308+
});
309+
310+
const team = await prismock.team.create({
311+
data: {
312+
name: "test",
313+
metadata: {
314+
paymentId: FAKE_PAYMENT_ID,
315+
},
316+
},
317+
});
318+
319+
const seatsToChargeFor = 1000;
320+
expect(
321+
await purchaseTeamOrOrgSubscription({
322+
teamId: team.id,
323+
seatsUsed: 10,
324+
seatsToChargeFor,
325+
userId: user.id,
326+
isOrg: true,
327+
billingPeriod: "ANNUALLY",
328+
})
329+
).toEqual({ url: "SESSION_URL" });
330+
331+
expect(checkoutPricesCreate).not.toHaveBeenCalled();
332+
});
133333
});
134334

135335
describe("updateQuantitySubscriptionFromStripe", () => {

0 commit comments

Comments
 (0)