Skip to content

Commit 23d5771

Browse files
authored
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](triggerdotdev/cloud#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
1 parent a2d382b commit 23d5771

8 files changed

Lines changed: 198 additions & 19 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: improvement
4+
---
5+
6+
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.

apps/webapp/app/components/billing/BillingLimitConfigSection.tsx

Lines changed: 31 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { z } from "zod";
77
import { getBillingLimitMode } from "~/components/billing/billingAlertsFormat";
88
import { formatGracePeriodMs } from "~/components/billing/billingLimitFormat";
99
import { AnimatedCallout } from "~/components/primitives/AnimatedCallout";
10+
import { Callout } from "~/components/primitives/Callout";
1011
import { Button } from "~/components/primitives/Buttons";
1112
import { CheckboxWithLabel } from "~/components/primitives/Checkbox";
1213
import { Fieldset } from "~/components/primitives/Fieldset";
@@ -51,10 +52,14 @@ type BillingLimitActionData = {
5152

5253
export function isBillingLimitFormDirty(input: {
5354
billingLimit: BillingLimitResult;
54-
mode: "none" | "plan" | "custom";
55+
mode: "" | "none" | "plan" | "custom";
5556
customAmount: string;
5657
cancelInProgressRuns: boolean;
5758
}): boolean {
59+
if (input.mode === "") {
60+
return false;
61+
}
62+
5863
const needsInitialSave = !input.billingLimit.isConfigured;
5964
const savedMode = getBillingLimitMode(input.billingLimit);
6065
const savedCustomAmount =
@@ -75,7 +80,7 @@ export function isBillingLimitFormDirty(input: {
7580

7681
export function getBillingLimitFormLastSubmission(
7782
submission: BillingLimitActionData["submission"] | undefined,
78-
mode: "none" | "plan" | "custom",
83+
mode: "" | "none" | "plan" | "custom",
7984
isDirty: boolean
8085
) {
8186
if (!isDirty || !submission) {
@@ -111,17 +116,20 @@ export function BillingLimitConfigSection({
111116
: "";
112117
const savedCancelInProgressRuns = billingLimit.isConfigured && billingLimit.cancelInProgressRuns;
113118

114-
const [mode, setMode] = useState<"none" | "plan" | "custom">(savedMode);
119+
// Unconfigured limit starts with nothing selected.
120+
const resetMode: "" | "none" | "plan" | "custom" = billingLimit.isConfigured ? savedMode : "";
121+
122+
const [mode, setMode] = useState<"" | "none" | "plan" | "custom">(resetMode);
115123
const [customAmount, setCustomAmount] = useState(savedCustomAmount);
116124
const [cancelInProgressRuns, setCancelInProgressRuns] = useState(savedCancelInProgressRuns);
117125
const customAmountInputRef = useRef<HTMLInputElement>(null);
118126
const formRef = useRef<HTMLFormElement>(null);
119127

120128
useEffect(() => {
121-
setMode(savedMode);
129+
setMode(resetMode);
122130
setCustomAmount(savedCustomAmount);
123131
setCancelInProgressRuns(savedCancelInProgressRuns);
124-
}, [savedMode, savedCustomAmount, savedCancelInProgressRuns]);
132+
}, [resetMode, savedCustomAmount, savedCancelInProgressRuns]);
125133

126134
function handleModeChange(value: string) {
127135
const nextMode = value as typeof mode;
@@ -183,6 +191,13 @@ export function BillingLimitConfigSection({
183191
</Paragraph>
184192
</div>
185193

194+
{!billingLimit.isConfigured && (
195+
<Callout variant="warning" className="mb-3">
196+
Configure a monthly billing limit below to cap your spend, or set no limit to let runs
197+
keep going.
198+
</Callout>
199+
)}
200+
186201
<Form method="post" {...getFormProps(form)} ref={formRef}>
187202
<input type="hidden" name="intent" value="billing-limit" />
188203
<Fieldset>
@@ -283,7 +298,7 @@ export function BillingLimitConfigSection({
283298
</div>
284299
</RadioGroup>
285300

286-
{mode !== "none" && (
301+
{(mode === "plan" || mode === "custom") && (
287302
<CheckboxWithLabel
288303
className="mt-4"
289304
name="cancelInProgressRuns"
@@ -295,14 +310,16 @@ export function BillingLimitConfigSection({
295310
onChange={setCancelInProgressRuns}
296311
/>
297312
)}
298-
<FormButtons
299-
className={isDirty ? undefined : "invisible"}
300-
confirmButton={
301-
<Button type="submit" variant="primary/small" disabled={!isDirty}>
302-
Save billing limit
303-
</Button>
304-
}
305-
/>
313+
{mode !== "" && (
314+
<FormButtons
315+
className={isDirty ? undefined : "invisible"}
316+
confirmButton={
317+
<Button type="submit" variant="primary/small" disabled={!isDirty}>
318+
Save billing limit
319+
</Button>
320+
}
321+
/>
322+
)}
306323
</Fieldset>
307324
</Form>
308325
</div>

apps/webapp/app/components/billing/billingAlertsFormat.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,11 @@ export function isLegacyDollarAmountField(
208208
return false;
209209
}
210210

211+
// The exact $1 absolute base marker always wins, even with levels below 100 (e.g. a $5 alert).
212+
if (rawAmount === ABSOLUTE_ALERT_BASE_CENTS) {
213+
return false;
214+
}
215+
211216
if (!Number.isFinite(rawAmount) || rawAmount < 10) {
212217
return false;
213218
}
@@ -315,8 +320,9 @@ export function getAlertPreviewLimitCents(
315320
planLimitCents: number
316321
): number {
317322
const amountCents = getSavedAlertAmountCents(alerts);
323+
// Percentages always apply to the current limit, not the base stored at last save.
318324
if (amountCents > 0 && percentageAlertLevelsToUiThresholds(alerts.alertLevels).length > 0) {
319-
return amountCents;
325+
return effectiveLimitCents;
320326
}
321327
if (percentageAlertAmountMatches(amountCents, effectiveLimitCents, planLimitCents)) {
322328
return amountCents;

apps/webapp/app/models/organization.server.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,22 @@ import type {
66
RuntimeEnvironment,
77
User,
88
} from "@trigger.dev/database";
9+
import { tryCatch } from "@trigger.dev/core/utils";
910
import { customAlphabet } from "nanoid";
1011
import { generate } from "random-words";
1112
import slug from "slug";
1213
import { $replica, prisma, type PrismaClientOrTransaction } from "~/db.server";
1314
import { env } from "~/env.server";
1415
import { featuresForUrl } from "~/features.server";
1516
import { createApiKeyForEnv, createPkApiKeyForEnv, envSlug } from "./api-key.server";
16-
import { getDefaultEnvironmentConcurrencyLimit } from "~/services/platform.v3.server";
17+
import {
18+
getDefaultEnvironmentConcurrencyLimit,
19+
isBillingConfigured,
20+
setBillingAlert,
21+
} from "~/services/platform.v3.server";
22+
import { buildDefaultBillingAlerts } from "~/services/billingAlertsDefaults.server";
1723
import { enqueueAttioWorkspaceSync } from "~/services/attio.server";
24+
import { logger } from "~/services/logger.server";
1825
import {
1926
applyBillingLimitPauseAfterEnvCreate,
2027
getInitialEnvPauseStateForBillingLimit,
@@ -122,9 +129,39 @@ export async function createOrganization(
122129
adminUserId: userId,
123130
});
124131

132+
// Awaited so the seed can't land after the user's first alert edit.
133+
await seedDefaultBillingAlerts(organization.id);
134+
125135
return { ...organization };
126136
}
127137

138+
// The platform client has no request timeout; don't let a slow billing backend stall org creation.
139+
const SEED_ALERTS_TIMEOUT_MS = 5_000;
140+
141+
/** Seed default billing alerts for a new org. Never fails org creation. */
142+
async function seedDefaultBillingAlerts(organizationId: string): Promise<void> {
143+
if (!isBillingConfigured()) {
144+
return;
145+
}
146+
147+
let timer: NodeJS.Timeout | undefined;
148+
const timeout = new Promise<never>((_, reject) => {
149+
timer = setTimeout(() => reject(new Error("Timed out")), SEED_ALERTS_TIMEOUT_MS);
150+
});
151+
152+
const [error] = await tryCatch(
153+
Promise.race([setBillingAlert(organizationId, buildDefaultBillingAlerts()), timeout]).finally(
154+
() => clearTimeout(timer)
155+
)
156+
);
157+
if (error) {
158+
logger.warn("Failed to seed default billing alerts for new org", {
159+
organizationId,
160+
error: error instanceof Error ? error.message : error,
161+
});
162+
}
163+
}
164+
128165
export async function createEnvironment({
129166
organization,
130167
project,
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import type { UpdateBillingAlertsRequest } from "@trigger.dev/platform";
2+
import { ABSOLUTE_ALERT_BASE_CENTS } from "~/components/billing/billingAlertsFormat";
3+
4+
/** Default absolute alert thresholds in dollars. */
5+
const DEFAULT_ALERT_THRESHOLD_DOLLARS = [5, 100, 500, 1000, 2500];
6+
7+
/**
8+
* Alerts fire at `usage / amount >= level`; amount = 100 cents makes levels
9+
* absolute dollar thresholds. Empty emails fall back to org admins.
10+
*/
11+
export function buildDefaultBillingAlerts(): UpdateBillingAlertsRequest {
12+
return {
13+
amount: ABSOLUTE_ALERT_BASE_CENTS,
14+
emails: [],
15+
alertLevels: [...DEFAULT_ALERT_THRESHOLD_DOLLARS],
16+
};
17+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { describe, expect, it } from "vitest";
2+
import { buildDefaultBillingAlerts } from "~/services/billingAlertsDefaults.server";
3+
import { ABSOLUTE_ALERT_BASE_CENTS } from "~/components/billing/billingAlertsFormat";
4+
5+
describe("buildDefaultBillingAlerts", () => {
6+
it("uses the absolute dollar base so alert levels read as dollar thresholds", () => {
7+
expect(buildDefaultBillingAlerts().amount).toBe(ABSOLUTE_ALERT_BASE_CENTS);
8+
expect(buildDefaultBillingAlerts().amount).toBe(100);
9+
});
10+
11+
it("starts with no recipients so the platform falls back to org members", () => {
12+
expect(buildDefaultBillingAlerts().emails).toEqual([]);
13+
});
14+
15+
it("seeds the default dollar alert thresholds", () => {
16+
expect(buildDefaultBillingAlerts().alertLevels).toEqual([5, 100, 500, 1000, 2500]);
17+
});
18+
19+
it("returns a fresh alertLevels array each call (no shared mutable state)", () => {
20+
const first = buildDefaultBillingAlerts();
21+
const second = buildDefaultBillingAlerts();
22+
expect(first.alertLevels).not.toBe(second.alertLevels);
23+
first.alertLevels.push(9999);
24+
expect(second.alertLevels).toEqual([5, 100, 500, 1000, 2500]);
25+
});
26+
});

apps/webapp/test/billingAlertsFormat.test.ts

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,19 @@ describe("billingAlertsFormat", () => {
9090
).toBe(10_000);
9191
});
9292

93+
it("previews saved percentage alerts against the current limit after it changes", () => {
94+
// Percentage alerts saved against a $30 custom limit, limit later raised to $300.
95+
const alerts = { amount: 30, emails: [], alertLevels: [0.75, 1.0] };
96+
97+
expect(getAlertPreviewLimitCents(alerts, 30_000, 10_000)).toBe(30_000);
98+
expect(
99+
previewDollarAmountForPercent(100, getAlertPreviewLimitCents(alerts, 30_000, 10_000))
100+
).toBe(300);
101+
expect(
102+
previewDollarAmountForPercent(75, getAlertPreviewLimitCents(alerts, 30_000, 10_000))
103+
).toBe(225);
104+
});
105+
93106
it("normalizes legacy API alerts with dollar amount field and whole percents", () => {
94107
expect(
95108
normalizeBillingAlertsFromApi(
@@ -150,6 +163,22 @@ describe("billingAlertsFormat", () => {
150163
);
151164
});
152165

166+
it("keeps seeded absolute defaults absolute when the plan limit is exactly $100", () => {
167+
const normalized = normalizeBillingAlertsFromApi(
168+
{
169+
amount: 100,
170+
emails: [],
171+
alertLevels: [5, 100, 500, 1000, 2500],
172+
},
173+
{ planLimitCents: 10_000, effectiveLimitCents: 10_000 }
174+
);
175+
176+
expect(normalized.amount).toBe(1);
177+
expect(storedAlertsToThresholds(normalized, "none", 10_000, 10_000)).toEqual([
178+
5, 100, 500, 1000, 2500,
179+
]);
180+
});
181+
153182
it("normalizes cents-based alerts with whole-number levels when amount matches limit", () => {
154183
const normalized = normalizeBillingAlertsFromApi(
155184
{
@@ -173,14 +202,14 @@ describe("billingAlertsFormat", () => {
173202
expect(
174203
normalizeBillingAlertsFromApi(
175204
{
176-
amount: 100,
205+
amount: 300,
177206
emails: [],
178207
alertLevels: [10, 50, 80],
179208
},
180-
{ planLimitCents: 10_000, effectiveLimitCents: 10_000 }
209+
{ planLimitCents: 30_000, effectiveLimitCents: 30_000 }
181210
)
182211
).toEqual({
183-
amount: 100,
212+
amount: 300,
184213
emails: [],
185214
alertLevels: [10, 50, 80],
186215
});

apps/webapp/test/billingLimitsRoute.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,27 @@ describe("isBillingLimitFormDirty", () => {
266266
gracePeriodMs: 86_400_000,
267267
};
268268

269+
it("is not dirty when no mode is selected, regardless of other inputs", () => {
270+
expect(
271+
isBillingLimitFormDirty({
272+
billingLimit: unconfiguredLimit,
273+
mode: "",
274+
customAmount: "999.00",
275+
cancelInProgressRuns: true,
276+
})
277+
).toBe(false);
278+
279+
// Even a configured limit stays clean while the form has no selection.
280+
expect(
281+
isBillingLimitFormDirty({
282+
billingLimit: configuredPlanLimit,
283+
mode: "",
284+
customAmount: "50.00",
285+
cancelInProgressRuns: true,
286+
})
287+
).toBe(false);
288+
});
289+
269290
it("is dirty when billing limit has never been saved", () => {
270291
expect(
271292
isBillingLimitFormDirty({
@@ -277,6 +298,26 @@ describe("isBillingLimitFormDirty", () => {
277298
).toBe(true);
278299
});
279300

301+
it("is dirty when an unconfigured limit picks a real mode (initial save)", () => {
302+
expect(
303+
isBillingLimitFormDirty({
304+
billingLimit: unconfiguredLimit,
305+
mode: "plan",
306+
customAmount: "",
307+
cancelInProgressRuns: false,
308+
})
309+
).toBe(true);
310+
311+
expect(
312+
isBillingLimitFormDirty({
313+
billingLimit: unconfiguredLimit,
314+
mode: "custom",
315+
customAmount: "100.00",
316+
cancelInProgressRuns: false,
317+
})
318+
).toBe(true);
319+
});
320+
280321
it("is clean when configured values match saved state", () => {
281322
expect(
282323
isBillingLimitFormDirty({

0 commit comments

Comments
 (0)