fix: Do subscription update even if team.members are less than 30 in case orgSeats allow it (#15209)
* Send all events to analytics apps * fix: Handle subscription update when orgSeats are less than 30 * Add more tests
This commit is contained in:
@@ -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))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user