Team Billing (#1552)

* added base logic for team billing

- moved Stripe customer related logic to customer.ts
- implemented unstable logic for team owner upgrading, downgrading and adding/removing seats

* logic improvements

* - improved Alert style
- hide free team members on public team page
- upgraded textarea to ui component TextArea in SAML setup
- added Alert on team settings for hidden members
- hide CreateEventTypeButton if not admin
- fixed missing locale strings in team settings

* remove random import

* - show hidden status on team list
- refactor team pill

* - improved logic (mostly functional)
- added Alerts for members & owners
- added local strings
- created upgrade modal
- added info notice on invite member modal
- fixed router redirect after leaving team

* - improved logic in team-billing
- error display on upgrade modal
- added better launch.json for VSCode debugger
- fixed bug with missing inviteeUserId

* code cleanup

* nit pick fixes i should sleep now

* fixed leave team bug
- quantity would not decrease upon leave or removal

* added stripe billing callback handler

* - better launch.json
- teams empty component

* - fixed error not removing after successful pro upgrade
- fixed silent fail on team create name conflict
- fixed input border radius on member invite modal

* updated local strings

* improved logic for edge cases, such as:
- team owned by member sponsored by another team can smoothly upgrade to pro if kicked from sponsored team
- logic to calculate if owner is specifically missing pro subscription (ownerIsMissingSeat)
- corrected calculation of members missing seats, shouldn't care for proPaidForByTeamId as that only matters for removing member and preserving pro if they pay for it themselves
- added react query devtools
- added missing locale string

* - allow type override for LinkIconButton
- consolidate filter logic for getMembersMissingSeats

* - only activate team billing for hosted cal
- fix prod price keys

* fix requiresUpgrade when not hosted by cal

* added HOSTED_CAL_FEATURES

* fixed failing build

- fixed broken import path
- added support for premium price plan. (will consider premium as a valid seat)
- remove rouge console log

* fix customer id type error

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
This commit is contained in:
Jamie Pine 2022-02-07 15:35:26 -08:00 committed by GitHub
parent c201bfab2d
commit 5567721431
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
31 changed files with 772 additions and 189 deletions

36
.vscode/launch.json vendored
View file

@ -1,15 +1,39 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Next.js: Server",
"type": "node-terminal",
"request": "launch",
"command": "npm run dev",
"skipFiles": ["<node_internals>/**"],
"outFiles": [
"${workspaceFolder}/**/*.js",
"!**/node_modules/**"
],
"sourceMaps": true,
"resolveSourceMapLocations": [
"${workspaceFolder}/**",
"!**/node_modules/**"
]
},
{
"name": "Next.js: Client",
"type": "pwa-chrome",
"request": "launch",
"name": "Launch Chrome against localhost",
"url": "http://localhost:8080",
"webRoot": "${workspaceFolder}"
"url": "http://localhost:3000"
},
{
"name": "Next.js: Full Stack",
"type": "node-terminal",
"request": "launch",
"command": "npm run dev",
"console": "integratedTerminal",
"serverReadyAction": {
"pattern": "started server on .+, url: (https?://.+)",
"uriFormat": "%s",
"action": "debugWithChrome"
}
}
]
}

View file

