2
0
Files
cal/calcom/packages/features/ee/teams/lib/payments.test.ts
2024-08-09 00:39:27 +02:00

714 lines
18 KiB
TypeScript

import prismock from "../../../../../tests/libs/__mocks__/prisma";
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import stripe from "@calcom/app-store/stripepayment/lib/server";
import {
getTeamWithPaymentMetadata,
purchaseTeamOrOrgSubscription,
updateQuantitySubscriptionFromStripe,
} from "./payments";
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();
});
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 () {
return "CUSTOMER_ID";
},
};
});
vi.mock("@calcom/lib/constant", () => {
return {
MINIMUM_NUMBER_OF_ORG_SEATS: 30,
};
});
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(),
},
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",
email: "test@email.com",
},
});
const checkoutSessionsCreate = mockStripeCheckoutSessionsCreate({
url: "SESSION_URL",
});
mockStripeCheckoutSessionRetrieve(
{
currency: "USD",
product: {
id: "PRODUCT_ID",
},
},
[FAKE_PAYMENT_ID]
);
mockStripeCheckoutPricesRetrieve({
id: "PRICE_ID",
product: {
id: "PRODUCT_ID",
},
});
mockStripePricesCreate({
id: "PRICE_ID",
});
const team = await prismock.team.create({
data: {
name: "test",
metadata: {
paymentId: FAKE_PAYMENT_ID,
},
},
});
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,
},
],
})
);
});
it("Should create a monthly subscription if billing period is set to monthly", async () => {
const FAKE_PAYMENT_ID = "FAKE_PAYMENT_ID";
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",
},
},
[FAKE_PAYMENT_ID]
);
mockStripeCheckoutPricesRetrieve({
id: "PRICE_ID",
product: {
id: "PRODUCT_ID",
},
});
const checkoutPricesCreate = mockStripePricesCreate({
id: "PRICE_ID",
});
const team = await prismock.team.create({
data: {
name: "test",
metadata: {
paymentId: FAKE_PAYMENT_ID,
},
},
});
const seatsToChargeFor = 1000;
expect(
await purchaseTeamOrOrgSubscription({
teamId: team.id,
seatsUsed: 10,
seatsToChargeFor,
userId: user.id,
isOrg: true,
pricePerSeat: 100,
billingPeriod: "MONTHLY",
})
).toEqual({ url: "SESSION_URL" });
expect(checkoutPricesCreate).toHaveBeenCalledWith(
expect.objectContaining({ recurring: { interval: "month" } })
);
expect(checkoutSessionsCreate).toHaveBeenCalledWith(
expect.objectContaining({
line_items: [
{
price: "PRICE_ID",
quantity: seatsToChargeFor,
},
],
})
);
});
it("Should create a annual subscription if billing period is set to annual", async () => {
const FAKE_PAYMENT_ID = "FAKE_PAYMENT_ID";
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",
},
},
[FAKE_PAYMENT_ID]
);
mockStripeCheckoutPricesRetrieve({
id: "PRICE_ID",
product: {
id: "PRODUCT_ID",
},
});
const checkoutPricesCreate = mockStripePricesCreate({
id: "PRICE_ID",
});
const team = await prismock.team.create({
data: {
name: "test",
metadata: {
paymentId: FAKE_PAYMENT_ID,
},
},
});
const seatsToChargeFor = 1000;
expect(
await purchaseTeamOrOrgSubscription({
teamId: team.id,
seatsUsed: 10,
seatsToChargeFor,
userId: user.id,
isOrg: true,
pricePerSeat: 100,
billingPeriod: "ANNUALLY",
})
).toEqual({ url: "SESSION_URL" });
expect(checkoutPricesCreate).toHaveBeenCalledWith(
expect.objectContaining({ recurring: { interval: "year" } })
);
expect(checkoutSessionsCreate).toHaveBeenCalledWith(
expect.objectContaining({
line_items: [
{
price: "PRICE_ID",
quantity: seatsToChargeFor,
},
],
})
);
});
it("It should not create a custom price if price_per_seat is not set", async () => {
const FAKE_PAYMENT_ID = "FAKE_PAYMENT_ID";
const user = await prismock.user.create({
data: {
name: "test",
email: "test@email.com",
},
});
mockStripeCheckoutSessionsCreate({
url: "SESSION_URL",
});
mockStripeCheckoutSessionRetrieve(
{
currency: "USD",
product: {
id: "PRODUCT_ID",
},
},
[FAKE_PAYMENT_ID]
);
mockStripeCheckoutPricesRetrieve({
id: "PRICE_ID",
product: {
id: "PRODUCT_ID",
},
});
const checkoutPricesCreate = mockStripePricesCreate({
id: "PRICE_ID",
});
const team = await prismock.team.create({
data: {
name: "test",
metadata: {
paymentId: FAKE_PAYMENT_ID,
},
},
});
const seatsToChargeFor = 1000;
expect(
await purchaseTeamOrOrgSubscription({
teamId: team.id,
seatsUsed: 10,
seatsToChargeFor,
userId: user.id,
isOrg: true,
billingPeriod: "ANNUALLY",
})
).toEqual({ url: "SESSION_URL" });
expect(checkoutPricesCreate).not.toHaveBeenCalled();
});
});
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
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, 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));
}
);
}
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))
);
}
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))
);
}