Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .server-changes/default-billing-alerts.md
Original file line number Diff line number Diff line change
@@ -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.
45 changes: 31 additions & 14 deletions apps/webapp/app/components/billing/BillingLimitConfigSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 =
Expand All @@ -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) {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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>
Expand Down Expand Up @@ -283,7 +298,7 @@ export function BillingLimitConfigSection({
</div>
</RadioGroup>

{mode !== "none" && (
{(mode === "plan" || mode === "custom") && (
<CheckboxWithLabel
className="mt-4"
name="cancelInProgressRuns"
Expand All @@ -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>
Expand Down
8 changes: 7 additions & 1 deletion apps/webapp/app/components/billing/billingAlertsFormat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
Expand Down
39 changes: 38 additions & 1 deletion apps/webapp/app/models/organization.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,22 @@ 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";
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,
Expand Down Expand Up @@ -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,
});
}
}
Comment thread
kathiekiwi marked this conversation as resolved.
Comment thread
kathiekiwi marked this conversation as resolved.

export async function createEnvironment({
organization,
project,
Expand Down
17 changes: 17 additions & 0 deletions apps/webapp/app/services/billingAlertsDefaults.server.ts
Original file line number Diff line number Diff line change
@@ -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],
};
Comment thread
kathiekiwi marked this conversation as resolved.
}
26 changes: 26 additions & 0 deletions apps/webapp/test/billingAlertsDefaults.test.ts
Original file line number Diff line number Diff line change
@@ -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]);
});
});
35 changes: 32 additions & 3 deletions apps/webapp/test/billingAlertsFormat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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(
{
Expand All @@ -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],
});
Expand Down
41 changes: 41 additions & 0 deletions apps/webapp/test/billingLimitsRoute.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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({
Expand Down