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:
		
							parent
							
								
									c201bfab2d
								
							
						
					
					
						commit
						5567721431
					
				
					 31 changed files with 772 additions and 189 deletions
				
			
		
							
								
								
									
										50
									
								
								.vscode/launch.json
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										50
									
								
								.vscode/launch.json
									
									
									
									
										vendored
									
									
								
							|  | @ -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": [ | ||||
|         { | ||||
|             "type": "pwa-chrome", | ||||
|             "request": "launch", | ||||
|             "name": "Launch Chrome against localhost", | ||||
|             "url": "http://localhost:8080", | ||||
|             "webRoot": "${workspaceFolder}" | ||||
|         } | ||||
|     ] | ||||
|   "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", | ||||
|       "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" | ||||
|       } | ||||
|     } | ||||
|   ] | ||||
| } | ||||
|  | @ -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>} | ||||
|  |  | |||
|  | @ -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 | ||||
|  |  | |||
|  | @ -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" | ||||
|                   id="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 className="space-y-4"> | ||||
|               <TextField | ||||
|                 label={t("email_or_username")} | ||||
|                 id="inviteUser" | ||||
|                 name="inviteUser" | ||||
|                 placeholder="email@example.com" | ||||
|                 required | ||||
|               /> | ||||
|               <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"> | ||||
|  |  | |||
|  | @ -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`}> | ||||
|  |  | |||
|  | @ -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")} | ||||
|  |  | |||
|  | @ -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 | ||||
|  |  | |||
							
								
								
									
										36
									
								
								components/team/TeamPill.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										36
									
								
								components/team/TeamPill.tsx
									
									
									
									
									
										Normal 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]} />; | ||||
| } | ||||
|  | @ -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> | ||||
|   ); | ||||
| } | ||||
|  | @ -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"> | ||||
|       <CreateEventTypeButton | ||||
|         isIndividualTeam | ||||
|         canAddEvents={true} | ||||
|         options={[ | ||||
|           { 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"} | ||||
|       /> */} | ||||
|       {(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, | ||||
|             }, | ||||
|           ]} | ||||
|         /> | ||||
|       )} | ||||
|       <div className="space-y-1"> | ||||
|         <Link href={permalink} passHref={true}> | ||||
|           <a target="_blank"> | ||||
|  |  | |||
							
								
								
									
										90
									
								
								components/team/UpgradeToFlexibleProModal.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										90
									
								
								components/team/UpgradeToFlexibleProModal.tsx
									
									
									
									
									
										Normal 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> | ||||
|   ); | ||||
| } | ||||
|  | @ -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"> | ||||
|  |  | |||
|  | @ -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} | ||||
|  |  | |||
|  | @ -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
									
								
							
							
						
						
									
										67
									
								
								ee/lib/stripe/customer.ts
									
									
									
									
									
										Normal 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; | ||||
| } | ||||
|  | @ -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; | ||||
|  |  | |||
							
								
								
									
										275
									
								
								ee/lib/stripe/team-billing.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										275
									
								
								ee/lib/stripe/team-billing.ts
									
									
									
									
									
										Normal 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"; | ||||
| } | ||||
|  | @ -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}`;
 | ||||
|  |  | |||
|  | @ -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({ | ||||
|  |  | |||
							
								
								
									
										21
									
								
								ee/pages/api/teams/[team]/upgrade.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								ee/pages/api/teams/[team]/upgrade.ts
									
									
									
									
									
										Normal 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`); | ||||
|   } | ||||
| } | ||||
|  | @ -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"; | ||||
|  |  | |||
|  | @ -10,7 +10,7 @@ declare global { | |||
| export const prisma = | ||||
|   globalThis.prisma || | ||||
|   new PrismaClient({ | ||||
|     log: ["query", "error", "warn"], | ||||
|     // log: ["query", "error", "warn"],
 | ||||
|   }); | ||||
| 
 | ||||
| if (!IS_PRODUCTION) { | ||||
|  |  | |||
|  | @ -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, | ||||
|     }; | ||||
|  |  | |||
|  | @ -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"; | ||||
|  |  | |||
							
								
								
									
										1
									
								
								pages/api/teams/[team]/upgrade.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								pages/api/teams/[team]/upgrade.ts
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1 @@ | |||
| export { default } from "@ee/pages/api/teams/[team]/upgrade"; | ||||
|  | @ -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"; | ||||
|  |  | |||
|  | @ -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> | ||||
|  |  | |||
|  | @ -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} /> | ||||
|  |  | |||
|  | @ -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) => ({ | ||||
|  |  | |||
|  | @ -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.", | ||||
|  |  | |||
|  | @ -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); | ||||
|     }, | ||||
|   }); | ||||
|  |  | |||
		Loading…
	
		Reference in a new issue
	
	 Jamie Pine
						Jamie Pine