diff --git a/packages/features/ee/organizations/lib/OrganizationPaymentService.test.ts b/packages/features/ee/organizations/lib/OrganizationPaymentService.test.ts
index 7806ecdbcc..9a48ba4b20 100644
--- a/packages/features/ee/organizations/lib/OrganizationPaymentService.test.ts
+++ b/packages/features/ee/organizations/lib/OrganizationPaymentService.test.ts
@@ -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,
diff --git a/packages/features/ee/organizations/lib/OrganizationPaymentService.ts b/packages/features/ee/organizations/lib/OrganizationPaymentService.ts
index fea4d56b6e..18da38affa 100644
--- a/packages/features/ee/organizations/lib/OrganizationPaymentService.ts
+++ b/packages/features/ee/organizations/lib/OrganizationPaymentService.ts
@@ -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
;
+ protected user: OnboardingUser;
- constructor(user: NonNullable, 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 {
diff --git a/packages/features/ee/organizations/lib/OrganizationPermissionService.ts b/packages/features/ee/organizations/lib/OrganizationPermissionService.ts
index a324b8b575..6d6f8c3868 100644
--- a/packages/features/ee/organizations/lib/OrganizationPermissionService.ts
+++ b/packages/features/ee/organizations/lib/OrganizationPermissionService.ts
@@ -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) {}
+ constructor(private readonly user: OnboardingUser) {}
async hasPermissionToCreateForEmail(targetEmail: string): Promise {
return this.user.email === targetEmail || this.user.role === "ADMIN";
diff --git a/packages/features/ee/organizations/lib/onboardingStore.test.ts b/packages/features/ee/organizations/lib/onboardingStore.test.ts
new file mode 100644
index 0000000000..d16b0312a9
--- /dev/null
+++ b/packages/features/ee/organizations/lib/onboardingStore.test.ts
@@ -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 | null) {
+ const organizationOnboarding = data ? createTestOrganizationOnboarding(data) : null;
+ (trpc.viewer.organizations.getOrganizationOnboarding.useQuery as ReturnType).mockReturnValue({
+ data: organizationOnboarding,
+ isPending: false,
+ isLoading: false,
+ error: null,
+ refetch: vi.fn(),
+ });
+}
+
+const createTestSearchParams = (params?: Record) => {
+ 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).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).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).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");
+ });
+ });
+ });
+});
diff --git a/packages/features/ee/organizations/lib/onboardingStore.ts b/packages/features/ee/organizations/lib/onboardingStore.ts
index 3dd9210aca..acf84ff8b3 100644
--- a/packages/features/ee/organizations/lib/onboardingStore.ts
+++ b/packages/features/ee/organizations/lib/onboardingStore.ts
@@ -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()(
persist(
- (set) => ({
+ (set, get) => ({
...initialState,
// Admin actions
@@ -84,6 +86,8 @@ export const useOnboardingStore = create()(
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()(
// Team actions
setTeams: (teams) => set({ teams }),
- // Reset action
reset: (state) => {
if (state) {
set(state);
@@ -116,57 +119,59 @@ export const useOnboardingStore = create()(
)
);
-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") {
diff --git a/packages/features/ee/organizations/lib/server/createOrganizationFromOnboarding.test.ts b/packages/features/ee/organizations/lib/server/createOrganizationFromOnboarding.test.ts
deleted file mode 100644
index 4b8d3fb6af..0000000000
--- a/packages/features/ee/organizations/lib/server/createOrganizationFromOnboarding.test.ts
+++ /dev/null
@@ -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 & { 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 & { 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;
-}) {
- 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).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();
- });
- });
-});
diff --git a/packages/features/ee/organizations/lib/server/createOrganizationFromOnboarding.ts b/packages/features/ee/organizations/lib/server/createOrganizationFromOnboarding.ts
deleted file mode 100644
index bb5573d041..0000000000
--- a/packages/features/ee/organizations/lib/server/createOrganizationFromOnboarding.ts
+++ /dev/null
@@ -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>;
-
-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>>;
- 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 };
-};
diff --git a/packages/features/ee/organizations/lib/server/orgCreationUtils.test.ts b/packages/features/ee/organizations/lib/server/orgCreationUtils.test.ts
new file mode 100644
index 0000000000..40c315d4b2
--- /dev/null
+++ b/packages/features/ee/organizations/lib/server/orgCreationUtils.test.ts
@@ -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);
+ });
+ });
+ });
+});
diff --git a/packages/features/ee/organizations/lib/server/orgCreationUtils.ts b/packages/features/ee/organizations/lib/server/orgCreationUtils.ts
index d96e6763fc..6bc078f440 100644
--- a/packages/features/ee/organizations/lib/server/orgCreationUtils.ts
+++ b/packages/features/ee/organizations/lib/server/orgCreationUtils.ts
@@ -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",
diff --git a/packages/features/ee/organizations/lib/service/onboarding/BaseOnboardingService.ts b/packages/features/ee/organizations/lib/service/onboarding/BaseOnboardingService.ts
new file mode 100644
index 0000000000..4390024ab7
--- /dev/null
+++ b/packages/features/ee/organizations/lib/service/onboarding/BaseOnboardingService.ts
@@ -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>;
+
+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;
+ 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>>;
+ 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();
+ createdTeams.forEach((team) => {
+ teamNameToId.set(team.name.toLowerCase(), team.id);
+ });
+
+ const teamIdMap = new Map();
+ 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();
+
+ 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,
+ });
+ }
+}
diff --git a/packages/features/ee/organizations/lib/service/onboarding/BillingEnabledOrgOnboardingService.ts b/packages/features/ee/organizations/lib/service/onboarding/BillingEnabledOrgOnboardingService.ts
new file mode 100644
index 0000000000..a8fc3bf6a1
--- /dev/null
+++ b/packages/features/ee/organizations/lib/service/onboarding/BillingEnabledOrgOnboardingService.ts
@@ -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 {
+ 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 };
+ }
+}
diff --git a/packages/features/ee/organizations/lib/service/onboarding/IOrganizationOnboardingService.ts b/packages/features/ee/organizations/lib/service/onboarding/IOrganizationOnboardingService.ts
new file mode 100644
index 0000000000..e66ed3c70a
--- /dev/null
+++ b/packages/features/ee/organizations/lib/service/onboarding/IOrganizationOnboardingService.ts
@@ -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;
+
+ /**
+ * 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 }>;
+}
diff --git a/packages/features/ee/organizations/lib/service/onboarding/OrganizationOnboardingFactory.ts b/packages/features/ee/organizations/lib/service/onboarding/OrganizationOnboardingFactory.ts
new file mode 100644
index 0000000000..1f2a95237a
--- /dev/null
+++ b/packages/features/ee/organizations/lib/service/onboarding/OrganizationOnboardingFactory.ts
@@ -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;
+ }
+}
diff --git a/packages/features/ee/organizations/lib/service/onboarding/OrganizationOnboardingSetup.md b/packages/features/ee/organizations/lib/service/onboarding/OrganizationOnboardingSetup.md
new file mode 100644
index 0000000000..9352144899
--- /dev/null
+++ b/packages/features/ee/organizations/lib/service/onboarding/OrganizationOnboardingSetup.md
@@ -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
+```
diff --git a/packages/features/ee/organizations/lib/service/onboarding/SelfHostedOnboardingService.ts b/packages/features/ee/organizations/lib/service/onboarding/SelfHostedOnboardingService.ts
new file mode 100644
index 0000000000..468f9471f3
--- /dev/null
+++ b/packages/features/ee/organizations/lib/service/onboarding/SelfHostedOnboardingService.ts
@@ -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 {
+ 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 };
+ }
+}
diff --git a/packages/features/ee/organizations/lib/service/onboarding/__tests__/BaseOnboardingService.test.ts b/packages/features/ee/organizations/lib/service/onboarding/__tests__/BaseOnboardingService.test.ts
new file mode 100644
index 0000000000..96d02fe588
--- /dev/null
+++ b/packages/features/ee/organizations/lib/service/onboarding/__tests__/BaseOnboardingService.test.ts
@@ -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 {
+ 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",
+ });
+ });
+ });
+});
diff --git a/packages/features/ee/organizations/lib/service/onboarding/__tests__/BillingEnabledOrgOnboardingService.test.ts b/packages/features/ee/organizations/lib/service/onboarding/__tests__/BillingEnabledOrgOnboardingService.test.ts
new file mode 100644
index 0000000000..493cdc9c0b
--- /dev/null
+++ b/packages/features/ee/organizations/lib/service/onboarding/__tests__/BillingEnabledOrgOnboardingService.test.ts
@@ -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) {
+ 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,
+ })
+ );
+ });
+ });
+});
diff --git a/packages/features/ee/organizations/lib/service/onboarding/__tests__/OrganizationOnboardingFactory.test.ts b/packages/features/ee/organizations/lib/service/onboarding/__tests__/OrganizationOnboardingFactory.test.ts
new file mode 100644
index 0000000000..2299ffe0f9
--- /dev/null
+++ b/packages/features/ee/organizations/lib/service/onboarding/__tests__/OrganizationOnboardingFactory.test.ts
@@ -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();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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");
+ }
+ });
+ });
+ });
+});
diff --git a/packages/features/ee/organizations/lib/service/onboarding/__tests__/SelfHostedOnboardingService.test.ts b/packages/features/ee/organizations/lib/service/onboarding/__tests__/SelfHostedOnboardingService.test.ts
new file mode 100644
index 0000000000..84bad8b010
--- /dev/null
+++ b/packages/features/ee/organizations/lib/service/onboarding/__tests__/SelfHostedOnboardingService.test.ts
@@ -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 },
+ ]);
+ });
+ });
+});
diff --git a/packages/features/ee/organizations/lib/service/onboarding/index.ts b/packages/features/ee/organizations/lib/service/onboarding/index.ts
new file mode 100644
index 0000000000..dc9f67ba9b
--- /dev/null
+++ b/packages/features/ee/organizations/lib/service/onboarding/index.ts
@@ -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";
diff --git a/packages/features/ee/organizations/lib/service/onboarding/types.ts b/packages/features/ee/organizations/lib/service/onboarding/types.ts
new file mode 100644
index 0000000000..32a68cc2d6
--- /dev/null
+++ b/packages/features/ee/organizations/lib/service/onboarding/types.ts
@@ -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;
+};
diff --git a/packages/features/ee/organizations/lib/utils.ts b/packages/features/ee/organizations/lib/utils.ts
index 72180e2852..5f2d778a43 100644
--- a/packages/features/ee/organizations/lib/utils.ts
+++ b/packages/features/ee/organizations/lib/utils.ts
@@ -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());
+}
diff --git a/packages/features/ee/organizations/types/schemas.ts b/packages/features/ee/organizations/types/schemas.ts
index 27c0155a20..2df5e8e1ee 100644
--- a/packages/features/ee/organizations/types/schemas.ts
+++ b/packages/features/ee/organizations/types/schemas.ts
@@ -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(),
diff --git a/packages/features/flags/config.ts b/packages/features/flags/config.ts
index 81baf16ca5..79582d7ac5 100644
--- a/packages/features/flags/config.ts
+++ b/packages/features/flags/config.ts
@@ -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;
};
diff --git a/packages/features/flags/hooks/index.ts b/packages/features/flags/hooks/index.ts
index b8c311a33f..05ce9e61ab 100644
--- a/packages/features/flags/hooks/index.ts
+++ b/packages/features/flags/hooks/index.ts
@@ -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,
};
diff --git a/packages/lib/server/repository/organizationOnboarding.ts b/packages/lib/server/repository/organizationOnboarding.ts
index 59a6598d79..d7998bae96 100644
--- a/packages/lib/server/repository/organizationOnboarding.ts
+++ b/packages/lib/server/repository/organizationOnboarding.ts
@@ -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 || [],
diff --git a/packages/prisma/migrations/20251005102651_add_onboarding_v3_feature_flag/migration.sql b/packages/prisma/migrations/20251005102651_add_onboarding_v3_feature_flag/migration.sql
new file mode 100644
index 0000000000..6aff33cc8f
--- /dev/null
+++ b/packages/prisma/migrations/20251005102651_add_onboarding_v3_feature_flag/migration.sql
@@ -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;
diff --git a/packages/prisma/migrations/20251007090722_modify_onboarding_table_orgs/migration.sql b/packages/prisma/migrations/20251007090722_modify_onboarding_table_orgs/migration.sql
new file mode 100644
index 0000000000..a48fbd08f9
--- /dev/null
+++ b/packages/prisma/migrations/20251007090722_modify_onboarding_table_orgs/migration.sql
@@ -0,0 +1,3 @@
+-- AlterTable
+ALTER TABLE "OrganizationOnboarding" ADD COLUMN "bannerUrl" TEXT,
+ADD COLUMN "brandColor" TEXT;
diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma
index 463d2ed3ec..0b2d04e7e5 100644
--- a/packages/prisma/schema.prisma
+++ b/packages/prisma/schema.prisma
@@ -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.
diff --git a/packages/prisma/zod-utils.ts b/packages/prisma/zod-utils.ts
index 8741f38f62..ad0ee178c9 100644
--- a/packages/prisma/zod-utils.ts
+++ b/packages/prisma/zod-utils.ts
@@ -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(
diff --git a/packages/trpc/server/routers/viewer/organizations/create.handler.ts b/packages/trpc/server/routers/viewer/organizations/create.handler.ts
index ff6e98fe15..5b0f09d929 100644
--- a/packages/trpc/server/routers/viewer/organizations/create.handler.ts
+++ b/packages/trpc/server/routers/viewer/organizations/create.handler.ts
@@ -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;
diff --git a/packages/trpc/server/routers/viewer/organizations/createSelfHosted.handler.ts b/packages/trpc/server/routers/viewer/organizations/createSelfHosted.handler.ts
index 5374315d9f..6a3b418be6 100644
--- a/packages/trpc/server/routers/viewer/organizations/createSelfHosted.handler.ts
+++ b/packages/trpc/server/routers/viewer/organizations/createSelfHosted.handler.ts
@@ -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);
diff --git a/packages/trpc/server/routers/viewer/organizations/createWithPaymentIntent.handler.ts b/packages/trpc/server/routers/viewer/organizations/createWithPaymentIntent.handler.ts
index 3bc6fff30d..03303d271a 100644
--- a/packages/trpc/server/routers/viewer/organizations/createWithPaymentIntent.handler.ts
+++ b/packages/trpc/server/routers/viewer/organizations/createWithPaymentIntent.handler.ts
@@ -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
);
diff --git a/packages/trpc/server/routers/viewer/organizations/createWithPaymentIntent.schema.ts b/packages/trpc/server/routers/viewer/organizations/createWithPaymentIntent.schema.ts
index b3c6747236..7ee2074d52 100644
--- a/packages/trpc/server/routers/viewer/organizations/createWithPaymentIntent.schema.ts
+++ b/packages/trpc/server/routers/viewer/organizations/createWithPaymentIntent.schema.ts
@@ -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;
diff --git a/packages/trpc/server/routers/viewer/organizations/intentToCreateOrg.handler.test.ts b/packages/trpc/server/routers/viewer/organizations/intentToCreateOrg.handler.test.ts
index e7753efa26..f1d5834c5a 100644
--- a/packages/trpc/server/routers/viewer/organizations/intentToCreateOrg.handler.test.ts
+++ b/packages/trpc/server/routers/viewer/organizations/intentToCreateOrg.handler.test.ts
@@ -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",
+ });
+ });
});
});
diff --git a/packages/trpc/server/routers/viewer/organizations/intentToCreateOrg.handler.ts b/packages/trpc/server/routers/viewer/organizations/intentToCreateOrg.handler.ts
index b61500d926..50ac1a837f 100644
--- a/packages/trpc/server/routers/viewer/organizations/intentToCreateOrg.handler.ts
+++ b/packages/trpc/server/routers/viewer/organizations/intentToCreateOrg.handler.ts
@@ -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;
diff --git a/packages/trpc/server/routers/viewer/organizations/intentToCreateOrg.schema.ts b/packages/trpc/server/routers/viewer/organizations/intentToCreateOrg.schema.ts
index 3c4a9fdd24..874c237920 100644
--- a/packages/trpc/server/routers/viewer/organizations/intentToCreateOrg.schema.ts
+++ b/packages/trpc/server/routers/viewer/organizations/intentToCreateOrg.schema.ts
@@ -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;