Skip to content

Commit 31b6df9

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 31b6df9

36 files changed

Lines changed: 670 additions & 100 deletions

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: 11 additions & 5 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,7 +18,7 @@ import { v3BillingLimitsPath, v3BillingPath, v3QueuesPath } from "~/utils/pathBu
1718

1819
function getUpgradeResetDate(): Date {
1920
const nextMonth = new Date();
20-
nextMonth.setUTCMonth(nextMonth.getMonth() + 1);
21+
nextMonth.setUTCMonth(nextMonth.getUTCMonth() + 1);
2122
nextMonth.setUTCDate(1);
2223
nextMonth.setUTCHours(0, 0, 0, 0);
2324
return nextMonth;
@@ -129,18 +130,23 @@ function LimitGraceBanner() {
129130

130131
function NoLimitConfiguredBanner() {
131132
const organization = useOrganization();
133+
const canManageBilling = useCanManageBilling();
132134

133135
return (
134136
<AnimatedOrgBannerBar
135137
show
136138
variant="warning"
137139
action={
138-
<LinkButton variant="tertiary/small" to={v3BillingLimitsPath(organization)}>
139-
Configure billing limit
140-
</LinkButton>
140+
canManageBilling ? (
141+
<LinkButton variant="tertiary/small" to={v3BillingLimitsPath(organization)}>
142+
Configure billing limit
143+
</LinkButton>
144+
) : undefined
141145
}
142146
>
143-
Protect your organization from unexpected usage spikes.
147+
{canManageBilling
148+
? "Protect your organization from unexpected usage spikes."
149+
: "Billing limits are not configured for this organization. Contact an organization administrator to configure them."}
144150
</AnimatedOrgBannerBar>
145151
);
146152
}

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/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");

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

Lines changed: 39 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,6 @@ import type { MetaFunction } from "@remix-run/react";
33
import {
44
json,
55
redirect,
6-
type ActionFunction,
7-
type LoaderFunctionArgs,
86
} from "@remix-run/server-runtime";
97
import { tryCatch } from "@trigger.dev/core";
108
import { typedjson, useTypedLoaderData } from "remix-typedjson";
@@ -43,6 +41,7 @@ import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/Page
4341
import { prisma } from "~/db.server";
4442
import { featuresForRequest } from "~/features.server";
4543
import { useScrollContainerToTop } from "~/hooks/useScrollContainerToTop";
44+
import { resolveOrgIdFromSlug } from "~/models/organization.server";
4645
import {
4746
commitSession,
4847
getSession,
@@ -59,6 +58,7 @@ import {
5958
setBillingAlert,
6059
setBillingLimit,
6160
} from "~/services/platform.v3.server";
61+
import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder";
6262
import type { BillingLimitResult } from "~/services/billingLimit.schemas";
6363
import {
6464
getAlertsResetRequested,
@@ -77,15 +77,31 @@ import {
7777
v3BillingLimitsPath,
7878
v3BillingPath,
7979
} from "~/utils/pathBuilder";
80-
import { requireUserId } from "~/services/session.server";
80+
81+
const billingLimitsAuthorization = {
82+
action: "manage" as const,
83+
resource: { type: "billing" as const },
84+
};
8185

8286
export const meta: MetaFunction = () => {
8387
return [{ title: `Billing limits | Trigger.dev` }];
8488
};
8589

86-
export async function loader({ params, request }: LoaderFunctionArgs) {
87-
const userId = await requireUserId(request);
88-
const { organizationSlug } = OrganizationParamsSchema.parse(params);
90+
export const loader = dashboardLoader(
91+
{
92+
params: OrganizationParamsSchema,
93+
context: async (params) => {
94+
const organizationId = await resolveOrgIdFromSlug(params.organizationSlug);
95+
return organizationId ? { organizationId } : {};
96+
},
97+
authorization: {
98+
...billingLimitsAuthorization,
99+
message: "With your current role, you can't manage billing limits.",
100+
},
101+
},
102+
async ({ params, request, user }) => {
103+
const userId = user.id;
104+
const { organizationSlug } = params;
89105

90106
const { isManagedCloud } = featuresForRequest(request);
91107
if (!isManagedCloud) {
@@ -131,9 +147,9 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
131147
firstDayOfMonth.setUTCHours(0, 0, 0, 0);
132148

133149
const firstDayOfNextMonth = new Date();
134-
firstDayOfNextMonth.setUTCMonth(firstDayOfNextMonth.getUTCMonth() + 1);
135150
firstDayOfNextMonth.setUTCDate(1);
136151
firstDayOfNextMonth.setUTCHours(0, 0, 0, 0);
152+
firstDayOfNextMonth.setUTCMonth(firstDayOfNextMonth.getUTCMonth() + 1);
137153

138154
const [usage, queuedRunCount, billingLimitPauseEnvCount] = await Promise.all([
139155
getCachedUsage(organization.id, { from: firstDayOfMonth, to: firstDayOfNextMonth }),
@@ -166,7 +182,8 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
166182
submittedResumeMode,
167183
suggestedNewLimitDollars,
168184
});
169-
}
185+
}
186+
);
170187

171188
type LoaderData = {
172189
billingLimit: BillingLimitResult;
@@ -182,9 +199,18 @@ type LoaderData = {
182199
suggestedNewLimitDollars: number;
183200
};
184201

185-
export const action: ActionFunction = async ({ request, params }) => {
186-
const userId = await requireUserId(request);
187-
const { organizationSlug } = OrganizationParamsSchema.parse(params);
202+
export const action = dashboardAction(
203+
{
204+
params: OrganizationParamsSchema,
205+
context: async (params) => {
206+
const organizationId = await resolveOrgIdFromSlug(params.organizationSlug);
207+
return organizationId ? { organizationId } : {};
208+
},
209+
authorization: billingLimitsAuthorization,
210+
},
211+
async ({ request, params, user }) => {
212+
const userId = user.id;
213+
const { organizationSlug } = params;
188214

189215
const organization = await prisma.organization.findFirst({
190216
where: { slug: organizationSlug, members: { some: { userId } } },
@@ -476,7 +502,8 @@ export const action: ActionFunction = async ({ request, params }) => {
476502
}
477503

478504
return json({ error: "Unknown form intent" }, { status: 400 });
479-
};
505+
}
506+
);
480507

481508
export default function Page() {
482509
const {

apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.usage/route.tsx

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { Suspense, useMemo } from "react";
66
import { redirect, typeddefer, useTypedLoaderData } from "remix-typedjson";
77
import { URL } from "url";
88
import { UsageBar } from "~/components/billing/UsageBar";
9+
import { getUsageBarBillingLimitDollars } from "~/components/billing/billingAlertsFormat";
910
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
1011
import { Card } from "~/components/primitives/charts/Card";
1112
import type { ChartConfig } from "~/components/primitives/charts/Chart";
@@ -30,6 +31,7 @@ import { useSearchParams } from "~/hooks/useSearchParam";
3031
import { UsagePresenter, type UsageSeriesData } from "~/presenters/v3/UsagePresenter.server";
3132
import { requireUserId } from "~/services/session.server";
3233
import { formatCurrency, formatCurrencyAccurate, formatNumber } from "~/utils/numberFormatter";
34+
import { useBillingLimit } from "~/hooks/useOrganizations";
3335
import { OrganizationParamsSchema, organizationPath } from "~/utils/pathBuilder";
3436
import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
3537

@@ -96,6 +98,11 @@ const monthDateFormatter = new Intl.DateTimeFormat("en-US", {
9698
export default function Page() {
9799
const { usage, tasks, months, isCurrentMonth } = useTypedLoaderData<typeof loader>();
98100
const currentPlan = useCurrentPlan();
101+
const billingLimit = useBillingLimit();
102+
const planLimitCents = currentPlan?.v3Subscription?.plan?.limits.includedUsage ?? 0;
103+
const billingLimitDollars = isCurrentMonth
104+
? getUsageBarBillingLimitDollars(billingLimit, planLimitCents)
105+
: undefined;
99106
const { value, replace } = useSearchParams();
100107

101108
const month = value("month") ?? months[0].toISOString();
@@ -156,10 +163,9 @@ export default function Page() {
156163
current={usage.overall.current}
157164
isPaying={currentPlan?.v3Subscription?.isPaying ?? false}
158165
tierLimit={
159-
isCurrentMonth
160-
? (currentPlan?.v3Subscription?.plan?.limits.includedUsage ?? 0) / 100
161-
: undefined
166+
isCurrentMonth ? planLimitCents / 100 : undefined
162167
}
168+
billingLimit={billingLimitDollars}
163169
/>
164170
</div>
165171
)}

0 commit comments

Comments
 (0)