diff --git a/components/dashboard/src/App.tsx b/components/dashboard/src/App.tsx index 7b874e766c062c..7ba0ba0c8b53f6 100644 --- a/components/dashboard/src/App.tsx +++ b/components/dashboard/src/App.tsx @@ -65,6 +65,7 @@ const NewTeam = React.lazy(() => import(/* webpackPrefetch: true */ "./teams/New const JoinTeam = React.lazy(() => import(/* webpackPrefetch: true */ "./teams/JoinTeam")); const Members = React.lazy(() => import(/* webpackPrefetch: true */ "./teams/Members")); const TeamSettings = React.lazy(() => import(/* webpackPrefetch: true */ "./teams/TeamSettings")); +const TeamBilling = React.lazy(() => import(/* webpackPrefetch: true */ "./teams/TeamBilling")); const NewProject = React.lazy(() => import(/* webpackPrefetch: true */ "./projects/NewProject")); const ConfigureProject = React.lazy(() => import(/* webpackPrefetch: true */ "./projects/ConfigureProject")); const Projects = React.lazy(() => import(/* webpackPrefetch: true */ "./projects/Projects")); @@ -441,6 +442,9 @@ function App() { if (maybeProject === "settings") { return ; } + if (maybeProject === "billing") { + return ; + } if (resourceOrPrebuild === "prebuilds") { return ; } diff --git a/components/dashboard/src/Menu.tsx b/components/dashboard/src/Menu.tsx index f0d190c5c1fded..225c1b5f6d3414 100644 --- a/components/dashboard/src/Menu.tsx +++ b/components/dashboard/src/Menu.tsx @@ -54,6 +54,7 @@ export default function Menu() { "projects", "members", "settings", + "billing", // admin sub-pages "users", "workspaces", @@ -188,7 +189,7 @@ export default function Menu() { teamSettingsList.push({ title: "Settings", link: `/t/${team.slug}/settings`, - alternatives: getTeamSettingsMenu(team).flatMap((e) => e.link), + alternatives: getTeamSettingsMenu({ team, showPaymentUI }).flatMap((e) => e.link), }); } diff --git a/components/dashboard/src/chargebee/chargebee-client.ts b/components/dashboard/src/chargebee/chargebee-client.ts index 04bcc2dfdeab58..0c758d8775bfe7 100644 --- a/components/dashboard/src/chargebee/chargebee-client.ts +++ b/components/dashboard/src/chargebee/chargebee-client.ts @@ -42,18 +42,23 @@ export interface OpenPortalParams { export class ChargebeeClient { constructor(protected readonly client: chargebee.Client) {} - static async getOrCreate(): Promise { + static async getOrCreate(teamId?: string): Promise { const create = async () => { const chargebeeClient = await ChargebeeClientProvider.get(); const client = new ChargebeeClient(chargebeeClient); - client.createPortalSession(); + client.createPortalSession(teamId); return client; }; const w = window as any; const _gp = w._gp || (w._gp = {}); - const chargebeeClient = _gp.chargebeeClient || (_gp.chargebeeClient = await create()); - return chargebeeClient; + if (teamId) { + if (!_gp.chargebeeClients) { + _gp.chargebeeClients = {}; + } + return _gp.chargebeeClients[teamId] || (_gp.chargebeeClients[teamId] = await create()); + } + return _gp.chargebeeClient || (_gp.chargebeeClient = await create()); } checkout( @@ -82,10 +87,10 @@ export class ChargebeeClient { }); } - createPortalSession() { + createPortalSession(teamId?: string) { const paymentServer = getGitpodService().server; this.client.setPortalSession(async () => { - return paymentServer.createPortalSession(); + return teamId ? paymentServer.createTeamPortalSession(teamId) : paymentServer.createPortalSession(); }); } diff --git a/components/dashboard/src/teams/TeamBilling.tsx b/components/dashboard/src/teams/TeamBilling.tsx new file mode 100644 index 00000000000000..c42221fadc7272 --- /dev/null +++ b/components/dashboard/src/teams/TeamBilling.tsx @@ -0,0 +1,284 @@ +/** + * Copyright (c) 2022 Gitpod GmbH. All rights reserved. + * Licensed under the GNU Affero General Public License (AGPL). + * See License-AGPL.txt in the project root for license information. + */ + +import { TeamMemberInfo } from "@gitpod/gitpod-protocol"; +import { Currency, Plan, Plans, PlanType } from "@gitpod/gitpod-protocol/lib/plans"; +import { TeamSubscription2 } from "@gitpod/gitpod-protocol/lib/team-subscription-protocol"; +import React, { useContext, useEffect, useState } from "react"; +import { useLocation } from "react-router"; +import { ChargebeeClient } from "../chargebee/chargebee-client"; +import { PageWithSubMenu } from "../components/PageWithSubMenu"; +import Card from "../components/Card"; +import DropDown from "../components/DropDown"; +import PillLabel from "../components/PillLabel"; +import SolidCard from "../components/SolidCard"; +import { ReactComponent as CheckSvg } from "../images/check.svg"; +import { ReactComponent as Spinner } from "../icons/Spinner.svg"; +import { PaymentContext } from "../payment-context"; +import { getGitpodService } from "../service/service"; +import { getCurrentTeam, TeamsContext } from "./teams-context"; +import { getTeamSettingsMenu } from "./TeamSettings"; + +type PendingPlan = Plan & { pendingSince: number }; + +export default function TeamBilling() { + const { teams } = useContext(TeamsContext); + const location = useLocation(); + const team = getCurrentTeam(location, teams); + const [members, setMembers] = useState([]); + const [teamSubscription, setTeamSubscription] = useState(); + const { showPaymentUI, currency, setCurrency } = useContext(PaymentContext); + const [pendingTeamPlan, setPendingTeamPlan] = useState(); + const [pollTeamSubscriptionTimeout, setPollTeamSubscriptionTimeout] = useState(); + + useEffect(() => { + if (!team) { + return; + } + (async () => { + const [memberInfos, subscription] = await Promise.all([ + getGitpodService().server.getTeamMembers(team.id), + getGitpodService().server.getTeamSubscription(team.id), + ]); + setMembers(memberInfos); + setTeamSubscription(subscription); + })(); + }, [team]); + + useEffect(() => { + setPendingTeamPlan(undefined); + if (!team) { + return; + } + try { + const pendingTeamPlanString = window.localStorage.getItem(`pendingPlanForTeam${team.id}`); + if (!pendingTeamPlanString) { + return; + } + const pending = JSON.parse(pendingTeamPlanString); + setPendingTeamPlan(pending); + } catch (error) { + console.error("Could not load pending team plan", team.id, error); + } + }, [team]); + + useEffect(() => { + if (!pendingTeamPlan || !team) { + return; + } + if (teamSubscription && teamSubscription.planId === pendingTeamPlan.chargebeeId) { + // The purchase was successful! + window.localStorage.removeItem(`pendingPlanForTeam${team.id}`); + clearTimeout(pollTeamSubscriptionTimeout!); + setPendingTeamPlan(undefined); + return; + } + if (pendingTeamPlan.pendingSince + 1000 * 60 * 5 < Date.now()) { + // Pending team plans expire after 5 minutes + window.localStorage.removeItem(`pendingPlanForTeam${team.id}`); + clearTimeout(pollTeamSubscriptionTimeout!); + setPendingTeamPlan(undefined); + return; + } + if (!pollTeamSubscriptionTimeout) { + // Refresh team subscription in 5 seconds in order to poll for purchase confirmation + const timeout = setTimeout(async () => { + const ts = await getGitpodService().server.getTeamSubscription(team.id); + setTeamSubscription(ts); + setPollTeamSubscriptionTimeout(undefined); + }, 5000); + setPollTeamSubscriptionTimeout(timeout); + } + return function cleanup() { + clearTimeout(pollTeamSubscriptionTimeout!); + }; + }, [pendingTeamPlan, pollTeamSubscriptionTimeout, team, teamSubscription]); + + const availableTeamPlans = Plans.getAvailableTeamPlans(currency || "USD").filter((p) => p.type !== "student"); + + const checkout = async (plan: Plan) => { + if (!team || members.length < 1) { + return; + } + const chargebeeClient = await ChargebeeClient.getOrCreate(team.id); + await new Promise((resolve, reject) => { + chargebeeClient.checkout((paymentServer) => paymentServer.teamCheckout(team.id, plan.chargebeeId), { + success: resolve, + error: reject, + }); + }); + const pending = { + ...plan, + pendingSince: Date.now(), + }; + setPendingTeamPlan(pending); + window.localStorage.setItem(`pendingPlanForTeam${team.id}`, JSON.stringify(pending)); + }; + + const isLoading = members.length === 0; + const teamPlan = pendingTeamPlan || Plans.getById(teamSubscription?.planId); + + const featuresByPlanType: { [type in PlanType]?: Array } = { + // Team Professional + "professional-new": [ + Public & Private Repositories, + 8 Parallel Workspaces, + 30 min Inactivity Timeout, + ], + // Team Unleaashed + professional: [ + Public & Private Repositories, + 16 Parallel Workspaces, + 1 hr Inactivity Timeout, + 3 hr Timeout Boost, + ], + }; + + return ( + +

{!teamPlan ? "No billing plan" : "Plan"}

+

+ {!teamPlan ? ( +
+ Select a new billing plan for this team. Currency: + setCurrency("EUR"), + }, + { + title: "USD", + onClick: () => setCurrency("USD"), + }, + ]} + /> +
+ ) : ( + + This team is currently on the {teamPlan.name} plan. + + )} +

