chore: organization onboarding refactor (#24381)

* feat: redirect to new onboarding flow

* Getting started

* Brand details

* Preview organization brands

* Orgs team pages

* Invite team steps

* Move to global zustand store

* Few darkmdoe fixes

* Wip onboarding + stripe flow

* Default plan state

Server Action for gettting slug satus of org

* Remove onboardingId

* Confirmation prompt

* Update old onboarding flow handlers to handle new fields

* update onboarding hook

* Filter out organization section for none -company emails

* Match placeholders to users domain

* Drop migration

* Wip new onboarding intent

* WIP flow for self-hosted. Same service call just split logic

* WIP

* Add TODO

* Use onboarding user type instead of trpc session

* WIP

* WIP

* pass role and team name from onboarding to save in schema

* Add test to ensure role + name + team are persisted into onboarding table

* migrate roles to enum values

* Update ENUM

* Fix type error

* Redirect if flag is disabled

* Remove web

* WIP

* WIP

* Fix migration

* Fix calls

* User onboarding User types instead of trpc session

* Fix factory tests

* Fix flow for self hoste

* Type error

* More type fixes

* Fix handler tests

* Fix enum return type being different

* Use consistant types across the oganization stuff

* Fix

* Use TEAM_BILLING for e2e test

* Refactor is not company email and add tests

* Fix

* Fix

* Refactor flow to submit after form complete

* Fix flow with billing disabled

* Fix tests

* Apply suggestion from @coderabbitai[bot]

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Rename and move test files

* WIP

* Fix types

* Update repo paths + tests

* Move to service folder

* Fix tests

* Fix types

* Remove old test files

* Restore lock

* Fix path

* Fix tests with new paths and factory logic

* Fix updaetdAt

* WIP onboardingID isolation

* Fix e2e test

* verify test

* Code rabbit

* Rename SelfHostedOnboardongService -> SelfHostedOrganizationOnboardingService

* Fix stores

* Fix type error

* Fix types

* remove tsignore

* Apply suggestion from @coderabbitai[bot]

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* NITS

* Add the logic to auto complete admin org when billing enabled

* Fix store being weird

* We need to return the parsed value

* fixes

* sync from db always

* Add onboardingSgtore tests

* fix test

* remove step and status

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
This commit is contained in:
sean-brydon
2025-10-18 14:13:30 +00:00
committed by GitHub
co-authored by coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Hariom Balhara
parent 1eb6ff3e65
commit fa35cc5210
48 changed files with 4959 additions and 1813 deletions
@@ -0,0 +1,22 @@
import { _generateMetadata } from "app/_utils";
import ResumeOnboardingPage, { LayoutWrapper } from "~/settings/organizations/new/resume-view";
export const generateMetadata = async () =>
await _generateMetadata(
(t) => t("resume_onboarding"),
(t) => t("resume_onboarding_description"),
undefined,
undefined,
"/settings/organizations/new/resume"
);
const ServerPage = async () => {
return (
<LayoutWrapper>
<ResumeOnboardingPage />
</LayoutWrapper>
);
};
export default ServerPage;
@@ -205,6 +205,7 @@ const AddNewTeamsFormChild = ({ teams }: { teams: { id: number; name: string; sl
<TextField
key={field.id}
{...register(`teams.${index}.name`)}
data-testid={`team.${index}.name`}
label=""
addOnClassname="bg-transparent p-0 border-l-0"
className={index > 0 ? "mb-2" : ""}
@@ -34,28 +34,28 @@ const useOrgCreation = () => {
const session = useSession();
const utils = trpc.useUtils();
const [serverErrorMessage, setServerErrorMessage] = useState("");
const { useOnboardingStore, isBillingEnabled } = useOnboarding();
const { useOnboardingStore } = useOnboarding();
const { reset } = useOnboardingStore();
const checkoutMutation = trpc.viewer.organizations.createWithPaymentIntent.useMutation({
onSuccess: (data) => {
if (data.checkoutUrl) {
window.location.href = data.checkoutUrl;
}
},
onError: (error) => {
setServerErrorMessage(t(error.message));
},
});
const createOrgMutation = trpc.viewer.organizations.createSelfHosted.useMutation({
// Single mutation for all flows (billing, self-hosted, admin)
const intentToCreateOrgMutation = trpc.viewer.organizations.intentToCreateOrg.useMutation({
onSuccess: async (data) => {
if (data.organization) {
// Invalidate the organizations query to ensure fresh data on the next page
reset({
onboardingId: data.organizationOnboardingId,
});
if (data.checkoutUrl) {
// Billing enabled - redirect to Stripe
window.location.href = data.checkoutUrl;
} else if (data.organizationId) {
// Self-hosted - org already created, redirect to organizations
await utils.viewer.organizations.listCurrent.invalidate();
await session.update();
reset();
window.location.href = `${window.location.origin}/settings/organizations/profile`;
} else {
// Unexpected state
setServerErrorMessage("Unexpected response from server");
}
},
onError: (error) => {
@@ -63,12 +63,10 @@ const useOrgCreation = () => {
},
});
const mutationToUse = isBillingEnabled ? checkoutMutation : createOrgMutation;
return {
mutation: mutationToUse,
mutate: mutationToUse.mutate,
isPending: mutationToUse.isPending,
mutation: intentToCreateOrgMutation,
mutate: intentToCreateOrgMutation.mutate,
isPending: intentToCreateOrgMutation.isPending,
errorMessage: serverErrorMessage,
};
};
@@ -84,7 +82,13 @@ export const AddNewTeamMembersForm = () => {
invitedMembers,
logo,
bio,
onboardingId,
name,
slug,
billingPeriod,
seats,
pricePerSeat,
brandColor,
bannerUrl,
} = useOnboardingStore();
const orgCreation = useOrgCreation();
@@ -155,7 +159,7 @@ export const AddNewTeamMembersForm = () => {
placeholder="colleague@company.com"
/>
</div>
<Button type="submit" StartIcon="plus" color="secondary">
<Button type="submit" StartIcon="plus" color="secondary" data-testid="invite-new-member-button">
{t("add")}
</Button>
</form>
@@ -203,22 +207,31 @@ export const AddNewTeamMembersForm = () => {
)}
</div>
<div className="mt-3 mt-6 flex items-center justify-end">
<div className="mt-3 flex items-center justify-end">
<Button
data-testid="publish-button"
onClick={() => {
if (!onboardingId) {
console.error("Org owner email and onboardingId are required", {
orgOwnerEmail,
onboardingId,
});
// Submit ALL data to intentToCreateOrg
if (!name || !slug || !orgOwnerEmail) {
console.error("Required fields missing", { name, slug, orgOwnerEmail });
showToast(t("required_fields_missing"), "error");
return;
}
orgCreation.mutation.mutate({
orgCreation.mutate({
name,
slug,
orgOwnerEmail,
seats,
pricePerSeat,
billingPeriod,
creationSource: "WEBAPP" as const,
logo,
bio,
brandColor,
bannerUrl,
teams,
invitedMembers,
onboardingId,
});
}}
loading={orgCreation.isPending}>
@@ -6,8 +6,8 @@ import { useEffect, useState } from "react";
import { useOnboarding } from "@calcom/features/ee/organizations/lib/onboardingStore";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
import { Icon } from "@calcom/ui/components/icon";
import { Button } from "@calcom/ui/components/button";
import { Icon } from "@calcom/ui/components/icon";
const PaymentStatusView = () => {
const { t } = useLocale();
@@ -15,9 +15,7 @@ const PaymentStatusView = () => {
const searchParams = useSearchParams();
const paymentStatus = searchParams?.get("paymentStatus");
const paymentError = searchParams?.get("error");
const { useOnboardingStore } = useOnboarding({
step: "status",
});
const { useOnboardingStore } = useOnboarding();
const [organizationCreated, setOrganizationCreated] = useState<boolean>(false);
const { name } = useOnboardingStore();
@@ -0,0 +1,114 @@
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect } from "react";
import { useOnboarding } from "@calcom/features/ee/organizations/lib/onboardingStore";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Alert } from "@calcom/ui/components/alert";
import { WizardLayout } from "@calcom/ui/components/layout";
import { SkeletonContainer, SkeletonText } from "@calcom/ui/components/skeleton";
export const LayoutWrapper = ({ children }: { children: React.ReactNode }) => {
return (
<WizardLayout currentStep={1} maxSteps={5}>
{children}
</WizardLayout>
);
};
const ResumeOnboardingView = () => {
const { t } = useLocale();
const router = useRouter();
const searchParams = useSearchParams();
const onboardingIdParam = searchParams?.get("onboardingId");
const { dbOnboarding, isLoadingOrgOnboarding, useOnboardingStore } = useOnboarding();
const { reset } = useOnboardingStore();
useEffect(() => {
if (isLoadingOrgOnboarding) {
return;
}
if (!onboardingIdParam) {
router.push("/settings/organizations/new");
return;
}
if (!dbOnboarding) {
return;
}
if (dbOnboarding.isComplete) {
router.push("/settings/organizations");
return;
}
// Load onboarding data into store
reset({
onboardingId: dbOnboarding.id,
name: dbOnboarding.name,
slug: dbOnboarding.slug,
orgOwnerEmail: dbOnboarding.orgOwnerEmail,
billingPeriod: dbOnboarding.billingPeriod,
seats: dbOnboarding.seats,
pricePerSeat: dbOnboarding.pricePerSeat,
logo: dbOnboarding.logo,
bio: dbOnboarding.bio,
brandColor: dbOnboarding.brandColor,
bannerUrl: dbOnboarding.bannerUrl,
});
// Redirect to next step (About page)
router.push("/settings/organizations/new/about");
}, [dbOnboarding, isLoadingOrgOnboarding, onboardingIdParam, reset, router]);
if (isLoadingOrgOnboarding) {
return (
<SkeletonContainer className="space-y-4">
<SkeletonText className="h-8 w-full" />
<SkeletonText className="h-4 w-3/4" />
<SkeletonText className="h-4 w-1/2" />
</SkeletonContainer>
);
}
if (!onboardingIdParam) {
return (
<Alert
data-testid="error"
severity="error"
title={t("error")}
message={t("no_onboarding_id_provided")}
/>
);
}
if (!dbOnboarding) {
return (
<Alert data-testid="error" severity="error" title={t("error")} message={t("onboarding_not_found")} />
);
}
if (dbOnboarding.isComplete) {
return (
<Alert
data-testid="error"
severity="info"
title={t("onboarding_already_complete")}
message={t("onboarding_already_complete_description")}
/>
);
}
// Loading state while redirecting
return (
<SkeletonContainer className="space-y-4">
<SkeletonText className="h-8 w-full" />
<SkeletonText className="h-4 w-3/4" />
</SkeletonContainer>
);
};
export default ResumeOnboardingView;
@@ -0,0 +1,451 @@
import type { Page } from "@playwright/test";
import { expect } from "@playwright/test";
import { uuid } from "short-uuid";
import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants";
import { test } from "../lib/fixtures";
/**
* Utility function to complete the organization onboarding wizard steps
* (about, add-teams, onboard-members)
*/
async function completeOnboardingWizard(
page: Page,
options: {
aboutText?: string;
teams?: string[];
members?: string[];
} = {}
) {
const { aboutText = "Test organization description", teams = ["Engineering"], members = [] } = options;
// Step 1: Complete "About" step
await page.waitForURL("**/settings/organizations/new/about");
await page.locator('textarea[name="about"]').fill(aboutText);
await page.locator("button[type=submit]").click();
// Step 2: Complete "Add Teams" step
await page.waitForURL("**/settings/organizations/new/add-teams");
for (let i = 0; i < teams.length; i++) {
await page.getByTestId(`team.${i}.name`).fill(teams[i]);
if (i < teams.length - 1) {
await page.getByTestId("add_a_team").click();
}
}
await page.locator("button[type=submit]").click();
// Step 3: Complete "Onboard Members" step
await page.waitForURL("**/settings/organizations/new/onboard-members");
// Add members if provided
for (const memberEmail of members) {
await page.locator('[placeholder="colleague\\@company\\.com"]').fill(memberEmail);
await page.getByTestId("invite-new-member-button").click();
await expect(page.getByTestId("pending-member-list")).toContainText(memberEmail);
}
// Click publish and wait for response
const responsePromise = page.waitForResponse("**/api/trpc/organizations/intentToCreateOrg**");
await page.getByTestId("publish-button").click();
const response = await responsePromise;
const responseData = await response.json();
// TRPC batch response is an array with one element
const result = responseData[0].result.data.json;
return result;
}
test.describe("Organization Creation Flows - Comprehensive Suite", () => {
test.afterEach(({ users, orgs }) => {
users.deleteAll();
orgs.deleteAll();
});
test.describe("Admin Handover Flow (Billing Disabled)", () => {
test("Admin creates org for existing user - handover and complete", async ({ page, users }) => {
// Setup: Create admin and future owner
const admin = await users.create({ role: "ADMIN" });
await admin.apiLogin();
const ownerEmail = users.trackEmail({
username: "orgowner",
domain: "example.com",
});
const ownerUser = await users.create({
username: "orgowner",
email: ownerEmail,
});
const orgName = "Test Organization";
const orgSlug = `test-org-${uuid()}`.toLowerCase();
// Step 1: Admin fills form
await page.goto("/settings/organizations/new");
await page.locator("input[name=orgOwnerEmail]").fill(ownerEmail);
await page.locator("input[name=name]").fill(orgName);
await page.locator("input[name=slug]").fill(orgSlug);
// If billing enabled, admin sees billing fields but we're testing billing disabled scenario
if (IS_TEAM_BILLING_ENABLED) {
await page.locator("input[name=seats]").fill("10");
await page.locator("input[name=pricePerSeat]").fill("15");
}
// Submit and wait for intentToCreateOrg
await Promise.all([
page.waitForResponse("**/api/trpc/organizations/intentToCreateOrg**"),
page.locator("button[type=submit]").click(),
]);
// Step 2: Verify admin is on handover page
await page.waitForURL("**/settings/organizations/new/handover");
const onboardingUrlElement = page.getByTestId("onboarding-url");
await expect(onboardingUrlElement).toBeVisible();
const onboardingUrl = await onboardingUrlElement.textContent();
expect(onboardingUrl).toContain("/settings/organizations/new/resume?onboardingId=");
// Extract onboardingId from URL
const onboardingIdMatch = onboardingUrl?.match(/onboardingId=([\w-]+)/);
expect(onboardingIdMatch).toBeTruthy();
const onboardingId = onboardingIdMatch?.[1];
// Step 3: Switch to owner user
await page.context().clearCookies();
await ownerUser.apiLogin();
// Step 4: Owner opens resume URL and completes the onboarding wizard
await page.goto(`/settings/organizations/new/resume?onboardingId=${onboardingId}`);
const memberEmail = users.trackEmail({ username: "member1", domain: "example.com" });
const result = await completeOnboardingWizard(page, {
aboutText: "This is our test organization",
teams: ["Engineering", "Marketing"],
members: [memberEmail],
});
// Verify the organization was created successfully in the API response
expect(result.organizationId).toBeTruthy();
expect(result.name).toBe(orgName);
expect(result.slug).toBe(orgSlug);
});
test("Admin creates org for self - immediate creation", async ({ page, users }) => {
const admin = await users.create({ role: "ADMIN" });
await admin.apiLogin();
const orgName = "Admin Org";
const orgSlug = `admin-org-${uuid()}`.toLowerCase();
await page.goto("/settings/organizations/new");
// Fill form with admin's own email
await page.locator("input[name=orgOwnerEmail]").fill(admin.email);
await page.locator("input[name=name]").fill(orgName);
await page.locator("input[name=slug]").fill(orgSlug);
if (IS_TEAM_BILLING_ENABLED) {
await page.locator("input[name=seats]").fill("5");
await page.locator("input[name=pricePerSeat]").fill("20");
}
// Submit - admin creating for self goes through wizard
await page.locator("button[type=submit]").click();
// Admin creating for self goes through the onboarding wizard
const result = await completeOnboardingWizard(page, {
aboutText: "Admin's organization",
teams: ["Operations"],
members: [],
});
// Verify the organization was created successfully
expect(result.organizationId).toBeTruthy();
expect(result.name).toBe(orgName);
expect(result.slug).toBe(orgSlug);
});
test("Admin handover URL structure is correct", async ({ page, users }) => {
const admin = await users.create({ role: "ADMIN" });
await admin.apiLogin();
const ownerEmail = users.trackEmail({
username: "testowner",
domain: "example.com",
});
await users.create({
username: "testowner",
email: ownerEmail,
});
const orgName = "Handover Test";
const orgSlug = `handover-${uuid()}`.toLowerCase();
await page.goto("/settings/organizations/new");
await page.locator("input[name=orgOwnerEmail]").fill(ownerEmail);
await page.locator("input[name=name]").fill(orgName);
await page.locator("input[name=slug]").fill(orgSlug);
if (IS_TEAM_BILLING_ENABLED) {
await page.locator("input[name=seats]").fill("10");
await page.locator("input[name=pricePerSeat]").fill("15");
}
const responsePromise = page.waitForResponse("**/api/trpc/organizations/intentToCreateOrg**");
await page.locator("button[type=submit]").click();
// Wait for the response
await responsePromise;
// Wait for redirect to handover page
await page.waitForURL("**/settings/organizations/new/handover");
// Wait for handover page elements to be visible (with longer timeout for store to populate)
await expect(page.getByTestId("onboarding-url")).toBeVisible({ timeout: 10000 });
await expect(page.getByTestId("copy-onboarding-url")).toBeVisible();
const onboardingUrl = await page.getByTestId("onboarding-url").textContent();
// Verify URL format
expect(onboardingUrl).toMatch(/\/settings\/organizations\/new\/resume\?onboardingId=[\w-]+/);
// Verify copy button is clickable (toast message may vary by locale)
await page.getByTestId("copy-onboarding-url").click();
});
});
test.describe("Non-Admin User Flow", () => {
test("Regular user creates org through full wizard", async ({ page, users }) => {
test.skip(process.env.NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED !== "1", "Org self serve is not enabled");
const user = await users.create({
username: "regularuser",
email: users.trackEmail({ username: "regularuser", domain: "example.com" }),
});
await user.apiLogin();
const orgName = "User Organization";
const orgSlug = `user-org-${uuid()}`.toLowerCase();
await page.goto("/settings/organizations/new");
// Verify user email is pre-filled and disabled
const emailInput = page.locator("input[name=orgOwnerEmail]");
await expect(emailInput).toBeDisabled();
await expect(emailInput).toHaveValue(user.email);
// Verify billing fields are NOT visible to regular users
if (IS_TEAM_BILLING_ENABLED) {
await expect(page.locator("input[name=seats]")).not.toBeVisible();
await expect(page.locator("input[name=pricePerSeat]")).not.toBeVisible();
// Verify "Upgrade to Organizations" UI is shown
await expect(page.getByText(/upgrade to organizations/i)).toBeVisible();
}
// Fill form
await page.locator("input[name=name]").fill(orgName);
await page.locator("input[name=slug]").fill(orgSlug);
await page.locator("button[type=submit]").click();
// Verify navigated to /about step
await page.waitForURL("**/settings/organizations/new/about");
// Complete about step
await page.locator('textarea[name="about"]').fill("User's organization description");
await page.locator("button[type=submit]").click();
// Complete add-teams step
await page.waitForURL("**/settings/organizations/new/add-teams");
await page.getByTestId("team.0.name").fill("Team Alpha");
await page.locator("button[type=submit]").click();
// Complete onboard-members step
await page.waitForURL("**/settings/organizations/new/onboard-members");
await Promise.all([
page.waitForResponse("**/api/trpc/organizations/intentToCreateOrg**"),
page.getByTestId("publish-button").click(),
]);
// In billing enabled mode, should redirect to Stripe or payment
// In billing disabled mode, org created immediately
if (IS_TEAM_BILLING_ENABLED) {
// Would redirect to Stripe checkout
await page.waitForURL(/checkout\.stripe\.com|\/settings\/organizations/);
} else {
// After org creation, session update causes redirect to profile page
// Wait for it to load, then navigate to organizations
await page.waitForURL("**/settings/my-account/profile");
await page.goto("/settings/organizations");
await page.waitForURL(/\/settings\/organizations/);
}
});
test("Regular user - email field is disabled", async ({ page, users }) => {
test.skip(process.env.NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED !== "1", "Org self serve is not enabled");
const user = await users.create({
username: "testuser",
email: users.trackEmail({ username: "testuser", domain: "company.com" }),
});
await user.apiLogin();
await page.goto("/settings/organizations/new");
// Verify email field state
const emailInput = page.locator("input[name=orgOwnerEmail]");
await expect(emailInput).toBeVisible();
await expect(emailInput).toBeDisabled();
await expect(emailInput).toHaveValue(user.email);
// Verify name and slug are derived from email domain
const nameInput = page.locator("input[name=name]");
const slugInput = page.locator("input[name=slug]");
// Name should be capitalized version of domain
const nameValue = await nameInput.inputValue();
expect(nameValue).toBeTruthy();
expect(nameValue).toMatch(/company/i);
// Slug should be domain-based
const slugValue = await slugInput.inputValue();
expect(slugValue).toBeTruthy();
expect(slugValue).toContain("company");
});
});
test.describe("Form Validation", () => {
test("Admin form - required fields validation", async ({ page, users }) => {
const admin = await users.create({ role: "ADMIN" });
await admin.apiLogin();
await page.goto("/settings/organizations/new");
// Submit empty form
await page.locator("button[type=submit]").click();
// Should show validation errors
await expect(page.getByTestId("field-error")).toHaveCount(2); // name and slug required
});
test("Slug already taken shows error", async ({ page, users, orgs }) => {
const admin = await users.create({ role: "ADMIN" });
await admin.apiLogin();
// Create an existing org with a specific slug
const existingSlug = `existing-${uuid()}`.toLowerCase();
await orgs.create({
name: "Existing Org",
slug: existingSlug,
});
await page.goto("/settings/organizations/new");
const ownerEmail = users.trackEmail({ username: "owner", domain: "example.com" });
await users.create({ username: "owner", email: ownerEmail });
await page.locator("input[name=orgOwnerEmail]").fill(ownerEmail);
await page.locator("input[name=name]").fill("New Org");
await page.locator("input[name=slug]").fill(existingSlug);
if (IS_TEAM_BILLING_ENABLED) {
await page.locator("input[name=seats]").fill("5");
await page.locator("input[name=pricePerSeat]").fill("10");
}
await page.locator("button[type=submit]").click();
// Should show slug taken error
await expect(page.getByText(/url.*taken|already.*exists/i)).toBeVisible();
});
});
test.describe("Navigation Between Steps", () => {
test("User can navigate through wizard steps", async ({ page, users }) => {
test.skip(process.env.NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED !== "1", "Org self serve is not enabled");
const user = await users.create({
username: "wizarduser",
email: users.trackEmail({ username: "wizarduser", domain: "example.com" }),
});
await user.apiLogin();
await page.goto("/settings/organizations/new");
const orgName = "Wizard Test";
const orgSlug = `wizard-${uuid()}`.toLowerCase();
// Step 1
await page.locator("input[name=name]").fill(orgName);
await page.locator("input[name=slug]").fill(orgSlug);
await page.locator("button[type=submit]").click();
// Step 2: About
await page.waitForURL("**/settings/organizations/new/about");
await page.locator('textarea[name="about"]').fill("Wizard test org");
await page.locator("button[type=submit]").click();
// Step 3: Add teams
await page.waitForURL("**/settings/organizations/new/add-teams");
await page.getByTestId("team.0.name").fill("Team 1");
await page.locator("button[type=submit]").click();
// Step 4: Onboard members
await page.waitForURL("**/settings/organizations/new/onboard-members");
// Verify wizard shows correct step numbers
// The WizardLayout component shows currentStep/maxSteps
// Just verify we reached the last step
await expect(page.getByTestId("publish-button")).toBeVisible();
});
});
test.describe("Billing Field Visibility", () => {
test("Admin sees billing fields when billing enabled", async ({ page, users }) => {
test.skip(!IS_TEAM_BILLING_ENABLED, "Billing is not enabled");
const admin = await users.create({ role: "ADMIN" });
await admin.apiLogin();
await page.goto("/settings/organizations/new");
// Verify billing fields are visible for admin
await expect(page.locator("input[name=seats]")).toBeVisible();
await expect(page.locator("input[name=pricePerSeat]")).toBeVisible();
await expect(page.locator("#billingPeriod")).toBeVisible();
// Verify toggle between monthly/annual
await expect(page.getByText("Monthly")).toBeVisible();
await expect(page.getByText("Annually")).toBeVisible();
});
test("Non-admin sees pricing UI but not billing fields", async ({ page, users }) => {
test.skip(!IS_TEAM_BILLING_ENABLED, "Billing is not enabled");
test.skip(process.env.NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED !== "1", "Org self serve is not enabled");
const user = await users.create({
username: "pricinguser",
email: users.trackEmail({ username: "pricinguser", domain: "example.com" }),
});
await user.apiLogin();
await page.goto("/settings/organizations/new");
// Should NOT see admin billing fields
await expect(page.locator("input[name=seats]")).not.toBeVisible();
await expect(page.locator("input[name=pricePerSeat]")).not.toBeVisible();
await expect(page.locator("#billingPeriod")).not.toBeVisible();
// Should see upgrade/pricing UI
await expect(page.getByText(/upgrade to organizations/i)).toBeVisible();
await expect(page.getByText(/organization/i)).toBeVisible();
});
});
});
@@ -1,475 +0,0 @@
import type { Page } from "@playwright/test";
import { expect } from "@playwright/test";
import { JSDOM } from "jsdom";
import type { Messages } from "mailhog";
import path from "path";
import { uuid } from "short-uuid";
import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants";
import type { createEmailsFixture } from "../fixtures/emails";
import { test } from "../lib/fixtures";
import { fillStripeTestCheckout } from "../lib/testUtils";
import { getEmailsReceivedByUser } from "../lib/testUtils";
async function expectEmailWithSubject(
page: Page,
emails: ReturnType<typeof createEmailsFixture>,
userEmail: string,
subject: string
) {
if (!emails) return null;
// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(2000);
const receivedEmails = await getEmailsReceivedByUser({ emails, userEmail });
const allEmails = (receivedEmails as Messages).items;
const email = allEmails.find((email) => email.subject === subject);
if (!email) {
throw new Error(`Email with subject ${subject} not found`);
}
const dom = new JSDOM(email.html);
return dom;
}
export async function expectOrganizationCreationEmailToBeSent({
page,
emails,
userEmail,
orgSlug,
}: {
page: Page;
emails: ReturnType<typeof createEmailsFixture>;
userEmail: string;
orgSlug: string;
}) {
const dom = await expectEmailWithSubject(page, emails, userEmail, "Your organization has been created");
const document = dom?.window?.document;
expect(document?.querySelector(`[href*=${orgSlug}]`)).toBeTruthy();
return dom;
}
async function expectOrganizationCreationEmailToBeSentWithLinks({
page,
emails,
userEmail,
oldUsername,
newUsername,
orgSlug,
}: {
page: Page;
emails: ReturnType<typeof createEmailsFixture>;
userEmail: string;
oldUsername: string;
newUsername: string;
orgSlug: string;
}) {
const dom = await expectOrganizationCreationEmailToBeSent({
page,
emails,
userEmail,
orgSlug,
});
const document = dom?.window.document;
const links = document?.querySelectorAll(`[data-testid="organization-link-info"] [href]`);
if (!links) {
throw new Error(`data-testid="organization-link-info doesn't have links`);
}
expect((links[0] as unknown as HTMLAnchorElement).href).toContain(oldUsername);
expect((links[1] as unknown as HTMLAnchorElement).href).toContain(newUsername);
}
export async function expectEmailVerificationEmailToBeSent(
page: Page,
emails: ReturnType<typeof createEmailsFixture>,
userEmail: string
) {
const subject = "Cal.com: Verify your account";
return expectEmailWithSubject(page, emails, userEmail, subject);
}
test.afterEach(({ users, orgs }) => {
users.deleteAll();
orgs.deleteAll();
});
function capitalize(text: string) {
if (!text) {
return text;
}
return text.charAt(0).toUpperCase() + text.slice(1);
}
test.describe("Organization", () => {
test("Admin should be able to do onboarding handover an org where an existing user is made an owner", async ({
page,
users,
emails,
}) => {
const appLevelAdmin = await users.create({
role: "ADMIN",
});
await appLevelAdmin.apiLogin();
const orgOwnerUsernamePrefix = "owner";
const orgOwnerEmail = users.trackEmail({
username: orgOwnerUsernamePrefix,
domain: `example.com`,
});
const orgOwnerUser = await users.create({
username: orgOwnerUsernamePrefix,
email: orgOwnerEmail,
role: "ADMIN",
});
const orgOwnerUsernameOutsideOrg = orgOwnerUser.username;
const orgOwnerUsernameInOrg = orgOwnerEmail.split("@")[0];
const orgName = capitalize(`${orgOwnerUser.username}`);
const orgSlug = `myOrg-${uuid()}`.toLowerCase();
await page.goto("/settings/organizations/new");
await test.step("Basic info", async () => {
// Check required fields
await page.locator("button[type=submit]").click();
await expect(page.getByTestId("field-error")).toHaveCount(2);
// Happy path
await fillAndSubmitFirstStepAsAdmin(page, orgOwnerEmail, orgName, orgSlug);
});
await test.step("Handover", async () => {
const onboardingUrl = await page.getByTestId("onboarding-url").textContent();
expect(onboardingUrl).toContain("settings/organizations/new");
});
});
test.skip("Admin should be able to create an org where the owner doesn't exist yet", async ({
page,
users,
emails,
}) => {
const appLevelAdmin = await users.create({
role: "ADMIN",
});
await appLevelAdmin.apiLogin();
const orgOwnerUsername = `owner`;
const orgName = capitalize(`${orgOwnerUsername}`);
const orgSlug = `myOrg-${uuid()}`.toLowerCase();
const orgOwnerEmail = users.trackEmail({
username: orgOwnerUsername,
domain: `example.com`,
});
await page.goto("/settings/organizations/new");
await test.step("Basic info", async () => {
// Check required fields
await page.locator("button[type=submit]").click();
await expect(page.getByTestId("field-error")).toHaveCount(2);
// Happy path
await fillAndSubmitFirstStepAsAdmin(page, orgOwnerEmail, orgName, orgSlug);
});
const dom = await expectOrganizationCreationEmailToBeSent({
page,
emails,
userEmail: orgOwnerEmail,
orgSlug,
});
expect(dom?.window.document.querySelector(`[href*=${orgSlug}]`)).toBeTruthy();
await expectEmailVerificationEmailToBeSent(page, emails, orgOwnerEmail);
// Rest of the steps remain same as org creation with existing user as owner. So skipping them
});
test("User can create and upgrade a org", async ({ page, users, emails }) => {
// eslint-disable-next-line playwright/no-skipped-test
test.skip(process.env.NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED !== "1", "Org self serve is not enabled");
const stringUUID = uuid();
const orgOwnerUsername = `owner-${stringUUID}`;
const targetOrgEmail = users.trackEmail({
username: orgOwnerUsername,
domain: `example.com`,
});
const orgOwnerUser = await users.create({
username: orgOwnerUsername,
email: targetOrgEmail,
});
await orgOwnerUser.apiLogin();
const orgName = capitalize(`${orgOwnerUsername}`);
await page.goto("/settings/organizations/new");
await test.step("Basic info", async () => {
// These values are inferred due to an existing user being signed
expect(await page.locator("input[name=name]").inputValue()).toBe("Example");
expect(await page.locator("input[name=slug]").inputValue()).toBe("example");
await page.locator("input[name=name]").fill(orgName);
await page.locator("input[name=slug]").fill(orgOwnerUsername);
await page.locator("button[type=submit]").click();
});
await test.step("About the organization", async () => {
// Choosing an avatar
await page.getByTestId("open-upload-avatar-dialog").click();
const fileChooserPromise = page.waitForEvent("filechooser");
await page.getByText("Choose a file...").click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(path.join(__dirname, "../../public/apple-touch-icon.png"));
await page.getByTestId("upload-avatar").click();
// About text
await page.locator('textarea[name="about"]').fill("This is a testing org");
await page.locator("button[type=submit]").click();
// Waiting to be in next step URL
await page.waitForURL("/settings/organizations/*/onboard-members");
});
await test.step("On-board administrators", async () => {
await page.waitForSelector('[data-testid="pending-member-list"]');
await expect(page.getByTestId("pending-member-item")).toHaveCount(1);
const adminEmail = users.trackEmail({ username: "rick", domain: `example.com` });
//can add members
await page.getByTestId("new-member-button").click();
await page.locator('[placeholder="email\\@example\\.com"]').fill(adminEmail);
await page.getByTestId("invite-new-member-button").click();
await expect(page.getByTestId("pending-member-item").filter({ hasText: adminEmail })).toBeVisible();
// TODO: Check if invited admin received the invitation email
// await expectInvitationEmailToBeReceived(
// page,
// emails,
// adminEmail,
// `${orgName}'s admin invited you to join the organization ${orgName} on Cal.com`
// );
await expect(page.getByTestId("pending-member-item")).toHaveCount(2);
// can remove members
await expect(page.getByTestId("pending-member-item")).toHaveCount(2);
const lastRemoveMemberButton = page.getByTestId("remove-member-button").last();
await lastRemoveMemberButton.click();
await expect(page.getByTestId("pending-member-item")).toHaveCount(1);
await page.getByTestId("publish-button").click();
// Waiting to be in next step URL
await page.waitForURL("/settings/organizations/*/add-teams");
});
await test.step("Create teams", async () => {
// Filling one team
await page.locator('input[name="teams.0.name"]').fill("Marketing");
// Adding another team
await page.getByTestId("add_a_team").click();
await page.locator('input[name="teams.1.name"]').fill("Sales");
// Finishing the creation wizard
await page.getByTestId("continue_or_checkout").click();
});
await test.step("Login as org owner and pay", async () => {
// eslint-disable-next-line playwright/no-skipped-test
test.skip(!IS_TEAM_BILLING_ENABLED, "Skipping paying for org as stripe is disabled");
await orgOwnerUser.apiLogin();
await page.goto("/event-types");
const upgradeButton = await page.getByTestId("upgrade_org_banner_button");
await expect(upgradeButton).toBeVisible();
await upgradeButton.click();
// Check that stripe checkout is present
const expectedUrl = "https://checkout.stripe.com";
await page.waitForURL((url) => url.href.startsWith(expectedUrl));
const url = page.url();
// Check that the URL matches the expected URL
expect(url).toContain(expectedUrl);
await fillStripeTestCheckout(page);
const upgradeButtonHidden = await page.getByTestId("upgrade_org_banner_button");
await expect(upgradeButtonHidden).toBeHidden();
});
});
test("User gets prompted with >=3 teams to upgrade & can transfer existing teams to org", async ({
page,
users,
}) => {
// eslint-disable-next-line playwright/no-skipped-test
test.skip(process.env.NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED !== "1", "Org self serve is not enabled");
const numberOfTeams = 3;
const stringUUID = uuid();
const orgOwnerUsername = `owner-${stringUUID}`;
const targetOrgEmail = users.trackEmail({
username: orgOwnerUsername,
domain: `example.com`,
});
const orgOwnerUser = await users.create(
{
username: orgOwnerUsername,
email: targetOrgEmail,
},
{ hasTeam: true, numberOfTeams }
);
await orgOwnerUser.apiLogin();
await page.goto("/teams");
await test.step("Has org self serve banner", async () => {
// These values are inferred due to an existing user being signed
const selfServeButtonLocator = await page.getByTestId("setup_your_org_action_button");
await expect(selfServeButtonLocator).toBeVisible();
await selfServeButtonLocator.click();
await page.waitForURL("/settings/organizations/new");
});
await test.step("Basic info", async () => {
// These values are inferred due to an existing user being signed
const slugLocator = await page.locator("input[name=slug]");
expect(await page.locator("input[name=name]").inputValue()).toBe("Example");
expect(await slugLocator.inputValue()).toBe("example");
await slugLocator.fill(`example-${stringUUID}`);
await page.locator("button[type=submit]").click();
});
await test.step("About the organization", async () => {
// Choosing an avatar
await page.getByTestId("open-upload-avatar-dialog").click();
const fileChooserPromise = page.waitForEvent("filechooser");
await page.getByText("Choose a file...").click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(path.join(__dirname, "../../public/apple-touch-icon.png"));
await page.getByTestId("upload-avatar").click();
// About text
await page.locator('textarea[name="about"]').fill("This is a testing org");
await page.locator("button[type=submit]").click();
// Waiting to be in next step URL
await page.waitForURL("/settings/organizations/*/onboard-members");
});
await test.step("On-board administrators", async () => {
await page.waitForSelector('[data-testid="pending-member-list"]');
await expect(page.getByTestId("pending-member-item")).toHaveCount(1);
const adminEmail = users.trackEmail({ username: "rick", domain: `example.com` });
//can add members
await page.getByTestId("new-member-button").click();
await page.locator('[placeholder="email\\@example\\.com"]').fill(adminEmail);
await page.getByTestId("invite-new-member-button").click();
await expect(page.getByTestId("pending-member-item").filter({ hasText: adminEmail })).toBeVisible();
// TODO: Check if invited admin received the invitation email
// await expectInvitationEmailToBeReceived(
// page,
// emails,
// adminEmail,
// `${orgName}'s admin invited you to join the organization ${orgName} on Cal.com`
// );
await expect(page.getByTestId("pending-member-item")).toHaveCount(2);
// can remove members
await expect(page.getByTestId("pending-member-item")).toHaveCount(2);
const lastRemoveMemberButton = page.getByTestId("remove-member-button").last();
await lastRemoveMemberButton.click();
await expect(page.getByTestId("pending-member-item")).toHaveCount(1);
await page.getByTestId("publish-button").click();
// Waiting to be in next step URL
await page.waitForURL("/settings/organizations/*/add-teams");
});
await test.step("Move existing teams to org", async () => {
// No easy way to get all team checkboxes so we fill all checkboxes on the page in
const foundCheckboxes = page.locator('input[type="checkbox"]');
const count = await foundCheckboxes.count();
for (let i = 0; i < count; i++) {
const checkbox = foundCheckboxes.nth(i);
await checkbox.click();
}
});
await test.step("Create teams", async () => {
// Filling one team
await page.locator('input[name="teams.0.name"]').fill("Marketing");
// Adding another team
await page.getByTestId("new-team-dialog-button").click();
await page.locator('input[name="teams.1.name"]').fill("Sales");
// Finishing the creation wizard
await page.getByTestId("continue_or_checkout").click();
});
await test.step("Login as org owner and pay", async () => {
// eslint-disable-next-line playwright/no-skipped-test
test.skip(!IS_TEAM_BILLING_ENABLED, "Skipping paying for org as stripe is disabled");
await orgOwnerUser.apiLogin();
await page.goto("/event-types");
const upgradeButton = await page.getByTestId("upgrade_org_banner_button");
await expect(upgradeButton).toBeVisible();
await upgradeButton.click();
// Check that stripe checkout is present
const expectedUrl = "https://checkout.stripe.com";
await page.waitForURL((url) => url.href.startsWith(expectedUrl));
const url = page.url();
// Check that the URL matches the expected URL
expect(url).toContain(expectedUrl);
await fillStripeTestCheckout(page);
const upgradeButtonHidden = await page.getByTestId("upgrade_org_banner_button");
await expect(upgradeButtonHidden).toBeHidden();
});
await test.step("Ensure correctnumberOfTeams are migrated", async () => {
// eslint-disable-next-line playwright/no-skipped-test
await page.goto("/teams");
const teamListItems = await page.getByTestId("team-list-item-link").all();
// Number of teams migrated + the two created in the create teams step
expect(teamListItems.length).toBe(numberOfTeams + 2);
});
});
});
async function fillAndSubmitFirstStepAsAdmin(
page: Page,
targetOrgEmail: string,
orgName: string,
orgSlug: string
) {
await page.locator("input[name=orgOwnerEmail]").fill(targetOrgEmail);
// Since we are admin fill in this information instead of deriving it
await page.locator("input[name=name]").fill(orgName);
await page.locator("input[name=slug]").fill(orgSlug);
// Fill in seat information
await page.locator("input[name=seats]").fill("30");
await page.locator("input[name=pricePerSeat]").fill("30");
await Promise.all([
page.waitForResponse("**/api/trpc/organizations/intentToCreateOrg**"),
page.locator("button[type=submit]").click(),
]);
}
@@ -3299,6 +3299,12 @@
"organization_onboarding_already_completed": "Organization onboarding already completed",
"organization_onboarding_already_exists": "Organization onboarding already exists",
"organization_onboarding_not_found": "Organization onboarding not found",
"no_onboarding_id_provided": "No onboarding ID provided",
"onboarding_not_found": "Onboarding not found",
"onboarding_already_complete": "Onboarding already complete",
"onboarding_already_complete_description": "This onboarding has already been completed. You can view your organization in settings.",
"resume_onboarding": "Resume Onboarding",
"resume_onboarding_description": "Continue with your organization onboarding process",
"payment_successful": "Payment successful!",
"handover_onboarding_page_title": "Handover onboarding",
"handover_onboarding_page_description": "Handover onboarding to the user",
@@ -3,11 +3,13 @@ import { z } from "zod";
import { Plan, SubscriptionStatus } from "@calcom/features/ee/billing/repository/IBillingRepository";
import { StripeBillingService } from "@calcom/features/ee/billing/stripe-billing-service";
import { InternalTeamBilling } from "@calcom/features/ee/billing/teams/internal-team-billing";
import { createOrganizationFromOnboarding } from "@calcom/features/ee/organizations/lib/server/createOrganizationFromOnboarding";
import { BillingEnabledOrgOnboardingService } from "@calcom/features/ee/organizations/lib/service/onboarding/BillingEnabledOrgOnboardingService";
import stripe from "@calcom/features/ee/payments/server/stripe";
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { OrganizationOnboardingRepository } from "@calcom/lib/server/repository/organizationOnboarding";
import { prisma } from "@calcom/prisma";
import type { SWHMap } from "./__handler";
@@ -92,10 +94,31 @@ const handler = async (data: SWHMap["invoice.paid"]["data"]) => {
};
}
const { organization } = await createOrganizationFromOnboarding({
organizationOnboarding,
paymentSubscriptionId,
paymentSubscriptionItemId,
// Get the user who created the onboarding (for service instantiation)
const userRepo = new UserRepository(prisma);
const creator = organizationOnboarding.createdById
? await userRepo.findById({ id: organizationOnboarding.createdById })
: null;
// Create a minimal user context for the service
// If no creator, use a system user context (webhook is system-initiated)
const userContext = creator
? {
id: creator.id,
email: creator.email,
role: "ADMIN" as const,
name: creator.name || undefined,
}
: {
id: 0, // System user
email: organizationOnboarding.orgOwnerEmail,
role: "ADMIN" as const,
};
const onboardingService = new BillingEnabledOrgOnboardingService(userContext);
const { organization } = await onboardingService.createOrganization(organizationOnboarding, {
subscriptionId: paymentSubscriptionId,
subscriptionItemId: paymentSubscriptionItemId,
});
// Get the Stripe subscription object
@@ -1,50 +1,79 @@
"use client";
import { useSession } from "next-auth/react";
import { useRouter } from "next/navigation";
import { useOnboarding } from "@calcom/features/ee/organizations/lib/onboardingStore";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { showToast } from "@calcom/ui/components/toast";
import { Alert } from "@calcom/ui/components/alert";
import { Button } from "@calcom/ui/components/button";
import { showToast } from "@calcom/ui/components/toast";
export const AdminOnboardingHandover = () => {
const { t } = useLocale();
const router = useRouter();
const session = useSession();
const isAdmin = session.data?.user.role === "ADMIN";
const { useOnboardingStore } = useOnboarding();
const { onboardingId, orgOwnerEmail } = useOnboardingStore();
if (!isAdmin) {
return null;
}
const onboardingUrl = `${WEBAPP_URL}/settings/organizations/new`;
// Admin handover page should ONLY use store data, not DB query
// The DB query returns the admin's own onboarding, not the one being handed over
if (!onboardingId) {
return <Alert severity="error" title={t("error")} message="No onboarding ID found. Please try again." />;
}
const onboardingUrl = `${WEBAPP_URL}/settings/organizations/new/resume?onboardingId=${onboardingId}`;
return (
<div className="bg-subtle rounded-md p-4">
<div className="flex-col items-center gap-2">
<span className="text-emphasis font-medium">
User can now continue onboarding with the following link
</span>
<div className="mt-2 flex items-center gap-2">
<code
data-testid="onboarding-url"
className="bg-default text-default flex w-full items-center truncate rounded-md px-3 py-2 font-mono text-sm">
{onboardingUrl}
</code>
<Button
data-testid="copy-onboarding-url"
type="button"
variant="icon"
color="secondary"
className="h-9 w-9"
onClick={() => {
navigator.clipboard.writeText(onboardingUrl);
showToast(t("link_copied"), "success");
}}
StartIcon="clipboard"
tooltip={t("copy_to_clipboard")}
/>
<div className="space-y-6">
{/* Instructions Card */}
<div className="bg-subtle rounded-lg p-6">
<h3 className="text-emphasis mb-2 text-lg font-medium">{t("next_steps")}</h3>
<p className="text-default mb-4 text-sm">
Send the following link to <strong>{orgOwnerEmail}</strong> so they can complete the organization
setup:
</p>
<div className="space-y-3">
<div className="bg-default flex items-center gap-2 rounded-md border p-3">
<code className="text-default flex-1 truncate font-mono text-sm" data-testid="onboarding-url">
{onboardingUrl}
</code>
<Button
data-testid="copy-onboarding-url"
type="button"
size="sm"
color="secondary"
onClick={() => {
navigator.clipboard.writeText(onboardingUrl);
showToast(t("link_copied"), "success");
}}
StartIcon="clipboard">
{t("copy")}
</Button>
</div>
</div>
</div>
{/* Actions */}
<div className="flex items-center justify-between">
<Button color="minimal" StartIcon="arrow-left" onClick={() => router.push("/settings/organizations")}>
{t("back")}
</Button>
<Button
color="primary"
onClick={() => {
router.push("/event-types");
}}>
{t("done")}
</Button>
</div>
</div>
);
};
@@ -10,8 +10,7 @@ import { subdomainSuffix } from "@calcom/features/ee/organizations/lib/orgDomain
import { MINIMUM_NUMBER_OF_ORG_SEATS, IS_SELF_HOSTED } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import slugify from "@calcom/lib/slugify";
import { CreationSource } from "@calcom/prisma/enums";
import { UserPermissionRole } from "@calcom/prisma/enums";
import { BillingPeriod, CreationSource, UserPermissionRole } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc/react";
import type { Ensure } from "@calcom/types/utils";
import classNames from "@calcom/ui/classNames";
@@ -37,7 +36,7 @@ function extractDomainFromEmail(email: string) {
export const CreateANewOrganizationForm = () => {
const session = useSession();
const { isLoadingOrgOnboarding } = useOnboarding({ step: "start" });
const { isLoadingOrgOnboarding } = useOnboarding();
if (!session.data || isLoadingOrgOnboarding) {
return null;
}
@@ -45,11 +44,6 @@ export const CreateANewOrganizationForm = () => {
return <CreateANewOrganizationFormChild session={session} />;
};
enum BillingPeriod {
MONTHLY = "MONTHLY",
ANNUALLY = "ANNUALLY",
}
const CreateANewOrganizationFormChild = ({ session }: { session: Ensure<SessionContextValue, "data"> }) => {
const { t } = useLocale();
const router = useRouter();
@@ -57,7 +51,7 @@ const CreateANewOrganizationFormChild = ({ session }: { session: Ensure<SessionC
const isAdmin = session.data.user.role === UserPermissionRole.ADMIN;
// Let self-hosters create an organization with their own email. Hosted's Admin already has an organization for their email
const defaultOrgOwnerEmail = (!isAdmin || IS_SELF_HOSTED ? session.data.user.email : null) ?? "";
const { useOnboardingStore, isBillingEnabled } = useOnboarding({ step: "start" });
const { useOnboardingStore, isBillingEnabled } = useOnboarding();
const { slug, name, orgOwnerEmail, billingPeriod, pricePerSeat, seats, onboardingId, reset } =
useOnboardingStore();
@@ -81,8 +75,6 @@ const CreateANewOrganizationFormChild = ({ session }: { session: Ensure<SessionC
const intentToCreateOrgMutation = trpc.viewer.organizations.intentToCreateOrg.useMutation({
onSuccess: async (data) => {
// TODO: To be moved to _invoice.paid.org.ts
// telemetry.event(telemetryEventTypes.org_created);
reset({
onboardingId: data.organizationOnboardingId,
billingPeriod: data.billingPeriod,
@@ -93,9 +85,17 @@ const CreateANewOrganizationFormChild = ({ session }: { session: Ensure<SessionC
slug: data.slug,
});
if (isAdmin && data.userId !== session.data.user.id) {
// Small delay to ensure Zustand persist middleware has time to write to localStorage
await new Promise((resolve) => setTimeout(resolve, 50));
if (data.handoverUrl) {
// Admin handover flow - redirect to handover page
router.push("/settings/organizations/new/handover");
} else if (data.organizationId) {
// Self-hosted flow - org already created, redirect to organizations list
router.push("/settings/organizations");
} else {
// Regular flow - continue to next step
router.push("/settings/organizations/new/about");
}
},
@@ -122,10 +122,30 @@ const CreateANewOrganizationFormChild = ({ session }: { session: Ensure<SessionC
id="createOrg"
handleSubmit={async (v) => {
if (!needToCreateOnboarding) {
// Resuming existing onboarding - just navigate to next step
router.push("/settings/organizations/new/about");
} else if (!intentToCreateOrgMutation.isPending) {
setServerErrorMessage(null);
intentToCreateOrgMutation.mutate({ ...v, creationSource: CreationSource.WEBAPP });
} else {
// Check if this is admin handover flow based on the submitted form value
const isAdminHandoverFlow = isAdmin && v.orgOwnerEmail !== session.data.user.email;
if (isAdminHandoverFlow) {
// Admin creating for someone else - submit immediately with just Step 1 data
if (!intentToCreateOrgMutation.isPending) {
setServerErrorMessage(null);
intentToCreateOrgMutation.mutate({ ...v, creationSource: CreationSource.WEBAPP });
}
} else {
// Regular user or admin creating for self - store locally and continue
reset({
billingPeriod: v.billingPeriod,
pricePerSeat: v.pricePerSeat,
seats: v.seats,
orgOwnerEmail: v.orgOwnerEmail,
name: v.name,
slug: v.slug,
});
router.push("/settings/organizations/new/about");
}
}
}}>
<div>
@@ -135,6 +135,8 @@ describe("OrganizationPaymentService", () => {
expect(data).toEqual({
bio: "BIO",
logo: "LOGO",
brandColor: null,
bannerUrl: null,
teams: [
{
id: 1,
@@ -183,6 +185,8 @@ describe("OrganizationPaymentService", () => {
expect(data).toEqual({
bio: "BIO",
logo: "LOGO",
brandColor: null,
bannerUrl: null,
teams: [
{
id: 1,
@@ -232,6 +236,9 @@ describe("OrganizationPaymentService", () => {
expect(data).toEqual({
bio: "BIO",
logo: "LOGO",
brandColor: null,
bannerUrl: null,
invitedMembers: undefined,
teams: [
{
id: 1,
@@ -261,6 +268,9 @@ describe("OrganizationPaymentService", () => {
expect(data).toEqual({
bio: "BIO",
logo: "LOGO",
brandColor: null,
bannerUrl: null,
invitedMembers: undefined,
teams: [
{
id: 1,
@@ -1,4 +1,5 @@
import { StripeBillingService } from "@calcom/features/ee/billing/stripe-billing-service";
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import {
ORGANIZATION_SELF_SERVE_MIN_SEATS,
ORGANIZATION_SELF_SERVE_PRICE,
@@ -7,24 +8,25 @@ import {
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { OrganizationOnboardingRepository } from "@calcom/lib/server/repository/organizationOnboarding";
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import { prisma } from "@calcom/prisma";
import type { OrganizationOnboarding } from "@calcom/prisma/client";
import { UserPermissionRole, type BillingPeriod } from "@calcom/prisma/enums";
import { userMetadata } from "@calcom/prisma/zod-utils";
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
import { TRPCError } from "@trpc/server";
import { OrganizationPermissionService } from "./OrganizationPermissionService";
import type { OnboardingUser } from "./service/onboarding/types";
type OrganizationOnboardingId = string;
const log = logger.getSubLogger({ prefix: ["OrganizationPaymentService"] });
type CreatePaymentIntentInput = {
logo: string | null;
bio: string | null;
brandColor?: string | null;
bannerUrl?: string | null;
teams?: { id: number; isBeingMigrated: boolean; slug: string | null; name: string }[];
invitedMembers?: { email: string }[];
invitedMembers?: { email: string; name?: string; teamId?: number; teamName?: string }[];
};
type CreateOnboardingInput = {
@@ -35,6 +37,12 @@ type CreateOnboardingInput = {
seats?: number | null;
pricePerSeat?: number | null;
createdByUserId: number;
logo?: string | null;
bio?: string | null;
brandColor?: string | null;
bannerUrl?: string | null;
teams?: { id: number; isBeingMigrated: boolean; slug: string | null; name: string }[];
invitedMembers?: { email: string; name?: string; teamId?: number; teamName?: string; role?: string }[];
};
type PermissionCheckInput = {
@@ -72,9 +80,9 @@ type StripePrice = {
export class OrganizationPaymentService {
protected billingService: StripeBillingService;
protected permissionService: OrganizationPermissionService;
protected user: NonNullable<TrpcSessionUser>;
protected user: OnboardingUser;
constructor(user: NonNullable<TrpcSessionUser>, permissionService?: OrganizationPermissionService) {
constructor(user: OnboardingUser, permissionService?: OrganizationPermissionService) {
this.billingService = new StripeBillingService();
this.permissionService = permissionService || new OrganizationPermissionService(user);
this.user = user;
@@ -191,6 +199,12 @@ export class OrganizationPaymentService {
seats: config.seats,
pricePerSeat: config.pricePerSeat,
createdById: input.createdByUserId,
logo: input.logo ?? null,
bio: input.bio ?? null,
brandColor: input.brandColor ?? null,
bannerUrl: input.bannerUrl ?? null,
teams: input.teams ?? [],
invitedMembers: input.invitedMembers ?? [],
});
}
@@ -287,7 +301,7 @@ export class OrganizationPaymentService {
) {
log.debug("createPaymentIntent", safeStringify(input));
const { teams: _teams, invitedMembers, logo, bio } = input;
const { teams: _teams, invitedMembers, logo, bio, brandColor, bannerUrl } = input;
const teams = _teams?.filter((team) => team.id === -1 || team.isBeingMigrated) || [];
const teamIds = teams.filter((team) => team.id > 0).map((team) => team.id);
@@ -362,15 +376,20 @@ export class OrganizationPaymentService {
})
);
log.debug("Updating onboarding");
log.debug("Updating onboarding with stripe details and form data");
// Update the onboarding record with all the form data
await OrganizationOnboardingRepository.update(organizationOnboarding.id, {
bio: bio ?? null,
logo: logo ?? null,
invitedMembers: invitedMembers,
teams,
stripeCustomerId,
...updatedConfig,
seats: updatedConfig.seats,
pricePerSeat: updatedConfig.pricePerSeat,
billingPeriod: updatedConfig.billingPeriod,
logo,
bio,
brandColor: brandColor ?? null,
bannerUrl: bannerUrl ?? null,
teams,
invitedMembers,
});
return {
@@ -5,10 +5,11 @@ import { safeStringify } from "@calcom/lib/safeStringify";
import { OrganizationRepository } from "@calcom/features/ee/organizations/repositories/OrganizationRepository";
import { prisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
import { TRPCError } from "@trpc/server";
import type { OnboardingUser } from "./service/onboarding/types";
const log = logger.getSubLogger({ prefix: ["ee", "organizations", "OrganizationPermissionService"] });
type SeatsPrice = {
seats?: number | null;
@@ -28,7 +29,7 @@ export interface validatePermissionsIOrganizationPermissionService {
}
export class OrganizationPermissionService {
constructor(private readonly user: NonNullable<TrpcSessionUser>) {}
constructor(private readonly user: OnboardingUser) {}
async hasPermissionToCreateForEmail(targetEmail: string): Promise<boolean> {
return this.user.email === targetEmail || this.user.role === "ADMIN";
@@ -0,0 +1,389 @@
/**
* @vitest-environment jsdom
*/
// @ts-nocheck - Test file with mock type compatibility issues that don't affect test functionality
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act, waitFor } from "@testing-library/react";
import { useSession } from "next-auth/react";
import { useSearchParams, useRouter, usePathname } from "next/navigation";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { UserPermissionRole } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc/react";
import { useOnboardingStore, useOnboarding } from "./onboardingStore";
// Mock all dependencies
vi.mock("next-auth/react");
vi.mock("next/navigation");
vi.mock("@calcom/trpc/react", () => ({
trpc: {
viewer: {
organizations: {
getOrganizationOnboarding: {
useQuery: vi.fn(),
},
},
},
},
}));
// Mock constants
vi.mock("@calcom/lib/constants", async () => {
const actual = await vi.importActual("@calcom/lib/constants");
return {
...actual,
IS_SELF_HOSTED: false, // Default to false, will be overridden in specific tests
};
});
function renderUseOnboardingStore() {
renderHook(() => useOnboarding(), { wrapper: createWrapper() });
return renderHook(() => useOnboardingStore(), { wrapper: createWrapper() });
}
// Test Data Builders
const createTestSession = (overrides?: {
email?: string;
role?: UserPermissionRole;
status?: "loading" | "authenticated" | "unauthenticated";
}) => {
const status = overrides?.status || "authenticated";
if (status === "loading") {
return {
data: null,
status: "loading" as const,
update: vi.fn(),
};
}
if (status === "unauthenticated") {
return {
data: null,
status: "unauthenticated" as const,
update: vi.fn(),
};
}
return {
data: {
user: {
email: overrides?.email || "test@example.com",
role: overrides?.role || UserPermissionRole.USER,
},
},
status: "authenticated" as const,
update: vi.fn(),
};
};
const createTestOrganizationOnboarding = (overrides?: {
isComplete?: boolean;
id?: string;
billingPeriod?: string;
pricePerSeat?: number;
seats?: number;
orgOwnerEmail?: string;
name?: string;
slug?: string;
bio?: string;
logo?: string;
}) => ({
id: overrides?.id || "test-onboarding-id",
isComplete: overrides?.isComplete ?? false,
billingPeriod: overrides?.billingPeriod || "MONTHLY",
pricePerSeat: overrides?.pricePerSeat ?? 20,
seats: overrides?.seats ?? 5,
orgOwnerEmail: overrides?.orgOwnerEmail || "test@example.com",
name: overrides?.name || "Test Organization",
slug: overrides?.slug || "test-org",
bio: overrides?.bio || "Test bio",
logo: overrides?.logo || "test-logo.png",
});
function mockOrganizationOnboardingFromDB(data: ReturnType<typeof createTestOrganizationOnboarding> | null) {
const organizationOnboarding = data ? createTestOrganizationOnboarding(data) : null;
(trpc.viewer.organizations.getOrganizationOnboarding.useQuery as ReturnType<typeof vi.fn>).mockReturnValue({
data: organizationOnboarding,
isPending: false,
isLoading: false,
error: null,
refetch: vi.fn(),
});
}
const createTestSearchParams = (params?: Record<string, string>) => {
return new URLSearchParams(params);
};
// Mock localStorage
const localStorageMock = {
getItem: vi.fn(),
setItem: vi.fn(),
removeItem: vi.fn(),
clear: vi.fn(),
};
Object.defineProperty(window, 'localStorage', {
value: localStorageMock,
writable: true,
});
// Mock implementations
const mockRouterPush = vi.fn().mockImplementation((...args: string[]) => {
console.trace();
console.log("mockRouterPush called", args[0]);
});
const mockUseSession = vi.mocked(useSession);
vi.mocked(useRouter).mockReturnValue({
push: mockRouterPush,
replace: vi.fn(),
back: vi.fn(),
forward: vi.fn(),
refresh: vi.fn(),
prefetch: vi.fn(),
});
const mockUsePathname = vi.mocked(usePathname);
const mockUseSearchParams = vi.mocked(useSearchParams);
// Test wrapper for React hooks - no wrapper needed for this test
const createWrapper = () => undefined;
describe("OnboardingStore", () => {
beforeEach(() => {
// Reset the store and let React do any side effects
act(() => {
useOnboardingStore.getState().reset();
});
// Reset localStorage mock
localStorageMock.clear.mockClear();
localStorageMock.getItem.mockClear();
localStorageMock.setItem.mockClear();
localStorageMock.removeItem.mockClear();
mockRouterPush.mockClear();
// Setup default mocks
mockUsePathname.mockReturnValue("/settings/organizations/new");
mockUseSearchParams.mockReturnValue(createTestSearchParams());
(trpc.viewer.organizations.getOrganizationOnboarding.useQuery as ReturnType<typeof vi.fn>).mockReturnValue({
data: undefined,
isPending: false,
isLoading: false,
error: null,
refetch: vi.fn(),
});
});
afterEach(() => {
act(() => {
useOnboardingStore.getState().reset();
});
vi.clearAllMocks();
mockRouterPush.mockClear();
localStorageMock.clear.mockClear();
localStorageMock.getItem.mockClear();
localStorageMock.setItem.mockClear();
localStorageMock.removeItem.mockClear();
});
describe("Zustand Store Actions - Complex Logic", () => {
describe("Reset Functionality", () => {
it("should reset to initial state when called without parameters", () => {
const { result } = renderHook(() => useOnboardingStore(), { wrapper: createWrapper() });
// Set some values
act(() => {
result.current.setName("Test Org");
result.current.setSlug("test-org");
result.current.setBillingPeriod("ANNUALLY" as const);
result.current.setSeats(15);
result.current.addInvitedMember({ email: "test@example.com", name: "Test User" });
});
// Reset
act(() => {
result.current.reset();
});
expect(result.current.name).toBe("");
expect(result.current.slug).toBe("");
expect(result.current.billingPeriod).toBeUndefined();
expect(result.current.seats).toBeNull();
expect(result.current.invitedMembers).toEqual([]);
});
it("should set specific state when called with parameters", () => {
const { result } = renderHook(() => useOnboardingStore(), { wrapper: createWrapper() });
const newState = {
name: "Partial Reset Org",
slug: "partial-reset",
seats: 25,
};
act(() => {
result.current.reset(newState);
});
expect(result.current.name).toBe("Partial Reset Org");
expect(result.current.slug).toBe("partial-reset");
expect(result.current.seats).toBe(25);
});
it("should clear localStorage when resetting without parameters", () => {
const { result } = renderHook(() => useOnboardingStore(), { wrapper: createWrapper() });
// Set some values to trigger localStorage save
act(() => {
result.current.setName("Test Org");
});
// Reset
act(() => {
result.current.reset();
});
// Verify localStorage removeItem was called
expect(localStorageMock.removeItem).toHaveBeenCalledWith("org-creation-onboarding");
});
});
});
describe("useOnboarding Hook", () => {
beforeEach(() => {
mockUseSession.mockReturnValue(createTestSession());
});
describe("Authentication Handling", () => {
it("should redirect to login when session is unauthenticated", async () => {
mockUseSession.mockReturnValue(createTestSession({ status: "unauthenticated" }));
mockUsePathname.mockReturnValue("/settings/organizations/new");
mockUseSearchParams.mockReturnValue(createTestSearchParams());
renderHook(() => useOnboarding(), { wrapper: createWrapper() });
await waitFor(() => {
expect(mockRouterPush).toHaveBeenCalledWith(
`/auth/login?callbackUrl=${WEBAPP_URL}/settings/organizations/new`
);
});
mockRouterPush.mockClear();
expect(mockRouterPush).not.toHaveBeenCalled();
});
it("should not redirect when session is authenticated", () => {
expect(mockRouterPush).not.toHaveBeenCalled();
mockUseSession.mockReturnValue(createTestSession({ status: "authenticated" }));
renderHook(() => useOnboarding(), { wrapper: createWrapper() });
expect(mockRouterPush).not.toHaveBeenCalled();
});
});
describe("Organization Onboarding State Sync", () => {
it("should return organization onboarding data when available", () => {
const organizationOnboarding = createTestOrganizationOnboarding({
name: "Test Org",
slug: "test-org",
});
(trpc.viewer.organizations.getOrganizationOnboarding.useQuery as ReturnType<typeof vi.fn>).mockReturnValue({
data: organizationOnboarding,
isPending: false,
isLoading: false,
error: null,
refetch: vi.fn(),
});
const { result } = renderHook(() => useOnboarding(), { wrapper: createWrapper() });
expect(result.current.dbOnboarding).toEqual(organizationOnboarding);
expect(result.current.isLoadingOrgOnboarding).toBe(false);
});
it("should set onboardingData from DB to zustand if not set already", async () => {
const organizationOnboarding = createTestOrganizationOnboarding({
id: "sync-test-id",
name: "Synced Organization",
slug: "synced-org",
bio: "Synced bio content",
logo: "synced-logo.png",
billingPeriod: "ANNUALLY",
pricePerSeat: 25,
seats: 10,
orgOwnerEmail: "owner@synced.com",
});
(trpc.viewer.organizations.getOrganizationOnboarding.useQuery as ReturnType<typeof vi.fn>).mockReturnValue({
data: organizationOnboarding,
isPending: false,
isLoading: false,
error: null,
refetch: vi.fn(),
});
renderHook(() => useOnboarding(), { wrapper: createWrapper() });
const { result: storeResult } = renderHook(() => useOnboardingStore(), { wrapper: createWrapper() });
await waitFor(() => {
expect(storeResult.current.onboardingId).toBe("sync-test-id");
});
expect(storeResult.current.name).toBe("Synced Organization");
expect(storeResult.current.slug).toBe("synced-org");
expect(storeResult.current.bio).toBe("Synced bio content");
expect(storeResult.current.logo).toBe("synced-logo.png");
expect(storeResult.current.billingPeriod).toBe("ANNUALLY");
expect(storeResult.current.pricePerSeat).toBe(25);
expect(storeResult.current.seats).toBe(10);
expect(storeResult.current.orgOwnerEmail).toBe("owner@synced.com");
});
it("should sync onboardingData from DB to zustand if set already", async () => {
mockOrganizationOnboardingFromDB(createTestOrganizationOnboarding({
id: "onboarding1",
name: "Onboarding 1 Organization",
slug: "onboarding1-org",
bio: "Onboarding 1 bio content",
logo: "onboarding1-logo.png",
billingPeriod: "MONTHLY",
pricePerSeat: 15,
seats: 5,
orgOwnerEmail: "owner@onboarding1.com",
}))
// Load the store to set the initial state
const { result: storeResultBefore } = renderUseOnboardingStore();
expect(storeResultBefore.current.name).toBe("Onboarding 1 Organization");
expect(storeResultBefore.current.slug).toBe("onboarding1-org");
mockOrganizationOnboardingFromDB(createTestOrganizationOnboarding({
id: "sync-test-id",
name: "Synced Organization",
slug: "synced-org",
bio: "Synced bio content",
logo: "synced-logo.png",
billingPeriod: "ANNUALLY",
pricePerSeat: 25,
seats: 10,
orgOwnerEmail: "owner@synced.com",
}));
// Load the store to sync the new data
const { result: storeResultAfter } = renderUseOnboardingStore();
// Verify the data is synced
expect(storeResultAfter.current.name).toBe("Synced Organization");
expect(storeResultAfter.current.slug).toBe("synced-org");
});
});
});
});
@@ -2,17 +2,13 @@ import { useSession } from "next-auth/react";
import { useSearchParams, useRouter, usePathname } from "next/navigation";
import { useEffect } from "react";
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { persist, StorageValue } from "zustand/middleware";
import { WEBAPP_URL, IS_SELF_HOSTED } from "@calcom/lib/constants";
import { UserPermissionRole } from "@calcom/prisma/enums";
import { WEBAPP_URL, IS_TEAM_BILLING_ENABLED_CLIENT } from "@calcom/lib/constants";
import { localStorage } from "@calcom/lib/webstorage";
import { BillingPeriod, UserPermissionRole } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc/react";
enum BillingPeriod {
MONTHLY = "MONTHLY",
ANNUALLY = "ANNUALLY",
}
interface OnboardingAdminStoreState {
billingPeriod?: BillingPeriod;
pricePerSeat?: number | null;
@@ -25,6 +21,8 @@ interface OnboardingUserStoreState {
slug: string;
logo?: string | null;
bio?: string | null;
brandColor?: string | null;
bannerUrl?: string | null;
onboardingId: string | null;
invitedMembers: { email: string; name?: string }[];
teams: { id: number; name: string; slug: string | null; isBeingMigrated: boolean }[];
@@ -42,6 +40,8 @@ interface OnboardingStoreState extends OnboardingAdminStoreState, OnboardingUser
setSlug: (slug: string) => void;
setLogo: (logo: string) => void;
setBio: (bio: string) => void;
setBrandColor: (brandColor: string) => void;
setBannerUrl: (bannerUrl: string) => void;
addInvitedMember: (member: { email: string; name?: string }) => void;
removeInvitedMember: (email: string) => void;
@@ -59,6 +59,8 @@ const initialState: OnboardingAdminStoreState & OnboardingUserStoreState = {
slug: "",
logo: "",
bio: "",
brandColor: "",
bannerUrl: "",
orgOwnerEmail: "",
seats: null,
pricePerSeat: null,
@@ -70,7 +72,7 @@ const initialState: OnboardingAdminStoreState & OnboardingUserStoreState = {
export const useOnboardingStore = create<OnboardingStoreState>()(
persist(
(set) => ({
(set, get) => ({
...initialState,
// Admin actions
@@ -84,6 +86,8 @@ export const useOnboardingStore = create<OnboardingStoreState>()(
setSlug: (slug) => set({ slug }),
setLogo: (logo) => set({ logo }),
setBio: (bio) => set({ bio }),
setBrandColor: (brandColor) => set({ brandColor }),
setBannerUrl: (bannerUrl) => set({ bannerUrl }),
setOnboardingId: (onboardingId) => set({ onboardingId }),
addInvitedMember: (member) =>
set((state) => ({
@@ -97,7 +101,6 @@ export const useOnboardingStore = create<OnboardingStoreState>()(
// Team actions
setTeams: (teams) => set({ teams }),
// Reset action
reset: (state) => {
if (state) {
set(state);
@@ -116,57 +119,59 @@ export const useOnboardingStore = create<OnboardingStoreState>()(
)
);
export const useOnboarding = (params?: { step?: "start" | "status" | null }) => {
export const useOnboarding = () => {
const session = useSession();
const router = useRouter();
const path = usePathname();
const isAdmin = session.data?.user?.role === UserPermissionRole.ADMIN;
const isBillingEnabled = !(IS_SELF_HOSTED && isAdmin) || process.env.NEXT_PUBLIC_IS_E2E;
const isBillingEnabled = process.env.NEXT_PUBLIC_IS_E2E ? false : IS_TEAM_BILLING_ENABLED_CLIENT;
const searchParams = useSearchParams();
const { data: organizationOnboarding, isPending: isLoadingOrgOnboarding } =
trpc.viewer.organizations.getOrganizationOnboarding.useQuery();
const { reset, onboardingId } = useOnboardingStore();
const step = params?.step ?? null;
const { reset } = useOnboardingStore();
useEffect(() => {
if (isLoadingOrgOnboarding) {
return;
}
// Admin on handover page should never touch the store
// The DB query returns admin's own onboarding, not the one being handed over
if (isAdmin && path?.includes("/handover")) {
return;
}
if (organizationOnboarding?.isComplete) {
reset();
}
if (organizationOnboarding) {
// Must not keep resetting state on each step change as every step doesn't save at the moment and this would reset user's changes
if (!window.isOrgOnboardingSynced) {
window.isOrgOnboardingSynced = true;
// Must reset with current state of onboarding in DB for the user
reset({
onboardingId: organizationOnboarding.id,
billingPeriod: organizationOnboarding.billingPeriod as BillingPeriod,
pricePerSeat: organizationOnboarding.pricePerSeat,
seats: organizationOnboarding.seats,
orgOwnerEmail: organizationOnboarding.orgOwnerEmail,
name: organizationOnboarding.name,
slug: organizationOnboarding.slug,
bio: organizationOnboarding.bio,
logo: organizationOnboarding.logo,
});
if (isAdmin && organizationOnboarding?.orgOwnerEmail !== session.data?.user.email) {
reset();
}
// Admin creating for someone else - don't sync from DB, let them proceed to handover
if (isAdmin && organizationOnboarding?.orgOwnerEmail !== session.data?.user.email) {
return;
}
} else {
// First step doesn't require onboardingId
const requireOnboardingId = step !== "start";
// Reset to first step if onboardingId isn't available
if (!onboardingId && requireOnboardingId) {
console.warn("No onboardingId found in store, redirecting to /settings/organizations/new");
router.push("/settings/organizations/new");
}
// Always sync from DB to ensure UI reflects latest changes
reset({
onboardingId: organizationOnboarding.id,
billingPeriod: organizationOnboarding.billingPeriod as BillingPeriod,
pricePerSeat: organizationOnboarding.pricePerSeat,
seats: organizationOnboarding.seats,
orgOwnerEmail: organizationOnboarding.orgOwnerEmail,
name: organizationOnboarding.name,
slug: organizationOnboarding.slug,
bio: organizationOnboarding.bio,
logo: organizationOnboarding.logo,
brandColor: organizationOnboarding.brandColor,
bannerUrl: organizationOnboarding.bannerUrl,
invitedMembers: [],
teams: [],
});
}
}, [organizationOnboarding, isLoadingOrgOnboarding, isAdmin, onboardingId, reset, step, router]);
// Note: We no longer redirect if onboardingId is missing, as regular users
// don't create the onboarding record until the final step
}, [organizationOnboarding, isLoadingOrgOnboarding, isAdmin, reset, path]);
useEffect(() => {
if (session.status === "loading") {
@@ -1,540 +0,0 @@
/**
* Goal is to e2e test createOrganizationFromOnboarding except inviteMembersWithNoInviterPermissionCheck, createTeamsHandler and createDomain
* Those are either already tested or should be tested out separately
*/
import prismock from "../../../../../../tests/libs/__mocks__/prisma";
import { describe, expect, it, vi, beforeEach } from "vitest";
import { LicenseKeySingleton } from "@calcom/ee/common/server/LicenseKeyService";
import * as constants from "@calcom/lib/constants";
import { createDomain } from "@calcom/lib/domainManager/organization";
import type { OrganizationOnboarding } from "@calcom/prisma/client";
import { MembershipRole } from "@calcom/prisma/enums";
import { createTeamsHandler } from "@calcom/trpc/server/routers/viewer/organizations/createTeams.handler";
import { inviteMembersWithNoInviterPermissionCheck } from "@calcom/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler";
import { createOrganizationFromOnboarding } from "./createOrganizationFromOnboarding";
vi.mock("@calcom/ee/common/server/LicenseKeyService", () => ({
LicenseKeySingleton: {
getInstance: vi.fn(),
},
}));
// Mock the external functions
vi.mock("@calcom/features/auth/lib/verifyEmail", () => ({
sendEmailVerification: vi.fn(),
}));
vi.mock("@calcom/emails/email-manager", () => ({
sendOrganizationCreationEmail: vi.fn(),
}));
vi.mock("@calcom/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler", () => ({
inviteMembersWithNoInviterPermissionCheck: vi.fn(),
}));
vi.mock("@calcom/trpc/server/routers/viewer/organizations/createTeams.handler", () => ({
createTeamsHandler: vi.fn(),
}));
vi.mock("@calcom/lib/domainManager/organization", () => ({
createDomain: vi.fn(),
}));
vi.mock("@calcom/lib/server/i18n", () => {
return {
getTranslation: async (locale: string, namespace: string) => {
const t = (key: string) => key;
t.locale = locale;
t.namespace = namespace;
return t;
},
};
});
const mockOrganizationOnboarding = {
name: "Test Org",
slug: "test-org",
orgOwnerEmail: "test@example.com",
billingPeriod: "MONTHLY",
seats: 5,
pricePerSeat: 20,
isComplete: false,
stripeCustomerId: "mock_stripe_customer_id",
createdById: 1,
invitedMembers: [{ email: "member1@example.com" }, { email: "member2@example.com" }],
teams: [
{ id: 1, name: "Team To Move", isBeingMigrated: true, slug: "new-team-slug" },
{ id: 2, name: "New Team", isBeingMigrated: false, slug: null },
],
logo: null,
bio: null,
organizationId: null,
isDomainConfigured: false,
isPlatform: false,
};
// Helper functions for creating test scenarios
async function createTestUser(data: {
email: string;
name?: string;
username?: string;
metadata?: any;
onboardingCompleted?: boolean;
emailVerified?: Date | null;
}) {
return prismock.user.create({
data: {
email: data.email,
name: data.name || "Test User",
username: data.username || "testuser",
metadata: data.metadata || {},
completedOnboarding: data.onboardingCompleted,
emailVerified: data.emailVerified,
},
});
}
async function createTestOrganization(data: {
name: string;
slug: string;
isOrganization?: boolean;
metadata?: any;
}) {
return prismock.team.create({
data: {
name: data.name,
slug: data.slug,
isOrganization: data.isOrganization ?? true,
metadata: data.metadata || {},
},
});
}
async function createTestTeam(data: { name: string; slug: string }) {
return prismock.team.create({
data: {
name: data.name,
slug: data.slug,
isOrganization: false,
},
});
}
async function createTestOrganizationOnboarding(
data?: Partial<typeof mockOrganizationOnboarding> & { organizationId?: number | null }
) {
console.log("TEST: Creating organization onboarding", data);
return prismock.organizationOnboarding.create({
data: {
...mockOrganizationOnboarding,
...data,
},
});
}
async function createOnboardingEligibleUserAndOnboarding(data: {
user?: {
email: string;
name?: string;
username?: string;
};
organizationOnboarding?: Partial<typeof mockOrganizationOnboarding> & { organizationId?: number | null };
}) {
const user = await createTestUser({
...data.user,
onboardingCompleted: true,
emailVerified: new Date(),
});
const organizationOnboarding = await createTestOrganizationOnboarding({
...data.organizationOnboarding,
createdById: user.id,
});
return { user, organizationOnboarding };
}
async function createTestMembership(data: { userId: number; teamId: number; role?: MembershipRole }) {
return prismock.membership.create({
data: {
createdAt: new Date(),
userId: data.userId,
teamId: data.teamId,
role: data.role || MembershipRole.MEMBER,
accepted: true,
},
});
}
async function expectOrganizationOnboardingToHaveDataContaining({
onboardingId,
expectedData,
}: {
onboardingId: string;
expectedData: Partial<OrganizationOnboarding>;
}) {
const updatedOrganizationOnboarding = await prismock.organizationOnboarding.findUnique({
where: { id: onboardingId },
});
expect(updatedOrganizationOnboarding).toEqual(expect.objectContaining(expectedData));
}
describe("createOrganizationFromOnboarding", () => {
beforeEach(async () => {
vi.resetAllMocks();
// @ts-expect-error reset is a method on Prismock
await prismock.reset();
});
describe("hosted", () => {
beforeEach(() => {
vi.spyOn(constants, "IS_SELF_HOSTED", "get").mockReturnValue(false);
});
// Skipped because non-existend user creation support isn't there through onboarding now.
it.skip("should create an organization with a non-existent user as owner", async () => {
const organizationOnboarding = await createTestOrganizationOnboarding();
const { organization, owner } = await createOrganizationFromOnboarding({
organizationOnboarding,
paymentSubscriptionId: "mock_subscription_id",
paymentSubscriptionItemId: "mock_subscription_item_id",
});
// Verify organization creation
expect(organization).toBeDefined();
expect(organization.name).toBe(organizationOnboarding.name);
expect(organization.slug).toBe(organizationOnboarding.slug);
expect(organization.metadata).toEqual({
orgSeats: 5,
orgPricePerSeat: 20,
billingPeriod: "MONTHLY",
subscriptionId: "mock_subscription_id",
subscriptionItemId: "mock_subscription_item_id",
});
// Verify owner creation
expect(owner).toBeDefined();
expect(owner.email).toBe(organizationOnboarding.orgOwnerEmail);
});
it("should create an organization with an existing user as owner", async () => {
const orgOwnerEmail = "test@example.com";
const existingUser = await createTestUser({
email: orgOwnerEmail,
name: "Existing User",
username: "existinguser",
onboardingCompleted: true,
emailVerified: new Date(),
});
const organizationOnboarding = await createTestOrganizationOnboarding({
orgOwnerEmail,
});
const { organization, owner } = await createOrganizationFromOnboarding({
organizationOnboarding,
paymentSubscriptionId: "mock_subscription_id",
paymentSubscriptionItemId: "mock_subscription_item_id",
});
// Verify organization creation
expect(organization).toBeDefined();
expect(organization.name).toBe(organizationOnboarding.name);
expect(organization.slug).toBe(organizationOnboarding.slug);
// Verify owner is the existing user
expect(owner.id).toBe(existingUser.id);
expect(owner.email).toBe(existingUser.email);
expect(createDomain).toHaveBeenCalledWith(organization.slug);
// Verify team creation and member invites still work
expect(inviteMembersWithNoInviterPermissionCheck).toHaveBeenCalledWith(
expect.objectContaining({
orgSlug: organization.slug,
teamId: organization.id,
invitations: organizationOnboarding.invitedMembers?.map((member) => ({
usernameOrEmail: member.email,
role: MembershipRole.MEMBER,
})),
})
);
expect(createTeamsHandler).toHaveBeenCalledWith(
expect.objectContaining({
input: expect.objectContaining({
orgId: organization.id,
teamNames: ["New Team"],
moveTeams: expect.arrayContaining([expect.objectContaining({ id: 1, newSlug: "new-team-slug" })]),
}),
})
);
});
it("should reuse existing organization if organizationId is already set in onboarding. Covers the retry scenario where org got created but then error occurred", async () => {
const orgName = "Existing Org";
const orgSlug = "existing-org";
const existingOrg = await createTestOrganization({
name: orgName,
slug: orgSlug,
});
const { organizationOnboarding } = await createOnboardingEligibleUserAndOnboarding({
user: {
name: "Existing User",
username: "existinguser",
email: "test@example.com",
},
organizationOnboarding: {
organizationId: existingOrg.id,
name: orgName,
slug: orgSlug,
},
});
const { organization } = await createOrganizationFromOnboarding({
organizationOnboarding,
paymentSubscriptionId: "mock_subscription_id",
paymentSubscriptionItemId: "mock_subscription_item_id",
});
// Verify the existing organization was reused
expect(organization.id).toBe(existingOrg.id);
});
it("should throw error if organization with same slug exists", async () => {
const { organizationOnboarding } = await createOnboardingEligibleUserAndOnboarding({
user: {
email: "test@example.com",
},
organizationOnboarding: {
slug: "conflicting-org-with-same-slug",
},
});
await createTestOrganization({
name: "Conflicting Org with same slug",
slug: organizationOnboarding.slug,
});
await expect(
createOrganizationFromOnboarding({
organizationOnboarding,
paymentSubscriptionId: "mock_subscription_id",
paymentSubscriptionItemId: "mock_subscription_item_id",
})
).rejects.toThrow("organization_url_taken");
});
it("should create team with slugified slug", async () => {
const teamToMove = await createTestTeam({
name: "Sales",
slug: "Company Sales",
});
const { organizationOnboarding } = await createOnboardingEligibleUserAndOnboarding({
user: {
email: "test@example.com",
},
organizationOnboarding: {
slug: "UPPERCASE-SLUG",
teams: [{ id: teamToMove.id, name: "Sales", isBeingMigrated: true, slug: "sales-team" }],
},
});
const { organization } = await createOrganizationFromOnboarding({
organizationOnboarding,
paymentSubscriptionId: "mock_subscription_id",
paymentSubscriptionItemId: "mock_subscription_item_id",
});
expect(createTeamsHandler).toHaveBeenCalledWith(
expect.objectContaining({
input: expect.objectContaining({
orgId: organization.id,
teamNames: [],
moveTeams: expect.arrayContaining([expect.objectContaining({ id: 1, newSlug: "sales-team" })]),
}),
})
);
});
it("should invite members with isDirectUserAction set to false", async () => {
const { organizationOnboarding } = await createOnboardingEligibleUserAndOnboarding({
user: {
username: "org-owner",
email: "owner@example.com",
},
});
// Call createOrganizationFromOnboarding
const result = await createOrganizationFromOnboarding({
organizationOnboarding,
paymentSubscriptionId: "sub_123",
paymentSubscriptionItemId: "si_123",
});
// Verify inviteMembersWithNoInviterPermissionCheck was called with isDirectUserAction: false
expect(inviteMembersWithNoInviterPermissionCheck).toHaveBeenCalledWith(
expect.objectContaining({
teamId: result.organization.id,
invitations: [
{ usernameOrEmail: "member1@example.com", role: MembershipRole.MEMBER },
{ usernameOrEmail: "member2@example.com", role: MembershipRole.MEMBER },
],
isDirectUserAction: false,
})
);
});
it("should update stripe customer ID for existing user", async () => {
const { user: existingUser, organizationOnboarding } = await createOnboardingEligibleUserAndOnboarding({
user: {
email: "test@example.com",
},
organizationOnboarding: {
slug: "conflicting-org-with-same-slug",
},
});
await createOrganizationFromOnboarding({
organizationOnboarding,
paymentSubscriptionId: "mock_subscription_id",
paymentSubscriptionItemId: "mock_subscription_item_id",
});
// Verify user's stripe customer ID was updated
const updatedUser = await prismock.user.findUnique({
where: { id: existingUser.id },
});
expect(updatedUser?.metadata).toEqual(
expect.objectContaining({
stripeCustomerId: organizationOnboarding.stripeCustomerId,
})
);
});
it("should create organization in with slug set even if there is a team with same slug of which orgOwner(to-be) is a member", async () => {
const { organizationOnboarding } = await createOnboardingEligibleUserAndOnboarding({
user: {
email: "test@example.com",
},
organizationOnboarding: {
slug: "conflicting-org-with-same-slug",
},
});
const teamWithConflictingSlug = await createTestTeam({
name: "TestTeamWithConflictingSlug",
slug: organizationOnboarding.slug,
});
// Make the orgOwner a member of the team
await createTestMembership({
userId: organizationOnboarding.createdById,
teamId: teamWithConflictingSlug.id,
role: MembershipRole.ADMIN,
});
const { organization } = await createOrganizationFromOnboarding({
organizationOnboarding,
paymentSubscriptionId: "mock_subscription_id",
paymentSubscriptionItemId: "mock_subscription_item_id",
});
expect(organization.slug).toBe(organizationOnboarding.slug);
});
it("should not create organization if there is a team with same slug of which orgOwner(to-be) is not a member", async () => {
const { organizationOnboarding } = await createOnboardingEligibleUserAndOnboarding({
user: {
email: "test@example.com",
},
organizationOnboarding: {
slug: "conflicting-org-with-same-slug",
},
});
await createTestTeam({
name: "TestTeamWithConflictingSlugNotOwnedByOrgOwner",
slug: organizationOnboarding.slug,
});
// Don't make the orgOwner a member of the team
// await createTestMembership({
// userId: organizationOnboarding.createdById,
// teamId: teamWithConflictingSlug.id,
// role: MembershipRole.ADMIN,
// });
await expect(
createOrganizationFromOnboarding({
organizationOnboarding,
paymentSubscriptionId: "mock_subscription_id",
paymentSubscriptionItemId: "mock_subscription_item_id",
})
).rejects.toThrow("organization_url_taken");
});
it("should not mark the Onboarding as completed(isComplete=true)", async () => {
const { organizationOnboarding } = await createOnboardingEligibleUserAndOnboarding({
user: {
email: "test@example.com",
},
});
await expectOrganizationOnboardingToHaveDataContaining({
onboardingId: organizationOnboarding.id,
expectedData: {
isComplete: false,
},
});
});
});
describe("self-hosted", () => {
beforeEach(() => {
vi.spyOn(constants, "IS_SELF_HOSTED", "get").mockReturnValue(true);
});
it("should fail if the license is invalid", async () => {
vi.mocked(LicenseKeySingleton.getInstance as ReturnType<typeof vi.fn>).mockResolvedValue({
checkLicense: vi.fn().mockResolvedValue(false),
});
const { organizationOnboarding } = await createOnboardingEligibleUserAndOnboarding({
user: {
email: "test@example.com",
},
});
await expect(
createOrganizationFromOnboarding({
organizationOnboarding,
paymentSubscriptionId: "mock_subscription_id",
paymentSubscriptionItemId: "mock_subscription_item_id",
})
).rejects.toThrow("Self hosted license not valid");
});
it("should create an organization with a valid license", async () => {
vi.mocked(LicenseKeySingleton.getInstance).mockResolvedValue({
checkLicense: vi.fn().mockResolvedValue(true),
});
const { organizationOnboarding } = await createOnboardingEligibleUserAndOnboarding({
user: {
email: "test@example.com",
},
});
const { organization, owner } = await createOrganizationFromOnboarding({
organizationOnboarding,
paymentSubscriptionId: "mock_subscription_id",
paymentSubscriptionItemId: "mock_subscription_item_id",
});
expect(organization).toBeDefined();
expect(owner).toBeDefined();
});
});
});
@@ -1,567 +0,0 @@
import type { TFunction } from "i18next";
import { LicenseKeySingleton } from "@calcom/ee/common/server/LicenseKeyService";
import { sendOrganizationCreationEmail } from "@calcom/emails/email-manager";
import { sendEmailVerification } from "@calcom/features/auth/lib/verifyEmail";
import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains";
import {
assertCanCreateOrg,
findUserToBeOrgOwner,
setupDomain,
} from "@calcom/features/ee/organizations/lib/server/orgCreationUtils";
import { DEFAULT_SCHEDULE } from "@calcom/lib/availability";
import { getAvailabilityFromSchedule } from "@calcom/lib/availability";
import { IS_SELF_HOSTED } from "@calcom/lib/constants";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { getTranslation } from "@calcom/lib/server/i18n";
import { DeploymentRepository } from "@calcom/lib/server/repository/deployment";
import { OrganizationRepository } from "@calcom/features/ee/organizations/repositories/OrganizationRepository";
import { OrganizationOnboardingRepository } from "@calcom/lib/server/repository/organizationOnboarding";
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import slugify from "@calcom/lib/slugify";
import { prisma } from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
import type { Team, User } from "@calcom/prisma/client";
import type { OrganizationOnboarding } from "@calcom/prisma/client";
import { MembershipRole, CreationSource } from "@calcom/prisma/enums";
import {
userMetadata,
orgOnboardingInvitedMembersSchema,
orgOnboardingTeamsSchema,
} from "@calcom/prisma/zod-utils";
import { teamMetadataStrictSchema } from "@calcom/prisma/zod-utils";
import { createTeamsHandler } from "@calcom/trpc/server/routers/viewer/organizations/createTeams.handler";
import { inviteMembersWithNoInviterPermissionCheck } from "@calcom/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler";
// Onboarding can only be done from webapp currently and so we consider the source for User as WEBAPP
const creationSource = CreationSource.WEBAPP;
const log = logger.getSubLogger({ prefix: ["createOrganizationFromOnboarding"] });
// Types for organization data
type OrganizationData = {
id: number | null;
name: string;
slug: string;
isOrganizationConfigured: boolean;
isOrganizationAdminReviewed: boolean;
autoAcceptEmail: string;
seats: number | null;
pricePerSeat: number | null;
isPlatform: boolean;
logoUrl: string | null;
bio: string | null;
billingPeriod?: "MONTHLY" | "ANNUALLY";
};
type TeamData = {
id: number;
name: string;
isBeingMigrated: boolean;
slug: string | null;
};
type InvitedMember = {
email: string;
name?: string;
};
type OrgOwner = Awaited<ReturnType<typeof findUserToBeOrgOwner>>;
type OrganizationOnboardingArg = Pick<
OrganizationOnboarding,
| "id"
| "organizationId"
| "name"
| "slug"
| "orgOwnerEmail"
| "seats"
| "pricePerSeat"
| "billingPeriod"
| "invitedMembers"
| "teams"
| "isPlatform"
| "logo"
| "bio"
| "stripeCustomerId"
| "isDomainConfigured"
>;
const invitedMembersSchema = orgOnboardingInvitedMembersSchema;
const teamsSchema = orgOnboardingTeamsSchema;
async function createOrganizationWithExistingUserAsOwner({
owner,
orgData,
}: {
owner: NonNullable<Awaited<ReturnType<typeof findUserToBeOrgOwner>>>;
orgData: OrganizationData;
}) {
const orgOwnerTranslation = await getTranslation(owner.locale || "en", "common");
// We prefer ID which would be available if it is a retry of createOrganizationFromOnboarding and earlier organization was created and connected with Onboarding
let organization = orgData.id ? await OrganizationRepository.findById({ id: orgData.id }) : null;
if (organization) {
log.info(
`Reusing existing organization:`,
safeStringify({
slug: orgData.slug,
id: organization.id,
})
);
return { organization };
}
const { slugConflictType } = await assertCanCreateOrg({
slug: orgData.slug,
isPlatform: orgData.isPlatform,
orgOwner: owner,
errorOnUserAlreadyPartOfOrg: false,
// If it would have been restricted, then it would have been done already in intentToCreateOrgHandler
// This restriction doesn't apply for admin as we would have validated it already in intentToCreateOrgHandler, we just relax it here as we don't know who created the organization.
// TODO: If needed, we could start tracking if the onboarding is being done by instance Admin and relax it then only.
restrictBasedOnMinimumPublishedTeams: false,
});
// In this case we create the organization with slug=null. Let the teams migrate and then the conflict would go away.
const canSetSlug = slugConflictType === "teamUserIsMemberOfExists" ? false : true;
log.info(
`Creating organization for owner ${owner.email} with slug ${orgData.slug} and canSetSlug=${canSetSlug}`
);
try {
const nonOrgUsername = owner.username || "";
const orgCreationResult = await OrganizationRepository.createWithExistingUserAsOwner({
orgData: {
...orgData,
...(canSetSlug ? { slug: orgData.slug } : { slug: null, requestedSlug: orgData.slug }),
},
owner: {
id: owner.id,
email: owner.email,
nonOrgUsername,
},
});
organization = orgCreationResult.organization;
const ownerProfile = orgCreationResult.ownerProfile;
// Send organization creation email
if (!orgData.isPlatform) {
await sendOrganizationCreationEmail({
language: orgOwnerTranslation,
from: `${organization.name}'s admin`,
to: owner.email,
ownerNewUsername: ownerProfile.username,
ownerOldUsername: nonOrgUsername,
orgDomain: getOrgFullOrigin(orgData.slug, { protocol: false }),
orgName: organization.name,
prevLink: `${getOrgFullOrigin("", { protocol: true })}/${owner.username || ""}`,
newLink: `${getOrgFullOrigin(orgData.slug, { protocol: true })}/${ownerProfile.username}`,
});
}
// Set up default availability
const availability = getAvailabilityFromSchedule(DEFAULT_SCHEDULE);
await prisma.availability.createMany({
data: availability.map((schedule) => ({
days: schedule.days,
startTime: schedule.startTime,
endTime: schedule.endTime,
userId: owner.id,
})),
});
} catch (error) {
// Catch and log so that redactError doesn't redact the log message
log.error(
`RecoverableError: Error creating organization for owner ${owner.email}.`,
safeStringify(error)
);
throw error;
}
return { organization };
}
async function createOrganizationWithNonExistentUserAsOwner({
email,
orgData,
}: {
email: string;
orgData: OrganizationData;
}) {
let organization = orgData.id
? await OrganizationRepository.findById({ id: orgData.id })
: await OrganizationRepository.findBySlug({ slug: orgData.slug });
if (organization) {
log.info(
`createOrganizationWithNonExistentUserAsOwner: Reusing existing organization:`,
safeStringify({ slug: orgData.slug, id: organization.id })
);
const owner = await findUserToBeOrgOwner(email);
if (!owner) {
// Can happen when the organization was created earlier and the webhook had failed and when the webhook got fired again in next subscription update, then the email was deleted already
// The fix would be to change the email in Onboarding record to new owner of the organization
// TODO: Identify the owner of the organization from Membership table and use that email instead here.
throw new Error(`Org exists but owner could not be found for email: ${email}`);
}
return { organization, owner };
}
const orgCreationResult = await OrganizationRepository.createWithNonExistentOwner({
orgData,
owner: {
email: email,
},
creationSource,
});
organization = orgCreationResult.organization;
const { ownerProfile, orgOwner: orgOwnerFromCreation } = orgCreationResult;
const orgOwner = await findUserToBeOrgOwner(orgOwnerFromCreation.email);
if (!orgOwner) {
throw new Error(
`Just created the owner ${orgOwnerFromCreation.email}, but couldn't find it in the database`
);
}
const translation = await getTranslation(orgOwner.locale ?? "en", "common");
await sendEmailVerification({
email: orgOwner.email,
language: orgOwner.locale ?? "en",
username: ownerProfile.username || "",
isPlatform: orgData.isPlatform,
});
if (!orgData.isPlatform) {
await sendOrganizationCreationEmail({
language: translation,
from: orgOwner.name ?? `${organization.name}'s admin`,
to: orgOwner.email,
ownerNewUsername: ownerProfile.username,
ownerOldUsername: null,
orgDomain: getOrgFullOrigin(orgData.slug, { protocol: false }),
orgName: organization.name,
prevLink: null,
newLink: `${getOrgFullOrigin(orgData.slug, { protocol: true })}/${ownerProfile.username}`,
});
}
return {
organization,
owner: orgOwner,
};
}
async function createOrMoveTeamsToOrganization(teams: TeamData[], owner: User, organizationId: number) {
if (teams.length === 0) return;
const teamsToCreate = teams.filter((team) => !team.isBeingMigrated).map((team) => team.name);
const teamsToMove = teams
.filter((team) => team.isBeingMigrated)
.map((team) => ({
id: team.id,
newSlug: team.slug ? slugify(team.slug) : team.slug,
shouldMove: true,
}));
log.info(
`Creating ${teamsToCreate} teams and moving ${teamsToMove.map(
(team) => team.newSlug
)} teams for organization ${organizationId}`
);
await createTeamsHandler({
ctx: {
user: {
...owner,
organizationId,
},
},
input: {
teamNames: teamsToCreate,
orgId: organizationId,
moveTeams: teamsToMove,
creationSource,
},
});
log.info(
`Created ${teamsToCreate.length} teams and moved ${teamsToMove.length} teams for organization ${organizationId}`
);
}
async function inviteMembers(invitedMembers: InvitedMember[], organization: Team) {
if (invitedMembers.length === 0) return;
log.info(`Inviting ${invitedMembers.length} members to organization ${organization.id}`);
await inviteMembersWithNoInviterPermissionCheck({
inviterName: null,
teamId: organization.id,
language: "en",
creationSource: CreationSource.WEBAPP,
orgSlug: organization.slug || null,
invitations: invitedMembers.map((member) => ({
usernameOrEmail: member.email,
role: MembershipRole.MEMBER,
})),
isDirectUserAction: false,
});
}
async function ensureStripeCustomerIdIsUpdated({
owner,
stripeCustomerId,
}: {
owner: User;
stripeCustomerId: string;
}) {
const parsedMetadata = userMetadata.parse(owner.metadata);
await new UserRepository(prisma).updateStripeCustomerId({
id: owner.id,
stripeCustomerId: stripeCustomerId,
existingMetadata: parsedMetadata,
});
}
/**
* Temporary till we adapt all other code reading from metadata about stripeSubscriptionId and stripeSubscriptionItemId
*/
async function backwardCompatibilityForSubscriptionDetails({
organization,
paymentSubscriptionId,
paymentSubscriptionItemId,
}: {
organization: {
id: number;
metadata: Prisma.JsonValue;
};
paymentSubscriptionId?: string;
paymentSubscriptionItemId?: string;
}) {
if (!paymentSubscriptionId || !paymentSubscriptionItemId) {
return organization;
}
const existingMetadata = teamMetadataStrictSchema.parse(organization.metadata);
const updatedOrganization = await OrganizationRepository.updateStripeSubscriptionDetails({
id: organization.id,
stripeSubscriptionId: paymentSubscriptionId,
stripeSubscriptionItemId: paymentSubscriptionItemId,
existingMetadata,
});
return updatedOrganization;
}
async function hasConflictingOrganization({ slug, onboardingId }: { slug: string; onboardingId: string }) {
const organization = await OrganizationRepository.findBySlugIncludeOnboarding({ slug });
if (!organization?.organizationOnboarding) {
// Old organizations created without onboarding,
return false;
}
// Different onboardingId means existing organization is owned by someone else
return organization.organizationOnboarding.id !== onboardingId;
}
async function handleDomainSetup({
organizationOnboarding,
orgOwnerTranslation,
}: {
organizationOnboarding: OrganizationOnboardingArg;
orgOwnerTranslation: TFunction;
}) {
if (!organizationOnboarding.isDomainConfigured) {
// Important: Setup Domain first before creating the organization as Vercel/Cloudflare might not allow the domain to be created and that could cause organization booking pages to not actually work
await setupDomain({
slug: organizationOnboarding.slug,
isPlatform: organizationOnboarding.isPlatform,
orgOwnerEmail: organizationOnboarding.orgOwnerEmail,
orgOwnerTranslation,
});
}
await OrganizationOnboardingRepository.update(organizationOnboarding.id, {
isDomainConfigured: true,
});
}
async function handleOrganizationCreation({
organizationOnboarding,
owner,
paymentSubscriptionId,
paymentSubscriptionItemId,
}: {
organizationOnboarding: OrganizationOnboardingArg;
owner: OrgOwner;
paymentSubscriptionId?: string;
paymentSubscriptionItemId?: string;
}) {
if (IS_SELF_HOSTED) {
const deploymentRepo = new DeploymentRepository(prisma);
const licenseKeyService = await LicenseKeySingleton.getInstance(deploymentRepo);
const hasValidLicense = await licenseKeyService.checkLicense();
if (!hasValidLicense) {
throw new Error("Self hosted license not valid");
}
}
let organization;
const orgData = {
id: organizationOnboarding.organizationId,
name: organizationOnboarding.name,
// Create organization with slug=null, so that org slug doesn't conflict with existing team that is probably being migrated and thus would not be a conflict any more
// Remember one can have a subteam with same slug as some organization, but regular team and organization can't have same slug
slug: organizationOnboarding.slug,
isOrganizationConfigured: true,
// We believe that as the payment has been accepted first and we also restrict the emails to company emails, it is safe to set this to true.
// We could easily set it to false if needed. In effect, it disables impersonations by non-admin reviewed organization's owner and also disables editing the member details
isOrganizationAdminReviewed: true,
autoAcceptEmail: organizationOnboarding.orgOwnerEmail.split("@")[1],
seats: organizationOnboarding.seats,
pricePerSeat: organizationOnboarding.pricePerSeat,
isPlatform: false,
billingPeriod: organizationOnboarding.billingPeriod,
logoUrl: organizationOnboarding.logo,
bio: organizationOnboarding.bio,
};
log.info(
"handleOrganizationCreation",
safeStringify({ orgId: organizationOnboarding.organizationId, orgSlug: organizationOnboarding.slug })
);
if (!owner) {
const result = await createOrganizationWithNonExistentUserAsOwner({
email: organizationOnboarding.orgOwnerEmail,
orgData,
});
organization = result.organization;
owner = result.owner;
} else {
const result = await createOrganizationWithExistingUserAsOwner({
orgData,
owner,
});
organization = result.organization;
}
if (organizationOnboarding.stripeCustomerId) {
// Mostly needed for newly created user through the flow, existing user would have it already when checkout was created
// We need to set it there to ensure that for the same user new customerId is not created again
await ensureStripeCustomerIdIsUpdated({
owner,
stripeCustomerId: organizationOnboarding.stripeCustomerId,
});
}
// Connect the organization to the onboarding
await OrganizationOnboardingRepository.update(organizationOnboarding.id, {
organizationId: organization.id,
});
const updatedOrganization = await backwardCompatibilityForSubscriptionDetails({
organization,
paymentSubscriptionId,
paymentSubscriptionItemId,
});
return {
organization: {
...organization,
metadata: updatedOrganization.metadata,
},
owner,
};
}
/**
* This function is used by stripe webhook, so it should expect to be called multiple times till the entire flow completes without any error.
* So, it should be idempotent.
*/
export const createOrganizationFromOnboarding = async ({
organizationOnboarding,
paymentSubscriptionId,
paymentSubscriptionItemId,
}: {
organizationOnboarding: OrganizationOnboardingArg;
paymentSubscriptionId?: string;
paymentSubscriptionItemId?: string;
}) => {
log.info(
"createOrganizationFromOnboarding",
safeStringify({
orgId: organizationOnboarding.organizationId,
orgSlug: organizationOnboarding.slug,
})
);
if (!IS_SELF_HOSTED && (!paymentSubscriptionId || !paymentSubscriptionItemId)) {
throw new Error("payment_subscription_id_and_payment_subscription_item_id_are_required");
}
if (
await hasConflictingOrganization({
slug: organizationOnboarding.slug,
onboardingId: organizationOnboarding.id,
})
) {
throw new Error("organization_already_exists_with_this_slug");
}
// We know admin permissions have been validated in the above step so we can safely normalize the input
const userFromEmail = await findUserToBeOrgOwner(organizationOnboarding.orgOwnerEmail);
const orgOwnerTranslation = await getTranslation(userFromEmail?.locale || "en", "common");
// TODO: We need to send emails to admin in the case of SELF_HOSTING where NEXT_PUBLIC_SINGLE_ORG_SLUG isn't set
if (!process.env.NEXT_PUBLIC_SINGLE_ORG_SLUG) {
await handleDomainSetup({
organizationOnboarding,
orgOwnerTranslation,
});
}
const { organization, owner } = await handleOrganizationCreation({
organizationOnboarding,
owner: userFromEmail,
paymentSubscriptionId,
paymentSubscriptionItemId,
});
await inviteMembers(invitedMembersSchema.parse(organizationOnboarding.invitedMembers), organization);
await createOrMoveTeamsToOrganization(
teamsSchema.parse(organizationOnboarding.teams),
owner,
organization.id
);
// If the organization was created with slug=null, then set the slug now, assuming that the team having the same slug is migrated now
// If the team wasn't owned by the orgOwner, then org creation would have failed and we wouldn't be here
if (!organization.slug) {
try {
const { slug } = await OrganizationRepository.setSlug({
id: organization.id,
slug: organizationOnboarding.slug,
});
organization.slug = slug;
} catch (error) {
// Almost always the reason would be that the organization's slug conflicts with a team's slug
// The owner might not have chosen the conflicting team for migration - Can be confirmed by checking `teams` column in the database.
log.error(
"RecoverableError: Error while setting slug for organization",
safeStringify(error),
safeStringify({
attemptedSlug: organizationOnboarding.slug,
organizationId: organization.id,
})
);
throw new Error(
`Unable to set slug '${organizationOnboarding.slug}' for organization ${organization.id}`
);
}
}
return { organization, owner };
};
@@ -0,0 +1,116 @@
import { describe, expect, it } from "vitest";
import { isNotACompanyEmail } from "./orgCreationUtils";
describe("orgCreationUtils", () => {
describe("isNotACompanyEmail", () => {
it("should return true for gmail.com email", () => {
expect(isNotACompanyEmail("user@gmail.com")).toBe(true);
});
it("should return true for yahoo.com email", () => {
expect(isNotACompanyEmail("user@yahoo.com")).toBe(true);
});
it("should return true for outlook.com email", () => {
expect(isNotACompanyEmail("user@outlook.com")).toBe(true);
});
it("should return true for hotmail.com email", () => {
expect(isNotACompanyEmail("user@hotmail.com")).toBe(true);
});
it("should return true for protonmail.com email", () => {
expect(isNotACompanyEmail("user@protonmail.com")).toBe(true);
});
it("should return true for proton.me email", () => {
expect(isNotACompanyEmail("user@proton.me")).toBe(true);
});
it("should return true for icloud.com email", () => {
expect(isNotACompanyEmail("user@icloud.com")).toBe(true);
});
it("should return true for live.com email", () => {
expect(isNotACompanyEmail("user@live.com")).toBe(true);
});
it("should return false for company email", () => {
expect(isNotACompanyEmail("user@company.com")).toBe(false);
});
it("should return false for company email with subdomain", () => {
expect(isNotACompanyEmail("user@mail.company.com")).toBe(false);
});
it("should return false for company email with country code", () => {
expect(isNotACompanyEmail("user@company.co.uk")).toBe(false);
});
it("should return true for invalid email without @", () => {
expect(isNotACompanyEmail("invalidemail")).toBe(true);
});
it("should return true for empty email", () => {
expect(isNotACompanyEmail("")).toBe(true);
});
it("should return true for email with multiple @ symbols", () => {
expect(isNotACompanyEmail("user@@company.com")).toBe(false);
});
it("should return true for all popular personal email providers", () => {
const personalProviders = [
"gmail.com",
"yahoo.com",
"outlook.com",
"hotmail.com",
"aol.com",
"icloud.com",
"mail.com",
"protonmail.com",
"proton.me",
"zoho.com",
"yandex.com",
"gmx.com",
"fastmail.com",
"inbox.com",
"me.com",
"hushmail.com",
"live.com",
"rediffmail.com",
"tutanota.com",
"mail.ru",
"usa.com",
"qq.com",
"163.com",
"web.de",
"rocketmail.com",
"excite.com",
"lycos.com",
"outlook.co",
"hotmail.co.uk",
];
personalProviders.forEach((provider) => {
expect(isNotACompanyEmail(`user@${provider}`)).toBe(true);
});
});
it("should return false for various company domains", () => {
const companyDomains = [
"acme.com",
"techcorp.io",
"business.co",
"startup.ai",
"enterprise.net",
"cal.com",
];
companyDomains.forEach((domain) => {
expect(isNotACompanyEmail(`user@${domain}`)).toBe(false);
});
});
});
});
@@ -2,6 +2,7 @@ import { lookup } from "dns";
import { type TFunction } from "i18next";
import { sendAdminOrganizationNotification } from "@calcom/emails";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import {
RESERVED_SUBDOMAINS,
ORG_MINIMUM_PUBLISHED_TEAMS_SELF_SERVE,
@@ -20,7 +21,7 @@ const log = logger.getSubLogger({ prefix: ["orgCreationUtils"] });
/**
* We can only say for sure that the email is not a company email. We can't say for sure if it is a company email.
*/
function isNotACompanyEmail(email: string) {
export function isNotACompanyEmail(email: string) {
// A list of popular @domains that can't be used to allow automatic acceptance of memberships to organization
const emailProviders = [
"gmail.com",
@@ -185,7 +186,13 @@ export async function assertCanCreateOrg({
restrictBasedOnMinimumPublishedTeams: boolean;
errorOnUserAlreadyPartOfOrg?: boolean;
}) {
const verifiedUser = orgOwner.completedOnboarding && !!orgOwner.emailVerified;
const featuresRepository = new FeaturesRepository(prisma);
const emailVerificationEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally(
"email-verification"
);
const verifiedUser =
orgOwner.completedOnboarding && (emailVerificationEnabled ? !!orgOwner.emailVerified : true);
if (!verifiedUser) {
log.warn(
"you_need_to_complete_user_onboarding_before_creating_an_organization",
@@ -0,0 +1,605 @@
import type { TFunction } from "i18next";
import { sendOrganizationCreationEmail } from "@calcom/emails/email-manager";
import { sendEmailVerification } from "@calcom/features/auth/lib/verifyEmail";
import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains";
import {
assertCanCreateOrg,
findUserToBeOrgOwner,
setupDomain,
} from "@calcom/features/ee/organizations/lib/server/orgCreationUtils";
import { OrganizationRepository } from "@calcom/features/ee/organizations/repositories/OrganizationRepository";
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import { DEFAULT_SCHEDULE, getAvailabilityFromSchedule } from "@calcom/lib/availability";
import { WEBAPP_URL } from "@calcom/lib/constants";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { getTranslation } from "@calcom/lib/server/i18n";
import { OrganizationOnboardingRepository } from "@calcom/lib/server/repository/organizationOnboarding";
import slugify from "@calcom/lib/slugify";
import { prisma } from "@calcom/prisma";
import type { Prisma, Team, User } from "@calcom/prisma/client";
import { CreationSource, MembershipRole, UserPermissionRole } from "@calcom/prisma/enums";
import {
userMetadata,
orgOnboardingInvitedMembersSchema,
orgOnboardingTeamsSchema,
teamMetadataStrictSchema,
} from "@calcom/prisma/zod-utils";
import { createTeamsHandler } from "@calcom/trpc/server/routers/viewer/organizations/createTeams.handler";
import { inviteMembersWithNoInviterPermissionCheck } from "@calcom/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler";
import { OrganizationPaymentService } from "../../OrganizationPaymentService";
import { OrganizationPermissionService } from "../../OrganizationPermissionService";
import type { IOrganizationOnboardingService } from "./IOrganizationOnboardingService";
import type {
TeamInput,
InvitedMemberInput,
CreateOnboardingIntentInput,
OnboardingUser,
OrganizationData,
TeamData,
InvitedMember,
OrganizationOnboardingData,
OnboardingIntentResult,
} from "./types";
const log = logger.getSubLogger({ prefix: ["BaseOnboardingService"] });
const invitedMembersSchema = orgOnboardingInvitedMembersSchema;
const teamsSchema = orgOnboardingTeamsSchema;
type OrgOwner = Awaited<ReturnType<typeof findUserToBeOrgOwner>>;
export abstract class BaseOnboardingService implements IOrganizationOnboardingService {
protected user: OnboardingUser;
protected paymentService: OrganizationPaymentService;
protected permissionService: OrganizationPermissionService;
constructor(
user: OnboardingUser,
paymentService?: OrganizationPaymentService,
permissionService?: OrganizationPermissionService
) {
this.user = user;
this.paymentService = paymentService || new OrganizationPaymentService(user);
this.permissionService = permissionService || new OrganizationPermissionService(user);
}
abstract createOnboardingIntent(input: CreateOnboardingIntentInput): Promise<any>;
abstract createOrganization(
organizationOnboarding: OrganizationOnboardingData,
paymentDetails?: { subscriptionId: string; subscriptionItemId: string }
): Promise<{ organization: Team; owner: User }>;
protected async createOnboardingRecord(input: CreateOnboardingIntentInput & { onboardingId?: string }) {
// If onboardingId exists, update the existing record (resume flow)
if (input.onboardingId) {
log.debug(
"Updating existing organization onboarding record (resume flow)",
safeStringify({
onboardingId: input.onboardingId,
slug: input.slug,
name: input.name,
})
);
await OrganizationOnboardingRepository.update(input.onboardingId, {
logo: input.logo ?? null,
bio: input.bio ?? null,
brandColor: input.brandColor ?? null,
bannerUrl: input.bannerUrl ?? null,
teams: input.teams ?? [],
invitedMembers: input.invitedMembers ?? [],
});
const updatedOnboarding = await OrganizationOnboardingRepository.findById(input.onboardingId);
if (!updatedOnboarding) {
throw new Error(`Onboarding record ${input.onboardingId} not found after update`);
}
log.debug("Organization onboarding updated", safeStringify({ onboardingId: updatedOnboarding.id }));
return updatedOnboarding;
}
// Create new onboarding record
log.debug(
"Creating organization onboarding record",
safeStringify({
slug: input.slug,
name: input.name,
orgOwnerEmail: input.orgOwnerEmail,
isPlatform: input.isPlatform,
})
);
const organizationOnboarding = await this.paymentService.createOrganizationOnboarding({
name: input.name,
slug: input.slug,
orgOwnerEmail: input.orgOwnerEmail,
seats: input.seats,
pricePerSeat: input.pricePerSeat,
billingPeriod: input.billingPeriod,
createdByUserId: this.user.id,
logo: input.logo ?? null,
bio: input.bio ?? null,
brandColor: input.brandColor ?? null,
bannerUrl: input.bannerUrl ?? null,
teams: input.teams ?? [],
invitedMembers: input.invitedMembers ?? [],
});
log.debug("Organization onboarding created", safeStringify({ onboardingId: organizationOnboarding.id }));
return organizationOnboarding;
}
protected filterTeamsAndInvites(teams: TeamInput[] = [], invitedMembers: InvitedMemberInput[] = []) {
const teamsData = teams
.filter((team) => team.name.trim().length > 0)
.map((team) => ({
id: team.id === -1 ? -1 : team.id,
name: team.name,
isBeingMigrated: team.isBeingMigrated,
slug: team.slug,
}));
const invitedMembersData = invitedMembers
.filter((invite) => invite.email.trim().length > 0)
.map((invite) => ({
email: invite.email,
name: invite.name,
teamId: invite.teamId,
teamName: invite.teamName,
role: invite.role,
}));
return { teamsData, invitedMembersData };
}
protected handleAdminHandoverIfNeeded(
input: CreateOnboardingIntentInput,
onboardingId: string
): OnboardingIntentResult | null {
const isAdminHandoverFlow =
this.user.role === UserPermissionRole.ADMIN &&
this.user.email !== input.orgOwnerEmail &&
(!input.teams || input.teams.length === 0) &&
(!input.invitedMembers || input.invitedMembers.length === 0);
log.debug(
"Checking admin handover flow",
safeStringify({
userRole: this.user.role,
isAdmin: this.user.role === UserPermissionRole.ADMIN,
userEmail: this.user.email,
orgOwnerEmail: input.orgOwnerEmail,
emailsDifferent: this.user.email !== input.orgOwnerEmail,
teamsEmpty: !input.teams || input.teams.length === 0,
membersEmpty: !input.invitedMembers || input.invitedMembers.length === 0,
isAdminHandoverFlow,
})
);
if (!isAdminHandoverFlow) {
return null;
}
log.debug(
"Admin handover flow detected",
safeStringify({ adminEmail: this.user.email, orgOwnerEmail: input.orgOwnerEmail })
);
return {
userId: this.user.id,
orgOwnerEmail: input.orgOwnerEmail,
name: input.name,
slug: input.slug,
seats: input.seats ?? null,
pricePerSeat: input.pricePerSeat ?? null,
billingPeriod: input.billingPeriod,
isPlatform: input.isPlatform,
organizationOnboardingId: onboardingId,
checkoutUrl: null,
organizationId: null,
handoverUrl: `${WEBAPP_URL}/settings/organizations/new/resume?onboardingId=${onboardingId}`,
};
}
protected isAdminCreatingForSelf(input: { orgOwnerEmail: string }): boolean {
return this.user.role === UserPermissionRole.ADMIN && this.user.email === input.orgOwnerEmail;
}
protected async createOrganizationWithExistingUserAsOwner({
owner,
orgData,
}: {
owner: NonNullable<Awaited<ReturnType<typeof findUserToBeOrgOwner>>>;
orgData: OrganizationData;
}) {
const orgOwnerTranslation = await getTranslation(owner.locale || "en", "common");
let organization = orgData.id ? await OrganizationRepository.findById({ id: orgData.id }) : null;
if (organization) {
log.info(
`Reusing existing organization:`,
safeStringify({
slug: orgData.slug,
id: organization.id,
})
);
return { organization };
}
const { slugConflictType } = await assertCanCreateOrg({
slug: orgData.slug,
isPlatform: orgData.isPlatform,
orgOwner: owner,
errorOnUserAlreadyPartOfOrg: false,
restrictBasedOnMinimumPublishedTeams: false,
});
const canSetSlug = slugConflictType === "teamUserIsMemberOfExists" ? false : true;
log.info(
`Creating organization for owner ${owner.email} with slug ${orgData.slug} and canSetSlug=${canSetSlug}`
);
try {
const nonOrgUsername = owner.username || "";
const orgCreationResult = await OrganizationRepository.createWithExistingUserAsOwner({
orgData: {
...orgData,
...(canSetSlug ? { slug: orgData.slug } : { slug: null, requestedSlug: orgData.slug }),
},
owner: {
id: owner.id,
email: owner.email,
nonOrgUsername,
},
});
organization = orgCreationResult.organization;
const ownerProfile = orgCreationResult.ownerProfile;
if (!orgData.isPlatform) {
await sendOrganizationCreationEmail({
language: orgOwnerTranslation,
from: `${organization.name}'s admin`,
to: owner.email,
ownerNewUsername: ownerProfile.username,
ownerOldUsername: nonOrgUsername,
orgDomain: getOrgFullOrigin(orgData.slug, { protocol: false }),
orgName: organization.name,
prevLink: `${getOrgFullOrigin("", { protocol: true })}/${owner.username || ""}`,
newLink: `${getOrgFullOrigin(orgData.slug, { protocol: true })}/${ownerProfile.username}`,
});
}
const availability = getAvailabilityFromSchedule(DEFAULT_SCHEDULE);
await prisma.availability.createMany({
data: availability.map((schedule) => ({
days: schedule.days,
startTime: schedule.startTime,
endTime: schedule.endTime,
userId: owner.id,
})),
});
} catch (error) {
log.error(
`RecoverableError: Error creating organization for owner ${owner.email}.`,
safeStringify(error)
);
throw error;
}
return { organization };
}
protected async createOrganizationWithNonExistentUserAsOwner({
email,
orgData,
}: {
email: string;
orgData: OrganizationData;
}) {
let organization = orgData.id
? await OrganizationRepository.findById({ id: orgData.id })
: await OrganizationRepository.findBySlug({ slug: orgData.slug });
if (organization) {
log.info(
`createOrganizationWithNonExistentUserAsOwner: Reusing existing organization:`,
safeStringify({ slug: orgData.slug, id: organization.id })
);
const owner = await findUserToBeOrgOwner(email);
if (!owner) {
throw new Error(`Org exists but owner could not be found for email: ${email}`);
}
return { organization, owner };
}
const orgCreationResult = await OrganizationRepository.createWithNonExistentOwner({
orgData,
owner: {
email: email,
},
creationSource: CreationSource.WEBAPP,
});
organization = orgCreationResult.organization;
const { ownerProfile, orgOwner: orgOwnerFromCreation } = orgCreationResult;
const orgOwner = await findUserToBeOrgOwner(orgOwnerFromCreation.email);
if (!orgOwner) {
throw new Error(
`Just created the owner ${orgOwnerFromCreation.email}, but couldn't find it in the database`
);
}
const translation = await getTranslation(orgOwner.locale ?? "en", "common");
await sendEmailVerification({
email: orgOwner.email,
language: orgOwner.locale ?? "en",
username: ownerProfile.username || "",
isPlatform: orgData.isPlatform,
});
if (!orgData.isPlatform) {
await sendOrganizationCreationEmail({
language: translation,
from: orgOwner.name ?? `${organization.name}'s admin`,
to: orgOwner.email,
ownerNewUsername: ownerProfile.username,
ownerOldUsername: null,
orgDomain: getOrgFullOrigin(orgData.slug, { protocol: false }),
orgName: organization.name,
prevLink: null,
newLink: `${getOrgFullOrigin(orgData.slug, { protocol: true })}/${ownerProfile.username}`,
});
}
return {
organization,
owner: orgOwner,
};
}
protected async createOrMoveTeamsToOrganization(teams: TeamData[], owner: User, organizationId: number) {
if (teams.length === 0) return;
const teamsToCreate = teams.filter((team) => !team.isBeingMigrated).map((team) => team.name);
const teamsToMove = teams
.filter((team) => team.isBeingMigrated)
.map((team) => ({
id: team.id,
newSlug: team.slug ? slugify(team.slug) : team.slug,
shouldMove: true,
}));
log.info(
`Creating ${teamsToCreate} teams and moving ${teamsToMove.map(
(team) => team.newSlug
)} teams for organization ${organizationId}`
);
await createTeamsHandler({
ctx: {
user: {
...owner,
organizationId,
},
},
input: {
teamNames: teamsToCreate,
orgId: organizationId,
moveTeams: teamsToMove,
creationSource: CreationSource.WEBAPP,
},
});
log.info(
`Created ${teamsToCreate.length} teams and moved ${teamsToMove.length} teams for organization ${organizationId}`
);
}
protected async inviteMembers(invitedMembers: InvitedMember[], organization: Team, teamsData: TeamData[]) {
if (invitedMembers.length === 0) return;
log.info(
`Processing ${invitedMembers.length} member invites for organization ${organization.id}`,
safeStringify({
invitedMembers: invitedMembers.map((m) => ({
email: m.email,
teamId: m.teamId,
teamName: m.teamName,
role: m.role,
})),
})
);
const createdTeams = await prisma.team.findMany({
where: {
parentId: organization.id,
},
select: {
id: true,
name: true,
slug: true,
},
});
log.info(
`Found ${createdTeams.length} teams in organization`,
safeStringify({
teams: createdTeams.map((t) => ({ id: t.id, name: t.name })),
})
);
const teamNameToId = new Map<string, number>();
createdTeams.forEach((team) => {
teamNameToId.set(team.name.toLowerCase(), team.id);
});
const teamIdMap = new Map<number, number>();
teamsData.forEach((teamData) => {
if (teamData.isBeingMigrated) {
teamIdMap.set(teamData.id, teamData.id);
} else {
const createdTeam = createdTeams.find((t) => t.name === teamData.name);
if (createdTeam) {
teamIdMap.set(teamData.id, createdTeam.id);
}
}
});
const invitesToOrg: InvitedMember[] = [];
const invitesToTeams = new Map<number, InvitedMember[]>();
for (const member of invitedMembers) {
let targetTeamId: number | undefined;
if (member.teamId !== undefined) {
targetTeamId = teamIdMap.get(member.teamId) || member.teamId;
log.debug(`Member ${member.email}: teamId ${member.teamId} -> resolved to ${targetTeamId}`);
} else if (member.teamName) {
targetTeamId = teamNameToId.get(member.teamName.toLowerCase());
log.debug(
`Member ${member.email}: teamName "${member.teamName}" -> resolved to ${
targetTeamId || "not found"
}`
);
}
if (targetTeamId && createdTeams.some((t) => t.id === targetTeamId)) {
if (!invitesToTeams.has(targetTeamId)) {
invitesToTeams.set(targetTeamId, []);
}
invitesToTeams.get(targetTeamId)!.push(member);
log.debug(`Member ${member.email} will be invited to team ${targetTeamId}`);
} else {
invitesToOrg.push(member);
log.debug(
`Member ${member.email} will be invited to organization (no team specified or team not found)`
);
}
}
log.info(
"Invite categorization complete",
safeStringify({
orgInvites: invitesToOrg.length,
teamInvites: invitesToTeams.size,
teamBreakdown: Array.from(invitesToTeams.entries()).map(([teamId, members]) => ({
teamId,
count: members.length,
emails: members.map((m) => m.email),
})),
})
);
if (invitesToOrg.length > 0) {
log.info(`Inviting ${invitesToOrg.length} members to organization ${organization.id}`);
await inviteMembersWithNoInviterPermissionCheck({
inviterName: null,
teamId: organization.id,
language: "en",
creationSource: CreationSource.WEBAPP,
orgSlug: organization.slug || null,
invitations: invitesToOrg.map((member) => ({
usernameOrEmail: member.email,
role: (member.role as MembershipRole) || MembershipRole.MEMBER,
})),
isDirectUserAction: false,
});
}
for (const [teamId, members] of Array.from(invitesToTeams.entries())) {
const teamName = createdTeams.find((t) => t.id === teamId)?.name || `team ${teamId}`;
log.info(`Inviting ${members.length} members to team "${teamName}" (${teamId})`);
await inviteMembersWithNoInviterPermissionCheck({
inviterName: null,
teamId: teamId,
language: "en",
creationSource: CreationSource.WEBAPP,
orgSlug: organization.slug || null,
invitations: members.map((member: InvitedMember) => ({
usernameOrEmail: member.email,
role: (member.role as MembershipRole) || MembershipRole.MEMBER,
})),
isDirectUserAction: false,
});
}
log.info("All member invites processed successfully");
}
protected async ensureStripeCustomerIdIsUpdated({
owner,
stripeCustomerId,
}: {
owner: User;
stripeCustomerId: string;
}) {
const parsedMetadata = userMetadata.parse(owner.metadata);
await new UserRepository(prisma).updateStripeCustomerId({
id: owner.id,
stripeCustomerId: stripeCustomerId,
existingMetadata: parsedMetadata,
});
}
protected async backwardCompatibilityForSubscriptionDetails({
organization,
paymentSubscriptionId,
paymentSubscriptionItemId,
}: {
organization: {
id: number;
metadata: Prisma.JsonValue;
};
paymentSubscriptionId?: string;
paymentSubscriptionItemId?: string;
}) {
if (!paymentSubscriptionId || !paymentSubscriptionItemId) {
return organization;
}
const existingMetadata = teamMetadataStrictSchema.parse(organization.metadata);
const updatedOrganization = await OrganizationRepository.updateStripeSubscriptionDetails({
id: organization.id,
stripeSubscriptionId: paymentSubscriptionId,
stripeSubscriptionItemId: paymentSubscriptionItemId,
existingMetadata,
});
return updatedOrganization;
}
protected async hasConflictingOrganization({ slug, onboardingId }: { slug: string; onboardingId: string }) {
const organization = await OrganizationRepository.findBySlugIncludeOnboarding({ slug });
if (!organization?.organizationOnboarding) {
return false;
}
return organization.organizationOnboarding.id !== onboardingId;
}
protected async handleDomainSetup({
organizationOnboarding,
orgOwnerTranslation,
}: {
organizationOnboarding: OrganizationOnboardingData;
orgOwnerTranslation: TFunction;
}) {
if (!organizationOnboarding.isDomainConfigured) {
await setupDomain({
slug: organizationOnboarding.slug,
isPlatform: organizationOnboarding.isPlatform,
orgOwnerEmail: organizationOnboarding.orgOwnerEmail,
orgOwnerTranslation,
});
}
await OrganizationOnboardingRepository.update(organizationOnboarding.id, {
isDomainConfigured: true,
});
}
}
@@ -0,0 +1,270 @@
import { findUserToBeOrgOwner } from "@calcom/features/ee/organizations/lib/server/orgCreationUtils";
import { OrganizationRepository } from "@calcom/features/ee/organizations/repositories/OrganizationRepository";
import { IS_SELF_HOSTED } from "@calcom/lib/constants";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { getTranslation } from "@calcom/lib/server/i18n";
import { OrganizationOnboardingRepository } from "@calcom/lib/server/repository/organizationOnboarding";
import type { Team, User } from "@calcom/prisma/client";
import { orgOnboardingInvitedMembersSchema, orgOnboardingTeamsSchema } from "@calcom/prisma/zod-utils";
import { BaseOnboardingService } from "../onboarding/BaseOnboardingService";
import type {
CreateOnboardingIntentInput,
OnboardingIntentResult,
OrganizationOnboardingData,
OrganizationData,
} from "../onboarding/types";
const log = logger.getSubLogger({ prefix: ["BillingEnabledOrgOnboardingService"] });
const invitedMembersSchema = orgOnboardingInvitedMembersSchema;
const teamsSchema = orgOnboardingTeamsSchema;
/**
* Handles organization onboarding when billing is enabled (Stripe flow).
*
* Flow:
* 1. Create onboarding record
* 2. Store teams/invites in database
* 3. Create Stripe checkout session
* 4. Return checkout URL
* 5. Organization created later via Stripe webhook
*
* Exception: Admin creating org for self - immediately creates organization (no payment required)
*/
export class BillingEnabledOrgOnboardingService extends BaseOnboardingService {
async createOnboardingIntent(input: CreateOnboardingIntentInput): Promise<OnboardingIntentResult> {
log.debug(
"BillingEnabledOrgOnboardingService.createOnboardingIntent - INPUT",
safeStringify({
invitedMembers: input.invitedMembers,
teams: input.teams,
})
);
const { teamsData, invitedMembersData } = this.filterTeamsAndInvites(input.teams, input.invitedMembers);
log.debug(
"BillingEnabledOrgOnboardingService - After filterTeamsAndInvites",
safeStringify({
teamsData,
invitedMembersData,
})
);
// Create onboarding record with ALL data at once
const organizationOnboarding = await this.createOnboardingRecord({
...input,
teams: teamsData,
invitedMembers: invitedMembersData,
});
const onboardingId = organizationOnboarding.id;
// Check if this is an admin handover flow
const handoverResult = this.handleAdminHandoverIfNeeded(input, onboardingId);
if (handoverResult) {
return handoverResult;
}
// Check if admin is creating org for themselves - skip payment, create immediately
if (
this.isAdminCreatingForSelf({
orgOwnerEmail: organizationOnboarding.orgOwnerEmail,
})
) {
log.debug(
"Admin creating org for self - skipping payment and creating organization immediately",
safeStringify({ adminEmail: this.user.email, onboardingId })
);
const { organization } = await this.createOrganization(organizationOnboarding);
await OrganizationOnboardingRepository.markAsComplete(onboardingId);
log.debug(
"Organization created successfully for admin",
safeStringify({ onboardingId, organizationId: organization.id })
);
return {
userId: this.user.id,
orgOwnerEmail: input.orgOwnerEmail,
name: input.name,
slug: input.slug,
seats: input.seats ?? null,
pricePerSeat: input.pricePerSeat ?? null,
billingPeriod: input.billingPeriod,
isPlatform: input.isPlatform,
organizationOnboardingId: onboardingId,
checkoutUrl: null,
organizationId: organization.id,
};
}
// Regular flow - create payment intent
const paymentIntent = await this.paymentService.createPaymentIntent(
{
logo: input.logo ?? null,
bio: input.bio ?? null,
brandColor: input.brandColor ?? null,
bannerUrl: input.bannerUrl ?? null,
teams: teamsData,
invitedMembers: invitedMembersData,
},
{
id: organizationOnboarding.id,
pricePerSeat: organizationOnboarding.pricePerSeat,
billingPeriod: organizationOnboarding.billingPeriod,
seats: organizationOnboarding.seats,
isComplete: organizationOnboarding.isComplete,
orgOwnerEmail: organizationOnboarding.orgOwnerEmail,
slug: organizationOnboarding.slug,
stripeCustomerId: organizationOnboarding.stripeCustomerId,
}
);
return {
userId: this.user.id,
orgOwnerEmail: input.orgOwnerEmail,
name: input.name,
slug: input.slug,
seats: input.seats ?? null,
pricePerSeat: input.pricePerSeat ?? null,
billingPeriod: input.billingPeriod,
isPlatform: input.isPlatform,
organizationOnboardingId: onboardingId,
checkoutUrl: paymentIntent.checkoutUrl,
organizationId: null, // Organization not created yet (pending payment)
};
}
async createOrganization(
organizationOnboarding: OrganizationOnboardingData,
paymentDetails?: { subscriptionId: string; subscriptionItemId: string }
): Promise<{ organization: Team; owner: User }> {
log.info(
"createOrganization (billing-enabled)",
safeStringify({
orgId: organizationOnboarding.organizationId,
orgSlug: organizationOnboarding.slug,
})
);
const isAdminForSelf = this.isAdminCreatingForSelf({
orgOwnerEmail: organizationOnboarding.orgOwnerEmail,
});
if (
!IS_SELF_HOSTED &&
!isAdminForSelf &&
(!paymentDetails?.subscriptionId || !paymentDetails?.subscriptionItemId)
) {
throw new Error("payment_subscription_id_and_payment_subscription_item_id_are_required");
}
if (
await this.hasConflictingOrganization({
slug: organizationOnboarding.slug,
onboardingId: organizationOnboarding.id,
})
) {
throw new Error("organization_already_exists_with_this_slug");
}
let owner = await findUserToBeOrgOwner(organizationOnboarding.orgOwnerEmail);
const orgOwnerTranslation = await getTranslation(owner?.locale || "en", "common");
if (!process.env.NEXT_PUBLIC_SINGLE_ORG_SLUG) {
await this.handleDomainSetup({
organizationOnboarding,
orgOwnerTranslation,
});
}
const orgData: OrganizationData = {
id: organizationOnboarding.organizationId,
name: organizationOnboarding.name,
slug: organizationOnboarding.slug,
isOrganizationConfigured: true,
isOrganizationAdminReviewed: true,
autoAcceptEmail: organizationOnboarding.orgOwnerEmail.split("@")[1],
seats: organizationOnboarding.seats,
pricePerSeat: organizationOnboarding.pricePerSeat,
isPlatform: false,
billingPeriod: organizationOnboarding.billingPeriod,
logoUrl: organizationOnboarding.logo,
bio: organizationOnboarding.bio,
brandColor: organizationOnboarding.brandColor,
bannerUrl: organizationOnboarding.bannerUrl,
};
let organization: Team;
if (!owner) {
const result = await this.createOrganizationWithNonExistentUserAsOwner({
email: organizationOnboarding.orgOwnerEmail,
orgData,
});
organization = result.organization;
owner = result.owner;
} else {
const result = await this.createOrganizationWithExistingUserAsOwner({
orgData,
owner,
});
organization = result.organization;
}
if (organizationOnboarding.stripeCustomerId) {
await this.ensureStripeCustomerIdIsUpdated({
owner,
stripeCustomerId: organizationOnboarding.stripeCustomerId,
});
}
await OrganizationOnboardingRepository.update(organizationOnboarding.id, {
organizationId: organization.id,
});
const updatedOrganization = await this.backwardCompatibilityForSubscriptionDetails({
organization,
paymentSubscriptionId: paymentDetails?.subscriptionId,
paymentSubscriptionItemId: paymentDetails?.subscriptionItemId,
});
organization.metadata = updatedOrganization.metadata;
const teamsData = teamsSchema.parse(organizationOnboarding.teams);
await this.createOrMoveTeamsToOrganization(teamsData, owner, organization.id);
await this.inviteMembers(
invitedMembersSchema.parse(organizationOnboarding.invitedMembers),
organization,
teamsData
);
if (!organization.slug) {
try {
const { slug } = await OrganizationRepository.setSlug({
id: organization.id,
slug: organizationOnboarding.slug,
});
organization.slug = slug;
} catch (error) {
log.error(
"RecoverableError: Error while setting slug for organization",
safeStringify(error),
safeStringify({
attemptedSlug: organizationOnboarding.slug,
organizationId: organization.id,
})
);
throw new Error(
`Unable to set slug '${organizationOnboarding.slug}' for organization ${organization.id}`
);
}
}
return { organization, owner };
}
}
@@ -0,0 +1,32 @@
import type { Team, User } from "@calcom/prisma/client";
import type {
CreateOnboardingIntentInput,
OnboardingIntentResult,
OrganizationOnboardingData,
} from "./types";
/**
* Interface for organization onboarding services.
* Implementations handle different onboarding flows (billing-enabled vs self-hosted).
*/
export interface IOrganizationOnboardingService {
/**
* Creates an organization onboarding intent.
* Depending on the implementation:
* - BillingEnabled: Creates Stripe checkout session, returns checkoutUrl
* - SelfHosted: Creates organization immediately, returns organizationId
*/
createOnboardingIntent(input: CreateOnboardingIntentInput): Promise<OnboardingIntentResult>;
/**
* Creates the actual organization from onboarding data.
* Depending on the implementation:
* - SelfHosted: Validates license, creates organization without payment
* - BillingEnabled: Requires payment details, handles subscription
*/
createOrganization(
organizationOnboarding: OrganizationOnboardingData,
paymentDetails?: { subscriptionId: string; subscriptionItemId: string }
): Promise<{ organization: Team; owner: User }>;
}
@@ -0,0 +1,53 @@
import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { BillingEnabledOrgOnboardingService } from "./BillingEnabledOrgOnboardingService";
import type { IOrganizationOnboardingService } from "./IOrganizationOnboardingService";
import { SelfHostedOrganizationOnboardingService } from "./SelfHostedOnboardingService";
import type { OnboardingUser } from "./types";
const log = logger.getSubLogger({ prefix: ["OrganizationOnboardingFactory"] });
/**
* Factory that selects the appropriate onboarding service based on configuration:
* - Self-hosted flow (billing disabled) when: IS_TEAM_BILLING_ENABLED=false
* - Exception: E2E tests always use self-hosted flow
* Otherwise, billing is enabled (Stripe flow).
*/
export class OrganizationOnboardingFactory {
static create(user: OnboardingUser): IOrganizationOnboardingService {
const isBillingEnabled = this.isBillingEnabled();
log.debug(
"Creating onboarding service",
safeStringify({
userId: user.id,
role: user.role,
IS_TEAM_BILLING_ENABLED: IS_TEAM_BILLING_ENABLED,
isBillingEnabled,
serviceType: isBillingEnabled ? "BillingEnabled" : "SelfHosted",
})
);
if (isBillingEnabled) {
return new BillingEnabledOrgOnboardingService(user);
} else {
return new SelfHostedOrganizationOnboardingService(user);
}
}
private static isBillingEnabled(): boolean {
// E2E tests always skip billing (use self-hosted flow)
if (process.env.NEXT_PUBLIC_IS_E2E) {
return false;
}
// If billing is enabled globally, use it
if (IS_TEAM_BILLING_ENABLED) {
return true;
}
return false;
}
}
@@ -0,0 +1,170 @@
# Organization Onboarding Setup
This document explains how the organization onboarding system works and how to configure it for different deployment scenarios.
## Overview
The organization onboarding system supports two different flows:
1. **Billing-Enabled Flow** (BillingEnabledOrgOnboardingService): Used on hosted Cal.com with Stripe integration
2. **Self-Hosted Flow** (SelfHostedOrganizationOnboardingService): Used on self-hosted instances where admins can create organizations without payment
The system automatically selects the appropriate flow based on environment variables and user permissions.
## Architecture
### Factory Pattern
The `OrganizationOnboardingFactory` class determines which onboarding service to use:
```typescript
OrganizationOnboardingFactory.create(user)
→ BillingEnabledOrgOnboardingService | SelfHostedOrganizationOnboardingService
```
### Decision Logic
The factory uses the following logic to determine which service to instantiate:
```
IF process.env.NEXT_PUBLIC_IS_E2E is set:
→ Use SelfHostedOrganizationOnboardingService (E2E tests always skip billing)
ELSE IF IS_TEAM_BILLING_ENABLED is true:
→ Use BillingEnabledOrgOnboardingService (hosted environment with Stripe)
ELSE IF user.role is ADMIN:
→ Use SelfHostedOrganizationOnboardingService (self-hosted admins skip billing)
ELSE:
→ Use BillingEnabledOrgOnboardingService (non-admins need billing even on self-hosted)
```
## Environment Variables
### Required for Billing-Enabled Flow
```bash
# Stripe Configuration
STRIPE_CLIENT_ID=your_stripe_client_id
NEXT_PUBLIC_STRIPE_PUBLIC_KEY=your_stripe_public_key
STRIPE_PRIVATE_KEY=your_stripe_private_key
# These enable IS_STRIPE_ENABLED and IS_TEAM_BILLING_ENABLED
```
### Optional Configuration
```bash
# Force hosted features on self-hosted instances
NEXT_PUBLIC_HOSTED_CAL_FEATURES=true
# E2E testing (disables billing flow)
NEXT_PUBLIC_IS_E2E=1
# Organization pricing
NEXT_PUBLIC_ORGANIZATIONS_MIN_SELF_SERVE_SEATS=5
NEXT_PUBLIC_ORGANIZATIONS_SELF_SERVE_PRICE_NEW=37
```
## Constants
The system relies on several computed constants from `@calcom/lib/constants`:
### Server-Side Constants
- **`IS_TEAM_BILLING_ENABLED`**: `IS_STRIPE_ENABLED && HOSTED_CAL_FEATURES`
- Determines if billing is globally enabled
- Used in: `OrganizationOnboardingFactory`
- **`IS_STRIPE_ENABLED`**: `!!(STRIPE_CLIENT_ID && NEXT_PUBLIC_STRIPE_PUBLIC_KEY && STRIPE_PRIVATE_KEY)`
- Checks if Stripe is properly configured
- **`HOSTED_CAL_FEATURES`**: `process.env.NEXT_PUBLIC_HOSTED_CAL_FEATURES || !IS_SELF_HOSTED`
- Enables hosted features (including billing)
- **`IS_SELF_HOSTED`**: `!CAL_DOMAINS.some(domain => WEBAPP_HOSTNAME.endsWith(domain))`
- Detects if running on official Cal.com domains
### Client-Side Constants
- **`IS_TEAM_BILLING_ENABLED_CLIENT`**: `!!process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY && HOSTED_CAL_FEATURES`
- Client-safe version of billing check
- Used in: `onboardingStore.ts`
## Configuration Scenarios
### Scenario 1: Hosted Cal.com (Official)
**Environment:**
```bash
# Domain: *.cal.com, *.cal.dev, etc.
STRIPE_CLIENT_ID=ca_xxx
NEXT_PUBLIC_STRIPE_PUBLIC_KEY=pk_live_xxx
STRIPE_PRIVATE_KEY=sk_live_xxx
```
**Result:**
- `IS_SELF_HOSTED` = `false`
- `HOSTED_CAL_FEATURES` = `true`
- `IS_TEAM_BILLING_ENABLED` = `true`
- All users → **BillingEnabledOrgOnboardingService**
### Scenario 2: Self-Hosted with Billing (Optional)
**Environment:**
```bash
# Domain: custom.example.com
NEXT_PUBLIC_HOSTED_CAL_FEATURES=true
STRIPE_CLIENT_ID=ca_xxx
NEXT_PUBLIC_STRIPE_PUBLIC_KEY=pk_live_xxx
STRIPE_PRIVATE_KEY=sk_live_xxx
```
**Result:**
- `IS_SELF_HOSTED` = `true`
- `HOSTED_CAL_FEATURES` = `true` (forced)
- `IS_TEAM_BILLING_ENABLED` = `true`
- All users → **BillingEnabledOrgOnboardingService**
### Scenario 3: Self-Hosted without Billing (Default)
**Environment:**
```bash
# Domain: custom.example.com
# No Stripe configuration
```
**Result:**
- `IS_SELF_HOSTED` = `true`
- `HOSTED_CAL_FEATURES` = `false`
- `IS_TEAM_BILLING_ENABLED` = `false`
- Admin users → **SelfHostedOrganizationOnboardingService**
- Regular users → **BillingEnabledOrgOnboardingService** (will fail without Stripe)
**Recommendation:** In this scenario, only admins should be allowed to create organizations.
### Scenario 4: E2E Testing
**Environment:**
```bash
NEXT_PUBLIC_IS_E2E=1
# Any other configuration
```
**Result:**
- All users → **SelfHostedOrganizationOnboardingService**
- Billing flow is completely bypassed
## User Roles
The system differentiates between two user roles:
### Admin Users (`UserPermissionRole.ADMIN`)
- Can create organizations without billing on self-hosted instances
- Can access self-hosted flow when `IS_TEAM_BILLING_ENABLED` is false
### Regular Users (`UserPermissionRole.USER`)
- Always require billing flow
- Cannot create organizations on self-hosted without Stripe configured
```
@@ -0,0 +1,230 @@
import { LicenseKeySingleton } from "@calcom/ee/common/server/LicenseKeyService";
import { findUserToBeOrgOwner } from "@calcom/features/ee/organizations/lib/server/orgCreationUtils";
import { OrganizationRepository } from "@calcom/features/ee/organizations/repositories/OrganizationRepository";
import { IS_SELF_HOSTED } from "@calcom/lib/constants";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { getTranslation } from "@calcom/lib/server/i18n";
import { DeploymentRepository } from "@calcom/lib/server/repository/deployment";
import { OrganizationOnboardingRepository } from "@calcom/lib/server/repository/organizationOnboarding";
import { prisma } from "@calcom/prisma";
import type { Team, User } from "@calcom/prisma/client";
import { BillingPeriod } from "@calcom/prisma/enums";
import { orgOnboardingInvitedMembersSchema, orgOnboardingTeamsSchema } from "@calcom/prisma/zod-utils";
import { BaseOnboardingService } from "./BaseOnboardingService";
import type {
CreateOnboardingIntentInput,
OnboardingIntentResult,
OrganizationOnboardingData,
OrganizationData,
} from "./types";
const log = logger.getSubLogger({ prefix: ["SelfHostedOrganizationOnboardingService"] });
const invitedMembersSchema = orgOnboardingInvitedMembersSchema;
const teamsSchema = orgOnboardingTeamsSchema;
/**
* Handles organization onboarding when billing is disabled (self-hosted admin flow).
*
* Flow:
* 1. Create onboarding record
* 2. Store teams/invites in database
* 3. Immediately create organization, teams, and invite members
* 4. Mark onboarding as complete
* 5. Return organization ID
*/
export class SelfHostedOrganizationOnboardingService extends BaseOnboardingService {
async createOnboardingIntent(input: CreateOnboardingIntentInput): Promise<OnboardingIntentResult> {
log.debug(
"Starting self-hosted onboarding flow (immediate organization creation)",
safeStringify({
slug: input.slug,
teamsCount: input.teams?.length ?? 0,
invitesCount: input.invitedMembers?.length ?? 0,
})
);
// Step 1: Filter and normalize teams/invites
const { teamsData, invitedMembersData } = this.filterTeamsAndInvites(input.teams, input.invitedMembers);
// Step 2: Create onboarding record with ALL data at once
const organizationOnboarding = await this.createOnboardingRecord({
...input,
teams: teamsData,
invitedMembers: invitedMembersData,
});
const onboardingId = organizationOnboarding.id;
// Check if this is an admin handover flow
const handoverResult = this.handleAdminHandoverIfNeeded(input, onboardingId);
if (handoverResult) {
return handoverResult;
}
// Step 3: Create organization immediately (regular self-hosted flow)
log.debug("Creating organization immediately (no payment required)", safeStringify({ onboardingId }));
const { organization } = await this.createOrganization({
id: onboardingId,
organizationId: null,
name: input.name,
slug: input.slug,
orgOwnerEmail: input.orgOwnerEmail,
seats: input.seats ?? null,
pricePerSeat: input.pricePerSeat ?? null,
billingPeriod: input.billingPeriod ?? BillingPeriod.MONTHLY,
invitedMembers: invitedMembersData,
teams: teamsData,
isPlatform: input.isPlatform,
logo: input.logo ?? null,
bio: input.bio ?? null,
brandColor: input.brandColor ?? null,
bannerUrl: input.bannerUrl ?? null,
stripeCustomerId: null,
isDomainConfigured: false,
});
// Step 4: Mark onboarding as complete
await OrganizationOnboardingRepository.markAsComplete(onboardingId);
log.debug(
"Organization created successfully",
safeStringify({ onboardingId, organizationId: organization.id })
);
// Step 5: Return result with organization ID
return {
userId: this.user.id,
orgOwnerEmail: input.orgOwnerEmail,
name: input.name,
slug: input.slug,
seats: input.seats ?? null,
pricePerSeat: input.pricePerSeat ?? null,
billingPeriod: input.billingPeriod,
isPlatform: input.isPlatform,
organizationOnboardingId: onboardingId,
checkoutUrl: null, // No checkout required
organizationId: organization.id, // Organization created immediately
};
}
async createOrganization(
organizationOnboarding: OrganizationOnboardingData
): Promise<{ organization: Team; owner: User }> {
log.info(
"createOrganization (self-hosted)",
safeStringify({
orgId: organizationOnboarding.organizationId,
orgSlug: organizationOnboarding.slug,
})
);
if (IS_SELF_HOSTED) {
const deploymentRepo = new DeploymentRepository(prisma);
const licenseKeyService = await LicenseKeySingleton.getInstance(deploymentRepo);
const hasValidLicense = await licenseKeyService.checkLicense();
if (!hasValidLicense) {
throw new Error("Self hosted license not valid");
}
}
if (
await this.hasConflictingOrganization({
slug: organizationOnboarding.slug,
onboardingId: organizationOnboarding.id,
})
) {
throw new Error("organization_already_exists_with_this_slug");
}
let owner = await findUserToBeOrgOwner(organizationOnboarding.orgOwnerEmail);
const orgOwnerTranslation = await getTranslation(owner?.locale || "en", "common");
if (!process.env.NEXT_PUBLIC_SINGLE_ORG_SLUG) {
await this.handleDomainSetup({
organizationOnboarding,
orgOwnerTranslation,
});
}
const orgData: OrganizationData = {
id: organizationOnboarding.organizationId,
name: organizationOnboarding.name,
slug: organizationOnboarding.slug,
isOrganizationConfigured: true,
isOrganizationAdminReviewed: true,
autoAcceptEmail: organizationOnboarding.orgOwnerEmail.split("@")[1],
seats: organizationOnboarding.seats,
pricePerSeat: organizationOnboarding.pricePerSeat,
isPlatform: false,
billingPeriod: organizationOnboarding.billingPeriod,
logoUrl: organizationOnboarding.logo,
bio: organizationOnboarding.bio,
brandColor: organizationOnboarding.brandColor,
bannerUrl: organizationOnboarding.bannerUrl,
};
let organization: Team;
if (!owner) {
const result = await this.createOrganizationWithNonExistentUserAsOwner({
email: organizationOnboarding.orgOwnerEmail,
orgData,
});
organization = result.organization;
owner = result.owner;
} else {
const result = await this.createOrganizationWithExistingUserAsOwner({
orgData,
owner,
});
organization = result.organization;
}
if (organizationOnboarding.stripeCustomerId) {
await this.ensureStripeCustomerIdIsUpdated({
owner,
stripeCustomerId: organizationOnboarding.stripeCustomerId,
});
}
await OrganizationOnboardingRepository.update(organizationOnboarding.id, {
organizationId: organization.id,
});
const teamsData = teamsSchema.parse(organizationOnboarding.teams);
await this.createOrMoveTeamsToOrganization(teamsData, owner, organization.id);
await this.inviteMembers(
invitedMembersSchema.parse(organizationOnboarding.invitedMembers),
organization,
teamsData
);
if (!organization.slug) {
try {
const { slug } = await OrganizationRepository.setSlug({
id: organization.id,
slug: organizationOnboarding.slug,
});
organization.slug = slug;
} catch (error) {
log.error(
"RecoverableError: Error while setting slug for organization",
safeStringify(error),
safeStringify({
attemptedSlug: organizationOnboarding.slug,
organizationId: organization.id,
})
);
throw new Error(
`Unable to set slug '${organizationOnboarding.slug}' for organization ${organization.id}`
);
}
}
return { organization, owner };
}
}
@@ -0,0 +1,249 @@
import { describe, expect, it } from "vitest";
import { UserPermissionRole } from "@calcom/prisma/enums";
import { BaseOnboardingService } from "../BaseOnboardingService";
import type { CreateOnboardingIntentInput } from "../types";
class TestableBaseOnboardingService extends BaseOnboardingService {
async createOnboardingIntent(input: CreateOnboardingIntentInput): Promise<any> {
throw new Error("Not implemented");
}
public testFilterTeamsAndInvites(
teams: CreateOnboardingIntentInput["teams"],
invitedMembers: CreateOnboardingIntentInput["invitedMembers"]
) {
return this.filterTeamsAndInvites(teams, invitedMembers);
}
}
const mockUser = {
id: 1,
email: "user@example.com",
role: UserPermissionRole.USER,
name: "Test User",
};
describe("BaseOnboardingService", () => {
describe("filterTeamsAndInvites", () => {
it("should filter out invites with empty emails", () => {
const service = new TestableBaseOnboardingService(mockUser as any);
const invites = [
{ email: "valid@example.com", teamName: "Marketing", role: "MEMBER" },
{ email: "", teamName: "Sales", role: "ADMIN" },
{ email: " ", teamName: "Engineering", role: "MEMBER" },
{ email: "another@example.com", teamName: "Design", role: "MEMBER" },
];
const { invitedMembersData } = service.testFilterTeamsAndInvites([], invites);
expect(invitedMembersData).toHaveLength(2);
expect(invitedMembersData).toEqual([
{
email: "valid@example.com",
name: undefined,
teamId: undefined,
teamName: "Marketing",
role: "MEMBER",
},
{
email: "another@example.com",
name: undefined,
teamId: undefined,
teamName: "Design",
role: "MEMBER",
},
]);
});
it("should preserve all fields from invites including role", () => {
const service = new TestableBaseOnboardingService(mockUser as any);
const invites = [
{
email: "member@example.com",
name: "John Doe",
teamId: 5,
teamName: "Marketing",
role: "MEMBER",
},
{
email: "admin@example.com",
name: "Jane Smith",
teamId: 10,
teamName: "Engineering",
role: "ADMIN",
},
];
const { invitedMembersData } = service.testFilterTeamsAndInvites([], invites);
expect(invitedMembersData).toEqual([
{
email: "member@example.com",
name: "John Doe",
teamId: 5,
teamName: "Marketing",
role: "MEMBER",
},
{
email: "admin@example.com",
name: "Jane Smith",
teamId: 10,
teamName: "Engineering",
role: "ADMIN",
},
]);
});
it("should handle invites without optional fields", () => {
const service = new TestableBaseOnboardingService(mockUser as any);
const invites = [
{ email: "minimal@example.com" },
{ email: "withteam@example.com", teamName: "Sales" },
];
const { invitedMembersData } = service.testFilterTeamsAndInvites([], invites);
expect(invitedMembersData).toEqual([
{
email: "minimal@example.com",
name: undefined,
teamId: undefined,
teamName: undefined,
role: undefined,
},
{
email: "withteam@example.com",
name: undefined,
teamId: undefined,
teamName: "Sales",
role: undefined,
},
]);
});
it("should filter out teams with empty names", () => {
const service = new TestableBaseOnboardingService(mockUser as any);
const teams = [
{ id: 1, name: "Marketing", isBeingMigrated: false, slug: null },
{ id: 2, name: "", isBeingMigrated: false, slug: null },
{ id: 3, name: " ", isBeingMigrated: false, slug: null },
{ id: 4, name: "Engineering", isBeingMigrated: true, slug: "eng" },
];
const { teamsData } = service.testFilterTeamsAndInvites(teams, []);
expect(teamsData).toHaveLength(2);
expect(teamsData).toEqual([
{ id: 1, name: "Marketing", isBeingMigrated: false, slug: null },
{ id: 4, name: "Engineering", isBeingMigrated: true, slug: "eng" },
]);
});
it("should preserve team properties including migration status", () => {
const service = new TestableBaseOnboardingService(mockUser as any);
const teams = [
{ id: -1, name: "New Team", isBeingMigrated: false, slug: null },
{ id: 42, name: "Existing Team", isBeingMigrated: true, slug: "existing-team" },
];
const { teamsData } = service.testFilterTeamsAndInvites(teams, []);
expect(teamsData).toEqual([
{ id: -1, name: "New Team", isBeingMigrated: false, slug: null },
{ id: 42, name: "Existing Team", isBeingMigrated: true, slug: "existing-team" },
]);
});
it("should handle empty teams and invites arrays", () => {
const service = new TestableBaseOnboardingService(mockUser as any);
const { teamsData, invitedMembersData } = service.testFilterTeamsAndInvites([], []);
expect(teamsData).toEqual([]);
expect(invitedMembersData).toEqual([]);
});
it("should handle undefined teams and invites", () => {
const service = new TestableBaseOnboardingService(mockUser as any);
const { teamsData, invitedMembersData } = service.testFilterTeamsAndInvites(undefined, undefined);
expect(teamsData).toEqual([]);
expect(invitedMembersData).toEqual([]);
});
it("should preserve invites with teamId=-1 for new teams", () => {
const service = new TestableBaseOnboardingService(mockUser as any);
const teams = [
{ id: -1, name: "Marketing", isBeingMigrated: false, slug: null },
{ id: -1, name: "Sales", isBeingMigrated: false, slug: null },
];
const invites = [
{ email: "user1@example.com", teamId: -1, teamName: "Marketing", role: "MEMBER" },
{ email: "user2@example.com", teamId: -1, teamName: "Sales", role: "ADMIN" },
];
const { teamsData, invitedMembersData } = service.testFilterTeamsAndInvites(teams, invites);
expect(teamsData).toHaveLength(2);
expect(invitedMembersData).toHaveLength(2);
expect(invitedMembersData[0].teamId).toBe(-1);
expect(invitedMembersData[1].teamId).toBe(-1);
expect(invitedMembersData[0].role).toBe("MEMBER");
expect(invitedMembersData[1].role).toBe("ADMIN");
});
it("should handle mixed scenarios with both org-level and team-specific invites", () => {
const service = new TestableBaseOnboardingService(mockUser as any);
const teams = [
{ id: -1, name: "Marketing", isBeingMigrated: false, slug: null },
{ id: 42, name: "Engineering", isBeingMigrated: true, slug: "eng" },
];
const invites = [
{ email: "orguser@example.com", role: "MEMBER" },
{ email: "marketing@example.com", teamName: "Marketing", teamId: -1, role: "MEMBER" },
{ email: "eng@example.com", teamName: "Engineering", teamId: 42, role: "ADMIN" },
];
const { teamsData, invitedMembersData } = service.testFilterTeamsAndInvites(teams, invites);
expect(teamsData).toHaveLength(2);
expect(invitedMembersData).toHaveLength(3);
expect(invitedMembersData[0]).toEqual({
email: "orguser@example.com",
name: undefined,
teamId: undefined,
teamName: undefined,
role: "MEMBER",
});
expect(invitedMembersData[1]).toEqual({
email: "marketing@example.com",
name: undefined,
teamId: -1,
teamName: "Marketing",
role: "MEMBER",
});
expect(invitedMembersData[2]).toEqual({
email: "eng@example.com",
name: undefined,
teamId: 42,
teamName: "Engineering",
role: "ADMIN",
});
});
});
});
@@ -0,0 +1,814 @@
import prismock from "../../../../../../../../tests/libs/__mocks__/prisma";
import { describe, expect, it, vi, beforeEach } from "vitest";
import * as constants from "@calcom/lib/constants";
import { createDomain } from "@calcom/lib/domainManager/organization";
import { UserPermissionRole, CreationSource, MembershipRole, BillingPeriod } from "@calcom/prisma/enums";
import { createTeamsHandler } from "@calcom/trpc/server/routers/viewer/organizations/createTeams.handler";
import { inviteMembersWithNoInviterPermissionCheck } from "@calcom/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler";
import type { CreateOnboardingIntentInput } from "../../onboarding/types";
import { BillingEnabledOrgOnboardingService } from "../BillingEnabledOrgOnboardingService";
vi.mock("../../OrganizationPaymentService");
vi.mock("@calcom/features/auth/lib/verifyEmail", () => ({
sendEmailVerification: vi.fn(),
}));
vi.mock("@calcom/emails/email-manager", () => ({
sendOrganizationCreationEmail: vi.fn(),
}));
vi.mock("@calcom/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler", () => ({
inviteMembersWithNoInviterPermissionCheck: vi.fn(),
}));
vi.mock("@calcom/trpc/server/routers/viewer/organizations/createTeams.handler", () => ({
createTeamsHandler: vi.fn(),
}));
vi.mock("@calcom/lib/domainManager/organization", () => ({
createDomain: vi.fn(),
}));
vi.mock("@calcom/lib/server/i18n", () => {
return {
getTranslation: async (locale: string, namespace: string) => {
const t = (key: string) => key;
t.locale = locale;
t.namespace = namespace;
return t;
},
};
});
const mockUser = {
id: 1,
email: "user@example.com",
role: UserPermissionRole.USER,
name: "Test User",
};
const mockInput: CreateOnboardingIntentInput = {
name: "Test Organization",
slug: "test-org",
orgOwnerEmail: "owner@example.com",
seats: 10,
pricePerSeat: 20,
isPlatform: false,
creationSource: CreationSource.WEBAPP,
logo: "https://example.com/logo.png",
bio: "Test bio",
brandColor: "#000000",
bannerUrl: "https://example.com/banner.png",
teams: [
{ id: -1, name: "Engineering", isBeingMigrated: false, slug: null },
{ id: -1, name: "Sales", isBeingMigrated: false, slug: null },
],
invitedMembers: [
{ email: "member1@example.com", name: "Member 1" },
{ email: "member2@example.com", name: "Member 2" },
],
};
describe("BillingEnabledOrgOnboardingService", () => {
let service: BillingEnabledOrgOnboardingService;
let mockPaymentService: any;
beforeEach(async () => {
vi.resetAllMocks();
// @ts-expect-error reset is a method on Prismock
await prismock.reset();
mockPaymentService = {
createOrganizationOnboarding: vi.fn().mockImplementation(async (data: any) => {
// Actually create the record in prismock so it can be updated later
return await prismock.organizationOnboarding.create({
data: {
id: "onboarding-123",
name: data.name,
slug: data.slug,
orgOwnerEmail: data.orgOwnerEmail,
seats: data.seats ?? null,
pricePerSeat: data.pricePerSeat ?? null,
billingPeriod: data.billingPeriod ?? BillingPeriod.MONTHLY,
isComplete: false,
stripeCustomerId: null,
createdById: data.createdByUserId,
teams: [],
invitedMembers: [],
isPlatform: data.isPlatform ?? false,
logo: data.logo ?? null,
bio: data.bio ?? null,
brandColor: data.brandColor ?? null,
bannerUrl: data.bannerUrl ?? null,
},
});
}),
createPaymentIntent: vi.fn().mockResolvedValue({
checkoutUrl: "https://stripe.com/checkout/session",
organizationOnboarding: {},
subscription: {},
sessionId: "session-123",
}),
};
service = new BillingEnabledOrgOnboardingService(mockUser as any, mockPaymentService);
});
describe("createOnboardingIntent", () => {
it("should create onboarding record", async () => {
await service.createOnboardingIntent(mockInput);
expect(mockPaymentService.createOrganizationOnboarding).toHaveBeenCalledWith(
expect.objectContaining({
name: mockInput.name,
slug: mockInput.slug,
orgOwnerEmail: mockInput.orgOwnerEmail,
createdByUserId: mockUser.id,
})
);
});
it("should create Stripe payment intent with teams and invites", async () => {
await service.createOnboardingIntent(mockInput);
expect(mockPaymentService.createPaymentIntent).toHaveBeenCalledWith(
expect.objectContaining({
teams: expect.arrayContaining([
{ id: -1, name: "Engineering", isBeingMigrated: false, slug: null },
{ id: -1, name: "Sales", isBeingMigrated: false, slug: null },
]),
invitedMembers: expect.arrayContaining([
{ email: "member1@example.com", name: "Member 1" },
{ email: "member2@example.com", name: "Member 2" },
]),
}),
expect.anything()
);
});
it("should return checkout URL", async () => {
const result = await service.createOnboardingIntent(mockInput);
expect(result.checkoutUrl).toBe("https://stripe.com/checkout/session");
expect(result.organizationId).toBeNull();
});
it("should filter out empty team names", async () => {
const inputWithEmptyTeams = {
...mockInput,
teams: [
{ id: -1, name: "Engineering", isBeingMigrated: false, slug: null },
{ id: -1, name: " ", isBeingMigrated: false, slug: null },
{ id: -1, name: "", isBeingMigrated: false, slug: null },
],
};
await service.createOnboardingIntent(inputWithEmptyTeams);
expect(mockPaymentService.createPaymentIntent).toHaveBeenCalledWith(
expect.objectContaining({
teams: [{ id: -1, name: "Engineering", isBeingMigrated: false, slug: null }],
}),
expect.anything()
);
});
it("should filter out empty invite emails", async () => {
const inputWithEmptyInvites = {
...mockInput,
invitedMembers: [
{ email: "member1@example.com", name: "Member 1" },
{ email: " ", name: "Empty" },
{ email: "", name: "Empty 2" },
],
};
await service.createOnboardingIntent(inputWithEmptyInvites);
expect(mockPaymentService.createPaymentIntent).toHaveBeenCalledWith(
expect.objectContaining({
invitedMembers: [{ email: "member1@example.com", name: "Member 1" }],
}),
expect.anything()
);
});
it("should NOT create organization immediately", async () => {
const result = await service.createOnboardingIntent(mockInput);
// Organization will be created later via Stripe webhook
expect(result.organizationId).toBeNull();
expect(result.checkoutUrl).not.toBeNull();
});
it("should return all required fields", async () => {
const result = await service.createOnboardingIntent(mockInput);
expect(result).toEqual({
userId: mockUser.id,
orgOwnerEmail: mockInput.orgOwnerEmail,
name: mockInput.name,
slug: mockInput.slug,
seats: mockInput.seats,
pricePerSeat: mockInput.pricePerSeat,
billingPeriod: mockInput.billingPeriod,
isPlatform: mockInput.isPlatform,
organizationOnboardingId: "onboarding-123",
checkoutUrl: "https://stripe.com/checkout/session",
organizationId: null,
});
});
it("should immediately create organization when admin creates org for self", async () => {
vi.spyOn(constants, "IS_SELF_HOSTED", "get").mockReturnValue(false);
const adminUser = {
id: 1,
email: "admin@example.com",
role: UserPermissionRole.ADMIN,
name: "Admin User",
};
const adminInput = {
...mockInput,
orgOwnerEmail: adminUser.email,
};
await prismock.user.create({
data: {
id: adminUser.id,
email: adminUser.email,
name: adminUser.name,
username: "admin",
completedOnboarding: true,
emailVerified: new Date(),
},
});
const adminService = new BillingEnabledOrgOnboardingService(adminUser as any, mockPaymentService);
const result = await adminService.createOnboardingIntent(adminInput);
expect(result.organizationId).not.toBeNull();
expect(result.checkoutUrl).toBeNull();
expect(mockPaymentService.createPaymentIntent).not.toHaveBeenCalled();
const onboarding = await prismock.organizationOnboarding.findUnique({
where: { id: result.organizationOnboardingId },
});
expect(onboarding?.isComplete).toBe(true);
});
it("should use checkout flow when admin creates org for someone else with no teams/invites (handover)", async () => {
const adminUser = {
id: 1,
email: "admin@example.com",
role: UserPermissionRole.ADMIN,
name: "Admin User",
};
const handoverInput = {
...mockInput,
orgOwnerEmail: "other@example.com",
teams: [],
invitedMembers: [],
};
const adminService = new BillingEnabledOrgOnboardingService(adminUser as any, mockPaymentService);
const result = await adminService.createOnboardingIntent(handoverInput);
expect(result.organizationId).toBeNull();
expect(result.checkoutUrl).toBeNull();
expect(result.handoverUrl).toContain("/settings/organizations/new/resume");
expect(mockPaymentService.createPaymentIntent).not.toHaveBeenCalled();
});
});
describe("createOrganization", () => {
// Helper functions for creating test data
async function createTestUser(data: {
email: string;
name?: string;
username?: string;
metadata?: any;
onboardingCompleted?: boolean;
emailVerified?: Date | null;
}) {
return prismock.user.create({
data: {
email: data.email,
name: data.name || "Test User",
username: data.username || "testuser",
metadata: data.metadata || {},
completedOnboarding: data.onboardingCompleted,
emailVerified: data.emailVerified,
},
});
}
async function createTestOrganization(data: {
name: string;
slug: string;
isOrganization?: boolean;
metadata?: any;
}) {
return prismock.team.create({
data: {
name: data.name,
slug: data.slug,
isOrganization: data.isOrganization ?? true,
metadata: data.metadata || {},
},
});
}
async function createTestTeam(data: { name: string; slug: string }) {
return prismock.team.create({
data: {
name: data.name,
slug: data.slug,
isOrganization: false,
},
});
}
async function createTestMembership(data: { userId: number; teamId: number; role?: MembershipRole }) {
return prismock.membership.create({
data: {
createdAt: new Date(),
userId: data.userId,
teamId: data.teamId,
role: data.role || MembershipRole.MEMBER,
accepted: true,
},
});
}
async function createTestOnboarding(overrides?: Partial<typeof mockOrganizationOnboardingData>) {
return await prismock.organizationOnboarding.create({
data: {
...mockOrganizationOnboardingData,
...overrides,
},
});
}
const mockOrganizationOnboardingData = {
id: "onboarding-123",
organizationId: null,
name: "Test Org",
slug: "test-org",
orgOwnerEmail: "owner@example.com",
seats: 5,
pricePerSeat: 20,
billingPeriod: BillingPeriod.MONTHLY,
invitedMembers: [{ email: "member1@example.com" }, { email: "member2@example.com" }],
teams: [
{ id: 1, name: "Team To Move", isBeingMigrated: true, slug: "new-team-slug" },
{ id: -1, name: "New Team", isBeingMigrated: false, slug: null },
],
isPlatform: false,
logo: null,
bio: null,
brandColor: null,
bannerUrl: null,
stripeCustomerId: "cus_123",
isDomainConfigured: false,
isComplete: false,
createdById: null,
};
it("should require payment details for billing-enabled flow", async () => {
vi.spyOn(constants, "IS_SELF_HOSTED", "get").mockReturnValue(false);
const mockOrganizationOnboarding = await createTestOnboarding();
await expect(service.createOrganization(mockOrganizationOnboarding)).rejects.toThrow(
"payment_subscription_id_and_payment_subscription_item_id_are_required"
);
});
it("should create organization with existing user as owner", async () => {
vi.spyOn(constants, "IS_SELF_HOSTED", "get").mockReturnValue(false);
const mockOrganizationOnboarding = await createTestOnboarding();
const existingUser = await createTestUser({
email: mockOrganizationOnboarding.orgOwnerEmail,
name: "Existing User",
username: "existinguser",
onboardingCompleted: true,
emailVerified: new Date(),
});
const { organization, owner } = await service.createOrganization(mockOrganizationOnboarding, {
subscriptionId: "sub_123",
subscriptionItemId: "si_123",
});
// Verify organization creation
expect(organization).toBeDefined();
expect(organization.name).toBe(mockOrganizationOnboarding.name);
expect(organization.slug).toBe(mockOrganizationOnboarding.slug);
// Verify owner is the existing user
expect(owner.id).toBe(existingUser.id);
expect(owner.email).toBe(existingUser.email);
expect(createDomain).toHaveBeenCalledWith(organization.slug);
expect(inviteMembersWithNoInviterPermissionCheck).toHaveBeenCalled();
expect(createTeamsHandler).toHaveBeenCalled();
});
it("should reuse existing organization if organizationId is already set (idempotency)", async () => {
vi.spyOn(constants, "IS_SELF_HOSTED", "get").mockReturnValue(false);
const mockOrganizationOnboarding = await createTestOnboarding();
const existingOrg = await createTestOrganization({
name: mockOrganizationOnboarding.name,
slug: mockOrganizationOnboarding.slug,
});
const existingUser = await createTestUser({
email: mockOrganizationOnboarding.orgOwnerEmail,
onboardingCompleted: true,
emailVerified: new Date(),
});
const onboardingWithOrgId = {
...mockOrganizationOnboarding,
organizationId: existingOrg.id,
};
const { organization } = await service.createOrganization(onboardingWithOrgId, {
subscriptionId: "sub_123",
subscriptionItemId: "si_123",
});
// Verify the existing organization was reused
expect(organization.id).toBe(existingOrg.id);
});
it("should throw error if organization with same slug exists", async () => {
vi.spyOn(constants, "IS_SELF_HOSTED", "get").mockReturnValue(false);
const mockOrganizationOnboarding = await createTestOnboarding();
await createTestUser({
email: mockOrganizationOnboarding.orgOwnerEmail,
onboardingCompleted: true,
emailVerified: new Date(),
});
await createTestOrganization({
name: "Conflicting Org",
slug: mockOrganizationOnboarding.slug,
});
await expect(
service.createOrganization(mockOrganizationOnboarding, {
subscriptionId: "sub_123",
subscriptionItemId: "si_123",
})
).rejects.toThrow("organization_url_taken");
});
it("should update stripe customer ID for existing user", async () => {
vi.spyOn(constants, "IS_SELF_HOSTED", "get").mockReturnValue(false);
const mockOrganizationOnboarding = await createTestOnboarding();
const existingUser = await createTestUser({
email: mockOrganizationOnboarding.orgOwnerEmail,
onboardingCompleted: true,
emailVerified: new Date(),
});
await service.createOrganization(mockOrganizationOnboarding, {
subscriptionId: "sub_123",
subscriptionItemId: "si_123",
});
// Verify user's stripe customer ID was updated
const updatedUser = await prismock.user.findUnique({
where: { id: existingUser.id },
});
expect(updatedUser?.metadata).toEqual(
expect.objectContaining({
stripeCustomerId: mockOrganizationOnboarding.stripeCustomerId,
})
);
});
it("should create organization even if there is a team with same slug that orgOwner is a member of", async () => {
vi.spyOn(constants, "IS_SELF_HOSTED", "get").mockReturnValue(false);
const mockOrganizationOnboarding = await createTestOnboarding();
const existingUser = await createTestUser({
email: mockOrganizationOnboarding.orgOwnerEmail,
onboardingCompleted: true,
emailVerified: new Date(),
});
const teamWithConflictingSlug = await createTestTeam({
name: "TestTeamWithConflictingSlug",
slug: mockOrganizationOnboarding.slug,
});
// Make the orgOwner a member of the team
await createTestMembership({
userId: existingUser.id,
teamId: teamWithConflictingSlug.id,
role: MembershipRole.ADMIN,
});
const { organization } = await service.createOrganization(mockOrganizationOnboarding, {
subscriptionId: "sub_123",
subscriptionItemId: "si_123",
});
expect(organization.slug).toBe(mockOrganizationOnboarding.slug);
});
it("should not create organization if there is a team with same slug that orgOwner is NOT a member of", async () => {
vi.spyOn(constants, "IS_SELF_HOSTED", "get").mockReturnValue(false);
const mockOrganizationOnboarding = await createTestOnboarding();
await createTestUser({
email: mockOrganizationOnboarding.orgOwnerEmail,
onboardingCompleted: true,
emailVerified: new Date(),
});
await createTestTeam({
name: "TestTeamWithConflictingSlugNotOwnedByOrgOwner",
slug: mockOrganizationOnboarding.slug,
});
await expect(
service.createOrganization(mockOrganizationOnboarding, {
subscriptionId: "sub_123",
subscriptionItemId: "si_123",
})
).rejects.toThrow("organization_url_taken");
});
it("should invite members with isDirectUserAction set to false", async () => {
vi.spyOn(constants, "IS_SELF_HOSTED", "get").mockReturnValue(false);
const mockOrganizationOnboarding = await createTestOnboarding();
await createTestUser({
email: mockOrganizationOnboarding.orgOwnerEmail,
username: "org-owner",
onboardingCompleted: true,
emailVerified: new Date(),
});
const result = await service.createOrganization(mockOrganizationOnboarding, {
subscriptionId: "sub_123",
subscriptionItemId: "si_123",
});
// Verify inviteMembersWithNoInviterPermissionCheck was called with isDirectUserAction: false
expect(inviteMembersWithNoInviterPermissionCheck).toHaveBeenCalledWith(
expect.objectContaining({
teamId: result.organization.id,
invitations: [
{ usernameOrEmail: "member1@example.com", role: MembershipRole.MEMBER },
{ usernameOrEmail: "member2@example.com", role: MembershipRole.MEMBER },
],
isDirectUserAction: false,
})
);
});
it("should invite members to specific teams based on teamName", async () => {
vi.spyOn(constants, "IS_SELF_HOSTED", "get").mockReturnValue(false);
const mockOrganizationOnboarding = await createTestOnboarding();
await createTestUser({
email: mockOrganizationOnboarding.orgOwnerEmail,
onboardingCompleted: true,
emailVerified: new Date(),
});
const onboardingWithTeamInvites = {
...mockOrganizationOnboarding,
teams: [
{ id: -1, name: "Marketing", isBeingMigrated: false, slug: null },
{ id: -1, name: "Engineering", isBeingMigrated: false, slug: null },
],
invitedMembers: [
{ email: "marketer@example.com", teamName: "marketing", role: MembershipRole.MEMBER },
{ email: "engineer@example.com", teamName: "engineering", role: MembershipRole.ADMIN },
{ email: "orguser@example.com" },
],
};
// Setup: createTeamsHandler will be called and we need teams to exist for inviteMembers
vi.mocked(createTeamsHandler).mockImplementation(async (options) => {
const marketing = await prismock.team.create({
data: {
name: "Marketing",
slug: "marketing",
parentId: options.input.orgId,
},
});
const engineering = await prismock.team.create({
data: {
name: "Engineering",
slug: "engineering",
parentId: options.input.orgId,
},
});
return { teams: [marketing, engineering] } as any;
});
const result = await service.createOrganization(onboardingWithTeamInvites, {
subscriptionId: "sub_123",
subscriptionItemId: "si_123",
});
// Verify invites were sent to 3 different targets (org + 2 teams)
expect(inviteMembersWithNoInviterPermissionCheck).toHaveBeenCalledTimes(3);
// Check org-level invite
const orgInviteCall = vi
.mocked(inviteMembersWithNoInviterPermissionCheck)
.mock.calls.find((call) => call[0].teamId === result.organization.id);
expect(orgInviteCall).toBeDefined();
expect(orgInviteCall![0].invitations).toEqual([
{ usernameOrEmail: "orguser@example.com", role: MembershipRole.MEMBER },
]);
// Check Marketing team invite
const teams = await prismock.team.findMany({ where: { parentId: result.organization.id } });
const marketingTeam = teams.find((t) => t.name === "Marketing");
const marketingInviteCall = vi
.mocked(inviteMembersWithNoInviterPermissionCheck)
.mock.calls.find((call) => call[0].teamId === marketingTeam!.id);
expect(marketingInviteCall).toBeDefined();
expect(marketingInviteCall![0].invitations).toEqual([
{ usernameOrEmail: "marketer@example.com", role: MembershipRole.MEMBER },
]);
// Check Engineering team invite
const engineeringTeam = teams.find((t) => t.name === "Engineering");
const engineeringInviteCall = vi
.mocked(inviteMembersWithNoInviterPermissionCheck)
.mock.calls.find((call) => call[0].teamId === engineeringTeam!.id);
expect(engineeringInviteCall).toBeDefined();
expect(engineeringInviteCall![0].invitations).toEqual([
{ usernameOrEmail: "engineer@example.com", role: MembershipRole.ADMIN },
]);
});
it("should preserve custom roles for invited members", async () => {
vi.spyOn(constants, "IS_SELF_HOSTED", "get").mockReturnValue(false);
const mockOrganizationOnboarding = await createTestOnboarding();
await createTestUser({
email: mockOrganizationOnboarding.orgOwnerEmail,
onboardingCompleted: true,
emailVerified: new Date(),
});
const onboardingWithRoles = {
...mockOrganizationOnboarding,
invitedMembers: [
{ email: "admin@example.com", role: MembershipRole.ADMIN },
{ email: "member@example.com", role: MembershipRole.MEMBER },
],
};
const result = await service.createOrganization(onboardingWithRoles, {
subscriptionId: "sub_123",
subscriptionItemId: "si_123",
});
const inviteCall = vi
.mocked(inviteMembersWithNoInviterPermissionCheck)
.mock.calls.find((call) => call[0].teamId === result.organization.id);
expect(inviteCall).toBeDefined();
expect(inviteCall![0].invitations).toEqual([
{ usernameOrEmail: "admin@example.com", role: MembershipRole.ADMIN },
{ usernameOrEmail: "member@example.com", role: MembershipRole.MEMBER },
]);
});
it("should handle mixed org-level and team-specific invites", async () => {
vi.spyOn(constants, "IS_SELF_HOSTED", "get").mockReturnValue(false);
const mockOrganizationOnboarding = await createTestOnboarding();
await createTestUser({
email: mockOrganizationOnboarding.orgOwnerEmail,
onboardingCompleted: true,
emailVerified: new Date(),
});
const onboardingWithMixedInvites = {
...mockOrganizationOnboarding,
teams: [{ id: -1, name: "Sales", isBeingMigrated: false, slug: null }],
invitedMembers: [
{ email: "sales1@example.com", teamName: "sales", role: MembershipRole.MEMBER },
{ email: "sales2@example.com", teamName: "sales", role: MembershipRole.ADMIN },
{ email: "org1@example.com", role: MembershipRole.MEMBER },
{ email: "org2@example.com", role: MembershipRole.MEMBER },
],
};
vi.mocked(createTeamsHandler).mockImplementation(async (options) => {
const salesTeam = await prismock.team.create({
data: {
name: "Sales",
slug: "sales",
parentId: options.input.orgId,
},
});
return { teams: [salesTeam] } as any;
});
const result = await service.createOrganization(onboardingWithMixedInvites, {
subscriptionId: "sub_123",
subscriptionItemId: "si_123",
});
expect(inviteMembersWithNoInviterPermissionCheck).toHaveBeenCalledTimes(2);
// Find org-level invites
const orgInviteCall = vi
.mocked(inviteMembersWithNoInviterPermissionCheck)
.mock.calls.find((call) => call[0].teamId === result.organization.id);
expect(orgInviteCall).toBeDefined();
expect(orgInviteCall![0].invitations).toEqual([
{ usernameOrEmail: "org1@example.com", role: MembershipRole.MEMBER },
{ usernameOrEmail: "org2@example.com", role: MembershipRole.MEMBER },
]);
// Find Sales team invites
const teams = await prismock.team.findMany({ where: { parentId: result.organization.id } });
const salesTeam = teams.find((t) => t.name === "Sales");
const salesInviteCall = vi
.mocked(inviteMembersWithNoInviterPermissionCheck)
.mock.calls.find((call) => call[0].teamId === salesTeam!.id);
expect(salesInviteCall).toBeDefined();
expect(salesInviteCall![0].invitations).toEqual([
{ usernameOrEmail: "sales1@example.com", role: MembershipRole.MEMBER },
{ usernameOrEmail: "sales2@example.com", role: MembershipRole.ADMIN },
]);
});
it("should fall back to org-level invite if teamName does not match any created team", async () => {
vi.spyOn(constants, "IS_SELF_HOSTED", "get").mockReturnValue(false);
const mockOrganizationOnboarding = await createTestOnboarding();
await createTestUser({
email: mockOrganizationOnboarding.orgOwnerEmail,
onboardingCompleted: true,
emailVerified: new Date(),
});
const onboardingWithNonMatchingTeam = {
...mockOrganizationOnboarding,
teams: [{ id: -1, name: "Marketing", isBeingMigrated: false, slug: null }],
invitedMembers: [
{ email: "user@example.com", teamName: "NonExistentTeam", role: MembershipRole.MEMBER },
],
};
vi.mocked(createTeamsHandler).mockResolvedValue({
teams: [{ id: 300, name: "Marketing" }],
} as any);
const result = await service.createOrganization(onboardingWithNonMatchingTeam, {
subscriptionId: "sub_123",
subscriptionItemId: "si_123",
});
expect(inviteMembersWithNoInviterPermissionCheck).toHaveBeenCalledWith(
expect.objectContaining({
teamId: result.organization.id,
invitations: [{ usernameOrEmail: "user@example.com", role: MembershipRole.MEMBER }],
isDirectUserAction: false,
})
);
});
});
});
@@ -0,0 +1,204 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { UserPermissionRole } from "@calcom/prisma/enums";
const mockRegularUser = {
id: 1,
email: "user@example.com",
role: UserPermissionRole.USER,
name: "Regular User",
};
const mockAdminUser = {
id: 2,
email: "admin@example.com",
role: UserPermissionRole.ADMIN,
name: "Admin User",
};
describe("OrganizationOnboardingFactory", () => {
let originalEnv: NodeJS.ProcessEnv;
beforeEach(() => {
originalEnv = { ...process.env };
vi.clearAllMocks();
});
afterEach(() => {
process.env = originalEnv;
vi.restoreAllMocks();
vi.resetModules();
});
describe("create", () => {
it("should return BillingEnabledOrgOnboardingService for regular user when billing is enabled", async () => {
vi.doMock("@calcom/lib/constants", async (importOriginal) => {
const actual = await importOriginal<typeof import("@calcom/lib/constants")>();
return {
...actual,
IS_TEAM_BILLING_ENABLED: true,
};
});
const { OrganizationOnboardingFactory: Factory } = await import("../OrganizationOnboardingFactory");
const service = Factory.create(mockRegularUser as any);
expect(service.constructor.name).toBe("BillingEnabledOrgOnboardingService");
});
it("should return BillingEnabledOrgOnboardingService for admin on hosted (billing enabled)", async () => {
vi.doMock("@calcom/lib/constants", async (importOriginal) => {
const actual = await importOriginal<typeof import("@calcom/lib/constants")>();
return {
...actual,
IS_TEAM_BILLING_ENABLED: true,
};
});
const { OrganizationOnboardingFactory: Factory } = await import("../OrganizationOnboardingFactory");
const service = Factory.create(mockAdminUser as any);
expect(service.constructor.name).toBe("BillingEnabledOrgOnboardingService");
});
it("should return SelfHostedOrganizationOnboardingService for admin when IS_TEAM_BILLING_ENABLED is false", async () => {
vi.doMock("@calcom/lib/constants", async (importOriginal) => {
const actual = await importOriginal<typeof import("@calcom/lib/constants")>();
return {
...actual,
IS_TEAM_BILLING_ENABLED: false,
};
});
const { OrganizationOnboardingFactory: Factory } = await import("../OrganizationOnboardingFactory");
const service = Factory.create(mockAdminUser as any);
// Self-hosted admins skip billing
expect(service.constructor.name).toBe("SelfHostedOrganizationOnboardingService");
});
it("should return SelfHostedOrganizationOnboardingService for regular user when IS_TEAM_BILLING_ENABLED is false", async () => {
vi.doMock("@calcom/lib/constants", async (importOriginal) => {
const actual = await importOriginal<typeof import("@calcom/lib/constants")>();
return {
...actual,
IS_TEAM_BILLING_ENABLED: false,
};
});
const { OrganizationOnboardingFactory: Factory } = await import("../OrganizationOnboardingFactory");
const service = Factory.create(mockRegularUser as any);
// When billing is disabled, everyone uses self-hosted flow
expect(service.constructor.name).toBe("SelfHostedOrganizationOnboardingService");
});
it("should return SelfHostedOrganizationOnboardingService in E2E mode", async () => {
process.env.NEXT_PUBLIC_IS_E2E = "1";
vi.doMock("@calcom/lib/constants", async (importOriginal) => {
const actual = await importOriginal<typeof import("@calcom/lib/constants")>();
return {
...actual,
IS_TEAM_BILLING_ENABLED: true,
};
});
const { OrganizationOnboardingFactory: Factory } = await import("../OrganizationOnboardingFactory");
const service = Factory.create(mockAdminUser as any);
// E2E mode returns false from isBillingEnabled, meaning SelfHosted flow
expect(service.constructor.name).toBe("SelfHostedOrganizationOnboardingService");
});
it("should return BillingEnabledOrgOnboardingService when billing is enabled for regular user", async () => {
vi.doMock("@calcom/lib/constants", async (importOriginal) => {
const actual = await importOriginal<typeof import("@calcom/lib/constants")>();
return {
...actual,
IS_TEAM_BILLING_ENABLED: true,
};
});
const { OrganizationOnboardingFactory: Factory } = await import("../OrganizationOnboardingFactory");
const service = Factory.create(mockRegularUser as any);
expect(service.constructor.name).toBe("BillingEnabledOrgOnboardingService");
});
});
describe("Factory Decision Matrix", () => {
const testCases = [
{
scenario: "Hosted (billing enabled) + Regular User",
user: mockRegularUser,
isBillingEnabled: true,
isE2E: false,
expected: "BillingEnabled",
},
{
scenario: "Hosted (billing enabled) + Admin User",
user: mockAdminUser,
isBillingEnabled: true,
isE2E: false,
expected: "BillingEnabled",
},
{
scenario: "Self-hosted (billing disabled) + Regular User",
user: mockRegularUser,
isBillingEnabled: false,
isE2E: false,
expected: "SelfHosted", // Everyone uses self-hosted when billing disabled
},
{
scenario: "Self-hosted (billing disabled) + Admin User",
user: mockAdminUser,
isBillingEnabled: false,
isE2E: false,
expected: "SelfHosted", // Everyone uses self-hosted when billing disabled
},
{
scenario: "E2E mode + Admin User",
user: mockAdminUser,
isBillingEnabled: true,
isE2E: true,
expected: "SelfHosted", // E2E always uses self-hosted flow
},
{
scenario: "E2E mode + Regular User",
user: mockRegularUser,
isBillingEnabled: true,
isE2E: true,
expected: "SelfHosted", // E2E always uses self-hosted flow
},
];
testCases.forEach(({ scenario, user, isBillingEnabled, isE2E, expected }) => {
it(`${scenario}${expected}`, async () => {
// Setup environment
if (isE2E) {
process.env.NEXT_PUBLIC_IS_E2E = "1";
} else {
delete process.env.NEXT_PUBLIC_IS_E2E;
}
vi.doMock("@calcom/lib/constants", async (importOriginal) => {
const actual = await importOriginal<typeof import("@calcom/lib/constants")>();
return {
...actual,
IS_TEAM_BILLING_ENABLED: isBillingEnabled,
};
});
const { OrganizationOnboardingFactory: Factory } = await import("../OrganizationOnboardingFactory");
const service = Factory.create(user as any);
if (expected === "BillingEnabled") {
expect(service.constructor.name).toBe("BillingEnabledOrgOnboardingService");
} else {
expect(service.constructor.name).toBe("SelfHostedOrganizationOnboardingService");
}
});
});
});
});
@@ -0,0 +1,523 @@
import prismock from "../../../../../../../../tests/libs/__mocks__/prisma";
import { describe, expect, it, vi, beforeEach } from "vitest";
import { LicenseKeySingleton } from "@calcom/ee/common/server/LicenseKeyService";
import * as constants from "@calcom/lib/constants";
import { OrganizationOnboardingRepository } from "@calcom/lib/server/repository/organizationOnboarding";
import { UserPermissionRole, CreationSource, MembershipRole, BillingPeriod } from "@calcom/prisma/enums";
import { createTeamsHandler } from "@calcom/trpc/server/routers/viewer/organizations/createTeams.handler";
import { inviteMembersWithNoInviterPermissionCheck } from "@calcom/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler";
import { SelfHostedOrganizationOnboardingService } from "../SelfHostedOnboardingService";
import type { CreateOnboardingIntentInput, OrganizationOnboardingData } from "../types";
vi.mock("../../OrganizationPaymentService");
vi.mock("@calcom/lib/server/repository/organizationOnboarding");
vi.mock("@calcom/ee/common/server/LicenseKeyService", () => ({
LicenseKeySingleton: {
getInstance: vi.fn(),
},
}));
vi.mock("@calcom/features/auth/lib/verifyEmail", () => ({
sendEmailVerification: vi.fn(),
}));
vi.mock("@calcom/emails/email-manager", () => ({
sendOrganizationCreationEmail: vi.fn(),
}));
vi.mock("@calcom/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler", () => ({
inviteMembersWithNoInviterPermissionCheck: vi.fn(),
}));
vi.mock("@calcom/trpc/server/routers/viewer/organizations/createTeams.handler", () => ({
createTeamsHandler: vi.fn(),
}));
vi.mock("@calcom/lib/domainManager/organization", () => ({
createDomain: vi.fn(),
}));
vi.mock("@calcom/lib/server/i18n", () => {
return {
getTranslation: async (locale: string, namespace: string) => {
const t = (key: string) => key;
t.locale = locale;
t.namespace = namespace;
return t;
},
};
});
const mockAdminUser = {
id: 2,
email: "admin@example.com",
role: UserPermissionRole.ADMIN,
name: "Admin User",
};
const mockInput: CreateOnboardingIntentInput = {
name: "Test Organization",
slug: "test-org",
orgOwnerEmail: "owner@example.com",
seats: 10,
pricePerSeat: 20,
isPlatform: false,
creationSource: CreationSource.WEBAPP,
logo: "https://example.com/logo.png",
bio: "Test bio",
brandColor: "#000000",
bannerUrl: "https://example.com/banner.png",
teams: [
{ id: -1, name: "Engineering", isBeingMigrated: false, slug: null },
{ id: -1, name: "Sales", isBeingMigrated: false, slug: null },
],
invitedMembers: [
{ email: "member1@example.com", name: "Member 1" },
{ email: "member2@example.com", name: "Member 2" },
],
};
describe("SelfHostedOrganizationOnboardingService", () => {
let service: SelfHostedOrganizationOnboardingService;
let mockPaymentService: any;
beforeEach(async () => {
vi.resetAllMocks();
// @ts-expect-error reset is a method on Prismock
await prismock.reset();
// Mock license check
vi.mocked(LicenseKeySingleton.getInstance).mockResolvedValue({
checkLicense: vi.fn().mockResolvedValue(true),
} as any);
mockPaymentService = {
createOrganizationOnboarding: vi.fn().mockResolvedValue({
id: "onboarding-123",
name: mockInput.name,
slug: mockInput.slug,
orgOwnerEmail: mockInput.orgOwnerEmail,
seats: mockInput.seats,
pricePerSeat: mockInput.pricePerSeat,
billingPeriod: "MONTHLY",
isComplete: false,
stripeCustomerId: null,
}),
};
vi.mocked(OrganizationOnboardingRepository.update).mockResolvedValue({
id: "onboarding-123",
teams: [],
invitedMembers: [],
} as any);
vi.mocked(OrganizationOnboardingRepository.markAsComplete).mockResolvedValue({} as any);
service = new SelfHostedOrganizationOnboardingService(mockAdminUser as any, mockPaymentService);
});
describe("createOnboardingIntent", () => {
it("should create onboarding record", async () => {
await service.createOnboardingIntent(mockInput);
expect(mockPaymentService.createOrganizationOnboarding).toHaveBeenCalledWith(
expect.objectContaining({
name: mockInput.name,
slug: mockInput.slug,
orgOwnerEmail: mockInput.orgOwnerEmail,
createdByUserId: mockAdminUser.id,
})
);
});
it("should store teams and invites in onboarding record", async () => {
await service.createOnboardingIntent(mockInput);
// Teams and invites are now passed during creation, not via update
expect(mockPaymentService.createOrganizationOnboarding).toHaveBeenCalledWith(
expect.objectContaining({
teams: expect.arrayContaining([
{ id: -1, name: "Engineering", isBeingMigrated: false, slug: null },
{ id: -1, name: "Sales", isBeingMigrated: false, slug: null },
]),
invitedMembers: expect.arrayContaining([
{ email: "member1@example.com", name: "Member 1" },
{ email: "member2@example.com", name: "Member 2" },
]),
})
);
});
it("should mark onboarding as complete", async () => {
await service.createOnboardingIntent(mockInput);
expect(OrganizationOnboardingRepository.markAsComplete).toHaveBeenCalledWith("onboarding-123");
});
it("should return organization ID (not checkout URL)", async () => {
const result = await service.createOnboardingIntent(mockInput);
expect(result.organizationId).toBe(1);
expect(result.checkoutUrl).toBeNull();
});
it("should filter out empty team names", async () => {
const inputWithEmptyTeams = {
...mockInput,
teams: [
{ id: -1, name: "Engineering", isBeingMigrated: false, slug: null },
{ id: -1, name: " ", isBeingMigrated: false, slug: null },
{ id: -1, name: "", isBeingMigrated: false, slug: null },
],
};
await service.createOnboardingIntent(inputWithEmptyTeams);
// Teams are now passed during creation with empty names filtered out
expect(mockPaymentService.createOrganizationOnboarding).toHaveBeenCalledWith(
expect.objectContaining({
teams: [{ id: -1, name: "Engineering", isBeingMigrated: false, slug: null }],
})
);
});
it("should filter out empty invite emails", async () => {
const inputWithEmptyInvites = {
...mockInput,
invitedMembers: [
{ email: "member1@example.com", name: "Member 1" },
{ email: " ", name: "Empty" },
{ email: "", name: "Empty 2" },
],
};
await service.createOnboardingIntent(inputWithEmptyInvites);
// Invites are now passed during creation with empty emails filtered out
expect(mockPaymentService.createOrganizationOnboarding).toHaveBeenCalledWith(
expect.objectContaining({
invitedMembers: [{ email: "member1@example.com", name: "Member 1" }],
})
);
});
it("should create organization immediately", async () => {
const result = await service.createOnboardingIntent(mockInput);
// Organization created immediately, not pending payment
expect(result.organizationId).toBe(1);
expect(result.checkoutUrl).toBeNull();
});
it("should return all required fields", async () => {
const result = await service.createOnboardingIntent(mockInput);
expect(result).toEqual({
userId: mockAdminUser.id,
orgOwnerEmail: mockInput.orgOwnerEmail,
name: mockInput.name,
slug: mockInput.slug,
seats: mockInput.seats,
pricePerSeat: mockInput.pricePerSeat,
billingPeriod: mockInput.billingPeriod,
isPlatform: mockInput.isPlatform,
organizationOnboardingId: "onboarding-123",
checkoutUrl: null,
organizationId: 1,
});
});
});
describe("createOrganization", () => {
beforeEach(() => {
vi.spyOn(constants, "IS_SELF_HOSTED", "get").mockReturnValue(true);
});
// Helper functions
async function createTestUser(data: {
email: string;
name?: string;
username?: string;
metadata?: any;
onboardingCompleted?: boolean;
emailVerified?: Date | null;
}) {
return prismock.user.create({
data: {
email: data.email,
name: data.name || "Test User",
username: data.username || "testuser",
metadata: data.metadata || {},
completedOnboarding: data.onboardingCompleted,
emailVerified: data.emailVerified,
},
});
}
async function createTestTeam(data: { name: string; slug: string }) {
return prismock.team.create({
data: {
name: data.name,
slug: data.slug,
isOrganization: false,
},
});
}
async function createTestMembership(data: { userId: number; teamId: number; role?: MembershipRole }) {
return prismock.membership.create({
data: {
createdAt: new Date(),
userId: data.userId,
teamId: data.teamId,
role: data.role || MembershipRole.MEMBER,
accepted: true,
},
});
}
const mockOrganizationOnboarding: OrganizationOnboardingData = {
id: "onboarding-123",
organizationId: null,
name: "Test Org",
slug: "test-org",
orgOwnerEmail: "test@example.com",
billingPeriod: BillingPeriod.MONTHLY,
seats: 5,
pricePerSeat: 20,
stripeCustomerId: null,
invitedMembers: [{ email: "member1@example.com" }, { email: "member2@example.com" }],
teams: [
{ id: 1, name: "Team To Move", isBeingMigrated: true, slug: "new-team-slug" },
{ id: 2, name: "New Team", isBeingMigrated: false, slug: null },
],
logo: null,
bio: null,
brandColor: null,
bannerUrl: null,
isDomainConfigured: false,
isPlatform: false,
};
it("should fail if the license is invalid", async () => {
vi.mocked(LicenseKeySingleton.getInstance).mockResolvedValue({
checkLicense: vi.fn().mockResolvedValue(false),
} as any);
await expect(service.createOrganization(mockOrganizationOnboarding)).rejects.toThrow(
"Self hosted license not valid"
);
});
it("should create an organization with a valid license", async () => {
vi.mocked(LicenseKeySingleton.getInstance).mockResolvedValue({
checkLicense: vi.fn().mockResolvedValue(true),
} as any);
const existingUser = await createTestUser({
email: mockOrganizationOnboarding.orgOwnerEmail,
name: "Existing User",
username: "existinguser",
onboardingCompleted: true,
emailVerified: new Date(),
});
const { organization, owner } = await service.createOrganization(mockOrganizationOnboarding);
expect(organization).toBeDefined();
expect(organization.name).toBe(mockOrganizationOnboarding.name);
expect(organization.slug).toBe(mockOrganizationOnboarding.slug);
expect(owner).toBeDefined();
expect(owner.id).toBe(existingUser.id);
});
it("should create organization with existing user as owner", async () => {
vi.mocked(LicenseKeySingleton.getInstance).mockResolvedValue({
checkLicense: vi.fn().mockResolvedValue(true),
} as any);
const existingUser = await createTestUser({
email: mockOrganizationOnboarding.orgOwnerEmail,
name: "Existing User",
username: "existinguser",
onboardingCompleted: true,
emailVerified: new Date(),
});
const { organization, owner } = await service.createOrganization(mockOrganizationOnboarding);
expect(organization).toBeDefined();
expect(owner.id).toBe(existingUser.id);
expect(owner.email).toBe(existingUser.email);
// Verify teams and members
expect(inviteMembersWithNoInviterPermissionCheck).toHaveBeenCalled();
expect(createTeamsHandler).toHaveBeenCalled();
});
it("should throw error if organization with same slug exists", async () => {
vi.mocked(LicenseKeySingleton.getInstance).mockResolvedValue({
checkLicense: vi.fn().mockResolvedValue(true),
} as any);
await createTestUser({
email: mockOrganizationOnboarding.orgOwnerEmail,
onboardingCompleted: true,
emailVerified: new Date(),
});
// Create existing organization with same slug
await prismock.team.create({
data: {
name: "Conflicting Org",
slug: mockOrganizationOnboarding.slug,
isOrganization: true,
},
});
await expect(service.createOrganization(mockOrganizationOnboarding)).rejects.toThrow(
"organization_url_taken"
);
});
it("should create organization with slug set even if there is a team with same slug owned by orgOwner", async () => {
vi.mocked(LicenseKeySingleton.getInstance).mockResolvedValue({
checkLicense: vi.fn().mockResolvedValue(true),
} as any);
const existingUser = await createTestUser({
email: mockOrganizationOnboarding.orgOwnerEmail,
onboardingCompleted: true,
emailVerified: new Date(),
});
const teamWithConflictingSlug = await createTestTeam({
name: "TestTeamWithConflictingSlug",
slug: mockOrganizationOnboarding.slug,
});
// Make the orgOwner a member of the team
await createTestMembership({
userId: existingUser.id,
teamId: teamWithConflictingSlug.id,
role: MembershipRole.ADMIN,
});
const { organization } = await service.createOrganization(mockOrganizationOnboarding);
expect(organization.slug).toBe(mockOrganizationOnboarding.slug);
});
it("should not create organization if there is a team with same slug not owned by orgOwner", async () => {
vi.mocked(LicenseKeySingleton.getInstance).mockResolvedValue({
checkLicense: vi.fn().mockResolvedValue(true),
} as any);
await createTestUser({
email: mockOrganizationOnboarding.orgOwnerEmail,
onboardingCompleted: true,
emailVerified: new Date(),
});
await createTestTeam({
name: "TestTeamWithConflictingSlugNotOwnedByOrgOwner",
slug: mockOrganizationOnboarding.slug,
});
// Don't make the orgOwner a member of the team
await expect(service.createOrganization(mockOrganizationOnboarding)).rejects.toThrow(
"organization_url_taken"
);
});
it("should invite members with isDirectUserAction set to false", async () => {
vi.mocked(LicenseKeySingleton.getInstance).mockResolvedValue({
checkLicense: vi.fn().mockResolvedValue(true),
} as any);
await createTestUser({
email: mockOrganizationOnboarding.orgOwnerEmail,
username: "org-owner",
onboardingCompleted: true,
emailVerified: new Date(),
});
const result = await service.createOrganization(mockOrganizationOnboarding);
// Verify inviteMembersWithNoInviterPermissionCheck was called with isDirectUserAction: false
expect(inviteMembersWithNoInviterPermissionCheck).toHaveBeenCalledWith(
expect.objectContaining({
teamId: result.organization.id,
invitations: [
{ usernameOrEmail: "member1@example.com", role: MembershipRole.MEMBER },
{ usernameOrEmail: "member2@example.com", role: MembershipRole.MEMBER },
],
isDirectUserAction: false,
})
);
});
it("should invite members to specific teams based on teamName", async () => {
vi.mocked(LicenseKeySingleton.getInstance).mockResolvedValue({
checkLicense: vi.fn().mockResolvedValue(true),
} as any);
await createTestUser({
email: "owner@example.com",
onboardingCompleted: true,
emailVerified: new Date(),
});
const onboardingWithTeamInvites: OrganizationOnboardingData = {
...mockOrganizationOnboarding,
orgOwnerEmail: "owner@example.com",
teams: [
{ id: -1, name: "Marketing", isBeingMigrated: false, slug: null },
{ id: -1, name: "Engineering", isBeingMigrated: false, slug: null },
],
invitedMembers: [
{ email: "marketer@example.com", teamName: "marketing", role: "MEMBER" },
{ email: "engineer@example.com", teamName: "engineering", role: "ADMIN" },
{ email: "orguser@example.com" },
],
};
// Setup: createTeamsHandler will be called and we need teams to exist for inviteMembers
vi.mocked(createTeamsHandler).mockImplementation(async (options) => {
const marketing = await prismock.team.create({
data: {
name: "Marketing",
slug: "marketing",
parentId: options.input.orgId,
},
});
const engineering = await prismock.team.create({
data: {
name: "Engineering",
slug: "engineering",
parentId: options.input.orgId,
},
});
return { teams: [marketing, engineering] } as any;
});
const result = await service.createOrganization(onboardingWithTeamInvites);
// Verify invites were sent to 3 different targets (org + 2 teams)
expect(inviteMembersWithNoInviterPermissionCheck).toHaveBeenCalledTimes(3);
// Check org-level invite
const orgInviteCall = vi
.mocked(inviteMembersWithNoInviterPermissionCheck)
.mock.calls.find((call) => call[0].teamId === result.organization.id);
expect(orgInviteCall).toBeDefined();
expect(orgInviteCall![0].invitations).toEqual([
{ usernameOrEmail: "orguser@example.com", role: MembershipRole.MEMBER },
]);
});
});
});
@@ -0,0 +1,5 @@
export { OrganizationOnboardingFactory } from "./OrganizationOnboardingFactory";
export type { IOrganizationOnboardingService } from "./IOrganizationOnboardingService";
export type { CreateOnboardingIntentInput, OnboardingIntentResult } from "./types";
export { BillingEnabledOrgOnboardingService } from "../service/BillingEnabledOrgOnboardingService";
export { SelfHostedOrganizationOnboardingService } from "./SelfHostedOnboardingService";
@@ -0,0 +1,109 @@
import type { Prisma } from "@calcom/prisma/client";
import type { BillingPeriod, CreationSource } from "@calcom/prisma/enums";
export type TeamInput = {
id: number;
name: string;
isBeingMigrated: boolean;
slug: string | null;
};
export type InvitedMemberInput = {
email: string;
name?: string;
teamId?: number;
teamName?: string;
role?: string;
};
export type CreateOnboardingIntentInput = {
name: string;
slug: string;
orgOwnerEmail: string;
seats?: number | null;
pricePerSeat?: number | null;
billingPeriod?: BillingPeriod;
isPlatform: boolean;
creationSource: CreationSource;
logo?: string | null;
bio?: string | null;
brandColor?: string | null;
bannerUrl?: string | null;
teams?: TeamInput[];
invitedMembers?: InvitedMemberInput[];
onboardingId?: string;
};
export type OnboardingIntentResult = {
userId: number;
orgOwnerEmail: string;
name: string;
slug: string;
seats: number | null;
pricePerSeat: number | null;
billingPeriod?: BillingPeriod;
isPlatform: boolean;
organizationOnboardingId: string;
checkoutUrl: string | null;
organizationId?: number | null;
handoverUrl?: string | null;
};
export interface OnboardingUser {
id: number;
email: string;
role: "ADMIN" | "USER";
name?: string;
}
export type OrganizationOnboardingData = {
id: string;
organizationId: number | null;
name: string;
slug: string;
orgOwnerEmail: string;
seats: number | null;
pricePerSeat: number | null;
billingPeriod: BillingPeriod;
invitedMembers: Prisma.JsonValue;
teams: Prisma.JsonValue;
isPlatform: boolean;
logo: string | null;
bio: string | null;
brandColor: string | null;
bannerUrl: string | null;
stripeCustomerId: string | null;
isDomainConfigured: boolean;
};
export type OrganizationData = {
id: number | null;
name: string;
slug: string;
isOrganizationConfigured: boolean;
isOrganizationAdminReviewed: boolean;
autoAcceptEmail: string;
seats: number | null;
pricePerSeat: number | null;
isPlatform: boolean;
logoUrl: string | null;
bio: string | null;
brandColor: string | null;
bannerUrl: string | null;
billingPeriod?: "MONTHLY" | "ANNUALLY";
};
export type TeamData = {
id: number;
name: string;
isBeingMigrated: boolean;
slug: string | null;
};
export type InvitedMember = {
email: string;
name?: string;
teamId?: number;
teamName?: string;
role?: string;
};
@@ -6,3 +6,45 @@ export function extractDomainFromEmail(email: string) {
} catch (ignore) {}
return out.split(".")[0];
}
/**
* Checks if an email is a company email (not a personal email provider)
*/
export function isCompanyEmail(email: string): boolean {
// A list of popular @domains that are personal email providers
const personalEmailProviders = [
"gmail.com",
"yahoo.com",
"outlook.com",
"hotmail.com",
"aol.com",
"icloud.com",
"mail.com",
"protonmail.com",
"proton.me",
"zoho.com",
"yandex.com",
"gmx.com",
"fastmail.com",
"inbox.com",
"me.com",
"hushmail.com",
"live.com",
"rediffmail.com",
"tutanota.com",
"mail.ru",
"usa.com",
"qq.com",
"163.com",
"web.de",
"rocketmail.com",
"excite.com",
"lycos.com",
"outlook.co",
"hotmail.co.uk",
];
const emailParts = email.split("@");
if (emailParts.length < 2) return false;
return !personalEmailProviders.includes(emailParts[1].toLowerCase());
}
@@ -7,6 +7,8 @@ export const createOrganizationSchema = z.object({
language: z.string().optional(),
logo: z.string().nullish(),
bio: z.string().nullish(),
brandColor: z.string().nullish(),
bannerUrl: z.string().nullish(),
onboardingId: z.string(),
invitedMembers: orgOnboardingInvitedMembersSchema.optional(),
teams: orgOnboardingTeamsSchema.optional(),
+1
View File
@@ -28,6 +28,7 @@ export type AppFlags = {
"tiered-support-chat": boolean;
"calendar-subscription-cache": boolean;
"calendar-subscription-sync": boolean;
"onboarding-v3": boolean;
"booker-botid": boolean;
};
+1
View File
@@ -27,6 +27,7 @@ const initialData: AppFlags = {
"tiered-support-chat": false,
"calendar-subscription-cache": false,
"calendar-subscription-sync": false,
"onboarding-v3": false,
"booker-botid": false,
};
@@ -16,6 +16,8 @@ export type CreateOrganizationOnboardingInput = {
slug: string;
logo?: string | null;
bio?: string | null;
brandColor?: string | null;
bannerUrl?: string | null;
stripeCustomerId?: string;
stripeSubscriptionId?: string;
stripeSubscriptionItemId?: string;
@@ -41,6 +43,8 @@ export class OrganizationOnboardingRepository {
slug: data.slug,
logo: data.logo,
bio: data.bio,
brandColor: data.brandColor,
bannerUrl: data.bannerUrl,
stripeCustomerId: data.stripeCustomerId,
stripeSubscriptionId: data.stripeSubscriptionId,
invitedMembers: data.invitedMembers || [],
@@ -0,0 +1,9 @@
INSERT INTO
"Feature" (slug, enabled, description, "type")
VALUES
(
'onboarding-v3',
false,
'Enable onboarding v3 for all users',
'EXPERIMENT'
) ON CONFLICT (slug) DO NOTHING;
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "OrganizationOnboarding" ADD COLUMN "bannerUrl" TEXT,
ADD COLUMN "brandColor" TEXT;
+2
View File
@@ -2424,6 +2424,8 @@ model OrganizationOnboarding {
slug String
logo String?
bio String?
brandColor String?
bannerUrl String?
isDomainConfigured Boolean @default(false)
// Set when payment intent is there.
+7 -1
View File
@@ -58,7 +58,13 @@ export const bookerLayouts = z
.nullable();
export const orgOnboardingInvitedMembersSchema = z.array(
z.object({ email: z.string().email(), name: z.string().optional() })
z.object({
email: z.string().email(),
name: z.string().optional(),
teamId: z.number().optional(),
teamName: z.string().optional(),
role: z.enum(["MEMBER", "ADMIN"]).optional().default("MEMBER"),
})
);
export const orgOnboardingTeamsSchema = z.array(
@@ -1,6 +1,7 @@
import { lookup } from "dns";
import { getOrgFullOrigin } from "@calcom/ee/organizations/lib/orgDomains";
import { isNotACompanyEmail } from "@calcom/ee/organizations/lib/server/orgCreationUtils";
import { sendAdminOrganizationNotification, sendOrganizationCreationEmail } from "@calcom/emails";
import { sendEmailVerification } from "@calcom/features/auth/lib/verifyEmail";
import { DEFAULT_SCHEDULE, getAvailabilityFromSchedule } from "@calcom/lib/availability";
@@ -23,47 +24,6 @@ import type { TrpcSessionUser } from "../../../types";
import { BillingPeriod } from "./create.schema";
import type { TCreateInputSchema } from "./create.schema";
/**
* We can only say for sure that the email is not a company email. We can't say for sure if it is a company email.
*/
function isNotACompanyEmail(email: string) {
// A list of popular @domains that can't be used to allow automatic acceptance of memberships to organization
const emailProviders = [
"gmail.com",
"yahoo.com",
"outlook.com",
"hotmail.com",
"aol.com",
"icloud.com",
"mail.com",
"protonmail.com",
"zoho.com",
"yandex.com",
"gmx.com",
"fastmail.com",
"inbox.com",
"me.com",
"hushmail.com",
"live.com",
"rediffmail.com",
"tutanota.com",
"mail.ru",
"usa.com",
"qq.com",
"163.com",
"web.de",
"rocketmail.com",
"excite.com",
"lycos.com",
"outlook.co",
"hotmail.co.uk",
];
const emailParts = email.split("@");
if (emailParts.length < 2) return true;
return emailProviders.includes(emailParts[1]);
}
type CreateOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
@@ -1,4 +1,4 @@
import { createOrganizationFromOnboarding } from "@calcom/features/ee/organizations/lib/server/createOrganizationFromOnboarding";
import { SelfHostedOrganizationOnboardingService } from "@calcom/features/ee/organizations/lib/service/onboarding/SelfHostedOnboardingService";
import { IS_SELF_HOSTED } from "@calcom/lib/constants";
import { OrganizationOnboardingRepository } from "@calcom/lib/server/repository/organizationOnboarding";
@@ -45,24 +45,32 @@ export const createSelfHostedHandler = async ({ input, ctx }: CreateSelfHostedOp
});
}
const { organization } = await createOrganizationFromOnboarding({
organizationOnboarding: {
id: input.onboardingId,
logo: input.logo ?? null,
bio: input.bio ?? null,
invitedMembers: input.invitedMembers ?? [],
teams: input.teams ?? [],
orgOwnerEmail: organizationOnboarding.orgOwnerEmail,
slug: organizationOnboarding.slug,
name: organizationOnboarding.name,
billingPeriod: organizationOnboarding.billingPeriod,
seats: organizationOnboarding.seats,
pricePerSeat: organizationOnboarding.pricePerSeat,
stripeCustomerId: organizationOnboarding.stripeCustomerId,
isPlatform: organizationOnboarding.isPlatform,
isDomainConfigured: organizationOnboarding.isDomainConfigured,
organizationId: organizationOnboarding.organizationId,
},
const userContext = {
id: ctx.user.id,
email: ctx.user.email,
role: ctx.user.role as "ADMIN" | "USER",
name: ctx.user.name || undefined,
};
const onboardingService = new SelfHostedOrganizationOnboardingService(userContext);
const { organization } = await onboardingService.createOrganization({
id: input.onboardingId,
logo: input.logo ?? null,
bio: input.bio ?? null,
brandColor: input.brandColor ?? null,
bannerUrl: input.bannerUrl ?? null,
invitedMembers: input.invitedMembers ?? [],
teams: input.teams ?? [],
orgOwnerEmail: organizationOnboarding.orgOwnerEmail,
slug: organizationOnboarding.slug,
name: organizationOnboarding.name,
billingPeriod: organizationOnboarding.billingPeriod,
seats: organizationOnboarding.seats,
pricePerSeat: organizationOnboarding.pricePerSeat,
stripeCustomerId: organizationOnboarding.stripeCustomerId,
isPlatform: organizationOnboarding.isPlatform,
isDomainConfigured: organizationOnboarding.isDomainConfigured,
organizationId: organizationOnboarding.organizationId,
});
await OrganizationOnboardingRepository.markAsComplete(organizationOnboarding.id);
@@ -1,3 +1,12 @@
/**
* @deprecated This endpoint is deprecated. Use `intentToCreateOrg` instead, which now handles
* teams and invites in a single mutation call. This endpoint will be removed in a future release.
*
* Migration guide:
* - Instead of calling `intentToCreateOrg` followed by `createWithPaymentIntent`,
* pass teams and invitedMembers directly to `intentToCreateOrg`
* - The new endpoint returns checkoutUrl directly in the response
*/
import { OrganizationPaymentService } from "@calcom/features/ee/organizations/lib/OrganizationPaymentService";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
@@ -15,8 +24,20 @@ type CreateOptions = {
input: TCreateWithPaymentIntentInputSchema;
};
const log = logger.getSubLogger({ prefix: ["viewer", "organizations", "createWithPaymentIntent"] });
/**
* @deprecated Use intentToCreateOrg with teams and invitedMembers instead
*/
export const createHandler = async ({ input, ctx }: CreateOptions) => {
const paymentService = new OrganizationPaymentService(ctx.user);
log.warn(
"DEPRECATED: createWithPaymentIntent is deprecated. Use intentToCreateOrg with teams and invitedMembers instead."
);
const paymentService = new OrganizationPaymentService({
id: ctx.user.id,
email: ctx.user.email,
role: ctx.user.role,
name: ctx.user.name ?? undefined,
});
const isAdmin = ctx.user.role === "ADMIN";
// Regular user can send onboardingId if the onboarding was started by ADMIN/someone else and they shared the link with them.
// ADMIN flow doesn't send onboardingId
@@ -57,6 +78,8 @@ export const createHandler = async ({ input, ctx }: CreateOptions) => {
...input,
logo: input.logo ?? null,
bio: input.bio ?? null,
brandColor: input.brandColor ?? null,
bannerUrl: input.bannerUrl ?? null,
},
organizationOnboarding
);
@@ -1,3 +1,8 @@
/**
* @deprecated This schema is deprecated. Use ZIntentToCreateOrgInputSchema instead.
* See createWithPaymentIntent.handler.ts for migration guide.
*/
import type { z } from "zod";
import { createOrganizationSchema } from "@calcom/features/ee/organizations/types/schemas";
@@ -7,6 +12,9 @@ export enum BillingPeriod {
ANNUALLY = "ANNUALLY",
}
/**
* @deprecated Use ZIntentToCreateOrgInputSchema instead
*/
export const ZCreateWithPaymentIntentInputSchema = createOrganizationSchema;
export type TCreateWithPaymentIntentInputSchema = z.infer<typeof ZCreateWithPaymentIntentInputSchema>;
@@ -3,7 +3,8 @@ import prismock from "../../../../../../tests/libs/__mocks__/prisma";
import { describe, expect, it, vi, beforeEach } from "vitest";
import { LicenseKeySingleton } from "@calcom/ee/common/server/LicenseKeyService";
import { UserPermissionRole } from "@calcom/prisma/enums";
import { OrganizationPaymentService } from "@calcom/features/ee/organizations/lib/OrganizationPaymentService";
import { BillingPeriod, UserPermissionRole, CreationSource } from "@calcom/prisma/enums";
import { TRPCError } from "@trpc/server";
@@ -15,6 +16,8 @@ vi.mock("@calcom/ee/common/server/LicenseKeyService", () => ({
},
}));
vi.mock("@calcom/features/ee/organizations/lib/OrganizationPaymentService");
const mockInput = {
name: "Test Org",
slug: "test-org",
@@ -23,6 +26,7 @@ const mockInput = {
seats: 5,
pricePerSeat: 20,
isPlatform: false,
creationSource: "WEBAPP",
};
// Helper functions for creating test data
@@ -49,8 +53,37 @@ async function createTestUser(data: {
describe("intentToCreateOrgHandler", () => {
beforeEach(async () => {
vi.resetAllMocks();
// @ts-expect-error reset is a method on Prismock
await prismock.reset();
vi.mocked(OrganizationPaymentService).mockImplementation(() => {
return {
createOrganizationOnboarding: vi.fn().mockImplementation(async (data: any) => {
return await prismock.organizationOnboarding.create({
data: {
id: "onboarding-123",
name: data.name,
slug: data.slug,
orgOwnerEmail: data.orgOwnerEmail,
seats: data.seats ?? 10,
pricePerSeat: data.pricePerSeat ?? 15,
billingPeriod: data.billingPeriod ?? BillingPeriod.MONTHLY,
isComplete: false,
stripeCustomerId: null,
createdById: data.createdByUserId,
teams: data.teams ?? [],
invitedMembers: data.invitedMembers ?? [],
isPlatform: data.isPlatform ?? false,
},
});
}),
createPaymentIntent: vi.fn().mockResolvedValue({
checkoutUrl: "https://stripe.com/checkout/session",
organizationOnboarding: {},
subscription: {},
sessionId: "session-123",
}),
} as any;
});
});
describe("hosted", () => {
@@ -64,7 +97,10 @@ describe("intentToCreateOrgHandler", () => {
});
vi.mocked(LicenseKeySingleton.getInstance).mockResolvedValue({
checkLicense: vi.fn().mockResolvedValue(true),
incrementUsage: vi.fn().mockResolvedValue(undefined),
});
process.env.STRIPE_ORG_PRODUCT_ID = "prod_test123";
process.env.STRIPE_ORG_MONTHLY_PRICE_ID = "price_test123";
});
it("should allow admin user to create org for another user", async () => {
@@ -88,7 +124,7 @@ describe("intentToCreateOrgHandler", () => {
},
});
// Verify the result shape
// Admin creating for different user with no teams/invites triggers handover
expect(result).toEqual({
userId: expect.any(Number),
orgOwnerEmail: mockInput.orgOwnerEmail,
@@ -99,6 +135,9 @@ describe("intentToCreateOrgHandler", () => {
billingPeriod: mockInput.billingPeriod,
isPlatform: mockInput.isPlatform,
organizationOnboardingId: expect.any(String),
checkoutUrl: null,
organizationId: null, // Not created yet - handover flow
handoverUrl: expect.stringContaining("/settings/organizations/new/resume?onboardingId="),
});
// Verify organization onboarding was created
@@ -193,7 +232,7 @@ describe("intentToCreateOrgHandler", () => {
);
});
it("should throw error when organization onboarding already exists", async () => {
it("should throw error when organization onboarding already exists and is complete", async () => {
// Create admin user
const adminUser = await createTestUser({
email: "admin@example.com",
@@ -207,7 +246,7 @@ describe("intentToCreateOrgHandler", () => {
emailVerified: new Date(),
});
// Create existing organization onboarding
// Create existing COMPLETE organization onboarding
await prismock.organizationOnboarding.create({
data: {
name: mockInput.name,
@@ -217,6 +256,7 @@ describe("intentToCreateOrgHandler", () => {
seats: mockInput.seats,
pricePerSeat: mockInput.pricePerSeat,
createdById: adminUser.id,
isComplete: true, // Must be complete to throw error
},
});
@@ -249,6 +289,7 @@ describe("intentToCreateOrgHandler", () => {
it("should throw error when license is not valid", async () => {
vi.mocked(LicenseKeySingleton.getInstance).mockResolvedValue({
checkLicense: vi.fn().mockResolvedValue(false),
incrementUsage: vi.fn().mockResolvedValue(undefined),
});
const adminUser = await createTestUser({
email: "admin@example.com",
@@ -268,6 +309,7 @@ describe("intentToCreateOrgHandler", () => {
it("should allow admin user to create org for another user", async () => {
vi.mocked(LicenseKeySingleton.getInstance).mockResolvedValue({
checkLicense: vi.fn().mockResolvedValue(true),
incrementUsage: vi.fn().mockResolvedValue(undefined),
});
// Create admin user
const adminUser = await createTestUser({
@@ -289,7 +331,7 @@ describe("intentToCreateOrgHandler", () => {
},
});
// Verify the result shape
// Admin creating for different user with no teams/invites triggers handover (same as hosted)
expect(result).toEqual({
userId: expect.any(Number),
orgOwnerEmail: mockInput.orgOwnerEmail,
@@ -300,6 +342,9 @@ describe("intentToCreateOrgHandler", () => {
billingPeriod: mockInput.billingPeriod,
isPlatform: mockInput.isPlatform,
organizationOnboardingId: expect.any(String),
checkoutUrl: null,
organizationId: null, // Not created yet - handover flow
handoverUrl: expect.stringContaining("/settings/organizations/new/resume?onboardingId="),
});
// Verify organization onboarding was created
@@ -314,5 +359,119 @@ describe("intentToCreateOrgHandler", () => {
expect(organizationOnboarding?.slug).toBe(mockInput.slug);
expect(organizationOnboarding?.orgOwnerEmail).toBe(mockInput.orgOwnerEmail);
});
it("should handle teams and invites in the request", async () => {
vi.mocked(LicenseKeySingleton.getInstance).mockResolvedValue({
checkLicense: vi.fn().mockResolvedValue(true),
incrementUsage: vi.fn().mockResolvedValue(undefined),
});
const adminUser = await createTestUser({
email: "admin@example.com",
role: UserPermissionRole.ADMIN,
});
await createTestUser({
email: mockInput.orgOwnerEmail,
completedOnboarding: true,
emailVerified: new Date(),
});
const inputWithTeamsAndInvites = {
...mockInput,
teams: [
{ id: -1, name: "Engineering", isBeingMigrated: false, slug: null },
{ id: -1, name: "Sales", isBeingMigrated: false, slug: null },
],
invitedMembers: [
{ email: "member1@example.com", name: "Member 1" },
{ email: "member2@example.com", name: "Member 2" },
],
};
const result = await intentToCreateOrgHandler({
input: inputWithTeamsAndInvites,
ctx: {
user: adminUser,
},
});
expect(result.organizationOnboardingId).toBeDefined();
const organizationOnboarding = await prismock.organizationOnboarding.findFirst({
where: {
slug: mockInput.slug,
},
});
expect(organizationOnboarding).toBeDefined();
expect(organizationOnboarding?.teams).toEqual(inputWithTeamsAndInvites.teams);
expect(organizationOnboarding?.invitedMembers).toEqual(inputWithTeamsAndInvites.invitedMembers);
});
it("should preserve teamName, teamId, and role in invites payload", async () => {
vi.mocked(LicenseKeySingleton.getInstance).mockResolvedValue({
checkLicense: vi.fn().mockResolvedValue(true),
incrementUsage: vi.fn().mockResolvedValue(undefined),
});
const adminUser = await createTestUser({
email: "admin@example.com",
role: UserPermissionRole.ADMIN,
});
await createTestUser({
email: mockInput.orgOwnerEmail,
completedOnboarding: true,
emailVerified: new Date(),
});
// This matches the exact payload from the frontend
const inputWithTeamsAndInvites = {
...mockInput,
teams: [
{ id: -1, name: "New", isBeingMigrated: false, slug: null },
{ id: -1, name: "team", isBeingMigrated: false, slug: null },
],
invitedMembers: [
{ email: "new@new.com", teamName: "new", teamId: -1, role: "ADMIN" },
{ email: "team@new.com", teamName: "team", teamId: -1, role: "ADMIN" },
],
};
const result = await intentToCreateOrgHandler({
input: inputWithTeamsAndInvites,
ctx: {
user: adminUser,
},
});
expect(result.organizationOnboardingId).toBeDefined();
const organizationOnboarding = await prismock.organizationOnboarding.findFirst({
where: {
slug: mockInput.slug,
},
});
expect(organizationOnboarding).toBeDefined();
expect(organizationOnboarding?.teams).toEqual(inputWithTeamsAndInvites.teams);
// Verify invitedMembers are stored with all fields including teamName, teamId, and role
expect(organizationOnboarding?.invitedMembers).toBeDefined();
expect(organizationOnboarding?.invitedMembers).toHaveLength(2);
const invitedMembers = organizationOnboarding?.invitedMembers as any[];
expect(invitedMembers[0]).toMatchObject({
email: "new@new.com",
teamName: "new",
teamId: -1,
role: "ADMIN",
});
expect(invitedMembers[1]).toMatchObject({
email: "team@new.com",
teamName: "team",
teamId: -1,
role: "ADMIN",
});
});
});
});
@@ -1,5 +1,5 @@
import { LicenseKeySingleton } from "@calcom/ee/common/server/LicenseKeyService";
import { OrganizationPaymentService } from "@calcom/features/ee/organizations/lib/OrganizationPaymentService";
import { OrganizationOnboardingFactory } from "@calcom/ee/organizations/lib/service/onboarding/OrganizationOnboardingFactory";
import {
assertCanCreateOrg,
findUserToBeOrgOwner,
@@ -27,7 +27,7 @@ type CreateOptions = {
};
export const intentToCreateOrgHandler = async ({ input, ctx }: CreateOptions) => {
const { slug, name, orgOwnerEmail, seats, pricePerSeat, billingPeriod, isPlatform } = input;
const { slug, name, orgOwnerEmail, isPlatform } = input;
log.debug(
"Starting organization creation intent",
safeStringify({ slug, name, orgOwnerEmail, isPlatform })
@@ -41,7 +41,6 @@ export const intentToCreateOrgHandler = async ({ input, ctx }: CreateOptions) =>
if (!hasValidLicense) {
throw new TRPCError({
code: "BAD_REQUEST",
// TODO: We need to send translation keys from here and frontend should translate it
message: "License is not valid",
});
}
@@ -75,9 +74,24 @@ export const intentToCreateOrgHandler = async ({ input, ctx }: CreateOptions) =>
}
log.debug("Found organization owner", safeStringify({ orgOwnerId: orgOwner.id, email: orgOwner.email }));
let organizationOnboarding = await OrganizationOnboardingRepository.findByOrgOwnerEmail(orgOwner.email);
const organizationOnboarding = await OrganizationOnboardingRepository.findByOrgOwnerEmail(orgOwner.email);
// If onboarding exists and is incomplete, this is a resume flow (e.g., admin handover)
// Allow proceeding with the existing onboarding record
if (organizationOnboarding) {
throw new Error("organization_onboarding_already_exists");
if (organizationOnboarding.isComplete) {
// Organization already created - shouldn't create another one
throw new Error("organization_onboarding_already_exists");
}
// Incomplete onboarding exists - this is expected for resume/handover flows
log.debug(
"Found incomplete onboarding record - proceeding with resume flow",
safeStringify({ onboardingId: organizationOnboarding.id, slug })
);
// Use existing onboarding ID for the resume flow
input.onboardingId = organizationOnboarding.id;
}
await assertCanCreateOrg({
@@ -87,24 +101,16 @@ export const intentToCreateOrgHandler = async ({ input, ctx }: CreateOptions) =>
restrictBasedOnMinimumPublishedTeams: !IS_USER_ADMIN,
});
const paymentService = new OrganizationPaymentService(ctx.user);
organizationOnboarding = await paymentService.createOrganizationOnboarding({
...input,
createdByUserId: loggedInUser.id,
const onboardingService = OrganizationOnboardingFactory.create({
id: ctx.user.id,
email: ctx.user.email,
role: ctx.user.role,
});
const result = await onboardingService.createOnboardingIntent(input);
log.debug("Organization creation intent successful", safeStringify({ slug, orgOwnerId: orgOwner.id }));
return {
userId: orgOwner.id,
orgOwnerEmail,
name,
slug,
seats,
pricePerSeat,
billingPeriod,
isPlatform,
organizationOnboardingId: organizationOnboarding.id,
};
return result;
};
export default intentToCreateOrgHandler;
@@ -2,12 +2,8 @@ import { z } from "zod";
import { emailSchema } from "@calcom/lib/emailSchema";
import slugify from "@calcom/lib/slugify";
import { CreationSource } from "@calcom/prisma/enums";
export enum BillingPeriod {
MONTHLY = "MONTHLY",
ANNUALLY = "ANNUALLY",
}
import { BillingPeriod, CreationSource } from "@calcom/prisma/enums";
import { orgOnboardingInvitedMembersSchema, orgOnboardingTeamsSchema } from "@calcom/prisma/zod-utils";
export const ZIntentToCreateOrgInputSchema = z.object({
name: z.string(),
@@ -19,6 +15,16 @@ export const ZIntentToCreateOrgInputSchema = z.object({
isPlatform: z.boolean().default(false),
billingPeriod: z.nativeEnum(BillingPeriod).default(BillingPeriod.MONTHLY),
creationSource: z.nativeEnum(CreationSource),
// Brand fields
logo: z.string().nullish(),
bio: z.string().nullish(),
brandColor: z.string().nullish(),
bannerUrl: z.string().nullish(),
// Teams and invites (new)
teams: orgOnboardingTeamsSchema.optional(),
invitedMembers: orgOnboardingInvitedMembersSchema.optional(),
// Optional onboarding ID for resume flows (admin handover)
onboardingId: z.string().optional(),
});
export type TIntentToCreateOrgInputSchema = z.infer<typeof ZIntentToCreateOrgInputSchema>;