import { all, first } from "../db"; import { accountDb } from "../scoped-db"; import { meteredAccountIds } from "./directory"; import { leaderboardRows, PERIOD_USAGE_SQL, usageLeaderboardReady, type UsageRow } from "./leaderboard"; import { forbidden, notFoundError } from "../errors"; import { HttpError } from "../errors"; import type { ErrorDetail } from "../errors"; import { publicUrl } from "../events/emit"; import type { MeterId } from "~/lib/meters"; import type { Plan } from "~/lib/plans"; import type { Viewer } from "~/lib/types"; /** * `canEdit` (checkout, portal, limits) is owner-only; `canView` adds site admins, who may inspect usage * but never act on a customer's Stripe account. */ export type BillingAccount = { accountId: string; name: string; kind: "user" | "org"; canEdit: boolean; canView: boolean; }; /** The account whose usage `viewer` may see: themselves, or an org they belong to (owners may edit). */ export async function billingAccountFor(viewer: Viewer, org: string | null): Promise { if (!org) return { accountId: viewer.id, name: viewer.name, kind: "user", canEdit: true, canView: true }; const account = await first<{ id: string; name: string }>( "SELECT id, name FROM accounts WHERE name = ? COLLATE NOCASE AND kind = 'org'", org, ); if (!account) throw notFoundError("Organization not found"); const member = await first<{ role: string }>( "SELECT role FROM org_members WHERE org_id = ? AND user_id = ?", account.id, viewer.id, ); if (!member && !viewer.isAdmin) throw notFoundError("Organization not found"); return { accountId: account.id, name: account.name, kind: "org", canEdit: member?.role === "owner", canView: member?.role === "owner" || viewer.isAdmin, }; } export function requireSiteAdmin(viewer: Viewer): void { if (!viewer.isAdmin) throw forbidden("Site administrators only"); } export type AdminUsageMeter = { meter: string; quantity: number; included: number | null; hardCap: number | null }; export type AdminUsageAccount = { accountId: string; name: string | null; kind: string | null; plan: string; /** Pinned by an admin, so Stripe events leave the plan alone (BILL-P8). */ pinned: boolean; /** Custom domains granted by an admin without the paid add-on. */ domainsGranted: boolean; meters: AdminUsageMeter[]; }; const TOP_PER_METER = 20; type BillingFlags = { plan: string | null; plan_override: number | null; custom_domains_grant: number | null }; /** Every account's counters and gauge peaks this period, read from each account until the leaderboard is filled. */ async function usageRows(period: string): Promise { if (await usageLeaderboardReady()) return leaderboardRows(period, TOP_PER_METER); const perAccount = await Promise.all( (await meteredAccountIds()).map((accountId) => accountDb(accountId).all(PERIOD_USAGE_SQL, period)), ); return perAccount.flat(); } async function accountNames(ids: string[]): Promise> { const out = new Map(); for (let index = 0; index < ids.length; index += 50) { const chunk = ids.slice(index, index + 50); const rows = await all<{ id: string; name: string; kind: string }>( `SELECT id, name, kind FROM accounts WHERE id IN (${chunk.map(() => "?").join(", ")})`, ...chunk, ); for (const row of rows) out.set(row.id, { name: row.name, kind: row.kind }); } return out; } async function billingFlags(ids: string[]): Promise> { const rows = await Promise.all( ids.map(async (id) => { const row = await accountDb(id).first( "SELECT plan, plan_override, custom_domains_grant FROM account_billing WHERE account_id = ?", id, ); return [id, row] as const; }), ); return new Map(rows.flatMap(([id, row]) => (row ? [[id, row] as const] : []))); } /** * Site-admin view: accounts that are among the heaviest on any meter this period (counters and gauge peaks), * grouped per account with their plan and its allowance for each meter. */ export async function topUsage(period: string, allPlans: Record): Promise { const rows = await usageRows(period); const ranked = new Map(); for (const row of rows) ranked.set(row.meter, [...(ranked.get(row.meter) ?? []), row]); const top = [...ranked.keys()] .sort() .flatMap((meter) => (ranked.get(meter) ?? []).sort((left, right) => right.quantity - left.quantity).slice(0, TOP_PER_METER), ); const ids = [...new Set(top.map((row) => row.account_id))]; const [directory, billing] = await Promise.all([accountNames(ids), billingFlags(ids)]); const joined = top.map((row) => ({ ...row, name: directory.get(row.account_id)?.name ?? null, kind: directory.get(row.account_id)?.kind ?? null, plan: billing.get(row.account_id)?.plan ?? null, plan_override: billing.get(row.account_id)?.plan_override ?? null, custom_domains_grant: billing.get(row.account_id)?.custom_domains_grant ?? null, })); const accounts = new Map(); for (const row of joined) { const planId = row.account_id.startsWith("scratch:") ? "scratch" : (row.plan ?? "free"); let account = accounts.get(row.account_id); if (!account) { account = { accountId: row.account_id, name: row.name, kind: row.kind, plan: planId, pinned: row.plan_override === 1, domainsGranted: row.custom_domains_grant === 1, meters: [], }; accounts.set(row.account_id, account); } const plan = allPlans[planId]; const meter = row.meter as MeterId; account.meters.push({ meter: row.meter, quantity: row.quantity, included: plan?.included[meter] ?? null, hardCap: plan?.hardCaps[meter] ?? null, }); } const pressure = (account: AdminUsageAccount) => Math.max(0, ...account.meters.map((item) => (item.included ? item.quantity / item.included : 0))); return [...accounts.values()].sort((left, right) => pressure(right) - pressure(left)); } const HTTP_PAYMENT_REQUIRED = 402; /** * Whether `viewerId` pays for this account: itself, or an org it owns. Site admins are deliberately not included: * the upsell's links and checkout act on the viewer's own billing pages, which would be the wrong account. */ async function managesBilling(accountId: string, viewerId: string | null): Promise { if (!viewerId) return false; if (viewerId === accountId) return true; const row = await first<{ ok: number }>( "SELECT 1 AS ok FROM org_members WHERE org_id = ? AND user_id = ? AND role = 'owner'", accountId, viewerId, ); return row !== null; } /** * Prepares a 402's detail for the client. People who manage the blocked account's billing get its name and * billing link; anyone else (a collaborator on someone else's repo) only learns that the owner must upgrade. * The internal account id never leaves. Other errors pass through untouched. */ export async function withBillingContext(error: HttpError, viewerId: string | null): Promise { const source = error.detail; if (error.status !== HTTP_PAYMENT_REQUIRED || !source) return source; if ("manageable" in source) return source; const { accountId: rawAccountId, ...detail } = source; const accountId = typeof rawAccountId === "string" ? rawAccountId : null; const generic: ErrorDetail = { ...detail, manageable: false }; if (!accountId || !(await managesBilling(accountId, viewerId).catch(() => false))) return generic; const account = await first<{ name: string; kind: string }>( "SELECT name, kind FROM accounts WHERE id = ?", accountId, ).catch(() => null); if (!account) return generic; const path = account.kind === "org" ? `/orgs/${encodeURIComponent(account.name)}/billing` : "/settings/billing"; return { ...detail, manageable: true, account: { name: account.name, kind: account.kind }, billingPath: path, billingUrl: publicUrl(path), }; } /** The 402 (or any other error) as it should leave a request boundary for `viewerId`. */ export async function paymentErrorFor(error: HttpError, viewerId: string | null): Promise { if (error.status !== HTTP_PAYMENT_REQUIRED || !error.detail || "manageable" in error.detail) return error; return new HttpError(error.status, error.message, await withBillingContext(error, viewerId)); }