Skip to content

Commit cb90d64

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 cb90d64

7 files changed

Lines changed: 257 additions & 13 deletions

File tree

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,
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.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
)}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import type { BillingLimitsPendingResolvesResult } from "~/services/billingLimit.schemas";
2+
import { runPendingBillingLimitResolves } from "./billingLimitPendingResolveCoordinator.server";
3+
import type { PendingBillingLimitResolve } from "./billingLimitPendingResolve.types";
4+
import type { OrgReconcileTarget } from "./billingLimitReconciliation.server";
5+
import type { reconcileBillingLimitTarget } from "./billingLimitReconcileTarget.server";
6+
7+
export type RunBillingLimitReconcileTickDeps = {
8+
getPendingResolves?: () => Promise<BillingLimitsPendingResolvesResult | undefined>;
9+
runPendingResolves?: (
10+
pendingResolves: PendingBillingLimitResolve[]
11+
) => Promise<Set<string>>;
12+
collectOrgs?: (options?: { excludeOrgIds?: Set<string> }) => Promise<{
13+
targets: OrgReconcileTarget[];
14+
queuedOrgIds: string[];
15+
}>;
16+
reconcileTarget?: typeof reconcileBillingLimitTarget;
17+
clearProcessedQueue?: (
18+
queuedOrgIds: string[],
19+
processedOrgIds: string[]
20+
) => Promise<void>;
21+
bustCaches?: (organizationId: string) => void;
22+
enqueueConverge?: (
23+
organizationId: string,
24+
targetState: OrgReconcileTarget["targetState"]
25+
) => Promise<void>;
26+
};
27+
28+
export async function runBillingLimitReconcileTick(
29+
deps: RunBillingLimitReconcileTickDeps = {}
30+
): Promise<void> {
31+
const getPendingResolves =
32+
deps.getPendingResolves ??
33+
(await import("~/services/platform.v3.server")).getPendingBillingLimitResolves;
34+
const runPendingResolves = deps.runPendingResolves ?? runPendingBillingLimitResolves;
35+
const collectOrgs =
36+
deps.collectOrgs ??
37+
(await import("./billingLimitReconciliation.server")).collectOrgsToReconcile;
38+
const reconcileTarget =
39+
deps.reconcileTarget ??
40+
(await import("./billingLimitReconcileTarget.server")).reconcileBillingLimitTarget;
41+
const clearProcessedQueue =
42+
deps.clearProcessedQueue ??
43+
(await import("./billingLimitReconciliation.server")).clearProcessedReconcileQueueEntries;
44+
const bustCaches =
45+
deps.bustCaches ?? (await import("~/services/platform.v3.server")).bustBillingLimitCaches;
46+
47+
const pendingResolves = (await getPendingResolves())?.orgs ?? [];
48+
const stillPendingOrgIds = await runPendingResolves(pendingResolves);
49+
50+
const { targets, queuedOrgIds } = await collectOrgs({
51+
excludeOrgIds: stillPendingOrgIds,
52+
});
53+
54+
const enqueueConverge =
55+
deps.enqueueConverge ??
56+
(async (organizationId, targetState) => {
57+
const { enqueueBillingLimitConverge } = await import("~/v3/billingLimitWorker.server");
58+
await enqueueBillingLimitConverge(organizationId, targetState);
59+
});
60+
61+
for (const target of targets) {
62+
await reconcileTarget(target, {
63+
bustCaches,
64+
enqueueConverge,
65+
});
66+
}
67+
68+
await clearProcessedQueue(
69+
queuedOrgIds,
70+
targets.map((target) => target.organizationId)
71+
);
72+
}

