Skip to content
Open
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.
37 changes: 23 additions & 14 deletions apps/webapp/app/components/billing/BillingLimitConfigSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,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 +79,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 +115,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 @@ -283,7 +290,7 @@ export function BillingLimitConfigSection({
</div>
</RadioGroup>

{mode !== "none" && (
{(mode === "plan" || mode === "custom") && (
<CheckboxWithLabel
className="mt-4"
name="cancelInProgressRuns"
Expand All @@ -295,14 +302,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
18 changes: 11 additions & 7 deletions apps/webapp/app/components/billing/OrgBanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ export function OrgBanner() {
case OrgBannerKind.LimitGrace:
return hideBillingLimitBanner ? null : <LimitGraceBanner />;
case OrgBannerKind.NoLimitConfigured:
return hideBillingLimitBanner ? null : <NoLimitConfiguredBanner />;
return <NoLimitConfiguredBanner onBillingLimitsPage={hideBillingLimitBanner} />;
case OrgBannerKind.Upgrade:
return organization ? <UpgradeBanner /> : null;
case OrgBannerKind.EnvironmentWarning:
Expand Down Expand Up @@ -141,25 +141,29 @@ function LimitGraceBanner() {
);
}

function NoLimitConfiguredBanner() {
function NoLimitConfiguredBanner({ onBillingLimitsPage }: { onBillingLimitsPage: boolean }) {
const organization = useOrganization();
const canManageBillingLimits = useCanManageBillingLimits();

if (!canManageBillingLimits) {
return null;
}

return (
<AnimatedOrgBannerBar
show
variant="warning"
action={
canManageBillingLimits ? (
onBillingLimitsPage ? undefined : (
<LinkButton variant="tertiary/small" to={v3BillingLimitsPath(organization)}>
Configure billing limit
</LinkButton>
) : undefined
)
}
>
{canManageBillingLimits
? "Protect your organization from unexpected usage spikes."
: "Billing limits are not configured for this organization. Contact an organization administrator to configure them."}
{onBillingLimitsPage
? "Please configure a billing limit to protect your organization from unexpected usage spikes."
: "Protect your organization from unexpected usage spikes."}
</AnimatedOrgBannerBar>
);
}
Expand Down
3 changes: 2 additions & 1 deletion apps/webapp/app/components/billing/billingAlertsFormat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -315,8 +315,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],
};
}
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]);
});
});
13 changes: 13 additions & 0 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
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