From 55677214310174aa7a1aeba24ddf998963861163 Mon Sep 17 00:00:00 2001 From: Jamie Pine <32987599+jamiepine@users.noreply.github.com> Date: Mon, 7 Feb 2022 15:35:26 -0800 Subject: [PATCH] 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 Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com> --- .vscode/launch.json | 50 +++- components/Dialog.tsx | 2 +- components/eventtype/EventTypeDescription.tsx | 2 +- components/team/MemberInvitationModal.tsx | 43 +-- components/team/MemberListItem.tsx | 16 +- components/team/TeamCreateModal.tsx | 10 +- components/team/TeamListItem.tsx | 6 +- components/team/TeamPill.tsx | 36 +++ components/team/TeamRole.tsx | 40 --- components/team/TeamSettingsRightSidebar.tsx | 30 +- components/team/UpgradeToFlexibleProModal.tsx | 90 ++++++ components/ui/Alert.tsx | 6 +- components/ui/LinkIconButton.tsx | 2 +- ee/components/saml/Configuration.tsx | 7 +- ee/lib/stripe/customer.ts | 67 +++++ ee/lib/stripe/server.ts | 41 --- ee/lib/stripe/team-billing.ts | 275 ++++++++++++++++++ .../api/integrations/stripepayment/add.ts | 2 +- .../api/integrations/stripepayment/portal.ts | 31 +- ee/pages/api/teams/[team]/upgrade.ts | 21 ++ lib/config/constants.ts | 1 + lib/prisma.ts | 2 +- lib/queries/teams/index.ts | 4 +- pages/_app.tsx | 1 + pages/api/teams/[team]/upgrade.ts | 1 + pages/api/user/me.ts | 2 +- pages/settings/teams.tsx | 13 +- pages/settings/teams/[id].tsx | 65 ++++- pages/team/[slug].tsx | 5 + public/static/locales/en/common.json | 14 + server/routers/viewer/teams.tsx | 76 ++++- 31 files changed, 772 insertions(+), 189 deletions(-) create mode 100644 components/team/TeamPill.tsx delete mode 100644 components/team/TeamRole.tsx create mode 100644 components/team/UpgradeToFlexibleProModal.tsx create mode 100644 ee/lib/stripe/customer.ts create mode 100644 ee/lib/stripe/team-billing.ts create mode 100644 ee/pages/api/teams/[team]/upgrade.ts create mode 100644 pages/api/teams/[team]/upgrade.ts diff --git a/.vscode/launch.json b/.vscode/launch.json index 7a9dfa04..e93ed3bc 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -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": ["/**"], + "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" + } + } + ] } \ No newline at end of file diff --git a/components/Dialog.tsx b/components/Dialog.tsx index e2b0d1ea..38639bb9 100644 --- a/components/Dialog.tsx +++ b/components/Dialog.tsx @@ -32,7 +32,7 @@ type DialogHeaderProps = { export function DialogHeader(props: DialogHeaderProps) { return (
- {props.subtitle &&
{props.subtitle}
} diff --git a/components/eventtype/EventTypeDescription.tsx b/components/eventtype/EventTypeDescription.tsx index 73f12a65..16f2a374 100644 --- a/components/eventtype/EventTypeDescription.tsx +++ b/components/eventtype/EventTypeDescription.tsx @@ -37,7 +37,7 @@ export const EventTypeDescription = ({ eventType, className }: EventTypeDescript {eventType.description.length > 100 && "..."} )} -
    +
-
-
- - -
-
-
{errorMessage && (

diff --git a/components/team/MemberListItem.tsx b/components/team/MemberListItem.tsx index 0bb67d68..db809023 100644 --- a/components/team/MemberListItem.tsx +++ b/components/team/MemberListItem.tsx @@ -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) {

- {!props.member.accepted && } - + {/* Tooltip doesn't show... WHY????? */} + {props.member.isMissingSeat && ( + + + + )} + {!props.member.accepted && } + {props.member.role && }
@@ -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"> @@ -167,7 +173,7 @@ export default function MemberListItem(props: Props) { {showTeamAvailabilityModal && ( -
+
{props.team.membership.role !== MembershipRole.MEMBER && ( diff --git a/components/team/TeamCreateModal.tsx b/components/team/TeamCreateModal.tsx index 30f76268..fd7efb6d 100644 --- a/components/team/TeamCreateModal.tsx +++ b/components/team/TeamCreateModal.tsx @@ -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); const nameRef = useRef() as React.MutableRefObject; 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) => { @@ -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" />
+ {errorMessage && }
+ ); +} + +export function TeamRole(props: { role: MembershipRole }) { + const { t } = useLocale(); + const keys: Record = { + [MembershipRole.OWNER]: undefined, + [MembershipRole.ADMIN]: "red", + [MembershipRole.MEMBER]: "blue", + }; + return ; +} diff --git a/components/team/TeamRole.tsx b/components/team/TeamRole.tsx deleted file mode 100644 index dc4e28fb..00000000 --- a/components/team/TeamRole.tsx +++ /dev/null @@ -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 ( - - {(() => { - 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 ""; - } - })()} - - ); -} diff --git a/components/team/TeamSettingsRightSidebar.tsx b/components/team/TeamSettingsRightSidebar.tsx index f2635c2c..6441c0cc 100644 --- a/components/team/TeamSettingsRightSidebar.tsx +++ b/components/team/TeamSettingsRightSidebar.tsx @@ -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 (
- - {/* */} + {(props.role === MembershipRole.OWNER || props.role === MembershipRole.ADMIN) && ( + + )}
diff --git a/components/team/UpgradeToFlexibleProModal.tsx b/components/team/UpgradeToFlexibleProModal.tsx new file mode 100644 index 00000000..082522ad --- /dev/null +++ b/components/team/UpgradeToFlexibleProModal.tsx @@ -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(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 ( + { + setErrorMessage(null); + }}> + + {"Upgrade Now"} + + + + +

{t("changed_team_billing_info")}

+ {data && ( +

+ {t("team_upgrade_seats_details", { + memberCount: data.totalMembers, + unpaidCount: data.missingSeats, + seatPrice: 12, + totalCost: (data.totalMembers - data.freeSeats) * 12 + 12, + })} +

+ )} + + {errorMessage && ( + + )} + + + + + + + +
+ + ); +} diff --git a/components/ui/Alert.tsx b/components/ui/Alert.tsx index f717ccc0..430afd08 100644 --- a/components/ui/Alert.tsx +++ b/components/ui/Alert.tsx @@ -15,10 +15,10 @@ export function Alert(props: AlertProps) { return (
diff --git a/components/ui/LinkIconButton.tsx b/components/ui/LinkIconButton.tsx index eefdb0e9..c155ab36 100644 --- a/components/ui/LinkIconButton.tsx +++ b/components/ui/LinkIconButton.tsx @@ -10,8 +10,8 @@ export default function LinkIconButton(props: LinkIconButtonProps) { return (