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 */