[admin] allow usage adjustments

This commit is contained in:
Sven Efftinge 2022-11-22 08:48:16 +00:00 committed by Robo Quat
parent 2fef214976
commit e044c1d49f
13 changed files with 549 additions and 117 deletions

View File

@ -15,14 +15,22 @@ import Label from "./Label";
import Property from "./Property"; import Property from "./Property";
import { AttributionId } from "@gitpod/gitpod-protocol/lib/attribution"; import { AttributionId } from "@gitpod/gitpod-protocol/lib/attribution";
import { BillingMode } from "@gitpod/gitpod-protocol/lib/billing-mode"; import { BillingMode } from "@gitpod/gitpod-protocol/lib/billing-mode";
import { CostCenterJSON, CostCenter_BillingStrategy } from "@gitpod/gitpod-protocol/lib/usage";
import Modal from "../components/Modal";
export default function TeamDetail(props: { team: Team }) { export default function TeamDetail(props: { team: Team }) {
const { team } = props; const { team } = props;
const [teamMembers, setTeamMembers] = useState<TeamMemberInfo[] | undefined>(undefined); const [teamMembers, setTeamMembers] = useState<TeamMemberInfo[] | undefined>(undefined);
const [billingMode, setBillingMode] = useState<BillingMode | undefined>(undefined); const [billingMode, setBillingMode] = useState<BillingMode | undefined>(undefined);
const [searchText, setSearchText] = useState<string>(""); const [searchText, setSearchText] = useState<string>("");
const [costCenter, setCostCenter] = useState<CostCenterJSON>();
const [usageBalance, setUsageBalance] = useState<number>(0);
const [usageLimit, setUsageLimit] = useState<number>();
const [editSpendingLimit, setEditSpendingLimit] = useState<boolean>(false);
const [creditNote, setCreditNote] = useState<{ credits: number; note?: string }>({ credits: 0 });
const [editAddCreditNote, setEditAddCreditNote] = useState<boolean>(false);
useEffect(() => { const initialize = () => {
(async () => { (async () => {
const members = await getGitpodService().server.adminGetTeamMembers(team.id); const members = await getGitpodService().server.adminGetTeamMembers(team.id);
if (members.length > 0) { if (members.length > 0) {
@ -30,9 +38,22 @@ export default function TeamDetail(props: { team: Team }) {
} }
})(); })();
getGitpodService() getGitpodService()
.server.adminGetBillingMode(AttributionId.render({ kind: "team", teamId: props.team.id })) .server.adminGetBillingMode(AttributionId.render({ kind: "team", teamId: team.id }))
.then((bm) => setBillingMode(bm)); .then((bm) => setBillingMode(bm));
}, [team]); const attributionId = AttributionId.render(AttributionId.create(team));
getGitpodService().server.adminGetBillingMode(attributionId).then(setBillingMode);
getGitpodService().server.adminGetCostCenter(attributionId).then(setCostCenter);
getGitpodService().server.adminGetUsageBalance(attributionId).then(setUsageBalance);
};
useEffect(initialize, [team]);
useEffect(() => {
if (!costCenter) {
return;
}
setUsageLimit(costCenter.spendingLimit);
}, [costCenter]);
const filteredMembers = teamMembers?.filter((m) => { const filteredMembers = teamMembers?.filter((m) => {
const memberSearchText = `${m.fullName || ""}${m.primaryEmail || ""}`.toLocaleLowerCase(); const memberSearchText = `${m.fullName || ""}${m.primaryEmail || ""}`.toLocaleLowerCase();
@ -64,8 +85,53 @@ export default function TeamDetail(props: { team: Team }) {
</div> </div>
</div> </div>
<div className="flex mt-6"> <div className="flex mt-6">
{!team.markedDeleted && teamMembers && <Property name="Members">{teamMembers.length}</Property>} {!team.markedDeleted && <Property name="Members">{teamMembers?.length || "?"}</Property>}
{!team.markedDeleted && <Property name="Billing Mode">{billingMode?.mode || "---"}</Property>} {!team.markedDeleted && <Property name="Billing Mode">{billingMode?.mode || "---"}</Property>}
{costCenter && (
<Property name="Stripe Subscription" actions={[]}>
<span>
{costCenter?.billingStrategy === CostCenter_BillingStrategy.BILLING_STRATEGY_STRIPE
? "Active"
: "Inactive"}
</span>
</Property>
)}
</div>
<div className="flex mt-6">
{costCenter && (
<Property name="Current Cycle" actions={[]}>
<span>
{dayjs(costCenter?.billingCycleStart).format("MMM D")} -{" "}
{dayjs(costCenter?.nextBillingTime).format("MMM D")}
</span>
</Property>
)}
{costCenter && (
<Property
name="Available Credits"
actions={[
{
label: "Add Credits",
onClick: () => setEditAddCreditNote(true),
},
]}
>
<span>{usageBalance * -1 + (costCenter?.spendingLimit || 0)} Credits</span>
</Property>
)}
{costCenter && (
<Property
name="Usage Limit"
actions={[
{
label: "Change Usage Limit",
onClick: () => setEditSpendingLimit(true),
},
]}
>
<span>{costCenter?.spendingLimit} Credits</span>
</Property>
)}
</div> </div>
<div className="flex mt-4"> <div className="flex mt-4">
<div className="flex"> <div className="flex">
@ -151,6 +217,91 @@ export default function TeamDetail(props: { team: Team }) {
)) ))
)} )}
</ItemsList> </ItemsList>
<Modal
visible={editSpendingLimit}
onClose={() => setEditSpendingLimit(false)}
title="Change Usage Limit"
onEnter={() => false}
buttons={[
<button
disabled={usageLimit === costCenter?.spendingLimit}
onClick={async () => {
if (usageLimit !== undefined) {
await getGitpodService().server.adminSetUsageLimit(
AttributionId.render(AttributionId.create(team)),
usageLimit || 0,
);
setUsageLimit(undefined);
initialize();
setEditSpendingLimit(false);
}
}}
>
Change
</button>,
]}
>
<p className="pb-4 text-gray-500 text-base">Change the usage limit in credits per month.</p>
<label>Credits</label>
<div className="flex flex-col">
<input
type="number"
className="w-full"
min={Math.max(usageBalance, 0)}
max={500000}
title="Change Usage Limit"
value={usageLimit}
onChange={(event) => setUsageLimit(Number.parseInt(event.target.value))}
/>
</div>
</Modal>
<Modal
onEnter={() => false}
visible={editAddCreditNote}
onClose={() => setEditAddCreditNote(false)}
title="Add Credits"
buttons={[
<button
disabled={creditNote.credits === 0 || !creditNote.note}
onClick={async () => {
if (creditNote.credits !== 0 && !!creditNote.note) {
await getGitpodService().server.adminAddUsageCreditNote(
AttributionId.render(AttributionId.create(team)),
creditNote.credits,
creditNote.note,
);
setEditAddCreditNote(false);
setCreditNote({ credits: 0 });
initialize();
}
}}
>
Add Credits
</button>,
]}
>
<p>Adds or subtracts the amount of credits from this account.</p>
<div className="flex flex-col">
<label className="mt-4">Credits</label>
<input
className="w-full"
type="number"
min={-50000}
max={50000}
title="Credits"
value={creditNote.credits}
onChange={(event) =>
setCreditNote({ credits: Number.parseInt(event.target.value), note: creditNote.note })
}
/>
<label className="mt-4">Note</label>
<textarea
className="w-full"
title="Note"
onChange={(event) => setCreditNote({ credits: creditNote.credits, note: event.target.value })}
/>
</div>
</Modal>
</> </>
); );
} }

