diff --git a/packages/features/ee/billing/service/teams/TeamBillingService.test.ts b/packages/features/ee/billing/service/teams/TeamBillingService.test.ts index b450d052c4..59e54d6174 100644 --- a/packages/features/ee/billing/service/teams/TeamBillingService.test.ts +++ b/packages/features/ee/billing/service/teams/TeamBillingService.test.ts @@ -173,26 +173,6 @@ describe("TeamBillingService", () => { membershipCount: 10, }); }); - - it("should not update if membership count is less than minimum for organizations", async () => { - const teamBillingService = new TeamBillingService({ - team: mockTeam, - billingProviderService: mockBillingProviderService, - teamBillingDataRepository: mockTeamBillingDataRepository, - billingRepository: mockBillingRepository, - }); - - prismaMock.membership.count.mockResolvedValue(2); - vi.spyOn(teamBillingService, "checkIfTeamPaymentRequired").mockResolvedValue({ - url: "http://checkout.url", - paymentId: "cs_789", - paymentRequired: false, - }); - - await teamBillingService.updateQuantity(); - - expect(mockBillingProviderService.handleSubscriptionUpdate).not.toHaveBeenCalled(); - }); }); describe("checkIfTeamPaymentRequired", () => { diff --git a/packages/features/ee/billing/service/teams/TeamBillingService.ts b/packages/features/ee/billing/service/teams/TeamBillingService.ts index 80942bc033..143a9f5827 100644 --- a/packages/features/ee/billing/service/teams/TeamBillingService.ts +++ b/packages/features/ee/billing/service/teams/TeamBillingService.ts @@ -2,7 +2,7 @@ import type { z } from "zod"; import { getRequestedSlugError } from "@calcom/app-store/stripepayment/lib/team-billing"; import { purchaseTeamOrOrgSubscription } from "@calcom/features/ee/teams/lib/payments"; -import { MINIMUM_NUMBER_OF_ORG_SEATS, WEBAPP_URL } from "@calcom/lib/constants"; +import { WEBAPP_URL } from "@calcom/lib/constants"; import { getMetadataHelpers } from "@calcom/lib/getMetadataHelpers"; import logger from "@calcom/lib/logger"; import { Redirect } from "@calcom/lib/redirect"; @@ -161,17 +161,8 @@ export class TeamBillingService implements ITeamBillingService { if (!url && !isOrganization) return; // TODO: To be read from organizationOnboarding for Organizations later, but considering the fact that certain old organization won't have onboarding - const { subscriptionId, subscriptionItemId, orgSeats } = metadata; - // Either it would be custom pricing where minimum number of seats are changed(available in orgSeats) or it would be default MINIMUM_NUMBER_OF_ORG_SEATS - // We can't go below this quantity for subscription - const orgMinimumSubscriptionQuantity = orgSeats || MINIMUM_NUMBER_OF_ORG_SEATS; + const { subscriptionId, subscriptionItemId } = metadata; const membershipCount = await prisma.membership.count({ where: { teamId } }); - if (isOrganization && membershipCount < orgMinimumSubscriptionQuantity) { - log.info( - `Org ${teamId} has less members than the min required ${orgMinimumSubscriptionQuantity}, skipping updating subscription.` - ); - return; - } if (!subscriptionId) throw Error("missing subscriptionId"); if (!subscriptionItemId) throw Error("missing subscriptionItemId"); await this.billingProviderService.handleSubscriptionUpdate({ diff --git a/packages/features/ee/billing/teams/internal-team-billing.test.ts b/packages/features/ee/billing/teams/internal-team-billing.test.ts index 611095b364..28501f2502 100644 --- a/packages/features/ee/billing/teams/internal-team-billing.test.ts +++ b/packages/features/ee/billing/teams/internal-team-billing.test.ts @@ -8,8 +8,8 @@ import { WEBAPP_URL } from "@calcom/lib/constants"; import type { IBillingRepository } from "../repository/billing/IBillingRepository"; import type { ITeamBillingDataRepository } from "../repository/teamBillingData/ITeamBillingDataRepository"; import type { IBillingProviderService } from "../service/billingProvider/IBillingProviderService"; -import { TeamBillingService } from "../service/teams/TeamBillingService"; import { TeamBillingPublishResponseStatus } from "../service/teams/ITeamBillingService"; +import { TeamBillingService } from "../service/teams/TeamBillingService"; vi.mock("@calcom/lib/constants", async () => { const actual = await vi.importActual("@calcom/lib/constants"); @@ -41,7 +41,7 @@ describe("TeamBillingService", () => { beforeEach(() => { vi.clearAllMocks(); - + mockBillingProviderService = { handleSubscriptionCancel: vi.fn(), handleSubscriptionUpdate: vi.fn(), @@ -49,15 +49,15 @@ describe("TeamBillingService", () => { getSubscriptionStatus: vi.fn(), handleEndTrial: vi.fn(), } as IBillingProviderService; - + mockTeamBillingDataRepository = { find: vi.fn(), } as unknown as ITeamBillingDataRepository; - + mockBillingRepository = { create: vi.fn(), } as unknown as IBillingRepository; - + teamBillingService = new TeamBillingService({ team: mockTeam, billingProviderService: mockBillingProviderService, @@ -150,25 +150,6 @@ describe("TeamBillingService", () => { membershipCount: 10, }); }); - - it("should not update if membership count is less than minimum for organizations", async () => { - const teamBillingServiceOrg = new TeamBillingService({ - team: mockTeam, - billingProviderService: mockBillingProviderService, - teamBillingDataRepository: mockTeamBillingDataRepository, - billingRepository: mockBillingRepository, - }); - prismaMock.membership.count.mockResolvedValue(2); - vi.spyOn(teamBillingServiceOrg, "checkIfTeamPaymentRequired").mockResolvedValue({ - url: "http://checkout.url", - paymentId: "cs_789", - paymentRequired: false, - }); - - await teamBillingServiceOrg.updateQuantity(); - - expect(mockBillingProviderService.handleSubscriptionUpdate).not.toHaveBeenCalled(); - }); }); describe("checkIfTeamPaymentRequired", () => { diff --git a/packages/features/ee/organizations/components/CreateANewOrganizationForm.tsx b/packages/features/ee/organizations/components/CreateANewOrganizationForm.tsx index d7516ada8c..cf8a0b9237 100644 --- a/packages/features/ee/organizations/components/CreateANewOrganizationForm.tsx +++ b/packages/features/ee/organizations/components/CreateANewOrganizationForm.tsx @@ -7,7 +7,7 @@ import { useState } from "react"; import { Controller, useForm } from "react-hook-form"; import { subdomainSuffix } from "@calcom/features/ee/organizations/lib/orgDomains"; -import { MINIMUM_NUMBER_OF_ORG_SEATS, IS_SELF_HOSTED } from "@calcom/lib/constants"; +import { IS_SELF_HOSTED } from "@calcom/lib/constants"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import slugify from "@calcom/lib/slugify"; import { BillingPeriod, CreationSource, UserPermissionRole } from "@calcom/prisma/enums"; @@ -280,12 +280,12 @@ const CreateANewOrganizationFormChild = ({ session }: { session: Ensure { onChange(+e.target.value); }} diff --git a/packages/features/ee/organizations/lib/OrganizationPaymentService.test.ts b/packages/features/ee/organizations/lib/OrganizationPaymentService.test.ts index f93d809711..77e801c687 100644 --- a/packages/features/ee/organizations/lib/OrganizationPaymentService.test.ts +++ b/packages/features/ee/organizations/lib/OrganizationPaymentService.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; -import { ORGANIZATION_SELF_SERVE_MIN_SEATS } from "@calcom/lib/constants"; import { prisma } from "@calcom/prisma"; import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; @@ -115,8 +114,8 @@ describe("OrganizationPaymentService", () => { ).rejects.toThrow("You do not have permission to modify the default payment settings"); }); - it("should use minimum seats when total unique members is less than minimum", async () => { - // Mock 3 unique members (less than minimum of 5) + it("should use max of onboarding seats and unique member count (no minimum enforcement)", async () => { + // Mock 3 unique members - should use max of onboarding seats (5) and unique count (4) vi.mocked(prisma.membership.findMany).mockImplementation(async () => [ { userId: 1, user: { email: "user1@example.com" } }, { userId: 2, user: { email: "user2@example.com" } }, @@ -126,9 +125,9 @@ describe("OrganizationPaymentService", () => { const result = await service.createPaymentIntent( { ...baseInput, - invitedMembers: [{ email: "invited1@example.com" }], // Adding 1 invited member + invitedMembers: [{ email: "invited1@example.com" }], // Adding 1 invited member = 4 total unique }, - defaultOrgOnboarding + defaultOrgOnboarding // Has seats: 5 set ); expect(result).toBeDefined(); @@ -156,7 +155,7 @@ describe("OrganizationPaymentService", () => { stripeCustomerId: "mock_stripe_customer_id", pricePerSeat: defaultOrgOnboarding.pricePerSeat, billingPeriod: "MONTHLY", - seats: Number(ORGANIZATION_SELF_SERVE_MIN_SEATS), + seats: 5, // Max of onboarding seats (5) and unique members (4) = 5 }); }); @@ -219,7 +218,7 @@ describe("OrganizationPaymentService", () => { vi.mocked(prisma.membership.findMany).mockResolvedValue([]); }); - it("should allow admin to override minimum seats to a lower value", async () => { + it("should allow admin to set custom seat count", async () => { const customSeats = 3; await service.createPaymentIntent( { @@ -320,5 +319,94 @@ describe("OrganizationPaymentService", () => { ); }); }); + + describe("seat count flexibility", () => { + beforeEach(() => { + mockPermissionService.validatePermissions.mockResolvedValue(true); + mockPermissionService.hasModifiedDefaultPayment.mockReturnValue(false); + vi.mocked(prisma.membership.findMany).mockResolvedValue([]); + }); + + it("should allow creating organization with 1 seat", async () => { + vi.mocked(prisma.organizationOnboarding.create).mockResolvedValue({ + id: 1, + name: "Small Org", + slug: "small-org", + orgOwnerEmail: "owner@example.com", + billingPeriod: "MONTHLY", + seats: 1, + pricePerSeat: 20, + stripeCustomerId: null, + isComplete: false, + }); + + const result = await service.createOrganizationOnboarding({ + name: "Small Org", + slug: "small-org", + orgOwnerEmail: "owner@example.com", + createdByUserId: 1, + seats: 1, + }); + + expect(result).toBeDefined(); + const createCall = vi.mocked(prisma.organizationOnboarding.create).mock.calls[0][0]; + expect(createCall.data.seats).toBe(1); + }); + + it("should default to 1 seat when seats not provided", async () => { + vi.mocked(prisma.organizationOnboarding.create).mockResolvedValue({ + id: 1, + name: "Default Org", + slug: "default-org", + orgOwnerEmail: "owner@example.com", + billingPeriod: "MONTHLY", + seats: 1, + pricePerSeat: 20, + stripeCustomerId: null, + isComplete: false, + }); + + const result = await service.createOrganizationOnboarding({ + name: "Default Org", + slug: "default-org", + orgOwnerEmail: "owner@example.com", + createdByUserId: 1, + }); + + expect(result).toBeDefined(); + const createCall = vi.mocked(prisma.organizationOnboarding.create).mock.calls[0][0]; + expect(createCall.data.seats).toBe(1); + }); + + it("should allow creating organization with any seat count", async () => { + const seatCounts = [1, 2, 5, 10, 50, 100]; + + for (const seatCount of seatCounts) { + vi.clearAllMocks(); + vi.mocked(prisma.organizationOnboarding.create).mockResolvedValue({ + id: 1, + name: `Org ${seatCount}`, + slug: `org-${seatCount}`, + orgOwnerEmail: `owner${seatCount}@example.com`, + billingPeriod: "MONTHLY", + seats: seatCount, + pricePerSeat: 20, + stripeCustomerId: null, + isComplete: false, + }); + + await service.createOrganizationOnboarding({ + name: `Org ${seatCount}`, + slug: `org-${seatCount}`, + orgOwnerEmail: `owner${seatCount}@example.com`, + createdByUserId: 1, + seats: seatCount, + }); + + const createCall = vi.mocked(prisma.organizationOnboarding.create).mock.calls[0][0]; + expect(createCall.data.seats).toBe(seatCount); + } + }); + }); }); }); diff --git a/packages/features/ee/organizations/lib/OrganizationPaymentService.ts b/packages/features/ee/organizations/lib/OrganizationPaymentService.ts index ceb9e4725e..6c5b004e31 100644 --- a/packages/features/ee/organizations/lib/OrganizationPaymentService.ts +++ b/packages/features/ee/organizations/lib/OrganizationPaymentService.ts @@ -2,7 +2,6 @@ import { getBillingProviderService } from "@calcom/features/ee/billing/di/contai import type { StripeBillingService } from "@calcom/features/ee/billing/service/billingProvider/StripeBillingService"; import { UserRepository } from "@calcom/features/users/repositories/UserRepository"; import { - ORGANIZATION_SELF_SERVE_MIN_SEATS, ORGANIZATION_SELF_SERVE_PRICE, WEBAPP_URL, ORG_TRIAL_DAYS, @@ -134,7 +133,7 @@ export class OrganizationPaymentService { }): PaymentConfig { return { billingPeriod: input.billingPeriod || "MONTHLY", - seats: input.seats || Number(ORGANIZATION_SELF_SERVE_MIN_SEATS), + seats: input.seats ?? 1, pricePerSeat: input.pricePerSeat || Number(ORGANIZATION_SELF_SERVE_PRICE), }; } diff --git a/packages/features/ee/organizations/lib/OrganizationPermissionService.ts b/packages/features/ee/organizations/lib/OrganizationPermissionService.ts index e21824e201..2c1d45dd7e 100644 --- a/packages/features/ee/organizations/lib/OrganizationPermissionService.ts +++ b/packages/features/ee/organizations/lib/OrganizationPermissionService.ts @@ -1,6 +1,6 @@ import { getOrganizationRepository } from "@calcom/features/ee/organizations/di/OrganizationRepository.container"; import { UserPermissionRole } from "@calcom/kysely/types"; -import { ORGANIZATION_SELF_SERVE_MIN_SEATS, ORGANIZATION_SELF_SERVE_PRICE } from "@calcom/lib/constants"; +import { ORGANIZATION_SELF_SERVE_PRICE } from "@calcom/lib/constants"; import logger from "@calcom/lib/logger"; import { safeStringify } from "@calcom/lib/safeStringify"; import { prisma } from "@calcom/prisma"; @@ -39,7 +39,7 @@ export class OrganizationPermissionService { * If an onboarding is complete then it also means that org is created already. */ async hasConflictingOrganization({ slug }: { slug: string }): Promise { - const organizationRepository = getOrganizationRepository(); + const organizationRepository = getOrganizationRepository(); return !!(await organizationRepository.findBySlug({ slug })); } @@ -61,9 +61,6 @@ export class OrganizationPermissionService { const isBillingPeriodModified = data.billingPeriod !== undefined && data.billingPeriod !== null && data.billingPeriod !== "MONTHLY"; - const isSeatsModified = - data.seats !== undefined && data.seats !== null && data.seats !== ORGANIZATION_SELF_SERVE_MIN_SEATS; - const isPricePerSeatModified = data.pricePerSeat !== undefined && data.pricePerSeat !== null && @@ -71,9 +68,9 @@ export class OrganizationPermissionService { log.debug( "hasModifiedDefaultPayment", - safeStringify({ isBillingPeriodModified, isSeatsModified, isPricePerSeatModified }) + safeStringify({ isBillingPeriodModified, isPricePerSeatModified }) ); - return isBillingPeriodModified || isSeatsModified || isPricePerSeatModified; + return isBillingPeriodModified || isPricePerSeatModified; } async hasPermissionToMigrateTeams(teamIds: number[]): Promise { diff --git a/packages/features/ee/organizations/lib/service/onboarding/OrganizationOnboardingSetup.md b/packages/features/ee/organizations/lib/service/onboarding/OrganizationOnboardingSetup.md index 9352144899..bd7186989e 100644 --- a/packages/features/ee/organizations/lib/service/onboarding/OrganizationOnboardingSetup.md +++ b/packages/features/ee/organizations/lib/service/onboarding/OrganizationOnboardingSetup.md @@ -63,7 +63,6 @@ NEXT_PUBLIC_HOSTED_CAL_FEATURES=true NEXT_PUBLIC_IS_E2E=1 # Organization pricing -NEXT_PUBLIC_ORGANIZATIONS_MIN_SELF_SERVE_SEATS=5 NEXT_PUBLIC_ORGANIZATIONS_SELF_SERVE_PRICE_NEW=37 ``` @@ -74,13 +73,16 @@ 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))` @@ -97,6 +99,7 @@ The system relies on several computed constants from `@calcom/lib/constants`: ### Scenario 1: Hosted Cal.com (Official) **Environment:** + ```bash # Domain: *.cal.com, *.cal.dev, etc. STRIPE_CLIENT_ID=ca_xxx @@ -105,6 +108,7 @@ STRIPE_PRIVATE_KEY=sk_live_xxx ``` **Result:** + - `IS_SELF_HOSTED` = `false` - `HOSTED_CAL_FEATURES` = `true` - `IS_TEAM_BILLING_ENABLED` = `true` @@ -113,6 +117,7 @@ STRIPE_PRIVATE_KEY=sk_live_xxx ### Scenario 2: Self-Hosted with Billing (Optional) **Environment:** + ```bash # Domain: custom.example.com NEXT_PUBLIC_HOSTED_CAL_FEATURES=true @@ -122,6 +127,7 @@ STRIPE_PRIVATE_KEY=sk_live_xxx ``` **Result:** + - `IS_SELF_HOSTED` = `true` - `HOSTED_CAL_FEATURES` = `true` (forced) - `IS_TEAM_BILLING_ENABLED` = `true` @@ -130,12 +136,14 @@ STRIPE_PRIVATE_KEY=sk_live_xxx ### 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` @@ -147,12 +155,14 @@ STRIPE_PRIVATE_KEY=sk_live_xxx ### Scenario 4: E2E Testing **Environment:** + ```bash NEXT_PUBLIC_IS_E2E=1 # Any other configuration ``` **Result:** + - All users → **SelfHostedOrganizationOnboardingService** - Billing flow is completely bypassed @@ -161,10 +171,15 @@ NEXT_PUBLIC_IS_E2E=1 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/teams/lib/payments.test.ts b/packages/features/ee/teams/lib/payments.test.ts index 053d430a92..6250774312 100644 --- a/packages/features/ee/teams/lib/payments.test.ts +++ b/packages/features/ee/teams/lib/payments.test.ts @@ -36,12 +36,6 @@ vi.mock("@calcom/app-store/stripepayment/lib/customer", () => { }; }); -vi.mock("@calcom/lib/constant", () => { - return { - MINIMUM_NUMBER_OF_ORG_SEATS: 30, - }; -}); - vi.mock("@calcom/features/ee/payments/server/stripe", () => { return { default: { @@ -336,19 +330,19 @@ describe("purchaseTeamOrOrgSubscription", () => { describe("updateQuantitySubscriptionFromStripe", () => { describe("For an organization", () => { - it("should not update subscription when team members are less than metadata.orgSeats", async () => { + it("should update subscription based on actual member count (orgSeats no longer enforced)", async () => { const FAKE_PAYMENT_ID = "FAKE_PAYMENT_ID"; const FAKE_SUBITEM_ID = "FAKE_SUBITEM_ID"; const FAKE_SUB_ID = "FAKE_SUB_ID"; const FAKE_SUBSCRIPTION_QTY_IN_STRIPE = 1000; - const consoleInfoSpy = vi.spyOn(console, "info"); + const membersInTeam = 2; const organization = await createOrgWithMembersAndPaymentData({ paymentId: FAKE_PAYMENT_ID, subscriptionId: FAKE_SUB_ID, subscriptionItemId: FAKE_SUBITEM_ID, - membersInTeam: 2, - orgSeats: 5, + membersInTeam, + orgSeats: 5, // Even though orgSeats is 5, we should update to actual member count (2) }); mockStripeCheckoutSessionRetrieve( @@ -375,11 +369,14 @@ describe("updateQuantitySubscriptionFromStripe", () => { const mockedSubscriptionsUpdate = mockStripeSubscriptionsUpdate(null); await updateQuantitySubscriptionFromStripe(organization.id); - // Ensure that we reached the flow we are expecting to - expect(consoleInfoSpy.mock.calls[0][0]).toContain("has less members"); - // orgSeats is more than the current number of members - So, no update in stripe - expect(mockedSubscriptionsUpdate).not.toHaveBeenCalled(); + // Should update subscription to actual member count, regardless of orgSeats + expect(mockedSubscriptionsUpdate).toHaveBeenCalledWith( + FAKE_SUB_ID, + expect.objectContaining({ + items: [{ quantity: membersInTeam, id: FAKE_SUBITEM_ID }], + }) + ); }); it("should update subscription when team members are more than metadata.orgSeats", async () => { @@ -432,13 +429,12 @@ describe("updateQuantitySubscriptionFromStripe", () => { }); }); - it("should not update subscription when team members are less than MINIMUM_NUMBER_OF_ORG_SEATS(if metadata.orgSeats is null)", async () => { + it("should update subscription regardless of member count", async () => { const FAKE_PAYMENT_ID = "FAKE_PAYMENT_ID"; const FAKE_SUBITEM_ID = "FAKE_SUBITEM_ID"; const FAKE_SUB_ID = "FAKE_SUB_ID"; const FAKE_SUBSCRIPTION_QTY_IN_STRIPE = 1000; - const membersInTeam = 2; - const consoleInfoSpy = vi.spyOn(console, "info"); + const membersInTeam = 2; // Even with just 2 members, should update const organization = await createOrgWithMembersAndPaymentData({ paymentId: FAKE_PAYMENT_ID, @@ -472,13 +468,16 @@ describe("updateQuantitySubscriptionFromStripe", () => { const mockedSubscriptionsUpdate = mockStripeSubscriptionsUpdate(null); await updateQuantitySubscriptionFromStripe(organization.id); - // Ensure that we reached the flow we are expecting to - expect(consoleInfoSpy.mock.calls[0][0]).toContain("has less members"); - // orgSeats is more than the current number of members - So, no update in stripe - expect(mockedSubscriptionsUpdate).not.toHaveBeenCalled(); + // Should update subscription even with low member count (no minimum enforcement) + expect(mockedSubscriptionsUpdate).toHaveBeenCalledWith( + FAKE_SUB_ID, + expect.objectContaining({ + items: [{ quantity: membersInTeam, id: FAKE_SUBITEM_ID }], + }) + ); }); - it("should update subscription when team members are more than MINIMUM_NUMBER_OF_ORG_SEATS(if metadata.orgSeats is null)", async () => { + it("should update subscription when team members count changes (no minimum enforcement)", async () => { const FAKE_PAYMENT_ID = "FAKE_PAYMENT_ID"; const FAKE_SUB_ID = "FAKE_SUB_ID"; const FAKE_SUBITEM_ID = "FAKE_SUBITEM_ID"; diff --git a/packages/features/ee/teams/lib/payments.ts b/packages/features/ee/teams/lib/payments.ts index 834532ff98..eba01f7979 100644 --- a/packages/features/ee/teams/lib/payments.ts +++ b/packages/features/ee/teams/lib/payments.ts @@ -6,8 +6,6 @@ import { getDubCustomer } from "@calcom/features/auth/lib/dub"; import stripe from "@calcom/features/ee/payments/server/stripe"; import { IS_PRODUCTION, - MINIMUM_NUMBER_OF_ORG_SEATS, - ORGANIZATION_SELF_SERVE_MIN_SEATS, ORGANIZATION_SELF_SERVE_PRICE, WEBAPP_URL, ORG_TRIAL_DAYS, @@ -120,7 +118,6 @@ export const purchaseTeamOrOrgSubscription = async (input: { teamId: number; /** * The actual number of seats in the team. - * The seats that we would charge for could be more than this depending on the MINIMUM_NUMBER_OF_ORG_SEATS in case of an organization * For a team it would be the same as this value */ seatsUsed: number; @@ -147,8 +144,8 @@ export const purchaseTeamOrOrgSubscription = async (input: { const { url } = await checkIfTeamPaymentRequired({ teamId }); if (url) return { url }; - // For orgs, enforce minimum of MINIMUM_NUMBER_OF_ORG_SEATS seats if `seatsToChargeFor` not set - const seats = isOrg ? Math.max(seatsUsed, MINIMUM_NUMBER_OF_ORG_SEATS) : seatsUsed; + // Use seatsUsed directly without enforcing minimum + const seats = seatsUsed; const quantity = seatsToChargeFor ? seatsToChargeFor : seats; const customer = await getStripeCustomerIdFromUserId(userId); @@ -160,8 +157,7 @@ export const purchaseTeamOrOrgSubscription = async (input: { if (pricePerSeat) { if ( isOrg && - pricePerSeat === ORGANIZATION_SELF_SERVE_PRICE && - seats === ORGANIZATION_SELF_SERVE_MIN_SEATS + pricePerSeat === ORGANIZATION_SELF_SERVE_PRICE ) { priceId = fixedPrice as string; } else { @@ -301,10 +297,7 @@ export const updateQuantitySubscriptionFromStripe = async (teamId: number) => { **/ if (!url) return; const team = await getTeamWithPaymentMetadata(teamId); - const { subscriptionId, subscriptionItemId, orgSeats } = team.metadata; - // Either it would be custom pricing where minimum number of seats are changed(available in orgSeats) or it would be default MINIMUM_NUMBER_OF_ORG_SEATS - // We can't go below this quantity for subscription - const orgMinimumSubscriptionQuantity = orgSeats || MINIMUM_NUMBER_OF_ORG_SEATS; + const { subscriptionId, subscriptionItemId } = team.metadata; const membershipCount = team.members.length; const subscription = await stripe.subscriptions.retrieve(subscriptionId); const subscriptionQuantity = subscription.items.data.find( @@ -312,13 +305,6 @@ export const updateQuantitySubscriptionFromStripe = async (teamId: number) => { )?.quantity; if (!subscriptionQuantity) throw new Error("Subscription not found"); - if (team.isOrganization && membershipCount < orgMinimumSubscriptionQuantity) { - console.info( - `Org ${teamId} has less members than the min required ${orgMinimumSubscriptionQuantity}, skipping updating subscription.` - ); - return; - } - await stripe.subscriptions.update(subscriptionId, { items: [{ quantity: membershipCount, id: subscriptionItemId }], }); diff --git a/packages/lib/constants.ts b/packages/lib/constants.ts index 6cad864ce9..77c9623c18 100644 --- a/packages/lib/constants.ts +++ b/packages/lib/constants.ts @@ -128,16 +128,10 @@ export const ENABLE_PROFILE_SWITCHER = process.env.NEXT_PUBLIC_ENABLE_PROFILE_SW export const ALLOWED_HOSTNAMES = JSON.parse(`[${process.env.ALLOWED_HOSTNAMES || ""}]`) as string[]; export const RESERVED_SUBDOMAINS = JSON.parse(`[${process.env.RESERVED_SUBDOMAINS || ""}]`) as string[]; -export const ORGANIZATION_SELF_SERVE_MIN_SEATS = parseInt( - process.env.NEXT_PUBLIC_ORGANIZATIONS_MIN_SELF_SERVE_SEATS || "5" -); - export const ORGANIZATION_SELF_SERVE_PRICE = parseFloat( process.env.NEXT_PUBLIC_ORGANIZATIONS_SELF_SERVE_PRICE_NEW || "37" ); -export const ORGANIZATION_MIN_SEATS = 5; - // Needed for emails in E2E export const IS_MAILHOG_ENABLED = process.env.E2E_TEST_MAILHOG_ENABLED === "1"; export const CALCOM_VERSION = process.env.NEXT_PUBLIC_CALCOM_VERSION as string; @@ -176,7 +170,6 @@ export const BOOKER_NUMBER_OF_DAYS_TO_LOAD = parseInt( export const CLOUDFLARE_SITE_ID = process.env.NEXT_PUBLIC_CLOUDFLARE_SITEKEY; export const CLOUDFLARE_USE_TURNSTILE_IN_BOOKER = process.env.NEXT_PUBLIC_CLOUDFLARE_USE_TURNSTILE_IN_BOOKER; -export const MINIMUM_NUMBER_OF_ORG_SEATS = 30; export const ORG_SELF_SERVE_ENABLED = process.env.NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED === "1"; export const ORG_MINIMUM_PUBLISHED_TEAMS_SELF_SERVE = 0; export const ORG_MINIMUM_PUBLISHED_TEAMS_SELF_SERVE_HELPER_DIALOGUE = 1; diff --git a/packages/trpc/server/routers/viewer/organizations/createWithPaymentIntent.handler.test.ts b/packages/trpc/server/routers/viewer/organizations/createWithPaymentIntent.handler.test.ts index cf62481fdb..4f6bc5593f 100644 --- a/packages/trpc/server/routers/viewer/organizations/createWithPaymentIntent.handler.test.ts +++ b/packages/trpc/server/routers/viewer/organizations/createWithPaymentIntent.handler.test.ts @@ -3,7 +3,7 @@ import prismock from "../../../../../../tests/libs/__mocks__/prisma"; import { v4 as uuidv4 } from "uuid"; import { describe, expect, it, vi, beforeEach } from "vitest"; -import { ORGANIZATION_SELF_SERVE_MIN_SEATS, ORGANIZATION_SELF_SERVE_PRICE } from "@calcom/lib/constants"; +import { ORGANIZATION_SELF_SERVE_PRICE } from "@calcom/lib/constants"; import { MembershipRole } from "@calcom/prisma/enums"; import { UserPermissionRole } from "@calcom/prisma/enums"; @@ -14,7 +14,6 @@ import { createHandler } from "./createWithPaymentIntent.handler"; vi.stubEnv("STRIPE_PRIVATE_KEY", "test-stripe-private-key"); vi.stubEnv("STRIPE_ORG_MONTHLY_PRICE_ID", "test-stripe-org-monthly-price-id"); vi.stubEnv("STRIPE_ORG_PRODUCT_ID", "test-stripe-org-product-id"); -vi.stubEnv("NEXT_PUBLIC_ORGANIZATIONS_MIN_SELF_SERVE_SEATS", "30"); vi.stubEnv("NEXT_PUBLIC_ORGANIZATIONS_SELF_SERVE_PRICE_NEW", "37"); const STRIPE_ORG_PRODUCT_ID = process.env.STRIPE_ORG_PRODUCT_ID!; @@ -47,7 +46,10 @@ const mockSharedStripe = vi.hoisted(() => ({ vi.mock("@calcom/features/ee/billing/di/containers/Billing", () => { type FakeBillingProvider = { - createCustomer(args: { email: string; metadata?: Record }): Promise<{ stripeCustomerId: string }>; + createCustomer(args: { + email: string; + metadata?: Record; + }): Promise<{ stripeCustomerId: string }>; createPrice(args: { amount: number; productId: string; @@ -96,7 +98,8 @@ vi.mock("@calcom/features/ee/billing/di/containers/Billing", () => { }; return { - getBillingProviderService: () => fake as unknown as import("@calcom/features/ee/billing/service/billingProvider/StripeBillingService").StripeBillingService, + getBillingProviderService: () => + fake as unknown as import("@calcom/features/ee/billing/service/billingProvider/StripeBillingService").StripeBillingService, }; }); @@ -172,7 +175,7 @@ async function createOrganizationOnboarding(data: { orgOwnerEmail: data.orgOwnerEmail, createdById: data.createdById, stripeSubscriptionId: data.stripeSubscriptionId, - seats: parseInt(data.seats ?? ORGANIZATION_SELF_SERVE_MIN_SEATS), + seats: parseInt(data.seats ?? "1"), pricePerSeat: parseFloat(data.pricePerSeat ?? ORGANIZATION_SELF_SERVE_PRICE), billingPeriod: data.billingPeriod ?? "MONTHLY", }, @@ -517,7 +520,7 @@ describe("createWithPaymentIntent handler", () => { }); const pricePerSeat = ORGANIZATION_SELF_SERVE_PRICE; - const seats = ORGANIZATION_SELF_SERVE_MIN_SEATS; + const seats = 5; // Using a test value, no longer enforcing minimum await createOrganizationOnboarding({ id: mockInput.onboardingId, name: mockInput.name, diff --git a/turbo.json b/turbo.json index 3aa61e1360..fae9ccf3cb 100644 --- a/turbo.json +++ b/turbo.json @@ -129,7 +129,6 @@ "NEXT_PUBLIC_VERCEL_ENV", "NEXT_PUBLIC_VERCEL_BRANCH_URL", "NEXT_PUBLIC_GTM_ID", - "NEXT_PUBLIC_ORGANIZATIONS_MIN_SELF_SERVE_SEATS", "NEXT_PUBLIC_ORGANIZATIONS_SELF_SERVE_PRICE_NEW", "NEXT_PUBLIC_WEBSITE_PRIVACY_POLICY_URL", "NEXT_PUBLIC_WEBSITE_TERMS_URL",