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; /** * The monthly overage limit for accounts that never set one (spending_limit_cents NULL). Without it a * paid plan's overage would be unbounded by default; owners can raise or lower it on the billing page. */ defaultSpendingLimitCents?: number; /** Without Stripe billing (self-hosted), whether accounts on this plan may add custom domains. */ customDomains?: boolean; }; const GB = 1024 ** 3; /** $50 of overage a month (a little over 3x the Pro price) unless the owner chooses another limit. */ export const DEFAULT_PRO_SPENDING_LIMIT_CENTS = 5000; /** The highest monthly spending limit an owner can set: $1,000,000. */ export const MAX_SPENDING_LIMIT_CENTS = 100_000_000; const HOUR = 3600; const FREE_INCLUDED: Plan["included"] = { "storage.artifacts": 1 * GB, "storage.lfs": 1 * GB, "storage.ci_cache": 0.5 * GB, "storage.sites": 0.25 * GB, "storage.r2": 0.25 * GB, "compute.runner": 5 * HOUR, "compute.runner_large": 0, "compute.runner_xlarge": 0, "compute.editor": 2 * HOUR, "compute.site_requests": 100_000, "compute.site_cpu": 1_000_000, "network.lfs": 2 * GB, "network.sites": 10 * GB, "ai.calls": 50, "ai.tokens": 200_000, }; export const DEFAULT_PLANS: Record = { scratch: { id: "scratch", label: "Scratch (anonymous)", included: { "storage.sites": 0.01 * GB, "compute.site_requests": 2_000, "network.sites": 0.1 * GB }, hardCaps: { "storage.sites": 0.01 * GB, "compute.site_requests": 2_000, "network.sites": 0.1 * GB, "ai.calls": 0, "compute.editor": 0, }, billsOverage: false, customDomains: false, }, free: { id: "free", label: "Free", included: FREE_INCLUDED, // Free never bills, so every allowance is a stop. The CI cache is the exception: it evicts LRU entries instead. hardCaps: Object.fromEntries( Object.entries(FREE_INCLUDED).filter(([meter]) => meter !== "storage.ci_cache"), ) as Plan["hardCaps"], billsOverage: false, customDomains: true, }, pro: { id: "pro", label: "Pro", // Sized so a month that uses every allowance in full still costs under PRO_PRICE_CENTS / MARKUP // (checked by pricing.test.ts); usage past these is billed at the same marked-up rates. included: { "storage.artifacts": 15 * GB, "storage.lfs": 10 * GB, "storage.ci_cache": 10 * GB, "storage.sites": 5 * GB, "storage.r2": 5 * GB, "compute.runner": 35 * HOUR, "compute.runner_large": 10 * HOUR, "compute.runner_xlarge": 0, "compute.editor": 20 * HOUR, "compute.site_requests": 3_000_000, "compute.site_cpu": 10_000_000, "network.lfs": 50 * GB, "network.sites": 1000 * GB, "ai.calls": 1000, "ai.tokens": 3_000_000, }, // Sites egress is unpriced, so the spending limit never stops it; this is the backstop. hardCaps: { "network.sites": 2000 * GB }, billsOverage: true, defaultSpendingLimitCents: DEFAULT_PRO_SPENDING_LIMIT_CENTS, customDomains: 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; }