Files
calendar/packages/features/ee/organizations/lib/OrganizationPaymentService.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

403 lines
12 KiB
TypeScript

import { StripeBillingService } from "@calcom/features/ee/billing/stripe-billing-service";
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import {
ORGANIZATION_SELF_SERVE_MIN_SEATS,
ORGANIZATION_SELF_SERVE_PRICE,
WEBAPP_URL,
} from "@calcom/lib/constants";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { OrganizationOnboardingRepository } from "@calcom/lib/server/repository/organizationOnboarding";
import { prisma } from "@calcom/prisma";
import type { OrganizationOnboarding } from "@calcom/prisma/client";
import { UserPermissionRole, type BillingPeriod } from "@calcom/prisma/enums";
import { userMetadata } from "@calcom/prisma/zod-utils";
import { TRPCError } from "@trpc/server";
import { OrganizationPermissionService } from "./OrganizationPermissionService";
import type { OnboardingUser } from "./service/onboarding/types";
type OrganizationOnboardingId = string;
const log = logger.getSubLogger({ prefix: ["OrganizationPaymentService"] });
type CreatePaymentIntentInput = {
logo: string | null;
bio: string | null;
brandColor?: string | null;
bannerUrl?: string | null;
teams?: { id: number; isBeingMigrated: boolean; slug: string | null; name: string }[];
invitedMembers?: { email: string; name?: string; teamId?: number; teamName?: string }[];
};
type CreateOnboardingInput = {
name: string;
slug: string;
orgOwnerEmail: string;
billingPeriod?: BillingPeriod;
seats?: number | null;
pricePerSeat?: number | null;
createdByUserId: number;
logo?: string | null;
bio?: string | null;
brandColor?: string | null;
bannerUrl?: string | null;
teams?: { id: number; isBeingMigrated: boolean; slug: string | null; name: string }[];
invitedMembers?: { email: string; name?: string; teamId?: number; teamName?: string; role?: string }[];
};
type PermissionCheckInput = {
orgOwnerEmail: string;
teams: { id: number; isBeingMigrated: boolean }[];
billingPeriod: BillingPeriod;
seats: number;
pricePerSeat: number;
slug: string;
};
type OrganizationOnboardingForPaymentIntent = Pick<
OrganizationOnboarding,
| "id"
| "pricePerSeat"
| "billingPeriod"
| "seats"
| "isComplete"
| "orgOwnerEmail"
| "slug"
| "stripeCustomerId"
>;
type PaymentConfig = {
billingPeriod: BillingPeriod;
seats: number;
pricePerSeat: number;
};
type StripePrice = {
priceId: string;
isCustom: boolean;
};
export class OrganizationPaymentService {
protected billingService: StripeBillingService;
protected permissionService: OrganizationPermissionService;
protected user: OnboardingUser;
constructor(user: OnboardingUser, permissionService?: OrganizationPermissionService) {
this.billingService = new StripeBillingService();
this.permissionService = permissionService || new OrganizationPermissionService(user);
this.user = user;
}
protected async getOrCreateStripeCustomerId(email: string) {
log.debug("getOrCreateStripeCustomerId", safeStringify({ email }));
const existingCustomer = await prisma.user.findUnique({
where: { email },
select: { id: true, metadata: true },
});
const parsedMetadata = existingCustomer?.metadata
? userMetadata.parse(existingCustomer.metadata)
: undefined;
if (parsedMetadata?.stripeCustomerId) {
return parsedMetadata.stripeCustomerId;
}
log.debug("Creating new Stripe customer", safeStringify({ email }));
const customer = await this.billingService.createCustomer({
email,
metadata: {
email,
},
});
const stripeCustomerId = customer.stripeCustomerId;
if (existingCustomer && parsedMetadata) {
await new UserRepository(prisma).updateStripeCustomerId({
id: existingCustomer.id,
stripeCustomerId,
existingMetadata: parsedMetadata,
});
}
log.debug("Created new Stripe customer", safeStringify({ email, stripeCustomerId }));
return stripeCustomerId;
}
protected normalizePaymentConfig(input: {
billingPeriod?: BillingPeriod;
seats?: number | null;
pricePerSeat?: number | null;
}): PaymentConfig {
return {
billingPeriod: input.billingPeriod || "MONTHLY",
seats: input.seats || Number(ORGANIZATION_SELF_SERVE_MIN_SEATS),
pricePerSeat: input.pricePerSeat || Number(ORGANIZATION_SELF_SERVE_PRICE),
};
}
protected async getUniqueTeamMembersCount({
teamIds,
invitedMembers,
}: {
teamIds: number[];
invitedMembers?: { email: string }[];
}) {
if (!teamIds.length) return 0;
const memberships = await prisma.membership.findMany({
where: {
teamId: {
in: teamIds,
},
},
select: {
userId: true,
user: {
select: {
email: true,
},
},
},
distinct: ["userId"],
});
const emailsSet = new Set(memberships.map((membership) => membership.user.email));
if (invitedMembers) {
invitedMembers.forEach((member) => {
emailsSet.add(member.email);
});
}
return emailsSet.size;
}
async createOrganizationOnboarding(input: CreateOnboardingInput) {
if (
this.permissionService.hasModifiedDefaultPayment(input) &&
!this.permissionService.hasPermissionToModifyDefaultPayment()
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You do not have permission to modify the default payment settings",
});
}
await this.permissionService.validatePermissions(input);
// We know admin permissions have been validated in the above step so we can safely normalize the input
const config = this.normalizePaymentConfig(input);
// Create new onboarding record if none exists
return await OrganizationOnboardingRepository.create({
name: input.name,
slug: input.slug,
orgOwnerEmail: input.orgOwnerEmail,
billingPeriod: config.billingPeriod,
seats: config.seats,
pricePerSeat: config.pricePerSeat,
createdById: input.createdByUserId,
logo: input.logo ?? null,
bio: input.bio ?? null,
brandColor: input.brandColor ?? null,
bannerUrl: input.bannerUrl ?? null,
teams: input.teams ?? [],
invitedMembers: input.invitedMembers ?? [],
});
}
protected async getOrCreatePrice(
config: PaymentConfig,
organizationOnboardingId: OrganizationOnboardingId,
shouldCreateCustomPrice: boolean
): Promise<StripePrice> {
log.debug(
"getOrCreatePrice",
safeStringify({
config,
organizationOnboardingId,
shouldCreateCustomPrice,
})
);
if (!process.env.STRIPE_ORG_PRODUCT_ID || !process.env.STRIPE_ORG_MONTHLY_PRICE_ID) {
throw new Error("STRIPE_ORG_PRODUCT_ID or STRIPE_ORG_MONTHLY_PRICE_ID is not set");
}
const fixedPriceId = process.env.STRIPE_ORG_MONTHLY_PRICE_ID;
if (!shouldCreateCustomPrice) {
return {
priceId: fixedPriceId,
isCustom: false,
};
}
const { interval, occurrence } = (() => {
if (config.billingPeriod === "MONTHLY") return { interval: "month" as const, occurrence: 1 };
if (config.billingPeriod === "ANNUALLY") return { interval: "year" as const, occurrence: 12 };
throw new Error(`Invalid billing period: ${config.billingPeriod}`);
})();
const customPrice = await this.billingService.createPrice({
amount: config.pricePerSeat * 100 * occurrence,
productId: process.env.STRIPE_ORG_PRODUCT_ID,
currency: "usd",
interval,
nickname: `Custom Organization Price - ${config.pricePerSeat} per seat`,
metadata: {
organizationOnboardingId,
pricePerSeat: config.pricePerSeat,
billingPeriod: config.billingPeriod,
createdAt: new Date().toISOString(),
},
});
return {
priceId: customPrice.priceId,
isCustom: true,
};
}
protected async createSubscription(
stripeCustomerId: string,
priceId: string,
config: PaymentConfig,
organizationOnboardingId: OrganizationOnboardingId,
params: URLSearchParams
) {
log.debug(
"Creating subscription",
safeStringify({
stripeCustomerId,
priceId,
config,
organizationOnboardingId,
})
);
return this.billingService.createSubscriptionCheckout({
customerId: stripeCustomerId,
successUrl: `${WEBAPP_URL}/settings/organizations/new/status?session_id={CHECKOUT_SESSION_ID}&paymentStatus=success&${params.toString()}`,
cancelUrl: `${WEBAPP_URL}/settings/organizations/new/status?session_id={CHECKOUT_SESSION_ID}&paymentStatus=failed&${params.toString()}`,
priceId,
quantity: config.seats,
metadata: {
organizationOnboardingId,
seats: config.seats,
pricePerSeat: config.pricePerSeat,
billingPeriod: config.billingPeriod,
},
});
}
protected async validatePermissions(input: PermissionCheckInput): Promise<boolean> {
return this.permissionService.validatePermissions(input);
}
async createPaymentIntent(
input: CreatePaymentIntentInput,
organizationOnboarding: OrganizationOnboardingForPaymentIntent
) {
log.debug("createPaymentIntent", safeStringify(input));
const { teams: _teams, invitedMembers, logo, bio, brandColor, bannerUrl } = input;
const teams = _teams?.filter((team) => team.id === -1 || team.isBeingMigrated) || [];
const teamIds = teams.filter((team) => team.id > 0).map((team) => team.id);
const { orgOwnerEmail, pricePerSeat, slug, billingPeriod, seats } = organizationOnboarding;
if (this.user.role === UserPermissionRole.ADMIN) {
log.debug("Admin flow, skipping checkout", safeStringify({ organizationOnboarding }));
return {
organizationOnboarding,
subscription: null,
checkoutUrl: null,
sessionId: null,
};
}
await this.validatePermissions({
orgOwnerEmail,
teams,
billingPeriod,
seats,
pricePerSeat,
slug,
});
const hasModifiedDefaultPayment = this.permissionService.hasModifiedDefaultPayment({
billingPeriod,
pricePerSeat,
seats,
});
const paymentConfigFromOnboarding = {
pricePerSeat,
billingPeriod,
seats,
};
// Get unique members count from existing teams
const uniqueMembersCount = await this.getUniqueTeamMembersCount({ teamIds, invitedMembers });
// Create new config with updated seats if necessary
const updatedConfig = {
...paymentConfigFromOnboarding,
seats: Math.max(paymentConfigFromOnboarding.seats, uniqueMembersCount),
};
log.debug(
"Creating subscription",
safeStringify({
paymentConfigFromOnboarding,
})
);
const { priceId } = await this.getOrCreatePrice(
updatedConfig,
organizationOnboarding.id,
// Whether modified default payment is allowed or not is checked as part of this.createPaymentIntent and onboarding entry is only created after that.
hasModifiedDefaultPayment
);
const stripeCustomerId = organizationOnboarding.stripeCustomerId
? organizationOnboarding.stripeCustomerId
: await this.getOrCreateStripeCustomerId(organizationOnboarding.orgOwnerEmail);
const subscription = await this.createSubscription(
stripeCustomerId,
priceId,
updatedConfig,
organizationOnboarding.id,
new URLSearchParams({
orgOwnerEmail: organizationOnboarding.orgOwnerEmail,
})
);
log.debug("Updating onboarding with stripe details and form data");
// Update the onboarding record with all the form data
await OrganizationOnboardingRepository.update(organizationOnboarding.id, {
stripeCustomerId,
seats: updatedConfig.seats,
pricePerSeat: updatedConfig.pricePerSeat,
billingPeriod: updatedConfig.billingPeriod,
logo,
bio,
brandColor: brandColor ?? null,
bannerUrl: bannerUrl ?? null,
teams,
invitedMembers,
});
return {
organizationOnboarding,
subscription,
checkoutUrl: subscription.checkoutUrl,
sessionId: subscription.sessionId,
};
}
}