Skip to content

Stop sign on "out of credits" #11576

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
Aug 5, 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
46 changes: 46 additions & 0 deletions components/dashboard/src/start/CreateWorkspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
RunningWorkspacePrebuildStarting,
ContextURL,
DisposableCollection,
Team,
} from "@gitpod/gitpod-protocol";
import { ErrorCodes } from "@gitpod/gitpod-protocol/lib/messaging/error";
import Modal from "../components/Modal";
Expand All @@ -26,6 +27,8 @@ import CodeText from "../components/CodeText";
import FeedbackComponent from "../feedback-form/FeedbackComponent";
import { isGitpodIo } from "../utils";
import { BillingAccountSelector } from "../components/BillingAccountSelector";
import { AttributionId } from "@gitpod/gitpod-protocol/lib/attribution";
import { TeamsContext } from "../teams/teams-context";

export interface CreateWorkspaceProps {
contextUrl: string;
Expand Down Expand Up @@ -199,6 +202,11 @@ export default class CreateWorkspace extends React.Component<CreateWorkspaceProp
/>
);
break;
case ErrorCodes.PAYMENT_SPENDING_LIMIT_REACHED:
error = undefined; // to hide the error (otherwise rendered behind the modal)
phase = StartPhase.Stopped;
statusMessage = <SpendingLimitReachedModal hints={this.state?.error?.data} />;
break;
default:
statusMessage = (
<p className="text-base text-gitpod-red w-96">
Expand Down Expand Up @@ -358,6 +366,44 @@ function LimitReachedOutOfHours() {
</LimitReachedModal>
);
}
function SpendingLimitReachedModal(p: { hints: any }) {
const { teams } = useContext(TeamsContext);
// const [attributionId, setAttributionId] = useState<AttributionId | undefined>();
const [attributedTeam, setAttributedTeam] = useState<Team | undefined>();

useEffect(() => {
const attributionId: AttributionId | undefined =
p.hints && p.hints.attributionId && AttributionId.parse(p.hints.attributionId);
if (attributionId) {
// setAttributionId(attributionId);
if (attributionId.kind === "team") {
const team = teams?.find((t) => t.id === attributionId.teamId);
setAttributedTeam(team);
}
}
}, []);

return (
<Modal visible={true} closeable={false} onClose={() => {}}>
<h3 className="flex">
<span className="flex-grow">Spending Limit Reached</span>
</h3>
<div className="border-t border-b border-gray-200 dark:border-gray-800 mt-4 -mx-6 px-6 py-2">
<p className="mt-1 mb-2 text-base dark:text-gray-400">Please increase the spending limit and retry.</p>
</div>
<div className="flex justify-end mt-6 space-x-2">
<a href={gitpodHostUrl.with({ pathname: "billing" }).toString()}>
<button>Billing Settings</button>
</a>
{attributedTeam && (
<a href={gitpodHostUrl.with({ pathname: `t/${attributedTeam?.slug}/billing` }).toString()}>
<button>Team Billing</button>
</a>
)}
</div>
</Modal>
);
}

function RepositoryNotFoundView(p: { error: StartWorkspaceError }) {
const [statusMessage, setStatusMessage] = useState<React.ReactNode>();
Expand Down
2 changes: 1 addition & 1 deletion components/dashboard/src/start/StartWorkspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -528,7 +528,7 @@ export default class StartWorkspace extends React.Component<StartWorkspaceProps,
try {
const desktopLink = new URL(openLink);
redirect =
desktopLink.protocol != "http:" && desktopLink.protocol != "https:";
desktopLink.protocol !== "http:" && desktopLink.protocol !== "https:";
} catch {}
if (redirect) {
window.location.href = openLink;
Expand Down
3 changes: 3 additions & 0 deletions components/gitpod-protocol/src/messaging/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ export namespace ErrorCodes {
// 450 Payment error
export const PAYMENT_ERROR = 450;

// 451 Out of credits
export const PAYMENT_SPENDING_LIMIT_REACHED = 451;

// 455 Invalid cost center (custom status code)
export const INVALID_COST_CENTER = 455;

Expand Down
46 changes: 36 additions & 10 deletions components/server/ee/src/workspace/gitpod-server-impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,32 @@ export class GitpodServerEEImpl extends GitpodServerImpl {
): Promise<void> {
await super.mayStartWorkspace(ctx, user, runningInstances);

// TODO(at) replace the naive implementation based on usage service
// with a proper call check against the upcoming invoice.
// For now this should just enable the work on fronend.
if (await this.isUsageBasedFeatureFlagEnabled(user)) {
Copy link
Member

Choose a reason for hiding this comment

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

💯

// dummy implementation to test frontend bits
const attributionId = await this.userService.getWorkspaceUsageAttributionId(user);
const costCenter = !!attributionId && (await this.costCenterDB.findById(attributionId));
if (costCenter) {
const allSessions = await this.listBilledUsage(ctx, {
attributionId,
startedTimeOrder: SortOrder.Descending,
});
const totalUsage = allSessions.map((s) => s.credits).reduce((a, b) => a + b, 0);

if (totalUsage >= costCenter.spendingLimit) {
throw new ResponseError(
ErrorCodes.PAYMENT_SPENDING_LIMIT_REACHED,
"Increase spending limit and try again.",
{
attributionId: user.usageAttributionId,
},
);
}
}
}

const result = await this.entitlementService.mayStartWorkspace(user, new Date(), runningInstances);
if (!result.enoughCredits) {
throw new ResponseError(
Expand Down Expand Up @@ -1926,16 +1952,16 @@ export class GitpodServerEEImpl extends GitpodServerImpl {
return subscription;
}

protected async ensureIsUsageBasedFeatureFlagEnabled(user: User): Promise<void> {
protected async isUsageBasedFeatureFlagEnabled(user: User): Promise<boolean> {
const teams = await this.teamDB.findTeamsByUser(user.id);
const isUsageBasedBillingEnabled = await getExperimentsClientForBackend().getValueAsync(
"isUsageBasedBillingEnabled",
false,
{
user,
teams: teams,
},
);
return await getExperimentsClientForBackend().getValueAsync("isUsageBasedBillingEnabled", false, {
user,
teams: teams,
});
}

protected async ensureIsUsageBasedFeatureFlagEnabled(user: User): Promise<void> {
const isUsageBasedBillingEnabled = await this.isUsageBasedFeatureFlagEnabled(user);
if (!isUsageBasedBillingEnabled) {
throw new ResponseError(ErrorCodes.PERMISSION_DENIED, "not allowed");
}
Expand Down Expand Up @@ -2084,7 +2110,7 @@ export class GitpodServerEEImpl extends GitpodServerImpl {
if (costCenter) {
if (totalUsage > costCenter.spendingLimit) {
result.unshift("The spending limit is reached.");
} else if (totalUsage > 0.8 * costCenter.spendingLimit * 0.8) {
} else if (totalUsage > costCenter.spendingLimit * 0.8) {
result.unshift("The spending limit is almost reached.");
}
} else {
Expand Down