Skip to content

Commit 57befcf

Browse files
committed
Merge branch 'feat/org-payment-before-creation' of github.com:calcom/cal.com into feat/org-payment-before-creation
2 parents 11a678a + 0b4fa8f commit 57befcf

File tree

2 files changed

+167
-74
lines changed

2 files changed

+167
-74
lines changed

PR_TODO.md

+2
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ Description:
33
- We store the complete progress of the onboarding in OrganizationOnboarding table including any error that last happened and the end of lifecycle with isComplete set to true.
44
- Admin can configure a custom price for a customer(identified by email) and handover the onboarding to the customer(through a handover onboarding link). In this way, the customer still setups the onboarding himself but pays a custom price.
55
- Admin Doing organization onboarding on behalf of an email that doesn't exist in our system, is temporarily disabled. [Can be enabled in the future if needed]
6+
- Earlier Organization onboarding updated DB step by step but now in one go after the payment, we create everything - Domain Creation, Org Setup, Teams Creation, Teams Migration, Migrating Teams' members migration, member invitations. So, we have been extra careful with the logic to ensure errors don;t occur and if occur they are retried by webhook and also recorded in OrganizationOnboarding table.
67

78
Deprecations/Removals:
89
- NEXT_PUBLIC_ORGANIZATIONS_SELF_SERVE_PRICE env variable is removed and user must set NEXT_PUBLIC_ORGANIZATIONS_SELF_SERVE_PRICE_NEW with the difference that it doesn't have 00 in the end (37 instead of 3700 now). Reason was that 00 is a stripe specific thing and also because we store the price in DB with OrganizationOnboarding record and it doesn't make sense for it to be 3700 when infact it is 37.
@@ -61,6 +62,7 @@ Testing:
6162

6263
Followup Improvements:
6364
- [ ] If due to the number of people being invited during the onboarding if the seats increase, we should show that as a toast in the last onboarding step
65+
- [ ] Onboarding handover URL should take the user to the first step(intead of second step) where he could review the org and price details first
6466
- [ ] Allow admin to change the price of an existing onboarding. This is important because the admin might need to change the price of the onboarding after it is created and there can only be one onboarding for an orgOwner email.
6567
- [ ] Logo upload isn't working form onboarding(Existing bug)
6668
- [ ] Send telemetry event for org creation,

packages/features/ee/organizations/lib/OrganizationPaymentService.test.ts

+165-74
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { describe, expect, it, vi, beforeEach } from "vitest";
22

3-
import { ORGANIZATION_SELF_SERVE_MIN_SEATS, ORGANIZATION_SELF_SERVE_PRICE } from "@calcom/lib/constants";
3+
import { ORGANIZATION_SELF_SERVE_MIN_SEATS } from "@calcom/lib/constants";
44
import { prisma } from "@calcom/prisma";
55
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
66

@@ -9,9 +9,20 @@ import type { IOrganizationPermissionService } from "./OrganizationPermissionSer
99

1010
vi.stubEnv("STRIPE_ORG_PRODUCT_ID", "STRIPE_ORG_PRODUCT_ID");
1111
vi.stubEnv("STRIPE_ORG_MONTHLY_PRICE_ID", "STRIPE_ORG_MONTHLY_PRICE_ID");
12+
const defaultOrgOnboarding = {
13+
id: "onboard-id-1",
14+
name: "Test Org",
15+
slug: "test-org",
16+
orgOwnerEmail: "test@example.com",
17+
billingPeriod: "MONTHLY",
18+
seats: 5,
19+
pricePerSeat: 20,
20+
isComplete: false,
21+
stripeCustomerId: "mock_stripe_customer_id",
22+
};
1223

