perf: move stripe to billing service out of app store (#20376)

* add more methods to billingService

* update profile handler

* stripe customer handler

* verify email

* move imports + move generic methods to billing service

* move to strip ebilling service class

* push changes to mocks

* fix enum

* fix typo

* correctly update customer metadata

* fix userId type to number

* fix types on billing services

* fix type errors due to return methods differing

* fix return types
This commit is contained in:
sean-brydon
2025-03-31 09:20:27 +00:00
committed by GitHub
parent f15d347804
commit 5cd2ba1362
17 changed files with 116 additions and 58 deletions
+1 -1
View File
@@ -1,8 +1,8 @@
import type { NextApiRequest } from "next";
import { getStripeCustomerIdFromUserId } from "@calcom/app-store/stripepayment/lib/customer";
import stripe from "@calcom/app-store/stripepayment/lib/server";
import { getDubCustomer } from "@calcom/features/auth/lib/dub";
import stripe from "@calcom/features/ee/payments/server/stripe";
import { IS_PRODUCTION } from "@calcom/lib/constants";
import { IS_TEAM_BILLING_ENABLED, WEBAPP_URL } from "@calcom/lib/constants";
import { HttpError } from "@calcom/lib/http-error";
@@ -8,7 +8,8 @@ import { DubReferralsPage } from "./DubReferralsPage";
export const metadata: Metadata = {
title: "Cal.com referral program - Earn money by sharing your link",
description: "Earn 20% recurring commissions for a full year by referring others to Cal.com, while giving your referrals 20% off for 12 months. Share your link and start earning today!",
description:
"Earn 20% recurring commissions for a full year by referring others to Cal.com, while giving your referrals 20% off for 12 months. Share your link and start earning today!",
};
// Export the appropriate component based on the feature flag
+4 -2
View File
@@ -1,8 +1,8 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { z } from "zod";
import stripe from "@calcom/app-store/stripepayment/lib/server";
import dayjs from "@calcom/dayjs";
import { StripeBillingService } from "@calcom/features/ee/billing/stripe-billling-service";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { IS_STRIPE_ENABLED } from "@calcom/lib/constants";
import { OrganizationRepository } from "@calcom/lib/server/repository/organization";
@@ -43,6 +43,7 @@ export async function moveUserToMatchingOrg({ email }: { email: string }) {
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const { token } = verifySchema.parse(req.query);
const billingService = new StripeBillingService();
const foundToken = await prisma.verificationToken.findFirst({
where: {
@@ -130,7 +131,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
});
if (IS_STRIPE_ENABLED && userMetadataParsed.stripeCustomerId) {
await stripe.customers.update(userMetadataParsed.stripeCustomerId, {
await billingService.updateCustomer({
customerId: userMetadataParsed.stripeCustomerId,
email: updatedEmail,
});
}
@@ -2,7 +2,7 @@ import { buffer } from "micro";
import type { NextApiRequest, NextApiResponse } from "next";
import type Stripe from "stripe";
import stripe from "@calcom/app-store/stripepayment/lib/server";
import stripe from "@calcom/features/ee/payments/server/stripe";
import { IS_PRODUCTION } from "@calcom/lib/constants";
import { getErrorFromUnknown } from "@calcom/lib/errors";
import { HttpError as HttpCode } from "@calcom/lib/http-error";
@@ -1,7 +1,8 @@
import stripe from "@calcom/app-store/stripepayment/lib/server";
import { StripeBillingService } from "@calcom/features/ee/billing/stripe-billling-service";
export async function getCustomerAndCheckoutSession(checkoutSessionId: string) {
const checkoutSession = await stripe.checkout.sessions.retrieve(checkoutSessionId);
const billingService = new StripeBillingService();
const checkoutSession = await billingService.getCheckoutSession(checkoutSessionId);
const customerOrCustomerId = checkoutSession.customer;
let customerId = null;
@@ -16,7 +17,7 @@ export async function getCustomerAndCheckoutSession(checkoutSessionId: string) {
} else {
customerId = customerOrCustomerId.id;
}
const stripeCustomer = await stripe.customers.retrieve(customerId);
const stripeCustomer = await billingService.getCustomer(customerId);
if (stripeCustomer.deleted) {
return { checkoutSession, stripeCustomer: null };
}
@@ -1,11 +1,11 @@
import type { NextApiResponse } from "next";
import stripe from "@calcom/app-store/stripepayment/lib/server";
import { getPremiumMonthlyPlanPriceId } from "@calcom/app-store/stripepayment/lib/utils";
import { hashPassword } from "@calcom/features/auth/lib/hashPassword";
import { sendEmailVerification } from "@calcom/features/auth/lib/verifyEmail";
import { createOrUpdateMemberships } from "@calcom/features/auth/signup/utils/createOrUpdateMemberships";
import { prefillAvatar } from "@calcom/features/auth/signup/utils/prefillAvatar";
import { StripeBillingService } from "@calcom/features/ee/billing/stripe-billling-service";
import { checkIfEmailIsBlockedInWatchlistController } from "@calcom/features/watchlist/operations/check-if-email-in-watchlist.controller";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { getLocaleFromRequest } from "@calcom/lib/getLocaleFromRequest";
@@ -40,6 +40,8 @@ async function handler(req: RequestWithUsernameStatus, res: NextApiResponse) {
})
.parse(req.body);
const billingService = new StripeBillingService();
const shouldLockByDefault = await checkIfEmailIsBlockedInWatchlistController(_email);
log.debug("handler", { email: _email });
@@ -96,7 +98,8 @@ async function handler(req: RequestWithUsernameStatus, res: NextApiResponse) {
}
// Create the customer in Stripe
const customer = await stripe.customers.create({
const customer = await billingService.createCustomer({
email,
metadata: {
email /* Stripe customer email can be changed, so we add this to keep track of which email was used to signup */,
@@ -108,22 +111,18 @@ async function handler(req: RequestWithUsernameStatus, res: NextApiResponse) {
// Pro username, must be purchased
if (req.usernameStatus.statusCode === 402) {
const checkoutSession = await stripe.checkout.sessions.create({
const checkoutSession = await billingService.createSubscriptionCheckout({
mode: "subscription",
customer: customer.id,
line_items: [
{
price: getPremiumMonthlyPlanPriceId(),
quantity: 1,
},
],
success_url: returnUrl,
cancel_url: returnUrl,
allow_promotion_codes: true,
customerId: customer.stripeCustomerId,
successUrl: returnUrl,
cancelUrl: returnUrl,
priceId: getPremiumMonthlyPlanPriceId(),
quantity: 1,
allowPromotionCodes: true,
});
/** We create a username-less user until he pays */
checkoutSessionId = checkoutSession.id;
checkoutSessionId = checkoutSession.sessionId;
username = null;
}
@@ -168,7 +167,7 @@ async function handler(req: RequestWithUsernameStatus, res: NextApiResponse) {
},
});
// Wrapping in a transaction as if one fails we want to rollback the whole thing to preventa any data inconsistencies
const { membership } = await createOrUpdateMemberships({
await createOrUpdateMemberships({
user,
team,
});
@@ -190,14 +189,14 @@ async function handler(req: RequestWithUsernameStatus, res: NextApiResponse) {
});
} else {
// Create the user
const user = await prisma.user.create({
await prisma.user.create({
data: {
username,
email,
locked: shouldLockByDefault,
password: { create: { hash: hashedPassword } },
metadata: {
stripeCustomerId: customer.id,
stripeCustomerId: customer.stripeCustomerId,
checkoutSessionId,
},
creationSource: CreationSource.WEBAPP,
@@ -221,7 +220,7 @@ async function handler(req: RequestWithUsernameStatus, res: NextApiResponse) {
});
}
return res.status(201).json({ message: "Created user", stripeCustomerId: customer.id });
return res.status(201).json({ message: "Created user", stripeCustomerId: customer.stripeCustomerId });
}
export default usernameHandler(handler);
@@ -3,7 +3,7 @@ import type { NextApiRequest } from "next";
import { createMocks } from "node-mocks-http";
import { describe, it, expect, vi, beforeEach } from "vitest";
import stripe from "@calcom/app-store/stripepayment/lib/server";
import stripe from "@calcom/features/ee/payments/server/stripe";
import { stripeWebhookHandler, HttpCode } from "./__handler";
@@ -11,7 +11,7 @@ vi.mock("micro", () => ({
buffer: vi.fn(),
}));
vi.mock("@calcom/app-store/stripepayment/lib/server", () => ({
vi.mock("@calcom/features/ee/payments/server/stripe", () => ({
default: {
webhooks: {
constructEvent: vi.fn(),
@@ -3,7 +3,7 @@ import { buffer } from "micro";
import type { NextApiRequest } from "next";
import type Stripe from "stripe";
import stripe from "@calcom/app-store/stripepayment/lib/server";
import stripe from "@calcom/features/ee/payments/server/stripe";
import { HttpError } from "@calcom/lib/http-error";
/** Stripe Webhook Handler Mappings */
@@ -31,6 +31,19 @@ export interface BillingService {
metadata?: Record<string, string | number>;
mode?: "subscription" | "setup" | "payment";
allowPromotionCodes?: boolean;
customerUpdate?: {
address?: "auto" | "never";
};
automaticTax?: {
enabled: boolean;
};
discounts?: Array<{
coupon: string;
}>;
subscriptionData?: {
metadata?: Record<string, string | number>;
trial_period_days?: number;
};
}): Promise<{ checkoutUrl: string | null; sessionId: string }>;
// Price management
@@ -42,5 +55,11 @@ export interface BillingService {
productId: string;
metadata?: Record<string, string | number>;
}): Promise<{ priceId: string }>;
getPrice(priceId: string): Promise<Stripe.Price | null>;
getSubscriptionStatus(subscriptionId: string): Promise<Stripe.Subscription.Status | null>;
getCheckoutSession(checkoutSessionId: string): Promise<Stripe.Checkout.Session | null>;
getCustomer(customerId: string): Promise<Stripe.Customer | Stripe.DeletedCustomer | null>;
getSubscriptions(customerId: string): Promise<Stripe.Subscription[] | null>;
updateCustomer(args: { customerId: string; email: string; userId?: number }): Promise<void>;
}
@@ -50,6 +50,10 @@ export class StripeBillingService implements BillingService {
metadata,
mode = "subscription",
allowPromotionCodes = true,
customerUpdate,
automaticTax,
discounts,
subscriptionData,
} = args;
const session = await this.stripe.checkout.sessions.create({
@@ -65,6 +69,10 @@ export class StripeBillingService implements BillingService {
},
],
allow_promotion_codes: allowPromotionCodes,
customer_update: customerUpdate,
automatic_tax: automaticTax,
discounts,
subscription_data: subscriptionData,
});
return {
@@ -122,4 +130,32 @@ export class StripeBillingService implements BillingService {
return subscription.status;
}
async getCheckoutSession(checkoutSessionId: string) {
const checkoutSession = await this.stripe.checkout.sessions.retrieve(checkoutSessionId);
return checkoutSession;
}
async getCustomer(customerId: string) {
const customer = await this.stripe.customers.retrieve(customerId);
return customer;
}
async getSubscriptions(customerId: string) {
const subscriptions = await this.stripe.subscriptions.list({ customer: customerId });
return subscriptions.data;
}
async updateCustomer(args: Parameters<BillingService["updateCustomer"]>[0]) {
const { customerId, email, userId } = args;
const metadata: { email?: string; userId?: number } = {};
if (email) metadata.email = email;
if (userId) metadata.userId = userId;
await this.stripe.customers.update(customerId, { metadata });
}
async getPrice(priceId: string) {
const price = await this.stripe.prices.retrieve(priceId);
return price;
}
}
+1 -1
View File
@@ -3,11 +3,11 @@ import { buffer } from "micro";
import type { NextApiRequest, NextApiResponse } from "next";
import type Stripe from "stripe";
import stripe from "@calcom/app-store/stripepayment/lib/server";
import { sendAttendeeRequestEmailAndSMS, sendOrganizerRequestEmail } from "@calcom/emails";
import { doesBookingRequireConfirmation } from "@calcom/features/bookings/lib/doesBookingRequireConfirmation";
import { getAllCredentials } from "@calcom/features/bookings/lib/getAllCredentialsForUsersOnEvent/getAllCredentials";
import { handleConfirmation } from "@calcom/features/bookings/lib/handleConfirmation";
import stripe from "@calcom/features/ee/payments/server/stripe";
import EventManager from "@calcom/lib/EventManager";
import { IS_PRODUCTION } from "@calcom/lib/constants";
import { getErrorFromUnknown } from "@calcom/lib/errors";
@@ -2,7 +2,8 @@ import prismock from "../../../../../tests/libs/__mocks__/prisma";
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import stripe from "@calcom/app-store/stripepayment/lib/server";
import stripe from "@calcom/features/ee/payments/server/stripe";
import { BillingPeriod } from "@calcom/prisma/zod-utils";
import {
getTeamWithPaymentMetadata,
@@ -41,7 +42,7 @@ vi.mock("@calcom/lib/constant", () => {
};
});
vi.mock("@calcom/app-store/stripepayment/lib/server", () => {
vi.mock("@calcom/features/ee/payments/server/stripe", () => {
return {
default: {
checkout: {
@@ -182,7 +183,7 @@ describe("purchaseTeamOrOrgSubscription", () => {
userId: user.id,
isOrg: true,
pricePerSeat: 100,
billingPeriod: "MONTHLY",
billingPeriod: BillingPeriod.MONTHLY,
})
).toEqual({ url: "SESSION_URL" });
@@ -253,7 +254,7 @@ describe("purchaseTeamOrOrgSubscription", () => {
userId: user.id,
isOrg: true,
pricePerSeat: 100,
billingPeriod: "ANNUALLY",
billingPeriod: BillingPeriod.ANNUALLY,
})
).toEqual({ url: "SESSION_URL" });
@@ -324,7 +325,8 @@ describe("purchaseTeamOrOrgSubscription", () => {
seatsToChargeFor,
userId: user.id,
isOrg: true,
billingPeriod: "ANNUALLY",
billingPeriod: BillingPeriod.ANNUALLY,
pricePerSeat: null,
})
).toEqual({ url: "SESSION_URL" });
+1 -1
View File
@@ -2,8 +2,8 @@ import type Stripe from "stripe";
import { z } from "zod";
import { getStripeCustomerIdFromUserId } from "@calcom/app-store/stripepayment/lib/customer";
import stripe from "@calcom/app-store/stripepayment/lib/server";
import { getDubCustomer } from "@calcom/features/auth/lib/dub";
import stripe from "@calcom/features/ee/payments/server/stripe";
import {
IS_PRODUCTION,
MINIMUM_NUMBER_OF_ORG_SEATS,
@@ -9,12 +9,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { WorkingHours } from "@calcom/types/schedule";
import cs from "@calcom/ui/classNames";
import { Button } from "@calcom/ui/components/button";
import {
DialogContent,
DialogHeader,
DialogTrigger,
DialogClose,
} from "@calcom/ui/components/dialog";
import { DialogContent, DialogHeader, DialogTrigger, DialogClose } from "@calcom/ui/components/dialog";
import { Switch } from "@calcom/ui/components/form";
import { Form } from "@calcom/ui/components/form";
import { showToast } from "@calcom/ui/components/toast";
+1 -1
View File
@@ -1,4 +1,4 @@
import stripe from "@calcom/app-store/stripepayment/lib/server";
import stripe from "@calcom/features/ee/payments/server/stripe";
import {
ZStripeCheckoutSessionInputSchema,
type TStripeCheckoutSessionInputSchema,
@@ -1,4 +1,4 @@
import stripe from "@calcom/app-store/stripepayment/lib/server";
import { StripeBillingService } from "@calcom/features/ee/billing/stripe-billling-service";
import { prisma } from "@calcom/prisma";
import { userMetadata } from "@calcom/prisma/zod-utils";
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
@@ -16,11 +16,14 @@ export const stripeCustomerHandler = async ({ ctx }: StripeCustomerOptions) => {
user: { id: userId },
} = ctx;
const billingService = new StripeBillingService();
const user = await prisma.user.findUnique({
where: {
id: userId,
},
select: {
email: true,
metadata: true,
},
});
@@ -33,7 +36,8 @@ export const stripeCustomerHandler = async ({ ctx }: StripeCustomerOptions) => {
let stripeCustomerId = metadata?.stripeCustomerId;
if (!stripeCustomerId) {
// Create stripe customer
const customer = await stripe.customers.create({
const customer = await billingService.createCustomer({
email: user.email,
metadata: {
userId: userId.toString(),
},
@@ -45,15 +49,15 @@ export const stripeCustomerHandler = async ({ ctx }: StripeCustomerOptions) => {
data: {
metadata: {
...metadata,
stripeCustomerId: customer.id,
stripeCustomerId: customer.stripeCustomerId,
},
},
});
stripeCustomerId = customer.id;
stripeCustomerId = customer.stripeCustomerId;
}
// Fetch stripe customer
const customer = await stripe.customers.retrieve(stripeCustomerId);
const customer = await billingService.getCustomer(stripeCustomerId);
if (customer.deleted) {
throw new TRPCError({ code: "BAD_REQUEST", message: "No stripe customer found" });
}
@@ -3,9 +3,9 @@ import { Prisma } from "@prisma/client";
import { keyBy } from "lodash";
import type { GetServerSidePropsContext, NextApiResponse } from "next";
import stripe from "@calcom/app-store/stripepayment/lib/server";
import { getPremiumMonthlyPlanPriceId } from "@calcom/app-store/stripepayment/lib/utils";
import { sendChangeOfEmailVerification } from "@calcom/features/auth/lib/verifyEmail";
import { StripeBillingService } from "@calcom/features/ee/billing/stripe-billling-service";
import { getFeatureFlag } from "@calcom/features/flags/server/utils";
import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata";
import { HttpError } from "@calcom/lib/http-error";
@@ -37,6 +37,7 @@ type UpdateProfileOptions = {
export const updateProfileHandler = async ({ ctx, input }: UpdateProfileOptions) => {
const { user } = ctx;
const billingService = new StripeBillingService();
const userMetadata = handleUserMetadata({ ctx, input });
const locale = input.locale || user.locale;
const emailVerification = await getFeatureFlag(prisma, "email-verification");
@@ -84,9 +85,9 @@ export const updateProfileHandler = async ({ ctx, input }: UpdateProfileOptions)
throw new TRPCError({ code: "BAD_REQUEST", message: "User is not premium" });
}
const stripeSubscriptions = await stripe.subscriptions.list({ customer: stripeCustomerId });
const stripeSubscriptions = await billingService.getSubscriptions(stripeCustomerId);
if (!stripeSubscriptions || !stripeSubscriptions.data.length) {
if (!stripeSubscriptions || !stripeSubscriptions.length) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "No stripeSubscription found",
@@ -95,7 +96,7 @@ export const updateProfileHandler = async ({ ctx, input }: UpdateProfileOptions)
// Iterate over subscriptions and look for premium product id and status active
// @TODO: iterate if stripeSubscriptions.hasMore is true
const isPremiumUsernameSubscriptionActive = stripeSubscriptions.data.some(
const isPremiumUsernameSubscriptionActive = stripeSubscriptions.some(
(subscription) =>
subscription.items.data[0].price.id === getPremiumMonthlyPlanPriceId() &&
subscription.status === "active"
@@ -284,12 +285,10 @@ export const updateProfileHandler = async ({ ctx, input }: UpdateProfileOptions)
// Notify stripe about the change
if (updatedUser && updatedUser.metadata && hasKeyInMetadata(updatedUser, "stripeCustomerId")) {
const stripeCustomerId = `${updatedUser.metadata.stripeCustomerId}`;
await stripe.customers.update(stripeCustomerId, {
metadata: {
username: updatedUser.username,
email: updatedUser.email,
userId: updatedUser.id,
},
await billingService.updateCustomer({
customerId: stripeCustomerId,
email: updatedUser.email,
userId: updatedUser.id,
});
}