Files
2026-07-13 13:32:57 +08:00

1145 lines
33 KiB
TypeScript

import { MachinePresetName, tryCatch } from "@trigger.dev/core/v3";
import type { RuntimeEnvironmentType } from "@trigger.dev/database";
import {
BillingClient,
defaultMachine as defaultMachineFromPlatform,
machines as machinesFromPlatform,
type BillingAlertsResult,
type CreatePrivateLinkConnectionBody,
type Limits,
type MachineCode,
type PrivateLinkConnection,
type PrivateLinkConnectionList,
type PrivateLinkRegionsResult,
type SetPlanBody,
type UpdateBillingAlertsRequest,
type UsageResult,
type UsageSeriesParams,
type CurrentPlan,
} from "@trigger.dev/platform";
import {
BillingLimitResultSchema,
BillingLimitsActiveResultSchema,
BillingLimitsPendingResolvesResultSchema,
EntitlementResultSchema,
asPlatformSchema,
type BillingLimitResult,
type BillingLimitsActiveResult,
type BillingLimitsPendingResolvesResult,
type EntitlementResult,
type ResolveBillingLimitRequest,
type UpdateBillingLimitRequest,
} from "~/services/billingLimit.schemas";
import { createCache, DefaultStatefulContext, Namespace } from "@unkey/cache";
import { createLRUMemoryStore } from "@internal/cache";
import { existsSync, readFileSync } from "node:fs";
import { redirect } from "remix-typedjson";
import { z } from "zod";
import { env } from "~/env.server";
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
import { logger } from "~/services/logger.server";
import { newProjectPath, organizationBillingPath } from "~/utils/pathBuilder";
import { singleton } from "~/utils/singleton";
import { RedisCacheStore } from "./unkey/redisCacheStore.server";
import { $replica } from "~/db.server";
import { metrics } from "@opentelemetry/api";
function initializeClient() {
if (isCloud() && process.env.BILLING_API_URL && process.env.BILLING_API_KEY) {
const client = new BillingClient({
url: process.env.BILLING_API_URL,
apiKey: process.env.BILLING_API_KEY,
});
return client;
}
}
const client = singleton("billingClient", initializeClient);
/**
* `true` when the billing client was instantiated — i.e. we're running
* in a cloud-style install with `BILLING_API_URL` + `BILLING_API_KEY`
* configured. OSS / self-hosted installs return `false` here, which
* lets callers distinguish "no billing wired up, fall back to
* defaults" from "billing wired up but the call failed, retry."
*/
export function isBillingConfigured(): boolean {
return client !== undefined;
}
// Failures from @trigger.dev/platform billing client calls are tracked via
// this metric (with low-cardinality {function, kind} labels) rather than
// logged. Every task invocation hits these paths, so per-call logs were too
// noisy; dashboard the counter for visibility instead.
const platformClientMeter = metrics.getMeter("trigger.dev/platform-client");
const platformClientFailuresCounter = platformClientMeter.createCounter(
"platform_client.failures_total",
{
description: "Failures returned or thrown by @trigger.dev/platform billing client calls",
}
);
function recordPlatformFailure(fn: string, kind: "caught" | "no_success") {
platformClientFailuresCounter.add(1, { function: fn, kind });
}
export type ValidatedPromoCode = {
valid: boolean;
amountInCents?: number;
expiresAt?: string | null;
};
/**
* Validate a promo code (no org context). Returns `undefined` when billing
* isn't configured or the call fails, so callers fall back to treating the
* code as not-yet-validated rather than crashing the page.
*/
export async function validatePromoCode(code: string): Promise<ValidatedPromoCode | undefined> {
if (!client) {
return undefined;
}
const [error, result] = await tryCatch(client.validatePromoCode(code));
if (error) {
recordPlatformFailure("validatePromoCode", "caught");
logger.error("validatePromoCode threw", { error });
return undefined;
}
if (!result.success) {
recordPlatformFailure("validatePromoCode", "no_success");
return undefined;
}
return {
valid: result.valid,
amountInCents: result.amountInCents,
expiresAt: result.expiresAt,
};
}
export type AppliedPromoCode = {
applied: boolean;
amountInCents?: number;
reason?: string;
};
/**
* Apply a promo code to a newly created org. Returns `undefined` when billing
* isn't configured or the call fails — callers treat that as "not applied" and
* must never block org creation on it.
*/
export async function applyPromoCode(
orgId: string,
userId: string,
code: string
): Promise<AppliedPromoCode | undefined> {
if (!client) {
return undefined;
}
const [error, result] = await tryCatch(client.applyPromoCode(orgId, { code, userId }));
if (error) {
recordPlatformFailure("applyPromoCode", "caught");
logger.error("applyPromoCode threw", { error });
return undefined;
}
if (!result.success) {
recordPlatformFailure("applyPromoCode", "no_success");
return undefined;
}
return {
applied: result.applied,
amountInCents: result.amountInCents,
reason: result.reason,
};
}
function initializePlatformCache() {
const ctx = new DefaultStatefulContext();
const memory = createLRUMemoryStore(1000);
const redisCacheStore = new RedisCacheStore({
connection: {
keyPrefix: "tr:cache:platform:v3",
port: env.CACHE_REDIS_PORT,
host: env.CACHE_REDIS_HOST,
username: env.CACHE_REDIS_USERNAME,
password: env.CACHE_REDIS_PASSWORD,
tlsDisabled: env.CACHE_REDIS_TLS_DISABLED === "true",
clusterMode: env.CACHE_REDIS_CLUSTER_MODE_ENABLED === "1",
},
});
// This cache holds the limits fetched from the platform service
const cache = createCache({
limits: new Namespace<number>(ctx, {
stores: [memory, redisCacheStore],
fresh: 60_000 * 5, // 5 minutes
stale: 60_000 * 10, // 10 minutes
}),
usage: new Namespace<UsageResult>(ctx, {
stores: [memory, redisCacheStore],
fresh: 60_000 * 5, // 5 minutes
stale: 60_000 * 10, // 10 minutes
}),
entitlement: new Namespace<EntitlementResult>(ctx, {
stores: [memory, redisCacheStore],
fresh: 60_000, // serve without revalidation for 60s
stale: 120_000, // total TTL — fresh 0-60s, stale-revalidate 60-120s
}),
billingLimit: new Namespace<BillingLimitResult>(ctx, {
stores: [memory, redisCacheStore],
fresh: 60_000,
stale: 120_000,
}),
promoCredits: new Namespace<PromoCreditsData | null>(ctx, {
stores: [memory, redisCacheStore],
fresh: 60_000,
stale: 120_000,
}),
});
return cache;
}
const platformCache = singleton("platformCache", initializePlatformCache);
function invalidateBillingLimitCaches(organizationId: string) {
platformCache.billingLimit.remove(organizationId).catch(() => {});
platformCache.entitlement.remove(organizationId).catch(() => {});
}
export function bustBillingLimitCaches(organizationId: string) {
invalidateBillingLimitCaches(organizationId);
}
// Clear the cached promo-credits read so a just-granted code shows on the usage
// page immediately rather than after the stale TTL.
export function bustPromoCreditsCache(organizationId: string) {
platformCache.promoCredits.remove(organizationId).catch(() => {});
}
type Machines = typeof machinesFromPlatform;
const MachineOverrideValues = z.object({
cpu: z.number(),
memory: z.number(),
});
type MachineOverrideValues = z.infer<typeof MachineOverrideValues>;
const MachineOverrides = z.record(MachinePresetName, MachineOverrideValues.partial());
type MachineOverrides = z.infer<typeof MachineOverrides>;
const MachinePresetOverrides = z.object({
defaultMachine: MachinePresetName.optional(),
machines: MachineOverrides.optional(),
});
function initializeMachinePresets(): {
defaultMachine: MachineCode;
machines: Machines;
} {
const overrides = getMachinePresetOverrides();
if (!overrides) {
return {
defaultMachine: defaultMachineFromPlatform,
machines: machinesFromPlatform,
};
}
logger.info("🎛️ Overriding machine presets", { overrides });
return {
defaultMachine: overrideDefaultMachine(defaultMachineFromPlatform, overrides.defaultMachine),
machines: overrideMachines(machinesFromPlatform, overrides.machines),
};
}
export const { defaultMachine, machines } = singleton("machinePresets", initializeMachinePresets);
function overrideDefaultMachine(defaultMachine: MachineCode, override?: MachineCode): MachineCode {
if (!override) {
return defaultMachine;
}
return override;
}
function overrideMachines(machines: Machines, overrides?: MachineOverrides): Machines {
if (!overrides) {
return machines;
}
const mergedMachines = {
...machines,
};
for (const machine of Object.keys(overrides) as MachinePresetName[]) {
mergedMachines[machine] = {
...mergedMachines[machine],
...overrides[machine],
};
}
return mergedMachines;
}
function getMachinePresetOverrides() {
const path = env.MACHINE_PRESETS_OVERRIDE_PATH;
if (!path) {
return;
}
const overrides = safeReadMachinePresetOverrides(path);
if (!overrides) {
return;
}
const parsed = MachinePresetOverrides.safeParse(overrides);
if (!parsed.success) {
logger.error("Error parsing machine preset overrides", { path, error: parsed.error });
return;
}
return parsed.data;
}
function safeReadMachinePresetOverrides(path: string) {
try {
const fileExists = existsSync(path);
if (!fileExists) {
logger.error("Machine preset overrides file does not exist", { path });
return;
}
const fileContents = readFileSync(path, "utf8");
return JSON.parse(fileContents);
} catch (error) {
logger.error("Error reading machine preset overrides", {
path,
error: error instanceof Error ? error.message : String(error),
});
return;
}
}
export async function getCurrentPlan(orgId: string) {
if (!client) return undefined;
try {
const result = await client.currentPlan(orgId);
const firstDayOfMonth = new Date();
firstDayOfMonth.setUTCDate(1);
firstDayOfMonth.setUTCHours(0, 0, 0, 0);
const firstDayOfNextMonth = new Date();
firstDayOfNextMonth.setUTCDate(1);
firstDayOfNextMonth.setUTCMonth(firstDayOfNextMonth.getUTCMonth() + 1);
firstDayOfNextMonth.setUTCHours(0, 0, 0, 0);
if (!result.success) {
recordPlatformFailure("getCurrentPlan", "no_success");
return undefined;
}
const periodStart = firstDayOfMonth;
const periodEnd = firstDayOfNextMonth;
const periodRemainingDuration = periodEnd.getTime() - new Date().getTime();
const usage = {
periodStart,
periodEnd,
periodRemainingDuration,
};
return { ...result, usage };
} catch (_e) {
recordPlatformFailure("getCurrentPlan", "caught");
return undefined;
}
}
export type SelfServePurchaseBlockReason = "plan_unavailable" | "managed_billing";
/**
* When cloud billing is configured, self-serve purchase endpoints must fail closed
* if the current plan can't be loaded or the org is on managed billing.
*/
export function getSelfServePurchaseBlockReason(
currentPlan: Awaited<ReturnType<typeof getCurrentPlan>>
): SelfServePurchaseBlockReason | undefined {
if (!isBillingConfigured()) {
return undefined;
}
if (!currentPlan) {
return "plan_unavailable";
}
if (currentPlan.v3Subscription?.showSelfServe === false) {
return "managed_billing";
}
return undefined;
}
export async function getLimits(orgId: string) {
if (!client) return undefined;
try {
const result = await client.currentPlan(orgId);
if (!result.success) {
recordPlatformFailure("getLimits", "no_success");
return undefined;
}
return result.v3Subscription?.plan?.limits;
} catch (_e) {
recordPlatformFailure("getLimits", "caught");
return undefined;
}
}
export async function getLimit(orgId: string, limit: keyof Limits, fallback: number) {
const limits = await getLimits(orgId);
if (!limits) return fallback;
const result = limits[limit];
if (!result) return fallback;
if (typeof result === "number") return result;
if (typeof result === "object" && "number" in result) return result.number;
return fallback;
}
export async function getDefaultEnvironmentConcurrencyLimit(
organizationId: string,
environmentType: RuntimeEnvironmentType
): Promise<number> {
if (!client) {
const org = await $replica.organization.findFirst({
where: {
id: organizationId,
},
select: {
maximumConcurrencyLimit: true,
},
});
if (!org) throw new Error("Organization not found");
return org.maximumConcurrencyLimit;
}
const result = await client.currentPlan(organizationId);
if (!result.success) throw new Error("Error getting current plan");
const limit = getDefaultEnvironmentLimitFromPlan(environmentType, result);
if (!limit) throw new Error("No plan found");
return limit;
}
export function getDefaultEnvironmentLimitFromPlan(
environmentType: RuntimeEnvironmentType,
plan: CurrentPlan
): number | undefined {
if (!plan.v3Subscription?.plan) return undefined;
switch (environmentType) {
case "DEVELOPMENT":
return plan.v3Subscription.plan.limits.concurrentRuns.development;
case "STAGING":
return plan.v3Subscription.plan.limits.concurrentRuns.staging;
case "PREVIEW":
return plan.v3Subscription.plan.limits.concurrentRuns.preview;
case "PRODUCTION":
return plan.v3Subscription.plan.limits.concurrentRuns.production;
default:
return plan.v3Subscription.plan.limits.concurrentRuns.number;
}
}
export async function getCachedLimit(orgId: string, limit: keyof Limits, fallback: number) {
return platformCache.limits.swr(`${orgId}:${limit}`, async () => {
return getLimit(orgId, limit, fallback);
});
}
export async function customerPortalUrl(orgId: string, orgSlug: string) {
if (!client) return undefined;
try {
return client.createPortalSession(orgId, {
returnUrl: `${env.APP_ORIGIN}${organizationBillingPath({ slug: orgSlug })}`,
});
} catch (_e) {
recordPlatformFailure("customerPortalUrl", "caught");
return undefined;
}
}
export async function getPlans() {
if (!client) return undefined;
try {
const result = await client.plans();
if (!result.success) {
recordPlatformFailure("getPlans", "no_success");
return undefined;
}
return result;
} catch (_e) {
recordPlatformFailure("getPlans", "caught");
return undefined;
}
}
export async function setPlan(
organization: { id: string; slug: string },
request: Request,
callerPath: string,
plan: SetPlanBody,
opts?: {
invalidateBillingCache?: (orgId: string) => void;
// Runs only after the Free plan has actually been provisioned, with the
// redirect it will return — the single success path where side effects that
// depend on a working free-plan entitlement (e.g. redeeming a promo code)
// are safe. It never fires on an error path, so callers can't act on a
// plan change that didn't happen.
onFreePlanProvisioned?: (response: Response) => void | Promise<void>;
}
) {
if (!client) {
return redirectWithErrorMessage(callerPath, request, "Error setting plan", {
ephemeral: false,
});
}
const [error, result] = await tryCatch(client.setPlan(organization.id, plan));
if (error) {
return redirectWithErrorMessage(callerPath, request, error.message, { ephemeral: false });
}
if (!result) {
return redirectWithErrorMessage(callerPath, request, "Error setting plan", {
ephemeral: false,
});
}
if (!result.success) {
return redirectWithErrorMessage(callerPath, request, result.error, { ephemeral: false });
}
switch (result.action) {
case "free_connect_required":
case "free_connected": {
// Selecting Free provisions the plan directly, so any free result is a success.
opts?.invalidateBillingCache?.(organization.id);
platformCache.entitlement.remove(organization.id).catch(() => {});
const response = redirect(newProjectPath(organization, "You're on the Free plan."));
await opts?.onFreePlanProvisioned?.(response);
return response;
}
case "create_subscription_flow_start": {
return redirect(result.checkoutUrl);
}
case "updated_subscription": {
// Invalidate billing cache since subscription changed
opts?.invalidateBillingCache?.(organization.id);
platformCache.entitlement.remove(organization.id).catch(() => {});
return redirectWithSuccessMessage(callerPath, request, "Subscription updated successfully.");
}
case "canceled_subscription": {
// Invalidate billing cache since subscription was canceled
opts?.invalidateBillingCache?.(organization.id);
platformCache.entitlement.remove(organization.id).catch(() => {});
return redirectWithSuccessMessage(callerPath, request, "Subscription canceled.");
}
}
// Unrecognised action shape — surface an error rather than falling through to
// an implicit undefined return, so callers always get a Response back.
return redirectWithErrorMessage(callerPath, request, "Error setting plan", {
ephemeral: false,
});
}
export async function setConcurrencyAddOn(organizationId: string, amount: number) {
if (!client) return undefined;
try {
const result = await client.setAddOn(organizationId, { type: "concurrency", amount });
if (!result.success) {
recordPlatformFailure("setConcurrencyAddOn", "no_success");
return undefined;
}
return result;
} catch (_e) {
recordPlatformFailure("setConcurrencyAddOn", "caught");
return undefined;
}
}
export async function setSeatsAddOn(organizationId: string, amount: number) {
if (!client) return undefined;
try {
const result = await client.setAddOn(organizationId, { type: "seats", amount });
if (!result.success) {
recordPlatformFailure("setSeatsAddOn", "no_success");
return undefined;
}
return result;
} catch (_e) {
recordPlatformFailure("setSeatsAddOn", "caught");
return undefined;
}
}
export async function setBranchesAddOn(organizationId: string, amount: number) {
if (!client) return undefined;
try {
const result = await client.setAddOn(organizationId, { type: "branches", amount });
if (!result.success) {
recordPlatformFailure("setBranchesAddOn", "no_success");
return undefined;
}
return result;
} catch (_e) {
recordPlatformFailure("setBranchesAddOn", "caught");
return undefined;
}
}
export async function setSchedulesAddOn(organizationId: string, amount: number) {
if (!client) return undefined;
try {
const result = await client.setAddOn(organizationId, { type: "schedules", amount });
if (!result.success) {
recordPlatformFailure("setSchedulesAddOn", "no_success");
return undefined;
}
return result;
} catch (_e) {
recordPlatformFailure("setSchedulesAddOn", "caught");
return undefined;
}
}
export async function getUsage(organizationId: string, { from, to }: { from: Date; to: Date }) {
if (!client) return undefined;
try {
const result = await client.usage(organizationId, { from, to });
if (!result.success) {
recordPlatformFailure("getUsage", "no_success");
return undefined;
}
return result;
} catch (_e) {
recordPlatformFailure("getUsage", "caught");
return undefined;
}
}
export async function getCachedUsage(
organizationId: string,
{ from, to }: { from: Date; to: Date }
) {
if (!client) return undefined;
try {
const result = await platformCache.usage.swr(
`${organizationId}:${from.toISOString()}:${to.toISOString()}`,
async () => {
const usageResponse = await getUsage(organizationId, { from, to });
return usageResponse;
}
);
return result.val;
} catch (_e) {
recordPlatformFailure("getCachedUsage", "caught");
return undefined;
}
}
export async function getUsageSeries(organizationId: string, params: UsageSeriesParams) {
if (!client) return undefined;
try {
const result = await client.usageSeries(organizationId, params);
if (!result.success) {
recordPlatformFailure("getUsageSeries", "no_success");
return undefined;
}
return result;
} catch (_e) {
recordPlatformFailure("getUsageSeries", "caught");
return undefined;
}
}
export async function reportInvocationUsage(
organizationId: string,
costInCents: number,
additionalData?: Record<string, any>
) {
if (!client) return undefined;
try {
const result = await client.reportInvocationUsage({
organizationId,
costInCents,
additionalData,
});
if (!result.success) {
recordPlatformFailure("reportInvocationUsage", "no_success");
return undefined;
}
return result;
} catch (_e) {
recordPlatformFailure("reportInvocationUsage", "caught");
return undefined;
}
}
export async function reportComputeUsage(request: Request) {
if (!client) return undefined;
return fetch(`${process.env.BILLING_API_URL}/api/v1/usage/ingest/compute`, {
method: "POST",
headers: request.headers,
body: await request.text(),
});
}
export async function getEntitlement(
organizationId: string
): Promise<EntitlementResult | undefined> {
if (!client) return undefined;
// Errors must be caught inside the loader — @unkey/cache passes the loader
// promise to waitUntil() with no .catch(), so an unhandled rejection during
// background SWR revalidation would crash the process. Returning undefined
// on error tells SWR not to commit a fail-open value to the cache, which
// prevents transient billing errors from overwriting a legitimate
// hasAccess: false entry. The fail-open default is applied *outside* the
// SWR call so it never becomes a cached access decision.
const result = await platformCache.entitlement.swr(organizationId, async () => {
try {
const response = await client.fetch(
`/api/v1/orgs/${organizationId}/usage/entitlement`,
asPlatformSchema(EntitlementResultSchema)
);
if (!response.success) {
recordPlatformFailure("getEntitlement", "no_success");
return undefined;
}
return response;
} catch (_e) {
recordPlatformFailure("getEntitlement", "caught");
return undefined;
}
});
if (result.err || result.val === undefined) {
return {
hasAccess: true as const,
};
}
return result.val;
}
export type PromoCreditsData = {
grantedCents: number;
remainingCents: number;
expiresAt: string | null;
};
/**
* Remaining promo/credit-grant balance for an org, or null when it has none.
* Billing-side gating keeps this cheap for orgs without credits; the SWR cache
* keeps repeated dashboard loads off the network. Fails closed to null so the
* display is simply hidden on any error — never blocks the page.
*/
export async function getPromoCredits(organizationId: string): Promise<PromoCreditsData | null> {
if (!client) return null;
const result = await platformCache.promoCredits.swr(organizationId, async () => {
try {
const response = await client.promoCredits(organizationId);
if (!response.success) {
recordPlatformFailure("promoCredits", "no_success");
// Return undefined (not null) so SWR doesn't cache a transient failure
// as "no credits" and hide the display for the stale TTL. null is
// reserved for a successful "org has no promo credits" response.
return undefined;
}
return response.promoCredits;
} catch (_e) {
recordPlatformFailure("promoCredits", "caught");
logger.error("promoCredits threw", { error: _e });
return undefined;
}
});
if (result.err || result.val === undefined) {
return null;
}
return result.val;
}
export async function getBillingLimit(
organizationId: string
): Promise<BillingLimitResult | undefined> {
if (!client) return undefined;
// Loader callback errors are caught below; also guard the SWR read itself so
// Redis/cache infra failures cannot reject org-layout Promise.all callers.
try {
const result = await platformCache.billingLimit.swr(organizationId, async () => {
try {
const response = await client.fetch(
`/api/v1/orgs/${organizationId}/billing-limit`,
asPlatformSchema(BillingLimitResultSchema)
);
if (!response.success) {
recordPlatformFailure("getBillingLimit", "no_success");
return undefined;
}
return response;
} catch (_e) {
recordPlatformFailure("getBillingLimit", "caught");
return undefined;
}
});
if (result.err || result.val === undefined) {
return undefined;
}
return result.val;
} catch (_e) {
recordPlatformFailure("getBillingLimit", "caught");
return undefined;
}
}
export async function setBillingLimit(
organizationId: string,
config: UpdateBillingLimitRequest
): Promise<BillingLimitResult | undefined> {
if (!client) return undefined;
const response = await client.fetch(
`/api/v1/orgs/${organizationId}/billing-limit`,
asPlatformSchema(BillingLimitResultSchema),
{
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(config),
}
);
if (!response.success) {
recordPlatformFailure("setBillingLimit", "no_success");
throw new Error(response.error ?? "Error setting billing limit");
}
invalidateBillingLimitCaches(organizationId);
return response;
}
export async function resolveBillingLimit(
organizationId: string,
payload: ResolveBillingLimitRequest
): Promise<BillingLimitResult | undefined> {
if (!client) return undefined;
const response = await client.fetch(
`/api/v1/orgs/${organizationId}/billing-limit/resolve`,
asPlatformSchema(BillingLimitResultSchema),
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
}
);
if (!response.success) {
recordPlatformFailure("resolveBillingLimit", "no_success");
throw new Error(response.error ?? "Error resolving billing limit");
}
invalidateBillingLimitCaches(organizationId);
return response;
}
/** Admin: orgs currently in grace or rejected — used by reconciliation worker (Phase 2). */
export async function getActiveBillingLimits(): Promise<BillingLimitsActiveResult | undefined> {
if (!client) return undefined;
try {
const response = await client.fetch(
`/api/v1/billing-limits/active`,
asPlatformSchema(BillingLimitsActiveResultSchema)
);
if (!response.success) {
recordPlatformFailure("getActiveBillingLimits", "no_success");
return undefined;
}
return response;
} catch (_e) {
recordPlatformFailure("getActiveBillingLimits", "caught");
return undefined;
}
}
/** Admin: orgs with pending resolve side effects — used by reconciliation worker. */
export async function getPendingBillingLimitResolves(): Promise<
BillingLimitsPendingResolvesResult | undefined
> {
if (!client) return undefined;
try {
const response = await client.fetch(
`/api/v1/billing-limits/pending-resolves`,
asPlatformSchema(BillingLimitsPendingResolvesResultSchema)
);
if (!response.success) {
recordPlatformFailure("getPendingBillingLimitResolves", "no_success");
return undefined;
}
return response;
} catch (_e) {
recordPlatformFailure("getPendingBillingLimitResolves", "caught");
return undefined;
}
}
/** Admin: mark billing limit resolve side effects as completed after webapp convergence. */
export async function completeBillingLimitResolve(
organizationId: string
): Promise<{ completed: boolean } | undefined> {
if (!client) return undefined;
const response = await client.fetch(
`/api/v1/orgs/${organizationId}/billing-limit/resolve-complete`,
asPlatformSchema(z.object({ completed: z.boolean() })),
{
method: "POST",
}
);
if (!response.success) {
recordPlatformFailure("completeBillingLimitResolve", "no_success");
throw new Error(response.error ?? "Error completing billing limit resolve");
}
return response;
}
export async function getBillingAlerts(
organizationId: string
): Promise<BillingAlertsResult | undefined> {
if (!client) return undefined;
const result = await client.getBillingAlerts(organizationId);
if (!result.success) {
recordPlatformFailure("getBillingAlert", "no_success");
throw new Error("Error getting billing alert");
}
return result;
}
export async function setBillingAlert(
organizationId: string,
alert: UpdateBillingAlertsRequest
): Promise<BillingAlertsResult | undefined> {
if (!client) return undefined;
const result = await client.updateBillingAlerts(organizationId, alert);
if (!result.success) {
recordPlatformFailure("setBillingAlert", "no_success");
throw new Error("Error setting billing alert");
}
return result;
}
export async function generateRegistryCredentials(
projectId: string,
region: "us-east-1" | "eu-central-1"
) {
if (!client) return undefined;
const result = await client.generateRegistryCredentials(projectId, region);
if (!result.success) {
recordPlatformFailure("generateRegistryCredentials", "no_success");
throw new Error("Failed to generate registry credentials");
}
return result;
}
export async function enqueueBuild(
projectId: string,
deploymentId: string,
artifactKey: string,
options: {
skipPromotion?: boolean;
configFilePath?: string;
}
) {
if (!client) return undefined;
const result = await client.enqueueBuild(projectId, { deploymentId, artifactKey, options });
if (!result.success) {
recordPlatformFailure("enqueueBuild", "no_success");
throw new Error("Failed to enqueue build");
}
return result;
}
export async function getPrivateLinks(
organizationId: string
): Promise<PrivateLinkConnectionList | undefined> {
if (!client) return undefined;
const [error, result] = await tryCatch(client.getPrivateLinks(organizationId));
if (error) {
recordPlatformFailure("getPrivateLinks", "caught");
return undefined;
}
if (!result.success) {
recordPlatformFailure("getPrivateLinks", "no_success");
return undefined;
}
return result;
}
export async function createPrivateLink(
organizationId: string,
body: CreatePrivateLinkConnectionBody
): Promise<PrivateLinkConnection | undefined> {
if (!client) throw new Error("Platform client not configured");
const [error, result] = await tryCatch(client.createPrivateLink(organizationId, body));
if (error) {
recordPlatformFailure("createPrivateLink", "caught");
throw error;
}
if (!result.success) {
recordPlatformFailure("createPrivateLink", "no_success");
throw new Error(result.error ?? "Failed to create private link");
}
return result;
}
export async function deletePrivateLink(
organizationId: string,
connectionId: string
): Promise<void> {
if (!client) throw new Error("Platform client not configured");
const [error, result] = await tryCatch(client.deletePrivateLink(organizationId, connectionId));
if (error) {
recordPlatformFailure("deletePrivateLink", "caught");
throw error;
}
if (!result.success) {
recordPlatformFailure("deletePrivateLink", "no_success");
throw new Error(result.error ?? "Failed to delete private link");
}
}
export async function getPrivateLinkRegions(
organizationId: string
): Promise<PrivateLinkRegionsResult | undefined> {
if (!client) return undefined;
const [error, result] = await tryCatch(client.getPrivateLinkRegions(organizationId));
if (error) {
recordPlatformFailure("getPrivateLinkRegions", "caught");
return undefined;
}
if (!result.success) {
recordPlatformFailure("getPrivateLinkRegions", "no_success");
return undefined;
}
return result;
}
export async function triggerInitialDeployment(
projectId: string,
options: { environment: "preview" | "prod" | "staging" }
): Promise<void> {
if (!client) return;
const [error, result] = await tryCatch(client.triggerInitialDeployment(projectId, options));
if (error) {
logger.warn("Error triggering initial deployment", {
projectId,
environment: options.environment,
error,
});
return;
}
if (!result.success) {
logger.warn("Failed to trigger initial deployment", {
projectId,
environment: options.environment,
error: result.error,
});
}
}
export type {
BillingLimitConfig,
BillingLimitPageData,
BillingLimitResult,
BillingLimitState,
BillingLimitsActiveResult,
EntitlementResult,
ResolveBillingLimitRequest,
UpdateBillingLimitRequest,
} from "~/services/billingLimit.schemas";
export function isCloud(): boolean {
const acceptableHosts = [
"https://cloud.trigger.dev",
"https://test-cloud.trigger.dev",
"https://internal.trigger.dev",
];
if (acceptableHosts.includes(env.LOGIN_ORIGIN)) {
return true;
}
if (process.env.CLOUD_ENV === "development" && process.env.NODE_ENV === "development") {
return true;
}
return false;
}