feat: /skip trial for teams (#20753)

* skip team trials

* add loading state

* update mocks

* cleanup

* add import oops

* fix tests files

---------

Co-authored-by: Sean Brydon <sean@Seans-Mac-mini.local>
Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
This commit is contained in:
sean-brydon
2025-04-29 11:48:41 +00:00
committed by GitHub
co-authored by Sean Brydon Carina Wollendorfer
parent 81b4651d6e
commit 3296255f80
9 changed files with 273 additions and 0 deletions
@@ -3153,6 +3153,8 @@
"custom_reply_to_email_title": "Custom 'Reply-To' email",
"custom_reply_to_email_description": "Use a different email address as the replyTo for confirmation emails instead of the organizer's email",
"email_survey_triggered_by_workflow": "This survey was triggered by a Workflow in Cal.",
"skip_trial": "Skip trial period",
"team_trials_skipped_successfully": "Team trials skipped successfully",
"sms_workflow_consent": "By entering your phone number you consent to receive SMS messages for this event. SMS rates may apply.",
"sms_opt_out_message": "Text STOP to opt-out of SMS messages",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
@@ -9,6 +9,7 @@ export interface BillingService {
subscriptionItemId: string;
membershipCount: number;
}): Promise<void>;
handleEndTrial(subscriptionId: string): Promise<void>;
// Customer management
createCustomer(args: {
@@ -120,6 +120,18 @@ export class StripeBillingService implements BillingService {
});
}
async handleEndTrial(subscriptionId: string) {
const subscription = await this.stripe.subscriptions.retrieve(subscriptionId);
if (subscription.status !== "trialing") {
return; // Do nothing if not in trial
}
await this.stripe.subscriptions.update(subscriptionId, {
trial_end: "now",
});
}
async checkoutSessionIsPaid(paymentId: string) {
const checkoutSession = await this.stripe.checkout.sessions.retrieve(paymentId);
return checkoutSession.payment_status === "paid";
@@ -181,4 +181,28 @@ export class InternalTeamBilling implements TeamBilling {
if (!subscriptionId) return null;
return await billing.getSubscriptionStatus(subscriptionId);
}
/**
* Ends the trial period for a team subscription by converting it to a regular subscription
* @returns {Promise<boolean>} True if successful, false otherwise
*/
async endTrial() {
try {
const { subscriptionId } = this.team.metadata;
log.info(`Ending trial for subscription ${subscriptionId} of team ${this.team.id}`);
if (!subscriptionId) {
log.warn(`No subscription ID found for team ${this.team.id}`);
return false;
}
// End the trial by converting to regular subscription
await billing.handleEndTrial(subscriptionId);
log.info(`Successfully ended trial for team ${this.team.id}`);
return true;
} catch (error) {
this.logErrorFromUnknown(error);
return false;
}
}
}
@@ -1,7 +1,9 @@
import type { User as UserAuth } from "next-auth";
import { IS_DUB_REFERRALS_ENABLED } from "@calcom/lib/constants";
import { useHasActiveTeamPlan } from "@calcom/lib/hooks/useHasPaidPlan";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { showToast } from "@calcom/ui/components/toast";
import { type NavigationItemType } from "./navigation/NavigationItem";
@@ -18,8 +20,33 @@ export function useBottomNavItems({
user,
}: BottomNavItemsProps): NavigationItemType[] {
const { t } = useLocale();
const { isTrial } = useHasActiveTeamPlan();
const utils = trpc.useUtils();
const skipTeamTrialsMutation = trpc.viewer.teams.skipTeamTrials.useMutation({
onSuccess: () => {
utils.viewer.teams.hasActiveTeamPlan.invalidate();
showToast(t("team_trials_skipped_successfully"), "success");
},
onError: () => {
showToast(t("something_went_wrong"), "error");
},
});
return [
// Render above to prevent layout shift as much as possible
isTrial
? {
name: "skip_trial",
href: "",
isLoading: skipTeamTrialsMutation.isPending,
icon: "clock",
onClick: (e: { preventDefault: () => void }) => {
e.preventDefault();
skipTeamTrialsMutation.mutate({});
},
}
: null,
{
name: "view_public_page",
href: publicPageUrl,
@@ -43,6 +70,7 @@ export function useBottomNavItems({
icon: "gift",
}
: null,
isAdmin
? {
name: "impersonation",
@@ -28,6 +28,7 @@ import { ZGetRoundRobinHostsInputSchema } from "./roundRobin/getRoundRobinHostsT
import { ZRoundRobinManualReassignInputSchema } from "./roundRobin/roundRobinManualReassign.schema";
import { ZRoundRobinReassignInputSchema } from "./roundRobin/roundRobinReassign.schema";
import { ZSetInviteExpirationInputSchema } from "./setInviteExpiration.schema";
import { ZSkipTeamTrialsInputSchema } from "./skipTeamTrials.schema";
import { ZUpdateInputSchema } from "./update.schema";
import { ZUpdateInternalNotesPresetsInputSchema } from "./updateInternalNotesPresets.schema";
import { ZUpdateMembershipInputSchema } from "./updateMembership.schema";
@@ -190,4 +191,8 @@ export const viewerTeamsRouter = router({
const { default: handler } = await import("./hasActiveTeamPlan.handler");
return handler(opts);
}),
skipTeamTrials: authedProcedure.input(ZSkipTeamTrialsInputSchema).mutation(async (opts) => {
const { default: handler } = await import("./skipTeamTrials.handler");
return handler(opts);
}),
});
@@ -0,0 +1,62 @@
import { InternalTeamBilling } from "@calcom/ee/billing/teams/internal-team-billing";
import { IS_SELF_HOSTED } from "@calcom/lib/constants";
import logger from "@calcom/lib/logger";
import { prisma } from "@calcom/prisma";
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
import type { TSkipTeamTrialsInputSchema } from "./skipTeamTrials.schema";
const log = logger.getSubLogger({ prefix: ["skipTeamTrials"] });
type SkipTeamTrialsOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
input: TSkipTeamTrialsInputSchema;
};
export const skipTeamTrialsHandler = async ({ ctx }: SkipTeamTrialsOptions) => {
// If self-hosted, no need to skip trials as they're already handled differently
if (IS_SELF_HOSTED) return { success: true };
try {
await prisma.user.update({
where: {
id: ctx.user.id,
},
data: {
trialEndsAt: null,
},
});
const ownedTeams = await prisma.team.findMany({
where: {
members: {
some: {
userId: ctx.user.id,
accepted: true,
role: "OWNER",
},
},
},
});
for (const team of ownedTeams) {
const teamBillingService = new InternalTeamBilling(team);
const subscriptionStatus = await teamBillingService.getSubscriptionStatus();
if (subscriptionStatus === "trialing") {
await teamBillingService.endTrial();
log.info(`Ended trial for team ${team.id}`);
}
}
return { success: true };
} catch (error) {
log.error("Error skipping team trials", error);
return { success: false, error: "Failed to skip team trials" };
}
};
export default skipTeamTrialsHandler;
@@ -0,0 +1,5 @@
import { z } from "zod";
export const ZSkipTeamTrialsInputSchema = z.object({});
export type TSkipTeamTrialsInputSchema = z.infer<typeof ZSkipTeamTrialsInputSchema>;
@@ -0,0 +1,134 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { InternalTeamBilling } from "@calcom/ee/billing/teams/internal-team-billing";
import { prisma } from "@calcom/prisma";
import { skipTeamTrialsHandler } from "./skipTeamTrials.handler";
// Instead of completely mocking the handler, we'll use the real implementation
// and mock its dependencies
vi.mock("./skipTeamTrials.handler", async () => {
const actual = await vi.importActual<typeof import("./skipTeamTrials.handler")>("./skipTeamTrials.handler");
return {
...actual,
// We keep this for spying purposes
skipTeamTrialsHandler: vi.fn().mockImplementation(actual.skipTeamTrialsHandler),
};
});
// Mock dependencies
vi.mock("@calcom/lib/constants", () => ({
IS_SELF_HOSTED: false,
IS_PRODUCTION: false,
CALCOM_ENV: "test",
WEBAPP_URL: "http://localhost:3000",
IS_CALCOM: false,
HOSTED_CAL_FEATURES: true,
}));
vi.mock("@calcom/prisma", () => ({
prisma: {
user: {
update: vi.fn().mockResolvedValue({}),
},
team: {
findMany: vi.fn(),
},
},
}));
vi.mock("@calcom/lib/logger", () => ({
default: {
getSubLogger: () => ({
info: vi.fn(),
error: vi.fn(),
}),
},
}));
const mockGetSubscriptionStatus = vi.fn();
const mockEndTrial = vi.fn().mockResolvedValue(true);
vi.mock("@calcom/ee/billing/teams/internal-team-billing", () => ({
InternalTeamBilling: vi.fn().mockImplementation(() => ({
getSubscriptionStatus: mockGetSubscriptionStatus,
endTrial: mockEndTrial,
})),
}));
describe("skipTeamTrialsHandler", () => {
const mockCtx = {
user: {
id: 1,
email: "test@example.com",
},
};
beforeEach(() => {
vi.clearAllMocks();
});
it("should set user's trialEndsAt to null", async () => {
vi.mocked(prisma.team.findMany).mockResolvedValueOnce([]);
// @ts-expect-error - simplified context for testing
await skipTeamTrialsHandler({ ctx: mockCtx, input: {} });
expect(prisma.user.update).toHaveBeenCalledWith({
where: {
id: 1,
},
data: {
trialEndsAt: null,
},
});
});
it("should end trials for all teams where user is OWNER", async () => {
// Mock teams where user is owner
const mockTeams = [
{ id: 101, name: "Team 1" },
{ id: 102, name: "Team 2" },
];
vi.mocked(prisma.team.findMany).mockResolvedValueOnce(mockTeams);
mockGetSubscriptionStatus
.mockResolvedValueOnce("trialing") // First team is in trial
.mockResolvedValueOnce("active"); // Second team is active
// @ts-expect-error - simplified context for testing
await skipTeamTrialsHandler({ ctx: mockCtx, input: {} });
expect(prisma.user.update).toHaveBeenCalled();
expect(prisma.team.findMany).toHaveBeenCalledWith({
where: {
members: {
some: {
userId: mockCtx.user.id,
accepted: true,
role: "OWNER",
},
},
},
});
expect(InternalTeamBilling).toHaveBeenCalledTimes(2);
expect(InternalTeamBilling).toHaveBeenNthCalledWith(1, mockTeams[0]);
expect(InternalTeamBilling).toHaveBeenNthCalledWith(2, mockTeams[1]);
expect(mockGetSubscriptionStatus).toHaveBeenCalledTimes(2);
expect(mockEndTrial).toHaveBeenCalledTimes(1);
});
it("should handle errors gracefully", async () => {
vi.mocked(prisma.user.update).mockRejectedValueOnce(new Error("Database error"));
// @ts-expect-error - simplified context for testing
const result = await skipTeamTrialsHandler({ ctx: mockCtx, input: {} });
expect(result).toEqual({ success: false, error: "Failed to skip team trials" });
});
});