diff --git a/.env.example b/.env.example index 28e2805647..67ff5923d6 100644 --- a/.env.example +++ b/.env.example @@ -168,13 +168,13 @@ NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE= NEXT_PUBLIC_IS_PREMIUM_NEW_PLAN=0 NEXT_PUBLIC_STRIPE_PREMIUM_NEW_PLAN_PRICE= STRIPE_TEAM_MONTHLY_PRICE_ID= +STRIPE_TEAM_PRODUCT_ID= STRIPE_ORG_MONTHLY_PRICE_ID= STRIPE_WEBHOOK_SECRET= STRIPE_WEBHOOK_SECRET_APPS= STRIPE_PRIVATE_KEY= STRIPE_CLIENT_ID= - # Use for internal Public API Keys and optional API_KEY_PREFIX=cal_ # *********************************************************************************************************** diff --git a/apps/web/pages/api/cron/downgradeUsers.ts b/apps/web/pages/api/cron/downgradeUsers.ts index 5ea1329ecb..d4d4c15ee7 100644 --- a/apps/web/pages/api/cron/downgradeUsers.ts +++ b/apps/web/pages/api/cron/downgradeUsers.ts @@ -1,7 +1,7 @@ import type { NextApiRequest, NextApiResponse } from "next"; import { z } from "zod"; -import { updateQuantitySubscriptionFromStripe } from "@calcom/features/ee/teams/lib/payments"; +import { TeamBilling } from "@calcom/ee/billing/teams"; import prisma from "@calcom/prisma"; const querySchema = z.object({ @@ -32,6 +32,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) }, select: { id: true, + metadata: true, + isOrganization: true, + parentId: true, }, skip: pageNumber * pageSize, take: pageSize, @@ -41,10 +44,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) break; } - for (const team of teams) { - await updateQuantitySubscriptionFromStripe(team.id); - await delay(100); // Adjust the delay as needed to avoid rate limiting - } + const teamsBilling = TeamBilling.initMany(teams); + const teamBillingPromises = teamsBilling.map((teamBilling) => teamBilling.updateQuantity()); + await Promise.allSettled(teamBillingPromises); pageNumber++; } diff --git a/apps/web/pages/api/stripe/webhook.ts b/apps/web/pages/api/stripe/webhook.ts new file mode 100644 index 0000000000..b4456dadf4 --- /dev/null +++ b/apps/web/pages/api/stripe/webhook.ts @@ -0,0 +1,7 @@ +export const config = { + api: { + bodyParser: false, + }, +}; + +export { default } from "@calcom/features/ee/billing/api/webhook"; diff --git a/packages/features/ee/billing/api/webhook/__handler.test.ts b/packages/features/ee/billing/api/webhook/__handler.test.ts new file mode 100644 index 0000000000..598db684c5 --- /dev/null +++ b/packages/features/ee/billing/api/webhook/__handler.test.ts @@ -0,0 +1,92 @@ +import { buffer } from "micro"; +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 { stripeWebhookHandler, HttpCode } from "./__handler"; + +vi.mock("micro", () => ({ + buffer: vi.fn(), +})); + +vi.mock("@calcom/app-store/stripepayment/lib/server", () => ({ + default: { + webhooks: { + constructEvent: vi.fn(), + }, + }, +})); + +const STRIPE_WEBHOOK_SECRET = "whsec_test_secret"; + +describe("stripeWebhookHandler", () => { + beforeEach(() => { + vi.stubEnv("STRIPE_WEBHOOK_SECRET", STRIPE_WEBHOOK_SECRET); + }); + + const mockRequest = (headers: Record, body: string): NextApiRequest => + ({ + headers, + body, + } as unknown as NextApiRequest); + + it("should throw an error if stripe-signature header is missing", async () => { + const { req } = createMocks({ + method: "POST", + }); + + const handler = stripeWebhookHandler({}); + await expect(handler(req)).rejects.toThrow(new HttpCode(400, "Missing stripe-signature")); + }); + + it("should throw an error if STRIPE_WEBHOOK_SECRET is missing", async () => { + vi.stubEnv("STRIPE_WEBHOOK_SECRET", ""); + const { req } = createMocks({ + method: "POST", + headers: { + "stripe-signature": "test_signature", + }, + }); + const handler = stripeWebhookHandler({}); + await expect(handler(req)).rejects.toThrow(new HttpCode(500, "Missing STRIPE_WEBHOOK_SECRET")); + }); + + it("should throw an error if event type is unhandled", async () => { + const { req, res } = createMocks({ + method: "POST", + headers: { + "stripe-signature": "test_signature", + }, + }); + // const req = mockRequest({ "stripe-signature": "test_signature" }, "test_payload"); + (buffer as any).mockResolvedValueOnce(Buffer.from("test_payload")); + (stripe.webhooks.constructEvent as any).mockReturnValueOnce({ type: "unhandled_event" }); + + const handler = stripeWebhookHandler({}); + await expect(handler(req)).rejects.toThrow( + new HttpCode(202, "Unhandled Stripe Webhook event type unhandled_event") + ); + }); + + it("should call the appropriate handler for a valid event", async () => { + const req = mockRequest({ "stripe-signature": "test_signature" }, "test_payload"); + (buffer as any).mockResolvedValueOnce(Buffer.from("test_payload")); + (stripe.webhooks.constructEvent as any).mockReturnValueOnce({ + type: "payment_intent.succeeded", + data: "test_data", + }); + + const mockHandler = vi.fn().mockResolvedValueOnce("handler_response"); + const handlers = { + "payment_intent.succeeded": () => Promise.resolve({ default: mockHandler }), + }; + + const handler = stripeWebhookHandler(handlers); + const response = await handler(req); + + expect(mockHandler).toHaveBeenCalledWith("test_data"); + expect(response).toBe("handler_response"); + }); +}); diff --git a/packages/features/ee/billing/api/webhook/__handler.ts b/packages/features/ee/billing/api/webhook/__handler.ts new file mode 100644 index 0000000000..97a8718519 --- /dev/null +++ b/packages/features/ee/billing/api/webhook/__handler.ts @@ -0,0 +1,63 @@ +/// +import { buffer } from "micro"; +import type { NextApiRequest } from "next"; +import type Stripe from "stripe"; + +import stripe from "@calcom/app-store/stripepayment/lib/server"; +import { HttpError } from "@calcom/lib/http-error"; + +/** Stripe Webhook Handler Mappings */ +export type SWHMap = { + [T in Stripe.DiscriminatedEvent as T["type"]]: { + [K in keyof T as Exclude]: T[K]; + }; +}; + +export type LazyModule = Promise<{ + default: (data: D) => unknown | Promise; +}>; + +type SWHandlers = { + [K in keyof SWHMap]?: () => LazyModule; +}; + +/** Just a shorthand for HttpError */ +export class HttpCode extends HttpError { + constructor(statusCode: number, message: string) { + super({ statusCode, message }); + } +} + +/** + * Allows us to split big API handlers by webhook event, and lazy load them. + * It already handles the Stripe signature verification and event construction. + * @param handlers - A mapping of Stripe webhook event types to lazy loaded handlers. + * @returns A function that can be used as a Next.js API handler. + * @example + * ```ts + * stripeWebhookHandler({ + * "payment_intent.succeeded": () => import("./_lazyLoadedSuccessHandler"), + * "customer.subscription.deleted": () => import("./_customer.subscription.deleted"), + * }) + * ``` + */ +export const stripeWebhookHandler = (handlers: SWHandlers) => async (req: NextApiRequest) => { + const STRIPE_WEBHOOK_SECRET = process.env.STRIPE_WEBHOOK_SECRET; + const sig = req.headers["stripe-signature"]; + if (!sig) throw new HttpCode(400, "Missing stripe-signature"); + if (!STRIPE_WEBHOOK_SECRET) throw new HttpCode(500, "Missing STRIPE_WEBHOOK_SECRET"); + const requestBuffer = await buffer(req); + const payload = requestBuffer.toString(); + const event = stripe.webhooks.constructEvent( + payload, + sig, + STRIPE_WEBHOOK_SECRET + ) as Stripe.DiscriminatedEvent; + const handlerGetter = handlers[event.type]; + if (!handlerGetter) throw new HttpCode(202, `Unhandled Stripe Webhook event type ${event.type}`); + const handler = (await handlerGetter())?.default; + // auto catch unsupported Stripe events. + if (!handler) throw new HttpCode(202, `Unhandled Stripe Webhook event type ${event.type}`); + // @ts-expect-error - we know the handler is defined and accpets the data type + return await handler(event.data); +}; diff --git a/packages/features/ee/billing/api/webhook/_customer.subscription.deleted.team-plan.ts b/packages/features/ee/billing/api/webhook/_customer.subscription.deleted.team-plan.ts new file mode 100644 index 0000000000..4370e0706e --- /dev/null +++ b/packages/features/ee/billing/api/webhook/_customer.subscription.deleted.team-plan.ts @@ -0,0 +1,26 @@ +import { z } from "zod"; + +import { TeamBilling } from "../../teams"; +import type { SWHMap } from "./__handler"; + +const metadataSchema = z.object({ + teamId: z.coerce.number(), +}); + +const handler = async (data: SWHMap["customer.subscription.deleted"]["data"]) => { + const subscription = data.object; + try { + const { teamId } = metadataSchema.parse(subscription.metadata); + const teamBilling = await TeamBilling.findAndInit(teamId); + await teamBilling.downgrade(); + return { success: true }; + } catch (error) { + // If stripe metadata is missing teamId, we attempt to find by sub ID. + const team = await TeamBilling.repo.findBySubscriptionId(subscription.id); + const teamBilling = TeamBilling.init(team); + await teamBilling.downgrade(); + return { success: true }; + } +}; + +export default handler; diff --git a/packages/features/ee/billing/api/webhook/_customer.subscription.deleted.ts b/packages/features/ee/billing/api/webhook/_customer.subscription.deleted.ts new file mode 100644 index 0000000000..383c3a144f --- /dev/null +++ b/packages/features/ee/billing/api/webhook/_customer.subscription.deleted.ts @@ -0,0 +1,25 @@ +import { HttpCode } from "./__handler"; +import type { LazyModule, SWHMap } from "./__handler"; + +type Data = SWHMap["customer.subscription.deleted"]["data"]; + +type Handlers = Record<`prod_${string}`, () => LazyModule>; + +const STRIPE_TEAM_PRODUCT_ID = process.env.STRIPE_TEAM_PRODUCT_ID || ""; + +const stripeWebhookProductHandler = (handlers: Handlers) => async (data: Data) => { + const subscription = data.object; + // @ts-expect-error - we know subscription.plan.product is defined when unsubscribing + const productId = subscription.plan.product; // prod_xxxxx + + const handlerGetter = handlers[productId]; + if (!handlerGetter) throw new HttpCode(202, `No product handler found for product: ${productId}`); + const handler = (await handlerGetter())?.default; + // auto catch unsupported Stripe products. + if (!handler) throw new HttpCode(202, `No product handler found for product: ${productId}`); + return await handler(data); +}; + +export default stripeWebhookProductHandler({ + [STRIPE_TEAM_PRODUCT_ID]: () => import("./_customer.subscription.deleted.team-plan"), +}); diff --git a/packages/features/ee/billing/api/webhook/_payment_intent.succeeded.ts b/packages/features/ee/billing/api/webhook/_payment_intent.succeeded.ts new file mode 100644 index 0000000000..205a765da8 --- /dev/null +++ b/packages/features/ee/billing/api/webhook/_payment_intent.succeeded.ts @@ -0,0 +1,10 @@ +import type { SWHMap } from "./__handler"; + +// This is a placeholder to showcase adding new event handlers +const handler = async (data: SWHMap["payment_intent.succeeded"]["data"]) => { + const paymentIntent = data.object; + // TODO: Implement payment intent succeeded webhook handler + return { success: true }; +}; + +export default handler; diff --git a/packages/features/ee/billing/api/webhook/index.ts b/packages/features/ee/billing/api/webhook/index.ts new file mode 100644 index 0000000000..11652f2326 --- /dev/null +++ b/packages/features/ee/billing/api/webhook/index.ts @@ -0,0 +1,17 @@ +import { defaultHandler, defaultResponder } from "@calcom/lib/server"; + +import { stripeWebhookHandler } from "./__handler"; + +export default defaultHandler({ + // We only need to handle POST requests + POST: Promise.resolve({ + default: defaultResponder( + // We handle each Stripe webhook event type with it's own lazy loaded handler + stripeWebhookHandler({ + "payment_intent.succeeded": () => import("./_payment_intent.succeeded"), + // "customer.subscription.updated": () => import("./_customer.subscription.deleted"), + "customer.subscription.deleted": () => import("./_customer.subscription.deleted"), + }) + ), + }), +}); diff --git a/packages/features/ee/billing/billing-service-factory.ts b/packages/features/ee/billing/billing-service-factory.ts new file mode 100644 index 0000000000..16ae5bbee6 --- /dev/null +++ b/packages/features/ee/billing/billing-service-factory.ts @@ -0,0 +1,7 @@ +import { StripeBillingService } from "./stripe-billling-service"; + +export class BillingFactory { + constructor() { + return new StripeBillingService(); + } +} diff --git a/packages/features/ee/billing/billing-service.ts b/packages/features/ee/billing/billing-service.ts new file mode 100644 index 0000000000..dc6b29708e --- /dev/null +++ b/packages/features/ee/billing/billing-service.ts @@ -0,0 +1,10 @@ +export interface BillingService { + checkoutSessionIsPaid(paymentId: string): Promise; + handleSubscriptionCancel(subscriptionId: string): Promise; + handleSubscriptionCreation(subscriptionId: string): Promise; + handleSubscriptionUpdate(args: { + subscriptionId: string; + subscriptionItemId: string; + membershipCount: number; + }): Promise; +} diff --git a/packages/features/ee/billing/index.ts b/packages/features/ee/billing/index.ts new file mode 100644 index 0000000000..86cf66bfe0 --- /dev/null +++ b/packages/features/ee/billing/index.ts @@ -0,0 +1,14 @@ +import type { BillingService } from "./billing-service"; +import { BillingFactory } from "./billing-service-factory"; + +const globalForBilling = global as unknown as { + billingService: BillingService; +}; + +const billing = globalForBilling.billingService || new BillingFactory(); + +if (process.env.NODE_ENV !== "production") { + globalForBilling.billingService = billing; +} + +export default billing; diff --git a/packages/features/ee/billing/package.json b/packages/features/ee/billing/package.json new file mode 100644 index 0000000000..cfd8577471 --- /dev/null +++ b/packages/features/ee/billing/package.json @@ -0,0 +1,8 @@ +{ + "name": "@calcom/billing", + "description": "Internal billing system for Cal.com", + "authors": "zomars", + "version": "1.0.0", + "main": "./index.js", + "dependencies": {} +} diff --git a/packages/features/ee/billing/stripe-billling-service.test.ts b/packages/features/ee/billing/stripe-billling-service.test.ts new file mode 100644 index 0000000000..dd0384af24 --- /dev/null +++ b/packages/features/ee/billing/stripe-billling-service.test.ts @@ -0,0 +1,88 @@ +import Stripe from "stripe"; +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import { StripeBillingService } from "./stripe-billling-service"; + +vi.mock("stripe"); + +describe("StripeBillingService", () => { + let stripeBillingService: StripeBillingService; + let stripeMock: any; + + beforeEach(() => { + stripeMock = { + subscriptions: { + cancel: vi.fn(), + retrieve: vi.fn(), + update: vi.fn(), + }, + checkout: { + sessions: { + retrieve: vi.fn(), + }, + }, + }; + (Stripe as any).mockImplementation(() => stripeMock); + stripeBillingService = new StripeBillingService(); + }); + + it("should cancel a subscription", async () => { + const subscriptionId = "sub_123"; + await stripeBillingService.handleSubscriptionCancel(subscriptionId); + expect(stripeMock.subscriptions.cancel).toHaveBeenCalledWith(subscriptionId); + }); + + it("should update a subscription", async () => { + const args = { + subscriptionId: "sub_123", + subscriptionItemId: "item_123", + membershipCount: 5, + }; + stripeMock.subscriptions.retrieve.mockResolvedValue({ + items: { + data: [{ id: "item_123", quantity: 3 }], + }, + }); + await stripeBillingService.handleSubscriptionUpdate(args); + expect(stripeMock.subscriptions.retrieve).toHaveBeenCalledWith(args.subscriptionId); + expect(stripeMock.subscriptions.update).toHaveBeenCalledWith(args.subscriptionId, { + items: [{ quantity: args.membershipCount, id: args.subscriptionItemId }], + }); + }); + + it("should throw an error if subscription item is not found", async () => { + const args = { + subscriptionId: "sub_123", + subscriptionItemId: "item_123", + membershipCount: 5, + }; + stripeMock.subscriptions.retrieve.mockResolvedValue({ + items: { + data: [], + }, + }); + await expect(stripeBillingService.handleSubscriptionUpdate(args)).rejects.toThrow( + "Subscription not found" + ); + }); + + it("should return true if checkout session is paid", async () => { + const paymentId = "pay_123"; + stripeMock.checkout.sessions.retrieve.mockResolvedValue({ + payment_status: "paid", + }); + const result = await stripeBillingService.checkoutSessionIsPaid(paymentId); + expect(result).toBe(true); + expect(stripeMock.checkout.sessions.retrieve).toHaveBeenCalledWith(paymentId); + }); + + it("should return false if checkout session is not paid", async () => { + const paymentId = "pay_123"; + stripeMock.checkout.sessions.retrieve.mockResolvedValue({ + payment_status: "unpaid", + }); + const result = await stripeBillingService.checkoutSessionIsPaid(paymentId); + expect(result).toBe(false); + expect(stripeMock.checkout.sessions.retrieve).toHaveBeenCalledWith(paymentId); + }); +}); diff --git a/packages/features/ee/billing/stripe-billling-service.ts b/packages/features/ee/billing/stripe-billling-service.ts new file mode 100644 index 0000000000..495c490c13 --- /dev/null +++ b/packages/features/ee/billing/stripe-billling-service.ts @@ -0,0 +1,33 @@ +import Stripe from "stripe"; + +import type { BillingService } from "./billing-service"; + +export class StripeBillingService implements BillingService { + private stripe: Stripe; + constructor() { + this.stripe = new Stripe(process.env.STRIPE_PRIVATE_KEY!, { + apiVersion: "2020-08-27", + }); + } + async handleSubscriptionCreation(subscriptionId: string) { + throw new Error("Method not implemented."); + } + async handleSubscriptionCancel(subscriptionId: string) { + await this.stripe.subscriptions.cancel(subscriptionId); + } + async handleSubscriptionUpdate(args: Parameters[0]) { + const { subscriptionId, subscriptionItemId, membershipCount } = args; + const subscription = await this.stripe.subscriptions.retrieve(subscriptionId); + const subscriptionQuantity = subscription.items.data.find( + (sub) => sub.id === subscriptionItemId + )?.quantity; + if (!subscriptionQuantity) throw new Error("Subscription not found"); + await this.stripe.subscriptions.update(subscriptionId, { + items: [{ quantity: membershipCount, id: subscriptionItemId }], + }); + } + async checkoutSessionIsPaid(paymentId: string) { + const checkoutSession = await this.stripe.checkout.sessions.retrieve(paymentId); + return checkoutSession.payment_status === "paid"; + } +} diff --git a/packages/features/ee/billing/teams/index.ts b/packages/features/ee/billing/teams/index.ts new file mode 100644 index 0000000000..482174b203 --- /dev/null +++ b/packages/features/ee/billing/teams/index.ts @@ -0,0 +1,30 @@ +import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants"; + +import { InternalTeamBilling } from "./internal-team-billing"; +import { StubTeamBilling } from "./stub-team-billing"; +import type { TeamBilling as _TeamBilling, TeamBillingInput } from "./team-billing"; +import { TeamBillingRepository } from "./team-billing.repository"; + +export class TeamBilling { + static repo = new TeamBillingRepository(); + + /** Initialize a single team billing */ + static init(team: TeamBillingInput): _TeamBilling { + if (IS_TEAM_BILLING_ENABLED) return new InternalTeamBilling(team); + return new StubTeamBilling(team); + } + /** Initialize multuple team billings at once for bulk operations */ + static initMany(teams: TeamBillingInput[]) { + return teams.map((team) => TeamBilling.init(team)); + } + /** Fetch and initialize multiple team billings in one go */ + static async findAndInit(teamId: number) { + const team = await TeamBilling.repo.find(teamId); + return TeamBilling.init(team); + } + /** Fetch and initialize multiple team billings in one go */ + static async findAndInitMany(teamIds: number[]) { + const teams = await TeamBilling.repo.findMany(teamIds); + return TeamBilling.initMany(teams); + } +} diff --git a/packages/features/ee/billing/teams/internal-team-billing.test.ts b/packages/features/ee/billing/teams/internal-team-billing.test.ts new file mode 100644 index 0000000000..4fbe172413 --- /dev/null +++ b/packages/features/ee/billing/teams/internal-team-billing.test.ts @@ -0,0 +1,176 @@ +import prismaMock from "../../../../../tests/libs/__mocks__/prismaMock"; + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +import { purchaseTeamOrOrgSubscription } from "@calcom/features/ee/teams/lib/payments"; +import { WEBAPP_URL } from "@calcom/lib/constants"; + +import * as billingModule from ".."; +import { InternalTeamBilling } from "./internal-team-billing"; +import { TeamBillingPublishResponseStatus } from "./team-billing"; + +vi.mock("@calcom/lib/constants", async () => { + const actual = await vi.importActual("@calcom/lib/constants"); + return { + ...actual, + WEBAPP_URL: "http://localhost:3000", + }; +}); + +vi.mock("..", () => ({ + default: { + handleSubscriptionCancel: vi.fn(), + handleSubscriptionUpdate: vi.fn(), + checkoutSessionIsPaid: vi.fn(), + }, +})); + +vi.mock("@calcom/features/ee/teams/lib/payments", () => ({ + purchaseTeamOrOrgSubscription: vi.fn(), +})); +const mockTeam = { + id: 1, + metadata: { + subscriptionId: "sub_123", + subscriptionItemId: "si_456", + paymentId: "cs_789", + }, + isOrganization: true, + parentId: null, +}; + +describe("InternalTeamBilling", () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("cancel", () => { + const internalTeamBilling = new InternalTeamBilling(mockTeam); + it("should cancel the subscription and downgrade the team", async () => { + await internalTeamBilling.cancel(); + + expect(billingModule.default.handleSubscriptionCancel).toHaveBeenCalledWith("sub_123"); + expect(prismaMock.team.update).toHaveBeenCalledWith({ + where: { id: 1 }, + data: { + metadata: {}, + }, + }); + }); + }); + + describe("publish", () => { + const internalTeamBilling = new InternalTeamBilling(mockTeam); + it("should create a checkout session and update the team", async () => { + vi.spyOn(billingModule.default, "checkoutSessionIsPaid").mockResolvedValue(false); + vi.mocked(purchaseTeamOrOrgSubscription).mockResolvedValue({ + url: "http://checkout.url", + }); + prismaMock.membership.count.mockResolvedValue(5); + prismaMock.membership.findFirstOrThrow.mockResolvedValue({ userId: 123 }); + + const result = await internalTeamBilling.publish(); + expect(result).toEqual({ + redirectUrl: "http://checkout.url", + status: TeamBillingPublishResponseStatus.REQUIRES_PAYMENT, + }); + + expect(prismaMock.membership.count).toHaveBeenCalledWith({ where: { teamId: 1 } }); + expect(prismaMock.membership.findFirstOrThrow).toHaveBeenCalledWith({ + where: { teamId: 1, role: "OWNER" }, + select: { userId: true }, + }); + }); + it("should return upgrade url if upgrade is required", async () => { + const internalTeamBilling = new InternalTeamBilling(mockTeam); + const mockUrl = `${WEBAPP_URL}/api/teams/${mockTeam.id}/upgrade?session_id=cs_789`; + vi.spyOn(internalTeamBilling, "checkIfTeamPaymentRequired").mockResolvedValue({ + url: mockUrl, + paymentId: "cs_789", + paymentRequired: false, + }); + + const result = await internalTeamBilling.publish(); + + expect(result).toEqual({ + redirectUrl: mockUrl, + status: TeamBillingPublishResponseStatus.REQUIRES_UPGRADE, + }); + expect(internalTeamBilling.checkIfTeamPaymentRequired).toHaveBeenCalled(); + }); + }); + + describe("updateQuantity", () => { + it("should update the subscription quantity", async () => { + const mockTeamNotOrg = { + ...mockTeam, + isOrganization: false, + }; + const internalTeamBilling = new InternalTeamBilling(mockTeamNotOrg); + prismaMock.membership.count.mockResolvedValue(10); + vi.spyOn(internalTeamBilling, "checkIfTeamPaymentRequired").mockResolvedValue({ + url: "http://checkout.url", + paymentId: "cs_789", + paymentRequired: false, + }); + + await internalTeamBilling.updateQuantity(); + + expect(billingModule.default.handleSubscriptionUpdate).toHaveBeenCalledWith({ + subscriptionId: "sub_123", + subscriptionItemId: "si_456", + membershipCount: 10, + }); + }); + + it("should not update if membership count is less than minimum for organizations", async () => { + const internalTeamBilling = new InternalTeamBilling(mockTeam); + prismaMock.membership.count.mockResolvedValue(2); + vi.spyOn(internalTeamBilling, "checkIfTeamPaymentRequired").mockResolvedValue({ + url: "http://checkout.url", + paymentId: "cs_789", + paymentRequired: false, + }); + + await internalTeamBilling.updateQuantity(); + + expect(billingModule.default.handleSubscriptionUpdate).not.toHaveBeenCalled(); + }); + }); + + describe("checkIfTeamPaymentRequired", () => { + const internalTeamBilling = new InternalTeamBilling(mockTeam); + it("should return payment required if no paymentId", async () => { + internalTeamBilling.team.metadata.paymentId = undefined; + + const result = await internalTeamBilling.checkIfTeamPaymentRequired(); + + expect(result).toEqual({ url: null, paymentId: null, paymentRequired: true }); + }); + + it("should return payment required if checkout session is not paid", async () => { + vi.spyOn(billingModule.default, "checkoutSessionIsPaid").mockResolvedValue(false); + const internalTeamBilling = new InternalTeamBilling(mockTeam); + + const result = await internalTeamBilling.checkIfTeamPaymentRequired(); + + expect(result).toEqual({ url: null, paymentId: "cs_789", paymentRequired: true }); + }); + + it("should return upgrade URL if checkout session is paid", async () => { + vi.spyOn(billingModule.default, "checkoutSessionIsPaid").mockResolvedValue(true); + const internalTeamBilling = new InternalTeamBilling(mockTeam); + const result = await internalTeamBilling.checkIfTeamPaymentRequired(); + + expect(result).toEqual({ + url: `${WEBAPP_URL}/api/teams/1/upgrade?session_id=cs_789`, + paymentId: "cs_789", + paymentRequired: false, + }); + }); + }); +}); diff --git a/packages/features/ee/billing/teams/internal-team-billing.ts b/packages/features/ee/billing/teams/internal-team-billing.ts new file mode 100644 index 0000000000..790d847461 --- /dev/null +++ b/packages/features/ee/billing/teams/internal-team-billing.ts @@ -0,0 +1,164 @@ +import type { Prisma } from "@prisma/client"; +import type { z } from "zod"; + +import { getRequestedSlugError } from "@calcom/app-store/stripepayment/lib/team-billing"; +import { purchaseTeamOrOrgSubscription } from "@calcom/features/ee/teams/lib/payments"; +import { MINIMUM_NUMBER_OF_ORG_SEATS, WEBAPP_URL } from "@calcom/lib/constants"; +import { getMetadataHelpers } from "@calcom/lib/getMetadataHelpers"; +import logger from "@calcom/lib/logger"; +import { Redirect } from "@calcom/lib/redirect"; +import prisma from "@calcom/prisma"; +import { teamMetadataSchema } from "@calcom/prisma/zod-utils"; + +import billing from ".."; +import { TeamBillingPublishResponseStatus, type TeamBilling, type TeamBillingInput } from "./team-billing"; + +const log = logger.getSubLogger({ prefix: ["TeamBilling"] }); + +const teamPaymentMetadataSchema = teamMetadataSchema.unwrap(); + +export class InternalTeamBilling implements TeamBilling { + private _team!: Omit & { + metadata: NonNullable>; + }; + constructor(team: TeamBillingInput) { + this.team = team; + } + set team(team: TeamBillingInput) { + const metadata = teamPaymentMetadataSchema.parse(team.metadata || {}); + this._team = { ...team, metadata }; + } + get team(): typeof this._team { + return this._team; + } + private async getOrgIfNeeded() { + if (!this.team.parentId) return; + const parentTeam = await prisma.team.findUniqueOrThrow({ + where: { id: this.team.parentId }, + select: { metadata: true, id: true, parentId: true, isOrganization: true }, + }); + this.team = parentTeam; + } + private logErrorFromUnknown(error: unknown) { + let message = "Unknown error on InternalTeamBilling."; + if (error instanceof Error) message = error.message; + log.error(message); + } + async cancel() { + try { + const { subscriptionId } = this.team.metadata; + log.info(`Cancelling subscription ${subscriptionId} for team ${this.team.id}`); + if (!subscriptionId) throw Error("missing subscriptionId"); + await billing.handleSubscriptionCancel(subscriptionId); + await this.downgrade(); + log.info(`Cancelled subscription ${subscriptionId} for team ${this.team.id}`); + } catch (error) { + this.logErrorFromUnknown(error); + } + } + // New teams are published on creation, this is for backwards compatibility + async publish() { + const { url } = await this.checkIfTeamPaymentRequired(); + const teamId = this.team.id; + if (url) { + // TODO: We should probably hit the logic of this URL handled by the /upgrade API handler as it just generates the url to check the payment status and upgrade if needed + return { redirectUrl: url, status: TeamBillingPublishResponseStatus.REQUIRES_UPGRADE }; + } + const requestedSlug = this.team.metadata?.requestedSlug || ""; + // if payment needed, respond with checkout url + const membershipCount = await prisma.membership.count({ where: { teamId } }); + const owner = await prisma.membership.findFirstOrThrow({ + where: { teamId, role: "OWNER" }, + select: { + userId: true, + }, + }); + + try { + // TODO: extract this to new billing service + const checkoutSession = await purchaseTeamOrOrgSubscription({ + teamId, + seatsUsed: membershipCount, + userId: owner.userId, + pricePerSeat: null, + }); + + if (checkoutSession.url) { + return { + redirectUrl: checkoutSession.url, + status: TeamBillingPublishResponseStatus.REQUIRES_PAYMENT, + }; + } + + const { mergeMetadata } = getMetadataHelpers(teamPaymentMetadataSchema, this.team.metadata); + const data: Prisma.TeamUpdateInput = { + metadata: mergeMetadata({ requestedSlug: undefined }), + }; + if (requestedSlug) data.slug = requestedSlug; + await prisma.team.update({ where: { id: teamId }, data }); + return { status: TeamBillingPublishResponseStatus.SUCCESS, redirectUrl: null }; + } catch (error) { + if (error instanceof Redirect) throw error; + const { message } = getRequestedSlugError(error, requestedSlug); + throw Error(message); + } + } + async downgrade() { + try { + const { mergeMetadata } = getMetadataHelpers(teamPaymentMetadataSchema, this.team.metadata); + const metadata = mergeMetadata({ + paymentId: undefined, + subscriptionId: undefined, + subscriptionItemId: undefined, + }); + await prisma.team.update({ where: { id: this.team.id }, data: { metadata } }); + log.info(`Downgraded team ${this.team.id}`); + } catch (error) { + this.logErrorFromUnknown(error); + } + } + async updateQuantity() { + try { + await this.getOrgIfNeeded(); + const { url } = await this.checkIfTeamPaymentRequired(); + /** + * If there's no pending checkout URL it means that this team has not been paid. + * We cannot update the subscription yet, this will be handled on publish/checkout. + **/ + if (!url) return; + const { id: teamId, metadata, isOrganization } = this.team; + const { subscriptionId, subscriptionItemId, orgSeats } = metadata; + // Either it would be custom pricing where minimum number of seats are changed(available in orgSeats) or it would be default MINIMUM_NUMBER_OF_ORG_SEATS + // We can't go below this quantity for subscription + const orgMinimumSubscriptionQuantity = orgSeats || MINIMUM_NUMBER_OF_ORG_SEATS; + const membershipCount = await prisma.membership.count({ where: { teamId } }); + if (isOrganization && membershipCount < orgMinimumSubscriptionQuantity) { + log.info( + `Org ${teamId} has less members than the min required ${orgMinimumSubscriptionQuantity}, skipping updating subscription.` + ); + return; + } + if (!subscriptionId) throw Error("missing subscriptionId"); + if (!subscriptionItemId) throw Error("missing subscriptionItemId"); + await billing.handleSubscriptionUpdate({ subscriptionId, subscriptionItemId, membershipCount }); + log.info(`Updated subscription ${subscriptionId} for team ${teamId} to ${membershipCount} seats.`); + } catch (error) { + this.logErrorFromUnknown(error); + } + } + /** Used to prevent double charges for the same team */ + async checkIfTeamPaymentRequired() { + const { paymentId } = this.team.metadata || {}; + /** If there's no paymentId, we need to pay this team */ + if (!paymentId) return { url: null, paymentId: null, paymentRequired: true }; + /** If there's a pending session but it isn't paid, we need to pay this team */ + const checkoutSessionIsPaid = await billing.checkoutSessionIsPaid(paymentId); + if (!checkoutSessionIsPaid) return { url: null, paymentId, paymentRequired: true }; + /** If the session is already paid we return the upgrade URL so team is updated. */ + return { + url: `${WEBAPP_URL}/api/teams/${this.team.id}/upgrade?session_id=${paymentId}`, + paymentId, + paymentRequired: false, + }; + } +} diff --git a/packages/features/ee/billing/teams/stub-team-billing.ts b/packages/features/ee/billing/teams/stub-team-billing.ts new file mode 100644 index 0000000000..5d841d675c --- /dev/null +++ b/packages/features/ee/billing/teams/stub-team-billing.ts @@ -0,0 +1,28 @@ +import logger from "@calcom/lib/logger"; + +import { TeamBillingPublishResponseStatus, type TeamBilling, type TeamBillingInput } from "./team-billing"; + +const log = logger.getSubLogger({ prefix: ["StubTeamBilling"] }); + +/** + * Stub implementation of TeamBilling that does nothing. + * Usually used when team billing is disabled. + */ +export class StubTeamBilling implements TeamBilling { + constructor(_team: TeamBillingInput) { + log.info(`Skipping team billing`); + } + async cancel() { + log.info(`Skipping team billing cancellation due team billing being disabled`); + } + async publish() { + log.info(`Skipping team billing publish due team billing being disabled`); + return { redirectUrl: null, status: TeamBillingPublishResponseStatus.SUCCESS }; + } + async downgrade() { + log.info(`Skipping team billing downgrade due team billing being disabled`); + } + async updateQuantity() { + log.info(`Skipping team billing update due team billing being disabled`); + } +} diff --git a/packages/features/ee/billing/teams/team-billing.repository.interface.ts b/packages/features/ee/billing/teams/team-billing.repository.interface.ts new file mode 100644 index 0000000000..c5efea4459 --- /dev/null +++ b/packages/features/ee/billing/teams/team-billing.repository.interface.ts @@ -0,0 +1,18 @@ +import { Prisma } from "@prisma/client"; + +export const teamBillingSelect = Prisma.validator()({ + id: true, + metadata: true, + isOrganization: true, + parentId: true, +}); + +export type TeamBillingType = Prisma.TeamGetPayload<{ + select: typeof teamBillingSelect; +}>; + +export interface ITeamBillingRepository { + find(teamId: number): Promise; + findBySubscriptionId(subscriptionId: string): Promise; + findMany(teamIds: number[]): Promise; +} diff --git a/packages/features/ee/billing/teams/team-billing.repository.test.ts b/packages/features/ee/billing/teams/team-billing.repository.test.ts new file mode 100644 index 0000000000..76f30e632a --- /dev/null +++ b/packages/features/ee/billing/teams/team-billing.repository.test.ts @@ -0,0 +1,110 @@ +import prismaMock from "../../../../../tests/libs/__mocks__/prismaMock"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import * as constants from "@calcom/lib/constants"; + +import { TeamBillingRepository } from "./team-billing.repository"; + +vi.mock("@calcom/lib/constants", async () => { + const actual = await vi.importActual("@calcom/lib/constants"); + return { + ...actual, + IS_TEAM_BILLING_ENABLED: vi.fn(), + IS_PRODUCTION: false, + }; +}); + +describe("TeamBillingRepository", () => { + const mockTeam = { id: 1, metadata: null, isOrganization: true, parentId: null }; + const mockTeams = [mockTeam, { id: 2, metadata: null, isOrganization: false, parentId: 1 }]; + + beforeEach(() => { + vi.resetAllMocks(); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + describe("find", () => { + it("should return stubTeam when team billing is disabled", async () => { + // @ts-expect-error - IS_TEAM_BILLING_ENABLED is not writable + constants.IS_TEAM_BILLING_ENABLED = false; + + const tbr = new TeamBillingRepository(); + const result = await tbr.find(1); + expect(result).toEqual({ id: -1, metadata: expect.any(Object), isOrganization: true, parentId: -1 }); + }); + + it("should call prisma.team.findUniqueOrThrow when team billing is enabled", async () => { + // @ts-expect-error - IS_TEAM_BILLING_ENABLED is not writable + constants.IS_TEAM_BILLING_ENABLED = true; + + prismaMock.team.findUniqueOrThrow.mockResolvedValue(mockTeam); + + const tbr = new TeamBillingRepository(); + const result = await tbr.find(1); + expect(result).toEqual(mockTeam); + }); + }); + + describe("findBySubscriptionId", () => { + it("should return stubTeam when team billing is disabled", async () => { + // @ts-expect-error - IS_TEAM_BILLING_ENABLED is not writable + constants.IS_TEAM_BILLING_ENABLED = false; + + const tbr = new TeamBillingRepository(); + const result = await tbr.findBySubscriptionId("sub_123"); + expect(result).toEqual({ id: -1, metadata: {}, isOrganization: true, parentId: -1 }); + }); + + it("should call prisma.team.findFirstOrThrow when team billing is enabled", async () => { + // @ts-expect-error - IS_TEAM_BILLING_ENABLED is not writable + constants.IS_TEAM_BILLING_ENABLED = true; + + prismaMock.team.findFirstOrThrow.mockResolvedValue({ + id: 1, + metadata: { subscriptionId: "sub_123" }, + isOrganization: true, + parentId: null, + }); + + const tbr = new TeamBillingRepository(); + await tbr.findBySubscriptionId("sub_123"); + expect(prismaMock.team.findFirstOrThrow).toHaveBeenCalledWith({ + where: { + metadata: { + path: ["subscriptionId"], + equals: "sub_123", + }, + }, + select: { id: true, metadata: true, isOrganization: true, parentId: true }, + }); + }); + }); + + describe("findMany", () => { + it("should return an empty array when IS_TEAM_BILLING_ENABLED is false", async () => { + // @ts-expect-error - IS_TEAM_BILLING_ENABLED is not writable + constants.IS_TEAM_BILLING_ENABLED = false; + const tbr = new TeamBillingRepository(); + const result = await tbr.findMany([1, 2]); + expect(result).toEqual([]); + }); + + it("should call prisma.team.findMany when IS_TEAM_BILLING_ENABLED is true", async () => { + // @ts-expect-error - IS_TEAM_BILLING_ENABLED is not writable + constants.IS_TEAM_BILLING_ENABLED = true; + + prismaMock.team.findMany.mockResolvedValue([mockTeam]); + + const tbr = new TeamBillingRepository(); + await tbr.findMany([1, 2]); + expect(prismaMock.team.findMany).toHaveBeenCalledWith({ + where: { id: { in: [1, 2] } }, + select: { id: true, metadata: true, isOrganization: true, parentId: true }, + }); + }); + }); +}); diff --git a/packages/features/ee/billing/teams/team-billing.repository.ts b/packages/features/ee/billing/teams/team-billing.repository.ts new file mode 100644 index 0000000000..ba8ebbcdd8 --- /dev/null +++ b/packages/features/ee/billing/teams/team-billing.repository.ts @@ -0,0 +1,34 @@ +import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants"; +import prisma from "@calcom/prisma"; + +import type { ITeamBillingRepository } from "./team-billing.repository.interface"; +import { teamBillingSelect } from "./team-billing.repository.interface"; + +const stubTeam = { id: -1, metadata: {}, isOrganization: true, parentId: -1 }; + +export class TeamBillingRepository implements ITeamBillingRepository { + /** Fetch a single team with minimal data needed for billing */ + async find(teamId: number) { + if (!IS_TEAM_BILLING_ENABLED) return stubTeam; + return prisma.team.findUniqueOrThrow({ where: { id: teamId }, select: teamBillingSelect }); + } + /** Fetch a single team with minimal data needed for billing */ + async findBySubscriptionId(subscriptionId: string) { + if (!IS_TEAM_BILLING_ENABLED) return stubTeam; + const team = await prisma.team.findFirstOrThrow({ + where: { + metadata: { + path: ["subscriptionId"], + equals: subscriptionId, + }, + }, + select: teamBillingSelect, + }); + return team; + } + /** Fetch multiple teams with minimal data needed for billing */ + async findMany(teamIds: number[]) { + if (!IS_TEAM_BILLING_ENABLED) return []; + return prisma.team.findMany({ where: { id: { in: teamIds } }, select: teamBillingSelect }); + } +} diff --git a/packages/features/ee/billing/teams/team-billing.test.ts b/packages/features/ee/billing/teams/team-billing.test.ts new file mode 100644 index 0000000000..4e42086a4e --- /dev/null +++ b/packages/features/ee/billing/teams/team-billing.test.ts @@ -0,0 +1,83 @@ +import prismaMock from "../../../../../tests/libs/__mocks__/prismaMock"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import * as constants from "@calcom/lib/constants"; + +import { TeamBilling } from "./index"; +import { InternalTeamBilling } from "./internal-team-billing"; +import { StubTeamBilling } from "./stub-team-billing"; + +vi.mock("@calcom/lib/constants", async () => { + const actual = await vi.importActual("@calcom/lib/constants"); + return { + ...actual, + IS_TEAM_BILLING_ENABLED: vi.fn(), + IS_PRODUCTION: false, + }; +}); + +describe("TeamBilling", () => { + const mockTeam = { id: 1, metadata: null, isOrganization: true, parentId: null }; + const mockTeams = [mockTeam, { id: 2, metadata: null, isOrganization: false, parentId: 1 }]; + + beforeEach(() => { + vi.resetAllMocks(); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + describe("init", () => { + it("should return InternalTeamBilling when team billing is enabled", () => { + // @ts-expect-error - IS_TEAM_BILLING_ENABLED is not writable + constants.IS_TEAM_BILLING_ENABLED = true; + const result = TeamBilling.init(mockTeam); + expect(result).toBeInstanceOf(InternalTeamBilling); + }); + + it("should return StubTeamBilling when team billing is disabled", () => { + // @ts-expect-error - IS_TEAM_BILLING_ENABLED is not writable + constants.IS_TEAM_BILLING_ENABLED = false; + + const result = TeamBilling.init(mockTeam); + expect(result).toBeInstanceOf(StubTeamBilling); + }); + }); + + describe("initMany", () => { + it("should initialize multiple team billings", () => { + const result = TeamBilling.initMany(mockTeams); + expect(result).toHaveLength(2); + expect(result[0]).toBeInstanceOf(StubTeamBilling); + expect(result[1]).toBeInstanceOf(StubTeamBilling); + }); + }); + + describe("findAndInit", () => { + it("should find and initialize a single team billing", async () => { + // @ts-expect-error - IS_TEAM_BILLING_ENABLED is not writable + constants.IS_TEAM_BILLING_ENABLED = true; + + prismaMock.team.findUniqueOrThrow.mockResolvedValue(mockTeam); + + const result = await TeamBilling.findAndInit(1); + expect(result).toBeInstanceOf(InternalTeamBilling); + }); + }); + + describe("findAndInitMany", () => { + it("should find and initialize multiple team billings", async () => { + // @ts-expect-error - IS_TEAM_BILLING_ENABLED is not writable + constants.IS_TEAM_BILLING_ENABLED = true; + + prismaMock.team.findMany.mockResolvedValue([mockTeam, { ...mockTeam, id: 2 }]); + + const result = await TeamBilling.findAndInitMany([1, 2]); + expect(result).toHaveLength(2); + expect(result[0]).toBeInstanceOf(InternalTeamBilling); + expect(result[1]).toBeInstanceOf(InternalTeamBilling); + }); + }); +}); diff --git a/packages/features/ee/billing/teams/team-billing.ts b/packages/features/ee/billing/teams/team-billing.ts new file mode 100644 index 0000000000..1ba234a3c1 --- /dev/null +++ b/packages/features/ee/billing/teams/team-billing.ts @@ -0,0 +1,20 @@ +import type { Team } from "@prisma/client"; + +export type TeamBillingInput = Pick; +export const TeamBillingPublishResponseStatus = { + REQUIRES_PAYMENT: "REQUIRES_PAYMENT", + REQUIRES_UPGRADE: "REQUIRES_UPGRADE", + SUCCESS: "SUCCESS", +} as const; + +export type TeamBillingPublishResponse = { + redirectUrl: string | null; + status: (typeof TeamBillingPublishResponseStatus)[keyof typeof TeamBillingPublishResponseStatus]; +}; + +export interface TeamBilling { + cancel(): Promise; + publish(): Promise; + downgrade(): Promise; + updateQuantity(): Promise; +} diff --git a/packages/features/ee/organizations/pages/settings/other-team-profile-view.tsx b/packages/features/ee/organizations/pages/settings/other-team-profile-view.tsx index c030fa568e..5becdbd1a5 100644 --- a/packages/features/ee/organizations/pages/settings/other-team-profile-view.tsx +++ b/packages/features/ee/organizations/pages/settings/other-team-profile-view.tsx @@ -9,7 +9,7 @@ import { useEffect, useLayoutEffect, useState } from "react"; import { Controller, useForm } from "react-hook-form"; import { z } from "zod"; -import { IS_TEAM_BILLING_ENABLED, WEBAPP_URL } from "@calcom/lib/constants"; +import { IS_TEAM_BILLING_ENABLED_CLIENT, WEBAPP_URL } from "@calcom/lib/constants"; import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage"; import { trackFormbricksAction } from "@calcom/lib/formbricks-client"; import { useLocale } from "@calcom/lib/hooks/useLocale"; @@ -262,7 +262,7 @@ const OtherTeamProfileView = ({ isAppDir }: { isAppDir?: boolean }) => { - {IS_TEAM_BILLING_ENABLED && + {IS_TEAM_BILLING_ENABLED_CLIENT && team.slug === null && (team.metadata as Prisma.JsonObject)?.requestedSlug && (