Skip to content

[cost center] put all costcenter access behind API #12776

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 1 commit into from
Sep 9, 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
5 changes: 0 additions & 5 deletions components/gitpod-db/src/container-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,6 @@ import { TypeORMBlockedRepositoryDBImpl } from "./typeorm/blocked-repository-db-
import { BlockedRepositoryDB } from "./blocked-repository-db";
import { WebhookEventDB } from "./webhook-event-db";
import { WebhookEventDBImpl } from "./typeorm/webhook-event-db-impl";
import { CostCenterDB } from "./cost-center-db";
import { CostCenterDBImpl } from "./typeorm/cost-center-db-impl";

// THE DB container module that contains all DB implementations
export const dbContainerModule = new ContainerModule((bind, unbind, isBound, rebind) => {
Expand Down Expand Up @@ -146,9 +144,6 @@ export const dbContainerModule = new ContainerModule((bind, unbind, isBound, reb
bind(WebhookEventDBImpl).toSelf().inSingletonScope();
bind(WebhookEventDB).toService(WebhookEventDBImpl);

bind(CostCenterDBImpl).toSelf().inSingletonScope();
bind(CostCenterDB).toService(CostCenterDBImpl);

// com concerns
bind(AccountingDB).to(TypeORMAccountingDBImpl).inSingletonScope();
bind(TransactionalAccountingDBFactory).toFactory((ctx) => {
Expand Down
13 changes: 0 additions & 13 deletions components/gitpod-db/src/cost-center-db.ts

This file was deleted.

1 change: 0 additions & 1 deletion components/gitpod-db/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,3 @@ export * from "./project-db";
export * from "./team-db";
export * from "./installation-admin-db";
export * from "./webhook-event-db";
export * from "./cost-center-db";
37 changes: 0 additions & 37 deletions components/gitpod-db/src/typeorm/cost-center-db-impl.ts

This file was deleted.

22 changes: 0 additions & 22 deletions components/gitpod-db/src/typeorm/entity/db-cost-center.ts

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/**
* 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 } from "./helper/helper";

const D_B_COST_CENTER = "d_b_cost_center";
const COL_CREATION_TIME = "creationTime";
const COL_BILLING_STRATEGY = "billingStrategy";

export class CostCenterPaymentStrategy1662639748206 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
if (!(await columnExists(queryRunner, D_B_COST_CENTER, COL_CREATION_TIME))) {
await queryRunner.query(
`ALTER TABLE ${D_B_COST_CENTER} ADD COLUMN ${COL_CREATION_TIME} varchar(30) NOT NULL, ALGORITHM=INPLACE, LOCK=NONE `,
Copy link
Member

Choose a reason for hiding this comment

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

they will be filled up with the implicit default value for the varchar type right? which is '', an empty string.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Oh, true. That was not my intend, but I think it will not break.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'm actually not sure what MYSQL will do with existing records here 😬 The migration worked well against an empty table.

Copy link
Contributor Author

@svenefftinge svenefftinge Sep 9, 2022

Choose a reason for hiding this comment

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

they will be filled up with the implicit default value for the varchar type right? which is '', an empty string.

Just verified that this is the case indeed

);
await queryRunner.query(
`ALTER TABLE ${D_B_COST_CENTER} ADD COLUMN ${COL_BILLING_STRATEGY} varchar(255) NOT NULL DEFAULT 'other', ALGORITHM=INPLACE, LOCK=NONE `,
);
await queryRunner.query(
`ALTER TABLE ${D_B_COST_CENTER} DROP PRIMARY KEY, ADD PRIMARY KEY(id, ${COL_CREATION_TIME}), ALGORITHM=INPLACE, LOCK=NONE `,
);
}
}

public async down(queryRunner: QueryRunner): Promise<void> {}
}
5 changes: 4 additions & 1 deletion components/gitpod-protocol/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { WorkspaceInstance, PortVisibility } from "./workspace-instance";
import { RoleOrPermission } from "./permission";
import { Project } from "./teams-projects-protocol";
import { createHash } from "crypto";
import { AttributionId } from "./attribution";

export interface UserInfo {
name?: string;
Expand Down Expand Up @@ -1503,10 +1504,12 @@ export interface Terms {
readonly formElements?: object;
}

export type BillingStrategy = "other" | "stripe";
export interface CostCenter {
readonly id: string;
readonly id: AttributionId;
/**
* Unit: credits
*/
spendingLimit: number;
billingStrategy: BillingStrategy;
}
6 changes: 2 additions & 4 deletions components/server/ee/src/billing/billing-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
* See License.enterprise.txt in the project root folder.
*/

import { CostCenterDB } from "@gitpod/gitpod-db/lib";
import { User } from "@gitpod/gitpod-protocol";
import { AttributionId } from "@gitpod/gitpod-protocol/lib/attribution";
import { log } from "@gitpod/gitpod-protocol/lib/util/logging";
Expand All @@ -25,19 +24,18 @@ export interface UsageLimitReachedResult {
@injectable()
export class BillingService {
@inject(UserService) protected readonly userService: UserService;
@inject(CostCenterDB) protected readonly costCenterDB: CostCenterDB;
@inject(CachingUsageServiceClientProvider)
protected readonly usageServiceClientProvider: CachingUsageServiceClientProvider;
@inject(CachingBillingServiceClientProvider)
protected readonly billingServiceClientProvider: CachingBillingServiceClientProvider;

async checkUsageLimitReached(user: User): Promise<UsageLimitReachedResult> {
const attributionId = await this.userService.getWorkspaceUsageAttributionId(user);
const costCenter = await this.costCenterDB.findById(AttributionId.render(attributionId));
const costCenter = await this.usageServiceClientProvider.getDefault().getCostCenter(attributionId);
if (!costCenter) {
const err = new Error("No CostCenter found");
log.error({ userId: user.id }, err.message, err, { attributionId });
// Technially we do not have any spending limit set, yet. But sending users down the "reached" path will fix this issues as well.
// Technically we do not have any spending limit set, yet. But sending users down the "reached" path will fix this issues as well.
return {
reached: true,
attributionId,
Expand Down
56 changes: 26 additions & 30 deletions components/server/ee/src/workspace/gitpod-server-impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ import { LicenseKeySource } from "@gitpod/licensor/lib";
import { Feature } from "@gitpod/licensor/lib/api";
import { LicenseValidationResult, LicenseFeature } from "@gitpod/gitpod-protocol/lib/license-protocol";
import { PrebuildManager } from "../prebuilds/prebuild-manager";
import { CostCenterDB, LicenseDB } from "@gitpod/gitpod-db/lib";
import { LicenseDB } from "@gitpod/gitpod-db/lib";
import { GuardedCostCenter, ResourceAccessGuard, ResourceAccessOp } from "../../../src/auth/resource-access";
import { AccountStatement, CreditAlert, Subscription } from "@gitpod/gitpod-protocol/lib/accounting-protocol";
import { BlockedRepository } from "@gitpod/gitpod-protocol/lib/blocked-repositories-protocol";
Expand Down Expand Up @@ -156,7 +156,6 @@ export class GitpodServerEEImpl extends GitpodServerImpl {
@inject(CachingUsageServiceClientProvider)
protected readonly usageServiceClientProvider: CachingUsageServiceClientProvider;

@inject(CostCenterDB) protected readonly costCenterDB: CostCenterDB;
@inject(EntitlementService) protected readonly entitlementService: EntitlementService;

@inject(BillingModes) protected readonly billingModes: BillingModes;
Expand Down Expand Up @@ -2117,12 +2116,13 @@ export class GitpodServerEEImpl extends GitpodServerImpl {
await this.stripeService.setDefaultPaymentMethodForCustomer(customer, setupIntentId);
await this.stripeService.createSubscriptionForCustomer(customer);

const attributionId = AttributionId.render({ kind: "team", teamId });
const attributionId: AttributionId = { kind: "team", teamId };

// Creating a cost center for this team
await this.costCenterDB.storeEntry({
await this.usageServiceClientProvider.getDefault().setCostCenter({
id: attributionId,
spendingLimit: this.defaultSpendingLimit,
billingStrategy: "stripe",
});
} catch (error) {
log.error(`Failed to subscribe team '${teamId}' to Stripe`, error);
Expand Down Expand Up @@ -2151,10 +2151,10 @@ export class GitpodServerEEImpl extends GitpodServerImpl {
const team = await this.guardTeamOperation(teamId, "get");
await this.ensureStripeApiIsAllowed({ team });

const attributionId = AttributionId.render({ kind: "team", teamId });
const attributionId: AttributionId = { kind: "team", teamId };
await this.guardCostCenterAccess(ctx, user.id, attributionId, "get");

const costCenter = await this.costCenterDB.findById(attributionId);
const costCenter = await this.usageServiceClientProvider.getDefault().getCostCenter(attributionId);
if (costCenter) {
return costCenter.spendingLimit;
}
Expand All @@ -2168,12 +2168,14 @@ export class GitpodServerEEImpl extends GitpodServerImpl {
if (typeof usageLimit !== "number" || usageLimit < 0) {
throw new ResponseError(ErrorCodes.BAD_REQUEST, "Unexpected `usageLimit` value.");
}
const attributionId = AttributionId.render({ kind: "team", teamId });
const attributionId: AttributionId = { kind: "team", teamId };
await this.guardCostCenterAccess(ctx, user.id, attributionId, "update");

await this.costCenterDB.storeEntry({
id: AttributionId.render({ kind: "team", teamId }),
const costCenter = await this.usageServiceClientProvider.getDefault().getCostCenter(attributionId);
await this.usageServiceClientProvider.getDefault().setCostCenter({
id: attributionId,
spendingLimit: usageLimit,
billingStrategy: costCenter?.billingStrategy || "other",
});
}

Expand All @@ -2184,31 +2186,33 @@ export class GitpodServerEEImpl extends GitpodServerImpl {
const billingMode = await this.billingModes.getBillingModeForUser(user, new Date());
if (billingMode.mode === "usage-based") {
const limit = await this.billingService.checkUsageLimitReached(user);
const costCenter = await this.costCenterDB.findById(AttributionId.render(limit.attributionId));
if (costCenter) {
if (limit.reached) {
result.unshift("The usage limit is reached.");
} else if (limit.almostReached) {
result.unshift("The usage limit is almost reached.");
}
if (limit.reached) {
result.unshift("The usage limit is reached.");
} else if (limit.almostReached) {
result.unshift("The usage limit is almost reached.");
}
}
return result;
}

async listUsage(ctx: TraceContext, req: ListUsageRequest): Promise<ListUsageResponse> {
const { attributionId, from, to } = req;
const { from, to } = req;
const attributionId = AttributionId.parse(req.attributionId);
if (!attributionId) {
throw new ResponseError(ErrorCodes.INVALID_COST_CENTER, "Bad attribution ID", {
attributionId: req.attributionId,
});
}
traceAPIParams(ctx, { attributionId });
const user = this.checkAndBlockUser("listUsage");

await this.guardCostCenterAccess(ctx, user.id, attributionId, "get");

const timestampFrom = from ? Timestamp.fromDate(new Date(from)) : undefined;
const timestampTo = to ? Timestamp.fromDate(new Date(to)) : undefined;

const usageClient = this.usageServiceClientProvider.getDefault();
const request = new usage_grpc.ListUsageRequest();
request.setAttributionId(attributionId);
request.setAttributionId(AttributionId.render(attributionId));
request.setFrom(timestampFrom);
if (to) {
request.setTo(timestampTo);
Expand Down Expand Up @@ -2253,23 +2257,15 @@ export class GitpodServerEEImpl extends GitpodServerImpl {
protected async guardCostCenterAccess(
ctx: TraceContext,
userId: string,
attributionId: string,
attributionId: AttributionId,
operation: ResourceAccessOp,
): Promise<void> {
traceAPIParams(ctx, { userId, attributionId });

// TODO(gpl) We need a CostCenter entity (with a strong connection to Team or User) to properly to authorize access to these reports
// const costCenter = await this.costCenterDB.findByAttributionId(attributionId);
const parsedId = AttributionId.parse(attributionId);
if (parsedId === undefined) {
log.warn({ userId }, "Unable to parse attributionId", { attributionId });
throw new ResponseError(ErrorCodes.BAD_REQUEST, "Unable to parse attributionId");
}

let owner: GuardedCostCenter["owner"];
switch (parsedId.kind) {
switch (attributionId.kind) {
case "team":
const team = await this.teamDB.findTeamById(parsedId.teamId);
const team = await this.teamDB.findTeamById(attributionId.teamId);
if (!team) {
throw new ResponseError(ErrorCodes.NOT_FOUND, "Team not found");
}
Expand Down
3 changes: 1 addition & 2 deletions components/server/src/user/user-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {
WORKSPACE_TIMEOUT_EXTENDED_ALT,
Team,
} from "@gitpod/gitpod-protocol";
import { CostCenterDB, ProjectDB, TeamDB, TermsAcceptanceDB, UserDB } from "@gitpod/gitpod-db/lib";
import { ProjectDB, TeamDB, TermsAcceptanceDB, UserDB } from "@gitpod/gitpod-db/lib";
import { HostContextProvider } from "../auth/host-context-provider";
import { log } from "@gitpod/gitpod-protocol/lib/util/logging";
import { Config } from "../config";
Expand Down Expand Up @@ -70,7 +70,6 @@ export class UserService {
@inject(TermsAcceptanceDB) protected readonly termsAcceptanceDb: TermsAcceptanceDB;
@inject(TermsProvider) protected readonly termsProvider: TermsProvider;
@inject(ProjectDB) protected readonly projectDb: ProjectDB;
@inject(CostCenterDB) protected readonly costCenterDb: CostCenterDB;
@inject(TeamDB) protected readonly teamDB: TeamDB;
@inject(StripeService) protected readonly stripeService: StripeService;
@inject(VerificationService) protected readonly verificationService: VerificationService;
Expand Down
Loading