Skip to content
749 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//! The Usage page: a workspace's usage over a range of days, at price, by
2//! product family, meter, project and day, with what paid for it.
3//!
4//! Every figure comes from the ledger's own lines, measured as the
5//! statement measures them (`statement::PRICE_SQL`: what was charged, plus
6//! what included usage, the trial, a pool or g1t paid, plus what a discount
7//! took off), so the Usage page, Billing, mission control and the agent
8//! fleet show the same numbers. Usage metered through the month and charged
9//! when it closes (storage, git operations, scans, embeddings, domains,
10//! app traffic) is added from `pending_usage`, at cost plus the margin, and
11//! said to be pending.
12
13use std::collections::{BTreeMap, BTreeSet};
14
15use futures_util::future::{try_join, try_join5};
16use g1t_contracts::billing::{
Spend adds up on one ledger. Charged this month has one definition, price less discount, included usage and credit, shared by the plan card, the spend limit, Spend and the top bar; the limit had counted usage not yet closed at full price before the discount, so a comped workspace read as charged a cent. Every agent line on the ledger names the agent and who asked, repository runs by g1t included, so Spent is the sum of its products, Agents is the agent product, and by agent adds up to it; the billing API returns by_agent and by_person. The usage and billing guide says how spend is counted.17 Allowance, FeatureUsage, MeterLine, ModelTokens, PlanKind, ProductUsage, ProjectUsage, UsageDay, UsageReport, UsageReportArgs, UsageShare,
18 UsageTotals, PRODUCTS,
Usage, Billing settings and prepaid AI credit; fixes from the UX audit19};
20use g1t_contracts::time::{parse_rfc3339, rfc3339};
21use g1t_contracts::{FailureCode, Outcome};
22use g1t_kit::now_ms;
23use serde::Deserialize;
24use worker::Result;
25
Spend adds up on one ledger. Charged this month has one definition, price less discount, included usage and credit, shared by the plan card, the spend limit, Spend and the top bar; the limit had counted usage not yet closed at full price before the discount, so a comped workspace read as charged a cent. Every agent line on the ledger names the agent and who asked, repository runs by g1t included, so Spent is the sum of its products, Agents is the agent product, and by agent adds up to it; the billing API returns by_agent and by_person. The usage and billing guide says how spend is counted.26use crate::charged::{charged, pending_split};
Usage, Billing settings and prepaid AI credit; fixes from the UX audit27use crate::statement::{PRICE_SQL, RUN_SQL};
28use crate::{Billing, members_only};
29
Spend adds up on one ledger. Charged this month has one definition, price less discount, included usage and credit, shared by the plan card, the spend limit, Spend and the top bar; the limit had counted usage not yet closed at full price before the discount, so a comped workspace read as charged a cent. Every agent line on the ledger names the agent and who asked, repository runs by g1t included, so Spent is the sum of its products, Agents is the agent product, and by agent adds up to it; the billing API returns by_agent and by_person. The usage and billing guide says how spend is counted.30/// The meters that make up the agent product, as `METER_KEY_SQL` names
31/// them: what agents cost, attributed to the agent and the person asking.
32pub(crate) const AGENT_METERS_SQL: &str = "('agent_models', 'agent_rate', 'agent_rate_own', 'agent_sandbox')";
33
Usage, Billing settings and prepaid AI credit; fixes from the UX audit34/// The longest range the page reads at once.
35pub(crate) const MAX_DAYS: u64 = 400;
36const DAY_MS: u64 = 86_400_000;
37
38/// Which meter a ledger line is on. One SQL expression, so every query
39/// agrees with `METERS`.
40pub(crate) const METER_KEY_SQL: &str = "CASE
41 WHEN task = 'sandbox' AND compute = 'agent' THEN 'agent_sandbox'
42 WHEN task = 'sandbox' THEN 'sandbox'
43 WHEN task = 'self_hosted' THEN 'self_hosted'
44 WHEN task = 'deployments' AND reference LIKE 'deploy/%' THEN 'builds'
45 WHEN task = 'deployments' THEN 'requests'
46 WHEN task = 'domains' THEN 'domains'
47 WHEN task = 'git' THEN 'git'
48 WHEN task = 'storage' THEN 'storage'
49 WHEN task = 'cache' THEN 'cache'
50 WHEN task = 'package_storage' THEN 'packages'
51 WHEN task = 'security' THEN 'security'
52 WHEN task = 'context' THEN 'context'
53 WHEN task = 'gateway' THEN 'gateway'
Merge branch 'model-routing'54 WHEN reference LIKE '%/agent-own%' THEN 'agent_rate_own'
Usage, Billing settings and prepaid AI credit; fixes from the UX audit55 WHEN reference LIKE '%/agent%' THEN 'agent_rate'
56 ELSE 'agent_models' END";
57
58/// Every meter: key, name, product family and the unit of its quantity.
Merge branch 'model-routing'59pub(crate) const METERS: [(&str, &str, &str, &str); 17] = [
Usage, Billing settings and prepaid AI credit; fixes from the UX audit60 ("agent_models", "Model tokens", "agent", "tokens"),
61 ("agent_rate", "Agent rate", "agent", "tokens"),
Merge branch 'model-routing'62 ("agent_rate_own", "Agent rate, your own model key", "agent", "tokens"),
Usage, Billing settings and prepaid AI credit; fixes from the UX audit63 ("agent_sandbox", "Agent sandbox time", "agent", "seconds"),
64 ("sandbox", "Sandbox time", "sandboxes", "seconds"),
65 ("self_hosted", "Self-hosted runner time", "sandboxes", "seconds"),
66 ("gateway", "AI Gateway models", "gateway", "requests"),
67 ("builds", "Builds", "deployments", "entries"),
68 ("requests", "Requests & CPU", "deployments", "entries"),
69 ("domains", "Custom domains", "deployments", "entries"),
70 ("git", "Git operations", "git_storage", "operations"),
71 ("storage", "Private storage", "git_storage", "bytes"),
72 ("cache", "Actions cache", "git_storage", "entries"),
73 ("packages", "Package storage", "packages", "entries"),
74 ("security", "Security scans", "security", "entries"),
75 ("context", "Search embeddings", "search", "entries"),
76 // Kept apart so nothing is ever lost: a line whose meter is unknown.
77 ("other", "Other", "agent", "entries"),
78];
79
80/// The meter month-end usage noted so far (`pending_usage.source`) is on.
81pub(crate) fn meter_of_pending(source: &str) -> &'static str {
82 match source {
83 "deployments" => "requests",
84 "domains" => "domains",
85 "storage" => "storage",
86 "git" => "git",
87 "cache" => "cache",
88 "package_storage" => "packages",
89 "security" => "security",
90 "context" => "context",
91 _ => "other",
92 }
93}
94
95/// What the agent was doing, by a run's task.
96pub(crate) fn feature_of(task: &str) -> (&'static str, &'static str) {
97 match task {
98 "review" => ("reviews", "Reviews"),
99 "plan" => ("plans", "Plans"),
100 "update" => ("catch_ups", "Catch-ups"),
101 "check" | "mergecheck" | "queue" => ("checks", "Checks"),
102 _ => ("runs", "Runs"),
103 }
104}
105
106/// Every day from `from` to `until`, both included, or why not.
107pub(crate) fn days_between(from: &str, until: &str) -> std::result::Result<Vec<String>, &'static str> {
108 let (Some(start), Some(end)) = (parse_rfc3339(&format!("{from}T00:00:00Z")), parse_rfc3339(&format!("{until}T00:00:00Z"))) else {
109 return Err("Give the range as days, YYYY-MM-DD.");
110 };
111 if end < start {
112 return Err("The range ends before it starts.");
113 }
114 if (end - start) / DAY_MS + 1 > MAX_DAYS {
115 return Err("Ask for at most 400 days at a time.");
116 }
117 Ok((0..=(end - start) / DAY_MS).map(|n| rfc3339(start + n * DAY_MS)[..10].to_owned()).collect())
118}
119
120/// One group of ledger lines: a day, a meter and a project.
121#[derive(Clone, Debug, Default, Deserialize, PartialEq)]
122pub(crate) struct Cell {
123 pub day: String,
124 pub meter: String,
125 #[serde(default)]
126 pub project: String,
127 #[serde(default)]
128 pub price: Option<f64>,
129 #[serde(default)]
130 pub quantity: Option<f64>,
131 #[serde(default)]
132 pub entries: Option<f64>,
133 #[serde(default)]
134 pub discount: Option<f64>,
135 #[serde(default)]
136 pub covered: Option<f64>,
137 #[serde(default)]
138 pub cost: Option<f64>,
139}
140
141/// Tokens a day's runs used, by project.
142#[derive(Clone, Debug, Default, Deserialize, PartialEq)]
143pub(crate) struct TokenCell {
144 pub day: String,
145 #[serde(default)]
146 pub project: String,
147 #[serde(default)]
148 pub tokens: Option<f64>,
149}
150
Spend adds up on one ledger. Charged this month has one definition, price less discount, included usage and credit, shared by the plan card, the spend limit, Spend and the top bar; the limit had counted usage not yet closed at full price before the discount, so a comped workspace read as charged a cent. Every agent line on the ledger names the agent and who asked, repository runs by g1t included, so Spent is the sum of its products, Agents is the agent product, and by agent adds up to it; the billing API returns by_agent and by_person. The usage and billing guide says how spend is counted.151/// What is metered this month and charged when it closes, by meter: at
152/// price, with what g1t covers of it and what the discount takes off, as
153/// the close will enter it (`charged::pending_split`).
154#[derive(Clone, Debug, Default, PartialEq)]
Usage, Billing settings and prepaid AI credit; fixes from the UX audit155pub(crate) struct Pending {
156 pub meter: &'static str,
157 pub micros: i64,
Spend adds up on one ledger. Charged this month has one definition, price less discount, included usage and credit, shared by the plan card, the spend limit, Spend and the top bar; the limit had counted usage not yet closed at full price before the discount, so a comped workspace read as charged a cent. Every agent line on the ledger names the agent and who asked, repository runs by g1t included, so Spent is the sum of its products, Agents is the agent product, and by agent adds up to it; the billing API returns by_agent and by_person. The usage and billing guide says how spend is counted.158 pub covered: i64,
159 pub discount: i64,
Usage, Billing settings and prepaid AI credit; fixes from the UX audit160}
161
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.162/// Whether a range of days reaches into the current month, so the month's
163/// usage not yet closed (`pending_usage`, which has no day of its own) is
164/// counted in it. `today` is billing's own UTC day, so the top bar, Spend,
165/// Home and Usage, which all ask for the first of the month to today, get
166/// the same answer whatever `until` they send: a range need not reach
167/// month end to include what the month has metered so far.
168pub(crate) fn reaches_this_month(from: &str, until: &str, today: &str) -> bool {
169 from <= today && until >= &format!("{}-01", &today[..7])[..]
170}
171
Spend adds up on one ledger. Charged this month has one definition, price less discount, included usage and credit, shared by the plan card, the spend limit, Spend and the top bar; the limit had counted usage not yet closed at full price before the discount, so a comped workspace read as charged a cent. Every agent line on the ledger names the agent and who asked, repository runs by g1t included, so Spent is the sum of its products, Agents is the agent product, and by agent adds up to it; the billing API returns by_agent and by_person. The usage and billing guide says how spend is counted.172/// One agent's and one asker's agent-product lines over the range.
173#[derive(Clone, Debug, Default, Deserialize, PartialEq)]
174pub(crate) struct Attributed {
175 #[serde(default)]
176 pub agent: String,
177 #[serde(default)]
178 pub person: String,
179 #[serde(default)]
180 pub price: Option<f64>,
181 #[serde(default)]
182 pub entries: Option<f64>,
183}
184
185/// The agent product by agent and by person, most first, from its lines:
186/// each group's price under the agent's handle and the asker's username,
187/// and lines attributed to no one under an empty key. Both sum to the
188/// agent product's total.
189pub(crate) fn attribution(rows: &[Attributed]) -> (Vec<UsageShare>, Vec<UsageShare>) {
190 fn fold(rows: &[Attributed], key_of: impl Fn(&Attributed) -> &str, label_of: impl Fn(&str) -> String) -> Vec<UsageShare> {
191 let mut by: BTreeMap<&str, UsageShare> = BTreeMap::new();
192 for row in rows {
193 let key = key_of(row);
194 let slice = by.entry(key).or_insert_with(|| UsageShare { key: key.to_owned(), label: label_of(key), ..UsageShare::default() });
195 slice.micros += row.price.unwrap_or(0.0).round() as i64;
196 slice.count += row.entries.unwrap_or(0.0).max(0.0) as u32;
197 }
198 let mut out: Vec<UsageShare> = by.into_values().filter(|s| s.micros != 0 || s.count > 0).collect();
199 out.sort_by(|a, b| b.micros.cmp(&a.micros).then(a.key.cmp(&b.key)));
200 out
201 }
202 let agents = fold(rows, |r| r.agent.as_str(), |k| if k.is_empty() { "Not attributed to an agent".to_owned() } else { format!("@{k}") });
203 let people = fold(rows, |r| r.person.as_str(), |k| if k.is_empty() { "No one asked".to_owned() } else { format!("@{k}") });
204 (agents, people)
205}
206
Usage, Billing settings and prepaid AI credit; fixes from the UX audit207/// Shapes the groups into the page: products in order, each with its
208/// meters, each meter with every day of the range and its projects. Only
209/// the products and projects asked for (all, when none are).
210pub(crate) fn shape(
211 days: &[String],
212 cells: &[Cell],
213 tokens: &[TokenCell],
214 pending: &[Pending],
215 products: &[String],
216 projects: &[String],
217) -> (Vec<ProductUsage>, Vec<UsageDay>, UsageTotals, Vec<String>) {
218 let wanted_product = |product: &str| products.is_empty() || products.iter().any(|p| p == product);
219 let wanted_project = |project: &str| projects.is_empty() || projects.iter().any(|p| p == project);
220 let index: BTreeMap<&str, usize> = days.iter().enumerate().map(|(i, d)| (d.as_str(), i)).collect();
221 let product_of = |meter: &str| METERS.iter().find(|m| m.0 == meter).map_or("agent", |m| m.2);
222 let mut all_projects = BTreeSet::new();
223 let mut lines: Vec<MeterLine> = METERS
224 .iter()
225 .map(|(key, label, product, unit)| MeterLine {
226 key: (*key).to_owned(),
227 label: (*label).to_owned(),
228 product: (*product).to_owned(),
229 unit: (*unit).to_owned(),
230 quantity: 0.0,
231 micros: 0,
232 pending_micros: 0,
233 daily: vec![0; days.len()],
234 allowance: None,
235 by_project: vec![],
Merge branch 'model-routing'236 note: None,
Usage, Billing settings and prepaid AI credit; fixes from the UX audit237 })
238 .collect();
239 let mut by_day: BTreeMap<(String, String), i64> = BTreeMap::new();
240 let mut totals = UsageTotals::default();
241 let mut projects_of: BTreeMap<(String, String), (i64, f64)> = BTreeMap::new();
242 for cell in cells {
243 if !cell.project.is_empty() {
244 all_projects.insert(cell.project.clone());
245 }
246 let product = product_of(&cell.meter);
247 if !wanted_product(product) || !wanted_project(&cell.project) {
248 continue;
249 }
250 let Some(line) = lines.iter_mut().find(|l| l.key == cell.meter) else { continue };
251 let price = cell.price.unwrap_or(0.0).round() as i64;
252 line.micros += price;
253 // Tokens for the model meter come from the token counts below.
254 if line.key != "agent_models" {
255 line.quantity += if line.unit == "entries" { cell.entries.unwrap_or(0.0) } else { cell.quantity.unwrap_or(0.0) };
256 }
257 if let Some(i) = index.get(cell.day.as_str()) {
258 line.daily[*i] += price;
259 }
260 *by_day.entry((cell.day.clone(), product.to_owned())).or_default() += price;
261 let part = projects_of.entry((cell.meter.clone(), cell.project.clone())).or_default();
262 part.0 += price;
263 part.1 += if line.unit == "entries" { cell.entries.unwrap_or(0.0) } else { cell.quantity.unwrap_or(0.0) };
264 totals.price_micros += price;
265 totals.discount_micros += cell.discount.unwrap_or(0.0).round() as i64;
266 totals.included_micros += cell.covered.unwrap_or(0.0).round() as i64;
267 totals.cost_micros += cell.cost.unwrap_or(0.0).round() as i64;
268 }
269 if wanted_product("agent") {
270 for cell in tokens {
271 if !wanted_project(&cell.project) {
272 continue;
273 }
274 let tokens = cell.tokens.unwrap_or(0.0);
275 if let Some(line) = lines.iter_mut().find(|l| l.key == "agent_models") {
276 line.quantity += tokens;
277 }
278 projects_of.entry(("agent_models".to_owned(), cell.project.clone())).or_default().1 += tokens;
279 }
280 }
281 // Pending usage is no one project's: only when all projects are shown.
282 if projects.is_empty() {
283 for p in pending {
284 let product = product_of(p.meter);
285 if !wanted_product(product) {
286 continue;
287 }
288 if let Some(line) = lines.iter_mut().find(|l| l.key == p.meter) {
289 line.micros += p.micros;
290 line.pending_micros += p.micros;
291 }
292 totals.price_micros += p.micros;
293 totals.pending_micros += p.micros;
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.294 totals.pending_charged_micros += p.micros - p.covered - p.discount;
Spend adds up on one ledger. Charged this month has one definition, price less discount, included usage and credit, shared by the plan card, the spend limit, Spend and the top bar; the limit had counted usage not yet closed at full price before the discount, so a comped workspace read as charged a cent. Every agent line on the ledger names the agent and who asked, repository runs by g1t included, so Spent is the sum of its products, Agents is the agent product, and by agent adds up to it; the billing API returns by_agent and by_person. The usage and billing guide says how spend is counted.295 totals.included_micros += p.covered;
296 totals.discount_micros += p.discount;
Usage, Billing settings and prepaid AI credit; fixes from the UX audit297 }
298 }
299 for ((meter, project), (micros, quantity)) in projects_of {
300 if let Some(line) = lines.iter_mut().find(|l| l.key == meter) {
301 line.by_project.push(ProjectUsage { project, micros, quantity });
302 }
303 }
304 for line in &mut lines {
305 line.by_project.sort_by(|a, b| b.micros.cmp(&a.micros).then(a.project.cmp(&b.project)));
306 }
307 let products_out = PRODUCTS
308 .iter()
309 .filter(|(key, _)| wanted_product(key))
310 .map(|(key, label)| {
311 let meters: Vec<MeterLine> = lines.iter().filter(|l| l.product == *key && (l.key != "other" || l.micros != 0)).cloned().collect();
312 ProductUsage {
313 key: (*key).to_owned(),
314 label: (*label).to_owned(),
315 micros: meters.iter().map(|m| m.micros).sum(),
316 meters,
317 features: vec![],
318 }
319 })
320 .collect();
321 let days_out = by_day.into_iter().filter(|(_, micros)| *micros != 0).map(|((day, product), micros)| UsageDay { day, product, micros }).collect();
322 (products_out, days_out, totals, all_projects.into_iter().collect())
323}
324
325impl Billing {
326 /// `usage_report`: members only.
327 pub(crate) async fn usage_report(&self, a: UsageReportArgs) -> Result<Outcome<UsageReport>> {
328 let workspace = a.workspace.to_lowercase();
329 if !a.viewer.is_some_and(|viewer| viewer.is_member(&workspace)) {
330 return Ok(members_only());
331 }
332 let days = match days_between(&a.from, &a.until) {
333 Ok(days) => days,
334 Err(why) => return Ok(Outcome::fail(FailureCode::Invalid, why)),
335 };
336 let from = days[0].clone();
337 let until = days[days.len() - 1].clone();
338 // Lines entered on the last day count, whatever the hour.
339 let end = rfc3339(parse_rfc3339(&format!("{until}T00:00:00Z")).unwrap_or(0) + DAY_MS);
340 let measure = if self.free { "COALESCE(cost_micros, 0)" } else { PRICE_SQL };
341 let now = rfc3339(now_ms());
342 let month = now[..7].to_owned();
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.343 let this_month = reaches_this_month(&from, &until, &now[..10]);
Usage, Billing settings and prepaid AI credit; fixes from the UX audit344
345 let cells = async {
346 self.db
347 .prepare(format!(
348 "SELECT substr(created_at, 1, 10) AS day, {meter} AS meter, COALESCE(repo, '') AS project,
349 SUM({measure}) AS price, SUM(COALESCE(quantity, 0)) AS quantity, COUNT(*) AS entries,
350 SUM(COALESCE(discount_micros, 0)) AS discount,
351 SUM(COALESCE(credit_micros, 0) + COALESCE(trial_micros, 0) + COALESCE(oss_micros, 0) + COALESCE(given_micros, 0)) AS covered,
352 SUM(COALESCE(cost_micros, 0)) AS cost
353 FROM ledger WHERE workspace = ?1 AND kind = 'usage' AND created_at >= ?2 AND created_at < ?3
354 GROUP BY 1, 2, 3 LIMIT 20000",
355 meter = METER_KEY_SQL
356 ))
357 .bind(&[workspace.as_str().into(), from.as_str().into(), end.as_str().into()])?
358 .all()
359 .await?
360 .results::<Cell>()
361 };
362 let tokens = async {
363 self.db
364 .prepare(
365 "SELECT t.day AS day, COALESCE(r.repo, '') AS project,
366 SUM(t.input + t.output + t.cache_read + t.cache_write) AS tokens
367 FROM token_usage t LEFT JOIN runs r ON r.session_id = t.session
368 WHERE t.workspace = ?1 AND t.day >= ?2 AND t.day <= ?3
369 GROUP BY 1, 2 LIMIT 20000",
370 )
371 .bind(&[workspace.as_str().into(), from.as_str().into(), until.as_str().into()])?
372 .all()
373 .await?
374 .results::<TokenCell>()
375 };
376 #[derive(Deserialize)]
377 struct Feature {
378 task: Option<String>,
379 price: Option<f64>,
380 runs: Option<f64>,
381 }
382 let features = async {
383 self.db
384 .prepare(format!(
385 "SELECT COALESCE(task, 'implement') AS task, SUM({measure}) AS price, SUM(CASE WHEN {RUN_SQL} THEN 1 ELSE 0 END) AS runs
386 FROM ledger WHERE workspace = ?1 AND kind = 'usage' AND created_at >= ?2 AND created_at < ?3
Merge branch 'model-routing'387 AND ({meter}) IN ('agent_models', 'agent_rate', 'agent_rate_own', 'agent_sandbox')
Usage, Billing settings and prepaid AI credit; fixes from the UX audit388 GROUP BY 1",
389 meter = METER_KEY_SQL
390 ))
391 .bind(&[workspace.as_str().into(), from.as_str().into(), end.as_str().into()])?
392 .all()
393 .await?
394 .results::<Feature>()
395 };
396 let pending = async {
397 if !this_month {
398 return Ok(vec![]);
399 }
Spend adds up on one ledger. Charged this month has one definition, price less discount, included usage and credit, shared by the plan card, the spend limit, Spend and the top bar; the limit had counted usage not yet closed at full price before the discount, so a comped workspace read as charged a cent. Every agent line on the ledger names the agent and who asked, repository runs by g1t included, so Spent is the sum of its products, Agents is the agent product, and by agent adds up to it; the billing API returns by_agent and by_person. The usage and billing guide says how spend is counted.400 self.pending_this_month(&[workspace.as_str().into()], &month).await
401 };
402 // The agent product by the agent that did the work and by who
403 // asked, from the same lines: only when the agent product is shown.
404 let agents_wanted = a.products.is_empty() || a.products.iter().any(|p| p == "agent");
405 let attributed = async {
406 if !agents_wanted {
407 return Ok(vec![]);
408 }
409 let mut binds: Vec<worker::wasm_bindgen::JsValue> = vec![workspace.as_str().into(), from.as_str().into(), end.as_str().into()];
410 let projects = if a.projects.is_empty() {
411 String::new()
412 } else {
413 binds.extend(a.projects.iter().map(|p| worker::wasm_bindgen::JsValue::from(p.as_str())));
414 format!(" AND COALESCE(repo, '') IN ({})", vec!["?"; a.projects.len()].join(", "))
415 };
Usage, Billing settings and prepaid AI credit; fixes from the UX audit416 self.db
Spend adds up on one ledger. Charged this month has one definition, price less discount, included usage and credit, shared by the plan card, the spend limit, Spend and the top bar; the limit had counted usage not yet closed at full price before the discount, so a comped workspace read as charged a cent. Every agent line on the ledger names the agent and who asked, repository runs by g1t included, so Spent is the sum of its products, Agents is the agent product, and by agent adds up to it; the billing API returns by_agent and by_person. The usage and billing guide says how spend is counted.417 .prepare(format!(
418 "SELECT COALESCE(agent, '') AS agent, COALESCE(asked_by, '') AS person, SUM({measure}) AS price, COUNT(*) AS entries
419 FROM ledger WHERE workspace = ?1 AND kind = 'usage' AND created_at >= ?2 AND created_at < ?3
420 AND ({meter}) IN {AGENT_METERS_SQL}{projects}
421 GROUP BY 1, 2 LIMIT 5000",
422 meter = METER_KEY_SQL
423 ))
424 .bind(&binds)?
Usage, Billing settings and prepaid AI credit; fixes from the UX audit425 .all()
426 .await?
Spend adds up on one ledger. Charged this month has one definition, price less discount, included usage and credit, shared by the plan card, the spend limit, Spend and the top bar; the limit had counted usage not yet closed at full price before the discount, so a comped workspace read as charged a cent. Every agent line on the ledger names the agent and who asked, repository runs by g1t included, so Spent is the sum of its products, Agents is the agent product, and by agent adds up to it; the billing API returns by_agent and by_person. The usage and billing guide says how spend is counted.427 .results::<Attributed>()
Usage, Billing settings and prepaid AI credit; fixes from the UX audit428 };
429 let credits = self.credit_paid_between(&workspace, &from, &end);
Spend adds up on one ledger. Charged this month has one definition, price less discount, included usage and credit, shared by the plan card, the spend limit, Spend and the top bar; the limit had counted usage not yet closed at full price before the discount, so a comped workspace read as charged a cent. Every agent line on the ledger names the agent and who asked, repository runs by g1t included, so Spent is the sum of its products, Agents is the agent product, and by agent adds up to it; the billing API returns by_agent and by_person. The usage and billing guide says how spend is counted.430 let ((cells, tokens, features, pending, (credits_paid, credits)), (attributed, account)) =
431 try_join(try_join5(cells, tokens, features, pending, credits), try_join(attributed, self.account_of(&workspace))).await?;
432 let percent = account.terms.percent_off();
433 // What is metered so far is entered on the account's terms when the
434 // month closes: its discount, and what g1t covers, come off it now.
Usage, Billing settings and prepaid AI credit; fixes from the UX audit435 let pending: Vec<Pending> = pending
436 .into_iter()
Spend adds up on one ledger. Charged this month has one definition, price less discount, included usage and credit, shared by the plan card, the spend limit, Spend and the top bar; the limit had counted usage not yet closed at full price before the discount, so a comped workspace read as charged a cent. Every agent line on the ledger names the agent and who asked, repository runs by g1t included, so Spent is the sum of its products, Agents is the agent product, and by agent adds up to it; the billing API returns by_agent and by_person. The usage and billing guide says how spend is counted.437 .map(|p| {
438 let split = pending_split(p.cost(), p.charge(), self.margin_percent, percent);
439 Pending { meter: meter_of_pending(&p.source), micros: split.price, covered: split.covered, discount: split.discount }
Usage, Billing settings and prepaid AI credit; fixes from the UX audit440 })
441 .filter(|p| p.micros > 0)
442 .collect();
443 let (mut products, days_out, mut totals, all_projects) = shape(&days, &cells, &tokens, &pending, &a.products, &a.projects);
Spend adds up on one ledger. Charged this month has one definition, price less discount, included usage and credit, shared by the plan card, the spend limit, Spend and the top bar; the limit had counted usage not yet closed at full price before the discount, so a comped workspace read as charged a cent. Every agent line on the ledger names the agent and who asked, repository runs by g1t included, so Spent is the sum of its products, Agents is the agent product, and by agent adds up to it; the billing API returns by_agent and by_person. The usage and billing guide says how spend is counted.444 let (by_agent, by_person) = attribution(&attributed);
Usage, Billing settings and prepaid AI credit; fixes from the UX audit445 let filtered = !a.products.is_empty() || !a.projects.is_empty();
446 // Credit is the workspace's, not a product's or a project's: only
447 // without a filter is it taken off.
448 totals.credits_micros = if filtered { 0 } else { credits_paid.max(0) };
449 totals.charged_micros = charged(&totals);
450 if let Some(agent) = products.iter_mut().find(|p| p.key == "agent") {
451 let mut grouped: BTreeMap<&str, FeatureUsage> = BTreeMap::new();
452 for f in &features {
453 let (key, label) = feature_of(f.task.as_deref().unwrap_or("implement"));
454 let entry = grouped.entry(key).or_insert_with(|| FeatureUsage { key: key.to_owned(), label: label.to_owned(), micros: 0, count: 0 });
455 entry.micros += f.price.unwrap_or(0.0).round() as i64;
456 entry.count += f.runs.unwrap_or(0.0) as u32;
457 }
458 agent.features = grouped.into_values().filter(|f| f.micros != 0 || f.count > 0).collect();
459 agent.features.sort_by_key(|f| std::cmp::Reverse(f.micros));
460 }
461 let plan = self.plan_kind_for(&workspace, &account).await?;
462 // Allowances, for the range that includes this month.
463 let mut included = None;
464 let mut trial = None;
465 if this_month {
466 let (used, stored, operations) = futures_util::future::try_join3(
467 async {
468 if plan == PlanKind::Paid { self.allowance_used("plan_credit", &workspace, &month).await } else { Ok(0) }
469 },
470 self.private_storage(&workspace),
471 self.git_operations_this_month(&workspace),
472 )
473 .await?;
474 if plan == PlanKind::Paid {
475 included = Some(Allowance { used: used as f64, of: self.plans.plan_included_micros as f64, unit: "micros".to_owned() });
476 }
477 for product in &mut products {
478 for meter in &mut product.meters {
479 match meter.key.as_str() {
480 "storage" => {
481 meter.quantity = stored as f64;
482 meter.allowance = Some(Allowance { used: stored as f64, of: self.plans.free_storage_bytes as f64, unit: "bytes".to_owned() });
483 }
484 "git" => {
485 meter.quantity = operations as f64;
486 meter.allowance = Some(Allowance { used: operations as f64, of: self.plans.git_included as f64, unit: "operations".to_owned() });
487 }
488 _ => {}
489 }
490 }
491 }
492 if plan == PlanKind::Free
493 && let Some(grant) = self.grant_of(&workspace).await?
494 {
495 trial = Some(crate::credits::left(grant.granted_micros, grant.used_micros));
496 }
497 }
Merge branch 'model-routing'498 // The agent rate's tokens are weighted by kind: say how.
499 let weights = self.token_weights().await?;
500 for product in &mut products {
501 for meter in &mut product.meters {
502 if meter.key == "agent_rate" || meter.key == "agent_rate_own" {
503 meter.note = Some(agent_rate_note(&weights));
504 }
505 }
506 }
Usage, Billing settings and prepaid AI credit; fixes from the UX audit507 let ai: i64 = credits.grants.iter().filter(|g| g.scope == "models").map(|g| g.left_micros).sum();
Merge branch 'model-routing'508 let models = self.tokens_by_model(&workspace, &from, &until).await?;
Usage, Billing settings and prepaid AI credit; fixes from the UX audit509 Ok(Outcome::Ok(UsageReport {
510 from,
511 until,
512 totals,
513 days: days_out,
514 products,
515 projects: all_projects,
Merge branch 'model-routing'516 models,
Spend adds up on one ledger. Charged this month has one definition, price less discount, included usage and credit, shared by the plan card, the spend limit, Spend and the top bar; the limit had counted usage not yet closed at full price before the discount, so a comped workspace read as charged a cent. Every agent line on the ledger names the agent and who asked, repository runs by g1t included, so Spent is the sum of its products, Agents is the agent product, and by agent adds up to it; the billing API returns by_agent and by_person. The usage and billing guide says how spend is counted.517 by_agent,
518 by_person,
Usage, Billing settings and prepaid AI credit; fixes from the UX audit519 included,
520 discount_percent: (percent > 0).then_some(percent),
521 ai_credit_micros: ai,
522 credit_micros: credits.left_micros - ai,
523 trial_micros: trial,
524 plan,
525 free: self.free,
526 }))
527 }
528}
529
Merge branch 'model-routing'530/// What the agent rate's meters say of their tokens.
531pub(crate) fn agent_rate_note(weights: &crate::ai::TokenWeights) -> String {
532 if weights.is_flat() {
533 "Weighted tokens: every token counts once (input ×1, output ×1, cache reads ×1, cache writes ×1)".to_owned()
534 } else {
535 format!("Weighted tokens: {}", weights.describe())
536 }
537}
538
539impl Billing {
540 /// Agent tokens by model over the days `from` to `until`, most first:
541 /// what the model proxy counted, on g1t's models and the workspace's
542 /// own provider alike.
543 async fn tokens_by_model(&self, workspace: &str, from: &str, until: &str) -> Result<Vec<ModelTokens>> {
544 #[derive(Deserialize)]
545 struct Row {
546 model: String,
547 input: Option<f64>,
548 output: Option<f64>,
549 cache_read: Option<f64>,
550 cache_write: Option<f64>,
551 }
552 let rows = self
553 .db
554 .prepare(
555 "SELECT model, SUM(input) AS input, SUM(output) AS output, SUM(cache_read) AS cache_read, SUM(cache_write) AS cache_write
556 FROM token_usage WHERE workspace = ?1 AND day >= ?2 AND day <= ?3
557 GROUP BY model ORDER BY SUM(input + output + cache_read + cache_write) DESC LIMIT 20",
558 )
559 .bind(&[workspace.into(), from.into(), until.into()])?
560 .all()
561 .await?
562 .results::<Row>()?;
563 let n = |v: Option<f64>| v.unwrap_or(0.0).max(0.0) as u64;
564 Ok(rows
565 .into_iter()
566 .map(|r| ModelTokens { model: r.model, input: n(r.input), output: n(r.output), cache_read: n(r.cache_read), cache_write: n(r.cache_write) })
567 .collect())
568 }
569}
570
Usage, Billing settings and prepaid AI credit; fixes from the UX audit571#[cfg(test)]
572mod tests {
573 use super::*;
574
575 fn cell(day: &str, meter: &str, project: &str, price: f64) -> Cell {
576 Cell { day: day.into(), meter: meter.into(), project: project.into(), price: Some(price), entries: Some(1.0), ..Cell::default() }
577 }
578
579 #[test]
580 fn a_range_is_every_day_in_it() {
581 let days = days_between("2026-10-01", "2026-10-03").unwrap();
582 assert_eq!(days, ["2026-10-01", "2026-10-02", "2026-10-03"]);
583 assert_eq!(days_between("2026-10-01", "2026-10-01").unwrap().len(), 1);
584 assert!(days_between("2026-10-03", "2026-10-01").is_err());
585 assert!(days_between("2025-01-01", "2026-10-01").is_err());
586 assert!(days_between("Oct 1", "2026-10-01").is_err());
587 }
588
589 #[test]
590 fn usage_is_shaped_into_products_meters_days_and_projects() {
591 let days = days_between("2026-10-01", "2026-10-03").unwrap();
592 let mut sandbox = cell("2026-10-02", "sandbox", "acme/web", 120_000.0);
593 sandbox.quantity = Some(600.0);
594 let cells = vec![
595 cell("2026-10-01", "agent_models", "acme/web", 2_000_000.0),
596 cell("2026-10-01", "agent_rate", "acme/web", 500_000.0),
597 cell("2026-10-03", "agent_models", "acme/api", 1_000_000.0),
598 sandbox,
599 Cell { covered: Some(1_000_000.0), discount: Some(0.0), ..cell("2026-10-03", "storage", "", 30_000.0) },
600 ];
601 let tokens = vec![TokenCell { day: "2026-10-01".into(), project: "acme/web".into(), tokens: Some(2_000_000.0) }];
Spend adds up on one ledger. Charged this month has one definition, price less discount, included usage and credit, shared by the plan card, the spend limit, Spend and the top bar; the limit had counted usage not yet closed at full price before the discount, so a comped workspace read as charged a cent. Every agent line on the ledger names the agent and who asked, repository runs by g1t included, so Spent is the sum of its products, Agents is the agent product, and by agent adds up to it; the billing API returns by_agent and by_person. The usage and billing guide says how spend is counted.602 let pending = vec![Pending { meter: "git", micros: 40_000, ..Pending::default() }];
Usage, Billing settings and prepaid AI credit; fixes from the UX audit603 let (products, by_day, totals, projects) = shape(&days, &cells, &tokens, &pending, &[], &[]);
604 // Every product family, in order.
605 assert_eq!(products.iter().map(|p| p.key.as_str()).collect::<Vec<_>>(), ["agent", "sandboxes", "gateway", "deployments", "git_storage", "packages", "security", "search"]);
606 let agent = &products[0];
607 assert_eq!(agent.micros, 3_500_000);
608 let models = agent.meters.iter().find(|m| m.key == "agent_models").unwrap();
609 assert_eq!(models.daily, [2_000_000, 0, 1_000_000]);
610 assert_eq!(models.quantity, 2_000_000.0);
611 assert_eq!(models.by_project[0].project, "acme/web");
612 let sandbox = products[1].meters.iter().find(|m| m.key == "sandbox").unwrap();
613 assert_eq!((sandbox.micros, sandbox.quantity), (120_000, 600.0));
614 // Pending usage counts in the totals and its meter, not on a day.
615 let git = products[4].meters.iter().find(|m| m.key == "git").unwrap();
616 assert_eq!((git.micros, git.pending_micros), (40_000, 40_000));
617 assert_eq!(totals.price_micros, 3_500_000 + 120_000 + 30_000 + 40_000);
618 assert_eq!(totals.pending_micros, 40_000);
619 assert_eq!(totals.included_micros, 1_000_000);
620 assert_eq!(by_day.iter().filter(|d| d.product == "agent").map(|d| d.micros).sum::<i64>(), 3_500_000);
621 assert_eq!(projects, ["acme/api", "acme/web"]);
622 }
623
624 #[test]
625 fn filters_keep_only_the_products_and_projects_asked_for() {
626 let days = days_between("2026-10-01", "2026-10-01").unwrap();
627 let cells = vec![cell("2026-10-01", "agent_models", "acme/web", 100.0), cell("2026-10-01", "agent_models", "acme/api", 50.0), cell("2026-10-01", "sandbox", "acme/web", 10.0)];
Spend adds up on one ledger. Charged this month has one definition, price less discount, included usage and credit, shared by the plan card, the spend limit, Spend and the top bar; the limit had counted usage not yet closed at full price before the discount, so a comped workspace read as charged a cent. Every agent line on the ledger names the agent and who asked, repository runs by g1t included, so Spent is the sum of its products, Agents is the agent product, and by agent adds up to it; the billing API returns by_agent and by_person. The usage and billing guide says how spend is counted.628 let pending = vec![Pending { meter: "git", micros: 7, ..Pending::default() }];
Usage, Billing settings and prepaid AI credit; fixes from the UX audit629 let (products, _, totals, projects) = shape(&days, &cells, &[], &pending, &["agent".into()], &["acme/web".into()]);
630 assert_eq!(products.len(), 1);
631 assert_eq!(totals.price_micros, 100);
632 // Every project stays listed, for the filter itself.
633 assert_eq!(projects, ["acme/api", "acme/web"]);
634 }
635
636 #[test]
Spend adds up on one ledger. Charged this month has one definition, price less discount, included usage and credit, shared by the plan card, the spend limit, Spend and the top bar; the limit had counted usage not yet closed at full price before the discount, so a comped workspace read as charged a cent. Every agent line on the ledger names the agent and who asked, repository runs by g1t included, so Spent is the sum of its products, Agents is the agent product, and by agent adds up to it; the billing API returns by_agent and by_person. The usage and billing guide says how spend is counted.637 fn pending_usage_counts_at_price_with_what_is_covered_and_discounted() {
638 let days = days_between("2026-10-01", "2026-10-02").unwrap();
639 // Storage on a 100% discount, and scans g1t covers on a free workspace.
640 let pending = [
641 Pending { meter: "storage", micros: 9_000, covered: 0, discount: 9_000 },
642 Pending { meter: "security", micros: 2_400, covered: 2_400, discount: 0 },
643 ];
644 let (_, _, totals, _) = shape(&days, &[], &[], &pending, &[], &[]);
645 assert_eq!(totals.price_micros, 11_400);
646 assert_eq!(totals.pending_micros, 11_400);
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.647 assert_eq!(totals.pending_charged_micros, 11_400 - totals.included_micros - totals.discount_micros);
Spend adds up on one ledger. Charged this month has one definition, price less discount, included usage and credit, shared by the plan card, the spend limit, Spend and the top bar; the limit had counted usage not yet closed at full price before the discount, so a comped workspace read as charged a cent. Every agent line on the ledger names the agent and who asked, repository runs by g1t included, so Spent is the sum of its products, Agents is the agent product, and by agent adds up to it; the billing API returns by_agent and by_person. The usage and billing guide says how spend is counted.648 assert_eq!(totals.included_micros, 2_400);
649 assert_eq!(totals.discount_micros, 9_000);
650 assert_eq!(charged(&totals), 0);
651 // With a project chosen, pending usage (no project's) is left out.
652 let (_, _, filtered, _) = shape(&days, &[], &[], &pending, &[], &["acme/api".to_owned()]);
653 assert_eq!(filtered.pending_micros, 0);
654 }
655
656 fn attributed(agent: &str, person: &str, price: f64, entries: f64) -> Attributed {
657 Attributed { agent: agent.into(), person: person.into(), price: Some(price), entries: Some(entries) }
658 }
659
660 #[test]
661 fn the_agent_product_is_attributed_to_agents_and_to_who_asked() {
662 let rows = [
663 attributed("g1t", "chase", 2_000_000.0, 3.0),
664 attributed("mike", "chase", 20_000.0, 2.0),
665 attributed("mike", "", 5_000.0, 1.0),
666 attributed("", "", 500_000.0, 4.0),
667 ];
668 let (agents, people) = attribution(&rows);
669 assert_eq!(agents.iter().map(|s| (s.key.as_str(), s.micros, s.count)).collect::<Vec<_>>(), [("g1t", 2_000_000, 3), ("", 500_000, 4), ("mike", 25_000, 3)]);
670 assert_eq!(agents[0].label, "@g1t");
671 assert_eq!(agents[1].label, "Not attributed to an agent");
672 assert_eq!(people.iter().map(|s| (s.key.as_str(), s.micros)).collect::<Vec<_>>(), [("chase", 2_020_000), ("", 505_000)]);
673 assert_eq!(people[1].label, "No one asked");
674 // Both views are the same money.
675 let total: i64 = rows.iter().map(|r| r.price.unwrap() as i64).sum();
676 assert_eq!(agents.iter().map(|s| s.micros).sum::<i64>(), total);
677 assert_eq!(people.iter().map(|s| s.micros).sum::<i64>(), total);
678 }
679
680 /// The figures every page shows come from one ledger: Home's and
681 /// Spend's "Spent" are the products' sum at price, the top bar's and
682 /// Spend's "Agents" are the agent product, agents by handle sum to it,
683 /// and a full discount is charged nothing.
684 #[test]
685 fn home_spend_and_the_top_bar_reconcile_on_the_ledger() {
686 let days = days_between("2026-10-01", "2026-10-10").unwrap();
687 // The same lines, grouped two ways: by day, meter and project for
688 // the products; by agent and asker for the attribution.
689 let lines: [(&str, &str, &str, &str, &str, f64); 7] = [
690 ("2026-10-02", "agent_models", "flagon-io/g1t", "g1t", "chase", 1_900_000.0),
691 ("2026-10-02", "agent_rate", "flagon-io/g1t", "g1t", "chase", 400_000.0),
692 ("2026-10-03", "agent_sandbox", "flagon-io/g1t", "g1t", "chase", 220_000.0),
693 ("2026-10-04", "agent_models", "flagon-io/@mike", "mike", "chase", 20_000.0),
694 ("2026-10-05", "agent_models", "flagon-io/@margo", "margo", "", 4_000.0),
695 ("2026-10-03", "sandbox", "flagon-io/g1t", "", "", 3_360_000.0),
696 ("2026-10-06", "requests", "flagon-io/site", "", "", 4_480_000.0),
697 ];
698 let cells: Vec<Cell> = lines
699 .iter()
700 .map(|(day, meter, project, _, _, price)| Cell { discount: Some(*price), ..cell(day, meter, project, *price) })
701 .collect();
702 let rows: Vec<Attributed> = lines
703 .iter()
704 .filter(|(_, meter, ..)| meter.starts_with("agent"))
705 .map(|(_, _, _, agent, person, price)| attributed(agent, person, *price, 1.0))
706 .collect();
707 let (products, by_day, totals, _) = shape(&days, &cells, &[], &[], &[], &[]);
708 let (agents, people) = attribution(&rows);
709 // Spent, on Home and Spend: every product at price.
710 let spent: i64 = lines.iter().map(|l| l.5 as i64).sum();
711 assert_eq!(totals.price_micros, spent);
712 assert_eq!(products.iter().map(|p| p.micros).sum::<i64>(), spent);
713 assert_eq!(by_day.iter().map(|d| d.micros).sum::<i64>(), spent);
714 // Agents, in the top bar and on Spend: the agent product, which is
715 // what the agents by handle add up to, g1t's own runs included.
716 let agent_product = products.iter().find(|p| p.key == "agent").unwrap().micros;
717 assert_eq!(agent_product, 1_900_000 + 400_000 + 220_000 + 20_000 + 4_000);
718 assert_eq!(agents.iter().map(|s| s.micros).sum::<i64>(), agent_product);
719 assert_eq!(people.iter().map(|s| s.micros).sum::<i64>(), agent_product);
720 assert!(agents.iter().all(|s| s.micros <= agent_product));
721 assert_eq!(agents[0].key, "g1t");
722 // Charged, on Billing, Spend and in the top bar: nothing, on a 100% discount.
723 assert_eq!(totals.discount_micros, spent);
724 assert_eq!(charged(&totals), 0);
Usage, Billing settings and prepaid AI credit; fixes from the UX audit725 }
726
727 #[test]
728 fn the_agent_is_counted_by_what_it_was_doing() {
729 assert_eq!(feature_of("review").0, "reviews");
730 assert_eq!(feature_of("plan").0, "plans");
731 assert_eq!(feature_of("implement").0, "runs");
732 assert_eq!(meter_of_pending("package_storage"), "packages");
733 assert_eq!(meter_of_pending("nonsense"), "other");
734 }
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.735
736 #[test]
737 fn pending_usage_counts_in_any_range_that_reaches_into_this_month() {
738 // The first of the month to today, as every page asks: counted.
739 assert!(reaches_this_month("2026-10-01", "2026-10-10", "2026-10-10"));
740 // To month end, or only the last week: counted too.
741 assert!(reaches_this_month("2026-10-01", "2026-10-31", "2026-10-10"));
742 assert!(reaches_this_month("2026-10-04", "2026-10-10", "2026-10-10"));
743 // Last month, or a month that has not started: not this month's.
744 assert!(!reaches_this_month("2026-09-01", "2026-09-30", "2026-10-10"));
745 assert!(!reaches_this_month("2026-11-01", "2026-11-30", "2026-10-10"));
746 // The month has turned on billing's clock: September's range no longer has it.
747 assert!(!reaches_this_month("2026-09-01", "2026-09-30", "2026-10-01"));
748 }
Usage, Billing settings and prepaid AI credit; fixes from the UX audit749}

This file's history is long; its oldest lines are credited to the oldest commit read.