* 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>
322 lines
10 KiB
TypeScript
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,
|
|
}),
|
|
})
|
|
);
|
|
});
|
|
});
|
|
});
|
|
});
|