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:
co-authored by
Sean Brydon
Carina Wollendorfer
parent
81b4651d6e
commit
3296255f80
@@ -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" });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user