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:
@@ -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> {
|
||||
|
||||
+16
-1
@@ -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
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 }],
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
+9
-6
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user