Usage billing alerts (#2323)

* First draft billing alerts page

* Budget alert form working

* Don't let free plan users change the billing alert amount

* Fix missing key in map in the form

* Disable queues/org from admin API endpoint

* Don't allow resuming if runsEnabled is false

* Refer to "Billing alerts" not "Plans"

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Form missing dependencies fix

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Deal with thrown errors, fix for duplicating email fields

* Added a RuntimeEnvironment organizationId index

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
Matt Aitken
2025-07-30 13:48:02 +01:00
committed by GitHub
parent 14dcc76f93
commit 8b31871998
17 changed files with 512 additions and 35 deletions
@@ -30,8 +30,8 @@ export function UpgradePrompt() {
<Icon icon={ExclamationCircleIcon} className="h-5 w-5 text-error" />
<Paragraph variant="small" className="text-error">
You have exceeded the monthly $
{(plan.v3Subscription?.plan?.limits.includedUsage ?? 500) / 100} free credits. No runs
will execute in Prod until{" "}
{(plan.v3Subscription?.plan?.limits.includedUsage ?? 500) / 100} free credits. Existing
runs will be queued and new runs won't be created until{" "}
<DateTime date={nextMonth} includeTime={false} timeZone="utc" />, or you upgrade.
</Paragraph>
</div>
@@ -1,4 +1,5 @@
import {
BellAlertIcon,
ChartBarIcon,
Cog8ToothIcon,
CreditCardIcon,
@@ -12,6 +13,7 @@ import {
organizationSettingsPath,
organizationTeamPath,
rootPath,
v3BillingAlertsPath,
v3BillingPath,
v3UsagePath,
} from "~/utils/pathBuilder";
@@ -67,27 +69,34 @@ export function OrganizationSettingsSideMenu({
<SideMenuHeader title="Organization" />
</div>
{isManagedCloud && (
<SideMenuItem
name="Usage"
icon={ChartBarIcon}
activeIconColor="text-indigo-500"
to={v3UsagePath(organization)}
data-action="usage"
/>
)}
{isManagedCloud && (
<SideMenuItem
name="Billing"
icon={CreditCardIcon}
activeIconColor="text-emerald-500"
to={v3BillingPath(organization)}
data-action="billing"
badge={
currentPlan?.v3Subscription?.isPaying ? (
<Badge variant="extra-small">{currentPlan?.v3Subscription?.plan?.title}</Badge>
) : undefined
}
/>
<>
<SideMenuItem
name="Usage"
icon={ChartBarIcon}
activeIconColor="text-indigo-500"
to={v3UsagePath(organization)}
data-action="usage"
/>
<SideMenuItem
name="Billing"
icon={CreditCardIcon}
activeIconColor="text-emerald-500"
to={v3BillingPath(organization)}
data-action="billing"
badge={
currentPlan?.v3Subscription?.isPaying ? (
<Badge variant="extra-small">{currentPlan?.v3Subscription?.plan?.title}</Badge>
) : undefined
}
/>
<SideMenuItem
name="Billing alerts"
icon={BellAlertIcon}
activeIconColor="text-rose-500"
to={v3BillingAlertsPath(organization)}
data-action="billing-alerts"
/>
</>
)}
<SideMenuItem
name="Team"
@@ -7,7 +7,7 @@ const containerBase =
"has-[:focus-visible]:outline-none has-[:focus-visible]:ring-1 has-[:focus-visible]:ring-charcoal-650 has-[:focus-visible]:ring-offset-0 has-[:focus]:border-ring has-[:focus]:outline-none has-[:focus]:ring-1 has-[:focus]:ring-ring has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-50 ring-offset-background transition cursor-text";
const inputBase =
"h-full w-full text-text-bright bg-transparent file:border-0 file:bg-transparent file:text-base file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-0 disabled:cursor-not-allowed outline-none ring-0 border-none";
"h-full w-full text-text-bright bg-transparent file:border-0 file:bg-transparent file:text-base file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-0 disabled:cursor-not-allowed outline-none ring-0 border-none [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:m-0 [&::-webkit-inner-spin-button]:m-0 [&]:[-moz-appearance:textfield]";
const variants = {
large: {
@@ -8,6 +8,7 @@ export type Environment = {
queued: number;
concurrencyLimit: number;
burstFactor: number;
runsEnabled: boolean;
};
export class EnvironmentQueuePresenter extends BasePresenter {
@@ -23,11 +24,25 @@ export class EnvironmentQueuePresenter extends BasePresenter {
const running = (engineV1Executing ?? 0) + (engineV2Executing ?? 0);
const queued = (engineV1Queued ?? 0) + (engineV2Queued ?? 0);
const organization = await this._replica.organization.findFirst({
where: {
id: environment.organizationId,
},
select: {
runsEnabled: true,
},
});
if (!organization) {
throw new Error("Organization not found");
}
return {
running,
queued,
concurrencyLimit: environment.maximumConcurrencyLimit,
burstFactor: environment.concurrencyLimitBurstFactor.toNumber(),
runsEnabled: environment.type === "DEVELOPMENT" || organization.runsEnabled,
};
}
}
@@ -285,7 +285,7 @@ export default function Page() {
>
View runs
</LinkButton>
<EnvironmentPauseResumeButton env={env} />
{environment.runsEnabled ? <EnvironmentPauseResumeButton env={env} /> : null}
</div>
}
valueClassName={env.paused ? "text-warning" : undefined}
@@ -0,0 +1,303 @@
import { conform, list, requestIntent, useFieldList, useForm } from "@conform-to/react";
import { parse } from "@conform-to/zod";
import { Form, useActionData, type MetaFunction } from "@remix-run/react";
import { json, type ActionFunction, type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { Fragment, useEffect, useRef, useState } from "react";
import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
import {
MainHorizontallyCenteredContainer,
PageBody,
PageContainer,
} from "~/components/layout/AppLayout";
import { Button } from "~/components/primitives/Buttons";
import { CheckboxWithLabel } from "~/components/primitives/Checkbox";
import { Fieldset } from "~/components/primitives/Fieldset";
import { FormButtons } from "~/components/primitives/FormButtons";
import { FormError } from "~/components/primitives/FormError";
import { Header2 } from "~/components/primitives/Headers";
import { Input } from "~/components/primitives/Input";
import { InputGroup } from "~/components/primitives/InputGroup";
import { Label } from "~/components/primitives/Label";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
import { Paragraph } from "~/components/primitives/Paragraph";
import { prisma } from "~/db.server";
import { featuresForRequest } from "~/features.server";
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
import { getBillingAlerts, setBillingAlert } from "~/services/platform.v3.server";
import { requireUserId } from "~/services/session.server";
import { formatCurrency } from "~/utils/numberFormatter";
import {
OrganizationParamsSchema,
organizationPath,
v3BillingAlertsPath,
} from "~/utils/pathBuilder";
import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
import { tryCatch } from "@trigger.dev/core";
export const meta: MetaFunction = () => {
return [
{
title: `Billing alerts | Trigger.dev`,
},
];
};
export async function loader({ params, request }: LoaderFunctionArgs) {
await requireUserId(request);
const { organizationSlug } = OrganizationParamsSchema.parse(params);
const { isManagedCloud } = featuresForRequest(request);
if (!isManagedCloud) {
return redirect(organizationPath({ slug: organizationSlug }));
}
const organization = await prisma.organization.findUnique({
where: { slug: organizationSlug },
});
if (!organization) {
throw new Response(null, { status: 404, statusText: "Organization not found" });
}
const [error, alerts] = await tryCatch(getBillingAlerts(organization.id));
if (error) {
throw new Response(null, { status: 404, statusText: `Billing alerts error: ${error}` });
}
if (!alerts) {
throw new Response(null, { status: 404, statusText: "Billing alerts not found" });
}
return typedjson({
alerts: {
...alerts,
amount: alerts.amount / 100,
},
});
}
const schema = z.object({
amount: z
.number({ invalid_type_error: "Not a valid amount" })
.min(0, "Amount must be greater than 0"),
emails: z.preprocess((i) => {
if (typeof i === "string") return [i];
if (Array.isArray(i)) {
const emails = i.filter((v) => typeof v === "string" && v !== "");
if (emails.length === 0) {
return [""];
}
return emails;
}
return [""];
}, z.string().email().array().nonempty("At least one email is required")),
alertLevels: z.preprocess((i) => {
if (typeof i === "string") return [i];
return i;
}, z.coerce.number().array().nonempty("At least one alert level is required")),
});
export const action: ActionFunction = async ({ request, params }) => {
const userId = await requireUserId(request);
const { organizationSlug } = OrganizationParamsSchema.parse(params);
const formData = await request.formData();
const submission = parse(formData, { schema });
if (!submission.value || submission.intent !== "submit") {
return json(submission);
}
try {
const organization = await prisma.organization.findFirst({
where: { slug: organizationSlug, members: { some: { userId } } },
});
if (!organization) {
return redirectWithErrorMessage(
v3BillingAlertsPath({ slug: organizationSlug }),
request,
"You are not authorized to update billing alerts"
);
}
const [error, updatedAlert] = await tryCatch(
setBillingAlert(organization.id, {
...submission.value,
amount: submission.value.amount * 100,
})
);
if (error) {
return redirectWithErrorMessage(
v3BillingAlertsPath({ slug: organizationSlug }),
request,
"Failed to update billing alert"
);
}
if (!updatedAlert) {
return redirectWithErrorMessage(
v3BillingAlertsPath({ slug: organizationSlug }),
request,
"Failed to update billing alert"
);
}
return redirectWithSuccessMessage(
v3BillingAlertsPath({ slug: organizationSlug }),
request,
"Billing alert updated"
);
} catch (error: any) {
return json({ errors: { body: error.message } }, { status: 400 });
}
};
export default function Page() {
const { alerts } = useTypedLoaderData<typeof loader>();
const plan = useCurrentPlan();
const [dollarAmount, setDollarAmount] = useState(alerts.amount.toFixed(2));
const lastSubmission = useActionData();
const [form, { emails, amount, alertLevels }] = useForm({
id: "invite-members",
// TODO: type this
lastSubmission: lastSubmission as any,
onValidate({ formData }) {
return parse(formData, { schema });
},
defaultValue: {
emails: [""],
},
});
const fieldValues = useRef<string[]>(alerts.emails);
const emailFields = useFieldList(form.ref, { ...emails, defaultValue: alerts.emails });
const checkboxLevels = [0.75, 0.9, 1.0];
useEffect(() => {
if (alerts.emails.length > 0) {
requestIntent(form.ref.current ?? undefined, list.append(emails.name));
}
}, [emails.name, form.ref]);
const isFree = !plan?.v3Subscription?.isPaying;
return (
<PageContainer>
<NavBar>
<PageTitle title="Billing alerts" />
<PageAccessories>
<AdminDebugTooltip />
</PageAccessories>
</NavBar>
<PageBody scrollable={true}>
<MainHorizontallyCenteredContainer>
<div>
<Header2 spacing>Billing alerts</Header2>
<Paragraph spacing variant="small">
Receive an email when your compute spend crosses different thresholds.
</Paragraph>
<Form method="post" {...form.props}>
<Fieldset>
<InputGroup fullWidth>
<Label htmlFor={amount.id}>Amount</Label>
{isFree ? (
<>
<Paragraph variant="small" className="text-text-dimmed">
${dollarAmount}
</Paragraph>
<input type="hidden" name={amount.name} value={dollarAmount} />
</>
) : (
<Input
{...conform.input(amount, { type: "number" })}
value={dollarAmount}
onChange={(e) => {
const numberValue = Number(e.target.value);
if (numberValue < 0) {
setDollarAmount("");
return;
}
setDollarAmount(e.target.value);
}}
step={0.01}
min={0}
placeholder="Enter an amount"
icon={
<span className="-mt-0.5 block pl-0.5 text-sm text-text-dimmed">$</span>
}
className="pl-px"
fullWidth
readOnly={isFree}
/>
)}
<FormError id={amount.errorId}>{amount.error}</FormError>
</InputGroup>
<InputGroup fullWidth>
<Label htmlFor={alertLevels.id}>Alert me when I reach</Label>
{checkboxLevels.map((level) => (
<CheckboxWithLabel
name={alertLevels.name}
id={`level_${level}`}
key={level}
value={level.toString()}
variant="simple/small"
label={
<span>
{level * 100}%{" "}
<span className="text-text-dimmed">
({formatCurrency(Number(dollarAmount) * level, false)})
</span>
</span>
}
defaultChecked={alerts.alertLevels.includes(level)}
className="pr-0"
readOnly={level === 1.0}
/>
))}
<FormError id={alertLevels.errorId}>{alertLevels.error}</FormError>
</InputGroup>
<InputGroup fullWidth>
<Label htmlFor={emails.id}>Email addresses</Label>
{emailFields.map((email, index) => (
<Fragment key={email.key}>
<Input
{...conform.input(email, { type: "email" })}
placeholder={index === 0 ? "Enter an email address" : "Add another email"}
autoFocus={index === 0}
onChange={(e) => {
fieldValues.current[index] = e.target.value;
if (
emailFields.length === fieldValues.current.length &&
fieldValues.current.every((v) => v !== "")
) {
requestIntent(form.ref.current ?? undefined, list.append(emails.name));
}
}}
fullWidth
/>
<FormError id={email.errorId}>{email.error}</FormError>
</Fragment>
))}
</InputGroup>
<FormButtons
confirmButton={
<Button type="submit" variant={"primary/small"}>
Update
</Button>
}
/>
</Fieldset>
</Form>
</div>
</MainHorizontallyCenteredContainer>
</PageBody>
</PageContainer>
);
}
@@ -1,6 +1,6 @@
import { CalendarDaysIcon, StarIcon } from "@heroicons/react/20/solid";
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { type PlanDefinition } from "@trigger.dev/platform/v3";
import { type PlanDefinition } from "@trigger.dev/platform";
import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
import { LinkButton } from "~/components/primitives/Buttons";
@@ -0,0 +1,97 @@
import { type ActionFunctionArgs, json } from "@remix-run/server-runtime";
import {
type RuntimeEnvironment,
type Organization,
type Project,
type RuntimeEnvironmentType,
} from "@trigger.dev/database";
import { z } from "zod";
import { prisma } from "~/db.server";
import { createEnvironment } from "~/models/organization.server";
import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server";
import { updateEnvConcurrencyLimits } from "~/v3/runQueue.server";
import { PauseEnvironmentService } from "~/v3/services/pauseEnvironment.server";
const ParamsSchema = z.object({
organizationId: z.string(),
});
const BodySchema = z.object({
enable: z.boolean(),
});
/**
* It will enabled/disable runs
*/
export async function action({ request, params }: ActionFunctionArgs) {
// Next authenticate the request
const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request);
if (!authenticationResult) {
return json({ error: "Invalid or Missing API key" }, { status: 401 });
}
const user = await prisma.user.findUnique({
where: {
id: authenticationResult.userId,
},
});
if (!user) {
return json({ error: "Invalid or Missing API key" }, { status: 401 });
}
if (!user.admin) {
return json({ error: "You must be an admin to perform this action" }, { status: 403 });
}
const { organizationId } = ParamsSchema.parse(params);
const body = BodySchema.safeParse(await request.json());
if (!body.success) {
return json({ error: "Invalid request body", details: body.error }, { status: 400 });
}
const organization = await prisma.organization.update({
where: {
id: organizationId,
},
data: {
runsEnabled: body.data.enable,
},
});
if (!organization) {
return json({ error: "Organization not found" }, { status: 404 });
}
const environments = await prisma.runtimeEnvironment.findMany({
where: {
organizationId,
type: {
not: "DEVELOPMENT",
},
},
include: {
organization: true,
project: true,
},
});
const pauseEnvironmentService = new PauseEnvironmentService();
// Set the organization.runsEnabled flag to false
for (const environment of environments) {
if (body.data.enable) {
await pauseEnvironmentService.call({ ...environment, organization }, "resumed");
} else {
await pauseEnvironmentService.call({ ...environment, organization }, "paused");
}
}
return json({
success: true,
message: `${environments.length} environments updated to ${
body.data.enable ? "enabled" : "disabled"
}`,
});
}
@@ -17,7 +17,7 @@ import {
Plans,
SetPlanBody,
SubscriptionResult,
} from "@trigger.dev/platform/v3";
} from "@trigger.dev/platform";
import React, { useEffect, useState } from "react";
import { z } from "zod";
import { DefinitionTip } from "~/components/DefinitionTooltip";
+28 -1
View File
@@ -8,7 +8,9 @@ import {
defaultMachine as defaultMachineFromPlatform,
machines as machinesFromPlatform,
type MachineCode,
} from "@trigger.dev/platform/v3";
type UpdateBillingAlertsRequest,
type BillingAlertsResult,
} from "@trigger.dev/platform";
import { createCache, DefaultStatefulContext, Namespace } from "@unkey/cache";
import { MemoryStore } from "@unkey/cache/stores";
import { redirect } from "remix-typedjson";
@@ -467,6 +469,31 @@ export async function projectCreated(organization: Organization, project: Projec
}
}
export async function getBillingAlerts(
organizationId: string
): Promise<BillingAlertsResult | undefined> {
if (!client) return undefined;
const result = await client.getBillingAlerts(organizationId);
if (!result.success) {
logger.error("Error getting billing alert", { error: result.error, organizationId });
throw new Error("Error getting billing alert");
}
return result;
}
export async function setBillingAlert(
organizationId: string,
alert: UpdateBillingAlertsRequest
): Promise<BillingAlertsResult | undefined> {
if (!client) return undefined;
const result = await client.updateBillingAlerts(organizationId, alert);
if (!result.success) {
logger.error("Error setting billing alert", { error: result.error, organizationId });
throw new Error("Error setting billing alert");
}
return result;
}
function isCloud(): boolean {
const acceptableHosts = [
"https://cloud.trigger.dev",
+4
View File
@@ -459,6 +459,10 @@ export function v3BillingPath(organization: OrgForPath, message?: string) {
}`;
}
export function v3BillingAlertsPath(organization: OrgForPath) {
return `${organizationPath(organization)}/settings/billing-alerts`;
}
export function v3StripePortalPath(organization: OrgForPath) {
return `/resources/${organization.slug}/subscription/portal`;
}
@@ -27,6 +27,25 @@ export class PauseEnvironmentService extends WithRunEngine {
action: PauseStatus
): Promise<PauseEnvironmentResult> {
try {
const org = await this._prisma.organization.findFirst({
where: {
id: environment.organizationId,
},
select: {
runsEnabled: true,
},
});
if (!org) {
throw new Error("Organization not found");
}
if (!org.runsEnabled && action === "resumed") {
throw new Error(
"Runs are disabled for this organization. Your free plan has probably been exceeded. If not please contact support."
);
}
await this._prisma.runtimeEnvironment.update({
where: {
id: environment.id,
+2 -2
View File
@@ -113,7 +113,7 @@
"@trigger.dev/core": "workspace:*",
"@trigger.dev/database": "workspace:*",
"@trigger.dev/otlp-importer": "workspace:*",
"@trigger.dev/platform": "1.0.15",
"@trigger.dev/platform": "1.0.17",
"@trigger.dev/redis-worker": "workspace:*",
"@trigger.dev/sdk": "workspace:*",
"@types/pg": "8.6.6",
@@ -278,4 +278,4 @@
"engines": {
"node": ">=16.0.0"
}
}
}
+1 -1
View File
@@ -23,7 +23,7 @@ pnpm run docker
### How to add a new index on a large table
1. Modify the Prisma.schema with a single index change (no other changes, just one index at a time)
2. Create a Prisma migration using `cd internal-packages/database && pnpm run db:migrate:dev --create-only`
2. Create a Prisma migration using `cd internal-packages/database && pnpm run db:migrate:dev:create`
3. Modify the SQL file: add IF NOT EXISTS to it and CONCURRENTLY:
```sql
@@ -0,0 +1,2 @@
-- CreateIndex
CREATE INDEX CONCURRENTLY IF NOT EXISTS "RuntimeEnvironment_organizationId_idx" ON "RuntimeEnvironment" ("organizationId");
@@ -300,6 +300,7 @@ model RuntimeEnvironment {
@@unique([projectId, shortcode])
@@index([parentEnvironmentId])
@@index([projectId])
@@index([organizationId])
}
enum RuntimeEnvironmentType {
+4 -4
View File
@@ -441,8 +441,8 @@ importers:
specifier: workspace:*
version: link:../../internal-packages/otlp-importer
'@trigger.dev/platform':
specifier: 1.0.15
version: 1.0.15
specifier: 1.0.17
version: 1.0.17
'@trigger.dev/redis-worker':
specifier: workspace:*
version: link:../../packages/redis-worker
@@ -19571,8 +19571,8 @@ packages:
react-dom: 18.2.0(react@18.2.0)
dev: false
/@trigger.dev/platform@1.0.15:
resolution: {integrity: sha512-rorRJJl7ecyiO8iQZcHGlXR00bTzm7e1xZt0ddCYJFhaQjxq2bo2oen5DVxUbLZsE2cp60ipQWFrmAipFwK79Q==}
/@trigger.dev/platform@1.0.17:
resolution: {integrity: sha512-cR05nn8HnP03h/bmRN6O/EKgvQncbs3Y/7fp1QboEDWn6rJTRrWJpZVrA3ZQ32SIW1qvHuZLcB1OVaEsJk2wjA==}
dependencies:
zod: 3.23.8
dev: false