View File

@ -26,12 +26,19 @@ import { BillingMode } from "@gitpod/gitpod-protocol/lib/billing-mode";
import { AttributionId } from "@gitpod/gitpod-protocol/lib/attribution"; import { AttributionId } from "@gitpod/gitpod-protocol/lib/attribution";
import CaretDown from "../icons/CaretDown.svg"; import CaretDown from "../icons/CaretDown.svg";
import ContextMenu from "../components/ContextMenu"; import ContextMenu from "../components/ContextMenu";
import { CostCenterJSON, CostCenter_BillingStrategy } from "@gitpod/gitpod-protocol/lib/usage";
export default function UserDetail(p: { user: User }) { export default function UserDetail(p: { user: User }) {
const [activity, setActivity] = useState(false); const [activity, setActivity] = useState(false);
const [user, setUser] = useState(p.user); const [user, setUser] = useState(p.user);
const [billingMode, setBillingMode] = useState<BillingMode | undefined>(undefined); const [billingMode, setBillingMode] = useState<BillingMode | undefined>(undefined);
const [accountStatement, setAccountStatement] = useState<AccountStatement>(); const [accountStatement, setAccountStatement] = useState<AccountStatement>();
const [costCenter, setCostCenter] = useState<CostCenterJSON>();
const [usageBalance, setUsageBalance] = useState<number>(0);
const [usageLimit, setUsageLimit] = useState<number>();
const [editSpendingLimit, setEditSpendingLimit] = useState<boolean>(false);
const [creditNote, setCreditNote] = useState<{ credits: number; note?: string }>({ credits: 0 });
const [editAddCreditNote, setEditAddCreditNote] = useState<boolean>(false);
const [isStudent, setIsStudent] = useState<boolean>(); const [isStudent, setIsStudent] = useState<boolean>();
const [editFeatureFlags, setEditFeatureFlags] = useState(false); const [editFeatureFlags, setEditFeatureFlags] = useState(false);
const [editRoles, setEditRoles] = useState(false); const [editRoles, setEditRoles] = useState(false);
@ -40,21 +47,28 @@ export default function UserDetail(p: { user: User }) {
const isProfessionalOpenSource = const isProfessionalOpenSource =
accountStatement && accountStatement.subscriptions.some((s) => s.planId === Plans.FREE_OPEN_SOURCE.chargebeeId); accountStatement && accountStatement.subscriptions.some((s) => s.planId === Plans.FREE_OPEN_SOURCE.chargebeeId);
useEffect(() => { const initialize = () => {
setUser(p.user); setUser(user);
getGitpodService() getGitpodService()
.server.adminGetAccountStatement(p.user.id) .server.adminGetAccountStatement(user.id)
.then((as) => setAccountStatement(as)) .then(setAccountStatement)
.catch((e) => { .catch((e) => {
console.error(e); console.error(e);
}); });
getGitpodService() getGitpodService().server.adminIsStudent(user.id).then(setIsStudent);
.server.adminIsStudent(p.user.id) const attributionId = AttributionId.render(AttributionId.create(user));
.then((isStud) => setIsStudent(isStud)); getGitpodService().server.adminGetBillingMode(attributionId).then(setBillingMode);
getGitpodService() getGitpodService().server.adminGetCostCenter(attributionId).then(setCostCenter);
.server.adminGetBillingMode(AttributionId.render({ kind: "user", userId: p.user.id })) getGitpodService().server.adminGetUsageBalance(attributionId).then(setUsageBalance);
.then((bm) => setBillingMode(bm)); };
}, [p.user]); useEffect(initialize, [user]);
useEffect(() => {
if (!costCenter) {
return;
}
setUsageLimit(costCenter.spendingLimit);
}, [costCenter, user]);
const email = User.getPrimaryEmail(p.user); const email = User.getPrimaryEmail(p.user);
const emailDomain = email ? email.split("@")[email.split("@").length - 1] : undefined; const emailDomain = email ? email.split("@")[email.split("@").length - 1] : undefined;
@ -208,7 +222,47 @@ export default function UserDetail(p: { user: User }) {
); );
break; break;
case "usage-based": case "usage-based":
// TODO(gpl) Add info about Stripe plan, etc. properties.push(
<Property name="Stripe Subscription" actions={[]}>
<span>
{costCenter?.billingStrategy === CostCenter_BillingStrategy.BILLING_STRATEGY_STRIPE
? "Active"
: "Inactive"}
</span>
</Property>,
);
properties.push(
<Property name="Current Cycle" actions={[]}>
<span>
{dayjs(costCenter?.billingCycleStart).format("MMM D")} -{" "}
{dayjs(costCenter?.nextBillingTime).format("MMM D")}
</span>
</Property>,
);
properties.push(
<Property
name="Available Credits"
actions={[
{
label: "Add Credits",
onClick: () => setEditAddCreditNote(true),
},
]}
>
<span>{usageBalance * -1 + (costCenter?.spendingLimit || 0)} Credits</span>
</Property>,
<Property
name="Usage Limit"
actions={[
{
label: "Change Usage Limit",
onClick: () => setEditSpendingLimit(true),
},
]}
>
<span>{costCenter?.spendingLimit} Credits</span>
</Property>,
);
break; break;
default: default:
break; break;
@ -290,8 +344,91 @@ export default function UserDetail(p: { user: User }) {
{renderUserBillingProperties()} {renderUserBillingProperties()}
</div> </div>
</div> </div>
<WorkspaceSearch user={user} /> <WorkspaceSearch user={user} />
</PageWithAdminSubMenu> </PageWithAdminSubMenu>
<Modal
visible={editSpendingLimit}
onClose={() => setEditSpendingLimit(false)}
title="Change Usage Limit"
onEnter={() => false}
buttons={[
<button
disabled={usageLimit === costCenter?.spendingLimit}
onClick={async () => {
if (usageLimit !== undefined) {
await getGitpodService().server.adminSetUsageLimit(
AttributionId.render(AttributionId.create(user)),
usageLimit || 0,
);
setUsageLimit(undefined);
initialize();
setEditSpendingLimit(false);
}
}}
>
Change
</button>,
]}
>
<p className="pb-4 text-gray-500 text-base">Change the usage limit in credits per month.</p>
<label>Credits</label>
<div className="flex flex-col">
<input
type="number"
min={Math.max(usageBalance, 0)}
max={500000}
title="Change Usage Limit"
value={usageLimit}
onChange={(event) => setUsageLimit(Number.parseInt(event.target.value))}
/>
</div>
</Modal>
<Modal
onEnter={() => false}
visible={editAddCreditNote}
onClose={() => setEditAddCreditNote(false)}
title="Add Credits"
buttons={[
<button
disabled={creditNote.credits === 0 || !creditNote.note}
onClick={async () => {
if (creditNote.credits !== 0 && !!creditNote.note) {
await getGitpodService().server.adminAddUsageCreditNote(
AttributionId.render(AttributionId.create(user)),
creditNote.credits,
creditNote.note,
);
setEditAddCreditNote(false);
setCreditNote({ credits: 0 });
initialize();
}
}}
>
Add Credits
</button>,
]}
>
<p>Adds or subtracts the amount of credits from this account.</p>
<div className="flex flex-col">
<label className="mt-4">Credits</label>
<input
type="number"
min={-50000}
max={50000}
title="Credits"
value={creditNote.credits}
onChange={(event) =>
setCreditNote({ credits: Number.parseInt(event.target.value), note: creditNote.note })
}
/>
<label className="mt-4">Note</label>
<textarea
title="Note"
onChange={(event) => setCreditNote({ credits: creditNote.credits, note: event.target.value })}
/>
</div>
</Modal>
<Modal <Modal
visible={editFeatureFlags} visible={editFeatureFlags}
onClose={() => setEditFeatureFlags(false)} onClose={() => setEditFeatureFlags(false)}

View File

@ -10,7 +10,6 @@ import { Link } from "react-router-dom";
import { Appearance, loadStripe, Stripe } from "@stripe/stripe-js"; import { Appearance, loadStripe, Stripe } from "@stripe/stripe-js";
import { Elements, PaymentElement, useElements, useStripe } from "@stripe/react-stripe-js"; import { Elements, PaymentElement, useElements, useStripe } from "@stripe/react-stripe-js";
import { AttributionId } from "@gitpod/gitpod-protocol/lib/attribution"; import { AttributionId } from "@gitpod/gitpod-protocol/lib/attribution";
import { Ordering } from "@gitpod/gitpod-protocol/lib/usage";
import { ReactComponent as Spinner } from "../icons/Spinner.svg"; import { ReactComponent as Spinner } from "../icons/Spinner.svg";
import { ReactComponent as Check } from "../images/check-circle.svg"; import { ReactComponent as Check } from "../images/check-circle.svg";
import { ThemeContext } from "../theme-context"; import { ThemeContext } from "../theme-context";
@ -37,8 +36,8 @@ export default function UsageBasedBillingConfig({ attributionId }: Props) {
const [showBillingSetupModal, setShowBillingSetupModal] = useState<boolean>(false); const [showBillingSetupModal, setShowBillingSetupModal] = useState<boolean>(false);
const [stripeSubscriptionId, setStripeSubscriptionId] = useState<string | undefined>(); const [stripeSubscriptionId, setStripeSubscriptionId] = useState<string | undefined>();
const [isLoadingStripeSubscription, setIsLoadingStripeSubscription] = useState<boolean>(true); const [isLoadingStripeSubscription, setIsLoadingStripeSubscription] = useState<boolean>(true);
const [currentUsage, setCurrentUsage] = useState<number | undefined>(); const [currentUsage, setCurrentUsage] = useState<number>(0);
const [usageLimit, setUsageLimit] = useState<number | undefined>(); const [usageLimit, setUsageLimit] = useState<number>(0);
const [stripePortalUrl, setStripePortalUrl] = useState<string | undefined>(); const [stripePortalUrl, setStripePortalUrl] = useState<string | undefined>();
const [pollStripeSubscriptionTimeout, setPollStripeSubscriptionTimeout] = useState<NodeJS.Timeout | undefined>(); const [pollStripeSubscriptionTimeout, setPollStripeSubscriptionTimeout] = useState<NodeJS.Timeout | undefined>();
const [pendingStripeSubscription, setPendingStripeSubscription] = useState<PendingStripeSubscription | undefined>(); const [pendingStripeSubscription, setPendingStripeSubscription] = useState<PendingStripeSubscription | undefined>();
@ -55,7 +54,7 @@ export default function UsageBasedBillingConfig({ attributionId }: Props) {
try { try {
getGitpodService().server.findStripeSubscriptionId(attributionId).then(setStripeSubscriptionId); getGitpodService().server.findStripeSubscriptionId(attributionId).then(setStripeSubscriptionId);
const costCenter = await getGitpodService().server.getCostCenter(attributionId); const costCenter = await getGitpodService().server.getCostCenter(attributionId);
setUsageLimit(costCenter?.spendingLimit); setUsageLimit(costCenter?.spendingLimit || 0);
setBillingCycleFrom(dayjs(costCenter?.billingCycleStart || now.startOf("month")).utc(true)); setBillingCycleFrom(dayjs(costCenter?.billingCycleStart || now.startOf("month")).utc(true));
setBillingCycleTo(dayjs(costCenter?.nextBillingTime || now.endOf("month")).utc(true)); setBillingCycleTo(dayjs(costCenter?.nextBillingTime || now.endOf("month")).utc(true));
} catch (error) { } catch (error) {
@ -175,15 +174,7 @@ export default function UsageBasedBillingConfig({ attributionId }: Props) {
if (!attributionId) { if (!attributionId) {
return; return;
} }
(async () => { getGitpodService().server.getUsageBalance(attributionId).then(setCurrentUsage);
const response = await getGitpodService().server.listUsage({
attributionId,
order: Ordering.ORDERING_DESCENDING,
from: billingCycleFrom.toDate().getTime(),
to: Date.now(),
});
setCurrentUsage(response.creditsUsed);
})();
}, [attributionId, billingCycleFrom]); }, [attributionId, billingCycleFrom]);
const showSpinner = !attributionId || isLoadingStripeSubscription || !!pendingStripeSubscription; const showSpinner = !attributionId || isLoadingStripeSubscription || !!pendingStripeSubscription;
@ -208,6 +199,10 @@ export default function UsageBasedBillingConfig({ attributionId }: Props) {
} }
}; };
const used = currentUsage >= 0 ? currentUsage : 0;
const totalAvailable = usageLimit + (currentUsage < 0 ? currentUsage * -1 : 0);
const percentage = Math.min(Math.max(Math.round((100 * used) / totalAvailable), 0), 100);
return ( return (
<div className="mb-16"> <div className="mb-16">
<h2 className="text-gray-500">Manage usage-based billing, usage limit, and payment method.</h2> <h2 className="text-gray-500">Manage usage-based billing, usage limit, and payment method.</h2>
@ -227,13 +222,8 @@ export default function UsageBasedBillingConfig({ attributionId }: Props) {
<div className="flex flex-col mt-4 p-4 rounded-xl bg-gray-50 dark:bg-gray-800"> <div className="flex flex-col mt-4 p-4 rounded-xl bg-gray-50 dark:bg-gray-800">
<div className="uppercase text-sm text-gray-400 dark:text-gray-500">Balance Used</div> <div className="uppercase text-sm text-gray-400 dark:text-gray-500">Balance Used</div>
<div className="mt-1 text-xl font-semibold flex-grow"> <div className="mt-1 text-xl font-semibold flex-grow">
<span className="text-gray-900 dark:text-gray-100"> <span className="text-gray-900 dark:text-gray-100">{used}</span>
{typeof currentUsage === "number" ? Math.round(currentUsage) : "?"} <span className="text-gray-400 dark:text-gray-500"> / {totalAvailable} Credits</span>
</span>
<span className="text-gray-400 dark:text-gray-500">
{" "}
/ {usageLimit} Credit{usageLimit === 1 ? "" : "s"}
</span>
</div> </div>
<div className="mt-4 text-sm flex"> <div className="mt-4 text-sm flex">
<span className="flex-grow"> <span className="flex-grow">
@ -244,9 +234,7 @@ export default function UsageBasedBillingConfig({ attributionId }: Props) {
)} )}
</span> </span>
{typeof currentUsage === "number" && typeof usageLimit === "number" && usageLimit > 0 && ( {typeof currentUsage === "number" && typeof usageLimit === "number" && usageLimit > 0 && (
<span className="text-gray-400 dark:text-gray-500"> <span className="text-gray-400 dark:text-gray-500">{percentage}% used</span>
{Math.round((100 * currentUsage) / usageLimit)}% used
</span>
)} )}
</div> </div>
<div className="mt-2 flex"> <div className="mt-2 flex">

