import type { MeterId } from "./meters"; /** * Plans are config-driven: `METERING_PLANS` (JSON, same shape as DEFAULT_PLANS) overrides these defaults. * `included` is free per period; usage past it is billed (paid plans) or blocked (hard caps). * `hardCaps` are enforced atomically by reserveUsage; every other meter is soft and only alerts. */ export type PlanId = "scratch" | "free" | "pro"; export type Plan = { id: PlanId; label: string; included: Partial>; hardCaps: Partial>; /** Whether overage is billed; when false every included amount acts as a soft cap with alerts. */ billsOverage: boolean; }; const GB = 1024 ** 3; const HOUR = 3600; export const DEFAULT_PLANS: Record = { scratch: { id: "scratch", label: "Scratch (anonymous)", included: { "storage.sites": 0.1 * GB, "compute.site_requests": 10_000, "network.sites": 1 * GB }, hardCaps: { "storage.sites": 0.1 * GB, "compute.site_requests": 10_000, "network.sites": 1 * GB, "ai.calls": 0, "compute.editor": 0, }, billsOverage: false, }, free: { id: "free", label: "Free", included: { "storage.artifacts": 5 * GB, "storage.lfs": 10 * GB, "storage.ci_cache": 10 * GB, "storage.sites": 1 * GB, "storage.r2": 1 * GB, "compute.runner": 33 * HOUR, "compute.runner_large": 0, "compute.runner_xlarge": 0, "compute.editor": 20 * HOUR, "compute.site_requests": 1_000_000, "compute.site_cpu": 10_000_000, "network.lfs": 10 * GB, "network.sites": 100 * GB, "ai.calls": 500, "ai.tokens": 2_000_000, }, hardCaps: { "ai.calls": 500, "ai.tokens": 2_000_000, "compute.editor": 20 * HOUR }, billsOverage: false, }, pro: { id: "pro", label: "Pro", included: { "storage.artifacts": 50 * GB, "storage.lfs": 50 * GB, "storage.ci_cache": 50 * GB, "storage.sites": 10 * GB, "storage.r2": 10 * GB, "compute.runner": 100 * HOUR, "compute.runner_large": 20 * HOUR, "compute.runner_xlarge": 10 * HOUR, "compute.editor": 100 * HOUR, "compute.site_requests": 10_000_000, "compute.site_cpu": 30_000_000, "network.lfs": 50 * GB, "network.sites": 1000 * GB, "ai.calls": 5000, "ai.tokens": 20_000_000, }, hardCaps: {}, billsOverage: true, }, }; export function isPlanId(value: string): value is PlanId { return value === "scratch" || value === "free" || value === "pro"; } export function parsePlans(raw: string | undefined): Record { if (!raw) return DEFAULT_PLANS; try { const parsed = JSON.parse(raw) as Partial>>; const out = { ...DEFAULT_PLANS }; for (const id of Object.keys(DEFAULT_PLANS) as PlanId[]) { const override = parsed[id]; if (override) out[id] = { ...DEFAULT_PLANS[id], ...override, id }; } return out; } catch { return DEFAULT_PLANS; } } export const ALERT_THRESHOLDS = [50, 80, 100] as const; /** The highest alert threshold crossed by `used` of `limit`, or null. */ /** * Editor containers need a plan that includes some compute.editor or doesn't hard-cap it at zero: * `scratch` (and `free` with its allowance set to 0) are out, `pro` is in even with a spending limit. */ export function planAllowsEditorContainers(plan: Plan): boolean { const included = plan.included["compute.editor"] ?? 0; const cap = plan.hardCaps["compute.editor"]; return included > 0 || cap === undefined || cap > 0; } /** Platform-billed AI features need some included ai.calls or a hard cap above zero; BYOK is checked separately. */ export function planAllowsAi(plan: Plan): boolean { const included = plan.included["ai.calls"] ?? 0; const cap = plan.hardCaps["ai.calls"]; return included > 0 || (cap !== undefined && cap > 0); } export function crossedThreshold(used: number, limit: number): number | null { if (limit <= 0) return null; const percent = (used / limit) * 100; let crossed: number | null = null; for (const threshold of ALERT_THRESHOLDS) if (percent >= threshold) crossed = threshold; return crossed; }