From a331b2ffbd977ccb9503e51dcfd499e1c1f325f3 Mon Sep 17 00:00:00 2001 From: Gero Posmyk-Leinemann Date: Sun, 24 Jul 2022 21:04:15 +0000 Subject: [PATCH 1/4] [server] Format commit --- .../src/accounting/subscription-service.ts | 68 ++++++++++++------- 1 file changed, 42 insertions(+), 26 deletions(-) diff --git a/components/ee/payment-endpoint/src/accounting/subscription-service.ts b/components/ee/payment-endpoint/src/accounting/subscription-service.ts index 10af091b16b0e9..c2a2d56fbece81 100644 --- a/components/ee/payment-endpoint/src/accounting/subscription-service.ts +++ b/components/ee/payment-endpoint/src/accounting/subscription-service.ts @@ -8,12 +8,12 @@ import { AccountingDB } from "@gitpod/gitpod-db/lib/accounting-db"; import { User } from "@gitpod/gitpod-protocol"; import { AccountEntry, Subscription } from "@gitpod/gitpod-protocol/lib/accounting-protocol"; import { inject, injectable } from "inversify"; -import { log } from '@gitpod/gitpod-protocol/lib/util/logging'; +import { log } from "@gitpod/gitpod-protocol/lib/util/logging"; import { Plan, Plans } from "@gitpod/gitpod-protocol/lib/plans"; import { orderByStartDateAscEndDateAsc } from "./accounting-util"; import { SubscriptionModel } from "./subscription-model"; -export type UserCreated = Pick; +export type UserCreated = Pick; @injectable() export class SubscriptionService { @@ -24,12 +24,14 @@ export class SubscriptionService { * @param user * @returns All persisted subscriptions + the Free subscriptions that fill up the periods in between, sorted by startDate (ASC) */ - async getSubscriptionHistoryForUserInPeriod(user: UserCreated, startDate: string, endDate: string): Promise { + async getSubscriptionHistoryForUserInPeriod( + user: UserCreated, + startDate: string, + endDate: string, + ): Promise { const subscriptions = await this.accountingDB.findSubscriptionsForUserInPeriod(user.id, startDate, endDate); const model = new SubscriptionModel(user.id, subscriptions); - return model - .mergedWithFreeSubscriptions(user.creationDate) - .sort(orderByStartDateAscEndDateAsc); + return model.mergedWithFreeSubscriptions(user.creationDate).sort(orderByStartDateAscEndDateAsc); } /** @@ -40,9 +42,7 @@ export class SubscriptionService { async getNotYetCancelledSubscriptions(user: UserCreated, date: string): Promise { const subscriptions = await this.accountingDB.findNotYetCancelledSubscriptions(user.id, date); const model = new SubscriptionModel(user.id, subscriptions); - return model - .mergedWithFreeSubscriptions(user.creationDate) - .sort(orderByStartDateAscEndDateAsc); + return model.mergedWithFreeSubscriptions(user.creationDate).sort(orderByStartDateAscEndDateAsc); } /** @@ -54,7 +54,7 @@ export class SubscriptionService { throw new Error("unsubscribe only works for 'free' plans!"); } - return this.accountingDB.transaction(async db => { + return this.accountingDB.transaction(async (db) => { await this.doUnsubscribe(db, userId, endDate, planId); }); } @@ -66,21 +66,28 @@ export class SubscriptionService { * @param startDate * @param endDate */ - async subscribe(userId: string, plan: Plan, paymentReference: string | undefined, startDate: string, endDate?: string): Promise { + async subscribe( + userId: string, + plan: Plan, + paymentReference: string | undefined, + startDate: string, + endDate?: string, + ): Promise { if (!Plans.isFreePlan(plan.chargebeeId)) { throw new Error("subscribe only works for 'free' plans!"); } - return this.accountingDB.transaction(async db => { + return this.accountingDB.transaction(async (db) => { await this.doUnsubscribe(db, userId, startDate, plan.chargebeeId); - const newSubscription = { + const newSubscription = { userId, amount: Plans.getHoursPerMonth(plan), planId: plan.chargebeeId, paymentReference, startDate, - endDate }; - log.info({ userId }, 'Creating subscription', { subscription: newSubscription }); + endDate, + }; + log.info({ userId }, "Creating subscription", { subscription: newSubscription }); return db.newSubscription(newSubscription); }); } @@ -95,7 +102,9 @@ export class SubscriptionService { // don't override but keep an existing, not-yet cancelled Prof. OSS subscription const subs = await this.getNotYetCancelledSubscriptions(user, now.toISOString()); - const uncancelledOssSub = subs.find(s => s.planId === Plans.FREE_OPEN_SOURCE.chargebeeId && !s.cancellationDate); + const uncancelledOssSub = subs.find( + (s) => s.planId === Plans.FREE_OPEN_SOURCE.chargebeeId && !s.cancellationDate, + ); if (uncancelledOssSub) { log.debug({ userId: userId }, "already has professional OSS subscription"); return; @@ -107,10 +116,14 @@ export class SubscriptionService { } async addCredit(userId: string, amount: number, date: string, expiryDate?: string): Promise { - const entry = { - userId, amount, date, expiryDate, kind: 'credit' + const entry = { + userId, + amount, + date, + expiryDate, + kind: "credit", }; - log.info({ userId }, 'Adding credit', { accountEntry: entry }); + log.info({ userId }, "Adding credit", { accountEntry: entry }); return this.accountingDB.newAccountEntry(entry); } @@ -121,20 +134,23 @@ export class SubscriptionService { */ async hasActivePaidSubscription(userId: string, date: Date): Promise { const subscriptions = await this.accountingDB.findActiveSubscriptionsForUser(userId, date.toISOString()); - return subscriptions - .filter(s => Subscription.isActive(s, date.toISOString())) - .length > 0; + return subscriptions.filter((s) => Subscription.isActive(s, date.toISOString())).length > 0; } async store(db: AccountingDB, model: SubscriptionModel) { const delta = model.getResult(); await Promise.all([ - ...delta.updates.map(s => db.storeSubscription(s)), - ...delta.inserts.map(s => db.newSubscription(s)) + ...delta.updates.map((s) => db.storeSubscription(s)), + ...delta.inserts.map((s) => db.newSubscription(s)), ]); } - private async doUnsubscribe(db: AccountingDB, userId: string, endDate: string, planId: string) : Promise{ + private async doUnsubscribe( + db: AccountingDB, + userId: string, + endDate: string, + planId: string, + ): Promise { const subscriptions = await db.findAllSubscriptionsForUser(userId); for (let subscription of subscriptions) { if (planId === subscription.planId) { @@ -144,7 +160,7 @@ export class SubscriptionService { } else { Subscription.cancelSubscription(subscription, subscription.startDate); } - log.info({ userId }, 'Canceling subscription', { subscription }); + log.info({ userId }, "Canceling subscription", { subscription }); await db.storeSubscription(subscription); } } From a3351658c602e5d1eb1e761e685f51ed201fbca7 Mon Sep 17 00:00:00 2001 From: Gero Posmyk-Leinemann Date: Tue, 2 Aug 2022 15:36:55 +0000 Subject: [PATCH 2/4] [server] Install dev dependency "deep-equal-in-any-order" --- components/server/package.json | 2 ++ yarn.lock | 25 +++++++++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/components/server/package.json b/components/server/package.json index 13917ec7b48095..5956f8168c0c62 100644 --- a/components/server/package.json +++ b/components/server/package.json @@ -95,6 +95,7 @@ "@types/cookie-parser": "^1.4.2", "@types/cors": "^2.8.12", "@types/deep-equal": "^1.0.1", + "@types/deep-equal-in-any-order": "^1.0.1", "@types/express": "^4.17.13", "@types/express-mysql-session": "^2.1.3", "@types/express-session": "1.17.4", @@ -115,6 +116,7 @@ "chai": "^4.3.4", "chai-http": "^4.3.0", "concurrently": "^6.2.1", + "deep-equal-in-any-order": "^2.0.0", "expect": "^1.20.2", "mocha": "^5.0.0", "mocha-typescript": "^1.1.11", diff --git a/yarn.lock b/yarn.lock index 8cbe35fd2dd274..5ffc8cbcc3991e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2692,6 +2692,11 @@ resolved "https://registry.npmjs.org/@types/cors/-/cors-2.8.12.tgz" integrity sha512-vt+kDhq/M2ayberEtJcIN/hxXy1Pk+59g2FV/ZQceeaTyCtCucjL2Q7FXlFjtWn4n15KCr1NE2lNNFhp0lEThw== +"@types/deep-equal-in-any-order@^1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@types/deep-equal-in-any-order/-/deep-equal-in-any-order-1.0.1.tgz#0559d855797a5034e187f248b23a4490e5444f2e" + integrity sha512-hUWUUE53WjKfcCncSmWmNXVNNT+0Iz7gYFnov3zdCXrX3Thxp1Cnmfd5LwWOeCVUV5LhpiFgS05vaAG72doo9w== + "@types/deep-equal@^1.0.1": version "1.0.1" resolved "https://registry.npmjs.org/@types/deep-equal/-/deep-equal-1.0.1.tgz" @@ -6666,6 +6671,14 @@ deep-eql@^3.0.1: dependencies: type-detect "^4.0.0" +deep-equal-in-any-order@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/deep-equal-in-any-order/-/deep-equal-in-any-order-2.0.0.tgz#46f4566d6168acf78397911a54ed3e575bb370be" + integrity sha512-pqXYzF6O4Y52SujX2YaMI2TZULPemwZEDgwZAIB/KioJ9pH9kUUyvJVoHRa/DPA44snfu0Bjr4QGa5ECjwm1MQ== + dependencies: + lodash.mapvalues "^4.6.0" + sort-any "^2.0.0" + deep-equal@^1.0.1: version "1.1.1" resolved "https://registry.npmjs.org/deep-equal/-/deep-equal-1.1.1.tgz" @@ -11339,6 +11352,11 @@ lodash.isstring@^4.0.1: resolved "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz" integrity sha1-1SfftUVuynzJu5XV2ur4i6VKVFE= +lodash.mapvalues@^4.6.0: + version "4.6.0" + resolved "https://registry.yarnpkg.com/lodash.mapvalues/-/lodash.mapvalues-4.6.0.tgz#1bafa5005de9dd6f4f26668c30ca37230cc9689c" + integrity sha512-JPFqXFeZQ7BfS00H58kClY7SPVeHertPE0lNuCyZ26/XlN8TvakYD7b9bGyNmXbT/D3BbtPAAmq90gPWqLkxlQ== + lodash.memoize@^4.1.2: version "4.1.2" resolved "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz" @@ -15927,6 +15945,13 @@ sonic-boom@^2.1.0: dependencies: atomic-sleep "^1.0.0" +sort-any@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/sort-any/-/sort-any-2.0.0.tgz#62a5409c9905c9483f03e41e17f46cc451aa7c55" + integrity sha512-T9JoiDewQEmWcnmPn/s9h/PH9t3d/LSWi0RgVmXSuDYeZXTZOZ1/wrK2PHaptuR1VXe3clLLt0pD6sgVOwjNEA== + dependencies: + lodash "^4.17.21" + sort-keys@^1.0.0: version "1.1.2" resolved "https://registry.npmjs.org/sort-keys/-/sort-keys-1.1.2.tgz" From 74b314613daa94f8b732eee1c008f4f8925e7ba5 Mon Sep 17 00:00:00 2001 From: Gero Posmyk-Leinemann Date: Tue, 2 Aug 2022 15:40:29 +0000 Subject: [PATCH 3/4] [server] Make ConfigCatClientFactory injectable --- .../src/experiments/configcat-server.ts | 3 +++ components/server/src/container-module.ts | 10 ++++++++++ 2 files changed, 13 insertions(+) diff --git a/components/gitpod-protocol/src/experiments/configcat-server.ts b/components/gitpod-protocol/src/experiments/configcat-server.ts index 528f2bf23dda53..5038b104653bd7 100644 --- a/components/gitpod-protocol/src/experiments/configcat-server.ts +++ b/components/gitpod-protocol/src/experiments/configcat-server.ts @@ -12,6 +12,9 @@ import { newAlwaysReturningDefaultValueClient } from "./always-default"; let client: Client | undefined; +export type ConfigCatClientFactory = () => Client; +export const ConfigCatClientFactory = Symbol("ConfigCatClientFactory"); + export function getExperimentsClientForBackend(): Client { // We have already instantiated a client, we can just re-use it. if (client !== undefined) { diff --git a/components/server/src/container-module.ts b/components/server/src/container-module.ts index 05139771cb2100..aaea774d9bfba8 100644 --- a/components/server/src/container-module.ts +++ b/components/server/src/container-module.ts @@ -107,6 +107,10 @@ import { UsageServiceClientProvider, } from "@gitpod/usage-api/lib/usage/v1/sugar"; import { CommunityEntitlementService, EntitlementService } from "./billing/entitlement-service"; +import { + ConfigCatClientFactory, + getExperimentsClientForBackend, +} from "@gitpod/gitpod-protocol/lib/experiments/configcat-server"; export const productionContainerModule = new ContainerModule((bind, unbind, isBound, rebind) => { bind(Config).toConstantValue(ConfigFile.fromFile()); @@ -264,4 +268,10 @@ export const productionContainerModule = new ContainerModule((bind, unbind, isBo bind(UsageServiceClientCallMetrics).toService(IClientCallMetrics); bind(EntitlementService).to(CommunityEntitlementService).inSingletonScope(); + + bind(ConfigCatClientFactory) + .toDynamicValue((ctx) => { + return () => getExperimentsClientForBackend(); + }) + .inSingletonScope(); }); From a1435e3f36da3790cb009ec0b035d0cf695589c3 Mon Sep 17 00:00:00 2001 From: Gero Posmyk-Leinemann Date: Thu, 4 Aug 2022 09:51:13 +0000 Subject: [PATCH 4/4] [server] Introduce BillingMode incl. tests --- .../src/accounting/subscription-service.ts | 11 +- .../src/accounting-protocol.ts | 3 + .../gitpod-protocol/src/billing-mode.ts | 66 +++ .../src/team-subscription-protocol.ts | 3 + .../ee/src/billing/billing-mode.spec.db.ts | 535 ++++++++++++++++++ .../server/ee/src/billing/billing-mode.ts | 174 ++++++ components/server/ee/src/container-module.ts | 2 + 7 files changed, 793 insertions(+), 1 deletion(-) create mode 100644 components/gitpod-protocol/src/billing-mode.ts create mode 100644 components/server/ee/src/billing/billing-mode.spec.db.ts create mode 100644 components/server/ee/src/billing/billing-mode.ts diff --git a/components/ee/payment-endpoint/src/accounting/subscription-service.ts b/components/ee/payment-endpoint/src/accounting/subscription-service.ts index c2a2d56fbece81..ea61f1e7464057 100644 --- a/components/ee/payment-endpoint/src/accounting/subscription-service.ts +++ b/components/ee/payment-endpoint/src/accounting/subscription-service.ts @@ -133,8 +133,17 @@ export class SubscriptionService { * @returns Whether the user has an active subscription (user-paid or team s.) at the given date */ async hasActivePaidSubscription(userId: string, date: Date): Promise { + return (await this.getActivePaidSubscription(userId, date)).length > 0; + } + + /** + * @param userId + * @param date The date on which the subscription has to be active + * @returns The list of a active subscriptions (user-paid or team s.) at the given date + */ + async getActivePaidSubscription(userId: string, date: Date): Promise { const subscriptions = await this.accountingDB.findActiveSubscriptionsForUser(userId, date.toISOString()); - return subscriptions.filter((s) => Subscription.isActive(s, date.toISOString())).length > 0; + return subscriptions.filter((s) => Subscription.isActive(s, date.toISOString())); } async store(db: AccountingDB, model: SubscriptionModel) { diff --git a/components/gitpod-protocol/src/accounting-protocol.ts b/components/gitpod-protocol/src/accounting-protocol.ts index e1e938b9af9eff..ae37d55420fdc2 100644 --- a/components/gitpod-protocol/src/accounting-protocol.ts +++ b/components/gitpod-protocol/src/accounting-protocol.ts @@ -199,6 +199,9 @@ export namespace Subscription { export function isActive(s: Subscription, date: string): boolean { return s.startDate <= date && (s.endDate === undefined || date < s.endDate); } + export function isCancelled(s: Subscription, date: string): boolean { + return (!!s.cancellationDate && s.cancellationDate < date) || (!!s.endDate && s.endDate < date); // This edge case is meant to handle bad data: If for whatever reason cancellationDate has not been set: treat endDate as such + } export function isDowngraded(s: Subscription) { return s.paymentData && s.paymentData.downgradeDate; } diff --git a/components/gitpod-protocol/src/billing-mode.ts b/components/gitpod-protocol/src/billing-mode.ts new file mode 100644 index 00000000000000..2fbe822355c872 --- /dev/null +++ b/components/gitpod-protocol/src/billing-mode.ts @@ -0,0 +1,66 @@ +/** + * 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. + */ + +/** + * BillingMode is used to answer the following questions: + * - Should UI piece x be displayed for this user/team? (getBillingModeForUser/Team) + * - What model should be used to limit this workspace's capabilities (mayStartWorkspace, setTimeout, workspace class, etc...) (getBillingMode(workspaceInstance.attributionId)) + * - How is a workspace session charged for? (getBillingMode(workspaceInstance.attributionId)) + */ +export type BillingMode = None | Chargebee | UsageBased; +export namespace BillingMode { + export const NONE: None = { + mode: "none", + }; + + export function showChargebeeBilling(billingMode: BillingMode): boolean { + if (billingMode.mode === "chargebee") { + return true; + } + return false; + } + + export function showChargebeeInvoices(billingMode: BillingMode): boolean { + if (showChargebeeBilling(billingMode)) { + return true; + } + // TODO(gpl) or hasBeenCustomer, so they can access invoices (edge case?) + return false; + } + + /** Incl. upgrade and status */ + export function showStripeBilling(billingMode: BillingMode): boolean { + return ( + billingMode.mode === "usage-based" || (billingMode.mode === "chargebee" && !!billingMode.canUpgradeToUBB) + ); + } + + export function canSetWorkspaceClass(billingMode: BillingMode): boolean { + // if has any Stripe subscription, either directly or per team + return billingMode.mode === "usage-based"; + } + + export function canSetCostCenter(billingMode: BillingMode): boolean { + // if has any Stripe Subscription, either directly or per team + return billingMode.mode === "usage-based"; + } +} + +/** Payment is disabled */ +interface None { + mode: "none"; +} + +/** Sessions is handled with old subscription logic based on Chargebee */ +interface Chargebee { + mode: "chargebee"; + canUpgradeToUBB?: boolean; +} + +/** Session is handld with new usage-based logic */ +interface UsageBased { + mode: "usage-based"; +} diff --git a/components/gitpod-protocol/src/team-subscription-protocol.ts b/components/gitpod-protocol/src/team-subscription-protocol.ts index 699bf8d09ad155..70b4177ad48fd4 100644 --- a/components/gitpod-protocol/src/team-subscription-protocol.ts +++ b/components/gitpod-protocol/src/team-subscription-protocol.ts @@ -55,6 +55,9 @@ export namespace TeamSubscription2 { export const isActive = (ts2: TeamSubscription2, date: string): boolean => { return ts2.startDate <= date && (ts2.endDate === undefined || date < ts2.endDate); }; + export function isCancelled(s: TeamSubscription2, date: string): boolean { + return (!!s.cancellationDate && s.cancellationDate < date) || (!!s.endDate && s.endDate < date); // This edge case is meant to handle bad data: If for whatever reason cancellationDate has not been set: treat endDate as such + } } /** diff --git a/components/server/ee/src/billing/billing-mode.spec.db.ts b/components/server/ee/src/billing/billing-mode.spec.db.ts new file mode 100644 index 00000000000000..b147e6eef34cf5 --- /dev/null +++ b/components/server/ee/src/billing/billing-mode.spec.db.ts @@ -0,0 +1,535 @@ +/** + * 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 { + AccountingDB, + DBUser, + TeamDB, + TeamSubscription2DB, + TeamSubscriptionDB, + TypeORM, + UserDB, +} from "@gitpod/gitpod-db/lib"; +import { dbContainerModule } from "@gitpod/gitpod-db/lib/container-module"; +import { DBSubscription } from "@gitpod/gitpod-db/lib/typeorm/entity/db-subscription"; +import { DBTeam } from "@gitpod/gitpod-db/lib/typeorm/entity/db-team"; +import { DBTeamMembership } from "@gitpod/gitpod-db/lib/typeorm/entity/db-team-membership"; +import { DBTeamSubscription } from "@gitpod/gitpod-db/lib/typeorm/entity/db-team-subscription"; +import { DBTeamSubscription2 } from "@gitpod/gitpod-db/lib/typeorm/entity/db-team-subscription-2"; +import { DBTeamSubscriptionSlot } from "@gitpod/gitpod-db/lib/typeorm/entity/db-team-subscription-slot"; +import { SubscriptionService } from "@gitpod/gitpod-payment-endpoint/lib/accounting"; +import { Team, User } from "@gitpod/gitpod-protocol"; +import { Subscription } from "@gitpod/gitpod-protocol/lib/accounting-protocol"; +import { AttributionId } from "@gitpod/gitpod-protocol/lib/attribution"; +import { BillingMode } from "@gitpod/gitpod-protocol/lib/billing-mode"; +import { ConfigCatClientFactory } from "@gitpod/gitpod-protocol/lib/experiments/configcat-server"; +import { Attributes, Client } from "@gitpod/gitpod-protocol/lib/experiments/types"; +import { Plan, Plans } from "@gitpod/gitpod-protocol/lib/plans"; +import { + TeamSubscription, + TeamSubscription2, + TeamSubscriptionSlot, +} from "@gitpod/gitpod-protocol/lib/team-subscription-protocol"; +import * as chai from "chai"; +import { Container, ContainerModule } from "inversify"; +import { suite, test, timeout } from "mocha-typescript"; +import Stripe from "stripe"; +import { Config } from "../../../src/config"; +import { StripeService } from "../user/stripe-service"; +import { BillingModes, BillingModesImpl } from "./billing-mode"; +import * as deepEqualInAnyOrder from "deep-equal-in-any-order"; +chai.use(deepEqualInAnyOrder); +const expect = chai.expect; + +type StripeSubscription = Pick & { customer: string }; +class StripeServiceMock extends StripeService { + constructor(protected readonly subscription?: StripeSubscription) { + super(); + } + + async findUncancelledSubscriptionByCustomer(customerId: string): Promise { + if (this.subscription?.customer === customerId) { + return this.subscription as Stripe.Subscription; + } + return undefined; + } + + async findCustomerByUserId(userId: string): Promise { + const customerId = this.subscription?.customer; + if (!customerId) { + return undefined; + } + return { + id: customerId, + } as Stripe.Customer; + } + + async findCustomerByTeamId(teamId: string): Promise { + return this.findCustomerByUserId(teamId); + } +} + +class ConfigCatClientMock implements Client { + constructor(protected readonly usageBasedPricingEnabled: boolean) {} + + async getValueAsync(experimentName: string, defaultValue: T, attributes: Attributes): Promise { + return this.usageBasedPricingEnabled as any as T; + } + + dispose() {} +} + +@suite +class BillingModeSpec { + @test(timeout(20000)) + public async testBillingModes() { + const userId = "123"; + const stripeCustomerId = "customer-123"; + const creationDate = "2022-01-01T19:00:00.000Z"; + const cancellationDate = "2022-01-15T19:00:00.000Z"; + const now = "2022-01-15T20:00:00.000Z"; + const endDate = "2022-01-16T19:00:00.000Z"; + function user(): User { + return { + id: userId, + creationDate, + identities: [], + }; + } + + function team(): Pick { + return { + name: "team-123", + }; + } + + function subscription(plan: Plan, cancellationDate?: string, endDate?: string): Subscription { + return Subscription.create({ + startDate: creationDate, + userId, + planId: plan.chargebeeId, + amount: Plans.getHoursPerMonth(plan), + cancellationDate, + endDate: endDate || cancellationDate, + }); + } + + function stripeSubscription(): StripeSubscription { + return { + id: "stripe-123", + customer: stripeCustomerId, + }; + } + + interface Test { + name: string; + subject: User | Pick; + config: { + enablePayment: boolean; + usageBasedPricingEnabled: boolean; + subscriptions?: Subscription[]; + stripeSubscription?: StripeSubscription; + }; + expectation: BillingMode; + } + const tests: Test[] = [ + // user: payment? + { + name: "payment disabled (ubb: true)", + subject: user(), + config: { + enablePayment: false, + usageBasedPricingEnabled: true, + }, + expectation: { + mode: "none", + }, + }, + { + name: "payment disabled (ubb: false)", + subject: user(), + config: { + enablePayment: false, + usageBasedPricingEnabled: false, + }, + expectation: { + mode: "none", + }, + }, + { + name: "payment enabled (ubb: false)", + subject: user(), + config: { + enablePayment: true, + usageBasedPricingEnabled: false, + }, + expectation: { + mode: "chargebee", + }, + }, + // user: chargebee + { + name: "user: chargbee paid personal", + subject: user(), + config: { + enablePayment: true, + usageBasedPricingEnabled: false, + subscriptions: [subscription(Plans.PERSONAL_EUR)], + }, + expectation: { + mode: "chargebee", + }, + }, + { + name: "user: chargbee paid team seat", + subject: user(), + config: { + enablePayment: true, + usageBasedPricingEnabled: false, + subscriptions: [subscription(Plans.TEAM_PROFESSIONAL_EUR)], + }, + expectation: { + mode: "chargebee", + }, + }, + { + name: "user: chargbee paid personal + team seat", + subject: user(), + config: { + enablePayment: true, + usageBasedPricingEnabled: false, + subscriptions: [subscription(Plans.PERSONAL_EUR), subscription(Plans.TEAM_PROFESSIONAL_EUR)], + }, + expectation: { + mode: "chargebee", + }, + }, + // user: transition chargebee -> UBB + { + name: "user: chargbee paid personal (cancelled) + team seat", + subject: user(), + config: { + enablePayment: true, + usageBasedPricingEnabled: true, + subscriptions: [ + subscription(Plans.PERSONAL_EUR, cancellationDate, endDate), + subscription(Plans.TEAM_PROFESSIONAL_EUR), + ], + }, + expectation: { + mode: "chargebee", + canUpgradeToUBB: true, + }, + }, + { + name: "user: chargbee paid personal (cancelled) + team seat (cancelled)", + subject: user(), + config: { + enablePayment: true, + usageBasedPricingEnabled: true, + subscriptions: [ + subscription(Plans.PERSONAL_EUR, cancellationDate, endDate), + subscription(Plans.TEAM_PROFESSIONAL_EUR, cancellationDate, endDate), + ], + }, + expectation: { + mode: "chargebee", + canUpgradeToUBB: true, + }, + }, + // user: usage-based + { + name: "user: stripe free, chargbee paid personal (inactive) + team seat (inactive)", + subject: user(), + config: { + enablePayment: true, + usageBasedPricingEnabled: true, + subscriptions: [ + subscription(Plans.PERSONAL_EUR, cancellationDate, cancellationDate), + subscription(Plans.TEAM_PROFESSIONAL_EUR, cancellationDate, cancellationDate), + ], + }, + expectation: { + mode: "usage-based", + }, + }, + { + name: "user: stripe paid, chargbee paid personal (inactive) + team seat (inactive)", + subject: user(), + config: { + enablePayment: true, + usageBasedPricingEnabled: true, + subscriptions: [ + subscription(Plans.PERSONAL_EUR, cancellationDate, cancellationDate), + subscription(Plans.TEAM_PROFESSIONAL_EUR, cancellationDate, cancellationDate), + ], + stripeSubscription: stripeSubscription(), + }, + expectation: { + mode: "usage-based", + }, + }, + { + name: "user: stripe paid", + subject: user(), + config: { + enablePayment: true, + usageBasedPricingEnabled: true, + stripeSubscription: stripeSubscription(), + }, + expectation: { + mode: "usage-based", + }, + }, + { + name: "user: stripe free", + subject: user(), + config: { + enablePayment: true, + usageBasedPricingEnabled: true, + }, + expectation: { + mode: "usage-based", + }, + }, + // team: payment? + { + name: "payment disabled (ubb: true)", + subject: team(), + config: { + enablePayment: false, + usageBasedPricingEnabled: true, + }, + expectation: { + mode: "none", + }, + }, + { + name: "payment disabled (ubb: false)", + subject: team(), + config: { + enablePayment: false, + usageBasedPricingEnabled: false, + }, + expectation: { + mode: "none", + }, + }, + { + name: "payment enabled (ubb: false)", + subject: team(), + config: { + enablePayment: true, + usageBasedPricingEnabled: false, + }, + expectation: { + mode: "chargebee", + }, + }, + // team: chargebee + { + name: "team: chargbee paid", + subject: team(), + config: { + enablePayment: true, + usageBasedPricingEnabled: false, + subscriptions: [subscription(Plans.TEAM_PROFESSIONAL_EUR)], + }, + expectation: { + mode: "chargebee", + }, + }, + { + name: "team: chargbee paid (UBB)", + subject: team(), + config: { + enablePayment: true, + usageBasedPricingEnabled: true, + subscriptions: [subscription(Plans.TEAM_PROFESSIONAL_EUR)], + }, + expectation: { + mode: "chargebee", + }, + }, + // team: transition chargebee -> UBB + { + name: "team: chargbee paid (cancelled)", + subject: team(), + config: { + enablePayment: true, + usageBasedPricingEnabled: true, + subscriptions: [subscription(Plans.TEAM_PROFESSIONAL_EUR, cancellationDate, endDate)], + }, + expectation: { + mode: "chargebee", + canUpgradeToUBB: true, + }, + }, + // team: usage-based + { + name: "team: usage-based free", + subject: team(), + config: { + enablePayment: true, + usageBasedPricingEnabled: true, + }, + expectation: { + mode: "usage-based", + }, + }, + { + name: "team: stripe free, chargbee (inactive)", + subject: team(), + config: { + enablePayment: true, + usageBasedPricingEnabled: true, + subscriptions: [subscription(Plans.TEAM_PROFESSIONAL_EUR, cancellationDate, cancellationDate)], + }, + expectation: { + mode: "usage-based", + }, + }, + { + name: "team: stripe paid, chargbee (inactive)", + subject: team(), + config: { + enablePayment: true, + usageBasedPricingEnabled: true, + subscriptions: [subscription(Plans.TEAM_PROFESSIONAL_EUR, cancellationDate, cancellationDate)], + stripeSubscription: stripeSubscription(), + }, + expectation: { + mode: "usage-based", + }, + }, + { + name: "team: stripe paid", + subject: team(), + config: { + enablePayment: true, + usageBasedPricingEnabled: true, + stripeSubscription: stripeSubscription(), + }, + expectation: { + mode: "usage-based", + }, + }, + ]; + + for (const test of tests) { + // Setup test code, environment and data + const testContainer = new Container(); + testContainer.load(dbContainerModule); + testContainer.load( + new ContainerModule((bind, unbind, isBound, rebind) => { + bind(Config).toConstantValue({ enablePayment: test.config.enablePayment } as Config); + bind(SubscriptionService).toSelf().inSingletonScope(); + bind(BillingModes).to(BillingModesImpl).inSingletonScope(); + + bind(StripeService).toConstantValue(new StripeServiceMock(test.config.stripeSubscription)); + bind(ConfigCatClientFactory).toConstantValue( + () => new ConfigCatClientMock(test.config.usageBasedPricingEnabled), + ); + }), + ); + + const userDB = testContainer.get(UserDB); + const teamDB = testContainer.get(TeamDB); + const accountingDB = testContainer.get(AccountingDB); + const teamSubscriptionDB = testContainer.get(TeamSubscriptionDB); + const teamSubscription2DB = testContainer.get(TeamSubscription2DB); + + let teamId: string | undefined = undefined; + let teamMembershipId: string | undefined = undefined; + let attributionId: AttributionId | undefined = undefined; + if (User.is(test.subject)) { + const user = test.subject; + await userDB.storeUser(user); + attributionId = { kind: "user", userId }; + } else { + const team = await teamDB.createTeam(userId, test.subject.name); + teamId = team.id; + attributionId = { kind: "team", teamId: team.id }; + const membership = await teamDB.findTeamMembership(userId, teamId); + if (!membership) { + throw new Error(`${test.name}: Invalid test data: expected membership for team to exist!`); + } + teamMembershipId = membership.id; + } + if (!attributionId) { + throw new Error("Invalid test data: no subject configured!"); + } + for (const sub of test.config.subscriptions || []) { + const plan = Plans.getById(sub.planId!); + if (plan?.team) { + if (teamId) { + // TeamSubscription2 - only relevant for teams (for BillingMode) + const ts2 = TeamSubscription2.create({ + teamId, + planId: plan.chargebeeId, + excludeFromMoreResources: false, + paymentReference: "some-cb-ref", + quantity: 10, + startDate: sub.startDate, + cancellationDate: sub.cancellationDate, + endDate: sub.endDate, + }); + await teamSubscription2DB.storeEntry(ts2); + sub.teamMembershipId = teamMembershipId; + await accountingDB.storeSubscription(sub); + } else { + // TeamSubscription - only relevant for users (for BillingMode) + const ts = TeamSubscription.create({ + userId, + planId: plan.chargebeeId, + quantity: 10, + paymentReference: "some-cb-ref", + excludeFromMoreResources: false, + startDate: sub.startDate, + cancellationDate: sub.cancellationDate, + endDate: sub.endDate, + }); + await teamSubscriptionDB.storeTeamSubscriptionEntry(ts); + const slot = TeamSubscriptionSlot.create({ + teamSubscriptionId: ts.id, + assigneeId: userId, + cancellationDate: sub.cancellationDate, + subscriptionId: sub.uid, + }); + await teamSubscriptionDB.storeSlot(slot); + sub.teamSubscriptionSlotId = slot.id; + await accountingDB.storeSubscription(sub); + } + } else { + await accountingDB.storeSubscription(sub); + } + } + + // Run test + const cut = testContainer.get(BillingModes); + const actual = await cut.getBillingMode(attributionId, new Date(now)); + expect( + actual, + `${test.name}: Expected BillingMode to be '${JSON.stringify( + test.expectation, + )}' but got '${JSON.stringify(actual)}'`, + ).to.deep.equalInAnyOrder(test.expectation); + + // Wipe DB + const typeorm = testContainer.get(TypeORM); + const manager = await typeorm.getConnection(); + await manager.getRepository(DBSubscription).delete({}); + await manager.getRepository(DBTeamSubscription).delete({}); + await manager.getRepository(DBTeamSubscription2).delete({}); + await manager.getRepository(DBTeamSubscriptionSlot).delete({}); + await manager.getRepository(DBTeam).delete({}); + await manager.getRepository(DBTeamMembership).delete({}); + await manager.getRepository(DBUser).delete({}); + } + } +} + +module.exports = new BillingModeSpec(); diff --git a/components/server/ee/src/billing/billing-mode.ts b/components/server/ee/src/billing/billing-mode.ts new file mode 100644 index 00000000000000..63a8380128a591 --- /dev/null +++ b/components/server/ee/src/billing/billing-mode.ts @@ -0,0 +1,174 @@ +/** + * 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 { inject, injectable } from "inversify"; + +import { Team, User } from "@gitpod/gitpod-protocol"; +import { ConfigCatClientFactory } from "@gitpod/gitpod-protocol/lib/experiments/configcat-server"; +import { SubscriptionService } from "@gitpod/gitpod-payment-endpoint/lib/accounting"; +import { Subscription } from "@gitpod/gitpod-protocol/lib/accounting-protocol"; +import { Config } from "../../../src/config"; +import { StripeService } from "../user/stripe-service"; +import { BillingMode } from "@gitpod/gitpod-protocol/lib/billing-mode"; +import { TeamDB, TeamSubscription2DB, UserDB } from "@gitpod/gitpod-db/lib"; +import { Plans } from "@gitpod/gitpod-protocol/lib/plans"; +import { AttributionId } from "@gitpod/gitpod-protocol/lib/attribution"; +import { TeamSubscription2 } from "@gitpod/gitpod-protocol/lib/team-subscription-protocol"; + +export const BillingModes = Symbol("BillingModes"); +export interface BillingModes { + getBillingMode(attributionId: AttributionId, now: Date): Promise; + getBillingModeForUser(user: User, now: Date): Promise; + getBillingModeForTeam(team: Team, now: Date): Promise; +} + +/** + * + * Some rules for how we decide about BillingMode someone is in: + * - Teams: Do they have either: + * - Chargebee subscription => cb + * - UBB (& maybe Stripe subscription): => ubb + * - Users: Do they have either: + * - personal Chargebee subscription => cb + * - personal Stripe Subscription => ubb + * - at least one Stripe Team seat => ubb + */ +@injectable() +export class BillingModesImpl implements BillingModes { + @inject(Config) protected readonly config: Config; + @inject(ConfigCatClientFactory) protected readonly configCatClientFactory: ConfigCatClientFactory; + @inject(SubscriptionService) protected readonly subscriptionSvc: SubscriptionService; + @inject(StripeService) protected readonly stripeSvc: StripeService; + @inject(TeamSubscription2DB) protected readonly teamSubscription2Db: TeamSubscription2DB; + @inject(TeamDB) protected readonly teamDB: TeamDB; + @inject(UserDB) protected readonly userDB: UserDB; + + async getBillingMode(attributionId: AttributionId, now: Date): Promise { + switch (attributionId.kind) { + case "team": + const team = await this.teamDB.findTeamById(attributionId.teamId); + if (!team) { + throw new Error(`Cannot find team with id '${attributionId.teamId}'!`); + } + return this.getBillingModeForTeam(team, now); + case "user": + const user = await this.userDB.findUserById(attributionId.userId); + if (!user) { + throw new Error(`Cannot find user with id '${attributionId.userId}'!`); + } + return this.getBillingModeForUser(user, now); + } + } + + async getBillingModeForUser(user: User, now: Date): Promise { + if (!this.config.enablePayment) { + // Payment is not enabled. E.g. Self-Hosted. + return { mode: "none" }; + } + + // Is Usage Based Billing enabled for this user or not? + const teams = await this.teamDB.findTeamsByUser(user.id); + const isUsageBasedBillingEnabled = await this.configCatClientFactory().getValueAsync( + "isUsageBasedBillingEnabled", + false, + { + user, + teams, + }, + ); + + // 1. UBB enabled? + if (!isUsageBasedBillingEnabled) { + // UBB is not enabled: definitely chargebee + return { mode: "chargebee" }; + } + + // 2. Any personal subscriptions? + // Chargebee takes precedence + function isTeamSubscription(s: Subscription): boolean { + return !!Plans.getById(s.planId)?.team; + } + const cbSubscriptions = await this.subscriptionSvc.getActivePaidSubscription(user.id, now); + const cbTeamSubscriptions = cbSubscriptions.filter((s) => isTeamSubscription(s)); + const cbPersonalSubscriptions = cbSubscriptions.filter((s) => !isTeamSubscription(s)); + if (cbPersonalSubscriptions.length > 0) { + if (cbPersonalSubscriptions.every((s) => Subscription.isCancelled(s, now.toISOString()))) { + // The user has one or more paid subscriptions, but all of them have already been cancelled + return { mode: "chargebee", canUpgradeToUBB: true }; + } + + // The user has at least one paid personal subscription + return { + mode: "chargebee", + }; + } + + // Stripe: Active personal subsciption? + const customer = await this.stripeSvc.findCustomerByUserId(user.id); + if (customer) { + const subscription = await this.stripeSvc.findUncancelledSubscriptionByCustomer(customer.id); + if (subscription) { + return { mode: "usage-based" }; + } + } + + // 3. Check team memberships/plans + // UBB overrides wins if there is _any_. But if there is none, use the existing Chargebee subscription. + const teamsModes = await Promise.all(teams.map((t) => this.getBillingModeForTeam(t, now))); + const hasUbbTeam = teamsModes.some((tm) => tm.mode === "usage-based"); + const hasCbTeam = teamsModes.some((tm) => tm.mode === "chargebee"); + const hasCbTeamSeat = cbTeamSubscriptions.length > 0; + + if (hasUbbTeam) { + return { mode: "usage-based" }; // UBB is gready: once a user has at least a team seat, they should benefit from it! + } + if (hasCbTeam || hasCbTeamSeat) { + // TODO(gpl): Q: How to test the free-tier, then? A: Make sure you have no CB seats anymore + // For that we could add a new field here, which lists all seats that are "blocking" you, and display them in the UI somewhere. + return { mode: "chargebee", canUpgradeToUBB: true }; // UBB is enabled, but no seat nor subscription yet. + } + + // UBB free tier + return { mode: "usage-based" }; + } + + async getBillingModeForTeam(team: Team, _now: Date): Promise { + if (!this.config.enablePayment) { + // Payment is not enabled. E.g. Self-Hosted. + return { mode: "none" }; + } + const now = _now.toISOString(); + + // Is Usage Based Billing enabled for this team? + const isUsageBasedBillingEnabled = await this.configCatClientFactory().getValueAsync( + "isUsageBasedBillingEnabled", + false, + { + teamId: team.id, + teamName: team.name, + }, + ); + + // 1. UBB enabled? + if (!isUsageBasedBillingEnabled) { + return { mode: "chargebee" }; + } + + // 2. Any Chargbee TeamSubscription2 (old Team Subscriptions are not relevant here, as they are not associated with a team) + const teamSubscription = await this.teamSubscription2Db.findForTeam(team.id, now); + if (teamSubscription && TeamSubscription2.isActive(teamSubscription, now)) { + if (TeamSubscription2.isCancelled(teamSubscription, now)) { + // The team has a paid subscription, but it's already cancelled, and UBB enabled + return { mode: "chargebee", canUpgradeToUBB: true }; + } + + return { mode: "chargebee" }; + } + + // 3. If not: we don't even have to check for a team subscription + return { mode: "usage-based" }; + } +} diff --git a/components/server/ee/src/container-module.ts b/components/server/ee/src/container-module.ts index 72820755feeb00..5cf0b0de60c710 100644 --- a/components/server/ee/src/container-module.ts +++ b/components/server/ee/src/container-module.ts @@ -63,6 +63,7 @@ import { UserCounter } from "./user/user-counter"; import { BitbucketServerApp } from "./prebuilds/bitbucket-server-app"; import { EntitlementService } from "../../src/billing/entitlement-service"; import { EntitlementServiceChargebee } from "./billing/entitlement-service-chargebee"; +import { BillingModes, BillingModesImpl } from "./billing/billing-mode"; export const productionEEContainerModule = new ContainerModule((bind, unbind, isBound, rebind) => { rebind(Server).to(ServerEE).inSingletonScope(); @@ -127,4 +128,5 @@ export const productionEEContainerModule = new ContainerModule((bind, unbind, is bind(EntitlementServiceChargebee).toSelf().inSingletonScope(); rebind(EntitlementService).to(EntitlementServiceChargebee).inSingletonScope(); + bind(BillingModes).to(BillingModesImpl).inSingletonScope(); });