diff --git a/apps/api/v1/pages/api/teams/[teamId]/_patch.ts b/apps/api/v1/pages/api/teams/[teamId]/_patch.ts index 2e1fcc5ca9..36737f24a7 100644 --- a/apps/api/v1/pages/api/teams/[teamId]/_patch.ts +++ b/apps/api/v1/pages/api/teams/[teamId]/_patch.ts @@ -107,7 +107,7 @@ export async function patchHandler(req: NextApiRequest) { if (IS_TEAM_BILLING_ENABLED) { const checkoutSession = await purchaseTeamOrOrgSubscription({ teamId: _team.id, - seats: _team.members.length, + seatsUsed: _team.members.length, userId, pricePerSeat: null, }); diff --git a/packages/features/ee/teams/lib/payments.test.ts b/packages/features/ee/teams/lib/payments.test.ts new file mode 100644 index 0000000000..c4961ff245 --- /dev/null +++ b/packages/features/ee/teams/lib/payments.test.ts @@ -0,0 +1,136 @@ +import prismock from "../../../../../tests/libs/__mocks__/prisma"; + +import { describe, expect, it, vi, beforeAll, afterAll } from "vitest"; + +import stripe from "@calcom/app-store/stripepayment/lib/server"; + +import { purchaseTeamOrOrgSubscription } from "./payments"; + +beforeAll(() => { + vi.stubEnv("STRIPE_ORG_MONTHLY_PRICE_ID", "STRIPE_ORG_MONTHLY_PRICE_ID"); + vi.stubEnv("STRIPE_TEAM_MONTHLY_PRICE_ID", "STRIPE_TEAM_MONTHLY_PRICE_ID"); +}); + +afterAll(() => { + vi.unstubAllEnvs(); +}); +vi.mock("@calcom/app-store/stripepayment/lib/customer", () => { + return { + getStripeCustomerIdFromUserId: function () { + return "CUSTOMER_ID"; + }, + }; +}); + +vi.mock("@calcom/app-store/stripepayment/lib/server", () => { + return { + default: { + checkout: { + sessions: { + create: vi.fn(), + retrieve: vi.fn(), + }, + }, + prices: { + retrieve: vi.fn(), + create: vi.fn(), + }, + }, + }; +}); + +describe("purchaseTeamOrOrgSubscription", () => { + it("should use `seatsToChargeFor` to create price", async () => { + const user = await prismock.user.create({ + data: { + name: "test", + email: "test@email.com", + }, + }); + + const checkoutSessionsCreate = mockStripeCheckoutSessionsCreate({ + url: "SESSION_URL", + }); + + mockStripeCheckoutSessionRetrieve({ + currency: "USD", + product: { + id: "PRODUCT_ID", + }, + }); + + mockStripeCheckoutPricesRetrieve({ + id: "PRICE_ID", + product: { + id: "PRODUCT_ID", + }, + }); + + mockStripePricesCreate({ + id: "PRICE_ID", + }); + + const team = await prismock.team.create({ + data: { + name: "test", + }, + }); + + const seatsToChargeFor = 1000; + expect( + await purchaseTeamOrOrgSubscription({ + teamId: team.id, + seatsUsed: 10, + seatsToChargeFor, + userId: user.id, + isOrg: true, + pricePerSeat: 100, + }) + ).toEqual({ url: "SESSION_URL" }); + + expect(checkoutSessionsCreate).toHaveBeenCalledWith( + expect.objectContaining({ + line_items: [ + { + price: "PRICE_ID", + quantity: seatsToChargeFor, + }, + ], + }) + ); + }); +}); + +function mockStripePricesCreate(data) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore + return vi.mocked(stripe.prices.create).mockImplementation(() => new Promise((resolve) => resolve(data))); +} + +function mockStripeCheckoutPricesRetrieve(data) { + return vi.mocked(stripe.prices.retrieve).mockImplementation( + async () => + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore + new Promise((resolve) => { + resolve(data); + }) + ); +} + +function mockStripeCheckoutSessionRetrieve(data) { + return vi.mocked(stripe.checkout.sessions.retrieve).mockImplementation( + async () => + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore + new Promise((resolve) => resolve(data)) + ); +} + +function mockStripeCheckoutSessionsCreate(data) { + return vi.mocked(stripe.checkout.sessions.create).mockImplementation( + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore + async () => new Promise((resolve) => resolve(data)) + ); +} diff --git a/packages/features/ee/teams/lib/payments.ts b/packages/features/ee/teams/lib/payments.ts index d5ff20c75e..afebfe98c6 100644 --- a/packages/features/ee/teams/lib/payments.ts +++ b/packages/features/ee/teams/lib/payments.ts @@ -81,17 +81,28 @@ export const generateTeamCheckoutSession = async ({ */ export const purchaseTeamOrOrgSubscription = async (input: { teamId: number; - seats: number; + /** + * The actual number of seats in the team. + * The seats that we would charge for could be more than this depending on the MINIMUM_NUMBER_OF_ORG_SEATS in case of an organization + * For a team it would be the same as this value + */ + seatsUsed: number; + /** + * If provided, this is the exact number we would charge for. + */ + seatsToChargeFor?: number | null; userId: number; isOrg?: boolean; pricePerSeat: number | null; }) => { - const { teamId, seats, userId, isOrg, pricePerSeat } = input; + const { teamId, seatsToChargeFor, seatsUsed, userId, isOrg, pricePerSeat } = input; const { url } = await checkIfTeamPaymentRequired({ teamId }); if (url) return { url }; - // For orgs, enforce minimum of 30 seats - const quantity = isOrg ? Math.max(seats, MINIMUM_NUMBER_OF_ORG_SEATS) : seats; + // For orgs, enforce minimum of MINIMUM_NUMBER_OF_ORG_SEATS seats if `seatsToChargeFor` not set + const seats = isOrg ? Math.max(seatsUsed, MINIMUM_NUMBER_OF_ORG_SEATS) : seatsUsed; + const quantity = seatsToChargeFor ? seatsToChargeFor : seats; + const customer = await getStripeCustomerIdFromUserId(userId); const session = await stripe.checkout.sessions.create({ diff --git a/packages/trpc/server/routers/viewer/organizations/publish.handler.ts b/packages/trpc/server/routers/viewer/organizations/publish.handler.ts index 05f6b92fdb..2c0fc9991b 100644 --- a/packages/trpc/server/routers/viewer/organizations/publish.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/publish.handler.ts @@ -39,7 +39,10 @@ export const publishHandler = async ({ ctx }: PublishOptions) => { if (IS_TEAM_BILLING_ENABLED) { const checkoutSession = await purchaseTeamOrOrgSubscription({ teamId: prevTeam.id, - seats: Math.max(prevTeam.members.length, metadata.data?.orgSeats ?? 0), + seatsUsed: prevTeam.members.length, + seatsToChargeFor: metadata.data?.orgSeats + ? Math.max(prevTeam.members.length, metadata.data?.orgSeats ?? 0) + : null, userId: ctx.user.id, isOrg: true, pricePerSeat: metadata.data?.orgPricePerSeat ?? null, diff --git a/packages/trpc/server/routers/viewer/teams/publish.handler.ts b/packages/trpc/server/routers/viewer/teams/publish.handler.ts index d5550ed5bf..ee20c27eeb 100644 --- a/packages/trpc/server/routers/viewer/teams/publish.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/publish.handler.ts @@ -45,7 +45,7 @@ const generateCheckoutSession = async ({ const checkoutSession = await purchaseTeamOrOrgSubscription({ teamId, - seats, + seatsUsed: seats, userId, pricePerSeat: null, });