/** * 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' } ]; } }; } }