Files
calendar/packages/features/ee/organizations/lib/OrganizationPaymentService.test.ts
T
sean-brydonGitHubcoderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>Hariom Balhara
fa35cc5210 chore: organization onboarding refactor (#24381)
* feat: redirect to new onboarding flow

* Getting started

* Brand details

* Preview organization brands

* Orgs team pages

* Invite team steps

* Move to global zustand store

* Few darkmdoe fixes

* Wip onboarding + stripe flow

* Default plan state

Server Action for gettting slug satus of org

* Remove onboardingId

* Confirmation prompt

* Update old onboarding flow handlers to handle new fields

* update onboarding hook

* Filter out organization section for none -company emails

* Match placeholders to users domain

* Drop migration

* Wip new onboarding intent

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

* WIP

* Add TODO

* Use onboarding user type instead of trpc session

* WIP

* WIP

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

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

* migrate roles to enum values

* Update ENUM

* Fix type error

* Redirect if flag is disabled

* Remove web

* WIP

* WIP

* Fix migration

* Fix calls

* User onboarding User types instead of trpc session

* Fix factory tests

* Fix flow for self hoste

* Type error

* More type fixes

* Fix handler tests

* Fix enum return type being different

* Use consistant types across the oganization stuff

* Fix

* Use TEAM_BILLING for e2e test

* Refactor is not company email and add tests

* Fix

* Fix

* Refactor flow to submit after form complete

* Fix flow with billing disabled

* Fix tests

* Apply suggestion from @coderabbitai[bot]

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

* Rename and move test files

* WIP

* Fix types

* Update repo paths + tests

* Move to service folder

* Fix tests

* Fix types

* Remove old test files

* Restore lock

* Fix path

* Fix tests with new paths and factory logic

* Fix updaetdAt

* WIP onboardingID isolation

* Fix e2e test

* verify test

* Code rabbit

* Rename SelfHostedOnboardongService -> SelfHostedOrganizationOnboardingService

* Fix stores

* Fix type error

* Fix types

* remove tsignore

* Apply suggestion from @coderabbitai[bot]

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

* NITS

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

* Fix store being weird

* We need to return the parsed value

* fixes

* sync from db always

* Add onboardingSgtore tests

* fix test

* remove step and status

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
2025-10-18 14:13:30 +00:00

322 lines
10 KiB
TypeScript

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";
import { OrganizationPaymentService } from "./OrganizationPaymentService";
import type { IOrganizationPermissionService } from "./OrganizationPermissionService";
vi.stubEnv("STRIPE_ORG_PRODUCT_ID", "STRIPE_ORG_PRODUCT_ID");
vi.stubEnv("STRIPE_ORG_MONTHLY_PRICE_ID", "STRIPE_ORG_MONTHLY_PRICE_ID");
const defaultOrgOnboarding = {
id: "onboard-id-1",
name: "Test Org",
slug: "test-org",
orgOwnerEmail: "test@example.com",
billingPeriod: "MONTHLY",
seats: 5,
pricePerSeat: 20,
isComplete: false,
stripeCustomerId: "mock_stripe_customer_id",
};
vi.mock("@calcom/prisma", () => {
const prismaMock = {
organizationOnboarding: {
findFirst: vi.fn(),
create: vi.fn(),
update: vi.fn(),
},
membership: {
findMany: vi.fn(),
},
user: {
findUnique: vi.fn(),
update: vi.fn(),
},
};
return {
prisma: prismaMock,
default: prismaMock,
};
});
vi.mock("@calcom/features/ee/billing/stripe-billing-service", () => ({
StripeBillingService: vi.fn().mockImplementation(() => ({
createCustomer: vi.fn().mockResolvedValue({ id: "mock_customer_id" }),
createPrice: vi.fn().mockResolvedValue({ id: "mock_price_id", isCustom: false }),
createSubscription: vi.fn().mockResolvedValue({ id: "mock_subscription_id" }),
createSubscriptionCheckout: vi.fn().mockResolvedValue({
checkoutUrl: "https://checkout.stripe.com/mock-checkout-url",
}),
})),
}));
describe("OrganizationPaymentService", () => {
let service: OrganizationPaymentService;
let mockPermissionService: IOrganizationPermissionService;
const mockUser: TrpcSessionUser = {
id: 1,
email: "test@example.com",
role: "USER",
};
beforeEach(() => {
vi.clearAllMocks();
mockPermissionService = {
hasPermissionToCreateForEmail: vi.fn().mockResolvedValue(true),
hasPendingOrganizations: vi.fn().mockResolvedValue(false),
hasPermissionToModifyDefaultPayment: vi.fn().mockReturnValue(false),
hasPermissionToMigrateTeams: vi.fn().mockResolvedValue(true),
hasModifiedDefaultPayment: vi.fn().mockReturnValue(false),
validatePermissions: vi.fn().mockResolvedValue(true),
};
service = new OrganizationPaymentService(mockUser, mockPermissionService);
vi.mocked(prisma.user.findUnique).mockResolvedValue({ metadata: {} } as any);
// Mock successful organization onboarding creation
vi.mocked(prisma.organizationOnboarding.create).mockResolvedValue({
id: 1,
name: "Test Org",
slug: "test-org",
orgOwnerEmail: "test@example.com",
billingPeriod: "MONTHLY",
seats: 5,
pricePerSeat: 20,
stripeCustomerId: "mock_customer_id",
isComplete: false,
} as any);
});
describe("createPaymentIntent", () => {
const baseInput = {
bio: "BIO",
logo: "LOGO",
teams: [{ id: 1, isBeingMigrated: true, name: "Team 1", slug: "team1" }],
};
it("should not allow non-admin to modify payment settings", async () => {
mockPermissionService.validatePermissions.mockRejectedValueOnce(
new Error("You do not have permission to modify the default payment settings")
);
await expect(
service.createPaymentIntent(
{
...baseInput,
pricePerSeat: 1000, // Trying to modify price
},
defaultOrgOnboarding
)
).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)
vi.mocked(prisma.membership.findMany).mockImplementation(async () => [
{ userId: 1, user: { email: "user1@example.com" } },
{ userId: 2, user: { email: "user2@example.com" } },
{ userId: 3, user: { email: "user3@example.com" } },
]);
const result = await service.createPaymentIntent(
{
...baseInput,
invitedMembers: [{ email: "invited1@example.com" }], // Adding 1 invited member
},
defaultOrgOnboarding
);
expect(result).toBeDefined();
const updateCall = vi.mocked(prisma.organizationOnboarding.update).mock.calls[0][0];
expect(updateCall.where).toEqual({ id: "onboard-id-1" });
const { updatedAt, ...data } = updateCall.data;
expect(data).toEqual({
bio: "BIO",
logo: "LOGO",
brandColor: null,
bannerUrl: null,
teams: [
{
id: 1,
isBeingMigrated: true,
name: "Team 1",
slug: "team1",
},
],
invitedMembers: [
{
email: "invited1@example.com",
},
],
stripeCustomerId: "mock_stripe_customer_id",
pricePerSeat: defaultOrgOnboarding.pricePerSeat,
billingPeriod: "MONTHLY",
seats: Number(ORGANIZATION_SELF_SERVE_MIN_SEATS),
});
});
it("should use total unique members when more than minimum", async () => {
// Mock 7 unique team members
vi.mocked(prisma.membership.findMany).mockImplementation(async () =>
Array.from({ length: 7 }, (_, i) => ({
userId: i + 1,
user: { email: `user${i + 1}@example.com` },
}))
);
const result = await service.createPaymentIntent(
{
...baseInput,
invitedMembers: [
{ email: "invited1@example.com" },
{ email: "invited2@example.com" },
{ email: "invited3@example.com" },
], // Adding 3 invited members, total should be 7 as these new invites are pending
},
defaultOrgOnboarding
);
expect(result).toBeDefined();
const updateCall = vi.mocked(prisma.organizationOnboarding.update).mock.calls[0][0];
expect(updateCall.where).toEqual({ id: "onboard-id-1" });
const { updatedAt, ...data } = updateCall.data;
expect(data).toEqual({
bio: "BIO",
logo: "LOGO",
brandColor: null,
bannerUrl: null,
teams: [
{
id: 1,
isBeingMigrated: true,
name: "Team 1",
slug: "team1",
},
],
invitedMembers: [
{ email: "invited1@example.com" },
{ email: "invited2@example.com" },
{ email: "invited3@example.com" },
],
stripeCustomerId: "mock_stripe_customer_id",
pricePerSeat: defaultOrgOnboarding.pricePerSeat,
billingPeriod: "MONTHLY",
seats: 10,
});
});
describe("admin overrides", () => {
beforeEach(() => {
mockPermissionService.hasPermissionToModifyDefaultPayment.mockReturnValue(true);
mockPermissionService.hasModifiedDefaultPayment.mockReturnValue(true);
mockPermissionService.validatePermissions.mockResolvedValue(true);
// Mock membership count to be 0 for admin override tests
vi.mocked(prisma.membership.findMany).mockResolvedValue([]);
});
it("should allow admin to override minimum seats to a lower value", async () => {
const customSeats = 3;
await service.createPaymentIntent(
{
...baseInput,
seats: customSeats,
},
{
...defaultOrgOnboarding,
seats: customSeats,
}
);
expect(vi.mocked(prisma.organizationOnboarding.update)).toHaveBeenCalled();
const updateCall = vi.mocked(prisma.organizationOnboarding.update).mock.calls[0][0];
expect(updateCall.where).toEqual({ id: "onboard-id-1" });
const { updatedAt, ...data } = updateCall.data;
expect(data).toEqual({
bio: "BIO",
logo: "LOGO",
brandColor: null,
bannerUrl: null,
invitedMembers: undefined,
teams: [
{
id: 1,
isBeingMigrated: true,
name: "Team 1",
slug: "team1",
},
],
stripeCustomerId: "mock_stripe_customer_id",
pricePerSeat: defaultOrgOnboarding.pricePerSeat,
billingPeriod: "MONTHLY",
seats: customSeats,
});
});
it("should allow admin to override price per seat", async () => {
const customPrice = 1000;
const result = await service.createPaymentIntent(baseInput, {
...defaultOrgOnboarding,
pricePerSeat: customPrice,
});
expect(result).toBeDefined();
const updateCall = vi.mocked(prisma.organizationOnboarding.update).mock.calls[0][0];
expect(updateCall.where).toEqual({ id: "onboard-id-1" });
const { updatedAt, ...data } = updateCall.data;
expect(data).toEqual({
bio: "BIO",
logo: "LOGO",
brandColor: null,
bannerUrl: null,
invitedMembers: undefined,
teams: [
{
id: 1,
isBeingMigrated: true,
name: "Team 1",
slug: "team1",
},
],
stripeCustomerId: "mock_stripe_customer_id",
pricePerSeat: customPrice,
billingPeriod: "MONTHLY",
seats: 5,
});
});
it("should create custom price in Stripe when admin overrides price", async () => {
const customPrice = 1500;
await service.createPaymentIntent(
{
...baseInput,
pricePerSeat: customPrice,
},
{
...defaultOrgOnboarding,
pricePerSeat: customPrice,
}
);
// Verify a custom price was created in Stripe
expect(vi.mocked(service["billingService"].createPrice)).toHaveBeenCalledWith(
expect.objectContaining({
amount: customPrice * 100, // Convert to cents for Stripe
currency: "usd",
interval: "month",
productId: "STRIPE_ORG_PRODUCT_ID",
nickname: expect.stringContaining("Custom Organization Price"),
metadata: expect.objectContaining({
billingPeriod: "MONTHLY",
organizationOnboardingId: "onboard-id-1",
pricePerSeat: customPrice,
}),
})
);
});
});
});
});