feat(webapp): unconfigured billing limit UX and default billing alerts (#4328)

## Default billing alerts + billing limit page UX

- New orgs get default billing alerts: $5, $100, $500, $1000, $2500.
Existing orgs are backfilled by a billing-side data migration (companion
[PR](https://github.com/triggerdotdev/cloud/pull/1657)).
- The billing limit form starts with nothing selected for orgs that
never set a limit — the save button appears once an option is picked.
- The yellow banner now also shows on the billing limits page itself,
asking to configure a limit. Hidden everywhere for members who can't
manage billing.
- Also fixes billing limit alert preview.

Tests
- `apps/webapp/test/billingLimitsRoute.test.ts` — dirty logic for
empty/selected mode
- `apps/webapp/test/billingAlertsDefaults.test.ts` — default values
- `apps/webapp/test/billingAlertsFormat.test.ts` — preview after a limit
change
This commit is contained in:
Katia Bulatova
2026-07-22 21:23:52 +02:00
committed by GitHub
parent a2d382b2be
commit 23d5771d56
8 changed files with 198 additions and 19 deletions
@@ -0,0 +1,6 @@
---
area: webapp
type: improvement
---
Organizations without billing alerts now get default spend alert thresholds, so you're notified before usage grows unexpectedly. The billing limit page no longer pre-selects an option before you've set a limit and prompts you to configure one. Alert previews now update immediately after you change your billing limit.
@@ -7,6 +7,7 @@ import { z } from "zod";
import { getBillingLimitMode } from "~/components/billing/billingAlertsFormat";
import { formatGracePeriodMs } from "~/components/billing/billingLimitFormat";
import { AnimatedCallout } from "~/components/primitives/AnimatedCallout";
import { Callout } from "~/components/primitives/Callout";
import { Button } from "~/components/primitives/Buttons";
import { CheckboxWithLabel } from "~/components/primitives/Checkbox";
import { Fieldset } from "~/components/primitives/Fieldset";
@@ -51,10 +52,14 @@ type BillingLimitActionData = {
export function isBillingLimitFormDirty(input: {
billingLimit: BillingLimitResult;
mode: "none" | "plan" | "custom";
mode: "" | "none" | "plan" | "custom";
customAmount: string;
cancelInProgressRuns: boolean;
}): boolean {
if (input.mode === "") {
return false;
}
const needsInitialSave = !input.billingLimit.isConfigured;
const savedMode = getBillingLimitMode(input.billingLimit);
const savedCustomAmount =
@@ -75,7 +80,7 @@ export function isBillingLimitFormDirty(input: {
export function getBillingLimitFormLastSubmission(
submission: BillingLimitActionData["submission"] | undefined,
mode: "none" | "plan" | "custom",
mode: "" | "none" | "plan" | "custom",
isDirty: boolean
) {
if (!isDirty || !submission) {
@@ -111,17 +116,20 @@ export function BillingLimitConfigSection({
: "";
const savedCancelInProgressRuns = billingLimit.isConfigured && billingLimit.cancelInProgressRuns;
const [mode, setMode] = useState<"none" | "plan" | "custom">(savedMode);
// Unconfigured limit starts with nothing selected.
const resetMode: "" | "none" | "plan" | "custom" = billingLimit.isConfigured ? savedMode : "";
const [mode, setMode] = useState<"" | "none" | "plan" | "custom">(resetMode);
const [customAmount, setCustomAmount] = useState(savedCustomAmount);
const [cancelInProgressRuns, setCancelInProgressRuns] = useState(savedCancelInProgressRuns);
const customAmountInputRef = useRef<HTMLInputElement>(null);
const formRef = useRef<HTMLFormElement>(null);
useEffect(() => {
setMode(savedMode);
setMode(resetMode);
setCustomAmount(savedCustomAmount);
setCancelInProgressRuns(savedCancelInProgressRuns);
}, [savedMode, savedCustomAmount, savedCancelInProgressRuns]);
}, [resetMode, savedCustomAmount, savedCancelInProgressRuns]);
function handleModeChange(value: string) {
const nextMode = value as typeof mode;
@@ -183,6 +191,13 @@ export function BillingLimitConfigSection({
</Paragraph>
</div>
{!billingLimit.isConfigured && (
<Callout variant="warning" className="mb-3">
Configure a monthly billing limit below to cap your spend, or set no limit to let runs
keep going.
</Callout>
)}
<Form method="post" {...getFormProps(form)} ref={formRef}>
<input type="hidden" name="intent" value="billing-limit" />
<Fieldset>
@@ -283,7 +298,7 @@ export function BillingLimitConfigSection({
</div>
</RadioGroup>
{mode !== "none" && (
{(mode === "plan" || mode === "custom") && (
<CheckboxWithLabel
className="mt-4"
name="cancelInProgressRuns"
@@ -295,14 +310,16 @@ export function BillingLimitConfigSection({
onChange={setCancelInProgressRuns}
/>
)}
<FormButtons
className={isDirty ? undefined : "invisible"}
confirmButton={
<Button type="submit" variant="primary/small" disabled={!isDirty}>
Save billing limit
</Button>
}
/>
{mode !== "" && (
<FormButtons
className={isDirty ? undefined : "invisible"}
confirmButton={
<Button type="submit" variant="primary/small" disabled={!isDirty}>
Save billing limit
</Button>
}
/>
)}
</Fieldset>
</Form>
</div>
@@ -208,6 +208,11 @@ export function isLegacyDollarAmountField(
return false;
}
// The exact $1 absolute base marker always wins, even with levels below 100 (e.g. a $5 alert).
if (rawAmount === ABSOLUTE_ALERT_BASE_CENTS) {
return false;
}
if (!Number.isFinite(rawAmount) || rawAmount < 10) {
return false;
}
@@ -315,8 +320,9 @@ export function getAlertPreviewLimitCents(
planLimitCents: number
): number {
const amountCents = getSavedAlertAmountCents(alerts);
// Percentages always apply to the current limit, not the base stored at last save.
if (amountCents > 0 && percentageAlertLevelsToUiThresholds(alerts.alertLevels).length > 0) {
return amountCents;
return effectiveLimitCents;
}
if (percentageAlertAmountMatches(amountCents, effectiveLimitCents, planLimitCents)) {
return amountCents;
+38 -1
View File
@@ -6,6 +6,7 @@ import type {
RuntimeEnvironment,
User,
} from "@trigger.dev/database";
import { tryCatch } from "@trigger.dev/core/utils";
import { customAlphabet } from "nanoid";
import { generate } from "random-words";
import slug from "slug";
@@ -13,8 +14,14 @@ import { $replica, prisma, type PrismaClientOrTransaction } from "~/db.server";
import { env } from "~/env.server";
import { featuresForUrl } from "~/features.server";
import { createApiKeyForEnv, createPkApiKeyForEnv, envSlug } from "./api-key.server";
import { getDefaultEnvironmentConcurrencyLimit } from "~/services/platform.v3.server";
import {
getDefaultEnvironmentConcurrencyLimit,
isBillingConfigured,
setBillingAlert,
} from "~/services/platform.v3.server";
import { buildDefaultBillingAlerts } from "~/services/billingAlertsDefaults.server";
import { enqueueAttioWorkspaceSync } from "~/services/attio.server";
import { logger } from "~/services/logger.server";
import {
applyBillingLimitPauseAfterEnvCreate,
getInitialEnvPauseStateForBillingLimit,
@@ -122,9 +129,39 @@ export async function createOrganization(
adminUserId: userId,
});
// Awaited so the seed can't land after the user's first alert edit.
await seedDefaultBillingAlerts(organization.id);
return { ...organization };
}
// The platform client has no request timeout; don't let a slow billing backend stall org creation.
const SEED_ALERTS_TIMEOUT_MS = 5_000;
/** Seed default billing alerts for a new org. Never fails org creation. */
async function seedDefaultBillingAlerts(organizationId: string): Promise<void> {
if (!isBillingConfigured()) {
return;
}
let timer: NodeJS.Timeout | undefined;
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error("Timed out")), SEED_ALERTS_TIMEOUT_MS);
});
const [error] = await tryCatch(
Promise.race([setBillingAlert(organizationId, buildDefaultBillingAlerts()), timeout]).finally(
() => clearTimeout(timer)
)
);
if (error) {
logger.warn("Failed to seed default billing alerts for new org", {
organizationId,
error: error instanceof Error ? error.message : error,
});
}
}
export async function createEnvironment({
organization,
project,
@@ -0,0 +1,17 @@
import type { UpdateBillingAlertsRequest } from "@trigger.dev/platform";
import { ABSOLUTE_ALERT_BASE_CENTS } from "~/components/billing/billingAlertsFormat";
/** Default absolute alert thresholds in dollars. */
const DEFAULT_ALERT_THRESHOLD_DOLLARS = [5, 100, 500, 1000, 2500];
/**
* Alerts fire at `usage / amount >= level`; amount = 100 cents makes levels
* absolute dollar thresholds. Empty emails fall back to org admins.
*/
export function buildDefaultBillingAlerts(): UpdateBillingAlertsRequest {
return {
amount: ABSOLUTE_ALERT_BASE_CENTS,
emails: [],
alertLevels: [...DEFAULT_ALERT_THRESHOLD_DOLLARS],
};
}
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { buildDefaultBillingAlerts } from "~/services/billingAlertsDefaults.server";
import { ABSOLUTE_ALERT_BASE_CENTS } from "~/components/billing/billingAlertsFormat";
describe("buildDefaultBillingAlerts", () => {
it("uses the absolute dollar base so alert levels read as dollar thresholds", () => {
expect(buildDefaultBillingAlerts().amount).toBe(ABSOLUTE_ALERT_BASE_CENTS);
expect(buildDefaultBillingAlerts().amount).toBe(100);
});
it("starts with no recipients so the platform falls back to org members", () => {
expect(buildDefaultBillingAlerts().emails).toEqual([]);
});
it("seeds the default dollar alert thresholds", () => {
expect(buildDefaultBillingAlerts().alertLevels).toEqual([5, 100, 500, 1000, 2500]);
});
it("returns a fresh alertLevels array each call (no shared mutable state)", () => {
const first = buildDefaultBillingAlerts();
const second = buildDefaultBillingAlerts();
expect(first.alertLevels).not.toBe(second.alertLevels);
first.alertLevels.push(9999);
expect(second.alertLevels).toEqual([5, 100, 500, 1000, 2500]);
});
});
+32 -3
View File
@@ -90,6 +90,19 @@ describe("billingAlertsFormat", () => {
).toBe(10_000);
});
it("previews saved percentage alerts against the current limit after it changes", () => {
// Percentage alerts saved against a $30 custom limit, limit later raised to $300.
const alerts = { amount: 30, emails: [], alertLevels: [0.75, 1.0] };
expect(getAlertPreviewLimitCents(alerts, 30_000, 10_000)).toBe(30_000);
expect(
previewDollarAmountForPercent(100, getAlertPreviewLimitCents(alerts, 30_000, 10_000))
).toBe(300);
expect(
previewDollarAmountForPercent(75, getAlertPreviewLimitCents(alerts, 30_000, 10_000))
).toBe(225);
});
it("normalizes legacy API alerts with dollar amount field and whole percents", () => {
expect(
normalizeBillingAlertsFromApi(
@@ -150,6 +163,22 @@ describe("billingAlertsFormat", () => {
);
});
it("keeps seeded absolute defaults absolute when the plan limit is exactly $100", () => {
const normalized = normalizeBillingAlertsFromApi(
{
amount: 100,
emails: [],
alertLevels: [5, 100, 500, 1000, 2500],
},
{ planLimitCents: 10_000, effectiveLimitCents: 10_000 }
);
expect(normalized.amount).toBe(1);
expect(storedAlertsToThresholds(normalized, "none", 10_000, 10_000)).toEqual([
5, 100, 500, 1000, 2500,
]);
});
it("normalizes cents-based alerts with whole-number levels when amount matches limit", () => {
const normalized = normalizeBillingAlertsFromApi(
{
@@ -173,14 +202,14 @@ describe("billingAlertsFormat", () => {
expect(
normalizeBillingAlertsFromApi(
{
amount: 100,
amount: 300,
emails: [],
alertLevels: [10, 50, 80],
},
{ planLimitCents: 10_000, effectiveLimitCents: 10_000 }
{ planLimitCents: 30_000, effectiveLimitCents: 30_000 }
)
).toEqual({
amount: 100,
amount: 300,
emails: [],
alertLevels: [10, 50, 80],
});
@@ -266,6 +266,27 @@ describe("isBillingLimitFormDirty", () => {
gracePeriodMs: 86_400_000,
};
it("is not dirty when no mode is selected, regardless of other inputs", () => {
expect(
isBillingLimitFormDirty({
billingLimit: unconfiguredLimit,
mode: "",
customAmount: "999.00",
cancelInProgressRuns: true,
})
).toBe(false);
// Even a configured limit stays clean while the form has no selection.
expect(
isBillingLimitFormDirty({
billingLimit: configuredPlanLimit,
mode: "",
customAmount: "50.00",
cancelInProgressRuns: true,
})
).toBe(false);
});
it("is dirty when billing limit has never been saved", () => {
expect(
isBillingLimitFormDirty({
@@ -277,6 +298,26 @@ describe("isBillingLimitFormDirty", () => {
).toBe(true);
});
it("is dirty when an unconfigured limit picks a real mode (initial save)", () => {
expect(
isBillingLimitFormDirty({
billingLimit: unconfiguredLimit,
mode: "plan",
customAmount: "",
cancelInProgressRuns: false,
})
).toBe(true);
expect(
isBillingLimitFormDirty({
billingLimit: unconfiguredLimit,
mode: "custom",
customAmount: "100.00",
cancelInProgressRuns: false,
})
).toBe(true);
});
it("is clean when configured values match saved state", () => {
expect(
isBillingLimitFormDirty({