Skip to content

Commit ff448c9

Browse files
committed
feat(webapp): show the billing limit on the usage page, with docs and tests
Add the usage-bar marker, documentation, and test coverage.
1 parent 543c5c0 commit ff448c9

42 files changed

Lines changed: 867 additions & 158 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.server-changes/billing-limits.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,18 @@ Add billing limits. Customers set a spend cap; when usage crosses it, billable
77
environments pause for a grace period, new triggers are rejected once it ends,
88
and a recovery flow resumes or cancels the queued backlog. Reconciliation keeps
99
the webapp converged to billing's state.
10+
11+
## Manual pause during billing enforcement
12+
13+
While `pauseSource=BILLING_LIMIT`, manual resume is rejected and manual pause is
14+
a silent no-op (`PauseEnvironmentService` returns success with state `paused`).
15+
We do not stack a manual pause on top of billing enforcement because resolve
16+
converge unpauses all `BILLING_LIMIT`-paused environments for the org.
17+
18+
API callers that pause during enforcement should expect the environment to
19+
resume when the billing limit is resolved. The queues UI hides pause/resume in
20+
this state; see `manualPauseEnvironmentGuard.server.ts`.
21+
22+
The admin `runs.enable` endpoint skips billing-paused environments when
23+
re-enabling or disabling org runs (returns them in `skipped`, not `failures` or
24+
the update count). They resume only after the billing limit is resolved.

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

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,7 @@ export const billingAlertsSchema = z.object({
5353
const values = typeof i === "string" ? [i] : Array.isArray(i) ? i : [];
5454
return values
5555
.filter((v) => v !== "")
56-
.map((v) => Number(v))
57-
.filter((n) => Number.isFinite(n));
56+
.map((v) => Number(v));
5857
}, z.number().array().refine(thresholdValuesAreUnique, "Each alert must be unique")),
5958
});
6059

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

Lines changed: 39 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -9,17 +9,19 @@ import {
99
useOptionalOrganization,
1010
useOrganization,
1111
useBillingLimit,
12+
useCanManageBilling,
1213
} from "~/hooks/useOrganizations";
1314
import { useOptionalProject, useProject } from "~/hooks/useProject";
1415
import { useShowSelfServe } from "~/hooks/useShowSelfServe";
1516
import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route";
1617
import { v3BillingLimitsPath, v3BillingPath, v3QueuesPath } from "~/utils/pathBuilder";
18+
import { ENVIRONMENT_PAUSE_SOURCE_BILLING_LIMIT } from "~/utils/environmentPauseSource";
1719

1820
function getUpgradeResetDate(): Date {
1921
const nextMonth = new Date();
20-
nextMonth.setUTCMonth(nextMonth.getMonth() + 1);
2122
nextMonth.setUTCDate(1);
2223
nextMonth.setUTCHours(0, 0, 0, 0);
24+
nextMonth.setUTCMonth(nextMonth.getUTCMonth() + 1);
2325
return nextMonth;
2426
}
2527

@@ -41,7 +43,7 @@ export function OrgBanner() {
4143
project &&
4244
environment &&
4345
environment.paused &&
44-
environment.pauseSource !== "BILLING_LIMIT"
46+
environment.pauseSource !== ENVIRONMENT_PAUSE_SOURCE_BILLING_LIMIT
4547
);
4648
const isArchived = !!(organization && project && environment && environment.archivedAt);
4749

