diff --git a/components/dashboard/src/settings/Plans.tsx b/components/dashboard/src/settings/Plans.tsx
index 2192e7fe6e1086..8b66e32cfe59fa 100644
--- a/components/dashboard/src/settings/Plans.tsx
+++ b/components/dashboard/src/settings/Plans.tsx
@@ -10,6 +10,7 @@ import {
Subscription,
UserPaidSubscription,
AssignedTeamSubscription,
+ AssignedTeamSubscription2,
CreditDescription,
} from "@gitpod/gitpod-protocol/lib/accounting-protocol";
import { PlanCoupon, GithubUpgradeURL } from "@gitpod/gitpod-protocol/lib/payment-protocol";
@@ -80,16 +81,18 @@ export default function () {
const paidSubscription = activeSubscriptions.find((s) => UserPaidSubscription.is(s));
const paidPlan = paidSubscription && Plans.getById(paidSubscription.planId);
- const assignedTeamSubscriptions = activeSubscriptions.filter((s) => AssignedTeamSubscription.is(s));
+ const assignedTeamSubscriptions = activeSubscriptions.filter(
+ (s) => AssignedTeamSubscription.is(s) || AssignedTeamSubscription2.is(s),
+ );
const getAssignedTs = (type: PlanType) =>
assignedTeamSubscriptions.find((s) => {
const p = Plans.getById(s.planId);
return !!p && p.type === type;
});
- const assignedProfessionalTs = getAssignedTs("professional-new");
const assignedUnleashedTs = getAssignedTs("professional");
const assignedStudentUnleashedTs = getAssignedTs("student");
- const assignedTs = assignedProfessionalTs || assignedUnleashedTs || assignedStudentUnleashedTs;
+ const assignedProfessionalTs = getAssignedTs("professional-new");
+ const assignedTs = assignedUnleashedTs || assignedStudentUnleashedTs || assignedProfessionalTs;
const claimedTeamSubscriptionId = new URL(window.location.href).searchParams.get("teamid");
if (
@@ -674,7 +677,7 @@ export default function () {
)}
{
ChargebeeClient.getOrCreate().then((chargebeeClient) =>
diff --git a/components/ee/payment-endpoint/src/accounting/subscription-model.ts b/components/ee/payment-endpoint/src/accounting/subscription-model.ts
index cf6d36fbc5a071..0f7aa87a0fa73e 100644
--- a/components/ee/payment-endpoint/src/accounting/subscription-model.ts
+++ b/components/ee/payment-endpoint/src/accounting/subscription-model.ts
@@ -10,8 +10,8 @@ import { orderByEndDateDescThenStartDateDesc, orderByStartDateAscEndDateAsc } fr
/**
* This class maintains the following invariant on a given set of Subscriptions and over the offered operations:
- * - Whenever a users paid (non-FREE) subscription starts: End his FREE subscription
- * - For every period a user has non paid subscription: Grant him a FREE subscription
+ * - Whenever a users paid (non-FREE) subscription starts: End their FREE subscription
+ * - For every period a user has non paid subscription: Grant them a FREE subscription
*/
export class SubscriptionModel {
protected readonly result: SubscriptionModel.Result = SubscriptionModel.Result.create();
@@ -61,6 +61,14 @@ export class SubscriptionModel {
return subscriptionsForSlot.sort(orderByEndDateDescThenStartDateDesc)[0];
}
+ findSubscriptionByTeamMembershipId(teamMembershipId: string): Subscription | undefined {
+ const subscriptionsForMembership = this.subscriptions.filter(s => s.teamMembershipId === teamMembershipId);
+ if (subscriptionsForMembership.length === 0) {
+ return undefined;
+ }
+ return subscriptionsForMembership.sort(orderByEndDateDescThenStartDateDesc)[0];
+ }
+
getResult(): SubscriptionModel.Result {
return SubscriptionModel.Result.copy(this.result);
}
diff --git a/components/gitpod-db/src/container-module.ts b/components/gitpod-db/src/container-module.ts
index f8af836c841884..49c72d54e618dd 100644
--- a/components/gitpod-db/src/container-module.ts
+++ b/components/gitpod-db/src/container-module.ts
@@ -62,6 +62,8 @@ import { OssAllowListDB } from "./oss-allowlist-db";
import { OssAllowListDBImpl } from "./typeorm/oss-allowlist-db-impl";
import { TypeORMInstallationAdminImpl } from "./typeorm/installation-admin-db-impl";
import { InstallationAdminDB } from "./installation-admin-db";
+import { TeamSubscription2DB } from "./team-subscription-2-db";
+import { TeamSubscription2DBImpl } from "./typeorm/team-subscription-2-db-impl";
// THE DB container module that contains all DB implementations
export const dbContainerModule = new ContainerModule((bind, unbind, isBound, rebind) => {
@@ -143,6 +145,7 @@ export const dbContainerModule = new ContainerModule((bind, unbind, isBound, reb
};
});
bind(TeamSubscriptionDB).to(TeamSubscriptionDBImpl).inSingletonScope();
+ bind(TeamSubscription2DB).to(TeamSubscription2DBImpl).inSingletonScope();
bind(EmailDomainFilterDB).to(EmailDomainFilterDBImpl).inSingletonScope();
bind(EduEmailDomainDB).to(EduEmailDomainDBImpl).inSingletonScope();
bind(EMailDB).to(TypeORMEMailDBImpl).inSingletonScope();
diff --git a/components/gitpod-db/src/index.ts b/components/gitpod-db/src/index.ts
index 179741cfd4150a..708e9f62d35495 100644
--- a/components/gitpod-db/src/index.ts
+++ b/components/gitpod-db/src/index.ts
@@ -32,6 +32,7 @@ export * from "./pending-github-event-db";
export * from "./typeorm/typeorm";
export * from "./accounting-db";
export * from "./team-subscription-db";
+export * from "./team-subscription-2-db";
export * from "./edu-email-domain-db";
export * from "./email-domain-filter-db";
export * from "./typeorm/entity/db-account-entry";
diff --git a/components/gitpod-db/src/tables.ts b/components/gitpod-db/src/tables.ts
index 47a46baf1588a2..0ef3b4eb0ba8e1 100644
--- a/components/gitpod-db/src/tables.ts
+++ b/components/gitpod-db/src/tables.ts
@@ -262,6 +262,12 @@ export class GitpodTableDescriptionProvider implements TableDescriptionProvider
deletionColumn: "deleted",
timeColumn: "_lastModified",
},
+ {
+ name: "d_b_team_subscription2;",
+ primaryKeys: ["id"],
+ deletionColumn: "deleted",
+ timeColumn: "_lastModified",
+ },
/**
* BEWARE
*
diff --git a/components/gitpod-db/src/team-db.ts b/components/gitpod-db/src/team-db.ts
index 84ef1db2122608..9219e7a4249b66 100644
--- a/components/gitpod-db/src/team-db.ts
+++ b/components/gitpod-db/src/team-db.ts
@@ -5,6 +5,7 @@
*/
import { Team, TeamMemberInfo, TeamMemberRole, TeamMembershipInvite } from "@gitpod/gitpod-protocol";
+import { DBTeamMembership } from "./typeorm/entity/db-team-membership";
export const TeamDB = Symbol("TeamDB");
export interface TeamDB {
@@ -17,11 +18,13 @@ export interface TeamDB {
): Promise<{ total: number; rows: Team[] }>;
findTeamById(teamId: string): Promise;
findMembersByTeam(teamId: string): Promise;
+ findTeamMembership(userId: string, teamId: string): Promise;
findTeamsByUser(userId: string): Promise;
findTeamsByUserAsSoleOwner(userId: string): Promise;
createTeam(userId: string, name: string): Promise;
addMemberToTeam(userId: string, teamId: string): Promise;
setTeamMemberRole(userId: string, teamId: string, role: TeamMemberRole): Promise;
+ setTeamMemberSubscription(userId: string, teamId: string, subscriptionId: string): Promise;
removeMemberFromTeam(userId: string, teamId: string): Promise;
findTeamMembershipInviteById(inviteId: string): Promise;
findGenericInviteByTeamId(teamId: string): Promise;
diff --git a/components/gitpod-db/src/team-subscription-2-db.ts b/components/gitpod-db/src/team-subscription-2-db.ts
new file mode 100644
index 00000000000000..8154147c4aad99
--- /dev/null
+++ b/components/gitpod-db/src/team-subscription-2-db.ts
@@ -0,0 +1,17 @@
+/**
+ * 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 { TeamSubscription2 } from "@gitpod/gitpod-protocol/lib/team-subscription-protocol";
+
+export const TeamSubscription2DB = Symbol("TeamSubscription2DB");
+export interface TeamSubscription2DB {
+ storeEntry(ts: TeamSubscription2): Promise;
+ findById(id: string): Promise;
+ findByPaymentRef(teamId: string, paymentReference: string): Promise;
+ findForTeam(teamId: string, date: string): Promise;
+
+ transaction(code: (db: TeamSubscription2DB) => Promise): Promise;
+}
diff --git a/components/gitpod-db/src/typeorm/deleted-entry-gc.ts b/components/gitpod-db/src/typeorm/deleted-entry-gc.ts
index e25f00f4047222..739f1b52d92163 100644
--- a/components/gitpod-db/src/typeorm/deleted-entry-gc.ts
+++ b/components/gitpod-db/src/typeorm/deleted-entry-gc.ts
@@ -62,6 +62,7 @@ const tables: TableWithDeletion[] = [
{ deletionColumn: "deleted", name: "d_b_project_env_var" },
{ deletionColumn: "deleted", name: "d_b_project_info" },
{ deletionColumn: "deleted", name: "d_b_project_usage" },
+ { deletionColumn: "deleted", name: "d_b_team_subscription2" },
];
interface TableWithDeletion {
diff --git a/components/gitpod-db/src/typeorm/entity/db-subscription.ts b/components/gitpod-db/src/typeorm/entity/db-subscription.ts
index 7e97bdcdef2604..481843c410c550 100644
--- a/components/gitpod-db/src/typeorm/entity/db-subscription.ts
+++ b/components/gitpod-db/src/typeorm/entity/db-subscription.ts
@@ -60,6 +60,12 @@ export class DBSubscription implements Subscription {
@Index("ind_teamSubscriptionSlotId")
teamSubscriptionSlotId?: string;
+ @Column({
+ default: "",
+ transformer: Transformer.MAP_EMPTY_STR_TO_UNDEFINED,
+ })
+ teamMembershipId?: string;
+
@Column({
default: false,
})
diff --git a/components/gitpod-db/src/typeorm/entity/db-team-membership.ts b/components/gitpod-db/src/typeorm/entity/db-team-membership.ts
index 090783d583310f..83a02372ea8096 100644
--- a/components/gitpod-db/src/typeorm/entity/db-team-membership.ts
+++ b/components/gitpod-db/src/typeorm/entity/db-team-membership.ts
@@ -6,6 +6,7 @@
import { TeamMemberRole } from "@gitpod/gitpod-protocol";
import { Entity, Column, PrimaryColumn, Index } from "typeorm";
+import { Transformer } from "../transformer";
import { TypeORM } from "../typeorm";
@Entity()
@@ -28,6 +29,13 @@ export class DBTeamMembership {
@Column("varchar")
creationTime: string;
+ @Column({
+ ...TypeORM.UUID_COLUMN_TYPE,
+ default: "",
+ transformer: Transformer.MAP_EMPTY_STR_TO_UNDEFINED,
+ })
+ subscriptionId?: string;
+
// This column triggers the db-sync deletion mechanism. It's not intended for public consumption.
@Column()
deleted: boolean;
diff --git a/components/gitpod-db/src/typeorm/entity/db-team-subscription-2.ts b/components/gitpod-db/src/typeorm/entity/db-team-subscription-2.ts
new file mode 100644
index 00000000000000..8e08e0cc172ef7
--- /dev/null
+++ b/components/gitpod-db/src/typeorm/entity/db-team-subscription-2.ts
@@ -0,0 +1,52 @@
+/**
+ * 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 { Entity, Column, PrimaryColumn, Index } from "typeorm";
+
+import { TeamSubscription2 } from "@gitpod/gitpod-protocol/lib/team-subscription-protocol";
+
+import { TypeORM } from "../../typeorm/typeorm";
+import { Transformer } from "../../typeorm/transformer";
+
+@Entity()
+@Index("ind_team_paymentReference", ["teamId", "paymentReference"])
+@Index("ind_team_startdate", ["teamId", "startDate"])
+// on DB but not Typeorm: @Index("ind_lastModified", ["_lastModified"]) // DBSync
+export class DBTeamSubscription2 implements TeamSubscription2 {
+ @PrimaryColumn("uuid")
+ id: string;
+
+ @Column(TypeORM.UUID_COLUMN_TYPE)
+ teamId: string;
+
+ @Column()
+ paymentReference: string;
+
+ @Column()
+ startDate: string;
+
+ @Column({
+ default: "",
+ transformer: Transformer.MAP_EMPTY_STR_TO_UNDEFINED,
+ })
+ endDate?: string;
+
+ @Column()
+ planId: string;
+
+ @Column("int")
+ quantity: number;
+
+ @Column({
+ default: "",
+ transformer: Transformer.MAP_EMPTY_STR_TO_UNDEFINED,
+ })
+ cancellationDate?: string;
+
+ // This column triggers the db-sync deletion mechanism. It's not intended for public consumption.
+ @Column()
+ deleted: boolean;
+}
diff --git a/components/gitpod-db/src/typeorm/migration/1650526577994-TeamSubscrition2.ts b/components/gitpod-db/src/typeorm/migration/1650526577994-TeamSubscrition2.ts
new file mode 100644
index 00000000000000..efef1028e20090
--- /dev/null
+++ b/components/gitpod-db/src/typeorm/migration/1650526577994-TeamSubscrition2.ts
@@ -0,0 +1,38 @@
+/**
+ * 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 { MigrationInterface, QueryRunner } from "typeorm";
+import { columnExists, tableExists } from "./helper/helper";
+
+export class TeamSubscrition21650526577994 implements MigrationInterface {
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(
+ "CREATE TABLE IF NOT EXISTS `d_b_team_subscription2` (`id` char(36) NOT NULL, `teamId` char(36) NOT NULL, `paymentReference` varchar(255) NOT NULL, `startDate` varchar(255) NOT NULL, `endDate` varchar(255) NOT NULL DEFAULT '', `planId` varchar(255) NOT NULL, `quantity` int(11) NOT NULL, `cancellationDate` varchar(255) NOT NULL DEFAULT '', `deleted` tinyint(4) NOT NULL DEFAULT '0', `_lastModified` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), PRIMARY KEY (`id`), KEY `ind_team_paymentReference` (`teamId`, `paymentReference`), KEY `ind_team_startDate` (`teamId`, `startDate`), KEY `ind_dbsync` (`_lastModified`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;",
+ );
+ if (!(await columnExists(queryRunner, "d_b_subscription", "teamMembershipId"))) {
+ await queryRunner.query(
+ "ALTER TABLE `d_b_subscription` ADD COLUMN `teamMembershipId` char(36) NOT NULL DEFAULT ''",
+ );
+ }
+ if (!(await columnExists(queryRunner, "d_b_team_membership", "subscriptionId"))) {
+ await queryRunner.query(
+ "ALTER TABLE `d_b_team_membership` ADD COLUMN `subscriptionId` char(36) NOT NULL DEFAULT ''",
+ );
+ }
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ if (await tableExists(queryRunner, "d_b_team_subscription2")) {
+ await queryRunner.query("DROP TABLE `d_b_team_subscription2`");
+ }
+ if (await columnExists(queryRunner, "d_b_subscription", "teamMembershipId")) {
+ await queryRunner.query("ALTER TABLE `d_b_subscription` DROP COLUMN `teamMembershipId`");
+ }
+ if (await columnExists(queryRunner, "d_b_team_membership", "subscriptionId")) {
+ await queryRunner.query("ALTER TABLE `d_b_team_membership` DROP COLUMN `subscriptionId`");
+ }
+ }
+}
diff --git a/components/gitpod-db/src/typeorm/team-db-impl.ts b/components/gitpod-db/src/typeorm/team-db-impl.ts
index ae89efe0bb7d12..6d480baf681a03 100644
--- a/components/gitpod-db/src/typeorm/team-db-impl.ts
+++ b/components/gitpod-db/src/typeorm/team-db-impl.ts
@@ -83,6 +83,11 @@ export class TeamDBImpl implements TeamDB {
return infos.sort((a, b) => (a.memberSince < b.memberSince ? 1 : a.memberSince === b.memberSince ? 0 : -1));
}
+ public async findTeamMembership(userId: string, teamId: string): Promise {
+ const membershipRepo = await this.getMembershipRepo();
+ return membershipRepo.findOne({ userId, teamId, deleted: false });
+ }
+
public async findTeamsByUser(userId: string): Promise {
const teamRepo = await this.getTeamRepo();
const membershipRepo = await this.getMembershipRepo();
@@ -192,6 +197,21 @@ export class TeamDBImpl implements TeamDB {
await membershipRepo.save(membership);
}
+ public async setTeamMemberSubscription(userId: string, teamId: string, subscriptionId: string): Promise {
+ const teamRepo = await this.getTeamRepo();
+ const team = await teamRepo.findOne(teamId);
+ if (!team || !!team.deleted) {
+ throw new Error("A team with this ID could not be found");
+ }
+ const membershipRepo = await this.getMembershipRepo();
+ const membership = await membershipRepo.findOne({ teamId, userId, deleted: false });
+ if (!membership) {
+ throw new Error("The user is not currently a member of this team");
+ }
+ membership.subscriptionId = subscriptionId;
+ await membershipRepo.save(membership);
+ }
+
public async removeMemberFromTeam(userId: string, teamId: string): Promise {
const teamRepo = await this.getTeamRepo();
const team = await teamRepo.findOne(teamId);
diff --git a/components/gitpod-db/src/typeorm/team-subscription-2-db-impl.ts b/components/gitpod-db/src/typeorm/team-subscription-2-db-impl.ts
new file mode 100644
index 00000000000000..e8f01bc4c4d338
--- /dev/null
+++ b/components/gitpod-db/src/typeorm/team-subscription-2-db-impl.ts
@@ -0,0 +1,73 @@
+/**
+ * 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 { injectable, inject } from "inversify";
+import { EntityManager, Repository } from "typeorm";
+
+import { TeamSubscription2 } from "@gitpod/gitpod-protocol/lib/team-subscription-protocol";
+
+import { TeamSubscription2DB } from "../team-subscription-2-db";
+import { DBTeamSubscription2 } from "./entity/db-team-subscription-2";
+import { TypeORM } from "./typeorm";
+
+@injectable()
+export class TeamSubscription2DBImpl implements TeamSubscription2DB {
+ @inject(TypeORM) protected readonly typeORM: TypeORM;
+
+ async transaction(code: (db: TeamSubscription2DB) => Promise): Promise {
+ const manager = await this.getEntityManager();
+ return await manager.transaction(async (manager) => {
+ return await code(new TransactionalTeamSubscription2DBImpl(manager));
+ });
+ }
+
+ protected async getEntityManager() {
+ return (await this.typeORM.getConnection()).manager;
+ }
+
+ protected async getRepo(): Promise> {
+ return (await this.getEntityManager()).getRepository(DBTeamSubscription2);
+ }
+
+ /**
+ * Team Subscriptions 2
+ */
+
+ async storeEntry(ts: TeamSubscription2): Promise {
+ const repo = await this.getRepo();
+ await repo.save(ts);
+ }
+
+ async findById(id: string): Promise {
+ const repo = await this.getRepo();
+ return repo.findOne(id);
+ }
+
+ async findByPaymentRef(teamId: string, paymentReference: string): Promise {
+ const repo = await this.getRepo();
+ return repo.findOne({ teamId, paymentReference });
+ }
+
+ async findForTeam(teamId: string, date: string): Promise {
+ const repo = await this.getRepo();
+ const query = repo
+ .createQueryBuilder("ts2")
+ .where("ts2.teamId = :teamId", { teamId })
+ .andWhere("ts2.startDate <= :date", { date })
+ .andWhere('ts2.endDate = "" OR ts2.endDate > :date', { date });
+ return query.getOne();
+ }
+}
+
+export class TransactionalTeamSubscription2DBImpl extends TeamSubscription2DBImpl {
+ constructor(protected readonly manager: EntityManager) {
+ super();
+ }
+
+ async getEntityManager(): Promise {
+ return this.manager;
+ }
+}
diff --git a/components/gitpod-protocol/src/accounting-protocol.ts b/components/gitpod-protocol/src/accounting-protocol.ts
index 68f7e89cb9d600..e1e938b9af9eff 100644
--- a/components/gitpod-protocol/src/accounting-protocol.ts
+++ b/components/gitpod-protocol/src/accounting-protocol.ts
@@ -124,6 +124,7 @@ export interface Subscription {
paymentReference?: string;
paymentData?: PaymentData;
teamSubscriptionSlotId?: string;
+ teamMembershipId?: string;
/** marks the subscription as deleted */
deleted?: boolean;
}
@@ -158,6 +159,15 @@ export namespace AssignedTeamSubscription {
}
}
+export interface AssignedTeamSubscription2 extends Subscription {
+ teamMembershipId: string;
+}
+export namespace AssignedTeamSubscription2 {
+ export function is(data: any): data is AssignedTeamSubscription2 {
+ return typeof data === "object" && data.hasOwnProperty("teamMembershipId");
+ }
+}
+
export namespace Subscription {
export function create(newSubscription: Omit) {
const subscription = newSubscription as Subscription;
diff --git a/components/gitpod-protocol/src/team-subscription-protocol.ts b/components/gitpod-protocol/src/team-subscription-protocol.ts
index 8fd5c8ea4dc569..deafdf2b557e28 100644
--- a/components/gitpod-protocol/src/team-subscription-protocol.ts
+++ b/components/gitpod-protocol/src/team-subscription-protocol.ts
@@ -33,6 +33,29 @@ export namespace TeamSubscription {
};
}
+export interface TeamSubscription2 {
+ id: string;
+ teamId: string;
+ planId: string;
+ startDate: string;
+ endDate?: string;
+ quantity: number;
+ /** The Chargebee subscription id */
+ paymentReference: string;
+ cancellationDate?: string;
+}
+
+export namespace TeamSubscription2 {
+ export const create = (ts2: Omit): TeamSubscription2 => {
+ const withId = ts2 as TeamSubscription2;
+ withId.id = uuidv4();
+ return withId;
+ };
+ export const isActive = (ts2: TeamSubscription2, date: string): boolean => {
+ return ts2.startDate <= date && (ts2.endDate === undefined || date < ts2.endDate);
+ };
+}
+
/**
* A slot represents one unit of a TeamSubscription that gets assigned to one user at a time
*/