View File

@ -13,6 +13,7 @@ import { RoleOrPermission } from "./permission";
import { AccountStatement } from "./accounting-protocol"; import { AccountStatement } from "./accounting-protocol";
import { InstallationAdminSettings } from "./installation-admin-protocol"; import { InstallationAdminSettings } from "./installation-admin-protocol";
import { BillingMode } from "./billing-mode"; import { BillingMode } from "./billing-mode";
import { CostCenterJSON, ListUsageRequest, ListUsageResponse } from "./usage";
export interface AdminServer { export interface AdminServer {
adminGetUsers(req: AdminGetListRequest<User>): Promise<AdminGetListResult<User>>; adminGetUsers(req: AdminGetListRequest<User>): Promise<AdminGetListResult<User>>;
@ -54,6 +55,13 @@ export interface AdminServer {
adminGetSettings(): Promise<InstallationAdminSettings>; adminGetSettings(): Promise<InstallationAdminSettings>;
adminUpdateSettings(settings: InstallationAdminSettings): Promise<void>; adminUpdateSettings(settings: InstallationAdminSettings): Promise<void>;
adminGetCostCenter(attributionId: string): Promise<CostCenterJSON | undefined>;
adminSetUsageLimit(attributionId: string, usageLimit: number): Promise<void>;
adminListUsage(req: ListUsageRequest): Promise<ListUsageResponse>;
adminAddUsageCreditNote(attributionId: string, credits: number, note: string): Promise<void>;
adminGetUsageBalance(attributionId: string): Promise<number>;
} }
export interface AdminGetListRequest<T> { export interface AdminGetListRequest<T> {

View File

@ -282,6 +282,7 @@ export interface GitpodServer extends JsonRpcServer<GitpodClient>, AdminServer,
getStripePortalUrl(attributionId: string): Promise<string>; getStripePortalUrl(attributionId: string): Promise<string>;
getCostCenter(attributionId: string): Promise<CostCenterJSON | undefined>; getCostCenter(attributionId: string): Promise<CostCenterJSON | undefined>;
setUsageLimit(attributionId: string, usageLimit: number): Promise<void>; setUsageLimit(attributionId: string, usageLimit: number): Promise<void>;
getUsageBalance(attributionId: string): Promise<number>;
listUsage(req: ListUsageRequest): Promise<ListUsageResponse>; listUsage(req: ListUsageRequest): Promise<ListUsageResponse>;

View File

@ -71,8 +71,9 @@ import { BlockedRepository } from "@gitpod/gitpod-protocol/lib/blocked-repositor
import { EligibilityService } from "../user/eligibility-service"; import { EligibilityService } from "../user/eligibility-service";
import { AccountStatementProvider } from "../user/account-statement-provider"; import { AccountStatementProvider } from "../user/account-statement-provider";
import { GithubUpgradeURL, PlanCoupon } from "@gitpod/gitpod-protocol/lib/payment-protocol"; import { GithubUpgradeURL, PlanCoupon } from "@gitpod/gitpod-protocol/lib/payment-protocol";
import { ListUsageRequest, ListUsageResponse } from "@gitpod/gitpod-protocol/lib/usage"; import { CostCenterJSON, ListUsageRequest, ListUsageResponse } from "@gitpod/gitpod-protocol/lib/usage";
import { import {
CostCenter,
CostCenter_BillingStrategy, CostCenter_BillingStrategy,
ListUsageRequest_Ordering, ListUsageRequest_Ordering,
UsageServiceClient, UsageServiceClient,
@ -122,7 +123,6 @@ import {
} from "@gitpod/usage-api/lib/usage/v1/billing.pb"; } from "@gitpod/usage-api/lib/usage/v1/billing.pb";
import { IncrementalPrebuildsService } from "../prebuilds/incremental-prebuilds-service"; import { IncrementalPrebuildsService } from "../prebuilds/incremental-prebuilds-service";
import { ConfigProvider } from "../../../src/workspace/config-provider"; import { ConfigProvider } from "../../../src/workspace/config-provider";
import { CostCenterJSON } from "@gitpod/gitpod-protocol/src/usage";
@injectable() @injectable()
export class GitpodServerEEImpl extends GitpodServerImpl { export class GitpodServerEEImpl extends GitpodServerImpl {
@ -2282,6 +2282,10 @@ export class GitpodServerEEImpl extends GitpodServerImpl {
await this.guardCostCenterAccess(ctx, user.id, attrId, "get"); await this.guardCostCenterAccess(ctx, user.id, attrId, "get");
const { costCenter } = await this.usageService.getCostCenter({ attributionId }); const { costCenter } = await this.usageService.getCostCenter({ attributionId });
return this.translateCostCenter(costCenter);
}
private translateCostCenter(costCenter?: CostCenter): CostCenterJSON | undefined {
return costCenter return costCenter
? { ? {
...costCenter, ...costCenter,
@ -2302,7 +2306,6 @@ export class GitpodServerEEImpl extends GitpodServerImpl {
if (typeof usageLimit !== "number" || usageLimit < 0) { if (typeof usageLimit !== "number" || usageLimit < 0) {
throw new ResponseError(ErrorCodes.BAD_REQUEST, `Unexpected usageLimit value: ${usageLimit}`); throw new ResponseError(ErrorCodes.BAD_REQUEST, `Unexpected usageLimit value: ${usageLimit}`);
} }
const user = this.checkAndBlockUser("setUsageLimit"); const user = this.checkAndBlockUser("setUsageLimit");
await this.guardCostCenterAccess(ctx, user.id, attrId, "update"); await this.guardCostCenterAccess(ctx, user.id, attrId, "update");
@ -2377,6 +2380,31 @@ export class GitpodServerEEImpl extends GitpodServerImpl {
} }
async listUsage(ctx: TraceContext, req: ListUsageRequest): Promise<ListUsageResponse> { async listUsage(ctx: TraceContext, req: ListUsageRequest): Promise<ListUsageResponse> {
const attributionId = AttributionId.parse(req.attributionId);
if (!attributionId) {
throw new ResponseError(ErrorCodes.INVALID_COST_CENTER, "Bad attribution ID", {
attributionId: req.attributionId,
});
}
const user = this.checkAndBlockUser("listUsage");
await this.guardCostCenterAccess(ctx, user.id, attributionId, "get");
return this.internalListUsage(ctx, req);
}
async getUsageBalance(ctx: TraceContext, attributionId: string): Promise<number> {
const user = this.checkAndBlockUser("listUsage");
const parsedAttributionId = AttributionId.parse(attributionId);
if (!parsedAttributionId) {
throw new ResponseError(ErrorCodes.INVALID_COST_CENTER, "Bad attribution ID", {
attributionId,
});
}
await this.guardCostCenterAccess(ctx, user.id, parsedAttributionId, "get");
const result = await this.usageService.getBalance({ attributionId });
return result.credits;
}
private async internalListUsage(ctx: TraceContext, req: ListUsageRequest): Promise<ListUsageResponse> {
const { from, to } = req; const { from, to } = req;
const attributionId = AttributionId.parse(req.attributionId); const attributionId = AttributionId.parse(req.attributionId);
if (!attributionId) { if (!attributionId) {
@ -2385,9 +2413,6 @@ export class GitpodServerEEImpl extends GitpodServerImpl {
}); });
} }
traceAPIParams(ctx, { attributionId }); traceAPIParams(ctx, { attributionId });
const user = this.checkAndBlockUser("listUsage");
await this.guardCostCenterAccess(ctx, user.id, attributionId, "get");
const response = await this.usageService.listUsage({ const response = await this.usageService.listUsage({
attributionId: AttributionId.render(attributionId), attributionId: AttributionId.render(attributionId),
from: from ? new Date(from) : undefined, from: from ? new Date(from) : undefined,
@ -2745,4 +2770,84 @@ export class GitpodServerEEImpl extends GitpodServerImpl {
); );
return project; return project;
} }
async adminGetCostCenter(ctx: TraceContext, attributionId: string): Promise<CostCenterJSON | undefined> {
const attrId = AttributionId.parse(attributionId);
if (attrId === undefined) {
log.error(`Invalid attribution id: ${attributionId}`);
throw new ResponseError(ErrorCodes.BAD_REQUEST, `Invalid attibution id: ${attributionId}`);
}
const user = this.checkAndBlockUser("adminGetCostCenter");
await this.guardAdminAccess("adminGetCostCenter", { id: user.id }, Permission.ADMIN_USERS);
const { costCenter } = await this.usageService.getCostCenter({ attributionId });
return this.translateCostCenter(costCenter);
}
async adminSetUsageLimit(ctx: TraceContext, attributionId: string, usageLimit: number): Promise<void> {
const attrId = AttributionId.parse(attributionId);
if (attrId === undefined) {
log.error(`Invalid attribution id: ${attributionId}`);
throw new ResponseError(ErrorCodes.BAD_REQUEST, `Invalid attibution id: ${attributionId}`);
}
if (typeof usageLimit !== "number" || usageLimit < 0) {
throw new ResponseError(ErrorCodes.BAD_REQUEST, `Unexpected usageLimit value: ${usageLimit}`);
}
const user = this.checkAndBlockUser("adminSetUsageLimit");
await this.guardAdminAccess("adminSetUsageLimit", { id: user.id }, Permission.ADMIN_USERS);
const response = await this.usageService.getCostCenter({ attributionId });
// backward compatibility for cost centers that were created before introduction of BillingStrategy
if (!response.costCenter) {
throw new ResponseError(ErrorCodes.NOT_FOUND, `Coudln't find cost center with id ${attributionId}`);
}
const stripeSubscriptionId = await this.findStripeSubscriptionId(ctx, attributionId);
if (stripeSubscriptionId != undefined) {
response.costCenter.billingStrategy = CostCenter_BillingStrategy.BILLING_STRATEGY_STRIPE;
}
await this.usageService.setCostCenter({
costCenter: {
attributionId,
spendingLimit: usageLimit,
billingStrategy: response.costCenter.billingStrategy,
},
});
this.messageBus.notifyOnSubscriptionUpdate(ctx, attrId).catch((e) => log.error(e));
}
async adminListUsage(ctx: TraceContext, req: ListUsageRequest): Promise<ListUsageResponse> {
traceAPIParams(ctx, { req });
const user = this.checkAndBlockUser("adminListUsage");
await this.guardAdminAccess("adminListUsage", { id: user.id }, Permission.ADMIN_USERS);
return this.internalListUsage(ctx, req);
}
async adminGetUsageBalance(ctx: TraceContext, attributionId: string): Promise<number> {
traceAPIParams(ctx, { attributionId });
const user = this.checkAndBlockUser("adminGetUsageBalance");
await this.guardAdminAccess("adminGetUsageBalance", { id: user.id }, Permission.ADMIN_USERS);
const result = await this.usageService.getBalance({ attributionId });
return result.credits;
}
async adminAddUsageCreditNote(
ctx: TraceContext,
attributionId: string,
credits: number,
description: string,
): Promise<void> {
traceAPIParams(ctx, { attributionId, credits, note: description });
const user = this.checkAndBlockUser("adminAddUsageCreditNote");
await this.guardAdminAccess("adminAddUsageCreditNote", { id: user.id }, Permission.ADMIN_USERS);
await this.usageService.addUsageCreditNote({
attributionId,
credits,
description,
userId: user.id,
});
}
} }

View File

@ -129,6 +129,7 @@ const defaultFunctions: FunctionsConfig = {
waitForSnapshot: { group: "default", points: 1 }, waitForSnapshot: { group: "default", points: 1 },
getSnapshots: { group: "default", points: 1 }, getSnapshots: { group: "default", points: 1 },
guessGitTokenScopes: { group: "default", points: 1 }, guessGitTokenScopes: { group: "default", points: 1 },
getUsageBalance: { group: "default", points: 1 },
adminGetUsers: { group: "default", points: 1 }, adminGetUsers: { group: "default", points: 1 },
adminGetUser: { group: "default", points: 1 }, adminGetUser: { group: "default", points: 1 },
@ -157,6 +158,11 @@ const defaultFunctions: FunctionsConfig = {
adminCreateBlockedRepository: { group: "default", points: 1 }, adminCreateBlockedRepository: { group: "default", points: 1 },
adminDeleteBlockedRepository: { group: "default", points: 1 }, adminDeleteBlockedRepository: { group: "default", points: 1 },
adminGetBillingMode: { group: "default", points: 1 }, adminGetBillingMode: { group: "default", points: 1 },
adminGetCostCenter: { group: "default", points: 1 },
adminSetUsageLimit: { group: "default", points: 1 },
adminListUsage: { group: "default", points: 1 },
adminAddUsageCreditNote: { group: "default", points: 1 },
adminGetUsageBalance: { group: "default", points: 1 },
validateLicense: { group: "default", points: 1 }, validateLicense: { group: "default", points: 1 },
getLicenseInfo: { group: "default", points: 1 }, getLicenseInfo: { group: "default", points: 1 },

View File

@ -3136,6 +3136,35 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable {
throw new ResponseError(ErrorCodes.SAAS_FEATURE, `Not implemented in this version`); throw new ResponseError(ErrorCodes.SAAS_FEATURE, `Not implemented in this version`);
} }
async getUsageBalance(ctx: TraceContext, attributionId: string): Promise<number> {
throw new ResponseError(ErrorCodes.SAAS_FEATURE, `Not implemented in this version`);
}
async adminGetCostCenter(ctx: TraceContext, attributionId: string): Promise<CostCenterJSON | undefined> {
throw new ResponseError(ErrorCodes.SAAS_FEATURE, `Not implemented in this version`);
}
async adminSetUsageLimit(ctx: TraceContext, attributionId: string, usageLimit: number): Promise<void> {
throw new ResponseError(ErrorCodes.SAAS_FEATURE, `Not implemented in this version`);
}
async adminListUsage(ctx: TraceContext, req: ListUsageRequest): Promise<ListUsageResponse> {
throw new ResponseError(ErrorCodes.SAAS_FEATURE, `Not implemented in this version`);
}
async adminGetUsageBalance(ctx: TraceContext, attributionId: string): Promise<number> {
throw new ResponseError(ErrorCodes.SAAS_FEATURE, `Not implemented in this version`);
}
async adminAddUsageCreditNote(
ctx: TraceContext,
attributionId: string,
credits: number,
note: string,
): Promise<void> {
throw new ResponseError(ErrorCodes.SAAS_FEATURE, `Not implemented in this version`);
}
async setUsageAttribution(ctx: TraceContext, usageAttributionId: string): Promise<void> { async setUsageAttribution(ctx: TraceContext, usageAttributionId: string): Promise<void> {
const user = this.checkAndBlockUser("setUsageAttribution"); const user = this.checkAndBlockUser("setUsageAttribution");
try { try {

View File

@ -1086,8 +1086,11 @@ type AddUsageCreditNoteRequest struct {
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
AttributionId string `protobuf:"bytes,1,opt,name=attribution_id,json=attributionId,proto3" json:"attribution_id,omitempty"` AttributionId string `protobuf:"bytes,1,opt,name=attribution_id,json=attributionId,proto3" json:"attribution_id,omitempty"`
// the amount of credits to add to the given account
Credits int32 `protobuf:"varint,2,opt,name=credits,proto3" json:"credits,omitempty"` Credits int32 `protobuf:"varint,2,opt,name=credits,proto3" json:"credits,omitempty"`
Note string `protobuf:"bytes,3,opt,name=note,proto3" json:"note,omitempty"` // a human readable description for the reason this credit note exists
Description string `protobuf:"bytes,3,opt,name=description,proto3" json:"description,omitempty"`
// the id of the user (admin) who created the note
UserId string `protobuf:"bytes,4,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` UserId string `protobuf:"bytes,4,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"`
} }
@ -1137,9 +1140,9 @@ func (x *AddUsageCreditNoteRequest) GetCredits() int32 {
return 0 return 0
} }
func (x *AddUsageCreditNoteRequest) GetNote() string { func (x *AddUsageCreditNoteRequest) GetDescription() string {
if x != nil { if x != nil {
return x.Note return x.Description
} }
return "" return ""
} }
@ -1326,57 +1329,58 @@ var file_usage_v1_usage_proto_rawDesc = []byte{
0x48, 0x45, 0x52, 0x10, 0x01, 0x22, 0x13, 0x0a, 0x11, 0x52, 0x65, 0x73, 0x65, 0x74, 0x55, 0x73, 0x48, 0x45, 0x52, 0x10, 0x01, 0x22, 0x13, 0x0a, 0x11, 0x52, 0x65, 0x73, 0x65, 0x74, 0x55, 0x73,
0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x14, 0x0a, 0x12, 0x52, 0x65, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x14, 0x0a, 0x12, 0x52, 0x65,
0x73, 0x65, 0x74, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x73, 0x65, 0x74, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65,
0x22, 0x89, 0x01, 0x0a, 0x19, 0x41, 0x64, 0x64, 0x55, 0x73, 0x61, 0x67, 0x65, 0x43, 0x72, 0x65, 0x22, 0x97, 0x01, 0x0a, 0x19, 0x41, 0x64, 0x64, 0x55, 0x73, 0x61, 0x67, 0x65, 0x43, 0x72, 0x65,
0x64, 0x69, 0x74, 0x4e, 0x6f, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x25, 0x64, 0x69, 0x74, 0x4e, 0x6f, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x25,
0x0a, 0x0e, 0x61, 0x74, 0x74, 0x72, 0x69, 0x62, 0x75, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x0a, 0x0e, 0x61, 0x74, 0x74, 0x72, 0x69, 0x62, 0x75, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64,
0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x61, 0x74, 0x74, 0x72, 0x69, 0x62, 0x75, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x61, 0x74, 0x74, 0x72, 0x69, 0x62, 0x75, 0x74,
0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x72, 0x65, 0x64, 0x69, 0x74, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x72, 0x65, 0x64, 0x69, 0x74, 0x73,
0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x07, 0x63, 0x72, 0x65, 0x64, 0x69, 0x74, 0x73, 0x12, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x07, 0x63, 0x72, 0x65, 0x64, 0x69, 0x74, 0x73, 0x12,
0x12, 0x0a, 0x04, 0x6e, 0x6f, 0x74, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x20, 0x0a, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x03,
0x6f, 0x74, 0x65, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f,
0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x22, 0x1c, 0x0a, 0x1a, 0x6e, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x04, 0x20, 0x01,
0x41, 0x64, 0x64, 0x55, 0x73, 0x61, 0x67, 0x65, 0x43, 0x72, 0x65, 0x64, 0x69, 0x74, 0x4e, 0x6f, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x22, 0x1c, 0x0a, 0x1a, 0x41, 0x64,
0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x32, 0xce, 0x04, 0x0a, 0x0c, 0x55, 0x64, 0x55, 0x73, 0x61, 0x67, 0x65, 0x43, 0x72, 0x65, 0x64, 0x69, 0x74, 0x4e, 0x6f, 0x74, 0x65,
0x73, 0x61, 0x67, 0x65, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x52, 0x0a, 0x0d, 0x47, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x32, 0xce, 0x04, 0x0a, 0x0c, 0x55, 0x73, 0x61,
0x65, 0x74, 0x43, 0x6f, 0x73, 0x74, 0x43, 0x65, 0x6e, 0x74, 0x65, 0x72, 0x12, 0x1e, 0x2e, 0x75, 0x67, 0x65, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x52, 0x0a, 0x0d, 0x47, 0x65, 0x74,
0x73, 0x61, 0x67, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x47, 0x65, 0x74, 0x43, 0x6f, 0x73, 0x74, 0x43, 0x43, 0x6f, 0x73, 0x74, 0x43, 0x65, 0x6e, 0x74, 0x65, 0x72, 0x12, 0x1e, 0x2e, 0x75, 0x73, 0x61,
0x65, 0x6e, 0x74, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1f, 0x2e, 0x75, 0x67, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x47, 0x65, 0x74, 0x43, 0x6f, 0x73, 0x74, 0x43, 0x65, 0x6e,
0x73, 0x61, 0x67, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x47, 0x65, 0x74, 0x43, 0x6f, 0x73, 0x74, 0x43, 0x74, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1f, 0x2e, 0x75, 0x73, 0x61,
0x65, 0x6e, 0x74, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x67, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x47, 0x65, 0x74, 0x43, 0x6f, 0x73, 0x74, 0x43, 0x65, 0x6e,
0x52, 0x0a, 0x0d, 0x53, 0x65, 0x74, 0x43, 0x6f, 0x73, 0x74, 0x43, 0x65, 0x6e, 0x74, 0x65, 0x72, 0x74, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x52, 0x0a,
0x12, 0x1e, 0x2e, 0x75, 0x73, 0x61, 0x67, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x65, 0x74, 0x43, 0x0d, 0x53, 0x65, 0x74, 0x43, 0x6f, 0x73, 0x74, 0x43, 0x65, 0x6e, 0x74, 0x65, 0x72, 0x12, 0x1e,
0x6f, 0x73, 0x74, 0x43, 0x65, 0x6e, 0x74, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x75, 0x73, 0x61, 0x67, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x65, 0x74, 0x43, 0x6f, 0x73,
0x1a, 0x1f, 0x2e, 0x75, 0x73, 0x61, 0x67, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x65, 0x74, 0x43, 0x74, 0x43, 0x65, 0x6e, 0x74, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1f,
0x6f, 0x73, 0x74, 0x43, 0x65, 0x6e, 0x74, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x2e, 0x75, 0x73, 0x61, 0x67, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x65, 0x74, 0x43, 0x6f, 0x73,
0x65, 0x22, 0x00, 0x12, 0x55, 0x0a, 0x0e, 0x52, 0x65, 0x63, 0x6f, 0x6e, 0x63, 0x69, 0x6c, 0x65, 0x74, 0x43, 0x65, 0x6e, 0x74, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22,
0x55, 0x73, 0x61, 0x67, 0x65, 0x12, 0x1f, 0x2e, 0x75, 0x73, 0x61, 0x67, 0x65, 0x2e, 0x76, 0x31, 0x00, 0x12, 0x55, 0x0a, 0x0e, 0x52, 0x65, 0x63, 0x6f, 0x6e, 0x63, 0x69, 0x6c, 0x65, 0x55, 0x73,
0x2e, 0x52, 0x65, 0x63, 0x6f, 0x6e, 0x63, 0x69, 0x6c, 0x65, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x61, 0x67, 0x65, 0x12, 0x1f, 0x2e, 0x75, 0x73, 0x61, 0x67, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x52,
0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x75, 0x73, 0x61, 0x67, 0x65, 0x2e, 0x76, 0x65, 0x63, 0x6f, 0x6e, 0x63, 0x69, 0x6c, 0x65, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71,
0x31, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x6e, 0x63, 0x69, 0x6c, 0x65, 0x55, 0x73, 0x61, 0x67, 0x65, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x75, 0x73, 0x61, 0x67, 0x65, 0x2e, 0x76, 0x31, 0x2e,
0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x49, 0x0a, 0x0a, 0x52, 0x65, 0x52, 0x65, 0x63, 0x6f, 0x6e, 0x63, 0x69, 0x6c, 0x65, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65,
0x73, 0x65, 0x74, 0x55, 0x73, 0x61, 0x67, 0x65, 0x12, 0x1b, 0x2e, 0x75, 0x73, 0x61, 0x67, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x49, 0x0a, 0x0a, 0x52, 0x65, 0x73, 0x65,
0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x73, 0x65, 0x74, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x74, 0x55, 0x73, 0x61, 0x67, 0x65, 0x12, 0x1b, 0x2e, 0x75, 0x73, 0x61, 0x67, 0x65, 0x2e, 0x76,
0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1c, 0x2e, 0x75, 0x73, 0x61, 0x67, 0x65, 0x2e, 0x76, 0x31, 0x31, 0x2e, 0x52, 0x65, 0x73, 0x65, 0x74, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75,
0x2e, 0x52, 0x65, 0x73, 0x65, 0x74, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x65, 0x73, 0x74, 0x1a, 0x1c, 0x2e, 0x75, 0x73, 0x61, 0x67, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x52,
0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x46, 0x0a, 0x09, 0x4c, 0x69, 0x73, 0x74, 0x55, 0x73, 0x61, 0x65, 0x73, 0x65, 0x74, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73,
0x67, 0x65, 0x12, 0x1a, 0x2e, 0x75, 0x73, 0x61, 0x67, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x65, 0x22, 0x00, 0x12, 0x46, 0x0a, 0x09, 0x4c, 0x69, 0x73, 0x74, 0x55, 0x73, 0x61, 0x67, 0x65,
0x73, 0x74, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x12, 0x1a, 0x2e, 0x75, 0x73, 0x61, 0x67, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74,
0x2e, 0x75, 0x73, 0x61, 0x67, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x55, 0x73, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x75,
0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x49, 0x0a, 0x73, 0x61, 0x67, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x55, 0x73, 0x61, 0x67,
0x0a, 0x47, 0x65, 0x74, 0x42, 0x61, 0x6c, 0x61, 0x6e, 0x63, 0x65, 0x12, 0x1b, 0x2e, 0x75, 0x73, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x49, 0x0a, 0x0a, 0x47,
0x61, 0x67, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x47, 0x65, 0x74, 0x42, 0x61, 0x6c, 0x61, 0x6e, 0x63, 0x65, 0x74, 0x42, 0x61, 0x6c, 0x61, 0x6e, 0x63, 0x65, 0x12, 0x1b, 0x2e, 0x75, 0x73, 0x61, 0x67,
0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1c, 0x2e, 0x75, 0x73, 0x61, 0x67, 0x65, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x47, 0x65, 0x74, 0x42, 0x61, 0x6c, 0x61, 0x6e, 0x63, 0x65, 0x52,
0x2e, 0x76, 0x31, 0x2e, 0x47, 0x65, 0x74, 0x42, 0x61, 0x6c, 0x61, 0x6e, 0x63, 0x65, 0x52, 0x65, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1c, 0x2e, 0x75, 0x73, 0x61, 0x67, 0x65, 0x2e, 0x76,
0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x61, 0x0a, 0x12, 0x41, 0x64, 0x64, 0x55, 0x31, 0x2e, 0x47, 0x65, 0x74, 0x42, 0x61, 0x6c, 0x61, 0x6e, 0x63, 0x65, 0x52, 0x65, 0x73, 0x70,
0x73, 0x61, 0x67, 0x65, 0x43, 0x72, 0x65, 0x64, 0x69, 0x74, 0x4e, 0x6f, 0x74, 0x65, 0x12, 0x23, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x61, 0x0a, 0x12, 0x41, 0x64, 0x64, 0x55, 0x73, 0x61,
0x2e, 0x75, 0x73, 0x61, 0x67, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x64, 0x64, 0x55, 0x73, 0x61, 0x67, 0x65, 0x43, 0x72, 0x65, 0x64, 0x69, 0x74, 0x4e, 0x6f, 0x74, 0x65, 0x12, 0x23, 0x2e, 0x75,
0x67, 0x65, 0x43, 0x72, 0x65, 0x64, 0x69, 0x74, 0x4e, 0x6f, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x73, 0x61, 0x67, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x64, 0x64, 0x55, 0x73, 0x61, 0x67, 0x65,
0x65, 0x73, 0x74, 0x1a, 0x24, 0x2e, 0x75, 0x73, 0x61, 0x67, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x43, 0x72, 0x65, 0x64, 0x69, 0x74, 0x4e, 0x6f, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73,
0x64, 0x64, 0x55, 0x73, 0x61, 0x67, 0x65, 0x43, 0x72, 0x65, 0x64, 0x69, 0x74, 0x4e, 0x6f, 0x74, 0x74, 0x1a, 0x24, 0x2e, 0x75, 0x73, 0x61, 0x67, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x64, 0x64,
0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x42, 0x2a, 0x5a, 0x28, 0x67, 0x55, 0x73, 0x61, 0x67, 0x65, 0x43, 0x72, 0x65, 0x64, 0x69, 0x74, 0x4e, 0x6f, 0x74, 0x65, 0x52,
0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x67, 0x69, 0x74, 0x70, 0x6f, 0x64, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x42, 0x2a, 0x5a, 0x28, 0x67, 0x69, 0x74,
0x2d, 0x69, 0x6f, 0x2f, 0x67, 0x69, 0x74, 0x70, 0x6f, 0x64, 0x2f, 0x75, 0x73, 0x61, 0x67, 0x65, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x67, 0x69, 0x74, 0x70, 0x6f, 0x64, 0x2d, 0x69,
0x2d, 0x61, 0x70, 0x69, 0x2f, 0x76, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, 0x6f, 0x2f, 0x67, 0x69, 0x74, 0x70, 0x6f, 0x64, 0x2f, 0x75, 0x73, 0x61, 0x67, 0x65, 0x2d, 0x61,
0x70, 0x69, 0x2f, 0x76, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
} }
var ( var (

View File

@ -251,8 +251,11 @@ export interface ResetUsageResponse {
export interface AddUsageCreditNoteRequest { export interface AddUsageCreditNoteRequest {
attributionId: string; attributionId: string;
/** the amount of credits to add to the given account */
credits: number; credits: number;
note: string; /** a human readable description for the reason this credit note exists */
description: string;
/** the id of the user (admin) who created the note */
userId: string; userId: string;
} }
@ -1255,7 +1258,7 @@ export const ResetUsageResponse = {
}; };
function createBaseAddUsageCreditNoteRequest(): AddUsageCreditNoteRequest { function createBaseAddUsageCreditNoteRequest(): AddUsageCreditNoteRequest {
return { attributionId: "", credits: 0, note: "", userId: "" }; return { attributionId: "", credits: 0, description: "", userId: "" };
} }
export const AddUsageCreditNoteRequest = { export const AddUsageCreditNoteRequest = {
@ -1266,8 +1269,8 @@ export const AddUsageCreditNoteRequest = {
if (message.credits !== 0) { if (message.credits !== 0) {
writer.uint32(16).int32(message.credits); writer.uint32(16).int32(message.credits);
} }
if (message.note !== "") { if (message.description !== "") {
writer.uint32(26).string(message.note); writer.uint32(26).string(message.description);
} }
if (message.userId !== "") { if (message.userId !== "") {
writer.uint32(34).string(message.userId); writer.uint32(34).string(message.userId);
@ -1289,7 +1292,7 @@ export const AddUsageCreditNoteRequest = {
message.credits = reader.int32(); message.credits = reader.int32();
break; break;
case 3: case 3:
message.note = reader.string(); message.description = reader.string();
break; break;
case 4: case 4:
message.userId = reader.string(); message.userId = reader.string();
@ -1306,7 +1309,7 @@ export const AddUsageCreditNoteRequest = {
return { return {
attributionId: isSet(object.attributionId) ? String(object.attributionId) : "", attributionId: isSet(object.attributionId) ? String(object.attributionId) : "",
credits: isSet(object.credits) ? Number(object.credits) : 0, credits: isSet(object.credits) ? Number(object.credits) : 0,
note: isSet(object.note) ? String(object.note) : "", description: isSet(object.description) ? String(object.description) : "",
userId: isSet(object.userId) ? String(object.userId) : "", userId: isSet(object.userId) ? String(object.userId) : "",
}; };
}, },
@ -1315,7 +1318,7 @@ export const AddUsageCreditNoteRequest = {
const obj: any = {}; const obj: any = {};
message.attributionId !== undefined && (obj.attributionId = message.attributionId); message.attributionId !== undefined && (obj.attributionId = message.attributionId);
message.credits !== undefined && (obj.credits = Math.round(message.credits)); message.credits !== undefined && (obj.credits = Math.round(message.credits));
message.note !== undefined && (obj.note = message.note); message.description !== undefined && (obj.description = message.description);
message.userId !== undefined && (obj.userId = message.userId); message.userId !== undefined && (obj.userId = message.userId);
return obj; return obj;
}, },
@ -1324,7 +1327,7 @@ export const AddUsageCreditNoteRequest = {
const message = createBaseAddUsageCreditNoteRequest(); const message = createBaseAddUsageCreditNoteRequest();
message.attributionId = object.attributionId ?? ""; message.attributionId = object.attributionId ?? "";
message.credits = object.credits ?? 0; message.credits = object.credits ?? 0;
message.note = object.note ?? ""; message.description = object.description ?? "";
message.userId = object.userId ?? ""; message.userId = object.userId ?? "";
return message; return message;
}, },

View File

@ -143,8 +143,8 @@ message AddUsageCreditNoteRequest {
string attribution_id = 1; string attribution_id = 1;
// the amount of credits to add to the given account // the amount of credits to add to the given account
int32 credits = 2; int32 credits = 2;
// a human readable note for the reason this credit note exists // a human readable description for the reason this credit note exists
string note = 3; string description = 3;
// the id of the user (admin) who created the note // the id of the user (admin) who created the note
string user_id = 4; string user_id = 4;
} }

View File

@ -459,7 +459,7 @@ func (s *UsageService) AddUsageCreditNote(ctx context.Context, req *v1.AddUsageC
WithField("attribution_id", req.AttributionId). WithField("attribution_id", req.AttributionId).
WithField("credits", req.Credits). WithField("credits", req.Credits).
WithField("user", req.UserId). WithField("user", req.UserId).
WithField("note", req.Note). WithField("note", req.Description).
Info("Adding usage credit note.") Info("Adding usage credit note.")
attributionId, err := db.ParseAttributionID(req.AttributionId) attributionId, err := db.ParseAttributionID(req.AttributionId)
@ -467,9 +467,9 @@ func (s *UsageService) AddUsageCreditNote(ctx context.Context, req *v1.AddUsageC
return nil, status.Errorf(codes.InvalidArgument, "AttributionID '%s' couldn't be parsed (error: %s).", req.AttributionId, err) return nil, status.Errorf(codes.InvalidArgument, "AttributionID '%s' couldn't be parsed (error: %s).", req.AttributionId, err)
} }
note := strings.TrimSpace(req.Note) description := strings.TrimSpace(req.Description)
if note == "" { if description == "" {
return nil, status.Error(codes.InvalidArgument, "The note must not be empty.") return nil, status.Error(codes.InvalidArgument, "The description must not be empty.")
} }
userId, err := uuid.Parse(req.UserId) userId, err := uuid.Parse(req.UserId)
@ -480,7 +480,7 @@ func (s *UsageService) AddUsageCreditNote(ctx context.Context, req *v1.AddUsageC
usage := db.Usage{ usage := db.Usage{
ID: uuid.New(), ID: uuid.New(),
AttributionID: attributionId, AttributionID: attributionId,
Description: note, Description: description,
CreditCents: db.NewCreditCents(float64(req.Credits * -1)), CreditCents: db.NewCreditCents(float64(req.Credits * -1)),
EffectiveTime: db.NewVarCharTime(time.Now()), EffectiveTime: db.NewVarCharTime(time.Now()),
Kind: db.CreditNoteKind, Kind: db.CreditNoteKind,

View File

@ -351,7 +351,7 @@ func TestAddUSageCreditNote(t *testing.T) {
tests := []struct { tests := []struct {
credits int32 credits int32
userId string userId string
note string description string
// expectations // expectations
expectedError bool expectedError bool
}{ }{
@ -366,7 +366,7 @@ func TestAddUSageCreditNote(t *testing.T) {
_, err := usageService.AddUsageCreditNote(context.Background(), &v1.AddUsageCreditNoteRequest{ _, err := usageService.AddUsageCreditNote(context.Background(), &v1.AddUsageCreditNoteRequest{
AttributionId: string(attributionID), AttributionId: string(attributionID),
Credits: test.credits, Credits: test.credits,
Note: test.note, Description: test.description,
UserId: test.userId, UserId: test.userId,
}) })
if test.expectedError { if test.expectedError {