@@ -77,30 +79,39 @@ export function OrgBanner() {
7779

7880
function LimitRejectedBanner() {
7981
const organization = useOrganization();
82+
const showSelfServe = useShowSelfServe();
83+
const canManageBilling = useCanManageBilling();
84+
const canResolve = showSelfServe && canManageBilling;
8085

8186
return (
8287
<AnimatedOrgBannerBar
8388
show
8489
variant="error"
8590
action={
86-
<LinkButton
87-
variant="danger/small"
88-
leadingIconClassName="px-0"
89-
to={v3BillingLimitsPath(organization)}
90-
>
91-
Resolve
92-
</LinkButton>
91+
canResolve ? (
92+
<LinkButton
93+
variant="danger/small"
94+
leadingIconClassName="px-0"
95+
to={v3BillingLimitsPath(organization)}
96+
>
97+
Resolve
98+
</LinkButton>
99+
) : undefined
93100
}
94101
>
95102
<span className="font-medium">Billing limit exceeded</span> — New triggers are currently
96103
blocked.
104+
{!canResolve ? " Contact your organization administrator to resolve this issue." : null}
97105
</AnimatedOrgBannerBar>
98106
);
99107
}
100108

101109
function LimitGraceBanner() {
102110
const organization = useOrganization();
103111
const billingLimit = useBillingLimit();
112+
const showSelfServe = useShowSelfServe();
113+
const canManageBilling = useCanManageBilling();
114+
const canResolve = showSelfServe && canManageBilling;
104115

105116
const graceEndsAt =
106117
billingLimit?.isConfigured && billingLimit.limitState.status === "grace"
@@ -112,35 +123,43 @@ function LimitGraceBanner() {
112123
show={graceEndsAt !== null}
113124
variant="error"
114125
action={
115-
<LinkButton
116-
variant="danger/small"
117-
leadingIconClassName="px-0"
118-
to={v3BillingLimitsPath(organization)}
119-
>
120-
Resolve
121-
</LinkButton>
126+
canResolve ? (
127+
<LinkButton
128+
variant="danger/small"
129+
leadingIconClassName="px-0"
130+
to={v3BillingLimitsPath(organization)}
131+
>
132+
Resolve
133+
</LinkButton>
134+
) : undefined
122135
}
123136
>
124137
<span className="font-medium">Billing limit reached</span> — Queues have been paused. New runs
125138
will continue to queue until <DateTime date={graceEndsAt ?? new Date()} includeTime />.
139+
{!canResolve ? " Contact your organization administrator to resolve this issue." : null}
126140
</AnimatedOrgBannerBar>
127141
);
128142
}
129143

130144
function NoLimitConfiguredBanner() {
131145
const organization = useOrganization();
146+
const canManageBilling = useCanManageBilling();
132147

133148
return (
134149
<AnimatedOrgBannerBar
135150
show
136151
variant="warning"
137152
action={
138-
<LinkButton variant="tertiary/small" to={v3BillingLimitsPath(organization)}>
139-
Configure billing limit
140-
</LinkButton>
153+
canManageBilling ? (
154+
<LinkButton variant="tertiary/small" to={v3BillingLimitsPath(organization)}>
155+
Configure billing limit
156+
</LinkButton>
157+
) : undefined
141158
}
142159
>
143-
Protect your organization from unexpected usage spikes.
160+
{canManageBilling
161+
? "Protect your organization from unexpected usage spikes."
162+
: "Billing limits are not configured for this organization. Contact an organization administrator to configure them."}
144163
</AnimatedOrgBannerBar>
145164
);
146165
}

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

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ export type BillingLimitMode = "plan" | "custom" | "none";
1818

1919
export function getBillingLimitMode(billingLimit: BillingLimitResult): BillingLimitMode {
2020
if (!billingLimit.isConfigured) {
21-
return "plan";
21+
return "none";
2222
}
2323
return billingLimit.mode;
2424
}
@@ -240,14 +240,16 @@ export function normalizeBillingAlertsFromApi(apiAlerts: {
240240
// Platform API stores amount in cents.
241241
let amountDollars = rawAmount / 100;
242242

243-
// Legacy percentage alerts sometimes stored plan dollars directly (e.g. 100 for $100).
244-
// Never apply to absolute dollar alerts — those use a fixed $1 base (100 cents).
243+
// Legacy percentage alerts sometimes stored plan dollars directly (e.g. 100 for $100)
244+
// with whole-number percents (10, 50, 80). New saves store cents and fractional levels
245+
// (0.75, 0.9) via thresholdsToAlertPayload — never treat those as legacy dollars.
245246
if (
246247
rawAmount !== ABSOLUTE_ALERT_BASE_CENTS &&
247248
Number.isFinite(rawAmount) &&
248249
rawAmount >= 10 &&
249250
rawAmount / 100 < 10 &&
250-
alertLevels.length > 0
251+
alertLevels.length > 0 &&
252+
!usesFractionAlertLevelFormat(alertLevels)
251253
) {
252254
amountDollars = rawAmount;
253255
}
@@ -311,11 +313,8 @@ export function storedAlertsToThresholds(
311313
return [];
312314
}
313315

314-
// Legacy percentage alerts keep their saved base amount even if billing limit changed.
315-
if (
316-
percentageAlertAmountMatches(amountCents, effectiveLimitCents, planLimitCents) ||
317-
amountCents > 0
318-
) {
316+
// Saved percentage alerts keep their thresholds whenever a positive base amount is stored.
317+
if (amountCents > 0) {
319318
return uiThresholds.slice(0, MAX_PERCENTAGE_ALERTS);
320319
}
321320

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,14 +31,16 @@ export function selectOrgBanner(input: {
3131
if (status === "grace") {
3232
return OrgBannerKind.LimitGrace;
3333
}
34-
} else if (billingLimit && !billingLimit.isConfigured && showSelfServe) {
35-
return OrgBannerKind.NoLimitConfigured;
3634
}
3735

3836
if (hasExceededFreeTier) {
3937
return OrgBannerKind.Upgrade;
4038
}
4139

40+
if (billingLimit && !billingLimit.isConfigured && showSelfServe) {
41+
return OrgBannerKind.NoLimitConfigured;
42+
}
43+
4244
if (showEnvironmentWarning) {
4345
return OrgBannerKind.EnvironmentWarning;
4446
}

apps/webapp/app/components/layout/AppLayout.tsx

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { forwardRef } from "react";
12
import { cn } from "~/utils/cn";
23

34
/** This container is used to surround the entire app, it correctly places the nav bar */
@@ -34,17 +35,17 @@ export function PageContainer({
3435
);
3536
}
3637

37-
export function PageBody({
38-
children,
39-
scrollable = true,
40-
className,
41-
}: {
42-
children: React.ReactNode;
43-
scrollable?: boolean;
44-
className?: string;
45-
}) {
38+
export const PageBody = forwardRef<
39+
HTMLDivElement,
40+
{
41+
children: React.ReactNode;
42+
scrollable?: boolean;
43+
className?: string;
44+
}
45+
>(function PageBody({ children, scrollable = true, className }, ref) {
4646
return (
4747
<div
48+
ref={ref}
4849
className={cn(
4950
scrollable
5051
? "overflow-y-auto p-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
@@ -55,7 +56,7 @@ export function PageBody({
5556
{children}
5657
</div>
5758
);
58-
}
59+
});
5960

6061
export function MainCenteredContainer({
6162
children,

apps/webapp/app/entry.server.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { PassThrough } from "stream";
1010
import * as Worker from "~/services/worker.server";
1111
import { initMollifierDrainerWorker } from "~/v3/mollifierDrainerWorker.server";
1212
import { initMollifierStaleSweepWorker } from "~/v3/mollifierStaleSweepWorker.server";
13-
import "~/v3/billingLimitWorker.server";
13+
import { initBillingLimitWorker } from "~/v3/billingLimitWorker.server";
1414
import { bootstrap } from "./bootstrap";
1515
import { LocaleContextProvider } from "./components/primitives/LocaleProvider";
1616
import {
@@ -236,6 +236,7 @@ Worker.init().catch((error) => {
236236

237237
initMollifierDrainerWorker();
238238
initMollifierStaleSweepWorker();
239+
initBillingLimitWorker();
239240

240241
bootstrap().catch((error) => {
241242
logError(error);

apps/webapp/app/hooks/useOrganizations.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,3 +95,11 @@ export function useBillingLimit(matches?: UIMatch[]) {
9595
});
9696
return data?.billingLimit;
9797
}
98+
99+
export function useCanManageBilling(matches?: UIMatch[]) {
100+
const data = useTypedMatchesData<typeof orgLoader>({
101+
id: "routes/_app.orgs.$organizationSlug",
102+
matches,
103+
});
104+
return data?.canManageBilling === true;
105+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { useLocation } from "@remix-run/react";
2+
import { useEffect, useRef } from "react";
3+
4+
/** Scroll a page body container back to the top when navigating to a route. */
5+
export function useScrollContainerToTop<T extends HTMLElement>() {
6+
const ref = useRef<T>(null);
7+
const location = useLocation();
8+
9+
useEffect(() => {
10+
ref.current?.scrollTo(0, 0);
11+
}, [location.key]);
12+
13+
return ref;
14+
}

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import {
1010
import { DialogClose } from "@radix-ui/react-dialog";
1111
import { Form, useNavigation, useSearchParams, type MetaFunction } from "@remix-run/react";
1212
import { type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/server-runtime";
13-
import { EnvironmentPauseSource } from "@trigger.dev/database";
1413
import type { RuntimeEnvironmentType } from "@trigger.dev/database";
1514
import type { QueueItem } from "@trigger.dev/core/v3/schemas";
1615
import { useEffect, useState } from "react";
@@ -68,6 +67,7 @@ import { EnvironmentQueuePresenter } from "~/presenters/v3/EnvironmentQueuePrese
6867
import { QueueListPresenter } from "~/presenters/v3/QueueListPresenter.server";
6968
import { requireUserId } from "~/services/session.server";
7069
import { cn } from "~/utils/cn";
70+
import { ENVIRONMENT_PAUSE_SOURCE_BILLING_LIMIT } from "~/utils/environmentPauseSource";
7171
import {
7272
concurrencyPath,
7373
docsPath,
@@ -183,24 +183,22 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
183183
}
184184

185185
switch (action) {
186-
case "environment-pause":
186+
case "environment-pause": {
187187
const pauseService = new PauseEnvironmentService();
188-
{
189-
const result = await pauseService.call(environment, "paused");
190-
if (!result.success) {
191-
return redirectWithErrorMessage(redirectPath, request, result.error);
192-
}
188+
const result = await pauseService.call(environment, "paused");
189+
if (!result.success) {
190+
return redirectWithErrorMessage(redirectPath, request, result.error);
193191
}
194192
return redirectWithSuccessMessage(redirectPath, request, "Environment paused");
195-
case "environment-resume":
193+
}
194+
case "environment-resume": {
196195
const resumeService = new PauseEnvironmentService();
197-
{
198-
const result = await resumeService.call(environment, "resumed");
199-
if (!result.success) {
200-
return redirectWithErrorMessage(redirectPath, request, result.error);
201-
}
196+
const result = await resumeService.call(environment, "resumed");
197+
if (!result.success) {
198+
return redirectWithErrorMessage(redirectPath, request, result.error);
202199
}
203200
return redirectWithSuccessMessage(redirectPath, request, "Environment resumed");
201+
}
204202
case "queue-pause":
205203
case "queue-resume": {
206204
const friendlyId = formData.get("friendlyId");
@@ -357,7 +355,8 @@ export default function Page() {
357355
animate
358356
accessory={
359357
<div className="flex items-start gap-1">
360-
{environment.runsEnabled && env.pauseSource !== EnvironmentPauseSource.BILLING_LIMIT ? (
358+
{environment.runsEnabled &&
359+
env.pauseSource !== ENVIRONMENT_PAUSE_SOURCE_BILLING_LIMIT ? (
361360
<EnvironmentPauseResumeButton env={env} />
362361
) : null}
363362
<LinkButton

0 commit comments

Comments
 (0)