Skip to content
564 linesCodeBlameRaw
1//! 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::{
17 Allowance, FeatureUsage, MeterLine, ModelTokens, PlanKind, ProductUsage, ProjectUsage, UsageDay, UsageReport, UsageReportArgs, UsageTotals,
18 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'
49 WHEN reference LIKE '%/agent-own%' THEN 'agent_rate_own'
50 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.
54pub(crate) const METERS: [(&str, &str, &str, &str); 17] = [
55 ("agent_models", "Model tokens", "agent", "tokens"),
56 ("agent_rate", "Agent rate", "agent", "tokens"),
57 ("agent_rate_own", "Agent rate, your own model key", "agent", "tokens"),
58 ("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![],
182 })
183 .collect();
184 let mut by_day: BTreeMap<(String, String), i64> = BTreeMap::new();
185 let mut totals = UsageTotals::default();
186 let mut projects_of: BTreeMap<(String, String), (i64, f64)> = BTreeMap::new();
187 for cell in cells {
188 if !cell.project.is_empty() {
189 all_projects.insert(cell.project.clone());
190 }
191 let product = product_of(&cell.meter);
192 if !wanted_product(product) || !wanted_project(&cell.project) {
193 continue;
194 }
195 let Some(line) = lines.iter_mut().find(|l| l.key == cell.meter) else { continue };
196 let price = cell.price.unwrap_or(0.0).round() as i64;
197 line.micros += price;
198 // Tokens for the model meter come from the token counts below.
199 if line.key != "agent_models" {
200 line.quantity += if line.unit == "entries" { cell.entries.unwrap_or(0.0) } else { cell.quantity.unwrap_or(0.0) };
201 }
202 if let Some(i) = index.get(cell.day.as_str()) {
203 line.daily[*i] += price;
204 }
205 *by_day.entry((cell.day.clone(), product.to_owned())).or_default() += price;
206 let part = projects_of.entry((cell.meter.clone(), cell.project.clone())).or_default();
207 part.0 += price;
208 part.1 += if line.unit == "entries" { cell.entries.unwrap_or(0.0) } else { cell.quantity.unwrap_or(0.0) };
209 totals.price_micros += price;
210 totals.discount_micros += cell.discount.unwrap_or(0.0).round() as i64;
211 totals.included_micros += cell.covered.unwrap_or(0.0).round() as i64;
212 totals.cost_micros += cell.cost.unwrap_or(0.0).round() as i64;
213 }
214 if wanted_product("agent") {
215 for cell in tokens {
216 if !wanted_project(&cell.project) {
217 continue;
218 }
219 let tokens = cell.tokens.unwrap_or(0.0);
220 if let Some(line) = lines.iter_mut().find(|l| l.key == "agent_models") {
221 line.quantity += tokens;
222 }
223 projects_of.entry(("agent_models".to_owned(), cell.project.clone())).or_default().1 += tokens;
224 }
225 }
226 // Pending usage is no one project's: only when all projects are shown.
227 if projects.is_empty() {
228 for p in pending {
229 let product = product_of(p.meter);
230 if !wanted_product(product) {
231 continue;
232 }
233 if let Some(line) = lines.iter_mut().find(|l| l.key == p.meter) {
234 line.micros += p.micros;
235 line.pending_micros += p.micros;
236 }
237 totals.price_micros += p.micros;
238 totals.pending_micros += p.micros;
239 }
240 }
241 for ((meter, project), (micros, quantity)) in projects_of {
242 if let Some(line) = lines.iter_mut().find(|l| l.key == meter) {
243 line.by_project.push(ProjectUsage { project, micros, quantity });
244 }
245 }
246 for line in &mut lines {
247 line.by_project.sort_by(|a, b| b.micros.cmp(&a.micros).then(a.project.cmp(&b.project)));
248 }
249 let products_out = PRODUCTS
250 .iter()
251 .filter(|(key, _)| wanted_product(key))
252 .map(|(key, label)| {
253 let meters: Vec<MeterLine> = lines.iter().filter(|l| l.product == *key && (l.key != "other" || l.micros != 0)).cloned().collect();
254 ProductUsage {
255 key: (*key).to_owned(),
256 label: (*label).to_owned(),
257 micros: meters.iter().map(|m| m.micros).sum(),
258 meters,
259 features: vec![],
260 }
261 })
262 .collect();
263 let days_out = by_day.into_iter().filter(|(_, micros)| *micros != 0).map(|((day, product), micros)| UsageDay { day, product, micros }).collect();
264 (products_out, days_out, totals, all_projects.into_iter().collect())
265}
266
267/// What is left to pay: usage at price less the discount, included usage
268/// and credit.
269pub(crate) fn charged(totals: &UsageTotals) -> i64 {
270 (totals.price_micros - totals.discount_micros - totals.included_micros - totals.credits_micros).max(0)
271}
272
273impl Billing {
274 /// `usage_report`: members only.
275 pub(crate) async fn usage_report(&self, a: UsageReportArgs) -> Result<Outcome<UsageReport>> {
276 let workspace = a.workspace.to_lowercase();
277 if !a.viewer.is_some_and(|viewer| viewer.is_member(&workspace)) {
278 return Ok(members_only());
279 }
280 let days = match days_between(&a.from, &a.until) {
281 Ok(days) => days,
282 Err(why) => return Ok(Outcome::fail(FailureCode::Invalid, why)),
283 };
284 let from = days[0].clone();
285 let until = days[days.len() - 1].clone();
286 // Lines entered on the last day count, whatever the hour.
287 let end = rfc3339(parse_rfc3339(&format!("{until}T00:00:00Z")).unwrap_or(0) + DAY_MS);
288 let measure = if self.free { "COALESCE(cost_micros, 0)" } else { PRICE_SQL };
289 let now = rfc3339(now_ms());
290 let month = now[..7].to_owned();
291 let this_month = from.as_str() <= now.as_str() && until.as_str() >= &format!("{month}-01")[..];
292
293 let cells = async {
294 self.db
295 .prepare(format!(
296 "SELECT substr(created_at, 1, 10) AS day, {meter} AS meter, COALESCE(repo, '') AS project,
297 SUM({measure}) AS price, SUM(COALESCE(quantity, 0)) AS quantity, COUNT(*) AS entries,
298 SUM(COALESCE(discount_micros, 0)) AS discount,
299 SUM(COALESCE(credit_micros, 0) + COALESCE(trial_micros, 0) + COALESCE(oss_micros, 0) + COALESCE(given_micros, 0)) AS covered,
300 SUM(COALESCE(cost_micros, 0)) AS cost
301 FROM ledger WHERE workspace = ?1 AND kind = 'usage' AND created_at >= ?2 AND created_at < ?3
302 GROUP BY 1, 2, 3 LIMIT 20000",
303 meter = METER_KEY_SQL
304 ))
305 .bind(&[workspace.as_str().into(), from.as_str().into(), end.as_str().into()])?
306 .all()
307 .await?
308 .results::<Cell>()
309 };
310 let tokens = async {
311 self.db
312 .prepare(
313 "SELECT t.day AS day, COALESCE(r.repo, '') AS project,
314 SUM(t.input + t.output + t.cache_read + t.cache_write) AS tokens
315 FROM token_usage t LEFT JOIN runs r ON r.session_id = t.session
316 WHERE t.workspace = ?1 AND t.day >= ?2 AND t.day <= ?3
317 GROUP BY 1, 2 LIMIT 20000",
318 )
319 .bind(&[workspace.as_str().into(), from.as_str().into(), until.as_str().into()])?
320 .all()
321 .await?
322 .results::<TokenCell>()
323 };
324 #[derive(Deserialize)]
325 struct Feature {
326 task: Option<String>,
327 price: Option<f64>,
328 runs: Option<f64>,
329 }
330 let features = async {
331 self.db
332 .prepare(format!(
333 "SELECT COALESCE(task, 'implement') AS task, SUM({measure}) AS price, SUM(CASE WHEN {RUN_SQL} THEN 1 ELSE 0 END) AS runs
334 FROM ledger WHERE workspace = ?1 AND kind = 'usage' AND created_at >= ?2 AND created_at < ?3
335 AND ({meter}) IN ('agent_models', 'agent_rate', 'agent_rate_own', 'agent_sandbox')
336 GROUP BY 1",
337 meter = METER_KEY_SQL
338 ))
339 .bind(&[workspace.as_str().into(), from.as_str().into(), end.as_str().into()])?
340 .all()
341 .await?
342 .results::<Feature>()
343 };
344 #[derive(Deserialize)]
345 struct PendingRow {
346 source: String,
347 cost_micros: Option<f64>,
348 }
349 let pending = async {
350 if !this_month {
351 return Ok(vec![]);
352 }
353 self.db
354 .prepare("SELECT source, cost_micros FROM pending_usage WHERE workspace = ? AND month = ? AND charged_at IS NULL")
355 .bind(&[workspace.as_str().into(), month.as_str().into()])?
356 .all()
357 .await?
358 .results::<PendingRow>()
359 };
360 let credits = self.credit_paid_between(&workspace, &from, &end);
361 let ((cells, tokens, features, pending, (credits_paid, credits)), account) =
362 try_join(try_join5(cells, tokens, features, pending, credits), self.account_of(&workspace)).await?;
363 let pending: Vec<Pending> = pending
364 .into_iter()
365 .map(|p| Pending {
366 meter: meter_of_pending(&p.source),
367 micros: crate::credits::with_margin(p.cost_micros.unwrap_or(0.0).round() as i64, self.margin_percent),
368 })
369 .filter(|p| p.micros > 0)
370 .collect();
371 let (mut products, days_out, mut totals, all_projects) = shape(&days, &cells, &tokens, &pending, &a.products, &a.projects);
372 let percent = account.terms.percent_off();
373 // What is metered so far is charged on the account's terms when the
374 // month closes: its discount comes off it too.
375 totals.discount_micros += totals.pending_micros * i64::from(percent.min(100)) / 100;
376 let filtered = !a.products.is_empty() || !a.projects.is_empty();
377 // Credit is the workspace's, not a product's or a project's: only
378 // without a filter is it taken off.
379 totals.credits_micros = if filtered { 0 } else { credits_paid.max(0) };
380 totals.charged_micros = charged(&totals);
381 if let Some(agent) = products.iter_mut().find(|p| p.key == "agent") {
382 let mut grouped: BTreeMap<&str, FeatureUsage> = BTreeMap::new();
383 for f in &features {
384 let (key, label) = feature_of(f.task.as_deref().unwrap_or("implement"));
385 let entry = grouped.entry(key).or_insert_with(|| FeatureUsage { key: key.to_owned(), label: label.to_owned(), micros: 0, count: 0 });
386 entry.micros += f.price.unwrap_or(0.0).round() as i64;
387 entry.count += f.runs.unwrap_or(0.0) as u32;
388 }
389 agent.features = grouped.into_values().filter(|f| f.micros != 0 || f.count > 0).collect();
390 agent.features.sort_by_key(|f| std::cmp::Reverse(f.micros));
391 }
392 let plan = self.plan_kind_for(&workspace, &account).await?;
393 // Allowances, for the range that includes this month.
394 let mut included = None;
395 let mut trial = None;
396 if this_month {
397 let (used, stored, operations) = futures_util::future::try_join3(
398 async {
399 if plan == PlanKind::Paid { self.allowance_used("plan_credit", &workspace, &month).await } else { Ok(0) }
400 },
401 self.private_storage(&workspace),
402 self.git_operations_this_month(&workspace),
403 )
404 .await?;
405 if plan == PlanKind::Paid {
406 included = Some(Allowance { used: used as f64, of: self.plans.plan_included_micros as f64, unit: "micros".to_owned() });
407 }
408 for product in &mut products {
409 for meter in &mut product.meters {
410 match meter.key.as_str() {
411 "storage" => {
412 meter.quantity = stored as f64;
413 meter.allowance = Some(Allowance { used: stored as f64, of: self.plans.free_storage_bytes as f64, unit: "bytes".to_owned() });
414 }
415 "git" => {
416 meter.quantity = operations as f64;
417 meter.allowance = Some(Allowance { used: operations as f64, of: self.plans.git_included as f64, unit: "operations".to_owned() });
418 }
419 _ => {}
420 }
421 }
422 }
423 if plan == PlanKind::Free
424 && let Some(grant) = self.grant_of(&workspace).await?
425 {
426 trial = Some(crate::credits::left(grant.granted_micros, grant.used_micros));
427 }
428 }
429 let ai: i64 = credits.grants.iter().filter(|g| g.scope == "models").map(|g| g.left_micros).sum();
430 let models = self.tokens_by_model(&workspace, &from, &until).await?;
431 Ok(Outcome::Ok(UsageReport {
432 from,
433 until,
434 totals,
435 days: days_out,
436 products,
437 projects: all_projects,
438 models,
439 included,
440 discount_percent: (percent > 0).then_some(percent),
441 ai_credit_micros: ai,
442 credit_micros: credits.left_micros - ai,
443 trial_micros: trial,
444 plan,
445 free: self.free,
446 }))
447 }
448}
449
450impl Billing {
451 /// Agent tokens by model over the days `from` to `until`, most first:
452 /// what the model proxy counted, on g1t's models and the workspace's
453 /// own provider alike.
454 async fn tokens_by_model(&self, workspace: &str, from: &str, until: &str) -> Result<Vec<ModelTokens>> {
455 #[derive(Deserialize)]
456 struct Row {
457 model: String,
458 input: Option<f64>,
459 output: Option<f64>,
460 cache_read: Option<f64>,
461 cache_write: Option<f64>,
462 }
463 let rows = self
464 .db
465 .prepare(
466 "SELECT model, SUM(input) AS input, SUM(output) AS output, SUM(cache_read) AS cache_read, SUM(cache_write) AS cache_write
467 FROM token_usage WHERE workspace = ?1 AND day >= ?2 AND day <= ?3
468 GROUP BY model ORDER BY SUM(input + output + cache_read + cache_write) DESC LIMIT 20",
469 )
470 .bind(&[workspace.into(), from.into(), until.into()])?
471 .all()
472 .await?
473 .results::<Row>()?;
474 let n = |v: Option<f64>| v.unwrap_or(0.0).max(0.0) as u64;
475 Ok(rows
476 .into_iter()
477 .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) })
478 .collect())
479 }
480}
481
482#[cfg(test)]
483mod tests {
484 use super::*;
485
486 fn cell(day: &str, meter: &str, project: &str, price: f64) -> Cell {
487 Cell { day: day.into(), meter: meter.into(), project: project.into(), price: Some(price), entries: Some(1.0), ..Cell::default() }
488 }
489
490 #[test]
491 fn a_range_is_every_day_in_it() {
492 let days = days_between("2026-10-01", "2026-10-03").unwrap();
493 assert_eq!(days, ["2026-10-01", "2026-10-02", "2026-10-03"]);
494 assert_eq!(days_between("2026-10-01", "2026-10-01").unwrap().len(), 1);
495 assert!(days_between("2026-10-03", "2026-10-01").is_err());
496 assert!(days_between("2025-01-01", "2026-10-01").is_err());
497 assert!(days_between("Oct 1", "2026-10-01").is_err());
498 }
499
500 #[test]
501 fn usage_is_shaped_into_products_meters_days_and_projects() {
502 let days = days_between("2026-10-01", "2026-10-03").unwrap();
503 let mut sandbox = cell("2026-10-02", "sandbox", "acme/web", 120_000.0);
504 sandbox.quantity = Some(600.0);
505 let cells = vec![
506 cell("2026-10-01", "agent_models", "acme/web", 2_000_000.0),
507 cell("2026-10-01", "agent_rate", "acme/web", 500_000.0),
508 cell("2026-10-03", "agent_models", "acme/api", 1_000_000.0),
509 sandbox,
510 Cell { covered: Some(1_000_000.0), discount: Some(0.0), ..cell("2026-10-03", "storage", "", 30_000.0) },
511 ];
512 let tokens = vec![TokenCell { day: "2026-10-01".into(), project: "acme/web".into(), tokens: Some(2_000_000.0) }];
513 let pending = vec![Pending { meter: "git", micros: 40_000 }];
514 let (products, by_day, totals, projects) = shape(&days, &cells, &tokens, &pending, &[], &[]);
515 // Every product family, in order.
516 assert_eq!(products.iter().map(|p| p.key.as_str()).collect::<Vec<_>>(), ["agent", "sandboxes", "gateway", "deployments", "git_storage", "packages", "security", "search"]);
517 let agent = &products[0];
518 assert_eq!(agent.micros, 3_500_000);
519 let models = agent.meters.iter().find(|m| m.key == "agent_models").unwrap();
520 assert_eq!(models.daily, [2_000_000, 0, 1_000_000]);
521 assert_eq!(models.quantity, 2_000_000.0);
522 assert_eq!(models.by_project[0].project, "acme/web");
523 let sandbox = products[1].meters.iter().find(|m| m.key == "sandbox").unwrap();
524 assert_eq!((sandbox.micros, sandbox.quantity), (120_000, 600.0));
525 // Pending usage counts in the totals and its meter, not on a day.
526 let git = products[4].meters.iter().find(|m| m.key == "git").unwrap();
527 assert_eq!((git.micros, git.pending_micros), (40_000, 40_000));
528 assert_eq!(totals.price_micros, 3_500_000 + 120_000 + 30_000 + 40_000);
529 assert_eq!(totals.pending_micros, 40_000);
530 assert_eq!(totals.included_micros, 1_000_000);
531 assert_eq!(by_day.iter().filter(|d| d.product == "agent").map(|d| d.micros).sum::<i64>(), 3_500_000);
532 assert_eq!(projects, ["acme/api", "acme/web"]);
533 }
534
535 #[test]
536 fn filters_keep_only_the_products_and_projects_asked_for() {
537 let days = days_between("2026-10-01", "2026-10-01").unwrap();
538 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)];
539 let pending = vec![Pending { meter: "git", micros: 7 }];
540 let (products, _, totals, projects) = shape(&days, &cells, &[], &pending, &["agent".into()], &["acme/web".into()]);
541 assert_eq!(products.len(), 1);
542 assert_eq!(totals.price_micros, 100);
543 // Every project stays listed, for the filter itself.
544 assert_eq!(projects, ["acme/api", "acme/web"]);
545 }
546
547 #[test]
548 fn what_is_left_to_pay_takes_off_the_discount_included_usage_and_credit() {
549 let t = UsageTotals { price_micros: 10_000_000, discount_micros: 0, included_micros: 4_000_000, credits_micros: 5_000_000, ..UsageTotals::default() };
550 assert_eq!(charged(&t), 1_000_000);
551 // A 100% discount: shown at price, then nothing to pay.
552 let comped = UsageTotals { price_micros: 3_320_000, discount_micros: 3_320_000, ..UsageTotals::default() };
553 assert_eq!(charged(&comped), 0);
554 }
555
556 #[test]
557 fn the_agent_is_counted_by_what_it_was_doing() {
558 assert_eq!(feature_of("review").0, "reviews");
559 assert_eq!(feature_of("plan").0, "plans");
560 assert_eq!(feature_of("implement").0, "runs");
561 assert_eq!(meter_of_pending("package_storage"), "packages");
562 assert_eq!(meter_of_pending("nonsense"), "other");
563 }
564}