diff --git a/packages/features/ee/teams/lib/payments.test.ts b/packages/features/ee/teams/lib/payments.test.ts index c4961ff245..71ea365f7e 100644 --- a/packages/features/ee/teams/lib/payments.test.ts +++ b/packages/features/ee/teams/lib/payments.test.ts @@ -1,19 +1,32 @@ import prismock from "../../../../../tests/libs/__mocks__/prisma"; -import { describe, expect, it, vi, beforeAll, afterAll } from "vitest"; +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; import stripe from "@calcom/app-store/stripepayment/lib/server"; -import { purchaseTeamOrOrgSubscription } from "./payments"; +import { + getTeamWithPaymentMetadata, + purchaseTeamOrOrgSubscription, + updateQuantitySubscriptionFromStripe, +} from "./payments"; -beforeAll(() => { +beforeEach(async () => { vi.stubEnv("STRIPE_ORG_MONTHLY_PRICE_ID", "STRIPE_ORG_MONTHLY_PRICE_ID"); vi.stubEnv("STRIPE_TEAM_MONTHLY_PRICE_ID", "STRIPE_TEAM_MONTHLY_PRICE_ID"); + vi.resetAllMocks(); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + await prismock.reset(); }); -afterAll(() => { +afterEach(async () => { vi.unstubAllEnvs(); + vi.resetAllMocks(); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + await prismock.reset(); }); + vi.mock("@calcom/app-store/stripepayment/lib/customer", () => { return { getStripeCustomerIdFromUserId: function () { @@ -22,6 +35,12 @@ vi.mock("@calcom/app-store/stripepayment/lib/customer", () => { }; }); +vi.mock("@calcom/lib/constant", () => { + return { + MINIMUM_NUMBER_OF_ORG_SEATS: 30, + }; +}); + vi.mock("@calcom/app-store/stripepayment/lib/server", () => { return { default: { @@ -35,12 +54,18 @@ vi.mock("@calcom/app-store/stripepayment/lib/server", () => { retrieve: vi.fn(), create: vi.fn(), }, + subscriptions: { + retrieve: vi.fn(), + update: vi.fn(), + create: vi.fn(), + }, }, }; }); describe("purchaseTeamOrOrgSubscription", () => { it("should use `seatsToChargeFor` to create price", async () => { + const FAKE_PAYMENT_ID = "FAKE_PAYMENT_ID"; const user = await prismock.user.create({ data: { name: "test", @@ -52,12 +77,15 @@ describe("purchaseTeamOrOrgSubscription", () => { url: "SESSION_URL", }); - mockStripeCheckoutSessionRetrieve({ - currency: "USD", - product: { - id: "PRODUCT_ID", + mockStripeCheckoutSessionRetrieve( + { + currency: "USD", + product: { + id: "PRODUCT_ID", + }, }, - }); + [FAKE_PAYMENT_ID] + ); mockStripeCheckoutPricesRetrieve({ id: "PRICE_ID", @@ -73,6 +101,9 @@ describe("purchaseTeamOrOrgSubscription", () => { const team = await prismock.team.create({ data: { name: "test", + metadata: { + paymentId: FAKE_PAYMENT_ID, + }, }, }); @@ -101,6 +132,331 @@ describe("purchaseTeamOrOrgSubscription", () => { }); }); +describe("updateQuantitySubscriptionFromStripe", () => { + describe("For an organization", () => { + it("should not update subscription when team members are less than metadata.orgSeats", async () => { + const FAKE_PAYMENT_ID = "FAKE_PAYMENT_ID"; + const FAKE_SUBITEM_ID = "FAKE_SUBITEM_ID"; + const FAKE_SUB_ID = "FAKE_SUB_ID"; + const FAKE_SUBSCRIPTION_QTY_IN_STRIPE = 1000; + const consoleInfoSpy = vi.spyOn(console, "info"); + + const organization = await createOrgWithMembersAndPaymentData({ + paymentId: FAKE_PAYMENT_ID, + subscriptionId: FAKE_SUB_ID, + subscriptionItemId: FAKE_SUBITEM_ID, + membersInTeam: 2, + orgSeats: 5, + }); + + mockStripeCheckoutSessionRetrieve( + { + payment_status: "paid", + }, + [FAKE_PAYMENT_ID] + ); + + mockStripeSubscriptionsRetrieve( + { + items: { + data: [ + { + id: "FAKE_SUBITEM_ID", + quantity: FAKE_SUBSCRIPTION_QTY_IN_STRIPE, + }, + ], + }, + }, + [FAKE_SUB_ID] + ); + + const mockedSubscriptionsUpdate = mockStripeSubscriptionsUpdate(null); + + await updateQuantitySubscriptionFromStripe(organization.id); + // Ensure that we reached the flow we are expecting to + expect(consoleInfoSpy.mock.calls[0][0]).toContain("has less members"); + + // orgSeats is more than the current number of members - So, no update in stripe + expect(mockedSubscriptionsUpdate).not.toHaveBeenCalled(); + }); + + it("should update subscription when team members are more than metadata.orgSeats", async () => { + const FAKE_PAYMENT_ID = "FAKE_PAYMENT_ID"; + const FAKE_SUB_ID = "FAKE_SUB_ID"; + const FAKE_SUBITEM_ID = "FAKE_SUBITEM_ID"; + const FAKE_SUBSCRIPTION_QTY_IN_STRIPE = 1000; + const membersInTeam = 4; + const organization = await createOrgWithMembersAndPaymentData({ + paymentId: FAKE_PAYMENT_ID, + subscriptionId: FAKE_SUB_ID, + subscriptionItemId: FAKE_SUBITEM_ID, + membersInTeam, + orgSeats: 3, + }); + + mockStripeCheckoutSessionRetrieve( + { + payment_status: "paid", + }, + [FAKE_PAYMENT_ID] + ); + + mockStripeSubscriptionsRetrieve( + { + items: { + data: [ + { + id: FAKE_SUBITEM_ID, + quantity: FAKE_SUBSCRIPTION_QTY_IN_STRIPE, + }, + ], + }, + }, + [FAKE_SUB_ID] + ); + + const mockedSubscriptionsUpdate = mockStripeSubscriptionsUpdate(null); + + await updateQuantitySubscriptionFromStripe(organization.id); + + // orgSeats is more than the current number of members - So, no update in stripe + expect(mockedSubscriptionsUpdate).toHaveBeenCalledWith(FAKE_SUB_ID, { + items: [ + { + quantity: membersInTeam, + id: FAKE_SUBITEM_ID, + }, + ], + }); + }); + + it("should not update subscription when team members are less than MINIMUM_NUMBER_OF_ORG_SEATS(if metadata.orgSeats is null)", async () => { + const FAKE_PAYMENT_ID = "FAKE_PAYMENT_ID"; + const FAKE_SUBITEM_ID = "FAKE_SUBITEM_ID"; + const FAKE_SUB_ID = "FAKE_SUB_ID"; + const FAKE_SUBSCRIPTION_QTY_IN_STRIPE = 1000; + const membersInTeam = 2; + const consoleInfoSpy = vi.spyOn(console, "info"); + + const organization = await createOrgWithMembersAndPaymentData({ + paymentId: FAKE_PAYMENT_ID, + subscriptionId: FAKE_SUB_ID, + subscriptionItemId: FAKE_SUBITEM_ID, + membersInTeam, + orgSeats: null, + }); + + mockStripeSubscriptionsRetrieve( + { + items: { + data: [ + { + id: "FAKE_SUBITEM_ID", + quantity: FAKE_SUBSCRIPTION_QTY_IN_STRIPE, + }, + ], + }, + }, + [FAKE_SUB_ID] + ); + + mockStripeCheckoutSessionRetrieve( + { + payment_status: "paid", + }, + [FAKE_PAYMENT_ID] + ); + + const mockedSubscriptionsUpdate = mockStripeSubscriptionsUpdate(null); + + await updateQuantitySubscriptionFromStripe(organization.id); + // Ensure that we reached the flow we are expecting to + expect(consoleInfoSpy.mock.calls[0][0]).toContain("has less members"); + // orgSeats is more than the current number of members - So, no update in stripe + expect(mockedSubscriptionsUpdate).not.toHaveBeenCalled(); + }); + + it("should update subscription when team members are more than MINIMUM_NUMBER_OF_ORG_SEATS(if metadata.orgSeats is null)", async () => { + const FAKE_PAYMENT_ID = "FAKE_PAYMENT_ID"; + const FAKE_SUB_ID = "FAKE_SUB_ID"; + const FAKE_SUBITEM_ID = "FAKE_SUBITEM_ID"; + const FAKE_SUBSCRIPTION_QTY_IN_STRIPE = 1000; + const membersInTeam = 35; + const organization = await createOrgWithMembersAndPaymentData({ + paymentId: FAKE_PAYMENT_ID, + subscriptionId: FAKE_SUB_ID, + subscriptionItemId: FAKE_SUBITEM_ID, + membersInTeam, + orgSeats: null, + }); + + mockStripeCheckoutSessionRetrieve( + { + payment_status: "paid", + }, + [FAKE_PAYMENT_ID] + ); + + mockStripeSubscriptionsRetrieve( + { + items: { + data: [ + { + id: FAKE_SUBITEM_ID, + quantity: FAKE_SUBSCRIPTION_QTY_IN_STRIPE, + }, + ], + }, + }, + [FAKE_SUB_ID] + ); + + const mockedSubscriptionsUpdate = mockStripeSubscriptionsUpdate(null); + + await updateQuantitySubscriptionFromStripe(organization.id); + + // orgSeats is more than the current number of members - So, no update in stripe + expect(mockedSubscriptionsUpdate).toHaveBeenCalledWith(FAKE_SUB_ID, { + items: [ + { + quantity: membersInTeam, + id: FAKE_SUBITEM_ID, + }, + ], + }); + }); + }); +}); + +describe("getTeamWithPaymentMetadata", () => { + it("should error if paymentId is not set", async () => { + const team = await prismock.team.create({ + data: { + isOrganization: true, + name: "TestTeam", + metadata: { + subscriptionId: "FAKE_SUB_ID", + subscriptionItemId: "FAKE_SUB_ITEM_ID", + }, + }, + }); + expect(() => getTeamWithPaymentMetadata(team.id)).rejects.toThrow(); + }); + + it("should error if subscriptionId is not set", async () => { + const team = await prismock.team.create({ + data: { + isOrganization: true, + name: "TestTeam", + metadata: { + paymentId: "FAKE_PAY_ID", + subscriptionItemId: "FAKE_SUB_ITEM_ID", + }, + }, + }); + expect(() => getTeamWithPaymentMetadata(team.id)).rejects.toThrow(); + }); + + it("should error if subscriptionItemId is not set", async () => { + const team = await prismock.team.create({ + data: { + isOrganization: true, + name: "TestTeam", + metadata: { + paymentId: "FAKE_PAY_ID", + subscriptionId: "FAKE_SUB_ID", + }, + }, + }); + expect(() => getTeamWithPaymentMetadata(team.id)).rejects.toThrow(); + }); + + it("should parse successfully if orgSeats is not set in metadata", async () => { + const team = await prismock.team.create({ + data: { + isOrganization: true, + name: "TestTeam", + metadata: { + paymentId: "FAKE_PAY_ID", + subscriptionId: "FAKE_SUB_ID", + subscriptionItemId: "FAKE_SUB_ITEM_ID", + }, + }, + }); + const teamWithPaymentData = await getTeamWithPaymentMetadata(team.id); + expect(teamWithPaymentData.metadata.orgSeats).toBeUndefined(); + }); + + it("should parse successfully if orgSeats is set in metadata", async () => { + const team = await prismock.team.create({ + data: { + isOrganization: true, + name: "TestTeam", + metadata: { + orgSeats: 5, + paymentId: "FAKE_PAY_ID", + subscriptionId: "FAKE_SUB_ID", + subscriptionItemId: "FAKE_SUB_ITEM_ID", + }, + }, + }); + const teamWithPaymentData = await getTeamWithPaymentMetadata(team.id); + expect(teamWithPaymentData.metadata.orgSeats).toEqual(5); + }); +}); + +async function createOrgWithMembersAndPaymentData({ + paymentId, + subscriptionId, + subscriptionItemId, + orgSeats, + membersInTeam, +}: { + paymentId: string; + subscriptionId: string; + subscriptionItemId: string; + orgSeats?: number | null; + membersInTeam: number; +}) { + const organization = await prismock.team.create({ + data: { + isOrganization: true, + name: "TestTeam", + metadata: { + // Make sure that payment is already done + paymentId, + orgSeats, + subscriptionId, + subscriptionItemId, + }, + }, + }); + + await Promise.all([ + Array(membersInTeam) + .fill(0) + .map(async (_, index) => { + return await prismock.membership.create({ + data: { + team: { + connect: { + id: organization.id, + }, + }, + user: { + create: { + name: "ABC", + email: `test-${index}@example.com`, + }, + }, + role: "MEMBER", + }, + }); + }), + ]); + return organization; +} + function mockStripePricesCreate(data) { // eslint-disable-next-line @typescript-eslint/ban-ts-comment //@ts-ignore @@ -118,12 +474,14 @@ function mockStripeCheckoutPricesRetrieve(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 mockStripeCheckoutSessionRetrieve(data, expectedArgs) { + return vi.mocked(stripe.checkout.sessions.retrieve).mockImplementation(async (sessionId) => + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore + { + const conditionMatched = expectedArgs[0] === sessionId; + return new Promise((resolve) => resolve(conditionMatched ? data : null)); + } ); } @@ -134,3 +492,22 @@ function mockStripeCheckoutSessionsCreate(data) { async () => new Promise((resolve) => resolve(data)) ); } + +function mockStripeSubscriptionsRetrieve(data, expectedArgs) { + return vi.mocked(stripe.subscriptions.retrieve).mockImplementation( + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore + async (subscriptionId) => { + const conditionMatched = expectedArgs ? expectedArgs[0] === subscriptionId : true; + return new Promise((resolve) => resolve(conditionMatched ? data : undefined)); + } + ); +} + +function mockStripeSubscriptionsUpdate(data) { + return vi.mocked(stripe.subscriptions.update).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 afebfe98c6..125e05e82f 100644 --- a/packages/features/ee/teams/lib/payments.ts +++ b/packages/features/ee/teams/lib/payments.ts @@ -4,7 +4,6 @@ import { getStripeCustomerIdFromUserId } from "@calcom/app-store/stripepayment/l import stripe from "@calcom/app-store/stripepayment/lib/server"; import { IS_PRODUCTION } from "@calcom/lib/constants"; import { MINIMUM_NUMBER_OF_ORG_SEATS, WEBAPP_URL } from "@calcom/lib/constants"; -import { ORGANIZATION_MIN_SEATS } from "@calcom/lib/constants"; import logger from "@calcom/lib/logger"; import { safeStringify } from "@calcom/lib/safeStringify"; import prisma from "@calcom/prisma"; @@ -12,9 +11,11 @@ import { teamMetadataSchema } from "@calcom/prisma/zod-utils"; const log = logger.getSubLogger({ prefix: ["teams/lib/payments"] }); const teamPaymentMetadataSchema = z.object({ + // Redefine paymentId, subscriptionId and subscriptionItemId to ensure that they are present and nonNullable paymentId: z.string(), subscriptionId: z.string(), subscriptionItemId: z.string(), + orgSeats: teamMetadataSchema.unwrap().shape.orgSeats, }); /** Used to prevent double charges for the same team */ @@ -180,7 +181,7 @@ export const purchaseTeamOrOrgSubscription = async (input: { } }; -const getTeamWithPaymentMetadata = async (teamId: number) => { +export const getTeamWithPaymentMetadata = async (teamId: number) => { const team = await prisma.team.findUniqueOrThrow({ where: { id: teamId }, select: { metadata: true, members: true, isOrganization: true }, @@ -211,7 +212,10 @@ export const updateQuantitySubscriptionFromStripe = async (teamId: number) => { **/ if (!url) return; const team = await getTeamWithPaymentMetadata(teamId); - const { subscriptionId, subscriptionItemId } = team.metadata; + const { subscriptionId, subscriptionItemId, orgSeats } = team.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 = team.members.length; const subscription = await stripe.subscriptions.retrieve(subscriptionId); const subscriptionQuantity = subscription.items.data.find( @@ -219,9 +223,9 @@ export const updateQuantitySubscriptionFromStripe = async (teamId: number) => { )?.quantity; if (!subscriptionQuantity) throw new Error("Subscription not found"); - if (team.isOrganization && membershipCount < ORGANIZATION_MIN_SEATS) { + if (team.isOrganization && membershipCount < orgMinimumSubscriptionQuantity) { console.info( - `Org ${teamId} has less members than the min ${ORGANIZATION_MIN_SEATS}, skipping updating subscription.` + `Org ${teamId} has less members than the min required ${orgMinimumSubscriptionQuantity}, skipping updating subscription.` ); return; }