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:
Hariom Balhara
2024-05-29 21:19:32 +00:00
committed by GitHub
parent 5f082e11f8
commit 817a3decb7
2 changed files with 401 additions and 20 deletions
+392 -15
View File
@@ -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))
);
}
+9 -5
View File
@@ -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;
}