Files
cal.diy/packages/features/ee/organizations/lib/OrganizationPaymentService.ts
T
Benny JooandDevin AI 3bf2a8fa6c refactor: replace TRPCError with ErrorWithCode in packages/features (#25482)
* refactor: replace TRPCError with ErrorWithCode in packages/features

This refactor moves error handling from throwing TRPCError directly in
packages/features to throwing ErrorWithCode instead. The conversion to
TRPCError now happens at the TRPC layer.

Changes:
- Add generic ErrorCode values (Unauthorized, Forbidden, NotFound,
  BadRequest, InternalServerError) to errorCodes.ts
- Update getServerErrorFromUnknown to map new ErrorCodes to proper
  HTTP status codes
- Create toTRPCError helper in packages/trpc/server/lib
- Create errorMappingMiddleware in packages/trpc/server/middlewares
- Migrate TRPCError throws in packages/features to ErrorWithCode:
  - teamService.ts
  - getEventTypeById.ts
  - eventTypeRepository.ts
  - OrganizationPermissionService.ts
  - OrganizationPaymentService.ts
  - sso.ts
  - handleCreatePhoneCall.ts
  - userCanCreateTeamGroupMapping.ts

This improves separation of concerns by making packages/features
transport-agnostic, allowing the same feature code to be reused from
tRPC, API routes, workers, etc.

Co-Authored-By: benny@cal.com <sldisek783@gmail.com>

* fix: remove isTrpcCall parameter and fix lint warning

- Remove isTrpcCall parameter from get.handler.ts call since the
  feature layer no longer needs to know about tRPC
- Fix unsafe optional chaining lint warning in getEventTypesByViewer.ts
  by precomputing usersSource variable
- Complete migration of getEventTypesByViewer.ts to ErrorWithCode

Co-Authored-By: benny@cal.com <sldisek783@gmail.com>

* revert

* add eslint rule

* add comment

* fix: add isTrpcCall back to getEventTypeById interface

The user reverted the removal of isTrpcCall parameter from the handler,
so we need to add it back to the interface to fix the type error.

Co-Authored-By: benny@cal.com <sldisek783@gmail.com>

* test: update teamService tests to expect ErrorWithCode instead of TRPCError

Co-Authored-By: benny@cal.com <sldisek783@gmail.com>

* refactor

* wip

* feat: integrate errorMappingMiddleware into base TRPC procedure

Co-Authored-By: benny@cal.com <sldisek783@gmail.com>

* connect middlewares

* revert

* revert

* refactor

* rename

* fix: handle ErrorWithCode in teams server-page error handling

The error handling was checking for TRPCError, but teamService now throws
ErrorWithCode. This caused the 'This invitation is not for your account'
error message to not be displayed when a wrong user tries to use an
invitation link.

Co-Authored-By: benny@cal.com <sldisek783@gmail.com>

* fix

* fix

* fix

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-12-02 08:11:17 -03:00

412 lines
12 KiB
TypeScript

import { getBillingProviderService } from "@calcom/features/ee/billing/di/containers/Billing";
import type { StripeBillingService } from "@calcom/features/ee/billing/service/billingProvider/StripeBillingService";
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import {
ORGANIZATION_SELF_SERVE_PRICE,
WEBAPP_URL,
ORG_TRIAL_DAYS,
} 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 { ErrorCode } from "@calcom/lib/errorCodes";
import { ErrorWithCode } from "@calcom/lib/errors";
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 = getBillingProviderService();
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 ?? 1,
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 ErrorWithCode(
ErrorCode.Unauthorized,
"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,
})
);
const subscriptionData = ORG_TRIAL_DAYS
? {
trial_period_days: ORG_TRIAL_DAYS,
}
: undefined;
return this.billingService.createSubscriptionCheckout({
customerId: stripeCustomerId,
successUrl: `${WEBAPP_URL}/api/organizations/payment-redirect?session_id={CHECKOUT_SESSION_ID}&paymentStatus=success&${params.toString()}`,
cancelUrl: `${WEBAPP_URL}/api/organizations/payment-redirect?session_id={CHECKOUT_SESSION_ID}&paymentStatus=failed&${params.toString()}`,
priceId,
quantity: config.seats,
metadata: {
organizationOnboardingId,
seats: config.seats,
pricePerSeat: config.pricePerSeat,
billingPeriod: config.billingPeriod,
},
...(subscriptionData && { subscriptionData }),
});
}
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,
};
}
}