2021-04-12 08:29:10 +02:00

633 lines
21 KiB
TypeScript

/**
* Copyright (c) 2021 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.
*/
export type Currency = 'USD' | 'EUR';
export namespace Currency {
export const getAll = (): Currency[] => {
return ['USD', 'EUR'];
}
export const getSymbol = (c: Currency) => {
return c === 'USD' ? '$' : '€';
}
}
/**
* Different plans of the same type MAY have different prices ($/€, for example) but MUST have the same feature set.
*/
export type PlanType = 'free' | 'free-50' | 'free-open-source' | 'student' | 'basic' | 'personal' | 'professional' | 'professional-new';
export type HoursPerMonthType = number | 'unlimited';
export interface Plan {
chargebeeId: string
githubId?: number
githubPlanNumber?: number
name: string
currency: Currency
/** In full currencies (Euro, US Dollar, ...) */
pricePerMonth: number
hoursPerMonth: HoursPerMonthType
type: PlanType
team?: boolean
}
export namespace Plan {
export const is = (o: any): o is Plan => {
return 'chargebeeId' in o
&& 'name' in o
&& 'currency' in o
&& 'pricePerMonth' in o
&& 'hoursPerMonth' in o
&& 'type' in o;
}
}
export const MAX_PARALLEL_WORKSPACES = 16;
export interface Coupon {
id: string;
isGithubStudentCoupon?: boolean;
}
export namespace Coupon {
export const is = (o: any): o is Coupon => {
return 'id' in o;
}
}
export namespace Coupons {
export const INTERNAL_GITPOD_GHSP: Coupon = {
id: "INTERNAL_GITPOD_GHSP",
isGithubStudentCoupon: true
};
export const INTERNAL_GITPOD_GHSP_2: Coupon = {
id: "INTERNAL_GITPOD_GHSP_2",
isGithubStudentCoupon: true
};
export const GITHUBSTUDENTPACKFORFACULTY: Coupon = {
id: "GITHUBSTUDENTPACKFORFACULTY",
isGithubStudentCoupon: true
};
export const isGithubStudentCoupon = (id: string): boolean | undefined => {
const c = getAllCoupons().find(ic => ic.id === id);
if (!c) {
return undefined;
}
return !!c.isGithubStudentCoupon;
};
export const getAllCoupons = (): Coupon[] => {
return Object.keys(Coupons)
.map(k => (Coupons as any)[k])
.filter(a => typeof a === 'object' && Coupon.is(a));
};
}
// Theoretical maximum of workspace hours: 16 workspaces for 24h a day for 31 days as permitted by the v3 unlimited plan
// Other unlimited hour plans are restricted by the number of Parallel Workspaces they can start.
export const ABSOLUTE_MAX_USAGE = MAX_PARALLEL_WORKSPACES * 24 * 31;
/**
* Version history:
* - v1:
* - Free
* - Basic
* - Professional
* - Team Professional
* - v2:
* - Free
* - Personal
* - Unlimited: rebranded professional with unlimited hours
* - Team Unlimited: rebranded professional with unlimited hours
* - dropped: Basic
* - v2.5:
* + Student Unlimited
* + Team Unlimited Student
* - V3:
* - Free: reduced to 50h (stays default, but not advertised directly anymore)
* - Personal (8/9)
* - Professional (23/25)
* - Unlimited (35/39)
* - v4:
* - Professional Open Source (free)
* - v5:
* - Unleashed: rebranded Unlimited
*/
export namespace Plans {
/**
* The old default plan (v1): 100h hours for public repos
*/
export const FREE: Plan = {
chargebeeId: 'free',
githubId: 2034,
githubPlanNumber: 1,
type: 'free',
name: 'Open Source',
currency: 'USD',
pricePerMonth: 0,
hoursPerMonth: 100
};
/**
* The new default plan (v3): 50h hours for public repos
*/
export const FREE_50: Plan = {
chargebeeId: 'free-50',
githubId: 4902,
githubPlanNumber: 5,
type: 'free-50',
name: 'Open Source',
currency: 'USD',
pricePerMonth: 0,
hoursPerMonth: 50
};
/**
* Users created after this date get the FREE_50 plan (v3) instead of the (old) FREE plan (v1)
*/
export const FREE_50_START_DATE = '2019-12-19T00:00:00.000Z';
/**
* The 'Professional Open Source' plan was introduced to offer professional open-souce developers unlimited hours.
*/
export const FREE_OPEN_SOURCE: Plan = {
chargebeeId: 'free-open-source',
type: 'free-open-source',
name: 'Professional Open Source',
currency: 'USD',
pricePerMonth: 0,
hoursPerMonth: 'unlimited'
};
/**
* The 'Student Unleashed' plans were introduced to give students access to the highly-priced unlimited plans.
*/
export const PROFESSIONAL_STUDENT_EUR: Plan = {
chargebeeId: 'professional-student-eur',
type: 'student',
name: 'Student Unleashed',
currency: 'EUR',
pricePerMonth: 8,
hoursPerMonth: 'unlimited'
};
/**
* The 'Student Unleashed' plans were introduced to give students access to the highly-priced unlimited plans.
*/
export const PROFESSIONAL_STUDENT_USD: Plan = {
chargebeeId: 'professional-student-usd',
type: 'student',
name: 'Student Unleashed',
currency: 'USD',
pricePerMonth: 9,
hoursPerMonth: 'unlimited'
};
/**
* The 'Student Unleashed' plans were introduced to give students access to the highly-priced unlimited plans.
*/
export const TEAM_PROFESSIONAL_STUDENT_EUR: Plan = {
chargebeeId: 'team-professional-student-eur',
type: 'student',
name: 'Team Student Unleashed',
team: true,
currency: 'EUR',
pricePerMonth: 8,
hoursPerMonth: 'unlimited'
};
/**
* The 'Student Unleashed' plans were introduced to give students access to the highly-priced unlimited plans.
*/
export const TEAM_PROFESSIONAL_STUDENT_USD: Plan = {
chargebeeId: 'team-professional-student-usd',
type: 'student',
name: 'Team Student Unleashed',
team: true,
currency: 'USD',
pricePerMonth: 9,
hoursPerMonth: 'unlimited'
};
/**
* The 'basic' plan was the original differentiator between FREE and Professional (v1) but got discarded soon.
*/
export const BASIC_EUR: Plan = {
chargebeeId: 'basic-eur',
type: 'basic',
name: 'Standard',
currency: 'EUR',
pricePerMonth: 17,
hoursPerMonth: 100
};
/**
* The 'basic' plan was the original differentiator between FREE and Professional (v1) but got discarded soon.
*/
export const BASIC_USD: Plan = {
chargebeeId: 'basic-usd',
githubId: 2035,
githubPlanNumber: 2,
type: 'basic',
name: 'Standard',
currency: 'USD',
pricePerMonth: 19,
hoursPerMonth: 100
};
/**
* The 'personal' plan was introduced to superseed the 'basic' plan (introduced with v2) to be more attractive to hobbyists.
*/
export const PERSONAL_EUR: Plan = {
chargebeeId: 'personal-eur',
type: 'personal',
name: 'Personal',
currency: 'EUR',
pricePerMonth: 8,
hoursPerMonth: 100
};
/**
* The 'personal' plan was introduced to superseed the 'basic' plan (introduced with v2) to be more attractive to hobbyists.
*/
export const PERSONAL_USD: Plan = {
chargebeeId: 'personal-usd',
githubId: 2274,
githubPlanNumber: 4,
type: 'personal',
name: 'Personal',
currency: 'USD',
pricePerMonth: 9,
hoursPerMonth: 100
};
/**
* This is the 'new' Professional plan (v3), which is meant to fit well between Personal (9$/8€) on the left and
* Unleashed (39$/35€) on the right.
*/
export const PROFESSIONAL_NEW_EUR: Plan = {
chargebeeId: 'professional-new-eur',
type: 'professional-new',
name: 'Professional',
currency: 'EUR',
pricePerMonth: 23,
hoursPerMonth: 'unlimited'
};
/**
* This is the 'new' Professional plan (v3), which is meant to fit well between Personal (9$/8€) on the left and
* Unleashed (39$/35€) on the right.
*/
export const PROFESSIONAL_NEW_USD: Plan = {
chargebeeId: 'professional-new-usd',
type: 'professional-new',
name: 'Professional',
currency: 'USD',
pricePerMonth: 25,
hoursPerMonth: 'unlimited'
};
/**
* This is the 'new' Team Professional plan (v3), which is meant to fit well between Personal (9$/8€) on the left and
* Unleashed (39$/35€) on the right.
*/
export const TEAM_PROFESSIONAL_NEW_EUR: Plan = {
chargebeeId: 'team-professional-new-eur',
type: 'professional-new',
name: 'Team Professional',
currency: 'EUR',
team: true,
pricePerMonth: 23,
hoursPerMonth: 'unlimited'
};
/**
* This is the 'new' Team Professional plan (v3), which is meant to fit well between Personal (9$/8€) on the left and
* Unleashed (39$/35€) on the right.
*/
export const TEAM_PROFESSIONAL_NEW_USD: Plan = {
chargebeeId: 'team-professional-new-usd',
type: 'professional-new',
name: 'Team Professional',
currency: 'USD',
team: true,
pricePerMonth: 25,
hoursPerMonth: 'unlimited'
};
/**
* This is the 'Unleashed' plan (v1, rebranded v2, v5)
* It was originally introduced as 'Professional', and we cannot update the ids, so it stays that way in the code.
*/
export const PROFESSIONAL_EUR: Plan = {
chargebeeId: 'professional-eur',
type: 'professional',
name: 'Unleashed',
currency: 'EUR',
pricePerMonth: 35,
hoursPerMonth: 'unlimited'
};
/**
* This is the 'Unleashed' plan (v1, rebranded v2, v5)
* It was originally introduced as 'Professional', and we cannot update the ids, so it stays that way in the code.
*/
export const PROFESSIONAL_USD: Plan = {
chargebeeId: 'professional-usd',
githubId: 2036,
githubPlanNumber: 3,
type: 'professional',
name: 'Unleashed',
currency: 'USD',
pricePerMonth: 39,
hoursPerMonth: 'unlimited'
};
/**
* This is the Team-'Unleashed' plan (v1, rebranded v2, v5)
* It was originally introduced as 'Professional', and we cannot update the ids, so it stays that way in the code.
*/
export const TEAM_PROFESSIONAL_USD: Plan = {
chargebeeId: 'team-professional-usd',
type: 'professional',
name: 'Team Unleashed',
currency: 'USD',
team: true,
pricePerMonth: 39,
hoursPerMonth: 'unlimited'
};
/**
* This is the Team-'Unleashed' plan (v1, rebranded v2, v5)
* It was originally introduced as 'Professional', and we cannot update the ids, so it stays that way in the code.
*/
export const TEAM_PROFESSIONAL_EUR: Plan = {
chargebeeId: 'team-professional-eur',
type: 'professional',
name: 'Team Unleashed',
currency: 'EUR',
team: true,
pricePerMonth: 35,
hoursPerMonth: 'unlimited'
};
const getAllPlans = (): Plan[] => {
return Object.keys(Plans)
.map(k => (Plans as any)[k])
.filter(a => typeof a === 'object' && Plan.is(a));
};
/**
* This function returns all individual plans that might be active (= we have subscriptions for) at the moment
*/
export function getAvailablePlans(currency: Currency): Plan[] {
const availablePaidPlans = [
Plans.BASIC_EUR,
Plans.BASIC_USD,
Plans.PERSONAL_EUR,
Plans.PERSONAL_USD,
Plans.PROFESSIONAL_NEW_EUR,
Plans.PROFESSIONAL_NEW_USD,
Plans.PROFESSIONAL_EUR,
Plans.PROFESSIONAL_USD
];
return [
Plans.FREE,
Plans.FREE_50,
Plans.FREE_OPEN_SOURCE,
...availablePaidPlans.filter(p => p.currency)
]
};
export const getAvailableTeamPlans = (currency?: Currency): Plan[] => {
const teamPlans = getAllPlans().filter(p => !!p.team);
return currency ? teamPlans.filter(p => p.currency === currency) : teamPlans;
};
export function getById(id: string | undefined): Plan | undefined {
if (id === undefined) {
return undefined;
}
return getAllPlans()
.find(p => p.chargebeeId === id) || undefined;
};
export function getByTypeAndCurrency(type: PlanType, currency: Currency): Plan | undefined {
return getAllPlans()
.filter(p => p.type)
.find(p => p.currency === currency);
}
export function getProPlan(currency: Currency): Plan {
switch (currency) {
case "EUR": return Plans.PROFESSIONAL_EUR;
case "USD": return Plans.PROFESSIONAL_USD;
}
}
export function getNewProPlan(currency: Currency): Plan {
switch (currency) {
case "EUR": return Plans.PROFESSIONAL_NEW_EUR;
case "USD": return Plans.PROFESSIONAL_NEW_USD;
}
}
export function getStudentProPlan(currency: Currency): Plan {
switch (currency) {
case "EUR": return Plans.PROFESSIONAL_STUDENT_EUR;
case "USD": return Plans.PROFESSIONAL_STUDENT_USD;
}
}
export function getBasicPlan(currency: Currency): Plan {
switch (currency) {
case "EUR": return Plans.BASIC_EUR;
case "USD": return Plans.BASIC_USD;
}
}
export function getPersonalPlan(currency: Currency): Plan {
switch (currency) {
case "EUR": return Plans.PERSONAL_EUR;
case "USD": return Plans.PERSONAL_USD;
}
}
export function getFreePlan(userCreationDate: string): Plan {
return userCreationDate < Plans.FREE_50_START_DATE ? Plans.FREE : Plans.FREE_50;
}
export function isFreePlan(chargebeeId: string | undefined): boolean {
return chargebeeId === Plans.FREE.chargebeeId
|| chargebeeId === Plans.FREE_50.chargebeeId
|| chargebeeId === Plans.FREE_OPEN_SOURCE.chargebeeId;
}
export function isFreeNonTransientPlan(chargebeeId: string | undefined): boolean {
return chargebeeId === Plans.FREE_OPEN_SOURCE.chargebeeId;
}
export function getHoursPerMonth(plan: Plan): number {
return plan.hoursPerMonth == 'unlimited' ? ABSOLUTE_MAX_USAGE : plan.hoursPerMonth;
}
/**
* Returns the maximum number of parallel workspaces for the given plan
* @param plan
*/
export function getParallelWorkspacesById(planId: string | undefined): number {
return getParallelWorkspaces(Plans.getById(planId));
}
/**
* Returns the maximum number of parallel workspaces for the given plan
* @param plan
*/
export function getParallelWorkspaces(plan: Plan | undefined): number {
const DEFAULT = 4;
if (!plan) {
return DEFAULT;
}
switch (plan.type) {
case "professional-new":
return 8;
case "professional":
case "student":
return 16;
}
return DEFAULT;
}
/**
* This declares the plan structure we have in Gitpod: All entries in a sub-array have the same arity in this structure.
* This is used to impose a partial order on plan types (cmp. compareTypes(...)).
* The order inside the sub-array carries meaning, too: The first one is the current, preferred plan (we advertise) for the given arity.
* This is used to be able to get the next "higher" plan (cmp. getNextHigherPlanType).
*/
const planStructure: PlanType[][] = [
["free-50", "free", "free-open-source"],
["personal", "basic"],
["professional-new"],
["professional", "student"]
];
function getPlanTypeArity(type: PlanType) {
return planStructure.findIndex((types: PlanType[]) => types.includes(type));
}
function getPlanTypeForArity(arity: number): PlanType | undefined {
if (arity >= planStructure.length) {
return undefined;
}
return planStructure[arity][0];
}
/**
* Returns the preferred plan type with the next higher arity
* @param type
*/
export function getNextHigherPlanType(type: PlanType): PlanType {
const arity = getPlanTypeArity(type);
const nextHigherType = getPlanTypeForArity(arity + 1);
return nextHigherType || "professional";
}
/**
* This imposes a partial order on the plan types
* @param planTypeA
* @param planTypeB
*/
export function compareTypes(planTypeA: PlanType, planTypeB: PlanType) {
const va = getPlanTypeArity(planTypeA);
const vb = getPlanTypeArity(planTypeB);
return va < vb ? -1 : va > vb ? 1 : 0;
}
export function subscriptionChange(fromType: PlanType, toType: PlanType): "upgrade" | "downgrade" | "none" {
const cmp = Plans.compareTypes(fromType, toType);
if (cmp < 0) {
return "upgrade";
} else if (cmp > 0) {
return "downgrade";
} else {
return "none";
}
}
export interface Feature {
title: string;
emph?: boolean;
link?: string;
tooltip?: string;
}
export namespace Feature {
export const getFeaturesFor = (p: Plan): Feature[] => {
switch (p.type) {
case "free":
return [
{ title: 'Public repositories' }
];
case "free-50":
return [
{ title: 'Public repositories' }
];
case "free-open-source":
return [
{ title: 'Public repositories' },
];
case "student":
return [
{ title: 'Private & Public repos' },
{ title: `${Plans.getParallelWorkspaces(p)} Parallel Workspaces`, tooltip: 'The number of workspaces running at the same time' },
{ title: 'Team Manageable', link: "/teams/", tooltip: 'Setup Gitpod for an entire Team with a single invoice and credit card' },
{ title: '1h Timeout', tooltip: 'Workspaces without user activity are stopped after 1 hour' },
{ title: '3h Timeout Boost', tooltip: 'You can manually boost the timeout to 3 hours within a running workspace' }
];
case "basic":
return [
{ title: 'Private & Public repos' },
{ title: `${Plans.getParallelWorkspaces(p)} Parallel Workspaces`, tooltip: 'The number of workspaces running at the same time.' },
];
// Personal
case "personal":
return [
{ title: 'Private & Public repos' },
{ title: `${Plans.getParallelWorkspaces(p)} Parallel Workspaces`, tooltip: 'The number of workspaces running at the same time' },
{ title: '30min Timeout', tooltip: 'Workspaces without user activity are stopped after 30 minutes' }
];
// Professional
case "professional-new":
return [
{ title: 'Private & Public repos' },
{ title: `${Plans.getParallelWorkspaces(p)} Parallel Workspaces`, tooltip: 'The number of workspaces running at the same time' },
{ title: 'Team Manageable', link: "/teams/", tooltip: 'Setup Gitpod for an entire Team with a single invoice and credit card' },
{ title: '30min Timeout', tooltip: 'Workspaces without user activity are stopped after 30 minutes' }
];
// Unleashed
case "professional":
return [
{ title: 'Private & Public repos' },
{ title: `${Plans.getParallelWorkspaces(p)} Parallel Workspaces`, tooltip: 'The number of workspaces running at the same time' },
{ title: 'Team Manageable', link: "/teams/", tooltip: 'Setup Gitpod for an entire Team with a single invoice and credit card' },
{ title: '1h Timeout', tooltip: 'Workspaces without user activity are stopped after 1 hour' },
{ title: '3h Timeout Boost', tooltip: 'You can manually boost the timeout to 3 hours within a running workspace' }
];
}
};
}
}