Skip to content

Commit c338591

Browse files
fix(webapp): use EntitlementResult for production entitlement validation
Replace the invalid @trigger.dev/platform/v3 import so billing limit typecheck passes in CI. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent cb90d64 commit c338591

27 files changed

Lines changed: 338 additions & 87 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/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+
}

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/route.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import { OrganizationsPresenter } from "~/presenters/OrganizationsPresenter.serv
1010
import { RegionsPresenter, type Region } from "~/presenters/v3/RegionsPresenter.server";
1111
import { getImpersonationId } from "~/services/impersonation.server";
1212
import { getCachedUsage, getBillingLimit, getCurrentPlan } from "~/services/platform.v3.server";
13+
import { rbac } from "~/services/rbac.server";
14+
import { canManageBilling } from "~/services/routeBuilders/permissions.server";
1315
import { requireUser } from "~/services/session.server";
1416
import { telemetry } from "~/services/telemetry.server";
1517
import { organizationPath } from "~/utils/pathBuilder";
@@ -97,6 +99,12 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
9799
const shouldLoadRegions =
98100
!!projectParam && !!environment && environment.type !== "DEVELOPMENT";
99101

102+
const sessionAuth = await rbac.authenticateSession(request, {
103+
userId: user.id,
104+
organizationId: organization.id,
105+
});
106+
const userCanManageBilling = sessionAuth.ok ? canManageBilling(sessionAuth.ability) : false;
107+
100108
const [plan, usage, billingLimit, customDashboards, regions] = await Promise.all([
101109
getCurrentPlan(organization.id),
102110
getCachedUsage(organization.id, { from: firstDayOfMonth, to: firstDayOfNextMonth }),
@@ -173,6 +181,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
173181
limit: dashboardLimit,
174182
},
175183
widgetLimitPerDashboard,
184+
canManageBilling: userCanManageBilling,
176185
});
177186
};
178187

apps/webapp/app/routes/admin.api.v1.orgs.$organizationId.billing-limit.hit.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { type ActionFunctionArgs, json } from "@remix-run/server-runtime";
22
import { z } from "zod";
33
import { prisma } from "~/db.server";
4-
import { BillingLimitHitWebhookBodySchema } from "~/services/billingLimit.schemas";
4+
import { BillingLimitHitWebhookBodySchema, type BillingLimitHitWebhookBody } from "~/services/billingLimit.schemas";
5+
import { logger } from "~/services/logger.server";
56
import { requireAdminApiRequest } from "~/services/personalAccessToken.server";
67
import { bustBillingLimitCaches } from "~/services/platform.v3.server";
78
import {
@@ -24,7 +25,17 @@ export async function action({ request, params }: ActionFunctionArgs) {
2425
}
2526

2627
const { organizationId } = ParamsSchema.parse(params);
27-
const body = BillingLimitHitWebhookBodySchema.parse(await request.json());
28+
29+
let body: BillingLimitHitWebhookBody;
30+
try {
31+
body = BillingLimitHitWebhookBodySchema.parse(await request.json());
32+
} catch (error) {
33+
logger.error("Invalid billing limit hit webhook payload", {
34+
error,
35+
organizationId,
36+
});
37+
return json({ error: "Invalid request body" }, { status: 400 });
38+
}
2839

2940
const organization = await prisma.organization.findFirst({
3041
where: { id: organizationId },

apps/webapp/app/runEngine/validators/validateProductionEntitlement.server.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
import type { ReportUsageResult } from "@trigger.dev/platform/v3";
1+
import type { EntitlementResult } from "~/services/billingLimit.schemas";
22
import { OutOfEntitlementError } from "~/v3/outOfEntitlementError.server";
33
import type { EntitlementValidationParams, EntitlementValidationResult } from "../types";
44

55
export type GetEntitlementFn = (
66
organizationId: string
7-
) => Promise<ReportUsageResult | undefined>;
7+
) => Promise<EntitlementResult | undefined>;
88

99
export async function validateProductionEntitlement(
1010
params: EntitlementValidationParams,

0 commit comments

Comments
 (0)