1145 lines
33 KiB
TypeScript
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;
|
|
}
|