Files
calendar/packages/features/ee/organizations/lib/OrganizationPaymentService.ts
T
devin-ai-integration[bot]GitHubmorgan@cal.com <morgan@cal.com>Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>morgan@cal.com <morgan@cal.com>Morgan
e4c47640fc refactor: convert UserRepository to use dependency injection pattern (#22360)
* refactor: convert UserRepository to use dependency injection pattern

- Convert all static methods to public instance methods
- Add constructor that takes PrismaClient parameter
- Update all usage sites to use new instantiation pattern: new UserRepository(prisma).method()
- Follow same pattern as PrismaOOORepository for consistency
- Maintain all existing method logic and signatures unchanged
- Update 125+ files across the codebase to adapt to new pattern

Co-Authored-By: morgan@cal.com <morgan@cal.com>

* optimize: reuse UserRepository instances within same function scope

- Create single UserRepository instance per function scope
- Reuse instance for multiple method calls within same function
- Reduces object instantiation overhead and improves performance
- Apply optimization pattern consistently across codebase

Co-Authored-By: morgan@cal.com <morgan@cal.com>

* fix: repository

* fixup! fix: repository

* fixup! fixup! fix: repository

* fixup! fixup! fixup! fix: repository

* fix: update test mocking strategies for UserRepository dependency injection

- Convert static method mocks to instance method mocks in userCreationService.test.ts
- Update vi.spyOn calls to work with constructor injection pattern in getAllCredentials.test.ts
- Fix UserRepository mocking in getRoutedUrl.test.ts to use constructor injection
- Ensure consistent mocking approach across all test files
- Fix 'UserRepository is not a constructor' errors in tests

Co-Authored-By: morgan@cal.com <morgan@cal.com>

* feat: optimize UserRepository instance reuse and add SessionUser type

- Reuse UserRepository instance in OrganizationRepository.createWithNonExistentOwner
- Add comprehensive SessionUser type definition for type safety
- Improve type constraints in enrichUserWithTheProfile and enrichUserWithItsProfile
- Ensure proper return types with profile information

Co-Authored-By: morgan@cal.com <morgan@cal.com>

* fix: make UserRepository mocking strategy more robust for CI environments

- Add defensive checks for vi.mocked() to handle CI environment differences
- Ensure mockImplementation is available before calling it
- Maintain consistent mocking pattern across all test files
- Fix 'Cannot read properties of undefined' error in CI

Co-Authored-By: morgan@cal.com <morgan@cal.com>

* fixup! fix: make UserRepository mocking strategy more robust for CI environments

* refactor: convert direct UserRepository instantiations to two-step pattern

- Change await new UserRepository(prisma).method(...) to const userRepo = new UserRepository(prisma); await userRepo.method(...)
- Optimize instance reuse within same function scopes
- Apply pattern consistently across all modified files in PR
- Fix type errors in organization.ts and sessionMiddleware.ts

Co-Authored-By: morgan@cal.com <morgan@cal.com>

* refactor: complete two-step UserRepository pattern for remaining files

- Apply two-step instantiation pattern to all remaining modified files in PR
- Ensure consistent UserRepository usage across entire codebase
- Maintain instance reuse optimization within function scopes

Co-Authored-By: morgan@cal.com <morgan@cal.com>

* chore: bump platform libs

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: morgan@cal.com <morgan@cal.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2025-07-10 12:11:14 +00:00

384 lines
11 KiB
TypeScript

import { StripeBillingService } from "@calcom/features/ee/billing/stripe-billling-service";
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 { UserRepository } from "@calcom/lib/server/repository/user";
import { prisma } from "@calcom/prisma";
import type { OrganizationOnboarding } from "@calcom/prisma/client";
import type { BillingPeriod } from "@calcom/prisma/enums";
import { userMetadata } from "@calcom/prisma/zod-utils";
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
import { TRPCError } from "@trpc/server";
import { OrganizationPermissionService } from "./OrganizationPermissionService";
type OrganizationOnboardingId = string;
const log = logger.getSubLogger({ prefix: ["OrganizationPaymentService"] });
type CreatePaymentIntentInput = {
logo: string | null;
bio: string | null;
teams?: { id: number; isBeingMigrated: boolean; slug: string | null; name: string }[];
invitedMembers?: { email: string }[];
};
type CreateOnboardingInput = {
name: string;
slug: string;
orgOwnerEmail: string;
billingPeriod?: BillingPeriod;
seats?: number | null;
pricePerSeat?: number | null;
createdByUserId: number;
};
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: NonNullable<TrpcSessionUser>;
constructor(user: NonNullable<TrpcSessionUser>, 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,
});
}
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 } = 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 === "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");
await OrganizationOnboardingRepository.update(organizationOnboarding.id, {
bio: bio ?? null,
logo: logo ?? null,
invitedMembers: invitedMembers,
teams,
stripeCustomerId,
...updatedConfig,
});
return {
organizationOnboarding,
subscription,
checkoutUrl: subscription.checkoutUrl,
sessionId: subscription.sessionId,
};
}
}