Skip to content
287 linesCodeBlameRaw

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 audit1/**
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
11import 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.13import { MICROS_PER_DOLLAR, money } from "./money.ts";
14import { monthSpan } from "./spend.ts";
15
Usage, Billing settings and prepaid AI credit; fixes from the UX audit16const 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). */
19export 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
30export 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). */
35export 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;
43export type Period = keyof typeof PERIODS;
44
45export type Grain = "day" | "week" | "month";
46export type GroupBy = "product" | "project" | "day";
47
48function day(at: Date): string {
49 return at.toISOString().slice(0, 10);
50}
51
52function 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. */
57export 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 audit81}
82
83/** `Oct 1 – Oct 8, 2026`. */
84export 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. */
93export 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. */
100export type Column = { key: string; label: string; from: string; until: string; parts: Record<string, number>; total: number };
101
102function 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
112function 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 */
122export 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. */
153export 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 */
163export 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
174function 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`. */
182export 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`. */
206export 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. */
220export 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 */
235export 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 audit243/** Rows of the breakdown when grouped by project: each project's usage across every meter. */
244export 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. */
261export 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. */
279export 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). */
284export const STAFF_WORKSPACE = "flagon-io";
285export function isStaff(viewer: { workspaces?: { slug: string }[] } | null | undefined): boolean {
286 return !!viewer?.workspaces?.some((w) => w.slug === STAFF_WORKSPACE);
287}