Skip to content

Commit 2cc401d

Browse files
committed
Add Spending Limit to Billing page
1 parent 4df9c3e commit 2cc401d

File tree

5 files changed

+156
-26
lines changed

5 files changed

+156
-26
lines changed

components/dashboard/src/teams/TeamUsageBasedBilling.tsx

Lines changed: 107 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ export default function TeamUsageBasedBilling() {
2828
const [pendingStripeSubscription, setPendingStripeSubscription] = useState<PendingStripeSubscription | undefined>();
2929
const [pollStripeSubscriptionTimeout, setPollStripeSubscriptionTimeout] = useState<NodeJS.Timeout | undefined>();
3030
const [stripePortalUrl, setStripePortalUrl] = useState<string | undefined>();
31+
const [showUpdateLimitModal, setShowUpdateLimitModal] = useState<boolean>(false);
32+
const [spendingLimit, setSpendingLimit] = useState<number | undefined>();
3133

3234
useEffect(() => {
3335
if (!team) {
@@ -54,6 +56,8 @@ export default function TeamUsageBasedBilling() {
5456
(async () => {
5557
const portalUrl = await getGitpodService().server.getStripePortalUrlForTeam(team.id);
5658
setStripePortalUrl(portalUrl);
59+
const spendingLimit = await getGitpodService().server.getSpendingLimitForTeam(team.id);
60+
setSpendingLimit(spendingLimit);
5761
})();
5862
}, [team, stripeSubscriptionId]);
5963

@@ -135,30 +139,50 @@ export default function TeamUsageBasedBilling() {
135139
return <></>;
136140
}
137141

142+
const showSpinner = isLoading || pendingStripeSubscription;
143+
const showUpgradeBilling = !showSpinner && !stripeSubscriptionId;
144+
const showManageBilling = !showSpinner && !!stripeSubscriptionId;
145+
146+
const doUpdateLimit = async (newLimit: number) => {
147+
if (!team) {
148+
return;
149+
}
150+
const oldLimit = spendingLimit;
151+
setSpendingLimit(newLimit);
152+
try {
153+
await getGitpodService().server.setSpendingLimitForTeam(team.id, newLimit);
154+
} catch (error) {
155+
setSpendingLimit(oldLimit);
156+
console.error(error);
157+
alert(error?.message || "Failed to update spending limit. See console for error message.");
158+
}
159+
setShowUpdateLimitModal(false);
160+
};
161+
138162
return (
139163
<div className="mb-16">
140164
<h3>Usage-Based Billing</h3>
141165
<h2 className="text-gray-500">Manage usage-based billing, spending limit, and payment method.</h2>
142-
<div className="max-w-xl">
143-
<div className="mt-4 h-32 p-4 flex flex-col rounded-xl bg-gray-100 dark:bg-gray-800">
144-
<div className="uppercase text-sm text-gray-400 dark:text-gray-500">Billing</div>
145-
{(isLoading || pendingStripeSubscription) && (
146-
<>
147-
<Spinner className="m-2 h-5 w-5 animate-spin" />
148-
</>
149-
)}
150-
{!isLoading && !pendingStripeSubscription && !stripeSubscriptionId && (
151-
<>
152-
<div className="text-xl font-semibold flex-grow text-gray-600 dark:text-gray-400">
153-
Inactive
154-
</div>
155-
<button className="self-end" onClick={() => setShowBillingSetupModal(true)}>
156-
Upgrade Billing
157-
</button>
158-
</>
159-
)}
160-
{!isLoading && !pendingStripeSubscription && !!stripeSubscriptionId && (
161-
<>
166+
<div className="max-w-xl flex flex-col">
167+
{showSpinner && (
168+
<div className="flex flex-col mt-4 h-32 p-4 rounded-xl bg-gray-100 dark:bg-gray-800">
169+
<div className="uppercase text-sm text-gray-400 dark:text-gray-500">Billing</div>
170+
<Spinner className="m-2 h-5 w-5 animate-spin" />
171+
</div>
172+
)}
173+
{showUpgradeBilling && (
174+
<div className="flex flex-col mt-4 h-32 p-4 rounded-xl bg-gray-100 dark:bg-gray-800">
175+
<div className="uppercase text-sm text-gray-400 dark:text-gray-500">Billing</div>
176+
<div className="text-xl font-semibold flex-grow text-gray-600 dark:text-gray-400">Inactive</div>
177+
<button className="self-end" onClick={() => setShowBillingSetupModal(true)}>
178+
Upgrade Billing
179+
</button>
180+
</div>
181+
)}
182+
{showManageBilling && (
183+
<div className="max-w-xl flex space-x-4">
184+
<div className="flex flex-col w-72 mt-4 h-32 p-4 rounded-xl bg-gray-100 dark:bg-gray-800">
185+
<div className="uppercase text-sm text-gray-400 dark:text-gray-500">Billing</div>
162186
<div className="text-xl font-semibold flex-grow text-gray-600 dark:text-gray-400">
163187
Active
164188
</div>
@@ -167,11 +191,27 @@ export default function TeamUsageBasedBilling() {
167191
Manage Billing →
168192
</button>
169193
</a>
170-
</>
171-
)}
172-
</div>
194+
</div>
195+
<div className="flex flex-col w-72 mt-4 h-32 p-4 rounded-xl bg-gray-100 dark:bg-gray-800">
196+
<div className="uppercase text-sm text-gray-400 dark:text-gray-500">Spending Limit</div>
197+
<div className="text-xl font-semibold flex-grow text-gray-600 dark:text-gray-400">
198+
{spendingLimit || "–"}
199+
</div>
200+
<button className="self-end" onClick={() => setShowUpdateLimitModal(true)}>
201+
Update Limit
202+
</button>
203+
</div>
204+
</div>
205+
)}
173206
</div>
174207
{showBillingSetupModal && <BillingSetupModal onClose={() => setShowBillingSetupModal(false)} />}
208+
{showUpdateLimitModal && (
209+
<UpdateLimitModal
210+
currentValue={spendingLimit}
211+
onClose={() => setShowUpdateLimitModal(false)}
212+
onUpdate={(newLimit) => doUpdateLimit(newLimit)}
213+
/>
214+
)}
175215
</div>
176216
);
177217
}
@@ -182,6 +222,49 @@ function getStripeAppearance(isDark?: boolean): Appearance {
182222
};
183223
}
184224

225+
function UpdateLimitModal(props: {
226+
currentValue: number | undefined;
227+
onClose: () => void;
228+
onUpdate: (newLimit: number) => {};
229+
}) {
230+
const [newLimit, setNewLimit] = useState<number | undefined>(props.currentValue);
231+
232+
return (
233+
<Modal visible={true} onClose={props.onClose}>
234+
<h3 className="flex">Update Limit</h3>
235+
<div className="border-t border-b border-gray-200 dark:border-gray-800 -mx-6 px-6 py-4 flex flex-col">
236+
<p className="pb-4 text-gray-500 text-base">Set up a spending limit on a monthly basis.</p>
237+
238+
<label htmlFor="newLimit" className="font-medium">
239+
Limit
240+
</label>
241+
<div className="w-full">
242+
<input
243+
name="newLimit"
244+
type="number"
245+
min={0}
246+
value={newLimit}
247+
className="rounded-md w-full truncate overflow-x-scroll pr-8"
248+
onChange={(e) => setNewLimit(parseInt(e.target.value || "1", 10))}
249+
/>
250+
</div>
251+
</div>
252+
<div className="flex justify-end mt-6 space-x-2">
253+
<button
254+
className="secondary"
255+
onClick={() => {
256+
if (typeof newLimit === "number") {
257+
props.onUpdate(newLimit);
258+
}
259+
}}
260+
>
261+
Update
262+
</button>
263+
</div>
264+
</Modal>
265+
);
266+
}
267+
185268
function BillingSetupModal(props: { onClose: () => void }) {
186269
const { isDark } = useContext(ThemeContext);
187270
const [stripePromise, setStripePromise] = useState<Promise<Stripe | null> | undefined>();
@@ -243,7 +326,7 @@ function CreditCardInputForm() {
243326
}
244327
} catch (error) {
245328
console.error(error);
246-
alert(error);
329+
alert(error?.message || "Failed to submit form. See console for error message.");
247330
} finally {
248331
setIsLoading(false);
249332
}

components/gitpod-protocol/src/gitpod-service.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,8 @@ export interface GitpodServer extends JsonRpcServer<GitpodClient>, AdminServer,
290290
findStripeSubscriptionIdForTeam(teamId: string): Promise<string | undefined>;
291291
subscribeTeamToStripe(teamId: string, setupIntentId: string, currency: Currency): Promise<void>;
292292
getStripePortalUrlForTeam(teamId: string): Promise<string>;
293+
getSpendingLimitForTeam(teamId: string): Promise<number | undefined>;
294+
setSpendingLimitForTeam(teamId: string, spendingLimit: number): Promise<void>;
293295

294296
listBilledUsage(attributionId: string): Promise<BillableSession[]>;
295297

components/server/ee/src/workspace/gitpod-server-impl.ts

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ import { LicenseKeySource } from "@gitpod/licensor/lib";
6565
import { Feature } from "@gitpod/licensor/lib/api";
6666
import { LicenseValidationResult, LicenseFeature } from "@gitpod/gitpod-protocol/lib/license-protocol";
6767
import { PrebuildManager } from "../prebuilds/prebuild-manager";
68-
import { LicenseDB } from "@gitpod/gitpod-db/lib";
68+
import { CostCenterDB, LicenseDB } from "@gitpod/gitpod-db/lib";
6969
import { GuardedCostCenter, ResourceAccessGuard, ResourceAccessOp } from "../../../src/auth/resource-access";
7070
import { AccountStatement, CreditAlert, Subscription } from "@gitpod/gitpod-protocol/lib/accounting-protocol";
7171
import { BlockedRepository } from "@gitpod/gitpod-protocol/lib/blocked-repositories-protocol";
@@ -152,6 +152,8 @@ export class GitpodServerEEImpl extends GitpodServerImpl {
152152
@inject(CachingUsageServiceClientProvider)
153153
protected readonly usageServiceClientProvider: CachingUsageServiceClientProvider;
154154

155+
@inject(CostCenterDB) protected readonly costCenterDB: CostCenterDB;
156+
155157
initialize(
156158
client: GitpodClient | undefined,
157159
user: User | undefined,
@@ -2004,6 +2006,7 @@ export class GitpodServerEEImpl extends GitpodServerImpl {
20042006
}
20052007
}
20062008

2009+
protected defaultSpendingLimit = 100;
20072010
async subscribeTeamToStripe(
20082011
ctx: TraceContext,
20092012
teamId: string,
@@ -2020,6 +2023,15 @@ export class GitpodServerEEImpl extends GitpodServerImpl {
20202023
customer = await this.stripeService.createCustomerForTeam(user, team!, setupIntentId);
20212024
}
20222025
await this.stripeService.createSubscriptionForCustomer(customer.id, currency);
2026+
2027+
const attributionId = AttributionId.render({ kind: "team", teamId });
2028+
2029+
// Creating a cost center for this team
2030+
await this.costCenterDB.storeEntry({
2031+
id: attributionId,
2032+
spendingLimit: this.defaultSpendingLimit,
2033+
});
2034+
20232035
// For all team members that didn't explicitly choose yet where their usage should be attributed to,
20242036
// we simplify the UX by automatically attributing their usage to this recently-upgraded team.
20252037
// Note: This default choice can be changed at any time by members in their personal billing settings.
@@ -2029,7 +2041,7 @@ export class GitpodServerEEImpl extends GitpodServerImpl {
20292041
const u = await this.userDB.findUserById(m.userId);
20302042
if (u && !u.additionalData?.usageAttributionId) {
20312043
u.additionalData = u.additionalData || {};
2032-
u.additionalData.usageAttributionId = `team:${teamId}`;
2044+
u.additionalData.usageAttributionId = attributionId;
20332045
await this.userDB.updateUserPartial(u);
20342046
}
20352047
}),
@@ -2057,6 +2069,31 @@ export class GitpodServerEEImpl extends GitpodServerImpl {
20572069
}
20582070
}
20592071

2072+
async getSpendingLimitForTeam(ctx: TraceContext, teamId: string): Promise<number | undefined> {
2073+
const user = this.checkAndBlockUser("getSpendingLimitForTeam");
2074+
await this.ensureIsUsageBasedFeatureFlagEnabled(user);
2075+
await this.guardTeamOperation(teamId, "get");
2076+
const attributionId = AttributionId.render({ kind: "team", teamId });
2077+
const costCenter = await this.costCenterDB.findById(attributionId);
2078+
if (costCenter) {
2079+
return costCenter.spendingLimit;
2080+
}
2081+
return undefined;
2082+
}
2083+
2084+
async setSpendingLimitForTeam(ctx: TraceContext, teamId: string, spendingLimit: number): Promise<void> {
2085+
const user = this.checkAndBlockUser("setSpendingLimitForTeam");
2086+
await this.ensureIsUsageBasedFeatureFlagEnabled(user);
2087+
await this.guardTeamOperation(teamId, "update");
2088+
if (typeof spendingLimit !== "number" || spendingLimit < 0) {
2089+
throw new ResponseError(ErrorCodes.BAD_REQUEST, "Unexpected `spendingLimit` value.");
2090+
}
2091+
await this.costCenterDB.storeEntry({
2092+
id: AttributionId.render({ kind: "team", teamId }),
2093+
spendingLimit,
2094+
});
2095+
}
2096+
20602097
async listBilledUsage(ctx: TraceContext, attributionId: string): Promise<BillableSession[]> {
20612098
traceAPIParams(ctx, { attributionId });
20622099
const user = this.checkAndBlockUser("listBilledUsage");

components/server/src/auth/rate-limiter.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,8 @@ function getConfig(config: RateLimiterConfig): RateLimiterConfig {
216216
identifyUser: { group: "default", points: 1 },
217217
getIDEOptions: { group: "default", points: 1 },
218218
getPrebuildEvents: { group: "default", points: 1 },
219+
getSpendingLimitForTeam: { group: "default", points: 1 },
220+
setSpendingLimitForTeam: { group: "default", points: 1 },
219221
};
220222

221223
return {

components/server/src/workspace/gitpod-server-impl.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3194,6 +3194,12 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable {
31943194
async listBilledUsage(ctx: TraceContext, attributionId: string): Promise<BillableSession[]> {
31953195
throw new ResponseError(ErrorCodes.SAAS_FEATURE, `Not implemented in this version`);
31963196
}
3197+
async getSpendingLimitForTeam(ctx: TraceContext, teamId: string): Promise<number | undefined> {
3198+
throw new ResponseError(ErrorCodes.SAAS_FEATURE, `Not implemented in this version`);
3199+
}
3200+
async setSpendingLimitForTeam(ctx: TraceContext, teamId: string, spendingLimit: number): Promise<void> {
3201+
throw new ResponseError(ErrorCodes.SAAS_FEATURE, `Not implemented in this version`);
3202+
}
31973203

31983204
//
31993205
//#endregion

0 commit comments

Comments
 (0)