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:
@@ -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;
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user