Skip to content

Commit 190c01a

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. Verified all billing + devBranch test suites load with REDIS_HOST unset.
1 parent 5e46814 commit 190c01a

76 files changed

Lines changed: 2027 additions & 982 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/AnimatedOrgBannerBar.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ export function AnimatedOrgBannerBar({
2626
"flex h-10 items-center justify-between overflow-hidden py-0 pl-3 pr-2",
2727
variant === "warning"
2828
? "border-y border-amber-400/20 bg-warning/20"
29-
: "border border-error bg-repeat"
29+
: "border border-error bg-repeat",
3030
)}
3131
style={
3232
variant === "error"

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

Lines changed: 19 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -49,13 +49,13 @@ export const billingAlertsSchema = z.object({
4949

5050
return [""];
5151
}, z.string().email().array().nonempty("At least one email is required")),
52-
alertLevels: z.preprocess((i) => {
53-
const values = typeof i === "string" ? [i] : Array.isArray(i) ? i : [];
54-
return values
55-
.filter((v) => v !== "")
56-
.map((v) => Number(v))
57-
.filter((n) => Number.isFinite(n));
58-
}, z.number().array().refine(thresholdValuesAreUnique, "Each alert must be unique")),
52+
alertLevels: z.preprocess(
53+
(i) => {
54+
const values = typeof i === "string" ? [i] : Array.isArray(i) ? i : [];
55+
return values.filter((v) => v !== "").map((v) => Number(v));
56+
},
57+
z.number().array().refine(thresholdValuesAreUnique, "Each alert must be unique"),
58+
),
5959
});
6060

6161
export type { BillingAlertsFormData } from "~/components/billing/billingAlertsFormat";
@@ -101,7 +101,7 @@ function isDuplicateThresholdRow(rows: ThresholdRow[], index: number): boolean {
101101
const parsed = Number(value);
102102
return rows.some(
103103
(row, rowIndex) =>
104-
rowIndex !== index && !isEmptyThresholdRow(row.value) && Number(row.value) === parsed
104+
rowIndex !== index && !isEmptyThresholdRow(row.value) && Number(row.value) === parsed,
105105
);
106106
}
107107

@@ -136,27 +136,26 @@ export function BillingAlertsSection({
136136
const alertPreviewLimitCents = getAlertPreviewLimitCents(
137137
alerts,
138138
effectiveLimitCents,
139-
planLimitCents
139+
planLimitCents,
140140
);
141141
const maxAlerts = isPercentageMode ? MAX_PERCENTAGE_ALERTS : MAX_ABSOLUTE_ALERTS;
142142

143143
const savedThresholds = useMemo(
144144
() => storedAlertsToThresholds(alerts, billingLimitMode, effectiveLimitCents, planLimitCents),
145-
[alerts, billingLimitMode, effectiveLimitCents, planLimitCents]
145+
[alerts, billingLimitMode, effectiveLimitCents, planLimitCents],
146146
);
147147
const savedEmails = useMemo(() => alerts.emails, [alerts.emails]);
148148
const hasLegacySpikes = useMemo(
149-
() =>
150-
hasLegacySpikeAlertLevels(alerts, billingLimitMode, effectiveLimitCents, planLimitCents),
151-
[alerts, billingLimitMode, effectiveLimitCents, planLimitCents]
149+
() => hasLegacySpikeAlertLevels(alerts, billingLimitMode, effectiveLimitCents, planLimitCents),
150+
[alerts, billingLimitMode, effectiveLimitCents, planLimitCents],
152151
);
153152

154153
const nextThresholdIdRef = useRef(savedThresholds.length);
155154
const [thresholdRows, setThresholdRows] = useState<ThresholdRow[]>(() =>
156-
toThresholdRows(savedThresholds)
155+
toThresholdRows(savedThresholds),
157156
);
158157
const [emailValues, setEmailValues] = useState<string[]>(
159-
savedEmails.length > 0 ? [...savedEmails, ""] : [""]
158+
savedEmails.length > 0 ? [...savedEmails, ""] : [""],
160159
);
161160
const actionData = useActionData<BillingAlertsActionData>();
162161
const alertsSubmission =
@@ -192,7 +191,7 @@ export function BillingAlertsSection({
192191

193192
function updateThresholdRow(index: number, rawValue: string) {
194193
setThresholdRows((current) =>
195-
current.map((row, rowIndex) => (rowIndex === index ? { ...row, value: rawValue } : row))
194+
current.map((row, rowIndex) => (rowIndex === index ? { ...row, value: rawValue } : row)),
196195
);
197196
}
198197

@@ -262,7 +261,7 @@ export function BillingAlertsSection({
262261
if (e.target.value === "") return;
263262
const clamped = Math.min(
264263
MAX_PERCENTAGE_THRESHOLD,
265-
Math.max(1, Number(e.target.value))
264+
Math.max(1, Number(e.target.value)),
266265
);
267266
if (String(clamped) !== e.target.value) {
268267
updateThresholdRow(index, String(clamped));
@@ -281,9 +280,9 @@ export function BillingAlertsSection({
281280
{formatCurrency(
282281
previewDollarAmountForPercent(
283282
Number.isFinite(parsed) ? parsed : 0,
284-
alertPreviewLimitCents
283+
alertPreviewLimitCents,
285284
),
286-
false
285+
false,
287286
)}
288287
)
289288
</span>
@@ -343,7 +342,7 @@ export function BillingAlertsSection({
343342
{emailFields.map((email, index) => {
344343
const { defaultValue: _emailDefaultValue, ...emailInputProps } = conform.input(
345344
email,
346-
{ type: "email" }
345+
{ type: "email" },
347346
);
348347

349348
return (

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ export function isBillingLimitFormDirty(input: {
7979
export function getBillingLimitFormLastSubmission(
8080
submission: BillingLimitActionData["submission"] | undefined,
8181
mode: "none" | "plan" | "custom",
82-
isDirty: boolean
82+
isDirty: boolean,
8383
) {
8484
if (!isDirty || !submission) {
8585
return undefined;
@@ -151,7 +151,7 @@ export function BillingLimitConfigSection({
151151
});
152152
const lastSubmission = useMemo(
153153
() => getBillingLimitFormLastSubmission(limitSubmission, mode, isDirty),
154-
[limitSubmission, mode, isDirty]
154+
[limitSubmission, mode, isDirty],
155155
);
156156

157157
const [form, fields] = useForm({

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

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,7 @@ export function BillingLimitResolveProgress({
1414
return (
1515
<div className="space-y-3">
1616
<AnimatedCallout show variant="success">
17-
Billing limit resolved. Environments are being unpaused — this usually takes a few
18-
seconds.
17+
Billing limit resolved. Environments are being unpaused — this usually takes a few seconds.
1918
</AnimatedCallout>
2019
{cancellingQueuedRuns && (
2120
<AnimatedCallout show variant="info">

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
}

0 commit comments

Comments
 (0)