Merge branch 'v2/teams-billing' of https://github.com/calcom/cal.com into v2/teams-billing

This commit is contained in:
Joe Au-Yeung 2022-10-26 12:45:14 -04:00
parent a4f435b9fb
commit e650112a35
11 changed files with 101 additions and 206 deletions

View File

@ -92,11 +92,10 @@ TWILIO_MESSAGING_SID=
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=
STRIPE_TEAM_MONTHLY_PRICE_ID=
STRIPE_PRIVATE_KEY=
STRIPE_WEBHOOK_SECRET=
STRIPE_CLIENT_ID=
# Use for internal Public API Keys and optional
API_KEY_PREFIX=cal_

View File

@ -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>
</>
);
}

View File

@ -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} />}

View File

@ -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,20 +63,15 @@ 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`}
alt="Cal.com Logo"
style={{
top: 46,
left: 24,
}}
/>
}
</Link>
<img
className="h-5·w-auto fixed z-10 hidden sm:inline-block"
src={`${WEBSITE_URL}/cal-logo-word-dark.svg`}
alt="Cal.com Logo"
style={{
top: 46,
left: 24,
}}
/>
</div>
</>
);

View File

@ -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,14 +71,16 @@ export async function cancelScheduledJobs(
scheduledJobs = [];
}
await prisma.booking.update({
where: {
uid: booking.uid,
},
data: {
scheduledJobs: scheduledJobs,
},
});
if (!isReschedule) {
await prisma.booking.update({
where: {
uid: booking.uid,
},
data: {
scheduledJobs: scheduledJobs,
},
});
}
});
}
}

View File

@ -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);

View File

@ -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");
}

View File

@ -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;
return teamStripeIds;
} else {
throw new Error(`Team ${teamId} not found`);
}
const teamStripeIds = { ...teamQuery.metadata };
return teamStripeIds;
};
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`);
}
};

View File

@ -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;

View File

@ -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,34 +78,34 @@ 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,
userId: ctx.user.id,
role: MembershipRole.OWNER,
accepted: true,
name,
slug,
logo,
members: {
create: {
userId: ctx.user.id,
role: MembershipRole.OWNER,
accepted: true,
},
},
},
});
@ -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", {

View File

@ -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"