From a4a3eb5ea9cff2e7a046dfe7ce4431f046b438cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Omar=20L=C3=B3pez?= Date: Fri, 4 Oct 2024 13:44:50 -0700 Subject: [PATCH] refactor: Billing service (#15366) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * WIP: Billing service * WIP * Update __handler.ts * Update index.ts * WIP * WIP Signed-off-by: zomars * WIP Signed-off-by: zomars * Update delete.handler.ts * Update delete.handler.ts * Update internal-team-billing.ts * WIP * Type fixes Signed-off-by: zomars * WIP Signed-off-by: zomars * Discard changes to apps/api/v2/src/ee/event-types/event-types_2024_04_15/controllers/event-types.controller.ts * Discard changes to packages/app-store/skype/config.json * style: update text area (#15934) * chore: update text-editor input * chore: update text-editor input * final update * Update Editor.tsx --------- Co-authored-by: unknown Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com> * fix: #15697 Assignment warning modal poping up ,even when hosts are added . (#15699) * removed reduntand check on assignedusers * update for managed eventtype * formatted * comments * Updated comments * extracted checkForEmptyAssignment and added tests * updated comments * updated to show dialog also when not yet saved * update translations * updated comments * updated translation * updated types * chore: update naming and comments * Delete apps/web/lib/checkForEmptyAssignment.ts deleted since file was renamed * Delete apps/web/test/lib/checkForEmptyAssignment.test.ts deleted as the file is renamed to be more descriptive * update to do teamcheck before * chore: revert name to CheckForEmptyAssignment * updated check conditions * updated useEffect dependencies --------- Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com> * fix: getting an error when a team member tries to leave (#15925) a team Co-authored-by: Somay Chauhan Co-authored-by: Amit Sharma <74371312+Amit91848@users.noreply.github.com> * feat: add transitions to more components (#15916) * add transitions to more components * chore: Remove duplicate class * fix: Change minimum number of teams required for org self-serve (#15955) * fix: Change minimum number of teams required for org self-serve Signed-off-by: Souptik Datta * refactor: Use a new constant for displaying org helper text dialogue Signed-off-by: Souptik Datta * Update packages/lib/constants.ts * Update turbo.json --------- Signed-off-by: Souptik Datta Co-authored-by: Keith Williams * feat: pwa ready and add shortcut for quick access (#15957) * chore: Update site.webmanifest with additional display properties * chore: Update site.webmanifest with additional display properties * Update apps/web/public/site.webmanifest --------- Co-authored-by: Keith Williams * i18n: update translations for "by proceeding, ..." (#15921) * feat: Add title attribute to iframe in embed.ts (#15944) * feat: Add title attribute to iframe in embed.ts * Update packages/embeds/embed-core/src/embed.ts --------- Co-authored-by: Hariom Balhara * fix: Assignment reminder modal appears when deleting a team event type (#15544) * fix: Assignment reminder modal appears when deleting a team event type * update * Update EventTypeSingleLayout.tsx * Update event-types-single-view.tsx * update * Early return https://handbook.cal.com/engineering/best-practices/prefer-early-returns --------- Co-authored-by: Keith Williams Co-authored-by: Hariom * fix: Add CORP and COEP headers to embedded pages (#15275) * Add CORP headers * Improve test * Add unit tests as well --------- Co-authored-by: Keith Williams * feat: No-show bookings - do not count towards a member's round robin assignment (#15323) * feat: No-show bookings - do not count towards a member's round robin assignment * filter no show organizer * remove logs * fix: filter bookings where host attendee is no show * fix: use not condition for noShow * chore: test * add default false for noShowHost * Revert "chore: test" This reverts commit c914a8fc4a14ca9c7d8c4a08ff8b0f463789171c. * chore: integration test * fix: noShowHost not working for null * Update packages/lib/server/getLuckyUser.ts * chore: update test description --------- Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com> Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com> Co-authored-by: gh0st91848 Co-authored-by: Hariom Balhara * refactor: v1 orgs unlimited teams (#15908) * refactor: v1 orgs unlimited teams * fix: response message ownerId --------- Co-authored-by: Keith Williams * fix: refresh connect atoms on token change (#15933) * added dropdown * change comment * fix: added types --------- Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com> Co-authored-by: Keith Williams * fix: Next.js bundle analysis (#15903) * fix: Next.js bundle analysis * Added the main checkout back * fix: type in webhook payload (#15912) Co-authored-by: CarinaWolli * fix: auto accept (#15975) * fix: auto accept * Update packages/lib/server/repository/organization.ts Co-authored-by: Hariom Balhara --------- Co-authored-by: Hariom Balhara * fix: Broken GitHub workflows (#15979) * test: Delete credential endpoint (#15322) * Init * Add mock trpc context * Intro mockDatabaseClient * Introduce mockDatabaseClient class * Add delete video app test * Add calendar test * Remove unused func from bookingScenario * Remove console.log * Add app repository * Add createMany method to event type repository * Remove instance of MockDatabaseClient from video test * Add destination calendar repository * Remove instances of MockDatabaseClient * abstract logic to own handler * Remove dev dependency * Clean up * Type fix * Pull yarn.lock from main --------- Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com> * fix: #15967 Show better error when wrong variable is used in custom event name (#15974) * update to show exact err msg with custom event name * updated tests for validateCustomEventName --------- Co-authored-by: Keith Williams Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com> * WIP Signed-off-by: zomars * Update yarn.lock * Update publish.handler.ts * Update internal-team-billing.test.ts * rename create to init * remove sync services * wip tests for delete team * wip tets * wip tests * tests/internal-test * remove return that is inaccessable * remove team calls to use repository * remove redudant file * update team repo * add stripe mocks and test * fix constants * tests to main stripe handler * remove logs * fix constants * Update packages/features/ee/billing/api/webhook/_customer.subscription.deleted.team-plan.ts Co-authored-by: Omar López * move workflow logic to a service * use static method > creating new class * fix: remove http code deep in repo + tests * Feedback * Update index.ts * Type fix * Lazy load fixes --------- Signed-off-by: zomars Signed-off-by: Souptik Datta Co-authored-by: Rohan Advani <122167726+RNAdvani@users.noreply.github.com> Co-authored-by: unknown Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com> Co-authored-by: Vijay Co-authored-by: Somay Chauhan Co-authored-by: Amit Sharma <74371312+Amit91848@users.noreply.github.com> Co-authored-by: Hichem Fantar Co-authored-by: Souptik Datta Co-authored-by: Keith Williams Co-authored-by: Johannes Maendle <141119154+jomaendle2@users.noreply.github.com> Co-authored-by: Hariom Balhara Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com> Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com> Co-authored-by: gh0st91848 Co-authored-by: Lauris Skraucis Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com> Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com> Co-authored-by: CarinaWolli Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com> Co-authored-by: sean-brydon Co-authored-by: sean --- .env.example | 2 +- apps/web/pages/api/cron/downgradeUsers.ts | 12 +- apps/web/pages/api/stripe/webhook.ts | 7 + .../ee/billing/api/webhook/__handler.test.ts | 92 ++++++++ .../ee/billing/api/webhook/__handler.ts | 63 +++++ ...customer.subscription.deleted.team-plan.ts | 26 +++ .../webhook/_customer.subscription.deleted.ts | 25 ++ .../api/webhook/_payment_intent.succeeded.ts | 10 + .../features/ee/billing/api/webhook/index.ts | 17 ++ .../ee/billing/billing-service-factory.ts | 7 + .../features/ee/billing/billing-service.ts | 10 + packages/features/ee/billing/index.ts | 14 ++ packages/features/ee/billing/package.json | 8 + .../billing/stripe-billling-service.test.ts | 88 +++++++ .../ee/billing/stripe-billling-service.ts | 33 +++ packages/features/ee/billing/teams/index.ts | 30 +++ .../teams/internal-team-billing.test.ts | 176 ++++++++++++++ .../ee/billing/teams/internal-team-billing.ts | 164 +++++++++++++ .../ee/billing/teams/stub-team-billing.ts | 28 +++ .../team-billing.repository.interface.ts | 18 ++ .../teams/team-billing.repository.test.ts | 110 +++++++++ .../billing/teams/team-billing.repository.ts | 34 +++ .../ee/billing/teams/team-billing.test.ts | 83 +++++++ .../features/ee/billing/teams/team-billing.ts | 20 ++ .../settings/other-team-profile-view.tsx | 4 +- packages/features/ee/teams/api/upgrade.ts | 9 +- .../components/MemberInvitationModal.tsx | 4 +- packages/features/ee/teams/lib/payments.ts | 13 +- packages/features/package.json | 1 + packages/lib/redirect.ts | 14 ++ .../server/repository/TeamRepository.test.ts | 136 +++++++++++ packages/lib/server/repository/team.ts | 122 ++++++++++ packages/lib/server/service/workflows.ts | 66 ++++++ .../organizations/bulkDeleteUsers.handler.ts | 7 +- .../routers/viewer/teams/delete.handler.ts | 104 +-------- .../inviteMember/inviteMember.handler.test.ts | 7 +- .../inviteMember/inviteMember.handler.ts | 16 +- .../teams/inviteMemberByToken.handler.ts | 53 +---- .../routers/viewer/teams/publish.handler.ts | 113 ++------- .../viewer/teams/removeMember.handler.ts | 30 +-- packages/types/environment.d.ts | 1 + tests/libs/__mocks__/stripe.ts | 24 ++ tests/libs/mockData.ts | 133 +++++++++++ tests/libs/mockTRPCContext.ts | 35 +++ turbo.json | 1 + yarn.lock | 215 ++++++++++++++++-- 46 files changed, 1848 insertions(+), 337 deletions(-) create mode 100644 apps/web/pages/api/stripe/webhook.ts create mode 100644 packages/features/ee/billing/api/webhook/__handler.test.ts create mode 100644 packages/features/ee/billing/api/webhook/__handler.ts create mode 100644 packages/features/ee/billing/api/webhook/_customer.subscription.deleted.team-plan.ts create mode 100644 packages/features/ee/billing/api/webhook/_customer.subscription.deleted.ts create mode 100644 packages/features/ee/billing/api/webhook/_payment_intent.succeeded.ts create mode 100644 packages/features/ee/billing/api/webhook/index.ts create mode 100644 packages/features/ee/billing/billing-service-factory.ts create mode 100644 packages/features/ee/billing/billing-service.ts create mode 100644 packages/features/ee/billing/index.ts create mode 100644 packages/features/ee/billing/package.json create mode 100644 packages/features/ee/billing/stripe-billling-service.test.ts create mode 100644 packages/features/ee/billing/stripe-billling-service.ts create mode 100644 packages/features/ee/billing/teams/index.ts create mode 100644 packages/features/ee/billing/teams/internal-team-billing.test.ts create mode 100644 packages/features/ee/billing/teams/internal-team-billing.ts create mode 100644 packages/features/ee/billing/teams/stub-team-billing.ts create mode 100644 packages/features/ee/billing/teams/team-billing.repository.interface.ts create mode 100644 packages/features/ee/billing/teams/team-billing.repository.test.ts create mode 100644 packages/features/ee/billing/teams/team-billing.repository.ts create mode 100644 packages/features/ee/billing/teams/team-billing.test.ts create mode 100644 packages/features/ee/billing/teams/team-billing.ts create mode 100644 packages/lib/redirect.ts create mode 100644 packages/lib/server/repository/TeamRepository.test.ts create mode 100644 packages/lib/server/service/workflows.ts create mode 100644 tests/libs/__mocks__/stripe.ts create mode 100644 tests/libs/mockData.ts create mode 100644 tests/libs/mockTRPCContext.ts 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 && (