feat: remove min seat usage from org creation (#25448)

* remove min seat usage

* update constants and also turbo json

* Remove comments

* remove redundant tests as no min exists
This commit is contained in:
sean-brydon
2025-12-01 07:55:44 +00:00
committed by GitHub
parent ce279da7c4
commit b64cfc9e66
13 changed files with 161 additions and 130 deletions
@@ -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", () => {
@@ -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({
@@ -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", () => {
@@ -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<SessionC
<div className="flex">
<TextField
containerClassName="w-full"
placeholder="30"
placeholder="1"
name="seats"
type="number"
label="Seats (optional)"
min={isAdmin ? 1 : MINIMUM_NUMBER_OF_ORG_SEATS}
defaultValue={value || MINIMUM_NUMBER_OF_ORG_SEATS}
min={1}
defaultValue={value || 1}
onChange={(e) => {
onChange(+e.target.value);
}}
@@ -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);
}
});
});
});
});
@@ -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),
};
}
@@ -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<boolean> {
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<boolean> {
@@ -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
```
```
+21 -22
View File
@@ -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";
+4 -18
View File
@@ -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 }],
});
-7
View File
@@ -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;
@@ -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<string, unknown> }): Promise<{ stripeCustomerId: string }>;
createCustomer(args: {
email: string;
metadata?: Record<string, unknown>;
}): 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,
-1
View File
@@ -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",