import { all } from "../db"; import { accountDb } from "../scoped-db"; import { METERS } from "~/lib/meters"; import type { MeterId } from "~/lib/meters"; import { billingSettings, hardCap } from "./quota"; import type { BillingSettings } from "./quota"; import { periodOf } from "./record"; import { creditBalances } from "./credits"; import { creditPoolFor } from "~/lib/pricing"; export type MeterUsage = { meter: MeterId; label: string; category: string; unit: string; quantity: number; reserved: number; included: number | null; /** The plan's hard cap plus prepaid credit headroom; null when soft. */ hardCap: number | null; /** Usage this period paid for by prepaid credit. */ creditCovered: number; /** Unspent prepaid credit this meter could still use, in its own units (pools are shared across meters). */ creditBalance: number; overageCents: number; }; export type UsageSummary = { accountId: string; period: string; plan: { id: string; label: string; billsOverage: boolean }; spendingLimitCents: number | null; totalCents: number; meters: MeterUsage[]; repos: { repoId: string; name: string | null; meter: string; quantity: number }[]; days: { day: string; meter: string; quantity: number }[]; alerts: { subject: string; threshold: number; createdAt: number }[]; /** Unspent prepaid credit per pool, in pool units (compute: standard-1 seconds; ai.tokens: tokens). */ credits: Record; }; export function overageCents(quantity: number, included: number | null, microCentsPerUnit: number): number { const billable = Math.max(0, quantity - (included ?? 0)); return (billable * microCentsPerUnit) / 1e6; } export function meterUsages( settings: BillingSettings, counters: Map, gauges: Map, balances: Record = {}, ): MeterUsage[] { return METERS.map((def) => { const counter = counters.get(def.id); const quantity = def.kind === "gauge" ? (gauges.get(def.id) ?? 0) : (counter?.quantity ?? 0); const included = settings.plan.included[def.id] ?? null; const creditCovered = counter?.credit_covered ?? 0; const pool = creditPoolFor(def.id); const creditBalance = pool ? (balances[pool.pool] ?? 0) / pool.factor : 0; const cap = hardCap(settings, def.id); return { meter: def.id, label: def.label, category: def.category, unit: def.unit, quantity, reserved: counter?.reserved ?? 0, included, hardCap: cap === null ? null : cap + creditCovered + creditBalance, creditCovered, creditBalance, overageCents: settings.plan.billsOverage ? overageCents(quantity - creditCovered, included, def.microCentsPerUnit) : 0, }; }); } export async function usageSummary(accountId: string, period = periodOf()): Promise { const settings = await billingSettings(accountId); const db = accountDb(accountId); const [counterRows, gaugeRows, repoRows, dayRows, alertRows, balances] = await Promise.all([ db.all<{ meter: string; quantity: number; reserved: number; credit_covered: number }>( "SELECT meter, quantity, reserved, credit_covered FROM usage_periods WHERE account_id = ? AND period = ?", accountId, period, ), db.all<{ meter: string; peak: number }>( "SELECT meter, peak FROM usage_gauges WHERE account_id = ? AND period = ?", accountId, period, ), db.all<{ repo_id: string; meter: string; quantity: number }>( `SELECT repo_id, meter, quantity FROM usage_repo_periods WHERE account_id = ? AND period = ? ORDER BY quantity DESC LIMIT 100`, accountId, period, ), db.all<{ day: string; meter: string; quantity: number }>( "SELECT day, meter, quantity FROM usage_days WHERE account_id = ? AND day LIKE ? ORDER BY day", accountId, `${period}-%`, ), db.all<{ subject: string; threshold: number; created_at: number }>( "SELECT subject, threshold, created_at FROM usage_alerts WHERE account_id = ? AND period = ? ORDER BY created_at DESC", accountId, period, ), creditBalances(accountId), ]); const names = await repoNames([...new Set(repoRows.map((row) => row.repo_id))]); const meters = meterUsages( settings, new Map(counterRows.map((row) => [row.meter, row])), new Map(gaugeRows.map((row) => [row.meter, row.peak])), balances, ); return { accountId, period, plan: { id: settings.plan.id, label: settings.plan.label, billsOverage: settings.plan.billsOverage }, spendingLimitCents: settings.spendingLimitCents, totalCents: meters.reduce((sum, meter) => sum + meter.overageCents, 0), credits: balances, meters, repos: repoRows.map((row) => ({ repoId: row.repo_id, name: names.get(row.repo_id) ?? null, meter: row.meter, quantity: row.quantity, })), days: dayRows, alerts: alertRows.map((row) => ({ subject: row.subject, threshold: row.threshold, createdAt: row.created_at })), }; } async function repoNames(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 }>( `SELECT id, name FROM repos WHERE id IN (${chunk.map(() => "?").join(", ")})`, ...chunk, ); for (const row of rows) out.set(row.id, row.name); } return out; }