* 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>
384 lines
11 KiB
TypeScript
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,
|
|
};
|
|
}
|
|
}
|