Merge branch 'v2/teams-billing' of https://github.com/calcom/cal.com into v2/teams-billing
This commit is contained in:
parent
a4f435b9fb
commit
e650112a35
|
@ -93,7 +93,6 @@ NEXT_PUBLIC_IS_E2E=
|
|||
|
||||
# Used for internal billing system
|
||||
STRIPE_TEAM_MONTHLY_PRICE_ID=
|
||||
STRIPE_TEAM_YEARLY_PRICE_ID=
|
||||
STRIPE_PRIVATE_KEY=
|
||||
STRIPE_WEBHOOK_SECRET=
|
||||
STRIPE_CLIENT_ID=
|
||||
|
|
|
@ -1,73 +0,0 @@
|
|||
import { useRef, useState } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Button } from "@calcom/ui";
|
||||
import { Icon } from "@calcom/ui/Icon";
|
||||
import { Alert } from "@calcom/ui/v2/core/Alert";
|
||||
import { Dialog, DialogContent, DialogFooter } from "@calcom/ui/v2/core/Dialog";
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function TeamCreate(props: Props) {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useContext();
|
||||
const [errorMessage, setErrorMessage] = useState<null | string>(null);
|
||||
const nameRef = useRef<HTMLInputElement>() as React.MutableRefObject<HTMLInputElement>;
|
||||
|
||||
const createTeamMutation = trpc.useMutation("viewer.teams.create", {
|
||||
onSuccess: () => {
|
||||
utils.invalidateQueries(["viewer.teams.list"]);
|
||||
props.onClose();
|
||||
},
|
||||
onError: (e) => {
|
||||
setErrorMessage(e?.message || t("something_went_wrong"));
|
||||
},
|
||||
});
|
||||
|
||||
const createTeam = () => {
|
||||
createTeamMutation.mutate({ name: nameRef?.current?.value });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={props.isOpen} onOpenChange={props.onClose}>
|
||||
<DialogContent type="creation" actionText={t("create_new_team")} actionOnClick={createTeam}>
|
||||
<div className="mb-4 sm:flex sm:items-start">
|
||||
<div className="bg-brand text-brandcontrast dark:bg-darkmodebrand dark:text-darkmodebrandcontrast mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-opacity-5 sm:mx-0 sm:h-10 sm:w-10">
|
||||
<Icon.FiUsers className="text-brandcontrast h-6 w-6" />
|
||||
</div>
|
||||
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
|
||||
<h3 className="text-lg font-medium leading-6 text-gray-900" id="modal-title">
|
||||
{t("create_new_team")}
|
||||
</h3>
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">{t("create_new_team_description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form>
|
||||
<div className="mb-4">
|
||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
|
||||
{t("name")}
|
||||
</label>
|
||||
<input
|
||||
ref={nameRef}
|
||||
type="text"
|
||||
name="name"
|
||||
id="name"
|
||||
placeholder="Acme Inc."
|
||||
required
|
||||
className="mt-1 block w-full rounded-sm border border-gray-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
{errorMessage && <Alert severity="error" title={errorMessage} />}
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -1,5 +1,6 @@
|
|||
import { useState } from "react";
|
||||
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Icon } from "@calcom/ui/Icon";
|
||||
|
@ -9,12 +10,10 @@ import Button from "@calcom/ui/v2/core/Button";
|
|||
import EmptyScreen from "@calcom/ui/v2/core/EmptyScreen";
|
||||
|
||||
import SkeletonLoaderTeamList from "@components/team/SkeletonloaderTeamList";
|
||||
import TeamCreateModal from "@components/team/TeamCreateModal";
|
||||
import TeamList from "@components/team/TeamList";
|
||||
|
||||
function Teams() {
|
||||
const { t } = useLocale();
|
||||
const [showCreateTeamModal, setShowCreateTeamModal] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
|
||||
const { data, isLoading } = trpc.useQuery(["viewer.teams.list"], {
|
||||
|
@ -25,22 +24,22 @@ function Teams() {
|
|||
|
||||
const teams = data?.filter((m) => m.accepted) || [];
|
||||
const invites = data?.filter((m) => !m.accepted) || [];
|
||||
const handleNewTeam = () => {
|
||||
// Hey
|
||||
};
|
||||
|
||||
return (
|
||||
<Shell
|
||||
heading={t("teams")}
|
||||
subtitle={t("create_manage_teams_collaborative")}
|
||||
CTA={
|
||||
<Button type="button" onClick={() => setShowCreateTeamModal(true)}>
|
||||
<Button type="button" href={`${WEBAPP_URL}/settings/teams/new`}>
|
||||
<Icon.FiPlus className="inline-block h-3.5 w-3.5 text-white group-hover:text-black ltr:mr-2 rtl:ml-2" />
|
||||
{t("new")}
|
||||
</Button>
|
||||
}>
|
||||
<>
|
||||
{!!errorMessage && <Alert severity="error" title={errorMessage} />}
|
||||
{showCreateTeamModal && (
|
||||
<TeamCreateModal isOpen={showCreateTeamModal} onClose={() => setShowCreateTeamModal(false)} />
|
||||
)}
|
||||
{invites.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<h1 className="mb-2 text-lg font-medium">{t("open_invitations")}</h1>
|
||||
|
@ -54,11 +53,10 @@ function Teams() {
|
|||
headline={t("no_teams")}
|
||||
description={t("no_teams_description")}
|
||||
buttonRaw={
|
||||
<Button color="secondary" onClick={() => setShowCreateTeamModal(true)}>
|
||||
<Button color="secondary" href={`${WEBAPP_URL}/settings/teams/new`}>
|
||||
{t("create_team")}
|
||||
</Button>
|
||||
}
|
||||
buttonOnClick={() => setShowCreateTeamModal(true)}
|
||||
/>
|
||||
)}
|
||||
{teams.length > 0 && <TeamList teams={teams} />}
|
||||
|
|
|
@ -2,7 +2,6 @@ import DailyIframe from "@daily-co/daily-js";
|
|||
import { NextPageContext } from "next";
|
||||
import { getSession } from "next-auth/react";
|
||||
import Head from "next/head";
|
||||
import Link from "next/link";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { SEO_IMG_OGIMG_VIDEO, WEBSITE_URL } from "@calcom/lib/constants";
|
||||
|
@ -64,9 +63,6 @@ export default function JoinCall(props: JoinCallPageProps) {
|
|||
<meta property="twitter:description" content={t("quick_video_meeting")} />
|
||||
</Head>
|
||||
<div style={{ zIndex: 2, position: "relative" }}>
|
||||
<Link href="/" passHref>
|
||||
{
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
className="h-5·w-auto fixed z-10 hidden sm:inline-block"
|
||||
src={`${WEBSITE_URL}/cal-logo-word-dark.svg`}
|
||||
|
@ -76,8 +72,6 @@ export default function JoinCall(props: JoinCallPageProps) {
|
|||
left: 24,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -49,7 +49,8 @@ export async function scheduleTrigger(
|
|||
|
||||
export async function cancelScheduledJobs(
|
||||
booking: { uid: string; scheduledJobs?: string[] },
|
||||
appId?: string | null
|
||||
appId?: string | null,
|
||||
isReschedule?: boolean
|
||||
) {
|
||||
let scheduledJobs = booking.scheduledJobs || [];
|
||||
|
||||
|
@ -70,6 +71,7 @@ export async function cancelScheduledJobs(
|
|||
scheduledJobs = [];
|
||||
}
|
||||
|
||||
if (!isReschedule) {
|
||||
await prisma.booking.update({
|
||||
where: {
|
||||
uid: booking.uid,
|
||||
|
@ -78,6 +80,7 @@ export async function cancelScheduledJobs(
|
|||
scheduledJobs: scheduledJobs,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -887,7 +887,7 @@ async function handler(req: NextApiRequest & { userId?: number | undefined }) {
|
|||
|
||||
subscribersMeetingEnded.forEach((subscriber) => {
|
||||
if (rescheduleUid && originalRescheduledBooking) {
|
||||
cancelScheduledJobs(originalRescheduledBooking);
|
||||
cancelScheduledJobs(originalRescheduledBooking, undefined, true);
|
||||
}
|
||||
if (booking && booking.status === BookingStatus.ACCEPTED) {
|
||||
scheduleTrigger(booking, subscriber.subscriberUrl, subscriber);
|
||||
|
|
|
@ -2,14 +2,12 @@ import { useRouter } from "next/router";
|
|||
import { Suspense, useState } from "react";
|
||||
|
||||
import MemberInvitationModal from "@calcom/features/ee/teams/components/MemberInvitationModal";
|
||||
import { BillingFrequency } from "@calcom/features/ee/teams/payments";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Icon } from "@calcom/ui";
|
||||
import { Avatar, Badge, Button, showToast } from "@calcom/ui/v2/core";
|
||||
import { Label } from "@calcom/ui/v2/core/form";
|
||||
import { SkeletonContainer, SkeletonText } from "@calcom/ui/v2/core/skeleton";
|
||||
|
||||
const AddNewTeamMemberSkeleton = () => {
|
||||
|
@ -53,7 +51,6 @@ const AddNewTeamMembers = ({ teamId }: { teamId: number }) => {
|
|||
});
|
||||
|
||||
const [memberInviteModal, setMemberInviteModal] = useState(false);
|
||||
const [billingFrequency, setBillingFrequency] = useState<BillingFrequency>("monthly");
|
||||
|
||||
if (isLoading) return <AddNewTeamMemberSkeleton />;
|
||||
|
||||
|
@ -124,34 +121,6 @@ const AddNewTeamMembers = ({ teamId }: { teamId: number }) => {
|
|||
/>
|
||||
)}
|
||||
|
||||
<>
|
||||
<Label className="font-sm mt-8 text-gray-900">
|
||||
<>{t("billing_frequency")}</>
|
||||
</Label>
|
||||
<div className="flex rounded-md border">
|
||||
<div
|
||||
className={classNames(
|
||||
"px-1/2 w-1/2 rounded-md py-2.5 text-center font-medium text-gray-900",
|
||||
billingFrequency === "monthly" && "bg-gray-200"
|
||||
)}
|
||||
onClick={() => {
|
||||
setBillingFrequency("monthly");
|
||||
}}>
|
||||
<p>{t("monthly")}</p>
|
||||
</div>
|
||||
<div
|
||||
className={classNames(
|
||||
"px-1/2 w-1/2 rounded-md py-2.5 text-center font-medium text-gray-900",
|
||||
billingFrequency === "yearly" && "bg-gray-200"
|
||||
)}
|
||||
onClick={() => {
|
||||
setBillingFrequency("yearly");
|
||||
}}>
|
||||
<p>{t("yearly")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
<hr className="my-6 border-neutral-200" />
|
||||
|
||||
<Button
|
||||
|
@ -159,7 +128,7 @@ const AddNewTeamMembers = ({ teamId }: { teamId: number }) => {
|
|||
className="mt-6 w-full justify-center"
|
||||
onClick={() => {
|
||||
if (team) {
|
||||
teamCheckoutMutation.mutate({ teamId, billingFrequency, seats: team.members.length });
|
||||
teamCheckoutMutation.mutate({ teamId, seats: team.members.length });
|
||||
} else {
|
||||
showToast(t("error_creating_team"), "error");
|
||||
}
|
||||
|
|
|
@ -1,27 +1,20 @@
|
|||
import { Prisma } from "@prisma/client";
|
||||
|
||||
import stripe from "@calcom/app-store/stripepayment/lib/server";
|
||||
import { CAL_URL } from "@calcom/lib/constants";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
export type BillingFrequency = "monthly" | "yearly";
|
||||
|
||||
export const purchaseTeamSubscription = async (
|
||||
teamId: number,
|
||||
billingFrequency: "monthly" | "yearly",
|
||||
seats: number,
|
||||
email: string
|
||||
) => {
|
||||
export const purchaseTeamSubscription = async (input: { teamId: number; seats: number; email: string }) => {
|
||||
const { teamId, seats, email } = input;
|
||||
return await stripe.checkout.sessions.create({
|
||||
mode: "subscription",
|
||||
success_url: `${CAL_URL}/settings/teams/${teamId}/profile`,
|
||||
cancel_url: `${CAL_URL}/settings/profile`,
|
||||
locale: "en",
|
||||
line_items: [
|
||||
{
|
||||
price:
|
||||
billingFrequency === "monthly"
|
||||
? process.env.STRIPE_TEAM_MONTHLY_PRICE_ID
|
||||
: process.env.STRIPE_TEAM_YEARLY_PRICE_ID,
|
||||
/** We only need to set the base price and we can upsell it directly on Stripe's checkout */
|
||||
price: process.env.STRIPE_TEAM_MONTHLY_PRICE_ID,
|
||||
quantity: seats,
|
||||
},
|
||||
],
|
||||
|
@ -48,29 +41,23 @@ export const getStripeIdsForTeam = async (teamId: number) => {
|
|||
},
|
||||
});
|
||||
|
||||
if (teamQuery?.metadata) {
|
||||
const teamStripeIds = teamQuery.metadata as Prisma.JsonObject;
|
||||
const teamStripeIds = { ...teamQuery.metadata };
|
||||
|
||||
return teamStripeIds;
|
||||
} else {
|
||||
throw new Error(`Team ${teamId} not found`);
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteTeamFromStripe = async (teamId: number) => {
|
||||
const teamQuery = await prisma.team.findFirst({
|
||||
const stripeCustomerId = await prisma.team.findFirst({
|
||||
where: {
|
||||
id: teamId,
|
||||
},
|
||||
select: { metadata: true },
|
||||
});
|
||||
|
||||
if (!teamQuery?.metadata) throw new Error(`Team ${teamId} not found`);
|
||||
const teamStripeIds = teamQuery.metadata as Prisma.JsonObject;
|
||||
|
||||
if (teamStripeIds.stripeCustomerId) {
|
||||
await stripe.customers.del(teamStripeIds.stripeCustomerId as string);
|
||||
if (stripeCustomerId?.metadata?.stripeCustomerId) {
|
||||
await stripe.customers.del(stripeCustomerId.metadata.stripeCustomerId);
|
||||
return;
|
||||
} else {
|
||||
throw new Error("Team not found");
|
||||
console.error(`Couldn't deleteTeamFromStripe, Team id: ${teamId} didn't have a stripeCustomerId`);
|
||||
}
|
||||
};
|
||||
|
|
|
@ -187,23 +187,49 @@ export default abstract class BaseCalendarService implements Calendar {
|
|||
additionalInfo: {},
|
||||
};
|
||||
}
|
||||
|
||||
let calendarEvent: CalendarEventType;
|
||||
const eventsToUpdate = events.filter((e) => e.uid === uid);
|
||||
return Promise.all(
|
||||
eventsToUpdate.map((e) => {
|
||||
eventsToUpdate.map((eventItem) => {
|
||||
calendarEvent = eventItem;
|
||||
return updateCalendarObject({
|
||||
calendarObject: {
|
||||
url: e.url,
|
||||
data: iCalString,
|
||||
etag: e?.etag,
|
||||
url: calendarEvent.url,
|
||||
// ensures compliance with standard iCal string (known as iCal2.0 by some) required by various providers
|
||||
data: iCalString?.replace(/METHOD:[^\r\n]+\r\n/g, ""),
|
||||
etag: calendarEvent?.etag,
|
||||
},
|
||||
headers: this.headers,
|
||||
});
|
||||
})
|
||||
).then((p) => p.map((r) => r.json() as unknown as NewCalendarEventType));
|
||||
).then((responses) =>
|
||||
responses.map((response) => {
|
||||
if (response.status >= 200 && response.status < 300) {
|
||||
return {
|
||||
uid,
|
||||
type: this.credentials.type,
|
||||
id: typeof calendarEvent.uid === "string" ? calendarEvent.uid : "-1",
|
||||
password: "",
|
||||
url: calendarEvent.url,
|
||||
additionalInfo:
|
||||
typeof event.additionalInformation === "string" ? event.additionalInformation : {},
|
||||
};
|
||||
} else {
|
||||
this.log.error("Error: Status Code", response.status);
|
||||
return {
|
||||
uid,
|
||||
type: event.type,
|
||||
id: typeof event.uid === "string" ? event.uid : "-1",
|
||||
password: "",
|
||||
url: typeof event.location === "string" ? event.location : "-1",
|
||||
additionalInfo:
|
||||
typeof event.additionalInformation === "string" ? event.additionalInformation : {},
|
||||
};
|
||||
}
|
||||
})
|
||||
);
|
||||
} catch (reason) {
|
||||
this.log.error(reason);
|
||||
|
||||
throw reason;
|
||||
}
|
||||
}
|
||||
|
@ -213,7 +239,6 @@ export default abstract class BaseCalendarService implements Calendar {
|
|||
const events = await this.getEventsByUID(uid);
|
||||
|
||||
const eventsToDelete = events.filter((event) => event.uid === uid);
|
||||
|
||||
await Promise.all(
|
||||
eventsToDelete.map((event) => {
|
||||
return deleteCalendarObject({
|
||||
|
@ -275,7 +300,7 @@ export default abstract class BaseCalendarService implements Calendar {
|
|||
const events: { start: string; end: string }[] = [];
|
||||
|
||||
objects.forEach((object) => {
|
||||
if (object.data == null) return;
|
||||
if (object.data == null || JSON.stringify(object.data) == "{}") return;
|
||||
|
||||
const jcalData = ICAL.parse(sanitizeCalendarObject(object));
|
||||
const vcalendar = new ICAL.Component(jcalData);
|
||||
|
@ -330,7 +355,7 @@ export default abstract class BaseCalendarService implements Calendar {
|
|||
} catch (error) {
|
||||
if (error instanceof Error && error.message !== currentError) {
|
||||
currentError = error.message;
|
||||
console.log("error", error);
|
||||
this.log.error("error", error);
|
||||
}
|
||||
}
|
||||
if (!currentEvent) return;
|
||||
|
|
|
@ -11,9 +11,9 @@ import {
|
|||
import { getUserAvailability } from "@calcom/core/getUserAvailability";
|
||||
import { sendTeamInviteEmail } from "@calcom/emails";
|
||||
import { deleteTeamFromStripe, purchaseTeamSubscription } from "@calcom/features/ee/teams/payments";
|
||||
import { HOSTED_CAL_FEATURES, WEBAPP_URL, IS_STRIPE_ENABLED } from "@calcom/lib/constants";
|
||||
import { HOSTED_CAL_FEATURES, IS_STRIPE_ENABLED, WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { getTranslation } from "@calcom/lib/server/i18n";
|
||||
import { getTeamWithMembers, isTeamAdmin, isTeamOwner, isTeamMember } from "@calcom/lib/server/queries/teams";
|
||||
import { getTeamWithMembers, isTeamAdmin, isTeamMember, isTeamOwner } from "@calcom/lib/server/queries/teams";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
import {
|
||||
closeComDeleteTeam,
|
||||
|
@ -78,35 +78,35 @@ export const viewerTeamsRouter = createProtectedRouter()
|
|||
.mutation("create", {
|
||||
input: z.object({
|
||||
name: z.string(),
|
||||
slug: z.string(),
|
||||
logo: z.string().optional().nullable(),
|
||||
slug: z.string().transform((val) => slugify(val.trim())),
|
||||
logo: z
|
||||
.string()
|
||||
.optional()
|
||||
.nullable()
|
||||
.transform((v) => v || null),
|
||||
}),
|
||||
async resolve({ ctx, input }) {
|
||||
const slug = input.slug || slugify(input.name);
|
||||
const { slug, name, logo } = input;
|
||||
|
||||
const nameCollisions = await ctx.prisma.team.findFirst({
|
||||
where: {
|
||||
OR: [{ name: input.name }, { slug: slug }],
|
||||
},
|
||||
where: { OR: [{ name }, { slug }] },
|
||||
});
|
||||
|
||||
if (nameCollisions) throw new TRPCError({ code: "BAD_REQUEST", message: "Team name already taken." });
|
||||
|
||||
const createTeam = await ctx.prisma.team.create({
|
||||
data: {
|
||||
name: input.name,
|
||||
slug: slug,
|
||||
logo: input.logo || null,
|
||||
},
|
||||
});
|
||||
|
||||
await ctx.prisma.membership.create({
|
||||
data: {
|
||||
teamId: createTeam.id,
|
||||
name,
|
||||
slug,
|
||||
logo,
|
||||
members: {
|
||||
create: {
|
||||
userId: ctx.user.id,
|
||||
role: MembershipRole.OWNER,
|
||||
accepted: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Sync Services: Close.com
|
||||
|
@ -482,18 +482,12 @@ export const viewerTeamsRouter = createProtectedRouter()
|
|||
.mutation("purchaseTeamSubscription", {
|
||||
input: z.object({
|
||||
teamId: z.number(),
|
||||
billingFrequency: z.union([z.literal("monthly"), z.literal("yearly")]),
|
||||
seats: z.number(),
|
||||
}),
|
||||
async resolve({ ctx, input }) {
|
||||
if (!IS_STRIPE_ENABLED)
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: "Team billing is not enabled" });
|
||||
return await purchaseTeamSubscription(
|
||||
input.teamId,
|
||||
input.billingFrequency,
|
||||
input.seats,
|
||||
ctx.user.email
|
||||
);
|
||||
return await purchaseTeamSubscription({ ...input, email: ctx.user.email });
|
||||
},
|
||||
})
|
||||
.query("getTeamSeats", {
|
||||
|
|
|
@ -40,7 +40,6 @@
|
|||
"$STRIPE_PREMIUM_PLAN_PRODUCT_ID",
|
||||
"$STRIPE_FREE_PLAN_PRODUCT_ID",
|
||||
"$STRIPE_TEAM_MONTHLY_PRICE_ID",
|
||||
"$STRIPE_TEAM_YEARLY_PRICE_ID",
|
||||
"$NEXT_PUBLIC_STRIPE_PUBLIC_KEY",
|
||||
"$NEXT_PUBLIC_WEBAPP_URL",
|
||||
"$NEXT_PUBLIC_WEBSITE_URL"
|
||||
|
|
Loading…
Reference in New Issue
Block a user