Skip to content

Commit 6d9aa23

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 6d9aa23

40 files changed

Lines changed: 805 additions & 147 deletions

.server-changes/billing-limits.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,14 @@ 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`.

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: 37 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ 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";
@@ -17,9 +18,9 @@ import { v3BillingLimitsPath, v3BillingPath, v3QueuesPath } from "~/utils/pathBu
1718

1819
function getUpgradeResetDate(): Date {
1920
const nextMonth = new Date();
20-
nextMonth.setUTCMonth(nextMonth.getMonth() + 1);
2121
nextMonth.setUTCDate(1);
2222
nextMonth.setUTCHours(0, 0, 0, 0);
23+
nextMonth.setUTCMonth(nextMonth.getUTCMonth() + 1);
2324
return nextMonth;
2425
}
2526

@@ -77,30 +78,39 @@ export function OrgBanner() {
7778

7879
function LimitRejectedBanner() {
7980
const organization = useOrganization();
81+
const showSelfServe = useShowSelfServe();
82+
const canManageBilling = useCanManageBilling();
83+
const canResolve = showSelfServe && canManageBilling;
8084

8185
return (
8286
<AnimatedOrgBannerBar
8387
show
8488
variant="error"
8589
action={
86-
<LinkButton
87-
variant="danger/small"
88-
leadingIconClassName="px-0"
89-
to={v3BillingLimitsPath(organization)}
90-
>
91-
Resolve
92-
</LinkButton>
90+
canResolve ? (
91+
<LinkButton
92+
variant="danger/small"
93+
leadingIconClassName="px-0"
94+
to={v3BillingLimitsPath(organization)}
95+
>
96+
Resolve
97+
</LinkButton>
98+
) : undefined
9399
}
94100
>
95101
<span className="font-medium">Billing limit exceeded</span> — New triggers are currently
96102
blocked.
103+
{!canResolve ? " Contact your organization administrator to resolve this issue." : null}
97104
</AnimatedOrgBannerBar>
98105
);
99106
}
100107

101108
function LimitGraceBanner() {
102109
const organization = useOrganization();
103110
const billingLimit = useBillingLimit();
111+
const showSelfServe = useShowSelfServe();
112+
const canManageBilling = useCanManageBilling();
113+
const canResolve = showSelfServe && canManageBilling;
104114

105115
const graceEndsAt =
106116
billingLimit?.isConfigured && billingLimit.limitState.status === "grace"
@@ -112,35 +122,43 @@ function LimitGraceBanner() {
112122
show={graceEndsAt !== null}
113123
variant="error"
114124
action={
115-
<LinkButton
116-
variant="danger/small"
117-
leadingIconClassName="px-0"
118-
to={v3BillingLimitsPath(organization)}
119-
>
120-
Resolve
121-
</LinkButton>
125+
canResolve ? (
126+
<LinkButton
127+
variant="danger/small"
128+
leadingIconClassName="px-0"
129+
to={v3BillingLimitsPath(organization)}
130+
>
131+
Resolve
132+
</LinkButton>
133+
) : undefined
122134
}
123135
>
124136
<span className="font-medium">Billing limit reached</span> — Queues have been paused. New runs
125137
will continue to queue until <DateTime date={graceEndsAt ?? new Date()} includeTime />.
138+
{!canResolve ? " Contact your organization administrator to resolve this issue." : null}
126139
</AnimatedOrgBannerBar>
127140
);
128141
}
129142

130143
function NoLimitConfiguredBanner() {
131144
const organization = useOrganization();
145+
const canManageBilling = useCanManageBilling();
132146

133147
return (
134148
<AnimatedOrgBannerBar
135149
show
136150
variant="warning"
137151
action={
138-
<LinkButton variant="tertiary/small" to={v3BillingLimitsPath(organization)}>
139-
Configure billing limit
140-
</LinkButton>
152+
canManageBilling ? (
153+
<LinkButton variant="tertiary/small" to={v3BillingLimitsPath(organization)}>
154+
Configure billing limit
155+
</LinkButton>
156+
) : undefined
141157
}
142158
>
143-
Protect your organization from unexpected usage spikes.
159+
{canManageBilling
160+
? "Protect your organization from unexpected usage spikes."
161+
: "Billing limits are not configured for this organization. Contact an organization administrator to configure them."}
144162
</AnimatedOrgBannerBar>
145163
);
146164
}

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: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -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");

0 commit comments

Comments
 (0)