mirror of
https://github.com/hanzoai/calendar.git
synced 2026-10-30 14:46:43 +00:00
f4abbb2de1
* factory and statergie * chore: use correct method of DI * feat: add onchagne * add logic to HWM stat * add webhook resolver methods to each statergy * move seat tracking + webhooks over to own statergy * Move to factory base approach * move logic to correct class * rename create -> createByTeamId * fix: remove debug `true ||` overrides from IS_STRIPE_ENABLED and IS_TEAM_BILLING_ENABLED Remove accidentally committed debug overrides that short-circuited IS_STRIPE_ENABLED and IS_TEAM_BILLING_ENABLED to always be true, bypassing Stripe credential checks. This would break self-hosted instances without Stripe configured. Identified by cubic (https://cubic.dev) Co-Authored-By: unknown <> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
454 lines
15 KiB
TypeScript
454 lines
15 KiB
TypeScript
import prismaMock from "@calcom/testing/lib/__mocks__/prismaMock";
|
|
import { purchaseTeamOrOrgSubscription } from "@calcom/features/ee/teams/lib/payments";
|
|
import { WEBAPP_URL } from "@calcom/lib/constants";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import type { IBillingRepository } from "../../repository/billing/IBillingRepository";
|
|
import { Plan, SubscriptionStatus } from "../../repository/billing/IBillingRepository";
|
|
import type { ITeamBillingDataRepository } from "../../repository/teamBillingData/ITeamBillingDataRepository";
|
|
import type { IBillingProviderService } from "../billingProvider/IBillingProviderService";
|
|
import type { ISeatBillingStrategy } from "../seatBillingStrategy/ISeatBillingStrategy";
|
|
import type { SeatBillingStrategyFactory } from "../seatBillingStrategy/SeatBillingStrategyFactory";
|
|
import { TeamBillingPublishResponseStatus } from "./ITeamBillingService";
|
|
import { TeamBillingService } from "./TeamBillingService";
|
|
|
|
vi.mock("@calcom/lib/constants", async () => {
|
|
const actual = await vi.importActual("@calcom/lib/constants");
|
|
return {
|
|
...actual,
|
|
WEBAPP_URL: "http://localhost:3000",
|
|
};
|
|
});
|
|
|
|
vi.mock("@calcom/features/ee/teams/lib/payments", () => ({
|
|
purchaseTeamOrOrgSubscription: vi.fn(),
|
|
}));
|
|
|
|
const mockTeam = {
|
|
id: 1,
|
|
metadata: {
|
|
subscriptionId: "sub_123",
|
|
subscriptionItemId: "si_456",
|
|
paymentId: "cs_789",
|
|
},
|
|
isOrganization: true,
|
|
parentId: null,
|
|
};
|
|
|
|
const createMockBillingProviderService = (): IBillingProviderService => ({
|
|
handleSubscriptionCancel: vi.fn(),
|
|
handleSubscriptionUpdate: vi.fn(),
|
|
handleSubscriptionCreation: vi.fn(),
|
|
checkoutSessionIsPaid: vi.fn(),
|
|
getSubscriptionStatus: vi.fn(),
|
|
handleEndTrial: vi.fn(),
|
|
createCustomer: vi.fn(),
|
|
createPaymentIntent: vi.fn(),
|
|
createSubscriptionCheckout: vi.fn(),
|
|
createPrice: vi.fn(),
|
|
getPrice: vi.fn(),
|
|
getCheckoutSession: vi.fn(),
|
|
getCustomer: vi.fn(),
|
|
getSubscriptions: vi.fn(),
|
|
updateCustomer: vi.fn(),
|
|
createInvoiceItem: vi.fn(),
|
|
deleteInvoiceItem: vi.fn(),
|
|
createInvoice: vi.fn(),
|
|
finalizeInvoice: vi.fn(),
|
|
getSubscription: vi.fn(),
|
|
getPaymentIntentFailureReason: vi.fn(),
|
|
hasDefaultPaymentMethod: vi.fn(),
|
|
});
|
|
|
|
const createMockTeamBillingDataRepository = (): ITeamBillingDataRepository => ({
|
|
find: vi.fn(),
|
|
findMany: vi.fn(),
|
|
findBySubscriptionId: vi.fn(),
|
|
});
|
|
|
|
const createMockBillingRepository = (): IBillingRepository => ({
|
|
create: vi.fn(),
|
|
});
|
|
|
|
function createMockStrategy(): ISeatBillingStrategy {
|
|
return { onSeatChange: vi.fn() } as unknown as ISeatBillingStrategy;
|
|
}
|
|
|
|
function createMockFactory(strategy: ISeatBillingStrategy): SeatBillingStrategyFactory {
|
|
return { createByTeamId: vi.fn().mockResolvedValue(strategy) } as unknown as SeatBillingStrategyFactory;
|
|
}
|
|
|
|
describe("TeamBillingService", () => {
|
|
let mockBillingProviderService: IBillingProviderService;
|
|
let mockTeamBillingDataRepository: ITeamBillingDataRepository;
|
|
let mockBillingRepository: IBillingRepository;
|
|
let defaultResolver: SeatBillingStrategyFactory;
|
|
|
|
beforeEach(() => {
|
|
vi.resetAllMocks();
|
|
mockBillingProviderService = createMockBillingProviderService();
|
|
mockTeamBillingDataRepository = createMockTeamBillingDataRepository();
|
|
mockBillingRepository = createMockBillingRepository();
|
|
defaultResolver = createMockFactory(createMockStrategy());
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe("cancel", () => {
|
|
it("should cancel the subscription and downgrade the team", async () => {
|
|
const teamBillingService = new TeamBillingService({
|
|
team: mockTeam,
|
|
billingProviderService: mockBillingProviderService,
|
|
teamBillingDataRepository: mockTeamBillingDataRepository,
|
|
billingRepository: mockBillingRepository,
|
|
seatBillingStrategyFactory: defaultResolver,
|
|
});
|
|
|
|
await teamBillingService.cancel();
|
|
|
|
expect(mockBillingProviderService.handleSubscriptionCancel).toHaveBeenCalledWith("sub_123");
|
|
expect(prismaMock.team.update).toHaveBeenCalledWith({
|
|
where: { id: 1 },
|
|
data: {
|
|
metadata: {},
|
|
},
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("publish", () => {
|
|
it("should create a checkout session and update the team", async () => {
|
|
const teamBillingService = new TeamBillingService({
|
|
team: mockTeam,
|
|
billingProviderService: mockBillingProviderService,
|
|
teamBillingDataRepository: mockTeamBillingDataRepository,
|
|
billingRepository: mockBillingRepository,
|
|
seatBillingStrategyFactory: defaultResolver,
|
|
});
|
|
|
|
vi.mocked(mockBillingProviderService.checkoutSessionIsPaid).mockResolvedValue(false);
|
|
vi.mocked(purchaseTeamOrOrgSubscription).mockResolvedValue({
|
|
url: "http://checkout.url",
|
|
});
|
|
prismaMock.membership.count.mockResolvedValue(5);
|
|
prismaMock.membership.findFirstOrThrow.mockResolvedValue({ userId: 123 });
|
|
|
|
const result = await teamBillingService.publish();
|
|
expect(result).toEqual({
|
|
redirectUrl: "http://checkout.url",
|
|
status: TeamBillingPublishResponseStatus.REQUIRES_PAYMENT,
|
|
});
|
|
|
|
expect(prismaMock.membership.count).toHaveBeenCalledWith({ where: { teamId: 1 } });
|
|
expect(prismaMock.membership.findFirstOrThrow).toHaveBeenCalledWith({
|
|
where: { teamId: 1, role: "OWNER" },
|
|
select: { userId: true },
|
|
});
|
|
});
|
|
it("should return upgrade url if upgrade is required", async () => {
|
|
const teamBillingService = new TeamBillingService({
|
|
team: mockTeam,
|
|
billingProviderService: mockBillingProviderService,
|
|
teamBillingDataRepository: mockTeamBillingDataRepository,
|
|
billingRepository: mockBillingRepository,
|
|
seatBillingStrategyFactory: defaultResolver,
|
|
});
|
|
|
|
const mockUrl = `${WEBAPP_URL}/api/teams/${mockTeam.id}/upgrade?session_id=cs_789`;
|
|
vi.spyOn(teamBillingService, "checkIfTeamPaymentRequired").mockResolvedValue({
|
|
url: mockUrl,
|
|
paymentId: "cs_789",
|
|
paymentRequired: false,
|
|
});
|
|
|
|
const result = await teamBillingService.publish();
|
|
|
|
expect(result).toEqual({
|
|
redirectUrl: mockUrl,
|
|
status: TeamBillingPublishResponseStatus.REQUIRES_UPGRADE,
|
|
});
|
|
expect(teamBillingService.checkIfTeamPaymentRequired).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("updateQuantity", () => {
|
|
it("should resolve and delegate to the seat billing strategy", async () => {
|
|
const strategy = createMockStrategy();
|
|
const resolver = createMockFactory(strategy);
|
|
const mockTeamNotOrg = { ...mockTeam, isOrganization: false };
|
|
|
|
const teamBillingService = new TeamBillingService({
|
|
team: mockTeamNotOrg,
|
|
billingProviderService: mockBillingProviderService,
|
|
teamBillingDataRepository: mockTeamBillingDataRepository,
|
|
billingRepository: mockBillingRepository,
|
|
seatBillingStrategyFactory: resolver,
|
|
});
|
|
|
|
prismaMock.membership.count.mockResolvedValue(10);
|
|
vi.spyOn(teamBillingService, "checkIfTeamPaymentRequired").mockResolvedValue({
|
|
url: "http://checkout.url",
|
|
paymentId: "cs_789",
|
|
paymentRequired: false,
|
|
});
|
|
|
|
await teamBillingService.updateQuantity("addition");
|
|
|
|
expect(resolver.createByTeamId).toHaveBeenCalledWith(mockTeamNotOrg.id);
|
|
expect(strategy.onSeatChange).toHaveBeenCalledWith({
|
|
teamId: mockTeamNotOrg.id,
|
|
subscriptionId: "sub_123",
|
|
subscriptionItemId: "si_456",
|
|
membershipCount: 10,
|
|
changeType: "addition",
|
|
});
|
|
});
|
|
|
|
it("should pass correct membership count to strategy", async () => {
|
|
const strategy = createMockStrategy();
|
|
const resolver = createMockFactory(strategy);
|
|
|
|
const teamBillingService = new TeamBillingService({
|
|
team: mockTeam,
|
|
billingProviderService: mockBillingProviderService,
|
|
teamBillingDataRepository: mockTeamBillingDataRepository,
|
|
billingRepository: mockBillingRepository,
|
|
seatBillingStrategyFactory: resolver,
|
|
});
|
|
|
|
prismaMock.membership.count.mockResolvedValue(7);
|
|
vi.spyOn(teamBillingService, "checkIfTeamPaymentRequired").mockResolvedValue({
|
|
url: "http://checkout.url",
|
|
paymentId: "cs_789",
|
|
paymentRequired: false,
|
|
});
|
|
|
|
await teamBillingService.updateQuantity("removal");
|
|
|
|
expect(strategy.onSeatChange).toHaveBeenCalledWith(
|
|
expect.objectContaining({ membershipCount: 7, changeType: "removal" })
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("checkIfTeamPaymentRequired", () => {
|
|
it("should return payment required if no paymentId", async () => {
|
|
const teamWithoutPaymentId = {
|
|
...mockTeam,
|
|
metadata: {
|
|
...mockTeam.metadata,
|
|
paymentId: undefined,
|
|
},
|
|
};
|
|
const teamBillingService = new TeamBillingService({
|
|
team: teamWithoutPaymentId,
|
|
billingProviderService: mockBillingProviderService,
|
|
teamBillingDataRepository: mockTeamBillingDataRepository,
|
|
billingRepository: mockBillingRepository,
|
|
seatBillingStrategyFactory: defaultResolver,
|
|
});
|
|
|
|
const result = await teamBillingService.checkIfTeamPaymentRequired();
|
|
|
|
expect(result).toEqual({ url: null, paymentId: null, paymentRequired: true });
|
|
});
|
|
|
|
it("should return payment required if checkout session is not paid", async () => {
|
|
const teamBillingService = new TeamBillingService({
|
|
team: mockTeam,
|
|
billingProviderService: mockBillingProviderService,
|
|
teamBillingDataRepository: mockTeamBillingDataRepository,
|
|
billingRepository: mockBillingRepository,
|
|
seatBillingStrategyFactory: defaultResolver,
|
|
});
|
|
|
|
vi.mocked(mockBillingProviderService.checkoutSessionIsPaid).mockResolvedValue(false);
|
|
|
|
const result = await teamBillingService.checkIfTeamPaymentRequired();
|
|
|
|
expect(result).toEqual({ url: null, paymentId: "cs_789", paymentRequired: true });
|
|
});
|
|
|
|
it("should return upgrade URL if checkout session is paid", async () => {
|
|
const teamBillingService = new TeamBillingService({
|
|
team: mockTeam,
|
|
billingProviderService: mockBillingProviderService,
|
|
teamBillingDataRepository: mockTeamBillingDataRepository,
|
|
billingRepository: mockBillingRepository,
|
|
seatBillingStrategyFactory: defaultResolver,
|
|
});
|
|
|
|
vi.mocked(mockBillingProviderService.checkoutSessionIsPaid).mockResolvedValue(true);
|
|
const result = await teamBillingService.checkIfTeamPaymentRequired();
|
|
|
|
expect(result).toEqual({
|
|
url: `${WEBAPP_URL}/api/teams/1/upgrade?session_id=cs_789`,
|
|
paymentId: "cs_789",
|
|
paymentRequired: false,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("saveTeamBilling", () => {
|
|
it("should delegate to billing repository when team is an organization", async () => {
|
|
const mockOrgTeam = {
|
|
id: 1,
|
|
metadata: {},
|
|
isOrganization: true,
|
|
parentId: null,
|
|
};
|
|
|
|
const mockBillingArgs = {
|
|
teamId: 1,
|
|
subscriptionId: "sub_org_123",
|
|
subscriptionItemId: "si_org_123",
|
|
customerId: "cus_org_123",
|
|
planName: Plan.ORGANIZATION,
|
|
status: SubscriptionStatus.ACTIVE,
|
|
};
|
|
|
|
const mockCreatedRecord = {
|
|
id: "billing_org_123",
|
|
...mockBillingArgs,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
};
|
|
|
|
vi.mocked(mockBillingRepository.create).mockResolvedValue(mockCreatedRecord);
|
|
|
|
const teamBillingService = new TeamBillingService({
|
|
team: mockOrgTeam,
|
|
billingProviderService: mockBillingProviderService,
|
|
teamBillingDataRepository: mockTeamBillingDataRepository,
|
|
billingRepository: mockBillingRepository,
|
|
seatBillingStrategyFactory: defaultResolver,
|
|
});
|
|
|
|
await teamBillingService.saveTeamBilling(mockBillingArgs);
|
|
|
|
expect(mockBillingRepository.create).toHaveBeenCalledWith(mockBillingArgs);
|
|
});
|
|
|
|
it("should delegate to billing repository when team is not an organization", async () => {
|
|
const mockRegularTeam = {
|
|
id: 2,
|
|
metadata: {},
|
|
isOrganization: false,
|
|
parentId: null,
|
|
};
|
|
|
|
const mockBillingArgs = {
|
|
teamId: 2,
|
|
subscriptionId: "sub_team_456",
|
|
subscriptionItemId: "si_team_456",
|
|
customerId: "cus_team_456",
|
|
planName: Plan.TEAM,
|
|
status: SubscriptionStatus.ACTIVE,
|
|
};
|
|
|
|
const mockCreatedRecord = {
|
|
id: "billing_team_456",
|
|
...mockBillingArgs,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
};
|
|
|
|
vi.mocked(mockBillingRepository.create).mockResolvedValue(mockCreatedRecord);
|
|
|
|
const teamBillingService = new TeamBillingService({
|
|
team: mockRegularTeam,
|
|
billingProviderService: mockBillingProviderService,
|
|
teamBillingDataRepository: mockTeamBillingDataRepository,
|
|
billingRepository: mockBillingRepository,
|
|
seatBillingStrategyFactory: defaultResolver,
|
|
});
|
|
|
|
await teamBillingService.saveTeamBilling(mockBillingArgs);
|
|
|
|
expect(mockBillingRepository.create).toHaveBeenCalledWith(mockBillingArgs);
|
|
});
|
|
|
|
it("should pass all billing arguments correctly to repository", async () => {
|
|
const mockTeam = {
|
|
id: 3,
|
|
metadata: {},
|
|
isOrganization: false,
|
|
parentId: null,
|
|
};
|
|
|
|
const mockBillingArgs = {
|
|
teamId: 3,
|
|
subscriptionId: "sub_detailed_789",
|
|
subscriptionItemId: "si_detailed_789",
|
|
customerId: "cus_detailed_789",
|
|
planName: Plan.ENTERPRISE,
|
|
status: SubscriptionStatus.TRIALING,
|
|
};
|
|
|
|
const mockCreatedRecord = {
|
|
id: "billing_detailed_789",
|
|
...mockBillingArgs,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
};
|
|
|
|
vi.mocked(mockBillingRepository.create).mockResolvedValue(mockCreatedRecord);
|
|
|
|
const teamBillingService = new TeamBillingService({
|
|
team: mockTeam,
|
|
billingProviderService: mockBillingProviderService,
|
|
teamBillingDataRepository: mockTeamBillingDataRepository,
|
|
billingRepository: mockBillingRepository,
|
|
seatBillingStrategyFactory: defaultResolver,
|
|
});
|
|
|
|
await teamBillingService.saveTeamBilling(mockBillingArgs);
|
|
|
|
expect(mockBillingRepository.create).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
teamId: 3,
|
|
subscriptionId: "sub_detailed_789",
|
|
subscriptionItemId: "si_detailed_789",
|
|
customerId: "cus_detailed_789",
|
|
planName: Plan.ENTERPRISE,
|
|
status: SubscriptionStatus.TRIALING,
|
|
})
|
|
);
|
|
});
|
|
|
|
it("should propagate repository errors", async () => {
|
|
const mockTeam = {
|
|
id: 4,
|
|
metadata: {},
|
|
isOrganization: false,
|
|
parentId: null,
|
|
};
|
|
|
|
const mockBillingArgs = {
|
|
teamId: 4,
|
|
subscriptionId: "sub_error_999",
|
|
subscriptionItemId: "si_error_999",
|
|
customerId: "cus_error_999",
|
|
planName: Plan.TEAM,
|
|
status: SubscriptionStatus.ACTIVE,
|
|
};
|
|
|
|
const repositoryError = new Error("Database constraint violation");
|
|
vi.mocked(mockBillingRepository.create).mockRejectedValue(repositoryError);
|
|
|
|
const teamBillingService = new TeamBillingService({
|
|
team: mockTeam,
|
|
billingProviderService: mockBillingProviderService,
|
|
teamBillingDataRepository: mockTeamBillingDataRepository,
|
|
billingRepository: mockBillingRepository,
|
|
seatBillingStrategyFactory: defaultResolver,
|
|
});
|
|
|
|
await expect(teamBillingService.saveTeamBilling(mockBillingArgs)).rejects.toThrow(
|
|
"Database constraint violation"
|
|
);
|
|
});
|
|
});
|
|
});
|