13-
vi.mock("@calcom/prisma", () => ({
14-
prisma: {
24+
vi.mock("@calcom/prisma", () => {
25+
const prismaMock = {
1526
organizationOnboarding: {
1627
findFirst: vi.fn(),
1728
create: vi.fn(),
@@ -22,9 +33,14 @@ vi.mock("@calcom/prisma", () => ({
2233
},
2334
user: {
2435
findUnique: vi.fn(),
36+
update: vi.fn(),
2537
},
26-
},
27-
}));
38+
};
39+
return {
40+
prisma: prismaMock,
41+
default: prismaMock,
42+
};
43+
});
2844

2945
vi.mock("@calcom/features/ee/billing/stripe-billling-service", () => ({
3046
StripeBillingService: vi.fn().mockImplementation(() => ({
@@ -75,10 +91,8 @@ describe("OrganizationPaymentService", () => {
7591

7692
describe("createPaymentIntent", () => {
7793
const baseInput = {
78-
id: 1,
79-
name: "Test Org",
80-
slug: "test-org",
81-
orgOwnerEmail: "test@example.com",
94+
bio: "BIO",
95+
logo: "LOGO",
8296
teams: [{ id: 1, isBeingMigrated: true, name: "Team 1", slug: "team1" }],
8397
};
8498

@@ -88,10 +102,13 @@ describe("OrganizationPaymentService", () => {
88102
);
89103

90104
await expect(
91-
service.createPaymentIntent({
92-
...baseInput,
93-
pricePerSeat: 1000, // Trying to modify price
94-
})
105+
service.createPaymentIntent(
106+
{
107+
...baseInput,
108+
pricePerSeat: 1000, // Trying to modify price
109+
},
110+
defaultOrgOnboarding
111+
)
95112
).rejects.toThrow("You do not have permission to modify the default payment settings");
96113
});
97114

@@ -103,18 +120,38 @@ describe("OrganizationPaymentService", () => {
103120
{ userId: 3, user: { email: "user3@example.com" } },
104121
]);
105122

106-
const result = await service.createPaymentIntent({
107-
...baseInput,
108-
invitedMembers: [{ email: "invited1@example.com" }], // Adding 1 invited member
109-
});
123+
const result = await service.createPaymentIntent(
124+
{
125+
...baseInput,
126+
invitedMembers: [{ email: "invited1@example.com" }], // Adding 1 invited member
127+
},
128+
defaultOrgOnboarding
129+
);
110130

111131
expect(result).toBeDefined();
112-
expect(prisma.organizationOnboarding.create).toHaveBeenCalledWith({
113-
data: expect.objectContaining({
114-
seats: Number(ORGANIZATION_SELF_SERVE_MIN_SEATS),
115-
billingPeriod: "MONTHLY",
116-
pricePerSeat: Number(ORGANIZATION_SELF_SERVE_PRICE),
117-
}),
132+
const updateCall = vi.mocked(prisma.organizationOnboarding.update).mock.calls[0][0];
133+
expect(updateCall.where).toEqual({ id: "onboard-id-1" });
134+
const { updatedAt, ...data } = updateCall.data;
135+
expect(data).toEqual({
136+
bio: "BIO",
137+
logo: "LOGO",
138+
teams: [
139+
{
140+
id: 1,
141+
isBeingMigrated: true,
142+
name: "Team 1",
143+
slug: "team1",
144+
},
145+
],
146+
invitedMembers: [
147+
{
148+
email: "invited1@example.com",
149+
},
150+
],
151+
stripeCustomerId: "mock_stripe_customer_id",
152+
pricePerSeat: defaultOrgOnboarding.pricePerSeat,
153+
billingPeriod: "MONTHLY",
154+
seats: Number(ORGANIZATION_SELF_SERVE_MIN_SEATS),
118155
});
119156
});
120157

@@ -127,22 +164,42 @@ describe("OrganizationPaymentService", () => {
127164
}))
128165
);
129166

130-
const result = await service.createPaymentIntent({
131-
...baseInput,
167+
const result = await service.createPaymentIntent(
168+
{
169+
...baseInput,
170+
invitedMembers: [
171+
{ email: "invited1@example.com" },
172+
{ email: "invited2@example.com" },
173+
{ email: "invited3@example.com" },
174+
], // Adding 3 invited members, total should be 7 as these new invites are pending
175+
},
176+
defaultOrgOnboarding
177+
);
178+
179+
expect(result).toBeDefined();
180+
const updateCall = vi.mocked(prisma.organizationOnboarding.update).mock.calls[0][0];
181+
expect(updateCall.where).toEqual({ id: "onboard-id-1" });
182+
const { updatedAt, ...data } = updateCall.data;
183+
expect(data).toEqual({
184+
bio: "BIO",
185+
logo: "LOGO",
186+
teams: [
187+
{
188+
id: 1,
189+
isBeingMigrated: true,
190+
name: "Team 1",
191+
slug: "team1",
192+
},
193+
],
132194
invitedMembers: [
133195
{ email: "invited1@example.com" },
134196
{ email: "invited2@example.com" },
135197
{ email: "invited3@example.com" },
136-
], // Adding 3 invited members, total should be 7 as these new invites are pending
137-
});
138-
139-
expect(result).toBeDefined();
140-
expect(prisma.organizationOnboarding.create).toHaveBeenCalledWith({
141-
data: expect.objectContaining({
142-
seats: 7,
143-
billingPeriod: "MONTHLY",
144-
pricePerSeat: Number(ORGANIZATION_SELF_SERVE_PRICE),
145-
}),
198+
],
199+
stripeCustomerId: "mock_stripe_customer_id",
200+
pricePerSeat: defaultOrgOnboarding.pricePerSeat,
201+
billingPeriod: "MONTHLY",
202+
seats: 7,
146203
});
147204
});
148205

@@ -157,63 +214,97 @@ describe("OrganizationPaymentService", () => {
157214

158215
it("should allow admin to override minimum seats to a lower value", async () => {
159216
const customSeats = 3;
160-
const result = await service.createPaymentIntent({
161-
...baseInput,
162-
seats: customSeats,
163-
});
164-
165-
expect(result).toBeDefined();
166-
expect(prisma.organizationOnboarding.create).toHaveBeenCalledWith({
167-
data: expect.objectContaining({
217+
await service.createPaymentIntent(
218+
{
219+
...baseInput,
220+
seats: customSeats,
221+
},
222+
{
223+
...defaultOrgOnboarding,
168224
seats: customSeats,
169-
billingPeriod: "MONTHLY",
170-
pricePerSeat: Number(ORGANIZATION_SELF_SERVE_PRICE),
171-
name: "Test Org",
172-
slug: "test-org",
173-
orgOwnerEmail: "test@example.com",
174-
teams: [{ id: 1, isBeingMigrated: true, name: "Team 1", slug: "team1" }],
175-
}),
225+
}
226+
);
227+
228+
expect(vi.mocked(prisma.organizationOnboarding.update)).toHaveBeenCalled();
229+
const updateCall = vi.mocked(prisma.organizationOnboarding.update).mock.calls[0][0];
230+
expect(updateCall.where).toEqual({ id: "onboard-id-1" });
231+
const { updatedAt, ...data } = updateCall.data;
232+
expect(data).toEqual({
233+
bio: "BIO",
234+
logo: "LOGO",
235+
teams: [
236+
{
237+
id: 1,
238+
isBeingMigrated: true,
239+
name: "Team 1",
240+
slug: "team1",
241+
},
242+
],
243+
stripeCustomerId: "mock_stripe_customer_id",
244+
pricePerSeat: defaultOrgOnboarding.pricePerSeat,
245+
billingPeriod: "MONTHLY",
246+
seats: customSeats,
176247
});
177248
});
178249

179250
it("should allow admin to override price per seat", async () => {
180251
const customPrice = 1000;
181-
const result = await service.createPaymentIntent({
182-
...baseInput,
252+
const result = await service.createPaymentIntent(baseInput, {
253+
...defaultOrgOnboarding,
183254
pricePerSeat: customPrice,
184255
});
185256

186257
expect(result).toBeDefined();
187-
expect(prisma.organizationOnboarding.create).toHaveBeenCalledWith({
188-
data: expect.objectContaining({
189-
seats: Number(ORGANIZATION_SELF_SERVE_MIN_SEATS),
190-
billingPeriod: "MONTHLY",
191-
pricePerSeat: customPrice,
192-
name: "Test Org",
193-
slug: "test-org",
194-
orgOwnerEmail: "test@example.com",
195-
teams: [{ id: 1, isBeingMigrated: true, name: "Team 1", slug: "team1" }],
196-
}),
258+
const updateCall = vi.mocked(prisma.organizationOnboarding.update).mock.calls[0][0];
259+
expect(updateCall.where).toEqual({ id: "onboard-id-1" });
260+
const { updatedAt, ...data } = updateCall.data;
261+
expect(data).toEqual({
262+
bio: "BIO",
263+
logo: "LOGO",
264+
teams: [
265+
{
266+
id: 1,
267+
isBeingMigrated: true,
268+
name: "Team 1",
269+
slug: "team1",
270+
},
271+
],
272+
stripeCustomerId: "mock_stripe_customer_id",
273+
pricePerSeat: customPrice,
274+
billingPeriod: "MONTHLY",
275+
seats: 5,
197276
});
198277
});
199278

200279
it("should create custom price in Stripe when admin overrides price", async () => {
201280
const customPrice = 1500;
202281

203-
await service.createPaymentIntent({
204-
...baseInput,
205-
pricePerSeat: customPrice,
206-
});
282+
await service.createPaymentIntent(
283+
{
284+
...baseInput,
285+
pricePerSeat: customPrice,
286+
},
287+
{
288+
...defaultOrgOnboarding,
289+
pricePerSeat: customPrice,
290+
}
291+
);
207292

208293
// Verify a custom price was created in Stripe
209-
expect(vi.mocked(service["billingService"].createPrice)).toHaveBeenCalledWith({
210-
amount: customPrice * 100, // Convert to cents for Stripe
211-
currency: "usd",
212-
interval: "month",
213-
productId: "STRIPE_ORG_PRODUCT_ID",
214-
metadata: expect.any(Object),
215-
nickname: expect.any(String),
216-
});
294+
expect(vi.mocked(service["billingService"].createPrice)).toHaveBeenCalledWith(
295+
expect.objectContaining({
296+
amount: customPrice * 100, // Convert to cents for Stripe
297+
currency: "usd",
298+
interval: "month",
299+
productId: "STRIPE_ORG_PRODUCT_ID",
300+
nickname: expect.stringContaining("Custom Organization Price"),
301+
metadata: expect.objectContaining({
302+
billingPeriod: "MONTHLY",
303+
organizationOnboardingId: "onboard-id-1",
304+
pricePerSeat: customPrice,
305+
}),
306+
})
307+
);
217308
});
218309
});
219310
});

0 commit comments

Comments
 (0)