import { env } from "cloudflare:workers"; import { first } from "../db"; import { accountDb } from "../scoped-db"; import { meterDef } from "~/lib/meters"; import type { MeterId } from "~/lib/meters"; import { isPlanId, parsePlans } from "~/lib/plans"; import type { Plan } from "~/lib/plans"; import { emailConfigured } from "../email"; export function plans(): Record { return parsePlans(env.METERING_PLANS); } export type BillingSettings = { plan: Plan; spendingLimitCents: number | null; disabled?: boolean }; const DEFAULT_PAST_DUE_GRACE_DAYS = 14; const DAY_MS = 86_400_000; /** Days a past_due subscription keeps Pro (PAST_DUE_GRACE_DAYS, default 14) before usage falls back to Free. */ export function pastDueGraceMs(raw = env.PAST_DUE_GRACE_DAYS): number { const days = raw === undefined || raw === "" ? DEFAULT_PAST_DUE_GRACE_DAYS : Number(raw); return (Number.isFinite(days) && days >= 0 ? days : DEFAULT_PAST_DUE_GRACE_DAYS) * DAY_MS; } export async function billingSettings(accountId: string): Promise { const all = plans(); if (accountId.startsWith("scratch:")) return { plan: all.scratch, spendingLimitCents: 0 }; const [billing, directory] = await Promise.all([ accountDb(accountId).first(BILLING_SQL, accountId), first(directorySql(emailConfigured()), accountId), ]); const row: BillingRow | null = billing || directory ? { ...EMPTY_BILLING, ...billing, ...directory } : null; let planId = row?.plan && isPlanId(row.plan) ? row.plan : "free"; // Stripe may leave a subscription past_due indefinitely; Pro compute stops after the grace period anyway. if ( planId === "pro" && !row?.plan_override && row?.subscription_status === "past_due" && // No recorded start (a row predating the column) never counts as lapsed: its grace starts now. Date.now() - (row.past_due_since ?? Date.now()) > pastDueGraceMs() ) { planId = "free"; } const plan = planId === "free" && row?.untrusted ? unverifiedPlan(all.free) : all[planId]; return { plan, spendingLimitCents: row?.spending_limit_cents ?? plan.defaultSpendingLimitCents ?? null, disabled: Boolean(row?.disabled_at), }; } type BillingFields = { plan: string | null; spending_limit_cents: number | null; plan_override: number | null; subscription_status: string | null; subscription_event_at: number | null; past_due_since: number | null; }; type DirectoryFields = { untrusted: number; disabled_at: number | null }; type BillingRow = BillingFields & DirectoryFields; const EMPTY_BILLING: BillingRow = { plan: null, spending_limit_cents: null, plan_override: null, subscription_status: null, subscription_event_at: null, past_due_since: null, untrusted: 0, disabled_at: null, }; const TRUSTED_USER = "(u.email_verified_at IS NOT NULL OR u.github_id IS NOT NULL OR u.is_admin = 1)"; const UNTRUSTED_SQL = `CASE a.kind WHEN 'user' THEN NOT EXISTS (SELECT 1 FROM users u WHERE u.account_id = a.id AND ${TRUSTED_USER}) WHEN 'org' THEN NOT EXISTS (SELECT 1 FROM org_members m JOIN users u ON u.account_id = m.user_id WHERE m.org_id = a.id AND m.role = 'owner' AND ${TRUSTED_USER}) ELSE 0 END`; /** Plan, limit and subscription state, from the account's own object. */ const BILLING_SQL = `SELECT b.plan, b.spending_limit_cents, b.plan_override, c.subscription_status, c.subscription_event_at, c.past_due_since FROM (SELECT ?1 AS id) k LEFT JOIN account_billing b ON b.account_id = k.id LEFT JOIN billing_customers c ON c.account_id = k.id`; /** * Whether the account is disabled and whether it is untrusted, from the D1 directory: a user who has not * verified an email (GitHub sign-ins and admins count as verified), or an org none of whose owners is trusted, * so a scripted signup cannot farm compute through an org either. Instances without email skip the trust check. */ function directorySql(checkTrust: boolean): string { return `SELECT ${checkTrust ? UNTRUSTED_SQL : "0"} AS untrusted, a.disabled_at FROM accounts a WHERE a.id = ?1`; } /** Meters an unverified free account may not use: the costly ones a scripted signup would farm. */ const VERIFIED_ONLY_METERS: MeterId[] = [ "compute.runner", "compute.runner_large", "compute.runner_xlarge", "compute.editor", "ai.calls", "ai.tokens", ]; function unverifiedPlan(free: Plan): Plan { const zero = Object.fromEntries(VERIFIED_ONLY_METERS.map((meter) => [meter, 0])); return { ...free, included: { ...free.included, ...zero }, hardCaps: { ...free.hardCaps, ...zero } }; } /** The hard cap for a meter on this account, or null when it is soft (alerts only). */ export function hardCap(settings: BillingSettings, meter: MeterId): number | null { const cap = settings.plan.hardCaps[meter]; if (cap !== undefined) return cap; const price = meterDef(meter)?.microCentsPerUnit ?? 0; const included = settings.plan.included[meter] ?? 0; if (!settings.plan.billsOverage) return null; // Paid plans: a spending limit caps each priced meter at the overage the limit alone could buy. // The cross-meter total is enforced separately by withinSpendingLimit. if (settings.spendingLimitCents !== null && price > 0) return included + (settings.spendingLimitCents * 1e6) / price; return null; }