+
+ {isLoading && ( + <> + +
+ +
+
+ +
+ +
+
+ + )} + {!isLoading && !teamPlan && ( + <> + {availableTeamPlans.map((tp) => ( + <> + checkout(tp)} + > +
+
{tp.name}
+
Unlimited hours
+
Includes:
+
+ {(featuresByPlanType[tp.type] || []).map((f) => ( + + + {f} + + ))} +
+
+ + {members.length} x {Currency.getSymbol(tp.currency)} + {tp.pricePerMonth} = {Currency.getSymbol(tp.currency)} + {members.length * tp.pricePerMonth} per month + +
+
+
+ + ))} + + )} + {!isLoading && teamPlan && ( + <> + +
+
{teamPlan.name}
+
Unlimited hours
+
Includes:
+
+ {(featuresByPlanType[teamPlan.type] || []).map((f) => ( + + + {f} + + ))} +
+
+
+
+ {!teamSubscription ? ( + +
+ +
+
+ ) : ( + +
+
Members
+
{members.length}
+
Next invoice on
+
+ {guessNextInvoiceDate(teamSubscription.startDate).toDateString()} +
+
+ +
+
+
+ )} + + )} +
+
+ ); +} + +function guessNextInvoiceDate(startDate: string): Date { + const now = new Date(); + const date = new Date(startDate); + while (date < now) { + date.setMonth(date.getMonth() + 1); + } + return date; +} diff --git a/components/dashboard/src/teams/TeamSettings.tsx b/components/dashboard/src/teams/TeamSettings.tsx index e67d0c09997fea..1f0fd0e8e8135a 100644 --- a/components/dashboard/src/teams/TeamSettings.tsx +++ b/components/dashboard/src/teams/TeamSettings.tsx @@ -10,16 +10,26 @@ import { Redirect, useLocation } from "react-router"; import CodeText from "../components/CodeText"; import ConfirmationModal from "../components/ConfirmationModal"; import { PageWithSubMenu } from "../components/PageWithSubMenu"; +import { PaymentContext } from "../payment-context"; import { getGitpodService, gitpodHostUrl } from "../service/service"; import { UserContext } from "../user-context"; import { getCurrentTeam, TeamsContext } from "./teams-context"; -export function getTeamSettingsMenu(team?: Team) { +export function getTeamSettingsMenu(params: { team?: Team; showPaymentUI?: boolean }) { + const { team, showPaymentUI } = params; return [ { title: "General", link: [`/t/${team?.slug}/settings`], }, + ...(showPaymentUI + ? [ + // { + // title: "Billing", + // link: [`/t/${team?.slug}/billing`], + // }, + ] + : []), ]; } @@ -31,6 +41,7 @@ export default function TeamSettings() { const { user } = useContext(UserContext); const location = useLocation(); const team = getCurrentTeam(location, teams); + const { showPaymentUI } = useContext(PaymentContext); const close = () => setModal(false); @@ -57,7 +68,7 @@ export default function TeamSettings() { return ( <> diff --git a/components/ee/payment-endpoint/src/accounting/index.ts b/components/ee/payment-endpoint/src/accounting/index.ts index acf4db6751bd37..8d4a10cd0c0473 100644 --- a/components/ee/payment-endpoint/src/accounting/index.ts +++ b/components/ee/payment-endpoint/src/accounting/index.ts @@ -9,4 +9,5 @@ export * from './account-service'; export * from './account-service-impl'; export * from './subscription-service'; export * from './team-subscription-service'; +export * from './team-subscription2-service'; export * from './accounting-util'; \ No newline at end of file diff --git a/components/ee/payment-endpoint/src/accounting/team-subscription2-service.ts b/components/ee/payment-endpoint/src/accounting/team-subscription2-service.ts new file mode 100644 index 00000000000000..84f306098c363a --- /dev/null +++ b/components/ee/payment-endpoint/src/accounting/team-subscription2-service.ts @@ -0,0 +1,92 @@ +/** + * Copyright (c) 2022 Gitpod GmbH. All rights reserved. + * Licensed under the Gitpod Enterprise Source Code License, + * See License.enterprise.txt in the project root folder. + */ + +import { AccountingDB, TeamDB } from "@gitpod/gitpod-db/lib"; +import { AssignedTeamSubscription2, Subscription } from "@gitpod/gitpod-protocol/lib/accounting-protocol"; +import { Plans } from "@gitpod/gitpod-protocol/lib/plans"; +import { TeamSubscription2 } from "@gitpod/gitpod-protocol/lib/team-subscription-protocol"; +import { inject, injectable } from "inversify"; +import { SubscriptionModel } from "./subscription-model"; +import { SubscriptionService } from "./subscription-service"; + +@injectable() +export class TeamSubscription2Service { + @inject(TeamDB) protected readonly teamDB: TeamDB; + @inject(AccountingDB) protected readonly accountingDb: AccountingDB; + @inject(SubscriptionService) protected readonly subscriptionService: SubscriptionService; + + async addAllTeamMemberSubscriptions(ts2: TeamSubscription2): Promise { + const members = await this.teamDB.findMembersByTeam(ts2.teamId); + for (const member of members) { + await this.addTeamMemberSubscription(ts2, member.userId); + } + } + + async addTeamMemberSubscription(ts2: TeamSubscription2, userId: string): Promise { + const membership = await this.teamDB.findTeamMembership(userId, ts2.teamId); + if (!membership) { + throw new Error(`Could not find membership for user '${userId}' in team '${ts2.teamId}'`); + } + const plan = Plans.getById(ts2.planId)!; + const { startDate } = Subscription.calculateCurrentPeriod(ts2.startDate, new Date()); + return this.accountingDb.transaction(async (db) => { + const subscription = await this.addSubscription(db, userId, ts2.planId, membership.id, startDate, Plans.getHoursPerMonth(plan)); + await this.teamDB.setTeamMemberSubscription(userId, ts2.teamId, subscription.uid); + }); + } + + protected async addSubscription(db: AccountingDB, userId: string, planId: string, teamMembershipId: string, startDate: string, amount: number, firstMonthAmount?: number, endDate?: string, cancelationDate?: string) { + const model = await this.loadSubscriptionModel(db, userId); + const subscription = Subscription.create({ + userId, + planId, + amount, + startDate, + endDate, + cancellationDate: cancelationDate || endDate, + teamMembershipId, + firstMonthAmount + }); + model.add(subscription); + await this.subscriptionService.store(db, model); + return subscription; + } + + async cancelAllTeamMemberSubscriptions(ts2: TeamSubscription2, date: Date): Promise { + const members = await this.teamDB.findMembersByTeam(ts2.teamId); + for (const member of members) { + const membership = await this.teamDB.findTeamMembership(member.userId, ts2.teamId); + if (!membership) { + throw new Error(`Could not find membership for user '${member.userId}' in team '${ts2.teamId}'`); + } + await this.cancelTeamMemberSubscription(ts2, member.userId, membership.id, date); + } + } + + async cancelTeamMemberSubscription(ts2: TeamSubscription2, userId: string, teamMemberShipId: string, date: Date): Promise { + const { endDate } = Subscription.calculateCurrentPeriod(ts2.startDate, date); + return this.accountingDb.transaction(async (db) => { + await this.cancelSubscription(db, userId, ts2.planId, teamMemberShipId, endDate); + }); + } + + protected async cancelSubscription(db: AccountingDB, userId: string, planId: string, teamMembershipId: string, cancellationDate: string) { + const model = await this.loadSubscriptionModel(db, userId); + const subscription = model.findSubscriptionByTeamMembershipId(teamMembershipId); + if (!subscription) { + throw new Error(`Cannot find subscription for Team Membership '${teamMembershipId}'!`); + } + model.cancel(subscription, cancellationDate, cancellationDate); + await this.subscriptionService.store(db, model); + } + + protected async loadSubscriptionModel(db: AccountingDB, userId: string) { + const subscriptions = await db.findAllSubscriptionsForUser(userId); + const subscriptionsFromTS = subscriptions.filter(s => AssignedTeamSubscription2.is(s)); + return new SubscriptionModel(userId, subscriptionsFromTS); + } + +} diff --git a/components/ee/payment-endpoint/src/chargebee/team-subscription-handler.ts b/components/ee/payment-endpoint/src/chargebee/team-subscription-handler.ts index 86b4bb59474b08..5e956970c257f6 100644 --- a/components/ee/payment-endpoint/src/chargebee/team-subscription-handler.ts +++ b/components/ee/payment-endpoint/src/chargebee/team-subscription-handler.ts @@ -4,26 +4,30 @@ * See License.enterprise.txt in the project root folder. */ -import { inject, injectable } from 'inversify'; +import { inject, injectable } from "inversify"; -import { TeamSubscriptionDB } from '@gitpod/gitpod-db/lib/team-subscription-db'; -import { log, LogContext } from '@gitpod/gitpod-protocol/lib/util/logging'; -import { Plans } from '@gitpod/gitpod-protocol/lib/plans'; -import { TeamSubscription } from '@gitpod/gitpod-protocol/lib/team-subscription-protocol'; -import { getCancelledAt, getStartDate } from './chargebee-subscription-helper'; -import { Chargebee as chargebee } from './chargebee-types'; -import { EventHandler } from './chargebee-event-handler'; +import { TeamSubscriptionDB } from "@gitpod/gitpod-db/lib/team-subscription-db"; +import { TeamSubscription2DB } from "@gitpod/gitpod-db/lib/team-subscription-2-db"; +import { log, LogContext } from "@gitpod/gitpod-protocol/lib/util/logging"; +import { Plans } from "@gitpod/gitpod-protocol/lib/plans"; +import { TeamSubscription, TeamSubscription2 } from "@gitpod/gitpod-protocol/lib/team-subscription-protocol"; +import { getCancelledAt, getStartDate } from "./chargebee-subscription-helper"; +import { Chargebee as chargebee } from "./chargebee-types"; +import { EventHandler } from "./chargebee-event-handler"; import { TeamSubscriptionService } from "../accounting/team-subscription-service"; -import { Config } from '../config'; +import { TeamSubscription2Service } from "../accounting/team-subscription2-service"; +import { Config } from "../config"; @injectable() export class TeamSubscriptionHandler implements EventHandler { @inject(Config) protected readonly config: Config; @inject(TeamSubscriptionDB) protected readonly db: TeamSubscriptionDB; + @inject(TeamSubscription2DB) protected readonly db2: TeamSubscription2DB; @inject(TeamSubscriptionService) protected readonly service: TeamSubscriptionService; + @inject(TeamSubscription2Service) protected readonly service2: TeamSubscription2Service; canHandle(event: chargebee.Event): boolean { - if (event.event_type.startsWith('subscription')) { + if (event.event_type.startsWith("subscription")) { const evt = event as chargebee.Event; const plan = Plans.getById(evt.content.subscription.plan_id); if (!plan) { @@ -38,13 +42,13 @@ export class TeamSubscriptionHandler implements EventHandler): Promise { const chargebeeSubscription = event.content.subscription; - const userId = chargebeeSubscription.customer_id; + const customerId = chargebeeSubscription.customer_id; const eventType = event.event_type; const logContext = this.userContext(event); log.info(logContext, `Start TeamSubscriptionHandler.handleSingleEvent`, { eventType }); try { - await this.mapToTeamSubscription(userId, eventType, chargebeeSubscription); + await this.mapToTeamSubscription(customerId, eventType, chargebeeSubscription); } catch (error) { log.error(logContext, "Error in TeamSubscriptionHandler.handleSingleEvent", error); throw error; @@ -53,17 +57,29 @@ export class TeamSubscriptionHandler implements EventHandler { const subs = await db.findTeamSubscriptions({ userId, - paymentReference: chargebeeSubscription.id + paymentReference: chargebeeSubscription.id, }); if (subs.length === 0) { // Sanity check: If we try to create too many slots here we OOM, so we error instead. const quantity = chargebeeSubscription.plan_quantity; if (quantity > this.config.maxTeamSlotsOnCreation) { - throw new Error(`(TS ${chargebeeSubscription.id}): nr of slots on creation (${quantity}) is higher than configured maximum (${this.config.maxTeamSlotsOnCreation}). Skipping creation!`); + throw new Error( + `(TS ${chargebeeSubscription.id}): nr of slots on creation (${quantity}) is higher than configured maximum (${this.config.maxTeamSlotsOnCreation}). Skipping creation!`, + ); } const ts = TeamSubscription.create({ @@ -78,12 +94,12 @@ export class TeamSubscriptionHandler implements EventHandler s.paymentReference === chargebeeSubscription.id); + const oldSubscription = subs.find((s) => s.paymentReference === chargebeeSubscription.id); if (!oldSubscription) { throw new Error(`Cannot find TeamSubscription for paymentReference ${chargebeeSubscription.id}!`); } - if (eventType === 'subscription_cancelled') { + if (eventType === "subscription_cancelled") { const cancelledAt = getCancelledAt(chargebeeSubscription); oldSubscription.endDate = cancelledAt; await this.service.deactivateAllSlots(oldSubscription, new Date(cancelledAt)); @@ -94,7 +110,37 @@ export class TeamSubscriptionHandler implements EventHandler { + const sub = await db2.findByPaymentRef(teamId, chargebeeSubscription.id); + if (!sub) { + const ts2 = TeamSubscription2.create({ + teamId, + paymentReference: chargebeeSubscription.id, + planId: chargebeeSubscription.plan_id, + quantity: chargebeeSubscription.plan_quantity, + startDate: getStartDate(chargebeeSubscription), + endDate: chargebeeSubscription.cancelled_at ? getCancelledAt(chargebeeSubscription) : undefined, + }); + await db2.storeEntry(ts2); + await this.service2.addAllTeamMemberSubscriptions(ts2); + } else { + if (eventType === "subscription_cancelled") { + const cancelledAt = getCancelledAt(chargebeeSubscription); + sub.endDate = cancelledAt; + await this.service2.cancelAllTeamMemberSubscriptions(sub, new Date(cancelledAt)); + } + sub.quantity = chargebeeSubscription.plan_quantity; + await db2.storeEntry(sub); + } + }); + } + protected userContext(event: chargebee.Event): LogContext { return { userId: event.content.subscription.customer_id }; } -} \ No newline at end of file +} diff --git a/components/ee/payment-endpoint/src/chargebee/upgrade-helper.ts b/components/ee/payment-endpoint/src/chargebee/upgrade-helper.ts index 58bf34fc481527..11185a4af3713a 100644 --- a/components/ee/payment-endpoint/src/chargebee/upgrade-helper.ts +++ b/components/ee/payment-endpoint/src/chargebee/upgrade-helper.ts @@ -6,6 +6,7 @@ import { injectable, inject } from "inversify"; import { ChargebeeProvider } from "./chargebee-provider"; +import { Chargebee as chargebee } from "./chargebee-types"; import { LogContext, log } from "@gitpod/gitpod-protocol/lib/util/logging"; @injectable() @@ -22,24 +23,75 @@ export class UpgradeHelper { * @param description * @param upgradeTimestamp */ - async chargeForUpgrade(userId: string, chargebeeSubscriptionId: string, amountInCents: number, description: string, upgradeTimestamp: string) { + async chargeForUpgrade( + userId: string, + chargebeeSubscriptionId: string, + amountInCents: number, + description: string, + upgradeTimestamp: string, + ) { const logContext: LogContext = { userId }; - const logPayload = { chargebeeSubscriptionId: chargebeeSubscriptionId, amountInCents, description, upgradeTimestamp }; + const logPayload = { + chargebeeSubscriptionId: chargebeeSubscriptionId, + amountInCents, + description, + upgradeTimestamp, + }; await new Promise((resolve, reject) => { - log.info(logContext, 'Charge on Upgrade: Upgrade detected.', logPayload); - this.chargebeeProvider.subscription.add_charge_at_term_end(chargebeeSubscriptionId, { - amount: amountInCents, - description - }).request(function (error: any, result: any) { - if (error) { - log.error(logContext, 'Charge on Upgrade: error', error, logPayload); - reject(error); - } else { - log.info(logContext, 'Charge on Upgrade: successful', logPayload); - resolve(); - } - }); + log.info(logContext, "Charge on Upgrade: Upgrade detected.", logPayload); + this.chargebeeProvider.subscription + .add_charge_at_term_end(chargebeeSubscriptionId, { + amount: amountInCents, + description, + }) + .request(function (error: any, result: any) { + if (error) { + log.error(logContext, "Charge on Upgrade: error", error, logPayload); + reject(error); + } else { + log.info(logContext, "Charge on Upgrade: successful", logPayload); + resolve(); + } + }); }); } -} \ No newline at end of file + + // Returns a ratio between 0 and 1: + // 0 means we've just finished the term + // 1 means we still have the entire term left + getCurrentTermRemainingRatio(chargebeeSubscription: chargebee.Subscription): number { + if (!chargebeeSubscription.next_billing_at) { + throw new Error("subscription.next_billing_at must be set."); + } + const now = new Date(); + const nextBilling = new Date(chargebeeSubscription.next_billing_at * 1000); + const remainingMs = nextBilling.getTime() - now.getTime(); + + const getBillingCycleMs = (unit: chargebee.BillingPeriodUnit): number => { + const previousBilling = new Date(nextBilling.getTime()); + switch (unit) { + case "day": + previousBilling.setDate(nextBilling.getDate() - 1); + break; + case "week": + previousBilling.setDate(nextBilling.getDate() - 7); + break; + case "month": + previousBilling.setMonth(nextBilling.getMonth() - 1); + break; + case "year": + previousBilling.setFullYear(nextBilling.getFullYear() - 1); + break; + } + return nextBilling.getTime() - previousBilling.getTime(); + }; + + const billingCycleMs = + typeof chargebeeSubscription.billing_period === "number" && chargebeeSubscription.billing_period_unit + ? chargebeeSubscription.billing_period * getBillingCycleMs(chargebeeSubscription.billing_period_unit) + : 1 * getBillingCycleMs("month"); + + return remainingMs / billingCycleMs; + } +} diff --git a/components/ee/payment-endpoint/src/container-module.ts b/components/ee/payment-endpoint/src/container-module.ts index 3156494e53e9fe..a9971f25b6975b 100644 --- a/components/ee/payment-endpoint/src/container-module.ts +++ b/components/ee/payment-endpoint/src/container-module.ts @@ -17,6 +17,7 @@ import { TeamSubscriptionHandler } from "./chargebee/team-subscription-handler"; import { CompositeEventHandler, EventHandler } from "./chargebee/chargebee-event-handler"; import { UpgradeHelper } from "./chargebee/upgrade-helper"; import { TeamSubscriptionService } from "./accounting/team-subscription-service"; +import { TeamSubscription2Service } from "./accounting/team-subscription2-service"; import { AccountService } from "./accounting/account-service"; import { AccountServiceImpl } from "./accounting/account-service-impl"; import { GithubEndpointController } from "./github/endpoint-controller"; @@ -46,6 +47,7 @@ export const productionContainerModule = new ContainerModule((bind, unbind, isBo bind(SubscriptionService).toSelf().inSingletonScope(); bind(TeamSubscriptionService).toSelf().inSingletonScope(); + bind(TeamSubscription2Service).toSelf().inSingletonScope(); bind(AccountService).to(AccountServiceImpl).inSingletonScope(); bind(ChargebeeProvider).toSelf().inSingletonScope(); diff --git a/components/gitpod-protocol/src/gitpod-service.ts b/components/gitpod-protocol/src/gitpod-service.ts index 003744e21d53f8..b2b9fc6d2324b5 100644 --- a/components/gitpod-protocol/src/gitpod-service.ts +++ b/components/gitpod-protocol/src/gitpod-service.ts @@ -47,7 +47,12 @@ import { LicenseService } from "./license-protocol"; import { Emitter } from "./util/event"; import { AccountStatement, CreditAlert } from "./accounting-protocol"; import { GithubUpgradeURL, PlanCoupon } from "./payment-protocol"; -import { TeamSubscription, TeamSubscriptionSlot, TeamSubscriptionSlotResolved } from "./team-subscription-protocol"; +import { + TeamSubscription, + TeamSubscription2, + TeamSubscriptionSlot, + TeamSubscriptionSlotResolved, +} from "./team-subscription-protocol"; import { RemotePageMessage, RemoteTrackMessage, RemoteIdentifyMessage } from "./analytics"; import { IDEServer } from "./ide-protocol"; import { InstallationAdminSettings, TelemetryData } from "./installation-admin-protocol"; @@ -235,7 +240,9 @@ export interface GitpodServer extends JsonRpcServer, AdminServer, */ getChargebeeSiteId(): Promise; createPortalSession(): Promise<{}>; + createTeamPortalSession(teamId: string): Promise<{}>; checkout(planId: string, planQuantity?: number): Promise<{}>; + teamCheckout(teamId: string, planId: string): Promise<{}>; getAvailableCoupons(): Promise; getAppliedCoupons(): Promise; @@ -247,6 +254,7 @@ export interface GitpodServer extends JsonRpcServer, AdminServer, subscriptionCancel(subscriptionId: string): Promise; subscriptionCancelDowngrade(subscriptionId: string): Promise; + getTeamSubscription(teamId: string): Promise; tsGet(): Promise; tsGetSlots(): Promise; tsGetUnassignedSlot(teamSubscriptionId: string): Promise; diff --git a/components/server/ee/src/container-module.ts b/components/server/ee/src/container-module.ts index 23d8b144df7268..df218fd47c88d4 100644 --- a/components/server/ee/src/container-module.ts +++ b/components/server/ee/src/container-module.ts @@ -36,6 +36,7 @@ import { AccountServiceImpl, SubscriptionService, TeamSubscriptionService, + TeamSubscription2Service, } from "@gitpod/gitpod-payment-endpoint/lib/accounting"; import { ChargebeeProvider, @@ -106,6 +107,7 @@ export const productionEEContainerModule = new ContainerModule((bind, unbind, is bind(AccountService).to(AccountServiceImpl).inSingletonScope(); bind(SubscriptionService).toSelf().inSingletonScope(); bind(TeamSubscriptionService).toSelf().inSingletonScope(); + bind(TeamSubscription2Service).toSelf().inSingletonScope(); // payment/billing bind(ChargebeeProvider).toSelf().inSingletonScope(); diff --git a/components/server/ee/src/workspace/gitpod-server-impl.ts b/components/server/ee/src/workspace/gitpod-server-impl.ts index b9f57f19d0ca44..53ff6ca9a3e57d 100644 --- a/components/server/ee/src/workspace/gitpod-server-impl.ts +++ b/components/server/ee/src/workspace/gitpod-server-impl.ts @@ -72,6 +72,7 @@ import { GithubUpgradeURL, PlanCoupon } from "@gitpod/gitpod-protocol/lib/paymen import { AssigneeIdentityIdentifier, TeamSubscription, + TeamSubscription2, TeamSubscriptionSlot, TeamSubscriptionSlotResolved, } from "@gitpod/gitpod-protocol/lib/team-subscription-protocol"; @@ -84,8 +85,9 @@ import { AccountService, SubscriptionService, TeamSubscriptionService, + TeamSubscription2Service, } from "@gitpod/gitpod-payment-endpoint/lib/accounting"; -import { AccountingDB, TeamSubscriptionDB, EduEmailDomainDB } from "@gitpod/gitpod-db/lib"; +import { AccountingDB, TeamSubscriptionDB, TeamSubscription2DB, EduEmailDomainDB } from "@gitpod/gitpod-db/lib"; import { ChargebeeProvider, UpgradeHelper } from "@gitpod/gitpod-payment-endpoint/lib/chargebee"; import { ChargebeeCouponComputer } from "../user/coupon-computer"; import { ChargebeeService } from "../user/chargebee-service"; @@ -115,8 +117,10 @@ export class GitpodServerEEImpl extends GitpodServerImpl { @inject(AccountingDB) protected readonly accountingDB: AccountingDB; @inject(EduEmailDomainDB) protected readonly eduDomainDb: EduEmailDomainDB; + @inject(TeamSubscription2DB) protected readonly teamSubscription2DB: TeamSubscription2DB; @inject(TeamSubscriptionDB) protected readonly teamSubscriptionDB: TeamSubscriptionDB; @inject(TeamSubscriptionService) protected readonly teamSubscriptionService: TeamSubscriptionService; + @inject(TeamSubscription2Service) protected readonly teamSubscription2Service: TeamSubscription2Service; @inject(ChargebeeProvider) protected readonly chargebeeProvider: ChargebeeProvider; @inject(UpgradeHelper) protected readonly upgradeHelper: UpgradeHelper; @@ -1108,6 +1112,37 @@ export class GitpodServerEEImpl extends GitpodServerImpl { }); } + async createTeamPortalSession(ctx: TraceContext, teamId: string): Promise<{}> { + traceAPIParams(ctx, { teamId }); + + this.checkUser("createTeamPortalSession"); + + const team = await this.teamDB.findTeamById(teamId); + if (!team) { + throw new ResponseError(ErrorCodes.NOT_FOUND, "Team not found"); + } + const members = await this.teamDB.findMembersByTeam(team.id); + await this.guardAccess({ kind: "team", subject: team, members }, "update"); + + return await new Promise((resolve, reject) => { + this.chargebeeProvider.portal_session + .create({ + customer: { + id: "team:" + team.id, + }, + }) + .request(function (error: any, result: any) { + if (error) { + log.error("Team portal session creation error", error); + reject(error); + } else { + log.debug("Team portal session created"); + resolve(result.portal_session); + } + }); + }); + } + async checkout(ctx: TraceContext, planId: string, planQuantity?: number): Promise<{}> { traceAPIParams(ctx, { planId, planQuantity }); @@ -1154,6 +1189,44 @@ export class GitpodServerEEImpl extends GitpodServerImpl { } } + async teamCheckout(ctx: TraceContext, teamId: string, planId: string): Promise<{}> { + traceAPIParams(ctx, { teamId, planId }); + + const user = this.checkUser("teamCheckout"); + + const team = await this.teamDB.findTeamById(teamId); + if (!team) { + throw new ResponseError(ErrorCodes.NOT_FOUND, "Team not found"); + } + const members = await this.teamDB.findMembersByTeam(team.id); + await this.guardAccess({ kind: "team", subject: team, members }, "update"); + + const coupon = await this.findAvailableCouponForPlan(user, planId); + + const email = User.getPrimaryEmail(user); + return new Promise((resolve, reject) => { + this.chargebeeProvider.hosted_page + .checkout_new({ + customer: { + id: "team:" + team.id, + email, + }, + subscription: { + plan_id: planId, + plan_quantity: members.length, + coupon, + }, + }) + .request((error: any, result: any) => { + if (error) { + reject(error); + return; + } + resolve(result.hosted_page); + }); + }); + } + protected async findAvailableCouponForPlan(user: User, planId: string): Promise { const couponNames = await this.couponComputer.getAvailableCouponIds(user); const chargbeeCoupons = await Promise.all( @@ -1304,9 +1377,102 @@ export class GitpodServerEEImpl extends GitpodServerImpl { return chargebeeSubscriptionId; } - // Team Subscriptions + // Team Subscriptions 2 + async getTeamSubscription(ctx: TraceContext, teamId: string): Promise { + this.checkUser("getTeamSubscription"); + await this.guardTeamOperation(teamId, "get"); + return this.teamSubscription2DB.findForTeam(teamId, new Date().toISOString()); + } + + protected async onTeamMemberAdded(userId: string, teamId: string): Promise { + const now = new Date(); + const teamSubscription = await this.teamSubscription2DB.findForTeam(teamId, now.toISOString()); + if (!teamSubscription) { + // No team subscription, nothing to do 🌴 + return; + } + await this.updateTeamSubscriptionQuantity(teamSubscription); + await this.teamSubscription2Service.addTeamMemberSubscription(teamSubscription, userId); + } + + protected async onTeamMemberRemoved(userId: string, teamId: string, teamMembershipId: string): Promise { + const now = new Date(); + const teamSubscription = await this.teamSubscription2DB.findForTeam(teamId, now.toISOString()); + if (!teamSubscription) { + // No team subscription, nothing to do 🌴 + return; + } + await this.updateTeamSubscriptionQuantity(teamSubscription); + await this.teamSubscription2Service.cancelTeamMemberSubscription( + teamSubscription, + userId, + teamMembershipId, + now, + ); + } + + protected async onTeamDeleted(teamId: string): Promise { + const now = new Date(); + const teamSubscription = await this.teamSubscription2DB.findForTeam(teamId, now.toISOString()); + if (!teamSubscription) { + // No team subscription, nothing to do 🌴 + return; + } + const chargebeeSubscriptionId = teamSubscription.paymentReference; + await this.chargebeeService.cancelSubscription( + chargebeeSubscriptionId, + {}, + { teamId, chargebeeSubscriptionId }, + ); + } + + protected async updateTeamSubscriptionQuantity(teamSubscription: TeamSubscription2): Promise { + const members = await this.teamDB.findMembersByTeam(teamSubscription.teamId); + const oldQuantity = teamSubscription.quantity; + const newQuantity = members.length; + try { + if (oldQuantity < newQuantity) { + // Upgrade: Charge for it! + const chargebeeSubscription = await this.getChargebeeSubscription( + {}, + teamSubscription.paymentReference, + ); + let pricePerUnitInCents = chargebeeSubscription.plan_unit_price; + if (pricePerUnitInCents === undefined) { + const plan = Plans.getById(teamSubscription.planId)!; + pricePerUnitInCents = plan.pricePerMonth * 100; + } + const currentTermRemainingRatio = + this.upgradeHelper.getCurrentTermRemainingRatio(chargebeeSubscription); + const diffInCents = Math.round( + pricePerUnitInCents * (newQuantity - oldQuantity) * currentTermRemainingRatio, + ); + const upgradeTimestamp = new Date().toISOString(); + const description = `Pro-rated upgrade from '${oldQuantity}' to '${newQuantity}' team members (${formatDate( + upgradeTimestamp, + )})`; + await this.upgradeHelper.chargeForUpgrade( + "", + teamSubscription.paymentReference, + diffInCents, + description, + upgradeTimestamp, + ); + } + await this.doUpdateSubscription("", teamSubscription.paymentReference, { + plan_quantity: newQuantity, + end_of_term: false, + }); + } catch (err) { + if (chargebee.ApiError.is(err) && err.type === "payment") { + throw new ResponseError(ErrorCodes.PAYMENT_ERROR, `${err.api_error_code}: ${err.message}`); + } + } + } + + // Team Subscriptions (legacy) async tsGet(ctx: TraceContext): Promise { - const user = this.checkUser("getTeamSubscriptions"); + const user = this.checkUser("tsGet"); return this.teamSubscriptionDB.findTeamSubscriptionsForUser(user.id, new Date().toISOString()); } diff --git a/components/server/src/auth/rate-limiter.ts b/components/server/src/auth/rate-limiter.ts index 5f65dd7c21fa2f..7ad4c170742d78 100644 --- a/components/server/src/auth/rate-limiter.ts +++ b/components/server/src/auth/rate-limiter.ts @@ -167,7 +167,9 @@ function getConfig(config: RateLimiterConfig): RateLimiterConfig { adminSetProfessionalOpenSource: { group: "default", points: 1 }, adminGrantExtraHours: { group: "default", points: 1 }, checkout: { group: "default", points: 1 }, + teamCheckout: { group: "default", points: 1 }, createPortalSession: { group: "default", points: 1 }, + createTeamPortalSession: { group: "default", points: 1 }, getAccountStatement: { group: "default", points: 1 }, getAppliedCoupons: { group: "default", points: 1 }, getAvailableCoupons: { group: "default", points: 1 }, @@ -184,6 +186,7 @@ function getConfig(config: RateLimiterConfig): RateLimiterConfig { tsAddSlots: { group: "default", points: 1 }, tsAssignSlot: { group: "default", points: 1 }, tsDeactivateSlot: { group: "default", points: 1 }, + getTeamSubscription: { group: "default", points: 1 }, tsGet: { group: "default", points: 1 }, tsGetSlots: { group: "default", points: 1 }, tsGetUnassignedSlot: { group: "default", points: 1 }, diff --git a/components/server/src/workspace/gitpod-server-impl.ts b/components/server/src/workspace/gitpod-server-impl.ts index 786bd703d2b1d2..7b81f3d7ad96ba 100644 --- a/components/server/src/workspace/gitpod-server-impl.ts +++ b/components/server/src/workspace/gitpod-server-impl.ts @@ -97,6 +97,7 @@ import { ErrorCodes } from "@gitpod/gitpod-protocol/lib/messaging/error"; import { GithubUpgradeURL, PlanCoupon } from "@gitpod/gitpod-protocol/lib/payment-protocol"; import { TeamSubscription, + TeamSubscription2, TeamSubscriptionSlot, TeamSubscriptionSlotResolved, } from "@gitpod/gitpod-protocol/lib/team-subscription-protocol"; @@ -2021,6 +2022,7 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable { } ctx.span?.setTag("teamId", invite.teamId); await this.teamDB.addMemberToTeam(user.id, invite.teamId); + await this.onTeamMemberAdded(user.id, invite.teamId); const team = await this.teamDB.findTeamById(invite.teamId); this.analytics.track({ @@ -2055,7 +2057,12 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable { const user = this.checkAndBlockUser("removeTeamMember"); // Users are free to leave any team themselves, but only owners can remove others from their teams. await this.guardTeamOperation(teamId, user.id === userId ? "get" : "update"); + const membership = await this.teamDB.findTeamMembership(userId, teamId); + if (!membership) { + throw new Error(`Could not find membership for user '${userId}' in team '${teamId}'`); + } await this.teamDB.removeMemberFromTeam(userId, teamId); + await this.onTeamMemberRemoved(userId, teamId, membership.id); this.analytics.track({ userId: user.id, event: "team_user_removed", @@ -2169,19 +2176,20 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable { const teamProjects = await this.projectsService.getTeamProjects(teamId); teamProjects.forEach((project) => { - /** no awat */ this.deleteProject(ctx, project.id).catch((err) => { + /** no await */ this.deleteProject(ctx, project.id).catch((err) => { /** ignore */ }); }); const teamMembers = await this.teamDB.findMembersByTeam(teamId); teamMembers.forEach((member) => { - /** no awat */ this.removeTeamMember(ctx, teamId, member.userId).catch((err) => { + /** no await */ this.removeTeamMember(ctx, teamId, member.userId).catch((err) => { /** ignore */ }); }); await this.teamDB.deleteTeam(teamId); + await this.onTeamDeleted(teamId); return this.analytics.track({ userId: user.id, @@ -2917,9 +2925,15 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable { async createPortalSession(ctx: TraceContext): Promise<{}> { throw new ResponseError(ErrorCodes.SAAS_FEATURE, `Not implemented in this version`); } + async createTeamPortalSession(ctx: TraceContext, teamId: string): Promise<{}> { + throw new ResponseError(ErrorCodes.SAAS_FEATURE, `Not implemented in this version`); + } async checkout(ctx: TraceContext, planId: string, planQuantity?: number): Promise<{}> { throw new ResponseError(ErrorCodes.SAAS_FEATURE, `Not implemented in this version`); } + async teamCheckout(ctx: TraceContext, teamId: string, planId: string): Promise<{}> { + throw new ResponseError(ErrorCodes.SAAS_FEATURE, `Not implemented in this version`); + } async getAvailableCoupons(ctx: TraceContext): Promise { throw new ResponseError(ErrorCodes.SAAS_FEATURE, `Not implemented in this version`); } @@ -2944,6 +2958,18 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable { async subscriptionCancelDowngrade(ctx: TraceContext, subscriptionId: string): Promise { throw new ResponseError(ErrorCodes.SAAS_FEATURE, `Not implemented in this version`); } + async getTeamSubscription(ctx: TraceContext, teamId: string): Promise { + throw new ResponseError(ErrorCodes.SAAS_FEATURE, `Not implemented in this version`); + } + protected async onTeamMemberAdded(userId: string, teamId: string): Promise { + // Extension point for EE + } + protected async onTeamMemberRemoved(userId: string, teamId: string, teamMembershipId: string): Promise { + // Extension point for EE + } + protected async onTeamDeleted(teamId: string): Promise { + // Extension point for EE + } async tsGet(ctx: TraceContext): Promise { throw new ResponseError(ErrorCodes.SAAS_FEATURE, `Not implemented in this version`); }