Skip to content

Add Spending Limit to Billing page #11508

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
Jul 21, 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
131 changes: 107 additions & 24 deletions components/dashboard/src/teams/TeamUsageBasedBilling.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ export default function TeamUsageBasedBilling() {
const [pendingStripeSubscription, setPendingStripeSubscription] = useState<PendingStripeSubscription | undefined>();
const [pollStripeSubscriptionTimeout, setPollStripeSubscriptionTimeout] = useState<NodeJS.Timeout | undefined>();
const [stripePortalUrl, setStripePortalUrl] = useState<string | undefined>();
const [showUpdateLimitModal, setShowUpdateLimitModal] = useState<boolean>(false);
const [spendingLimit, setSpendingLimit] = useState<number | undefined>();

useEffect(() => {
if (!team) {
Expand All @@ -54,6 +56,8 @@ export default function TeamUsageBasedBilling() {
(async () => {
const portalUrl = await getGitpodService().server.getStripePortalUrlForTeam(team.id);
setStripePortalUrl(portalUrl);
const spendingLimit = await getGitpodService().server.getSpendingLimitForTeam(team.id);
setSpendingLimit(spendingLimit);
})();
}, [team, stripeSubscriptionId]);

Expand Down Expand Up @@ -135,30 +139,50 @@ export default function TeamUsageBasedBilling() {
return <></>;
}

const showSpinner = isLoading || pendingStripeSubscription;
const showUpgradeBilling = !showSpinner && !stripeSubscriptionId;
const showManageBilling = !showSpinner && !!stripeSubscriptionId;

const doUpdateLimit = async (newLimit: number) => {
if (!team) {
return;
}
const oldLimit = spendingLimit;
setSpendingLimit(newLimit);
try {
await getGitpodService().server.setSpendingLimitForTeam(team.id, newLimit);
} catch (error) {
setSpendingLimit(oldLimit);
console.error(error);
alert(error?.message || "Failed to update spending limit. See console for error message.");
}
setShowUpdateLimitModal(false);
};

return (
<div className="mb-16">
<h3>Usage-Based Billing</h3>
<h2 className="text-gray-500">Manage usage-based billing, spending limit, and payment method.</h2>
<div className="max-w-xl">
<div className="mt-4 h-32 p-4 flex flex-col rounded-xl bg-gray-100 dark:bg-gray-800">
<div className="uppercase text-sm text-gray-400 dark:text-gray-500">Billing</div>
{(isLoading || pendingStripeSubscription) && (
<>
<Spinner className="m-2 h-5 w-5 animate-spin" />
</>
)}
{!isLoading && !pendingStripeSubscription && !stripeSubscriptionId && (
<>
<div className="text-xl font-semibold flex-grow text-gray-600 dark:text-gray-400">
Inactive
</div>
<button className="self-end" onClick={() => setShowBillingSetupModal(true)}>
Upgrade Billing
</button>
</>
)}
{!isLoading && !pendingStripeSubscription && !!stripeSubscriptionId && (
<>
<div className="max-w-xl flex flex-col">
{showSpinner && (
<div className="flex flex-col mt-4 h-32 p-4 rounded-xl bg-gray-100 dark:bg-gray-800">
<div className="uppercase text-sm text-gray-400 dark:text-gray-500">Billing</div>
<Spinner className="m-2 h-5 w-5 animate-spin" />
</div>
)}
{showUpgradeBilling && (
<div className="flex flex-col mt-4 h-32 p-4 rounded-xl bg-gray-100 dark:bg-gray-800">
<div className="uppercase text-sm text-gray-400 dark:text-gray-500">Billing</div>
<div className="text-xl font-semibold flex-grow text-gray-600 dark:text-gray-400">Inactive</div>
<button className="self-end" onClick={() => setShowBillingSetupModal(true)}>
Upgrade Billing
</button>
</div>
)}
{showManageBilling && (
<div className="max-w-xl flex space-x-4">
<div className="flex flex-col w-72 mt-4 h-32 p-4 rounded-xl bg-gray-100 dark:bg-gray-800">
<div className="uppercase text-sm text-gray-400 dark:text-gray-500">Billing</div>
<div className="text-xl font-semibold flex-grow text-gray-600 dark:text-gray-400">
Active
</div>
Expand All @@ -167,11 +191,27 @@ export default function TeamUsageBasedBilling() {
Manage Billing →
</button>
</a>
</>
)}
</div>
</div>
<div className="flex flex-col w-72 mt-4 h-32 p-4 rounded-xl bg-gray-100 dark:bg-gray-800">
<div className="uppercase text-sm text-gray-400 dark:text-gray-500">Spending Limit</div>
<div className="text-xl font-semibold flex-grow text-gray-600 dark:text-gray-400">
{spendingLimit || "–"}
</div>
<button className="self-end" onClick={() => setShowUpdateLimitModal(true)}>
Update Limit
</button>
</div>
</div>
)}
</div>
{showBillingSetupModal && <BillingSetupModal onClose={() => setShowBillingSetupModal(false)} />}
{showUpdateLimitModal && (
<UpdateLimitModal
currentValue={spendingLimit}
onClose={() => setShowUpdateLimitModal(false)}
onUpdate={(newLimit) => doUpdateLimit(newLimit)}
/>
)}
</div>
);
}
Expand All @@ -182,6 +222,49 @@ function getStripeAppearance(isDark?: boolean): Appearance {
};
}

function UpdateLimitModal(props: {
currentValue: number | undefined;
onClose: () => void;
onUpdate: (newLimit: number) => {};
}) {
const [newLimit, setNewLimit] = useState<number | undefined>(props.currentValue);

return (
<Modal visible={true} onClose={props.onClose}>
<h3 className="flex">Update Limit</h3>
<div className="border-t border-b border-gray-200 dark:border-gray-800 -mx-6 px-6 py-4 flex flex-col">
<p className="pb-4 text-gray-500 text-base">Set up a spending limit on a monthly basis.</p>

<label htmlFor="newLimit" className="font-medium">
Limit
</label>
<div className="w-full">
<input
name="newLimit"
type="number"
min={0}
value={newLimit}
className="rounded-md w-full truncate overflow-x-scroll pr-8"
onChange={(e) => setNewLimit(parseInt(e.target.value || "1", 10))}
/>
</div>
</div>
<div className="flex justify-end mt-6 space-x-2">
<button
className="secondary"
onClick={() => {
if (typeof newLimit === "number") {
props.onUpdate(newLimit);
}
}}
>
Update
</button>
</div>
</Modal>
);
}

function BillingSetupModal(props: { onClose: () => void }) {
const { isDark } = useContext(ThemeContext);
const [stripePromise, setStripePromise] = useState<Promise<Stripe | null> | undefined>();
Expand Down Expand Up @@ -243,7 +326,7 @@ function CreditCardInputForm() {
}
} catch (error) {
console.error(error);
alert(error);
alert(error?.message || "Failed to submit form. See console for error message.");
} finally {
setIsLoading(false);
}
Expand Down
2 changes: 2 additions & 0 deletions components/gitpod-protocol/src/gitpod-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,8 @@ export interface GitpodServer extends JsonRpcServer<GitpodClient>, AdminServer,
findStripeSubscriptionIdForTeam(teamId: string): Promise<string | undefined>;
subscribeTeamToStripe(teamId: string, setupIntentId: string, currency: Currency): Promise<void>;
getStripePortalUrlForTeam(teamId: string): Promise<string>;
getSpendingLimitForTeam(teamId: string): Promise<number | undefined>;
setSpendingLimitForTeam(teamId: string, spendingLimit: number): Promise<void>;

listBilledUsage(attributionId: string): Promise<BillableSession[]>;
setUsageAttribution(usageAttribution: string): Promise<void>;
Expand Down
48 changes: 46 additions & 2 deletions components/server/ee/src/workspace/gitpod-server-impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,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 { LicenseDB } from "@gitpod/gitpod-db/lib";
import { CostCenterDB, 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 @@ -152,6 +152,8 @@ export class GitpodServerEEImpl extends GitpodServerImpl {
@inject(CachingUsageServiceClientProvider)
protected readonly usageServiceClientProvider: CachingUsageServiceClientProvider;

@inject(CostCenterDB) protected readonly costCenterDB: CostCenterDB;

initialize(
client: GitpodClient | undefined,
user: User | undefined,
Expand Down Expand Up @@ -2001,6 +2003,7 @@ export class GitpodServerEEImpl extends GitpodServerImpl {
}
}

protected defaultSpendingLimit = 100;
async subscribeTeamToStripe(
ctx: TraceContext,
teamId: string,
Expand All @@ -2017,6 +2020,15 @@ export class GitpodServerEEImpl extends GitpodServerImpl {
customer = await this.stripeService.createCustomerForTeam(user, team!, setupIntentId);
}
await this.stripeService.createSubscriptionForCustomer(customer.id, currency);

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

// Creating a cost center for this team
await this.costCenterDB.storeEntry({
id: attributionId,
spendingLimit: this.defaultSpendingLimit,
});

// For all team members that didn't explicitly choose yet where their usage should be attributed to,
// we simplify the UX by automatically attributing their usage to this recently-upgraded team.
// Note: This default choice can be changed at any time by members in their personal billing settings.
Expand All @@ -2025,7 +2037,8 @@ export class GitpodServerEEImpl extends GitpodServerImpl {
members.map(async (m) => {
const u = await this.userDB.findUserById(m.userId);
if (u && !u.usageAttributionId) {
await this.userService.setUsageAttribution(u, `team:${teamId}`);
u.usageAttributionId = attributionId;
await this.userDB.storeUser(u);
}
}),
);
Expand All @@ -2052,6 +2065,37 @@ export class GitpodServerEEImpl extends GitpodServerImpl {
}
}

async getSpendingLimitForTeam(ctx: TraceContext, teamId: string): Promise<number | undefined> {
const user = this.checkAndBlockUser("getSpendingLimitForTeam");
await this.ensureIsUsageBasedFeatureFlagEnabled(user);
await this.guardTeamOperation(teamId, "get");

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

const costCenter = await this.costCenterDB.findById(attributionId);
if (costCenter) {
return costCenter.spendingLimit;
}
return undefined;
}

async setSpendingLimitForTeam(ctx: TraceContext, teamId: string, spendingLimit: number): Promise<void> {
const user = this.checkAndBlockUser("setSpendingLimitForTeam");
await this.ensureIsUsageBasedFeatureFlagEnabled(user);
await this.guardTeamOperation(teamId, "update");
if (typeof spendingLimit !== "number" || spendingLimit < 0) {
throw new ResponseError(ErrorCodes.BAD_REQUEST, "Unexpected `spendingLimit` value.");
}
const attributionId = AttributionId.render({ kind: "team", teamId });
await this.guardCostCenterAccess(ctx, user.id, attributionId, "update");

await this.costCenterDB.storeEntry({
id: AttributionId.render({ kind: "team", teamId }),
spendingLimit,
});
}

async listBilledUsage(ctx: TraceContext, attributionId: string): Promise<BillableSession[]> {
traceAPIParams(ctx, { attributionId });
const user = this.checkAndBlockUser("listBilledUsage");
Expand Down
2 changes: 2 additions & 0 deletions components/server/src/auth/rate-limiter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,8 @@ function getConfig(config: RateLimiterConfig): RateLimiterConfig {
getIDEOptions: { group: "default", points: 1 },
getPrebuildEvents: { group: "default", points: 1 },
setUsageAttribution: { group: "default", points: 1 },
getSpendingLimitForTeam: { group: "default", points: 1 },
setSpendingLimitForTeam: { group: "default", points: 1 },
};

return {
Expand Down
6 changes: 6 additions & 0 deletions components/server/src/workspace/gitpod-server-impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3194,6 +3194,12 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable {
async listBilledUsage(ctx: TraceContext, attributionId: string): Promise<BillableSession[]> {
throw new ResponseError(ErrorCodes.SAAS_FEATURE, `Not implemented in this version`);
}
async getSpendingLimitForTeam(ctx: TraceContext, teamId: string): Promise<number | undefined> {
throw new ResponseError(ErrorCodes.SAAS_FEATURE, `Not implemented in this version`);
}
async setSpendingLimitForTeam(ctx: TraceContext, teamId: string, spendingLimit: number): Promise<void> {
throw new ResponseError(ErrorCodes.SAAS_FEATURE, `Not implemented in this version`);
}

async setUsageAttribution(ctx: TraceContext, usageAttributionId: string): Promise<void> {
const user = this.checkAndBlockUser("setUsageAttribution");
Expand Down