Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 1 | /** |
| 2 | * The Usage page's arithmetic: which days a period covers, how a report's | |
| 3 | * days become the chart's columns, clean axis ticks, quantities in their | |
| 4 | * units, and the CSV export. Pure, so it can be tested. | |
| 5 | * | |
| 6 | * Every figure is usage at price (`UsageReport.totals.priceMicros`), the | |
| 7 | * one number mission control, the agent fleet, Usage and Billing all show: | |
| 8 | * what was charged, plus what included usage, credit or a discount paid. | |
| 9 | */ | |
| 10 | ||
| 11 | import type { MeterLine, UsageDay, UsageReport, UsageTotals } from "@g1t/contracts"; | |
| 12 | ||
| Money is written one way. A single formatter turns millionths of a dollar into dollars, rounding half up on whole micros rather than on a float, so the same sum reads the same on every page: under a cent reads <$0.01 on a total and exactly nothing is $0.00, while the statement's lines, a session's receipt, the price book and an agent's effort costs carry up to four places where the fraction of a cent is the point; the six formatters that each rounded their own way are gone. This month is the calendar month in UTC from its first day to today everywhere, and billing counts the month's not-yet-closed usage in any range that reaches into the current month, so the top bar's pill, Spend, Home and Usage ask for the same days and get the same figure; Home now reads the usage report the others read instead of adding up statements. Usage's pending sentence says what of that usage the close will charge after the discount and included usage, which the billing API returns as pending_charged_micros. A reconciliation test holds the pill, Spend, Home and Usage to one number for one month. The usage and billing guide says how amounts are written and what this month means. | 13 | import { MICROS_PER_DOLLAR, money } from "./money.ts"; |
| 14 | import { monthSpan } from "./spend.ts"; | |
| 15 | ||
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 16 | const DAY_MS = 86_400_000; |
| 17 | ||
| 18 | /** The product families in order, with their names and colors (the chart's categorical slots, validated against the dark surface). */ | |
| 19 | export const PRODUCT_STYLE: { key: string; label: string; color: string }[] = [ | |
| 20 | { key: "agent", label: "Agent", color: "#3987e5" }, | |
| 21 | { key: "sandboxes", label: "Sandboxes", color: "#d95926" }, | |
| 22 | { key: "gateway", label: "AI Gateway", color: "#199e70" }, | |
| 23 | { key: "deployments", label: "Deployments", color: "#c98500" }, | |
| 24 | { key: "git_storage", label: "Git & storage", color: "#d55181" }, | |
| 25 | { key: "packages", label: "Packages", color: "#008300" }, | |
| 26 | { key: "security", label: "Security & quality", color: "#9085e9" }, | |
| 27 | { key: "search", label: "Search", color: "#e66767" }, | |
| 28 | ]; | |
| 29 | ||
| 30 | export function productStyle(key: string): { key: string; label: string; color: string } { | |
| 31 | return PRODUCT_STYLE.find((p) => p.key === key) ?? { key, label: key, color: "#86868e" }; | |
| 32 | } | |
| 33 | ||
| 34 | /** The periods the page offers. Billing cycles are calendar months (UTC). */ | |
| 35 | export const PERIODS = { | |
| 36 | cycle: "Current billing cycle", | |
| 37 | last_cycle: "Last billing cycle", | |
| 38 | "7d": "Last 7 days", | |
| 39 | "30d": "Last 30 days", | |
| 40 | "90d": "Last 90 days", | |
| 41 | custom: "Custom range", | |
| 42 | } as const; | |
| 43 | export type Period = keyof typeof PERIODS; | |
| 44 | ||
| 45 | export type Grain = "day" | "week" | "month"; | |
| 46 | export type GroupBy = "product" | "project" | "day"; | |
| 47 | ||
| 48 | function day(at: Date): string { | |
| 49 | return at.toISOString().slice(0, 10); | |
| 50 | } | |
| 51 | ||
| 52 | function isDay(text: string | null | undefined): text is string { | |
| 53 | return !!text && /^\d{4}-\d{2}-\d{2}$/.test(text) && !Number.isNaN(Date.parse(`${text}T00:00:00Z`)) && day(new Date(`${text}T00:00:00Z`)) === text; | |
| 54 | } | |
| 55 | ||
| 56 | /** The days a period covers, both included, as `YYYY-MM-DD` (UTC), and the period actually used. */ | |
| 57 | export function resolveRange( | |
| 58 | asked: string | null | undefined, | |
| 59 | custom: { from?: string | null; until?: string | null }, | |
| 60 | now = new Date(), | |
| 61 | ): { period: Period; from: string; until: string } { | |
| 62 | const today = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())); | |
| 63 | const period: Period = asked && asked in PERIODS ? (asked as Period) : "cycle"; | |
| 64 | if (period === "custom" && isDay(custom.from) && isDay(custom.until)) { | |
| 65 | const [from, until] = custom.from <= custom.until ? [custom.from, custom.until] : [custom.until, custom.from]; | |
| 66 | // At most 400 days, as billing reads them. | |
| 67 | const earliest = day(new Date(Date.parse(`${until}T00:00:00Z`) - 399 * DAY_MS)); | |
| 68 | return { period, from: from < earliest ? earliest : from, until }; | |
| 69 | } | |
| 70 | if (period === "last_cycle") { | |
| 71 | const start = new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth() - 1, 1)); | |
| 72 | const end = new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), 0)); | |
| 73 | return { period, from: day(start), until: day(end) }; | |
| 74 | } | |
| 75 | if (period === "7d" || period === "30d" || period === "90d") { | |
| 76 | const days = Number(period.slice(0, -1)); | |
| 77 | return { period, from: day(new Date(today.getTime() - (days - 1) * DAY_MS)), until: day(today) }; | |
| 78 | } | |
| Money is written one way. A single formatter turns millionths of a dollar into dollars, rounding half up on whole micros rather than on a float, so the same sum reads the same on every page: under a cent reads <$0.01 on a total and exactly nothing is $0.00, while the statement's lines, a session's receipt, the price book and an agent's effort costs carry up to four places where the fraction of a cent is the point; the six formatters that each rounded their own way are gone. This month is the calendar month in UTC from its first day to today everywhere, and billing counts the month's not-yet-closed usage in any range that reaches into the current month, so the top bar's pill, Spend, Home and Usage ask for the same days and get the same figure; Home now reads the usage report the others read instead of adding up statements. Usage's pending sentence says what of that usage the close will charge after the discount and included usage, which the billing API returns as pending_charged_micros. A reconciliation test holds the pill, Spend, Home and Usage to one number for one month. The usage and billing guide says how amounts are written and what this month means. | 79 | // This month is the one range Spend and the top bar read too (`spend.ts` `monthSpan`). |
| 80 | return { period: "cycle", ...monthSpan(today) }; | |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 81 | } |
| 82 | ||
| 83 | /** `Oct 1 – Oct 8, 2026`. */ | |
| 84 | export function rangeLabel(from: string, until: string): string { | |
| 85 | const f = new Date(`${from}T00:00:00Z`); | |
| 86 | const u = new Date(`${until}T00:00:00Z`); | |
| 87 | const short = (d: Date, year: boolean) => | |
| 88 | d.toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: "UTC", ...(year ? { year: "numeric" } : {}) }); | |
| 89 | return from === until ? short(u, true) : `${short(f, f.getUTCFullYear() !== u.getUTCFullYear())} – ${short(u, true)}`; | |
| 90 | } | |
| 91 | ||
| 92 | /** Every day from `from` to `until`, both included. */ | |
| 93 | export function daysIn(from: string, until: string): string[] { | |
| 94 | const out: string[] = []; | |
| 95 | for (let t = Date.parse(`${from}T00:00:00Z`); t <= Date.parse(`${until}T00:00:00Z`); t += DAY_MS) out.push(day(new Date(t))); | |
| 96 | return out; | |
| 97 | } | |
| 98 | ||
| 99 | /** One column of the chart: a day, a week (from its Monday) or a month, with each product's part. */ | |
| 100 | export type Column = { key: string; label: string; from: string; until: string; parts: Record<string, number>; total: number }; | |
| 101 | ||
| 102 | function columnKey(d: string, grain: Grain): string { | |
| 103 | if (grain === "month") return d.slice(0, 7); | |
| 104 | if (grain === "week") { | |
| 105 | const t = new Date(`${d}T00:00:00Z`); | |
| 106 | const monday = new Date(t.getTime() - ((t.getUTCDay() + 6) % 7) * DAY_MS); | |
| 107 | return day(monday); | |
| 108 | } | |
| 109 | return d; | |
| 110 | } | |
| 111 | ||
| 112 | function columnLabel(key: string, grain: Grain): string { | |
| 113 | if (grain === "month") return new Date(`${key}-01T00:00:00Z`).toLocaleDateString("en-US", { month: "short", year: "numeric", timeZone: "UTC" }); | |
| 114 | return new Date(`${key}T00:00:00Z`).toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: "UTC" }); | |
| 115 | } | |
| 116 | ||
| 117 | /** | |
| 118 | * The chart's columns: every day (or week, or month) of the range, zeros | |
| 119 | * included, each product's usage at price. Cumulative adds each column to | |
| 120 | * the ones before, product by product. | |
| 121 | */ | |
| 122 | export function columns(days: UsageDay[], from: string, until: string, grain: Grain = "day", cumulative = false): Column[] { | |
| 123 | const out: Column[] = []; | |
| 124 | const at = new Map<string, Column>(); | |
| 125 | for (const d of daysIn(from, until)) { | |
| 126 | const key = columnKey(d, grain); | |
| 127 | let column = at.get(key); | |
| 128 | if (!column) { | |
| 129 | column = { key, label: columnLabel(key, grain), from: d, until: d, parts: {}, total: 0 }; | |
| 130 | at.set(key, column); | |
| 131 | out.push(column); | |
| 132 | } | |
| 133 | column.until = d; | |
| 134 | } | |
| 135 | for (const d of days) { | |
| 136 | const column = at.get(columnKey(d.day, grain)); | |
| 137 | if (!column) continue; | |
| 138 | column.parts[d.product] = (column.parts[d.product] ?? 0) + d.micros; | |
| 139 | column.total += d.micros; | |
| 140 | } | |
| 141 | if (cumulative) { | |
| 142 | const running: Record<string, number> = {}; | |
| 143 | for (const column of out) { | |
| 144 | for (const [product, micros] of Object.entries(column.parts)) running[product] = (running[product] ?? 0) + micros; | |
| 145 | column.parts = { ...running }; | |
| 146 | column.total = Object.values(running).reduce((a, b) => a + b, 0); | |
| 147 | } | |
| 148 | } | |
| 149 | return out; | |
| 150 | } | |
| 151 | ||
| 152 | /** The finest grain that keeps the chart readable: days up to 45 of them, weeks up to 200, months past that. */ | |
| 153 | export function defaultGrain(from: string, until: string): Grain { | |
| 154 | const days = daysIn(from, until).length; | |
| 155 | return days <= 45 ? "day" : days <= 200 ? "week" : "month"; | |
| 156 | } | |
| 157 | ||
| 158 | /** | |
| 159 | * Clean ticks for a money axis from 0 to at least `max` micros: 1, 2 or 5 | |
| 160 | * times a power of ten, every label different. With nothing used, one tick | |
| 161 | * at $0. | |
| 162 | */ | |
| 163 | export function ticks(max: number, count = 4): number[] { | |
| 164 | if (!(max > 0)) return [0]; | |
| 165 | const raw = max / count; | |
| 166 | const power = 10 ** Math.floor(Math.log10(raw)); | |
| 167 | const step = [1, 2, 2.5, 5, 10].map((m) => m * power).find((s) => s >= raw) ?? 10 * power; | |
| 168 | const out: number[] = []; | |
| 169 | for (let v = 0; v < max + step / 2 && out.length <= count + 1; v += step) out.push(Math.round(v)); | |
| 170 | if (out[out.length - 1]! < max) out.push(Math.round(out[out.length - 1]! + step)); | |
| 171 | return out; | |
| 172 | } | |
| 173 | ||
| 174 | function compact(n: number): string { | |
| 175 | if (n >= 1e9) return `${(n / 1e9).toLocaleString("en-US", { maximumFractionDigits: 1 })}B`; | |
| 176 | if (n >= 1e6) return `${(n / 1e6).toLocaleString("en-US", { maximumFractionDigits: 1 })}M`; | |
| 177 | if (n >= 1e4) return `${(n / 1e3).toLocaleString("en-US", { maximumFractionDigits: 1 })}K`; | |
| 178 | return Math.round(n).toLocaleString("en-US"); | |
| 179 | } | |
| 180 | ||
| 181 | /** How much of a meter, in its unit: `1.2M tokens`, `3h 12m`, `504 MB`, `12 entries`. */ | |
| 182 | export function quantity(amount: number, unit: string): string { | |
| 183 | switch (unit) { | |
| 184 | case "tokens": | |
| 185 | return `${compact(amount)} tokens`; | |
| 186 | case "seconds": { | |
| 187 | const s = Math.round(amount); | |
| 188 | const h = Math.floor(s / 3600); | |
| 189 | const m = Math.floor((s % 3600) / 60); | |
| 190 | return h > 0 ? `${h}h ${m}m` : m > 0 ? `${m}m ${s % 60}s` : `${s}s`; | |
| 191 | } | |
| 192 | case "bytes": | |
| 193 | return bytes(amount); | |
| 194 | case "operations": | |
| 195 | return `${compact(amount)} ${amount === 1 ? "operation" : "operations"}`; | |
| 196 | case "requests": | |
| 197 | return `${compact(amount)} ${amount === 1 ? "request" : "requests"}`; | |
| 198 | case "micros": | |
| 199 | return money(amount); | |
| 200 | default: | |
| 201 | return `${compact(amount)} ${amount === 1 ? "entry" : "entries"}`; | |
| 202 | } | |
| 203 | } | |
| 204 | ||
| 205 | /** Storage in powers of ten, as it is priced: `504 MB`, `1 TB`. */ | |
| 206 | export function bytes(n: number): string { | |
| 207 | const units: [number, string][] = [ | |
| 208 | [1e12, "TB"], | |
| 209 | [1e9, "GB"], | |
| 210 | [1e6, "MB"], | |
| 211 | [1e3, "KB"], | |
| 212 | ]; | |
| 213 | for (const [size, name] of units) { | |
| 214 | if (n >= size) return `${(n / size).toLocaleString("en-US", { maximumFractionDigits: n / size >= 100 ? 0 : 1 })} ${name}`; | |
| 215 | } | |
| 216 | return `${Math.round(n)} B`; | |
| 217 | } | |
| 218 | ||
| 219 | /** What paid for usage, in order, each line with anything to show: the receipt under the totals. */ | |
| 220 | export function receipt(totals: UsageTotals, discountPercent: number | null | undefined): { label: string; micros: number; minus: boolean }[] { | |
| 221 | const lines: { label: string; micros: number; minus: boolean }[] = [{ label: "Usage at price", micros: totals.priceMicros, minus: false }]; | |
| 222 | if (totals.discountMicros > 0) lines.push({ label: `Discount${discountPercent ? ` (${discountPercent}%)` : ""}`, micros: totals.discountMicros, minus: true }); | |
| 223 | if (totals.includedMicros > 0) lines.push({ label: "Included usage and pools", micros: totals.includedMicros, minus: true }); | |
| 224 | if (totals.creditsMicros > 0) lines.push({ label: "Credits applied", micros: totals.creditsMicros, minus: true }); | |
| 225 | lines.push({ label: "Charged", micros: totals.chargedMicros, minus: false }); | |
| 226 | return lines; | |
| 227 | } | |
| 228 | ||
| Money is written one way. A single formatter turns millionths of a dollar into dollars, rounding half up on whole micros rather than on a float, so the same sum reads the same on every page: under a cent reads <$0.01 on a total and exactly nothing is $0.00, while the statement's lines, a session's receipt, the price book and an agent's effort costs carry up to four places where the fraction of a cent is the point; the six formatters that each rounded their own way are gone. This month is the calendar month in UTC from its first day to today everywhere, and billing counts the month's not-yet-closed usage in any range that reaches into the current month, so the top bar's pill, Spend, Home and Usage ask for the same days and get the same figure; Home now reads the usage report the others read instead of adding up statements. Usage's pending sentence says what of that usage the close will charge after the discount and included usage, which the billing API returns as pending_charged_micros. A reconciliation test holds the pill, Spend, Home and Usage to one number for one month. The usage and billing guide says how amounts are written and what this month means. | 229 | /** |
| 230 | * The note under the receipt: what is metered this month and not yet | |
| 231 | * closed, with the fraction of a cent it carries (it explains a gap of | |
| 232 | * one), and what of it the close will charge on the account's terms. An | |
| 233 | * older billing says only that it is charged at the close. | |
| 234 | */ | |
| 235 | export function pendingSentence(totals: Pick<UsageTotals, "pendingMicros" | "pendingChargedMicros">): string { | |
| 236 | const pending = money(totals.pendingMicros, { precise: true }); | |
| 237 | const charged = totals.pendingChargedMicros; | |
| 238 | if (charged == null) return `${pending} of it is metered this month and not yet closed; it is charged when the month closes.`; | |
| 239 | const part = charged <= 0 ? "nothing" : charged >= totals.pendingMicros ? "all of it" : `${money(charged, { precise: true })} of it`; | |
| 240 | return `${pending} of it is metered this month and not yet closed; ${part} will be charged when the month closes, after your discount and included usage.`; | |
| 241 | } | |
| 242 | ||
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 243 | /** Rows of the breakdown when grouped by project: each project's usage across every meter. */ |
| 244 | export function byProject(report: Pick<UsageReport, "products">): { project: string; micros: number; meters: { label: string; micros: number }[] }[] { | |
| 245 | const rows = new Map<string, { project: string; micros: number; meters: { label: string; micros: number }[] }>(); | |
| 246 | for (const product of report.products) { | |
| 247 | for (const meter of product.meters) { | |
| 248 | for (const part of meter.byProject) { | |
| 249 | if (part.micros === 0) continue; | |
| 250 | const row = rows.get(part.project) ?? { project: part.project, micros: 0, meters: [] }; | |
| 251 | row.micros += part.micros; | |
| 252 | row.meters.push({ label: meter.label, micros: part.micros }); | |
| 253 | rows.set(part.project, row); | |
| 254 | } | |
| 255 | } | |
| 256 | } | |
| 257 | return [...rows.values()].sort((a, b) => b.micros - a.micros); | |
| 258 | } | |
| 259 | ||
| 260 | /** The report as CSV: one row per meter, day and amount at price. */ | |
| 261 | export function usageCsv(report: Pick<UsageReport, "from" | "until" | "products">): string { | |
| 262 | const days = daysIn(report.from, report.until); | |
| 263 | const quote = (s: string) => (/[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s); | |
| 264 | const lines = ["day,product,meter,usd"]; | |
| 265 | for (const product of report.products) { | |
| 266 | for (const meter of product.meters) { | |
| 267 | meter.daily.forEach((micros, i) => { | |
| 268 | if (micros !== 0) lines.push([days[i] ?? "", quote(product.label), quote(meter.label), (micros / MICROS_PER_DOLLAR).toFixed(6)].join(",")); | |
| 269 | }); | |
| 270 | if ((meter.pendingMicros ?? 0) !== 0) { | |
| 271 | lines.push(["pending", quote(product.label), quote(meter.label), ((meter.pendingMicros ?? 0) / MICROS_PER_DOLLAR).toFixed(6)].join(",")); | |
| 272 | } | |
| 273 | } | |
| 274 | } | |
| 275 | return `${lines.join("\n")}\n`; | |
| 276 | } | |
| 277 | ||
| 278 | /** The meters worth a row: anything used, and those with an allowance. */ | |
| 279 | export function shownLines(meters: MeterLine[]): MeterLine[] { | |
| 280 | return meters.filter((m) => m.micros !== 0 || m.quantity !== 0 || m.allowance); | |
| 281 | } | |
| 282 | ||
| 283 | /** Who sees test-mode hints: g1t's own people (members of Flagon's workspace). */ | |
| 284 | export const STAFF_WORKSPACE = "flagon-io"; | |
| 285 | export function isStaff(viewer: { workspaces?: { slug: string }[] } | null | undefined): boolean { | |
| 286 | return !!viewer?.workspaces?.some((w) => w.slug === STAFF_WORKSPACE); | |
| 287 | } |