From 3296255f80f5fe6552cb2f2eb07725c89322726d Mon Sep 17 00:00:00 2001 From: sean-brydon <55134778+sean-brydon@users.noreply.github.com> Date: Tue, 29 Apr 2025 12:48:41 +0100 Subject: [PATCH] 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 Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com> --- apps/web/public/static/locales/en/common.json | 2 + .../features/ee/billing/billing-service.ts | 1 + .../ee/billing/stripe-billling-service.ts | 12 ++ .../ee/billing/teams/internal-team-billing.ts | 24 ++++ packages/features/shell/useBottomNavItems.ts | 28 ++++ .../server/routers/viewer/teams/_router.tsx | 5 + .../viewer/teams/skipTeamTrials.handler.ts | 62 ++++++++ .../viewer/teams/skipTeamTrials.schema.ts | 5 + .../viewer/teams/skipTeamTrials.test.ts | 134 ++++++++++++++++++ 9 files changed, 273 insertions(+) create mode 100644 packages/trpc/server/routers/viewer/teams/skipTeamTrials.handler.ts create mode 100644 packages/trpc/server/routers/viewer/teams/skipTeamTrials.schema.ts create mode 100644 packages/trpc/server/routers/viewer/teams/skipTeamTrials.test.ts diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 27f6b51fb9..0f674ccd84 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -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 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" diff --git a/packages/features/ee/billing/billing-service.ts b/packages/features/ee/billing/billing-service.ts index 28b714929f..a3730ad30d 100644 --- a/packages/features/ee/billing/billing-service.ts +++ b/packages/features/ee/billing/billing-service.ts @@ -9,6 +9,7 @@ export interface BillingService { subscriptionItemId: string; membershipCount: number; }): Promise; + handleEndTrial(subscriptionId: string): Promise; // Customer management createCustomer(args: { diff --git a/packages/features/ee/billing/stripe-billling-service.ts b/packages/features/ee/billing/stripe-billling-service.ts index 6f607a9c6c..585cf3be08 100644 --- a/packages/features/ee/billing/stripe-billling-service.ts +++ b/packages/features/ee/billing/stripe-billling-service.ts @@ -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"; diff --git a/packages/features/ee/billing/teams/internal-team-billing.ts b/packages/features/ee/billing/teams/internal-team-billing.ts index dea5c54d61..fd623b60bc 100644 --- a/packages/features/ee/billing/teams/internal-team-billing.ts +++ b/packages/features/ee/billing/teams/internal-team-billing.ts @@ -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} 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; + } + } } diff --git a/packages/features/shell/useBottomNavItems.ts b/packages/features/shell/useBottomNavItems.ts index 56b54f2955..38a942e026 100644 --- a/packages/features/shell/useBottomNavItems.ts +++ b/packages/features/shell/useBottomNavItems.ts @@ -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", diff --git a/packages/trpc/server/routers/viewer/teams/_router.tsx b/packages/trpc/server/routers/viewer/teams/_router.tsx index 783c5ceda9..d3e553f988 100644 --- a/packages/trpc/server/routers/viewer/teams/_router.tsx +++ b/packages/trpc/server/routers/viewer/teams/_router.tsx @@ -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); + }), }); diff --git a/packages/trpc/server/routers/viewer/teams/skipTeamTrials.handler.ts b/packages/trpc/server/routers/viewer/teams/skipTeamTrials.handler.ts new file mode 100644 index 0000000000..7edefad5a6 --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/skipTeamTrials.handler.ts @@ -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; + }; + 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; diff --git a/packages/trpc/server/routers/viewer/teams/skipTeamTrials.schema.ts b/packages/trpc/server/routers/viewer/teams/skipTeamTrials.schema.ts new file mode 100644 index 0000000000..27ed513011 --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/skipTeamTrials.schema.ts @@ -0,0 +1,5 @@ +import { z } from "zod"; + +export const ZSkipTeamTrialsInputSchema = z.object({}); + +export type TSkipTeamTrialsInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/teams/skipTeamTrials.test.ts b/packages/trpc/server/routers/viewer/teams/skipTeamTrials.test.ts new file mode 100644 index 0000000000..109a95eb59 --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/skipTeamTrials.test.ts @@ -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("./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" }); + }); +});