Skip to content

g1t/services/billing/src/report.rs

583 lines26,822 bytesCodeBlame

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::{
Merge branch 'model-routing'17 Allowance, FeatureUsage, MeterLine, ModelTokens, PlanKind, ProductUsage, ProjectUsage, UsageDay, UsageReport, UsageReportArgs, UsageTotals,
Usage, Billing settings and prepaid AI credit; fixes from the UX audit18 PRODUCTS,
19};
20use g1t_contracts::time::{parse_rfc3339, rfc3339};
21use g1t_contracts::{FailureCode, Outcome};
22use g1t_kit::now_ms;
23use serde::Deserialize;
24use worker::Result;
25
26use crate::statement::{PRICE_SQL, RUN_SQL};
27use crate::{Billing, members_only};
28
29/// The longest range the page reads at once.
30pub(crate) const MAX_DAYS: u64 = 400;
31const DAY_MS: u64 = 86_400_000;
32
33/// Which meter a ledger line is on. One SQL expression, so every query
34/// agrees with `METERS`.
35pub(crate) const METER_KEY_SQL: &str = "CASE
36 WHEN task = 'sandbox' AND compute = 'agent' THEN 'agent_sandbox'
37 WHEN task = 'sandbox' THEN 'sandbox'
38 WHEN task = 'self_hosted' THEN 'self_hosted'
39 WHEN task = 'deployments' AND reference LIKE 'deploy/%' THEN 'builds'
40 WHEN task = 'deployments' THEN 'requests'
41 WHEN task = 'domains' THEN 'domains'
42 WHEN task = 'git' THEN 'git'
43 WHEN task = 'storage' THEN 'storage'
44 WHEN task = 'cache' THEN 'cache'
45 WHEN task = 'package_storage' THEN 'packages'
46 WHEN task = 'security' THEN 'security'
47 WHEN task = 'context' THEN 'context'
48 WHEN task = 'gateway' THEN 'gateway'
Merge branch 'model-routing'49 WHEN reference LIKE '%/agent-own%' THEN 'agent_rate_own'
Usage, Billing settings and prepaid AI credit; fixes from the UX audit50 WHEN reference LIKE '%/agent%' THEN 'agent_rate'
51 ELSE 'agent_models' END";
52
53/// Every meter: key, name, product family and the unit of its quantity.
Merge branch 'model-routing'54pub(crate) const METERS: [(&str, &str, &str, &str); 17] = [
Usage, Billing settings and prepaid AI credit; fixes from the UX audit55 ("agent_models", "Model tokens", "agent", "tokens"),
56 ("agent_rate", "Agent rate", "agent", "tokens"),
Merge branch 'model-routing'57 ("agent_rate_own", "Agent rate, your own model key", "agent", "tokens"),
Usage, Billing settings and prepaid AI credit; fixes from the UX audit58 ("agent_sandbox", "Agent sandbox time", "agent", "seconds"),
59 ("sandbox", "Sandbox time", "sandboxes", "seconds"),
60 ("self_hosted", "Self-hosted runner time", "sandboxes", "seconds"),
61 ("gateway", "AI Gateway models", "gateway", "requests"),
62 ("builds", "Builds", "deployments", "entries"),
63 ("requests", "Requests & CPU", "deployments", "entries"),
64 ("domains", "Custom domains", "deployments", "entries"),
65 ("git", "Git operations", "git_storage", "operations"),
66 ("storage", "Private storage", "git_storage", "bytes"),
67 ("cache", "Actions cache", "git_storage", "entries"),
68 ("packages", "Package storage", "packages", "entries"),
69 ("security", "Security scans", "security", "entries"),
70 ("context", "Search embeddings", "search", "entries"),
71 // Kept apart so nothing is ever lost: a line whose meter is unknown.
72 ("other", "Other", "agent", "entries"),
73];
74
75/// The meter month-end usage noted so far (`pending_usage.source`) is on.
76pub(crate) fn meter_of_pending(source: &str) -> &'static str {
77 match source {
78 "deployments" => "requests",
79 "domains" => "domains",
80 "storage" => "storage",
81 "git" => "git",
82 "cache" => "cache",
83 "package_storage" => "packages",
84 "security" => "security",
85 "context" => "context",
86 _ => "other",
87 }
88}
89
90/// What the agent was doing, by a run's task.
91pub(crate) fn feature_of(task: &str) -> (&'static str, &'static str) {
92 match task {
93 "review" => ("reviews", "Reviews"),
94 "plan" => ("plans", "Plans"),
95 "update" => ("catch_ups", "Catch-ups"),
96 "check" | "mergecheck" | "queue" => ("checks", "Checks"),
97 _ => ("runs", "Runs"),
98 }
99}
100
101/// Every day from `from` to `until`, both included, or why not.
102pub(crate) fn days_between(from: &str, until: &str) -> std::result::Result<Vec<String>, &'static str> {
103 let (Some(start), Some(end)) = (parse_rfc3339(&format!("{from}T00:00:00Z")), parse_rfc3339(&format!("{until}T00:00:00Z"))) else {
104 return Err("Give the range as days, YYYY-MM-DD.");
105 };
106 if end < start {
107 return Err("The range ends before it starts.");
108 }
109 if (end - start) / DAY_MS + 1 > MAX_DAYS {
110 return Err("Ask for at most 400 days at a time.");
111 }
112 Ok((0..=(end - start) / DAY_MS).map(|n| rfc3339(start + n * DAY_MS)[..10].to_owned()).collect())
113}
114
115/// One group of ledger lines: a day, a meter and a project.
116#[derive(Clone, Debug, Default, Deserialize, PartialEq)]
117pub(crate) struct Cell {
118 pub day: String,
119 pub meter: String,
120 #[serde(default)]
121 pub project: String,
122 #[serde(default)]
123 pub price: Option<f64>,
124 #[serde(default)]
125 pub quantity: Option<f64>,
126 #[serde(default)]
127 pub entries: Option<f64>,
128 #[serde(default)]
129 pub discount: Option<f64>,
130 #[serde(default)]
131 pub covered: Option<f64>,
132 #[serde(default)]
133 pub cost: Option<f64>,
134}
135
136/// Tokens a day's runs used, by project.
137#[derive(Clone, Debug, Default, Deserialize, PartialEq)]
138pub(crate) struct TokenCell {
139 pub day: String,
140 #[serde(default)]
141 pub project: String,
142 #[serde(default)]
143 pub tokens: Option<f64>,
144}
145
146/// What is metered this month and charged when it closes, by meter.
147#[derive(Clone, Debug, PartialEq)]
148pub(crate) struct Pending {
149 pub meter: &'static str,
150 pub micros: i64,
151}
152
153/// Shapes the groups into the page: products in order, each with its
154/// meters, each meter with every day of the range and its projects. Only
155/// the products and projects asked for (all, when none are).
156pub(crate) fn shape(
157 days: &[String],
158 cells: &[Cell],
159 tokens: &[TokenCell],
160 pending: &[Pending],
161 products: &[String],
162 projects: &[String],
163) -> (Vec<ProductUsage>, Vec<UsageDay>, UsageTotals, Vec<String>) {
164 let wanted_product = |product: &str| products.is_empty() || products.iter().any(|p| p == product);
165 let wanted_project = |project: &str| projects.is_empty() || projects.iter().any(|p| p == project);
166 let index: BTreeMap<&str, usize> = days.iter().enumerate().map(|(i, d)| (d.as_str(), i)).collect();
167 let product_of = |meter: &str| METERS.iter().find(|m| m.0 == meter).map_or("agent", |m| m.2);
168 let mut all_projects = BTreeSet::new();
169 let mut lines: Vec<MeterLine> = METERS
170 .iter()
171 .map(|(key, label, product, unit)| MeterLine {
172 key: (*key).to_owned(),
173 label: (*label).to_owned(),
174 product: (*product).to_owned(),
175 unit: (*unit).to_owned(),
176 quantity: 0.0,
177 micros: 0,
178 pending_micros: 0,
179 daily: vec![0; days.len()],
180 allowance: None,
181 by_project: vec![],
Merge branch 'model-routing'182 note: None,
Usage, Billing settings and prepaid AI credit; fixes from the UX audit183 })
184 .collect();
185 let mut by_day: BTreeMap<(String, String), i64> = BTreeMap::new();
186 let mut totals = UsageTotals::default();
187 let mut projects_of: BTreeMap<(String, String), (i64, f64)> = BTreeMap::new();
188 for cell in cells {
189 if !cell.project.is_empty() {
190 all_projects.insert(cell.project.clone());
191 }
192 let product = product_of(&cell.meter);
193 if !wanted_product(product) || !wanted_project(&cell.project) {
194 continue;
195 }
196 let Some(line) = lines.iter_mut().find(|l| l.key == cell.meter) else { continue };
197 let price = cell.price.unwrap_or(0.0).round() as i64;
198 line.micros += price;
199 // Tokens for the model meter come from the token counts below.
200 if line.key != "agent_models" {
201 line.quantity += if line.unit == "entries" { cell.entries.unwrap_or(0.0) } else { cell.quantity.unwrap_or(0.0) };
202 }
203 if let Some(i) = index.get(cell.day.as_str()) {
204 line.daily[*i] += price;
205 }
206 *by_day.entry((cell.day.clone(), product.to_owned())).or_default() += price;
207 let part = projects_of.entry((cell.meter.clone(), cell.project.clone())).or_default();
208 part.0 += price;
209 part.1 += if line.unit == "entries" { cell.entries.unwrap_or(0.0) } else { cell.quantity.unwrap_or(0.0) };
210 totals.price_micros += price;
211 totals.discount_micros += cell.discount.unwrap_or(0.0).round() as i64;
212 totals.included_micros += cell.covered.unwrap_or(0.0).round() as i64;
213 totals.cost_micros += cell.cost.unwrap_or(0.0).round() as i64;
214 }
215 if wanted_product("agent") {
216 for cell in tokens {
217 if !wanted_project(&cell.project) {
218 continue;
219 }
220 let tokens = cell.tokens.unwrap_or(0.0);
221 if let Some(line) = lines.iter_mut().find(|l| l.key == "agent_models") {
222 line.quantity += tokens;
223 }
224 projects_of.entry(("agent_models".to_owned(), cell.project.clone())).or_default().1 += tokens;
225 }
226 }
227 // Pending usage is no one project's: only when all projects are shown.
228 if projects.is_empty() {
229 for p in pending {
230 let product = product_of(p.meter);
231 if !wanted_product(product) {
232 continue;
233 }
234 if let Some(line) = lines.iter_mut().find(|l| l.key == p.meter) {
235 line.micros += p.micros;
236 line.pending_micros += p.micros;
237 }
238 totals.price_micros += p.micros;
239 totals.pending_micros += p.micros;
240 }
241 }
242 for ((meter, project), (micros, quantity)) in projects_of {
243 if let Some(line) = lines.iter_mut().find(|l| l.key == meter) {
244 line.by_project.push(ProjectUsage { project, micros, quantity });
245 }
246 }
247 for line in &mut lines {
248 line.by_project.sort_by(|a, b| b.micros.cmp(&a.micros).then(a.project.cmp(&b.project)));
249 }
250 let products_out = PRODUCTS
251 .iter()
252 .filter(|(key, _)| wanted_product(key))
253 .map(|(key, label)| {
254 let meters: Vec<MeterLine> = lines.iter().filter(|l| l.product == *key && (l.key != "other" || l.micros != 0)).cloned().collect();
255 ProductUsage {
256 key: (*key).to_owned(),
257 label: (*label).to_owned(),
258 micros: meters.iter().map(|m| m.micros).sum(),
259 meters,
260 features: vec![],
261 }
262 })
263 .collect();
264 let days_out = by_day.into_iter().filter(|(_, micros)| *micros != 0).map(|((day, product), micros)| UsageDay { day, product, micros }).collect();
265 (products_out, days_out, totals, all_projects.into_iter().collect())
266}
267
268/// What is left to pay: usage at price less the discount, included usage
269/// and credit.
270pub(crate) fn charged(totals: &UsageTotals) -> i64 {
271 (totals.price_micros - totals.discount_micros - totals.included_micros - totals.credits_micros).max(0)
272}
273
274impl Billing {
275 /// `usage_report`: members only.
276 pub(crate) async fn usage_report(&self, a: UsageReportArgs) -> Result<Outcome<UsageReport>> {
277 let workspace = a.workspace.to_lowercase();
278 if !a.viewer.is_some_and(|viewer| viewer.is_member(&workspace)) {
279 return Ok(members_only());
280 }
281 let days = match days_between(&a.from, &a.until) {
282 Ok(days) => days,
283 Err(why) => return Ok(Outcome::fail(FailureCode::Invalid, why)),
284 };
285 let from = days[0].clone();
286 let until = days[days.len() - 1].clone();
287 // Lines entered on the last day count, whatever the hour.
288 let end = rfc3339(parse_rfc3339(&format!("{until}T00:00:00Z")).unwrap_or(0) + DAY_MS);
289 let measure = if self.free { "COALESCE(cost_micros, 0)" } else { PRICE_SQL };
290 let now = rfc3339(now_ms());
291 let month = now[..7].to_owned();
292 let this_month = from.as_str() <= now.as_str() && until.as_str() >= &format!("{month}-01")[..];
293
294 let cells = async {
295 self.db
296 .prepare(format!(
297 "SELECT substr(created_at, 1, 10) AS day, {meter} AS meter, COALESCE(repo, '') AS project,
298 SUM({measure}) AS price, SUM(COALESCE(quantity, 0)) AS quantity, COUNT(*) AS entries,
299 SUM(COALESCE(discount_micros, 0)) AS discount,
300 SUM(COALESCE(credit_micros, 0) + COALESCE(trial_micros, 0) + COALESCE(oss_micros, 0) + COALESCE(given_micros, 0)) AS covered,
301 SUM(COALESCE(cost_micros, 0)) AS cost
302 FROM ledger WHERE workspace = ?1 AND kind = 'usage' AND created_at >= ?2 AND created_at < ?3
303 GROUP BY 1, 2, 3 LIMIT 20000",
304 meter = METER_KEY_SQL
305 ))
306 .bind(&[workspace.as_str().into(), from.as_str().into(), end.as_str().into()])?
307 .all()
308 .await?
309 .results::<Cell>()
310 };
311 let tokens = async {
312 self.db
313 .prepare(
314 "SELECT t.day AS day, COALESCE(r.repo, '') AS project,
315 SUM(t.input + t.output + t.cache_read + t.cache_write) AS tokens
316 FROM token_usage t LEFT JOIN runs r ON r.session_id = t.session
317 WHERE t.workspace = ?1 AND t.day >= ?2 AND t.day <= ?3
318 GROUP BY 1, 2 LIMIT 20000",
319 )
320 .bind(&[workspace.as_str().into(), from.as_str().into(), until.as_str().into()])?
321 .all()
322 .await?
323 .results::<TokenCell>()
324 };
325 #[derive(Deserialize)]
326 struct Feature {
327 task: Option<String>,
328 price: Option<f64>,
329 runs: Option<f64>,
330 }
331 let features = async {
332 self.db
333 .prepare(format!(
334 "SELECT COALESCE(task, 'implement') AS task, SUM({measure}) AS price, SUM(CASE WHEN {RUN_SQL} THEN 1 ELSE 0 END) AS runs
335 FROM ledger WHERE workspace = ?1 AND kind = 'usage' AND created_at >= ?2 AND created_at < ?3
Merge branch 'model-routing'336 AND ({meter}) IN ('agent_models', 'agent_rate', 'agent_rate_own', 'agent_sandbox')
Usage, Billing settings and prepaid AI credit; fixes from the UX audit337 GROUP BY 1",
338 meter = METER_KEY_SQL
339 ))
340 .bind(&[workspace.as_str().into(), from.as_str().into(), end.as_str().into()])?
341 .all()
342 .await?
343 .results::<Feature>()
344 };
345 #[derive(Deserialize)]
346 struct PendingRow {
347 source: String,
348 cost_micros: Option<f64>,
349 }
350 let pending = async {
351 if !this_month {
352 return Ok(vec![]);
353 }
354 self.db
355 .prepare("SELECT source, cost_micros FROM pending_usage WHERE workspace = ? AND month = ? AND charged_at IS NULL")
356 .bind(&[workspace.as_str().into(), month.as_str().into()])?
357 .all()
358 .await?
359 .results::<PendingRow>()
360 };
361 let credits = self.credit_paid_between(&workspace, &from, &end);
362 let ((cells, tokens, features, pending, (credits_paid, credits)), account) =
363 try_join(try_join5(cells, tokens, features, pending, credits), self.account_of(&workspace)).await?;
364 let pending: Vec<Pending> = pending
365 .into_iter()
366 .map(|p| Pending {
367 meter: meter_of_pending(&p.source),
368 micros: crate::credits::with_margin(p.cost_micros.unwrap_or(0.0).round() as i64, self.margin_percent),
369 })
370 .filter(|p| p.micros > 0)
371 .collect();
372 let (mut products, days_out, mut totals, all_projects) = shape(&days, &cells, &tokens, &pending, &a.products, &a.projects);
373 let percent = account.terms.percent_off();
374 // What is metered so far is charged on the account's terms when the
375 // month closes: its discount comes off it too.
376 totals.discount_micros += totals.pending_micros * i64::from(percent.min(100)) / 100;
377 let filtered = !a.products.is_empty() || !a.projects.is_empty();
378 // Credit is the workspace's, not a product's or a project's: only
379 // without a filter is it taken off.
380 totals.credits_micros = if filtered { 0 } else { credits_paid.max(0) };
381 totals.charged_micros = charged(&totals);
382 if let Some(agent) = products.iter_mut().find(|p| p.key == "agent") {
383 let mut grouped: BTreeMap<&str, FeatureUsage> = BTreeMap::new();
384 for f in &features {
385 let (key, label) = feature_of(f.task.as_deref().unwrap_or("implement"));
386 let entry = grouped.entry(key).or_insert_with(|| FeatureUsage { key: key.to_owned(), label: label.to_owned(), micros: 0, count: 0 });
387 entry.micros += f.price.unwrap_or(0.0).round() as i64;
388 entry.count += f.runs.unwrap_or(0.0) as u32;
389 }
390 agent.features = grouped.into_values().filter(|f| f.micros != 0 || f.count > 0).collect();
391 agent.features.sort_by_key(|f| std::cmp::Reverse(f.micros));
392 }
393 let plan = self.plan_kind_for(&workspace, &account).await?;
394 // Allowances, for the range that includes this month.
395 let mut included = None;
396 let mut trial = None;
397 if this_month {
398 let (used, stored, operations) = futures_util::future::try_join3(
399 async {
400 if plan == PlanKind::Paid { self.allowance_used("plan_credit", &workspace, &month).await } else { Ok(0) }
401 },
402 self.private_storage(&workspace),
403 self.git_operations_this_month(&workspace),
404 )
405 .await?;
406 if plan == PlanKind::Paid {
407 included = Some(Allowance { used: used as f64, of: self.plans.plan_included_micros as f64, unit: "micros".to_owned() });
408 }
409 for product in &mut products {
410 for meter in &mut product.meters {
411 match meter.key.as_str() {
412 "storage" => {
413 meter.quantity = stored as f64;
414 meter.allowance = Some(Allowance { used: stored as f64, of: self.plans.free_storage_bytes as f64, unit: "bytes".to_owned() });
415 }
416 "git" => {
417 meter.quantity = operations as f64;
418 meter.allowance = Some(Allowance { used: operations as f64, of: self.plans.git_included as f64, unit: "operations".to_owned() });
419 }
420 _ => {}
421 }
422 }
423 }
424 if plan == PlanKind::Free
425 && let Some(grant) = self.grant_of(&workspace).await?
426 {
427 trial = Some(crate::credits::left(grant.granted_micros, grant.used_micros));
428 }
429 }
Merge branch 'model-routing'430 // The agent rate's tokens are weighted by kind: say how.
431 let weights = self.token_weights().await?;
432 for product in &mut products {
433 for meter in &mut product.meters {
434 if meter.key == "agent_rate" || meter.key == "agent_rate_own" {
435 meter.note = Some(agent_rate_note(&weights));
436 }
437 }
438 }
Usage, Billing settings and prepaid AI credit; fixes from the UX audit439 let ai: i64 = credits.grants.iter().filter(|g| g.scope == "models").map(|g| g.left_micros).sum();
Merge branch 'model-routing'440 let models = self.tokens_by_model(&workspace, &from, &until).await?;
Usage, Billing settings and prepaid AI credit; fixes from the UX audit441 Ok(Outcome::Ok(UsageReport {
442 from,
443 until,
444 totals,
445 days: days_out,
446 products,
447 projects: all_projects,
Merge branch 'model-routing'448 models,
Usage, Billing settings and prepaid AI credit; fixes from the UX audit449 included,
450 discount_percent: (percent > 0).then_some(percent),
451 ai_credit_micros: ai,
452 credit_micros: credits.left_micros - ai,
453 trial_micros: trial,
454 plan,
455 free: self.free,
456 }))
457 }
458}
459
Merge branch 'model-routing'460/// What the agent rate's meters say of their tokens.
461pub(crate) fn agent_rate_note(weights: &crate::ai::TokenWeights) -> String {
462 if weights.is_flat() {
463 "Weighted tokens: every token counts once (input ×1, output ×1, cache reads ×1, cache writes ×1)".to_owned()
464 } else {
465 format!("Weighted tokens: {}", weights.describe())
466 }
467}
468
469impl Billing {
470 /// Agent tokens by model over the days `from` to `until`, most first:
471 /// what the model proxy counted, on g1t's models and the workspace's
472 /// own provider alike.
473 async fn tokens_by_model(&self, workspace: &str, from: &str, until: &str) -> Result<Vec<ModelTokens>> {
474 #[derive(Deserialize)]
475 struct Row {
476 model: String,
477 input: Option<f64>,
478 output: Option<f64>,
479 cache_read: Option<f64>,
480 cache_write: Option<f64>,
481 }
482 let rows = self
483 .db
484 .prepare(
485 "SELECT model, SUM(input) AS input, SUM(output) AS output, SUM(cache_read) AS cache_read, SUM(cache_write) AS cache_write
486 FROM token_usage WHERE workspace = ?1 AND day >= ?2 AND day <= ?3
487 GROUP BY model ORDER BY SUM(input + output + cache_read + cache_write) DESC LIMIT 20",
488 )
489 .bind(&[workspace.into(), from.into(), until.into()])?
490 .all()
491 .await?
492 .results::<Row>()?;
493 let n = |v: Option<f64>| v.unwrap_or(0.0).max(0.0) as u64;
494 Ok(rows
495 .into_iter()
496 .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) })
497 .collect())
498 }
499}
500
Usage, Billing settings and prepaid AI credit; fixes from the UX audit501#[cfg(test)]
502mod tests {
503 use super::*;
504
505 fn cell(day: &str, meter: &str, project: &str, price: f64) -> Cell {
506 Cell { day: day.into(), meter: meter.into(), project: project.into(), price: Some(price), entries: Some(1.0), ..Cell::default() }
507 }
508
509 #[test]
510 fn a_range_is_every_day_in_it() {
511 let days = days_between("2026-10-01", "2026-10-03").unwrap();
512 assert_eq!(days, ["2026-10-01", "2026-10-02", "2026-10-03"]);
513 assert_eq!(days_between("2026-10-01", "2026-10-01").unwrap().len(), 1);
514 assert!(days_between("2026-10-03", "2026-10-01").is_err());
515 assert!(days_between("2025-01-01", "2026-10-01").is_err());
516 assert!(days_between("Oct 1", "2026-10-01").is_err());
517 }
518
519 #[test]
520 fn usage_is_shaped_into_products_meters_days_and_projects() {
521 let days = days_between("2026-10-01", "2026-10-03").unwrap();
522 let mut sandbox = cell("2026-10-02", "sandbox", "acme/web", 120_000.0);
523 sandbox.quantity = Some(600.0);
524 let cells = vec![
525 cell("2026-10-01", "agent_models", "acme/web", 2_000_000.0),
526 cell("2026-10-01", "agent_rate", "acme/web", 500_000.0),
527 cell("2026-10-03", "agent_models", "acme/api", 1_000_000.0),
528 sandbox,
529 Cell { covered: Some(1_000_000.0), discount: Some(0.0), ..cell("2026-10-03", "storage", "", 30_000.0) },
530 ];
531 let tokens = vec![TokenCell { day: "2026-10-01".into(), project: "acme/web".into(), tokens: Some(2_000_000.0) }];
532 let pending = vec![Pending { meter: "git", micros: 40_000 }];
533 let (products, by_day, totals, projects) = shape(&days, &cells, &tokens, &pending, &[], &[]);
534 // Every product family, in order.
535 assert_eq!(products.iter().map(|p| p.key.as_str()).collect::<Vec<_>>(), ["agent", "sandboxes", "gateway", "deployments", "git_storage", "packages", "security", "search"]);
536 let agent = &products[0];
537 assert_eq!(agent.micros, 3_500_000);
538 let models = agent.meters.iter().find(|m| m.key == "agent_models").unwrap();
539 assert_eq!(models.daily, [2_000_000, 0, 1_000_000]);
540 assert_eq!(models.quantity, 2_000_000.0);
541 assert_eq!(models.by_project[0].project, "acme/web");
542 let sandbox = products[1].meters.iter().find(|m| m.key == "sandbox").unwrap();
543 assert_eq!((sandbox.micros, sandbox.quantity), (120_000, 600.0));
544 // Pending usage counts in the totals and its meter, not on a day.
545 let git = products[4].meters.iter().find(|m| m.key == "git").unwrap();
546 assert_eq!((git.micros, git.pending_micros), (40_000, 40_000));
547 assert_eq!(totals.price_micros, 3_500_000 + 120_000 + 30_000 + 40_000);
548 assert_eq!(totals.pending_micros, 40_000);
549 assert_eq!(totals.included_micros, 1_000_000);
550 assert_eq!(by_day.iter().filter(|d| d.product == "agent").map(|d| d.micros).sum::<i64>(), 3_500_000);
551 assert_eq!(projects, ["acme/api", "acme/web"]);
552 }
553
554 #[test]
555 fn filters_keep_only_the_products_and_projects_asked_for() {
556 let days = days_between("2026-10-01", "2026-10-01").unwrap();
557 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)];
558 let pending = vec![Pending { meter: "git", micros: 7 }];
559 let (products, _, totals, projects) = shape(&days, &cells, &[], &pending, &["agent".into()], &["acme/web".into()]);
560 assert_eq!(products.len(), 1);
561 assert_eq!(totals.price_micros, 100);
562 // Every project stays listed, for the filter itself.
563 assert_eq!(projects, ["acme/api", "acme/web"]);
564 }
565
566 #[test]
567 fn what_is_left_to_pay_takes_off_the_discount_included_usage_and_credit() {
568 let t = UsageTotals { price_micros: 10_000_000, discount_micros: 0, included_micros: 4_000_000, credits_micros: 5_000_000, ..UsageTotals::default() };
569 assert_eq!(charged(&t), 1_000_000);
570 // A 100% discount: shown at price, then nothing to pay.
571 let comped = UsageTotals { price_micros: 3_320_000, discount_micros: 3_320_000, ..UsageTotals::default() };
572 assert_eq!(charged(&comped), 0);
573 }
574
575 #[test]
576 fn the_agent_is_counted_by_what_it_was_doing() {
577 assert_eq!(feature_of("review").0, "reviews");
578 assert_eq!(feature_of("plan").0, "plans");
579 assert_eq!(feature_of("implement").0, "runs");
580 assert_eq!(meter_of_pending("package_storage"), "packages");
581 assert_eq!(meter_of_pending("nonsense"), "other");
582 }
583}

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