Skip to content

Commit 1a60059

Browse files
committed
feat(webapp): unconfigured billing limit UX and default billing alerts
- Billing limit form starts with no option selected and no save button until the user picks one (unconfigured orgs only) - Show the yellow no-limit banner on the billing-limits page itself with explanatory text and no action button - Seed default billing alerts ($5, $100, $500, $1000, $2500) for orgs without any, lazily on page load plus an admin backfill endpoint
1 parent 95307ba commit 1a60059

7 files changed

Lines changed: 233 additions & 24 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 sensible default spend alert thresholds automatically, so you're notified before usage grows unexpectedly. You can adjust or remove them anytime in billing settings.

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

Lines changed: 24 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -51,10 +51,15 @@ type BillingLimitActionData = {
5151

5252
export function isBillingLimitFormDirty(input: {
5353
billingLimit: BillingLimitResult;
54-
mode: "none" | "plan" | "custom";
54+
mode: "" | "none" | "plan" | "custom";
5555
customAmount: string;
5656
cancelInProgressRuns: boolean;
5757
}): boolean {
58+
// No mode selected yet (unconfigured limit) — nothing to save.
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+
// When no limit is configured yet, start with no radio option 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;
@@ -283,7 +291,7 @@ export function BillingLimitConfigSection({
283291
</div>
284292
</RadioGroup>
285293

286-
{mode !== "none" && (
294+
{(mode === "plan" || mode === "custom") && (
287295
<CheckboxWithLabel
288296
className="mt-4"
289297
name="cancelInProgressRuns"
@@ -295,14 +303,16 @@ export function BillingLimitConfigSection({
295303
onChange={setCancelInProgressRuns}
296304
/>
297305
)}
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-
/>
306+
{mode !== "" && (
307+
<FormButtons
308+
className={isDirty ? undefined : "invisible"}
309+
confirmButton={
310+
<Button type="submit" variant="primary/small" disabled={!isDirty}>
311+
Save billing limit
312+
</Button>
313+
}
314+
/>
315+
)}
306316
</Fieldset>
307317
</Form>
308318
</div>

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

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,9 @@ export function OrgBanner() {
6363
case OrgBannerKind.LimitGrace:
6464
return hideBillingLimitBanner ? null : <LimitGraceBanner />;
6565
case OrgBannerKind.NoLimitConfigured:
66-
return hideBillingLimitBanner ? null : <NoLimitConfiguredBanner />;
66+
// On the billing-limits page we still surface the warning, but without the
67+
// "Configure billing limit" action — the form is already on the page.
68+
return <NoLimitConfiguredBanner onBillingLimitsPage={hideBillingLimitBanner} />;
6769
case OrgBannerKind.Upgrade:
6870
return organization ? <UpgradeBanner /> : null;
6971
case OrgBannerKind.EnvironmentWarning:
@@ -141,25 +143,29 @@ function LimitGraceBanner() {
141143
);
142144
}
143145

144-
function NoLimitConfiguredBanner() {
146+
function NoLimitConfiguredBanner({ onBillingLimitsPage }: { onBillingLimitsPage: boolean }) {
145147
const organization = useOrganization();
146148
const canManageBillingLimits = useCanManageBillingLimits();
147149

150+
const message = canManageBillingLimits
151+
? onBillingLimitsPage
152+
? "Please configure a billing limit to protect your organization from unexpected usage spikes."
153+
: "Protect your organization from unexpected usage spikes."
154+
: "Billing limits are not configured for this organization. Contact an organization administrator to configure them.";
155+
148156
return (
149157
<AnimatedOrgBannerBar
150158
show
151159
variant="warning"
152160
action={
153-
canManageBillingLimits ? (
161+
canManageBillingLimits && !onBillingLimitsPage ? (
154162
<LinkButton variant="tertiary/small" to={v3BillingLimitsPath(organization)}>
155163
Configure billing limit
156164
</LinkButton>
157165
) : undefined
158166
}
159167
>
160-
{canManageBillingLimits
161-
? "Protect your organization from unexpected usage spikes."
162-
: "Billing limits are not configured for this organization. Contact an organization administrator to configure them."}
168+
{message}
163169
</AnimatedOrgBannerBar>
164170
);
165171
}

apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.billing-limits/route.tsx

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ import {
2424
thresholdValuesAreUnique,
2525
} from "~/components/billing/billingAlertsFormat";
2626
import { getSuggestedRecoveryLimitDollars } from "~/components/billing/billingLimitFormat";
27+
import {
28+
billingAlertsLookUnconfigured,
29+
buildDefaultBillingAlerts,
30+
} from "~/services/billingAlertsDefaults.server";
2731
import {
2832
BillingLimitConfigSection,
2933
billingLimitFormSchema,
@@ -126,15 +130,25 @@ export const loader = dashboardLoader(
126130
});
127131
}
128132

129-
const [alertsError, alerts] = await tryCatch(getBillingAlerts(organization.id));
130-
if (alertsError || !alerts) {
133+
const planLimitCents = currentPlan?.v3Subscription?.plan?.limits.includedUsage ?? 500;
134+
135+
const [alertsError, fetchedAlerts] = await tryCatch(getBillingAlerts(organization.id));
136+
let alerts = alertsError ? undefined : fetchedAlerts;
137+
if (
138+
getBillingLimitMode(billingLimit) === "none" &&
139+
(!alerts || billingAlertsLookUnconfigured(alerts))
140+
) {
141+
// Lazily seed default alerts for orgs that have none configured.
142+
const defaults = buildDefaultBillingAlerts();
143+
const [seedError, seeded] = await tryCatch(setBillingAlert(organization.id, defaults));
144+
alerts = seedError || !seeded ? defaults : seeded;
145+
}
146+
if (!alerts) {
131147
throw new Response(null, {
132148
status: 404,
133149
statusText: `Billing alerts error: ${alertsError ?? "not found"}`,
134150
});
135151
}
136-
137-
const planLimitCents = currentPlan?.v3Subscription?.plan?.limits.includedUsage ?? 500;
138152
const alertsResetRequested = getAlertsResetRequested(request);
139153
const resolveSubmitted = getResolveSubmitted(request);
140154
const submittedResumeMode = getSubmittedResumeMode(request);
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { type ActionFunctionArgs, json } from "@remix-run/server-runtime";
2+
import { z } from "zod";
3+
import { backfillBillingAlerts } from "~/services/billingAlertsBackfiller.server";
4+
import { requireAdminApiRequest } from "~/services/personalAccessToken.server";
5+
6+
const Body = z.object({
7+
cursor: z.string().optional(),
8+
batchSize: z.number().int().positive().max(500).optional(),
9+
dryRun: z.boolean().optional(),
10+
});
11+
12+
export async function action({ request }: ActionFunctionArgs) {
13+
await requireAdminApiRequest(request);
14+
15+
try {
16+
const body = await request.json();
17+
const { cursor, batchSize, dryRun } = Body.parse(body);
18+
19+
const result = await backfillBillingAlerts({ cursor, batchSize, dryRun });
20+
21+
return json({ success: true, ...result });
22+
} catch (error) {
23+
return json({ error: error instanceof Error ? error.message : error }, { status: 400 });
24+
}
25+
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { tryCatch } from "@trigger.dev/core";
2+
import { prisma } from "~/db.server";
3+
import { logger } from "~/services/logger.server";
4+
import {
5+
billingAlertsLookUnconfigured,
6+
buildDefaultBillingAlerts,
7+
} from "~/services/billingAlertsDefaults.server";
8+
import { getBillingAlerts, getBillingLimit, setBillingAlert } from "~/services/platform.v3.server";
9+
10+
export type BillingAlertsBackfillResult = {
11+
orgCount: number;
12+
seeded: number;
13+
skipped: number;
14+
failed: number;
15+
/** Pass back as `cursor` to continue; undefined when done. */
16+
cursor: string | undefined;
17+
};
18+
19+
/** Seeds default billing alerts for orgs that have none configured. */
20+
export async function backfillBillingAlerts({
21+
cursor,
22+
batchSize = 50,
23+
dryRun = false,
24+
}: {
25+
cursor?: string;
26+
batchSize?: number;
27+
dryRun?: boolean;
28+
}): Promise<BillingAlertsBackfillResult> {
29+
const orgs = await prisma.organization.findMany({
30+
where: {
31+
deletedAt: null,
32+
...(cursor ? { id: { gt: cursor } } : {}),
33+
},
34+
orderBy: { id: "asc" },
35+
take: batchSize,
36+
select: { id: true, slug: true },
37+
});
38+
39+
let seeded = 0;
40+
let skipped = 0;
41+
let failed = 0;
42+
43+
for (const org of orgs) {
44+
const [alertsError, alerts] = await tryCatch(getBillingAlerts(org.id));
45+
if (alertsError || !alerts) {
46+
logger.warn("backfillBillingAlerts: failed to get alerts, skipping org", {
47+
organizationId: org.id,
48+
error: alertsError instanceof Error ? alertsError.message : alertsError,
49+
});
50+
failed++;
51+
continue;
52+
}
53+
54+
if (!billingAlertsLookUnconfigured(alerts)) {
55+
skipped++;
56+
continue;
57+
}
58+
59+
// Absolute-dollar defaults only apply when no billing limit is configured.
60+
const [limitError, billingLimit] = await tryCatch(getBillingLimit(org.id));
61+
if (limitError || !billingLimit) {
62+
logger.warn("backfillBillingAlerts: failed to get billing limit, skipping org", {
63+
organizationId: org.id,
64+
error: limitError instanceof Error ? limitError.message : limitError,
65+
});
66+
failed++;
67+
continue;
68+
}
69+
70+
if (billingLimit.isConfigured && billingLimit.mode !== "none") {
71+
skipped++;
72+
continue;
73+
}
74+
75+
const defaults = buildDefaultBillingAlerts();
76+
77+
if (dryRun) {
78+
logger.info("backfillBillingAlerts: would seed defaults (dry run)", {
79+
organizationId: org.id,
80+
slug: org.slug,
81+
defaults,
82+
});
83+
seeded++;
84+
continue;
85+
}
86+
87+
const [seedError] = await tryCatch(setBillingAlert(org.id, defaults));
88+
if (seedError) {
89+
logger.warn("backfillBillingAlerts: failed to seed defaults, skipping org", {
90+
organizationId: org.id,
91+
error: seedError instanceof Error ? seedError.message : seedError,
92+
});
93+
failed++;
94+
continue;
95+
}
96+
97+
seeded++;
98+
}
99+
100+
return {
101+
orgCount: orgs.length,
102+
seeded,
103+
skipped,
104+
failed,
105+
cursor: orgs.length === batchSize ? orgs[orgs.length - 1].id : undefined,
106+
};
107+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
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+
* Build the default billing alerts for an org that has none configured.
9+
*
10+
* The platform evaluates alerts as `usage / amount >= level`. Setting `amount`
11+
* to the $1 base (100 cents) turns `alertLevels` into absolute dollar thresholds,
12+
* which is what the current "no limit configured" UI expects.
13+
*
14+
* Emails are left empty: the platform falls back to org admin/member addresses
15+
* when no recipients are configured.
16+
*/
17+
export function buildDefaultBillingAlerts(): UpdateBillingAlertsRequest {
18+
return {
19+
amount: ABSOLUTE_ALERT_BASE_CENTS,
20+
emails: [],
21+
alertLevels: [...DEFAULT_ALERT_THRESHOLD_DOLLARS],
22+
};
23+
}
24+
25+
/**
26+
* Whether alerts look never-configured. When no alert row exists the platform
27+
* returns a default of `{ amount: planIncludedUsage, emails: [], alertLevels: [] }`;
28+
* a deliberately cleared config stores the $1 absolute base amount and/or keeps
29+
* the configured emails.
30+
*/
31+
export function billingAlertsLookUnconfigured(alerts: {
32+
amount: number;
33+
emails: string[];
34+
alertLevels: number[];
35+
}): boolean {
36+
return (
37+
alerts.alertLevels.length === 0 &&
38+
alerts.emails.length === 0 &&
39+
alerts.amount !== ABSOLUTE_ALERT_BASE_CENTS
40+
);
41+
}

0 commit comments

Comments
 (0)