Skip to content

Commit

Permalink
Add Spending Limit to Billing page
Browse files Browse the repository at this point in the history
  • Loading branch information
AlexTugarev committed Jul 21, 2022
1 parent 472f0b7 commit af66aba
Show file tree
Hide file tree
Showing 5 changed files with 163 additions and 26 deletions.
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

0 comments on commit af66aba

Please sign in to comment.