Skip to content

Commit 03fea35

Browse files
committed
feat(webapp): reject new triggers while a billing limit is active
Reject triggers with a 422 once entitlement reports no access, and bust the entitlement cache on state changes.
1 parent d521260 commit 03fea35

7 files changed

Lines changed: 198 additions & 22 deletions
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { type ActionFunctionArgs, json } from "@remix-run/server-runtime";
2+
import { z } from "zod";
3+
import { prisma } from "~/db.server";
4+
import { requireAdminApiRequest } from "~/services/personalAccessToken.server";
5+
import { bustBillingLimitCaches } from "~/services/platform.v3.server";
6+
import { BillingLimitConvergeEnvironmentsService } from "~/v3/services/billingLimit/billingLimitConvergeEnvironmentsService.server";
7+
import { enqueueBillingLimitConverge } from "~/v3/billingLimitWorker.server";
8+
9+
const ParamsSchema = z.object({
10+
organizationId: z.string(),
11+
});
12+
13+
/** Billing platform webhook: org billing limit grace expired. Idempotent — returns 202. */
14+
export async function action({ request, params }: ActionFunctionArgs) {
15+
await requireAdminApiRequest(request);
16+
17+
if (request.method.toLowerCase() !== "post") {
18+
return json({ error: "Method not allowed" }, { status: 405 });
19+
}
20+
21+
const { organizationId } = ParamsSchema.parse(params);
22+
23+
const organization = await prisma.organization.findFirst({
24+
where: { id: organizationId },
25+
select: { id: true },
26+
});
27+
28+
if (!organization) {
29+
return json({ error: "Organization not found" }, { status: 404 });
30+
}
31+
32+
bustBillingLimitCaches(organizationId);
33+
await BillingLimitConvergeEnvironmentsService.seedReconcileQueue(organizationId);
34+
await enqueueBillingLimitConverge(organizationId, "rejected");
35+
36+
return json({ success: true, accepted: true }, { status: 202 });
37+
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { type ActionFunctionArgs, json } from "@remix-run/server-runtime";
2+
import { z } from "zod";
3+
import { prisma } from "~/db.server";
4+
import { requireAdminApiRequest } from "~/services/personalAccessToken.server";
5+
import { completeBillingLimitResolve } from "~/services/platform.v3.server";
6+
import { logger } from "~/services/logger.server";
7+
import {
8+
convergeBillingLimitResolve,
9+
type PendingBillingLimitResolve,
10+
} from "~/v3/services/billingLimit/billingLimitConvergeResolve.server";
11+
12+
const ParamsSchema = z.object({
13+
organizationId: z.string(),
14+
});
15+
16+
const BodySchema = z.object({
17+
resumeMode: z.enum(["queue", "new_only"]),
18+
resolvedAt: z.string(),
19+
});
20+
21+
/** Billing platform webhook: org resolved billing limit to ok. Idempotent — returns 202. */
22+
export async function action({ request, params }: ActionFunctionArgs) {
23+
await requireAdminApiRequest(request);
24+
25+
if (request.method.toLowerCase() !== "post") {
26+
return json({ error: "Method not allowed" }, { status: 405 });
27+
}
28+
29+
const { organizationId } = ParamsSchema.parse(params);
30+
31+
const organization = await prisma.organization.findFirst({
32+
where: { id: organizationId },
33+
select: { id: true },
34+
});
35+
36+
if (!organization) {
37+
return json({ error: "Organization not found" }, { status: 404 });
38+
}
39+
40+
let pending: PendingBillingLimitResolve;
41+
try {
42+
const body = await request.json();
43+
pending = {
44+
organizationId,
45+
...BodySchema.parse(body),
46+
};
47+
} catch (error) {
48+
logger.error("Invalid billing limit resolve webhook payload", {
49+
error,
50+
organizationId,
51+
});
52+
return json({ error: "Invalid request body" }, { status: 400 });
53+
}
54+
55+
try {
56+
await convergeBillingLimitResolve(pending);
57+
await completeBillingLimitResolve(organizationId);
58+
} catch (error) {
59+
logger.error("Billing limit resolve webhook failed", {
60+
error,
61+
organizationId,
62+
resumeMode: pending.resumeMode,
63+
resolvedAt: pending.resolvedAt,
64+
});
65+
return json({ error: "Failed to process billing limit resolve" }, { status: 500 });
66+
}
67+
68+
return json({ success: true, accepted: true }, { status: 202 });
69+
}

apps/webapp/app/runEngine/validators/triggerTaskValidator.ts

Lines changed: 3 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { MAX_TAGS_PER_RUN } from "~/models/taskRunTag.server";
22
import { logger } from "~/services/logger.server";
33
import { getEntitlement } from "~/services/platform.v3.server";
4-
import { MAX_ATTEMPTS, OutOfEntitlementError } from "~/v3/services/triggerTask.server";
4+
import { MAX_ATTEMPTS } from "~/v3/services/triggerTask.server";
55
import { isFinalRunStatus } from "~/v3/taskStatus";
66
import type {
77
EntitlementValidationParams,
@@ -12,6 +12,7 @@ import type {
1212
TriggerTaskValidator,
1313
ValidationResult,
1414
} from "../types";
15+
import { validateProductionEntitlement } from "./validateProductionEntitlement.server";
1516
import { ServiceValidationError } from "~/v3/services/common.server";
1617

1718
export class DefaultTriggerTaskValidator implements TriggerTaskValidator {
@@ -41,22 +42,7 @@ export class DefaultTriggerTaskValidator implements TriggerTaskValidator {
4142
async validateEntitlement(
4243
params: EntitlementValidationParams
4344
): Promise<EntitlementValidationResult> {
44-
const { environment } = params;
45-
46-
if (environment.type === "DEVELOPMENT") {
47-
return { ok: true };
48-
}
49-
50-
const result = await getEntitlement(environment.organizationId);
51-
52-
if (result && result.hasAccess === false) {
53-
return {
54-
ok: false,
55-
error: new OutOfEntitlementError(),
56-
};
57-
}
58-
59-
return { ok: true, plan: result?.plan };
45+
return validateProductionEntitlement(params, getEntitlement);
6046
}
6147

6248
validateMaxAttempts(params: MaxAttemptsValidationParams): ValidationResult {
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import type { ReportUsageResult } from "@trigger.dev/platform/v3";
2+
import { OutOfEntitlementError } from "~/v3/outOfEntitlementError.server";
3+
import type { EntitlementValidationParams, EntitlementValidationResult } from "../types";
4+
5+
export type GetEntitlementFn = (
6+
organizationId: string
7+
) => Promise<ReportUsageResult | undefined>;
8+
9+
export async function validateProductionEntitlement(
10+
params: EntitlementValidationParams,
11+
getEntitlementFn: GetEntitlementFn
12+
): Promise<EntitlementValidationResult> {
13+
const { environment } = params;
14+
15+
if (environment.type === "DEVELOPMENT") {
16+
return { ok: true };
17+
}
18+
19+
const result = await getEntitlementFn(environment.organizationId);
20+
21+
if (result && result.hasAccess === false) {
22+
return {
23+
ok: false,
24+
error: new OutOfEntitlementError(),
25+
};
26+
}
27+
28+
return { ok: true, plan: result?.plan };
29+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export class OutOfEntitlementError extends Error {
2+
constructor() {
3+
super("You can't trigger a task because you have run out of credits.");
4+
this.name = "OutOfEntitlementError";
5+
}
6+
}

apps/webapp/app/v3/services/triggerTask.server.ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,7 @@ export type TriggerTaskServiceOptions = {
3737
triggerAction?: string;
3838
};
3939

40-
export class OutOfEntitlementError extends Error {
41-
constructor() {
42-
super("You can't trigger a task because you have run out of credits.");
43-
}
44-
}
40+
export { OutOfEntitlementError } from "../outOfEntitlementError.server";
4541

4642
export type TriggerTaskServiceResult = {
4743
run: TaskRun;
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { describe, expect, it } from "vitest";
2+
import { validateProductionEntitlement } from "~/runEngine/validators/validateProductionEntitlement.server";
3+
4+
const productionEnv = {
5+
type: "PRODUCTION" as const,
6+
organizationId: "org_123",
7+
};
8+
9+
const developmentEnv = {
10+
type: "DEVELOPMENT" as const,
11+
organizationId: "org_123",
12+
};
13+
14+
describe("validateProductionEntitlement", () => {
15+
it("allows development environments without checking entitlement", async () => {
16+
const result = await validateProductionEntitlement(
17+
{ environment: developmentEnv as never },
18+
async () => ({ hasAccess: false, reason: "billing_limit" })
19+
);
20+
21+
expect(result).toEqual({ ok: true });
22+
});
23+
24+
it("rejects production triggers when entitlement has billing_limit denial", async () => {
25+
const result = await validateProductionEntitlement(
26+
{ environment: productionEnv as never },
27+
async () => ({
28+
hasAccess: false,
29+
reason: "billing_limit",
30+
plan: { type: "paid", code: "pro", isPaying: true },
31+
})
32+
);
33+
34+
expect(result.ok).toBe(false);
35+
if (!result.ok) {
36+
expect(result.error.name).toBe("OutOfEntitlementError");
37+
}
38+
});
39+
40+
it("allows production triggers when entitlement grants access", async () => {
41+
const plan = { type: "paid" as const, code: "pro", isPaying: true };
42+
43+
const result = await validateProductionEntitlement(
44+
{ environment: productionEnv as never },
45+
async () => ({
46+
hasAccess: true,
47+
plan,
48+
})
49+
);
50+
51+
expect(result).toEqual({ ok: true, plan });
52+
});
53+
});

0 commit comments

Comments
 (0)