apps/webapp/server.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,10 @@ if (ENABLE_CLUSTER && cluster.isPrimary) {
103103

104104
// Remix fingerprints its assets so we can cache forever.
105105
app.use("/build", express.static("public/build", { immutable: true, maxAge: "1y" }));
106+
// Stale dev builds can request an old hashed manifest; don't fall through to Remix.
107+
app.use("/build", (_req, res) => {
108+
res.status(404).end();
109+
});
106110

107111
// Everything else (like favicon.ico) is cached for an hour. You may want to be
108112
// more aggressive with this caching.
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { EnvironmentPauseSource } from "@trigger.dev/database";
2+
import { describe, expect, it } from "vitest";
3+
import type { BillingLimitResult } from "~/services/billingLimit.schemas";
4+
import { getInitialEnvPauseStateForBillingLimit } from "~/v3/services/billingLimit/getInitialEnvPauseStateForBillingLimit.server";
5+
6+
function configuredLimit(
7+
status: "grace" | "rejected" | "ok"
8+
): BillingLimitResult {
9+
const hitAt = "2026-06-16T12:00:00.000Z";
10+
const graceEndsAt = "2026-06-17T12:00:00.000Z";
11+
12+
return {
13+
isConfigured: true,
14+
mode: "custom",
15+
amountCents: 50_000,
16+
cancelInProgressRuns: false,
17+
effectiveAmountCents: 50_000,
18+
gracePeriodMs: 86_400_000,
19+
limitState:
20+
status === "ok"
21+
? { status: "ok" }
22+
: { status, hitAt, graceEndsAt },
23+
};
24+
}
25+
26+
describe("getInitialEnvPauseStateForBillingLimit", () => {
27+
it("pauses billable environments when org is in grace", async () => {
28+
const result = await getInitialEnvPauseStateForBillingLimit("org_123", "PRODUCTION", {
29+
getBillingLimit: async () => configuredLimit("grace"),
30+
});
31+
32+
expect(result).toEqual({
33+
paused: true,
34+
pauseSource: EnvironmentPauseSource.BILLING_LIMIT,
35+
});
36+
});
37+
38+
it("pauses billable environments when org is rejected", async () => {
39+
const result = await getInitialEnvPauseStateForBillingLimit("org_123", "STAGING", {
40+
getBillingLimit: async () => configuredLimit("rejected"),
41+
});
42+
43+
expect(result).toEqual({
44+
paused: true,
45+
pauseSource: EnvironmentPauseSource.BILLING_LIMIT,
46+
});
47+
});
48+
49+
it("does not pause development environments", async () => {
50+
const result = await getInitialEnvPauseStateForBillingLimit("org_123", "DEVELOPMENT", {
51+
getBillingLimit: async () => configuredLimit("rejected"),
52+
});
53+
54+
expect(result).toEqual({
55+
paused: false,
56+
pauseSource: null,
57+
});
58+
});
59+
60+
it("does not pause when billing limit is ok", async () => {
61+
const result = await getInitialEnvPauseStateForBillingLimit("org_123", "PRODUCTION", {
62+
getBillingLimit: async () => configuredLimit("ok"),
63+
});
64+
65+
expect(result).toEqual({
66+
paused: false,
67+
pauseSource: null,
68+
});
69+
});
70+
});
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { describe, expect, it } from "vitest";
2+
import { runBillingLimitReconcileTick } from "~/v3/services/billingLimit/runBillingLimitReconcileTick.server";
3+
import type { PendingBillingLimitResolve } from "~/v3/services/billingLimit/billingLimitPendingResolve.types";
4+
5+
describe("runBillingLimitReconcileTick", () => {
6+
const pending: PendingBillingLimitResolve = {
7+
organizationId: "org_pending",
8+
resumeMode: "queue",
9+
resolvedAt: "2026-06-17T12:00:00.000Z",
10+
};
11+
12+
it("runs pending resolves before collecting orgs and excludes still-pending orgs", async () => {
13+
const order: string[] = [];
14+
15+
await runBillingLimitReconcileTick({
16+
getPendingResolves: async () => ({ orgs: [pending] }),
17+
runPendingResolves: async (pendingResolves) => {
18+
order.push(`pending:${pendingResolves.map((row) => row.organizationId).join(",")}`);
19+
return new Set(["org_pending"]);
20+
},
21+
collectOrgs: async (options) => {
22+
order.push(`collect:${[...options?.excludeOrgIds ?? []].join(",")}`);
23+
return {
24+
targets: [{ organizationId: "org_active", targetState: "grace" }],
25+
queuedOrgIds: ["org_active"],
26+
};
27+
},
28+
reconcileTarget: async (target) => {
29+
order.push(`reconcile:${target.organizationId}:${target.targetState}`);
30+
},
31+
clearProcessedQueue: async (queuedOrgIds, processedOrgIds) => {
32+
order.push(`clear:${queuedOrgIds.join(",")}:${processedOrgIds.join(",")}`);
33+
},
34+
bustCaches: () => {},
35+
enqueueConverge: async () => undefined,
36+
});
37+
38+
expect(order).toEqual([
39+
"pending:org_pending",
40+
"collect:org_pending",
41+
"reconcile:org_active:grace",
42+
"clear:org_active:org_active",
43+
]);
44+
});
45+
46+
it("reconciles collected targets when no pending resolves remain", async () => {
47+
const reconciled: Array<{ organizationId: string; targetState: string }> = [];
48+
49+
await runBillingLimitReconcileTick({
50+
getPendingResolves: async () => ({ orgs: [] }),
51+
runPendingResolves: async () => new Set(),
52+
collectOrgs: async () => ({
53+
targets: [
54+
{ organizationId: "org_grace", targetState: "grace" },
55+
{ organizationId: "org_ok", targetState: "ok" },
56+
],
57+
queuedOrgIds: ["org_grace", "org_ok"],
58+
}),
59+
reconcileTarget: async (target, deps) => {
60+
reconciled.push(target);
61+
await deps.enqueueConverge(target.organizationId, target.targetState);
62+
},
63+
clearProcessedQueue: async () => undefined,
64+
bustCaches: () => {},
65+
enqueueConverge: async (organizationId, targetState) => {
66+
reconciled.push({ organizationId, targetState: `enqueued:${targetState}` });
67+
},
68+
});
69+
70+
expect(reconciled).toEqual([
71+
{ organizationId: "org_grace", targetState: "grace" },
72+
{ organizationId: "org_grace", targetState: "enqueued:grace" },
73+
{ organizationId: "org_ok", targetState: "ok" },
74+
{ organizationId: "org_ok", targetState: "enqueued:ok" },
75+
]);
76+
});
77+
});

0 commit comments

Comments
 (0)