Skip to content

Introduce BillingMode (1/4) #11812

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Aug 4, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<User, 'id' | 'creationDate'>;
export type UserCreated = Pick<User, "id" | "creationDate">;

@injectable()
export class SubscriptionService {
Expand All @@ -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<Subscription[]> {
async getSubscriptionHistoryForUserInPeriod(
user: UserCreated,
startDate: string,
endDate: string,
): Promise<Subscription[]> {
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);
}

/**
Expand All @@ -40,9 +42,7 @@ export class SubscriptionService {
async getNotYetCancelledSubscriptions(user: UserCreated, date: string): Promise<Subscription[]> {
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);
}

/**
Expand All @@ -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);
});
}
Expand All @@ -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<Subscription> {
async subscribe(
userId: string,
plan: Plan,
paymentReference: string | undefined,
startDate: string,
endDate?: string,
): Promise<Subscription> {
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 = <Subscription> {
const newSubscription = <Subscription>{
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);
});
}
Expand All @@ -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;
Expand All @@ -107,10 +116,14 @@ export class SubscriptionService {
}

async addCredit(userId: string, amount: number, date: string, expiryDate?: string): Promise<AccountEntry> {
const entry = <AccountEntry> {
userId, amount, date, expiryDate, kind: 'credit'
const entry = <AccountEntry>{
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);
}

Expand All @@ -120,21 +133,33 @@ 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<boolean> {
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<Subscription[]> {
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) {
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<Subscription[]>{
private async doUnsubscribe(
db: AccountingDB,
userId: string,
endDate: string,
planId: string,
): Promise<Subscription[]> {
const subscriptions = await db.findAllSubscriptionsForUser(userId);
for (let subscription of subscriptions) {
if (planId === subscription.planId) {
Expand All @@ -144,7 +169,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);
}
}
Expand Down
3 changes: 3 additions & 0 deletions components/gitpod-protocol/src/accounting-protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
66 changes: 66 additions & 0 deletions components/gitpod-protocol/src/billing-mode.ts
Original file line number Diff line number Diff line change
@@ -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 {
Copy link
Member Author

@geropl geropl Aug 4, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These functions are not used nor tested, yet, but helped me greatly when thinking about how we want to used BillingMode later on. Left them in for reference and to ensure our usage is in line with the implementation/derivation logic.

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";
}
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

/**
Expand Down
Loading