Skip to content

g1t/services/billing/src/report.rs

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