@ -32,7 +32,7 @@ type DialogHeaderProps = {
export function DialogHeader(props: DialogHeaderProps) {
return (
<div className="mb-8">
<h3 className="text-lg font-bold leading-6 text-gray-900 font-cal" id="modal-title">
<h3 className="text-xl text-gray-900 leading-16 font-cal" id="modal-title">
{props.title}
</h3>
{props.subtitle && <div className="text-sm text-gray-400">{props.subtitle}</div>}

View file

@ -37,7 +37,7 @@ export const EventTypeDescription = ({ eventType, className }: EventTypeDescript
{eventType.description.length > 100 && "..."}
</h2>
)}
<ul className="flex mt-2 rtl:space-x-reverse space-x-4 ">
<ul className="flex mt-2 space-x-4 rtl:space-x-reverse ">
<li className="flex whitespace-nowrap">
<ClockIcon className="inline mt-0.5 mr-1.5 h-4 w-4 text-neutral-400" aria-hidden="true" />
{eventType.length}m

View file

@ -1,4 +1,5 @@
import { UserIcon } from "@heroicons/react/outline";
import { InformationCircleIcon } from "@heroicons/react/solid";
import { MembershipRole } from "@prisma/client";
import { useState } from "react";
import React, { SyntheticEvent } from "react";
@ -7,7 +8,7 @@ import { useLocale } from "@lib/hooks/useLocale";
import { TeamWithMembers } from "@lib/queries/teams";
import { trpc } from "@lib/trpc";
import { EmailInput } from "@components/form/fields";
import { TextField } from "@components/form/fields";
import Button from "@components/ui/Button";
export default function MemberInvitationModal(props: { team: TeamWithMembers | null; onExit: () => void }) {
@ -76,27 +77,21 @@ export default function MemberInvitationModal(props: { team: TeamWithMembers | n
</div>
</div>
<form onSubmit={inviteMember}>
<div>
<div className="mb-4">
<label htmlFor="inviteUser" className="block text-sm font-medium text-gray-700">
{t("email_or_username")}
</label>
<EmailInput
type="text"
name="inviteUser"
<div className="space-y-4">
<TextField
label={t("email_or_username")}
id="inviteUser"
name="inviteUser"
placeholder="email@example.com"
required
className="block w-full px-3 py-2 mt-1 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-black focus:border-brand sm:text-sm"
/>
</div>
<div className="mb-4">
<label className="block mb-2 text-sm font-medium tracking-wide text-gray-700" htmlFor="role">
<div>
<label className="block mb-1 text-sm font-medium tracking-wide text-gray-700" htmlFor="role">
{t("role")}
</label>
<select
id="role"
className="block w-full mt-1 border-gray-300 rounded-md shadow-sm focus:ring-black focus:border-brand sm:text-sm">
className="block w-full mt-1 border-gray-300 rounded-sm shadow-sm focus:ring-black focus:border-brand sm:text-sm">
<option value="MEMBER">{t("member")}</option>
<option value="ADMIN">{t("admin")}</option>
</select>
@ -108,7 +103,7 @@ export default function MemberInvitationModal(props: { team: TeamWithMembers | n
name="sendInviteEmail"
defaultChecked
id="sendInviteEmail"
className="text-black border-gray-300 rounded-md shadow-sm focus:ring-black focus:border-brand sm:text-sm"
className="text-black border-gray-300 rounded-sm shadow-sm focus:ring-black focus:border-brand sm:text-sm"
/>
</div>
<div className="ltr:ml-2 rtl:mr-2text-sm">
@ -117,6 +112,16 @@ export default function MemberInvitationModal(props: { team: TeamWithMembers | n
</label>
</div>
</div>
<div className="flex flex-row px-3 py-2 rounded-sm bg-gray-50">
<InformationCircleIcon className="flex-shrink-0 w-5 h-5 fill-gray-400" aria-hidden="true" />
<span className="ml-2 text-sm leading-tight text-gray-500">
Note: This will cost an extra seat ($12/m) on your subscription if this invitee does not
have a pro account.{" "}
<a href="#" className="underline">
Learn More
</a>
</span>
</div>
</div>
{errorMessage && (
<p className="text-sm text-red-700">

View file

@ -24,7 +24,7 @@ import Dropdown, {
DropdownMenuTrigger,
} from "../ui/Dropdown";
import MemberChangeRoleModal from "./MemberChangeRoleModal";
import TeamRole from "./TeamRole";
import TeamPill, { TeamRole } from "./TeamPill";
import { MembershipRole } from ".prisma/client";
interface Props {
@ -80,8 +80,14 @@ export default function MemberListItem(props: Props) {
</div>
</div>
<div className="flex mt-2 ltr:mr-2 rtl:ml-2 sm:mt-0 sm:justify-center">
{!props.member.accepted && <TeamRole invitePending />}
<TeamRole role={props.member.role} />
{/* Tooltip doesn't show... WHY????? */}
{props.member.isMissingSeat && (
<Tooltip content={t("hidden_team_member_message")}>
<TeamPill color="red" text={t("hidden")} />
</Tooltip>
)}
{!props.member.accepted && <TeamPill color="yellow" text={t("invitee")} />}
{props.member.role && <TeamRole role={props.member.role} />}
</div>
</div>
<div className="flex">
@ -96,7 +102,7 @@ export default function MemberListItem(props: Props) {
disabled={!props.member.accepted}
onClick={() => (props.member.accepted ? setShowTeamAvailabilityModal(true) : null)}
color="minimal"
className="hidden w-10 h-10 p-0 border border-transparent group text-neutral-400 hover:border-gray-200 hover:bg-white sm:block">
className="items-center justify-center hidden w-10 h-10 px-0 py-0 border border-transparent group text-neutral-400 hover:border-gray-200 hover:bg-white sm:flex">
<ClockIcon className="w-5 h-5 group-hover:text-gray-800" />
</Button>
</Tooltip>
@ -167,7 +173,7 @@ export default function MemberListItem(props: Props) {
{showTeamAvailabilityModal && (
<ModalContainer wide noPadding>
<TeamAvailabilityModal team={props.team} member={props.member} />
<div className="p-5 rtl:space-x-reverse space-x-2 border-t">
<div className="p-5 space-x-2 border-t rtl:space-x-reverse">
<Button onClick={() => setShowTeamAvailabilityModal(false)}>{t("done")}</Button>
{props.team.membership.role !== MembershipRole.MEMBER && (
<Link href={`/settings/teams/${props.team.id}/availability`}>

View file

@ -1,9 +1,11 @@
import { UsersIcon } from "@heroicons/react/outline";
import { useRef } from "react";
import { useRef, useState } from "react";
import { useLocale } from "@lib/hooks/useLocale";
import { trpc } from "@lib/trpc";
import { Alert } from "@components/ui/Alert";
interface Props {
onClose: () => void;
}
@ -11,7 +13,7 @@ interface Props {
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", {
@ -19,6 +21,9 @@ export default function TeamCreate(props: Props) {
utils.invalidateQueries(["viewer.teams.list"]);
props.onClose();
},
onError: (e) => {
setErrorMessage(e?.message || t("something_went_wrong"));
},
});
const createTeam = (e: React.FormEvent<HTMLFormElement>) => {
@ -70,6 +75,7 @@ export default function TeamCreate(props: Props) {
className="block w-full px-3 py-2 mt-1 border border-gray-300 rounded-sm shadow-sm focus:outline-none focus:ring-neutral-500 focus:border-neutral-500 sm:text-sm"
/>
</div>
{errorMessage && <Alert severity="error" title={errorMessage} />}
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
<button type="submit" className="btn btn-primary">
{t("create_team")}

View file

@ -20,7 +20,7 @@ import Dropdown, {
DropdownMenuSeparator,
} from "@components/ui/Dropdown";
import TeamRole from "./TeamRole";
import { TeamRole } from "./TeamPill";
import { MembershipRole } from ".prisma/client";
interface Props {
@ -99,8 +99,8 @@ export default function TeamListItem(props: Props) {
</>
)}
{!isInvitee && (
<div className="flex rtl:space-x-reverse space-x-2">
<TeamRole role={team.role as MembershipRole} />
<div className="flex space-x-2 rtl:space-x-reverse">
<TeamRole role={team.role} />
<Tooltip content={t("copy_link_team")}>
<Button

View file

@ -0,0 +1,36 @@
import { MembershipRole } from "@prisma/client";
import classNames from "classnames";
import { useLocale } from "@lib/hooks/useLocale";
type PillColor = "blue" | "green" | "red" | "yellow";
interface Props {
text: string;
color?: PillColor;
}
export default function TeamPill(props: Props) {
return (
<div
className={classNames("self-center px-3 py-1 ltr:mr-2 rtl:ml-2 text-xs capitalize border rounded-md", {
"bg-gray-50 border-gray-200 text-gray-700": !props.color,
"bg-blue-50 border-blue-200 text-blue-700": props.color === "blue",
"bg-red-50 border-red-200 text-red-700": props.color === "red",
"bg-yellow-50 border-yellow-200 text-yellow-700": props.color === "yellow",
"bg-green-50 border-green-200 text-green-600": props.color === "green",
})}>
{props.text}
</div>
);
}
export function TeamRole(props: { role: MembershipRole }) {
const { t } = useLocale();
const keys: Record<MembershipRole, PillColor | undefined> = {
[MembershipRole.OWNER]: undefined,
[MembershipRole.ADMIN]: "red",
[MembershipRole.MEMBER]: "blue",
};
return <TeamPill text={t(props.role.toLowerCase())} color={keys[props.role]} />;
}

View file

@ -1,40 +0,0 @@
import { MembershipRole } from "@prisma/client";
import classNames from "classnames";
import { useLocale } from "@lib/hooks/useLocale";
interface Props {
role?: MembershipRole;
invitePending?: boolean;
}
export default function TeamRole(props: Props) {
const { t } = useLocale();
return (
<span
className={classNames(
"self-center px-3 py-1 ltr:mr-2 rtl:ml-2 text-xs capitalize border rounded-md",
{
"bg-blue-50 border-blue-200 text-blue-700": props.role === "MEMBER",
"bg-gray-50 border-gray-200 text-gray-700": props.role === "OWNER",
"bg-red-50 border-red-200 text-red-700": props.role === "ADMIN",
"bg-yellow-50 border-yellow-200 text-yellow-700": props.invitePending,
}
)}>
{(() => {
if (props.invitePending) return t("invitee");
switch (props.role) {
case "OWNER":
return t("owner");
case "ADMIN":
return t("admin");
case "MEMBER":
return t("member");
default:
return "";
}
})()}
</span>
);
}

View file

@ -15,8 +15,6 @@ import LinkIconButton from "@components/ui/LinkIconButton";
import { MembershipRole } from ".prisma/client";
// import Switch from "@components/ui/Switch";
export default function TeamSettingsRightSidebar(props: { team: TeamWithMembers; role: MembershipRole }) {
const { t } = useLocale();
const utils = trpc.useContext();
@ -27,6 +25,7 @@ export default function TeamSettingsRightSidebar(props: { team: TeamWithMembers;
const deleteTeamMutation = trpc.useMutation("viewer.teams.delete", {
async onSuccess() {
await utils.invalidateQueries(["viewer.teams.get"]);
router.push(`/settings/teams`);
showToast(t("your_team_updated_successfully"), "success");
},
});
@ -50,19 +49,20 @@ export default function TeamSettingsRightSidebar(props: { team: TeamWithMembers;
return (
<div className="px-2 space-y-6">
{(props.role === MembershipRole.OWNER || props.role === MembershipRole.ADMIN) && (
<CreateEventTypeButton
isIndividualTeam
canAddEvents={true}
options={[
{ teamId: props.team?.id, name: props.team?.name, slug: props.team?.slug, image: props.team?.logo },
{
teamId: props.team?.id,
name: props.team?.name,
slug: props.team?.slug,
image: props.team?.logo,
},
]}
/>
{/* <Switch
name="isHidden"
defaultChecked={hidden}
onCheckedChange={setHidden}
label={"Hide team from view"}
/> */}
)}
<div className="space-y-1">
<Link href={permalink} passHref={true}>
<a target="_blank">

View file

@ -0,0 +1,90 @@
import { useState } from "react";
import { useLocale } from "@lib/hooks/useLocale";
import showToast from "@lib/notification";
import { trpc } from "@lib/trpc";
import {
Dialog,
DialogTrigger,
DialogContent,
DialogClose,
DialogFooter,
DialogHeader,
} from "@components/Dialog";
import { Alert } from "@components/ui/Alert";
import Button from "@components/ui/Button";
interface Props {
teamId: number;
}
export function UpgradeToFlexibleProModal(props: Props) {
const { t } = useLocale();
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const utils = trpc.useContext();
const { data } = trpc.useQuery(["viewer.teams.getTeamSeats", { teamId: props.teamId }], {
onError: (err) => {
setErrorMessage(err.message);
},
});
const mutation = trpc.useMutation(["viewer.teams.upgradeTeam"], {
onSuccess: (data) => {
// if the user does not already have a Stripe subscription, this wi
if (data?.url) {
window.location.href = data.url;
}
if (data?.success) {
utils.invalidateQueries(["viewer.teams.get"]);
showToast(t("team_upgraded_successfully"), "success");
}
},
onError: (err) => {
setErrorMessage(err.message);
},
});
return (
<Dialog
onOpenChange={() => {
setErrorMessage(null);
}}>
<DialogTrigger asChild>
<a className="underline cursor-pointer">{"Upgrade Now"}</a>
</DialogTrigger>
<DialogContent>
<DialogHeader title={t("Purchase missing seats")} />
<p className="-mt-4 text-sm text-gray-600">{t("changed_team_billing_info")}</p>
{data && (
<p className="mt-2 text-sm italic text-gray-700">
{t("team_upgrade_seats_details", {
memberCount: data.totalMembers,
unpaidCount: data.missingSeats,
seatPrice: 12,
totalCost: (data.totalMembers - data.freeSeats) * 12 + 12,
})}
</p>
)}
{errorMessage && (
<Alert severity="error" title={errorMessage} message={t("further_billing_help")} className="my-4" />
)}
<DialogFooter>
<DialogClose>
<Button color="secondary">{t("close")}</Button>
</DialogClose>
<Button
disabled={mutation.isLoading}
onClick={() => {
setErrorMessage(null);
mutation.mutate({ teamId: props.teamId });
}}>
{t("upgrade_to_per_seat")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -15,10 +15,10 @@ export function Alert(props: AlertProps) {
return (
<div
className={classNames(
"rounded-sm p-2",
"rounded-sm p-3 border border-opacity-20",
props.className,
severity === "error" && "bg-red-50 text-red-800",
severity === "warning" && "bg-yellow-50 text-yellow-700",
severity === "error" && "bg-red-50 text-red-800 border-red-900",
severity === "warning" && "bg-yellow-50 text-yellow-700 border-yellow-700",
severity === "success" && "bg-gray-900 text-white"
)}>
<div className="flex">

View file

@ -10,8 +10,8 @@ export default function LinkIconButton(props: LinkIconButtonProps) {
return (
<div className="-ml-2">
<button
{...props}
type="button"
{...props}
className="flex items-center px-2 py-1 text-sm font-medium text-gray-700 rounded-sm text-md hover:text-gray-900 hover:bg-gray-200">
<props.Icon className="w-4 h-4 ltr:mr-2 rtl:ml-2 text-neutral-500" />
{props.children}

View file

@ -7,6 +7,7 @@ import { trpc } from "@lib/trpc";
import { Dialog, DialogTrigger } from "@components/Dialog";
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
import { TextArea } from "@components/form/fields";
import { Alert } from "@components/ui/Alert";
import Badge from "@components/ui/Badge";
import Button from "@components/ui/Button";
@ -89,10 +90,9 @@ export default function SAMLConfiguration({
const { t } = useLocale();
return (
<>
<hr className="mt-8" />
{isSAMLLoginEnabled ? (
<>
<hr className="mt-8" />
<div className="mt-6">
<h2 className="text-lg font-medium leading-6 text-gray-900 font-cal">
{t("saml_configuration")}
@ -141,14 +141,13 @@ export default function SAMLConfiguration({
<form className="mt-3 divide-y divide-gray-200 lg:col-span-9" onSubmit={updateSAMLConfigHandler}>
{hasErrors && <Alert severity="error" title={errorMessage} />}
<textarea
<TextArea
data-testid="saml_config"
ref={samlConfigRef}
name="saml_config"
id="saml_config"
required={true}
rows={10}
className="block w-full border-gray-300 rounded-md shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-black sm:text-sm"
placeholder={t("saml_configuration_placeholder")}
/>

67
ee/lib/stripe/customer.ts Normal file
View file

@ -0,0 +1,67 @@
import { Prisma } from "@prisma/client";
import stripe from "@ee/lib/stripe/server";
import { HttpError as HttpCode } from "@lib/core/http/error";
import { prisma } from "@lib/prisma";
export async function getStripeCustomerFromUser(userId: number) {
// Get user
const user = await prisma.user.findUnique({
where: {
id: userId,
},
select: {
email: true,
name: true,
metadata: true,
},
});
if (!user?.email) throw new HttpCode({ statusCode: 404, message: "User email not found" });
const customerId = await getStripeCustomerId(user);
return customerId;
}
const userType = Prisma.validator<Prisma.UserArgs>()({
select: {
email: true,
metadata: true,
},
});
type UserType = Prisma.UserGetPayload<typeof userType>;
export async function getStripeCustomerId(user: UserType): Promise<string | null> {
let customerId: string | null = null;
if (user?.metadata && typeof user.metadata === "object" && "stripeCustomerId" in user.metadata) {
customerId = (user?.metadata as Prisma.JsonObject).stripeCustomerId as string;
} else {
/* We fallback to finding the customer by email (which is not optimal) */
const customersResponse = await stripe.customers.list({
email: user.email,
limit: 1,
});
if (customersResponse.data[0]?.id) {
customerId = customersResponse.data[0].id;
}
}
return customerId;
}
export async function deleteStripeCustomer(user: UserType): Promise<string | null> {
const customerId = await getStripeCustomerId(user);
if (!customerId) {
console.warn("No stripe customer found for user:" + user.email);
return null;
}
//delete stripe customer
const deletedCustomer = await stripe.customers.del(customerId);
return deletedCustomer.id;
}

View file

@ -168,45 +168,4 @@ async function handleRefundError(opts: { event: CalendarEvent; reason: string; p
});
}
const userType = Prisma.validator<Prisma.UserArgs>()({
select: {
email: true,
metadata: true,
},
});
type UserType = Prisma.UserGetPayload<typeof userType>;
export async function getStripeCustomerId(user: UserType): Promise<string | null> {
let customerId: string | null = null;
if (user?.metadata && typeof user.metadata === "object" && "stripeCustomerId" in user.metadata) {
customerId = (user?.metadata as Prisma.JsonObject).stripeCustomerId as string;
} else {
/* We fallback to finding the customer by email (which is not optimal) */
const customersReponse = await stripe.customers.list({
email: user.email,
limit: 1,
});
if (customersReponse.data[0]?.id) {
customerId = customersReponse.data[0].id;
}
}
return customerId;
}
export async function deleteStripeCustomer(user: UserType): Promise<string | null> {
const customerId = await getStripeCustomerId(user);
if (!customerId) {
console.warn("No stripe customer found for user:" + user.email);
return null;
}
//delete stripe customer
const deletedCustomer = await stripe.customers.del(customerId);
return deletedCustomer.id;
}
export default stripe;

View file

@ -0,0 +1,275 @@
import { MembershipRole, Prisma, UserPlan } from "@prisma/client";
import Stripe from "stripe";
import { getStripeCustomerFromUser } from "@ee/lib/stripe/customer";
import { HOSTED_CAL_FEATURES } from "@lib/config/constants";
import { HttpError } from "@lib/core/http/error";
import prisma from "@lib/prisma";
import stripe from "./server";
// get team owner's Pro Plan subscription from Cal userId
export async function getProPlanSubscription(userId: number) {
const stripeCustomerId = await getStripeCustomerFromUser(userId);
if (!stripeCustomerId) return null;
const customer = await stripe.customers.retrieve(stripeCustomerId, {
expand: ["subscriptions.data.plan"],
});
if (customer.deleted) throw new HttpError({ statusCode: 404, message: "Stripe customer not found" });
// get the first subscription item which is the Pro Plan TODO: change to find()
return customer.subscriptions?.data[0];
}
async function getMembersMissingSeats(teamId: number) {
const members = await prisma.membership.findMany({
where: { teamId },
select: { role: true, accepted: true, user: { select: { id: true, plan: true, metadata: true } } },
});
// any member that is not Pro is missing a seat excluding the owner
const membersMissingSeats = members.filter(
(m) => m.role !== MembershipRole.OWNER || m.user.plan !== UserPlan.PRO
);
// as owner's billing is handled by a different Price, we count this separately
const ownerIsMissingSeat = !!members.find(
(m) => m.role === MembershipRole.OWNER && m.user.plan === UserPlan.FREE
);
return {
members,
membersMissingSeats,
ownerIsMissingSeat,
};
}
// a helper for the upgrade dialog
export async function getTeamSeatStats(teamId: number) {
const { membersMissingSeats, members, ownerIsMissingSeat } = await getMembersMissingSeats(teamId);
return {
totalMembers: members.length,
// members we need not pay for
freeSeats: members.length - membersMissingSeats.length,
// members we need to pay for (if not hosted cal, team billing is disabled)
missingSeats: HOSTED_CAL_FEATURES ? membersMissingSeats.length : 0,
// members who have been hidden from view
hiddenMembers: members.filter((m) => m.user.plan === UserPlan.FREE).length,
ownerIsMissingSeat: HOSTED_CAL_FEATURES ? ownerIsMissingSeat : false,
};
}
async function updatePerSeatQuantity(subscription: Stripe.Subscription, quantity: number) {
const perSeatProPlan = subscription.items.data.find((item) => item.plan.id === getPerSeatProPlanPrice());
// if their subscription does not contain Per Seat Pro, add it—otherwise, update the existing one
return await stripe.subscriptions.update(subscription.id, {
items: [
perSeatProPlan ? { id: perSeatProPlan.id, quantity } : { plan: getPerSeatProPlanPrice(), quantity },
],
});
}
// called by the team owner when they are ready to upgrade their team to Per Seat Pro
// if user has no subscription, this will be called again after successful stripe checkout callback, with subscription now present
export async function upgradeTeam(userId: number, teamId: number) {
const ownerUser = await prisma.membership.findFirst({
where: { userId, teamId },
select: { role: true, user: true },
});
if (ownerUser?.role !== MembershipRole.OWNER)
throw new HttpError({ statusCode: 400, message: "User is not an owner" });
const subscription = await getProPlanSubscription(userId);
const { membersMissingSeats, ownerIsMissingSeat } = await getMembersMissingSeats(teamId);
if (!subscription) {
const customer = await getStripeCustomerFromUser(userId);
if (!customer) throw new HttpError({ statusCode: 400, message: "User has no Stripe customer" });
// create a checkout session with the quantity of missing seats
const session = await createCheckoutSession(
customer,
membersMissingSeats.length,
teamId,
ownerIsMissingSeat
);
// return checkout session url for redirect
return { url: session.url };
}
// if the owner has a subscription but does not have an individual Pro account
if (ownerIsMissingSeat) {
const ownerHasProPlan = !!subscription.items.data.find(
(item) => item.plan.id === getProPlanPrice() || item.plan.id === getPremiumPlanPrice()
);
if (!ownerHasProPlan)
await stripe.subscriptions.update(subscription.id, {
items: [
{
price: getProPlanPrice(),
quantity: 1,
},
],
});
await prisma.user.update({
where: { id: userId },
data: { plan: UserPlan.PRO },
});
}
// update the subscription with Stripe
await updatePerSeatQuantity(subscription, membersMissingSeats.length);
// loop through all members and update their account to Pro
for (const member of membersMissingSeats) {
await prisma.user.update({
where: { id: member.user.id },
data: {
plan: UserPlan.PRO,
// declare which team is sponsoring their Pro membership
metadata: { proPaidForByTeamId: teamId, ...((member.user.metadata as Prisma.JsonObject) ?? {}) },
},
});
}
return { success: true };
}
// shared logic for add/removing members, called on member invite and member removal/leave
async function addOrRemoveSeat(remove: boolean, userId: number, teamId: number, memberUserId?: number) {
console.log(remove ? "removing member" : "adding member", { userId, teamId, memberUserId });
const subscription = await getProPlanSubscription(userId);
if (!subscription) return;
// get the per seat plan from the subscription
const perSeatProPlanPrice = subscription?.items.data.find(
(item) => item.plan.id === getPerSeatProPlanPrice()
);
// find the member's local user account
const memberUser = await prisma.user.findUnique({
where: { id: memberUserId },
select: { id: true, plan: true, metadata: true },
});
// in the rare event there is no account, return
if (!memberUser) return;
// check if this user is paying for their own Pro account, if so return.
const memberSubscription = await getProPlanSubscription(memberUser.id);
const proPlanPrice = memberSubscription?.items.data.find((item) => item.plan.id === getProPlanPrice());
if (proPlanPrice) return;
// takes care of either adding per seat pricing, or updating the existing one's quantity
await updatePerSeatQuantity(
subscription,
remove ? (perSeatProPlanPrice?.quantity ?? 1) - 1 : (perSeatProPlanPrice?.quantity ?? 0) + 1
);
// add or remove proPaidForByTeamId from metadata
const metadata: Record<string, unknown> = {
proPaidForByTeamId: teamId,
...((memberUser.metadata as Prisma.JsonObject) ?? {}),
};
// entirely remove property if removing member from team and proPaidForByTeamId is this team
if (remove && metadata.proPaidForByTeamId === teamId) delete metadata.proPaidForByTeamId;
await prisma.user.update({
where: { id: memberUserId },
data: { plan: remove ? UserPlan.FREE : UserPlan.PRO, metadata: metadata as Prisma.JsonObject },
});
}
// aliased helpers for more verbose usage
export async function addSeat(userId: number, teamId: number, memberUserId?: number) {
return await addOrRemoveSeat(false, userId, teamId, memberUserId);
}
export async function removeSeat(userId: number, teamId: number, memberUserId?: number) {
return await addOrRemoveSeat(true, userId, teamId, memberUserId);
}
// if a team has failed to pay for the pro plan, downgrade all team members to free
export async function downgradeTeamMembers(teamId: number) {
const members = await prisma.membership.findMany({
where: { teamId, user: { plan: UserPlan.PRO } },
select: { role: true, accepted: true, user: { select: { id: true, plan: true, metadata: true } } },
});
for (const member of members) {
// skip if user had their own Pro subscription
const subscription = await getProPlanSubscription(member.user.id);
if (subscription?.items.data.length) continue;
// skip if Pro is paid for by another team
const metadata = (member.user.metadata as Prisma.JsonObject) ?? {};
if (metadata.proPaidForByTeamId !== teamId) continue;
// downgrade only if their pro plan was paid for by this team
delete metadata.proPaidForByTeamId;
await prisma.user.update({
where: { id: member.user.id },
data: { plan: UserPlan.FREE, metadata },
});
}
}
async function createCheckoutSession(
customerId: string,
quantity: number,
teamId: number,
includeBaseProPlan?: boolean
) {
// if the user is missing the base plan, we should include it agnostic of the seat quantity
const line_items: Stripe.Checkout.SessionCreateParams["line_items"] =
quantity === 0
? []
: [
{
price: getPerSeatProPlanPrice(),
quantity: quantity ?? 1,
},
];
if (includeBaseProPlan) line_items.push({ price: getProPlanPrice(), quantity: 1 });
const params: Stripe.Checkout.SessionCreateParams = {
mode: "subscription",
payment_method_types: ["card"],
customer: customerId,
line_items,
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/api/teams/${teamId}/upgrade?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/`,
allow_promotion_codes: true,
};
return await stripe.checkout.sessions.create(params);
}
// verifies that the subscription's quantity is correct for the number of members the team has
// this is a function is a dev util, but could be utilized as a sync technique in the future
export async function ensureSubscriptionQuantityCorrectness(userId: number, teamId: number) {
const subscription = await getProPlanSubscription(userId);
const stripeQuantity =
subscription?.items.data.find((item) => item.plan.id === getPerSeatProPlanPrice())?.quantity ?? 0;
const { membersMissingSeats } = await getMembersMissingSeats(teamId);
// correct the quantity if missing seats is out of sync with subscription quantity
if (subscription && membersMissingSeats.length !== stripeQuantity) {
await updatePerSeatQuantity(subscription, membersMissingSeats.length);
}
}
// TODO: these should be moved to env vars
export function getPerSeatProPlanPrice(): string {
return process.env.NODE_ENV === "production"
? "price_1KHkoeH8UDiwIftkkUbiggsM"
: "price_1KLD4GH8UDiwIftkWQfsh1Vh";
}
export function getProPlanPrice(): string {
return process.env.NODE_ENV === "production"
? "price_1KHkoeH8UDiwIftkkUbiggsM"
: "price_1JZ0J3H8UDiwIftk0YIHYKr8";
}
export function getPremiumPlanPrice(): string {
return process.env.NODE_ENV === "production"
? "price_1Jv3CMH8UDiwIftkFgyXbcHN"
: "price_1Jv3CMH8UDiwIftkFgyXbcHN";
}

View file

@ -39,7 +39,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
};
const query = stringify(stripeConnectParams);
/**
* Choose Express or Stantard Stripe accounts
* Choose Express or Standard Stripe accounts
* @url https://stripe.com/docs/connect/accounts
*/
// const url = `https://connect.stripe.com/express/oauth/authorize?${query}`;

View file

@ -1,9 +1,9 @@
import type { NextApiRequest, NextApiResponse } from "next";
import stripe, { getStripeCustomerId } from "@ee/lib/stripe/server";
import { getStripeCustomerFromUser } from "@ee/lib/stripe/customer";
import stripe from "@ee/lib/stripe/server";
import { getSession } from "@lib/auth";
import prisma from "@lib/prisma";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "POST") {
@ -15,29 +15,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
return;
}
// Get user
const user = await prisma.user.findUnique({
where: {
id: session.user?.id,
},
select: {
email: true,
name: true,
metadata: true,
},
});
const customerId = await getStripeCustomerFromUser(session.user.id);
if (!user?.email)
return res.status(404).json({
message: "User email not found",
});
const customerId = await getStripeCustomerId(user);
if (!customerId)
return res.status(404).json({
message: "Stripe customer id not found",
});
if (!customerId) {
res.status(500).json({ message: "Missing customer id" });
return;
}
const return_url = `${process.env.BASE_URL}/settings/billing`;
const stripeSession = await stripe.billingPortal.sessions.create({

View file

@ -0,0 +1,21 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { upgradeTeam } from "@ee/lib/stripe/team-billing";
import { getSession } from "@lib/auth";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "GET") {
const session = await getSession({ req });
if (!session) {
res.status(401).json({ message: "You must be logged in to do this" });
return;
}
await upgradeTeam(session.user.id, Number(req.query.team));
// redirect to team screen
res.redirect(302, `${process.env.NEXT_PUBLIC_APP_URL}/settings/teams/${req.query.team}?upgraded=true`);
}
}

View file

@ -2,3 +2,4 @@ export const BASE_URL = process.env.BASE_URL || `https://${process.env.VERCEL_UR
export const WEBSITE_URL = process.env.NEXT_PUBLIC_APP_URL || "https://cal.com";
export const IS_PRODUCTION = process.env.NODE_ENV === "production";
export const TRIAL_LIMIT_DAYS = 14;
export const HOSTED_CAL_FEATURES = process.env.HOSTED_CAL_FEATURES || BASE_URL === "https://app.cal.com";

View file

@ -10,7 +10,7 @@ declare global {
export const prisma =
globalThis.prisma ||
new PrismaClient({
log: ["query", "error", "warn"],
// log: ["query", "error", "warn"],
});
if (!IS_PRODUCTION) {

View file

@ -1,4 +1,4 @@
import { Prisma } from "@prisma/client";
import { Prisma, UserPlan } from "@prisma/client";
import prisma from "@lib/prisma";
@ -15,6 +15,7 @@ export async function getTeamWithMembers(id?: number, slug?: string) {
email: true,
name: true,
id: true,
plan: true,
bio: true,
});
@ -69,6 +70,7 @@ export async function getTeamWithMembers(id?: number, slug?: string) {
const membership = memberships.find((membership) => obj.user.id === membership.userId);
return {
...obj.user,
isMissingSeat: obj.user.plan === UserPlan.FREE,
role: membership?.role,
accepted: membership?.role === "OWNER" ? true : membership?.accepted,
};

View file

@ -1,4 +1,5 @@
import { DefaultSeo } from "next-seo";
// import { ReactQueryDevtools } from "react-query/devtools";
import superjson from "superjson";
import AppProviders, { AppProps } from "@lib/app-providers";

View file

@ -0,0 +1 @@
export { default } from "@ee/pages/api/teams/[team]/upgrade";

View file

@ -1,6 +1,6 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { deleteStripeCustomer } from "@ee/lib/stripe/server";
import { deleteStripeCustomer } from "@ee/lib/stripe/customer";
import { getSession } from "@lib/auth";
import prisma from "@lib/prisma";

View file

@ -1,4 +1,4 @@
import { PlusIcon } from "@heroicons/react/solid";
import { PlusIcon, UserGroupIcon } from "@heroicons/react/solid";
import classNames from "classnames";
import { useSession } from "next-auth/react";
import { Trans } from "next-i18next";
@ -7,6 +7,7 @@ import { useState } from "react";
import { useLocale } from "@lib/hooks/useLocale";
import { trpc } from "@lib/trpc";
import EmptyScreen from "@components/EmptyScreen";
import Loader from "@components/Loader";
import SettingsShell from "@components/SettingsShell";
import Shell, { useMeQuery } from "@components/Shell";
@ -24,7 +25,7 @@ export default function Teams() {
const me = useMeQuery();
const { data } = trpc.useQuery(["viewer.teams.list"], {
const { data, isLoading } = trpc.useQuery(["viewer.teams.list"], {
onError: (e) => {
setErrorMessage(e.message);
},
@ -67,12 +68,20 @@ export default function Teams() {
{t("new_team")}
</Button>
</div>
{invites.length > 0 && (
<div className="mb-4">
<h1 className="mb-2 text-lg font-medium">{t("open_invitations")}</h1>
<TeamList teams={invites}></TeamList>
</div>
)}
{!isLoading && !teams.length && (
<EmptyScreen
Icon={UserGroupIcon}
headline={t("no_teams")}
description={t("no_teams_description")}
/>
)}
{teams.length > 0 && <TeamList teams={teams}></TeamList>}
</SettingsShell>
</Shell>

View file

@ -1,11 +1,13 @@
import { PlusIcon } from "@heroicons/react/solid";
import { MembershipRole } from "@prisma/client";
import { useRouter } from "next/router";
import { useState } from "react";
import { useEffect, useState } from "react";
import SAMLConfiguration from "@ee/components/saml/Configuration";
import { getPlaceholderAvatar } from "@lib/getPlaceholderAvatar";
import { useLocale } from "@lib/hooks/useLocale";
import showToast from "@lib/notification";
import { trpc } from "@lib/trpc";
import Loader from "@components/Loader";
@ -14,6 +16,7 @@ import MemberInvitationModal from "@components/team/MemberInvitationModal";
import MemberList from "@components/team/MemberList";
import TeamSettings from "@components/team/TeamSettings";
import TeamSettingsRightSidebar from "@components/team/TeamSettingsRightSidebar";
import { UpgradeToFlexibleProModal } from "@components/team/UpgradeToFlexibleProModal";
import { Alert } from "@components/ui/Alert";
import Avatar from "@components/ui/Avatar";
import { Button } from "@components/ui/Button";
@ -22,6 +25,15 @@ export function TeamSettingsPage() {
const { t } = useLocale();
const router = useRouter();
const upgraded = router.query.upgraded as string;
useEffect(() => {
if (upgraded) {
showToast(t("team_upgraded_successfully"), "success");
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const [showMemberInvitationModal, setShowMemberInvitationModal] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
@ -31,13 +43,14 @@ export function TeamSettingsPage() {
},
});
const isAdmin = team && (team.membership.role === "OWNER" || team.membership.role === "ADMIN");
const isAdmin =
team && (team.membership.role === MembershipRole.OWNER || team.membership.role === MembershipRole.ADMIN);
return (
<Shell
backPath={!errorMessage ? `/settings/teams` : undefined}
heading={team?.name}
subtitle={team && "Manage this team"}
subtitle={team && t("manage_this_team")}
HeadingLeftIcon={
team && (
<Avatar
@ -54,12 +67,54 @@ export function TeamSettingsPage() {
<>
<div className="block sm:flex md:max-w-5xl">
<div className="w-full ltr:mr-2 rtl:ml-2 sm:w-9/12">
{team.membership.role === MembershipRole.OWNER &&
team.membership.isMissingSeat &&
team.requiresUpgrade ? (
<Alert
severity="warning"
title={t("hidden_team_member_title")}
message={
<>
{t("hidden_team_owner_message")} <UpgradeToFlexibleProModal teamId={team.id} />
{/* <a href={"https://cal.com/upgrade"} className="underline">
{"https://cal.com/upgrade"}
</a> */}
</>
}
className="mb-4 "
/>
) : (
<>
{team.membership.isMissingSeat && (
<Alert
severity="warning"
title={t("hidden_team_member_title")}
message={t("hidden_team_member_message")}
className="mb-4 "
/>
)}
{team.membership.role === MembershipRole.OWNER && team.requiresUpgrade && (
<Alert
severity="warning"
title={t("upgrade_to_flexible_pro_title")}
message={
<span>
{t("upgrade_to_flexible_pro_message")} <br />
<UpgradeToFlexibleProModal teamId={team.id} />
</span>
}
className="mb-4"
/>
)}
</>
)}
<div className="px-4 -mx-0 bg-white border rounded-sm border-neutral-200 sm:px-6">
{isAdmin ? (
<TeamSettings team={team} />
) : (
<div className="py-5">
<span className="mb-1 font-bold">Team Info</span>
<span className="mb-1 font-bold">{t("team_info")}</span>
<p className="text-sm text-gray-700">{team.bio}</p>
</div>
)}
@ -80,7 +135,7 @@ export function TeamSettingsPage() {
)}
</div>
<MemberList team={team} members={team.members || []} />
{isAdmin ? <SAMLConfiguration teamsView={true} teamId={team.id} /> : null}
{isAdmin && <SAMLConfiguration teamsView={true} teamId={team.id} />}
</div>
<div className="w-full px-2 mt-8 ltr:ml-2 rtl:mr-2 md:w-3/12 sm:mt-0 min-w-32">
<TeamSettingsRightSidebar role={team.membership.role} team={team} />

View file

@ -1,4 +1,5 @@
import { ArrowRightIcon } from "@heroicons/react/solid";
import { UserPlan } from "@prisma/client";
import { GetServerSidePropsContext } from "next";
import Link from "next/link";
import React from "react";
@ -115,6 +116,10 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
if (!team) return { notFound: true };
const members = team.members.filter((member) => member.plan !== UserPlan.FREE);
team.members = members ?? [];
team.eventTypes = team.eventTypes.map((type) => ({
...type,
users: type.users.map((user) => ({

View file

@ -80,10 +80,18 @@
"rejected_event_type_with_organizer": "Rejected: {{eventType}} with {{organizer}} on {{date}}",
"hi": "Hi",
"join_team": "Join team",
"manage_this_team": "Manage this team",
"team_info": "Team Info",
"request_another_invitation_email": "If you prefer not to use {{toEmail}} as your Cal.com email or already have a Cal.com account, please request another invitation to that email.",
"you_have_been_invited": "You have been invited to join the team {{teamName}}",
"user_invited_you": "{{user}} invited you to join the team {{team}} on Cal.com",
"hidden_team_member_title": "You are hidden in this team",
"hidden_team_member_message": "Your seat is not paid for, either upgrade to Pro or let the team owner know they can pay for your seat.",
"hidden_team_owner_message": "You need a pro account to use teams, you are hidden until you upgrade.",
"link_expires": "p.s. It expires in {{expiresIn}} hours.",
"upgrade_to_per_seat": "Upgrade to Per-Seat",
"team_upgrade_seats_details": "Of the {{memberCount}} members in your team, {{unpaidCount}} seat(s) are unpaid. At ${{seatPrice}}/m per seat the estimated total cost of your membership is ${{totalCost}}/m.",
"team_upgraded_successfully": "Your team was upgraded successfully!",
"use_link_to_reset_password": "Use the link below to reset your password",
"hey_there": "Hey there,",
"forgot_your_password_calcom": "Forgot your password? - Cal.com",
@ -431,6 +439,8 @@
"confirm_remove_member": "Yes, remove member",
"remove_member": "Remove member",
"manage_your_team": "Manage your team",
"no_teams": "You don't have any teams yet.",
"no_teams_description": "Teams allow others to book events shared between your coworkers.",
"submit": "Submit",
"delete": "Delete",
"update": "Update",
@ -496,6 +506,10 @@
"create_first_team_and_invite_others": "Create your first team and invite other users to work together with you.",
"create_team_to_get_started": "Create a team to get started",
"teams": "Teams",
"team_billing": "Team Billing",
"upgrade_to_flexible_pro_title": "We've changed billing for teams",
"upgrade_to_flexible_pro_message": "There are members in your team without a seat. Upgrade your pro plan to cover missing seats.",
"changed_team_billing_info": "As of January 2020 we charge on a per-seat basis for team members. Members of your team who had Pro for free are now on a 14 day trial. Once their trial expires these members will be hidden from your team unless you upgrade now.",
"create_manage_teams_collaborative": "Create and manage teams to use collaborative features.",
"only_available_on_pro_plan": "This feature is only available in Pro plan",
"remove_cal_branding_description": "In order to remove the Cal branding from your booking pages, you need to upgrade to a Pro account.",

View file

@ -1,9 +1,19 @@
import { MembershipRole } from "@prisma/client";
import { MembershipRole, UserPlan } from "@prisma/client";
import { Prisma } from "@prisma/client";
import { randomBytes } from "crypto";
import { z } from "zod";
import {
addSeat,
removeSeat,
getTeamSeatStats,
downgradeTeamMembers,
upgradeTeam,
ensureSubscriptionQuantityCorrectness,
} from "@ee/lib/stripe/team-billing";
import { BASE_URL } from "@lib/config/constants";
import { HOSTED_CAL_FEATURES } from "@lib/config/constants";
import { sendTeamInviteEmail } from "@lib/emails/email-manager";
import { TeamInvite } from "@lib/emails/templates/team-invite-email";
import { getUserAvailability } from "@lib/queries/availability";
@ -26,7 +36,14 @@ export const viewerTeamsRouter = createProtectedRouter()
throw new TRPCError({ code: "UNAUTHORIZED", message: "You are not a member of this team." });
}
const membership = team?.members.find((membership) => membership.id === ctx.user.id);
return { ...team, membership: { role: membership?.role as MembershipRole } };
return {
...team,
membership: {
role: membership?.role as MembershipRole,
isMissingSeat: membership?.plan === UserPlan.FREE,
},
requiresUpgrade: HOSTED_CAL_FEATURES ? !!team.members.find((m) => m.plan !== UserPlan.PRO) : false,
};
},
})
// Returns teams I a member of
@ -132,6 +149,8 @@ export const viewerTeamsRouter = createProtectedRouter()
async resolve({ ctx, input }) {
if (!(await isTeamOwner(ctx.user?.id, input.teamId))) throw new TRPCError({ code: "UNAUTHORIZED" });
await downgradeTeamMembers(input.teamId);
// delete all memberships
await ctx.prisma.membership.deleteMany({
where: {
@ -166,6 +185,8 @@ export const viewerTeamsRouter = createProtectedRouter()
userId_teamId: { userId: input.memberId, teamId: input.teamId },
},
});
if (HOSTED_CAL_FEATURES) await removeSeat(ctx.user.id, input.teamId, input.memberId);
},
})
.mutation("inviteMember", {
@ -195,6 +216,8 @@ export const viewerTeamsRouter = createProtectedRouter()
},
});
let inviteeUserId: number | undefined = invitee?.id;
if (!invitee) {
// liberal email match
const isEmail = (str: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(str);
@ -206,7 +229,7 @@ export const viewerTeamsRouter = createProtectedRouter()
});
// valid email given, create User and add to team
await ctx.prisma.user.create({
const user = await ctx.prisma.user.create({
data: {
email: input.usernameOrEmail,
invitedTo: input.teamId,
@ -218,6 +241,7 @@ export const viewerTeamsRouter = createProtectedRouter()
},
},
});
inviteeUserId = user.id;
const token: string = randomBytes(32).toString("hex");
@ -273,6 +297,11 @@ export const viewerTeamsRouter = createProtectedRouter()
await sendTeamInviteEmail(teamInviteEvent);
}
}
try {
if (HOSTED_CAL_FEATURES) await addSeat(ctx.user.id, team.id, inviteeUserId);
} catch (e) {
console.log(e);
}
},
})
.mutation("acceptOrLeave", {
@ -291,6 +320,17 @@ export const viewerTeamsRouter = createProtectedRouter()
},
});
} else {
try {
//get team owner so we can alter their subscription seat count
const teamOwner = await ctx.prisma.membership.findFirst({
where: { teamId: input.teamId, role: MembershipRole.OWNER },
});
// TODO: disable if not hosted by Cal
if (teamOwner) await removeSeat(teamOwner.userId, input.teamId, ctx.user.id);
} catch (e) {
console.log(e);
}
await ctx.prisma.membership.delete({
where: {
userId_teamId: { userId: ctx.user.id, teamId: input.teamId },
@ -372,13 +412,37 @@ export const viewerTeamsRouter = createProtectedRouter()
throw new TRPCError({ code: "BAD_REQUEST", message: "Member doesn't have a username" });
// get availability for this member
const availability = await getUserAvailability({
return await getUserAvailability({
username: member.user.username,
timezone: input.timezone,
dateFrom: input.dateFrom,
dateTo: input.dateTo,
});
return availability;
},
})
.mutation("upgradeTeam", {
input: z.object({
teamId: z.number(),
}),
async resolve({ ctx, input }) {
if (!HOSTED_CAL_FEATURES)
throw new TRPCError({ code: "FORBIDDEN", message: "Team billing is not enabled" });
return await upgradeTeam(ctx.user.id, input.teamId);
},
})
.query("getTeamSeats", {
input: z.object({
teamId: z.number(),
}),
async resolve({ input }) {
return await getTeamSeatStats(input.teamId);
},
})
.mutation("ensureSubscriptionQuantityCorrectness", {
input: z.object({
teamId: z.number(),
}),
async resolve({ ctx, input }) {
return await ensureSubscriptionQuantityCorrectness(ctx.user.id, input.teamId);
},
});