diff --git a/.server-changes/default-billing-alerts.md b/.server-changes/default-billing-alerts.md new file mode 100644 index 0000000000..e828c50605 --- /dev/null +++ b/.server-changes/default-billing-alerts.md @@ -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. diff --git a/apps/webapp/app/components/billing/BillingLimitConfigSection.tsx b/apps/webapp/app/components/billing/BillingLimitConfigSection.tsx index c69d6f0c17..e6362d4cb1 100644 --- a/apps/webapp/app/components/billing/BillingLimitConfigSection.tsx +++ b/apps/webapp/app/components/billing/BillingLimitConfigSection.tsx @@ -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(null); const formRef = useRef(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({ + {!billingLimit.isConfigured && ( + + Configure a monthly billing limit below to cap your spend, or set no limit to let runs + keep going. + + )} +
@@ -283,7 +298,7 @@ export function BillingLimitConfigSection({ - {mode !== "none" && ( + {(mode === "plan" || mode === "custom") && ( )} - - Save billing limit - - } - /> + {mode !== "" && ( + + Save billing limit + + } + /> + )}
diff --git a/apps/webapp/app/components/billing/billingAlertsFormat.ts b/apps/webapp/app/components/billing/billingAlertsFormat.ts index dc99c49ff1..5940405a84 100644 --- a/apps/webapp/app/components/billing/billingAlertsFormat.ts +++ b/apps/webapp/app/components/billing/billingAlertsFormat.ts @@ -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; diff --git a/apps/webapp/app/models/organization.server.ts b/apps/webapp/app/models/organization.server.ts index a4b8a8ab5f..9f7fb38231 100644 --- a/apps/webapp/app/models/organization.server.ts +++ b/apps/webapp/app/models/organization.server.ts @@ -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 { + if (!isBillingConfigured()) { + return; + } + + let timer: NodeJS.Timeout | undefined; + const timeout = new Promise((_, 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, diff --git a/apps/webapp/app/services/billingAlertsDefaults.server.ts b/apps/webapp/app/services/billingAlertsDefaults.server.ts new file mode 100644 index 0000000000..14db67bf4e --- /dev/null +++ b/apps/webapp/app/services/billingAlertsDefaults.server.ts @@ -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], + }; +} diff --git a/apps/webapp/test/billingAlertsDefaults.test.ts b/apps/webapp/test/billingAlertsDefaults.test.ts new file mode 100644 index 0000000000..6e655e38fe --- /dev/null +++ b/apps/webapp/test/billingAlertsDefaults.test.ts @@ -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]); + }); +}); diff --git a/apps/webapp/test/billingAlertsFormat.test.ts b/apps/webapp/test/billingAlertsFormat.test.ts index 7ef84770d7..98a61ca8b8 100644 --- a/apps/webapp/test/billingAlertsFormat.test.ts +++ b/apps/webapp/test/billingAlertsFormat.test.ts @@ -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], }); diff --git a/apps/webapp/test/billingLimitsRoute.test.ts b/apps/webapp/test/billingLimitsRoute.test.ts index d85a48ffb2..66f1ac5a7b 100644 --- a/apps/webapp/test/billingLimitsRoute.test.ts +++ b/apps/webapp/test/billingLimitsRoute.test.ts @@ -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({