Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh
559 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 and Billing show the same numbers. Usage metered through the month and charged
8//! when it closes (storage, git operations, scans, embeddings, domains,
9//! app traffic) is added from `pending_usage`, at cost plus the margin, and
10//! said to be pending.
11//!
12//! Lines from before g1t stopped running agents and selling model access
13//! (agent runs, the agent rate, AI Gateway requests), and any line on a
14//! task this page does not know, are kept on one meter, Other, in a
15//! product of their own: nothing charged is ever left out.
16
17use std::collections::{BTreeMap, BTreeSet};
18
19use futures_util::future::{try_join, try_join3};
20use g1t_contracts::billing::{
21 Allowance, MeterLine, PlanKind, ProductUsage, ProjectUsage, UsageDay, UsageReport, UsageReportArgs, UsageTotals, PRODUCTS,
22};
23use g1t_contracts::time::{parse_rfc3339, rfc3339};
24use g1t_contracts::{FailureCode, Outcome};
25use g1t_kit::now_ms;
26use serde::Deserialize;
27use worker::Result;
28
29use crate::charged::{charged, cover_with_included, pending_split};
30use crate::statement::PRICE_SQL;
31use crate::{Billing, members_only};
32
33/// The longest range the page reads at once.
34pub(crate) const MAX_DAYS: u64 = 400;
35const DAY_MS: u64 = 86_400_000;
36
37/// Which meter a ledger line is on. One SQL expression, so every query
38/// agrees with `meters()`. Runner minutes are by machine (`runner_2-core`,
39/// one meter for each machine in the catalogue); sandbox lines from before
40/// minutes have no machine and stay sandbox time, in seconds.
41pub(crate) const METER_KEY_SQL: &str = "CASE
42 WHEN task = 'sandbox' AND machine IS NOT NULL THEN 'runner_' || machine
43 WHEN task = 'sandbox' THEN 'sandbox'
44 WHEN task = 'self_hosted' THEN 'self_hosted'
45 WHEN task = 'deployments' AND reference LIKE 'deploy/%' THEN 'builds'
46 WHEN task = 'deployments' THEN 'requests'
47 WHEN task = 'domains' THEN 'domains'
48 WHEN task = 'git' THEN 'git'
49 WHEN task = 'storage' THEN 'storage'
50 WHEN task = 'cache' THEN 'cache'
51 WHEN task = 'package_storage' THEN 'packages'
52 WHEN task = 'security' THEN 'security'
53 WHEN task = 'context' THEN 'context'
54 WHEN task = 'telemetry' THEN 'telemetry'
55 ELSE 'other' END";
56
57/// A meter: key, name, product family and the unit of its quantity.
58pub(crate) type Meter = (String, String, &'static str, &'static str);
59
60/// The runner-minutes meter of a machine (`runner_2-core`).
61pub(crate) fn runner_meter(machine: &str) -> String {
62 format!("runner_{machine}")
63}
64
65/// Every meter: runner minutes for each machine in the catalogue, then
66/// `METERS`.
67pub(crate) fn meters() -> Vec<Meter> {
68 g1t_contracts::actions::machines()
69 .into_iter()
70 .map(|m| (runner_meter(m.machine), format!("Runner minutes, {} ({})", m.machine, m.size()), "sandboxes", "minutes"))
71 .chain(METERS.iter().map(|(key, label, product, unit)| ((*key).to_owned(), (*label).to_owned(), *product, *unit)))
72 .collect()
73}
74
75/// Every meter but the runners': key, name, product family and the unit
76/// of its quantity.
77pub(crate) const METERS: [(&str, &str, &str, &str); 13] = [
78 // Sandbox lines from before runner minutes, by the second.
79 ("sandbox", "Sandbox time", "sandboxes", "seconds"),
80 ("self_hosted", "Self-hosted runner time", "sandboxes", "seconds"),
81 ("builds", "Builds", "deployments", "entries"),
82 ("requests", "Requests & CPU", "deployments", "entries"),
83 ("domains", "Custom domains", "deployments", "entries"),
84 ("git", "Git operations", "git_storage", "operations"),
85 ("storage", "Private storage", "git_storage", "bytes"),
86 ("cache", "Actions cache", "git_storage", "entries"),
87 ("packages", "Package storage", "packages", "entries"),
88 ("security", "Security scans", "security", "entries"),
89 ("context", "Search embeddings", "search", "entries"),
90 ("telemetry", "Observability data", "observability", "entries"),
91 // Kept apart so nothing is ever lost: a line whose meter is unknown,
92 // and agents' and models' usage from before they were retired.
93 ("other", "Other", OTHER, "entries"),
94];
95
96/// The product of the Other meter: not one of `PRODUCTS`, listed last and
97/// only when it has something.
98pub(crate) const OTHER: &str = "other";
99
100/// The meter month-end usage noted so far (`pending_usage.source`) is on.
101pub(crate) fn meter_of_pending(source: &str) -> &'static str {
102 match source {
103 "deployments" => "requests",
104 "domains" => "domains",
105 "storage" => "storage",
106 "git" => "git",
107 "cache" => "cache",
108 "package_storage" => "packages",
109 "security" => "security",
110 "context" => "context",
111 "telemetry" => "telemetry",
112 _ => "other",
113 }
114}
115
116/// Every day from `from` to `until`, both included, or why not.
117pub(crate) fn days_between(from: &str, until: &str) -> std::result::Result<Vec<String>, &'static str> {
118 let (Some(start), Some(end)) = (parse_rfc3339(&format!("{from}T00:00:00Z")), parse_rfc3339(&format!("{until}T00:00:00Z"))) else {
119 return Err("Give the range as days, YYYY-MM-DD.");
120 };
121 if end < start {
122 return Err("The range ends before it starts.");
123 }
124 if (end - start) / DAY_MS + 1 > MAX_DAYS {
125 return Err("Ask for at most 400 days at a time.");
126 }
127 Ok((0..=(end - start) / DAY_MS).map(|n| rfc3339(start + n * DAY_MS)[..10].to_owned()).collect())
128}
129
130/// One group of ledger lines: a day, a meter and a project.
131#[derive(Clone, Debug, Default, Deserialize, PartialEq)]
132pub(crate) struct Cell {
133 pub day: String,
134 pub meter: String,
135 #[serde(default)]
136 pub project: String,
137 #[serde(default)]
138 pub price: Option<f64>,
139 #[serde(default)]
140 pub quantity: Option<f64>,
141 #[serde(default)]
142 pub entries: Option<f64>,
143 #[serde(default)]
144 pub discount: Option<f64>,
145 #[serde(default)]
146 pub covered: Option<f64>,
147 #[serde(default)]
148 pub cost: Option<f64>,
149}
150
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)]
155pub(crate) struct Pending {
156 pub meter: &'static str,
157 pub micros: i64,
158 pub covered: i64,
159 pub discount: i64,
160}
161
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
172/// Shapes the groups into the page: products in order, each with its
173/// meters, each meter with every day of the range and its projects. Only
174/// the products and projects asked for (all, when none are).
175pub(crate) fn shape(
176 days: &[String],
177 cells: &[Cell],
178 pending: &[Pending],
179 products: &[String],
180 projects: &[String],
181) -> (Vec<ProductUsage>, Vec<UsageDay>, UsageTotals, Vec<String>) {
182 let wanted_product = |product: &str| products.is_empty() || products.iter().any(|p| p == product);
183 let wanted_project = |project: &str| projects.is_empty() || projects.iter().any(|p| p == project);
184 let index: BTreeMap<&str, usize> = days.iter().enumerate().map(|(i, d)| (d.as_str(), i)).collect();
185 let all_meters = meters();
186 let product_of = |meter: &str| all_meters.iter().find(|m| m.0 == meter).map_or(OTHER, |m| m.2);
187 let mut all_projects = BTreeSet::new();
188 let mut lines: Vec<MeterLine> = all_meters
189 .iter()
190 .map(|(key, label, product, unit)| MeterLine {
191 key: key.clone(),
192 label: label.clone(),
193 product: (*product).to_owned(),
194 unit: (*unit).to_owned(),
195 quantity: 0.0,
196 micros: 0,
197 pending_micros: 0,
198 daily: vec![0; days.len()],
199 allowance: None,
200 by_project: vec![],
201 note: None,
202 })
203 .collect();
204 let mut by_day: BTreeMap<(String, String), i64> = BTreeMap::new();
205 let mut totals = UsageTotals::default();
206 let mut projects_of: BTreeMap<(String, String), (i64, f64)> = BTreeMap::new();
207 for cell in cells {
208 if !cell.project.is_empty() {
209 all_projects.insert(cell.project.clone());
210 }
211 let product = product_of(&cell.meter);
212 if !wanted_product(product) || !wanted_project(&cell.project) {
213 continue;
214 }
215 // A meter this page does not know is Other's: never dropped.
216 let meter = if all_meters.iter().any(|m| m.0 == cell.meter) { cell.meter.as_str() } else { "other" };
217 let Some(line) = lines.iter_mut().find(|l| l.key == meter) else { continue };
218 let price = cell.price.unwrap_or(0.0).round() as i64;
219 line.micros += price;
220 line.quantity += if line.unit == "entries" { cell.entries.unwrap_or(0.0) } else { cell.quantity.unwrap_or(0.0) };
221 if let Some(i) = index.get(cell.day.as_str()) {
222 line.daily[*i] += price;
223 }
224 *by_day.entry((cell.day.clone(), product.to_owned())).or_default() += price;
225 let part = projects_of.entry((meter.to_owned(), cell.project.clone())).or_default();
226 part.0 += price;
227 part.1 += if line.unit == "entries" { cell.entries.unwrap_or(0.0) } else { cell.quantity.unwrap_or(0.0) };
228 totals.price_micros += price;
229 totals.discount_micros += cell.discount.unwrap_or(0.0).round() as i64;
230 totals.included_micros += cell.covered.unwrap_or(0.0).round() as i64;
231 totals.cost_micros += cell.cost.unwrap_or(0.0).round() as i64;
232 }
233 // Pending usage is no one project's: only when all projects are shown.
234 if projects.is_empty() {
235 for p in pending {
236 let product = product_of(p.meter);
237 if !wanted_product(product) {
238 continue;
239 }
240 if let Some(line) = lines.iter_mut().find(|l| l.key == p.meter) {
241 line.micros += p.micros;
242 line.pending_micros += p.micros;
243 }
244 totals.price_micros += p.micros;
245 totals.pending_micros += p.micros;
246 totals.pending_charged_micros += p.micros - p.covered - p.discount;
247 totals.included_micros += p.covered;
248 totals.discount_micros += p.discount;
249 }
250 }
251 for ((meter, project), (micros, quantity)) in projects_of {
252 if let Some(line) = lines.iter_mut().find(|l| l.key == meter) {
253 line.by_project.push(ProjectUsage { project, micros, quantity });
254 }
255 }
256 for line in &mut lines {
257 line.by_project.sort_by(|a, b| b.micros.cmp(&a.micros).then(a.project.cmp(&b.project)));
258 }
259 // The products that still have meters, in order, then Other when it
260 // has something.
261 let other_used = lines.iter().any(|l| l.product == OTHER && (l.micros != 0 || l.quantity != 0.0));
262 let products_out = PRODUCTS
263 .iter()
264 .copied()
265 .filter(|(key, _)| all_meters.iter().any(|m| m.2 == *key))
266 .chain(other_used.then_some((OTHER, "Other")))
267 .filter(|(key, _)| wanted_product(key))
268 .map(|(key, label)| {
269 let meters: Vec<MeterLine> = lines.iter().filter(|l| l.product == key).cloned().collect();
270 ProductUsage {
271 key: key.to_owned(),
272 label: label.to_owned(),
273 micros: meters.iter().map(|m| m.micros).sum(),
274 meters,
275 }
276 })
277 .collect();
278 let days_out = by_day.into_iter().filter(|(_, micros)| *micros != 0).map(|((day, product), micros)| UsageDay { day, product, micros }).collect();
279 (products_out, days_out, totals, all_projects.into_iter().collect())
280}
281
282impl Billing {
283 /// `usage_report`: members only.
284 pub(crate) async fn usage_report(&self, a: UsageReportArgs) -> Result<Outcome<UsageReport>> {
285 let workspace = a.workspace.to_lowercase();
286 if !a.viewer.is_some_and(|viewer| viewer.is_member(&workspace)) {
287 return Ok(members_only());
288 }
289 let days = match days_between(&a.from, &a.until) {
290 Ok(days) => days,
291 Err(why) => return Ok(Outcome::fail(FailureCode::Invalid, why)),
292 };
293 let from = days[0].clone();
294 let until = days[days.len() - 1].clone();
295 // Lines entered on the last day count, whatever the hour.
296 let end = rfc3339(parse_rfc3339(&format!("{until}T00:00:00Z")).unwrap_or(0) + DAY_MS);
297 let measure = if self.free { "COALESCE(cost_micros, 0)" } else { PRICE_SQL };
298 let now = rfc3339(now_ms());
299 let month = now[..7].to_owned();
300 let this_month = reaches_this_month(&from, &until, &now[..10]);
301
302 let cells = async {
303 self.db
304 .prepare(format!(
305 "SELECT substr(created_at, 1, 10) AS day, {meter} AS meter, COALESCE(repo, '') AS project,
306 SUM({measure}) AS price, SUM(COALESCE(quantity, 0)) AS quantity, COUNT(*) AS entries,
307 SUM(COALESCE(discount_micros, 0)) AS discount,
308 SUM(COALESCE(credit_micros, 0) + COALESCE(trial_micros, 0) + COALESCE(oss_micros, 0) + COALESCE(given_micros, 0)) AS covered,
309 SUM(COALESCE(cost_micros, 0)) AS cost
310 FROM ledger WHERE workspace = ?1 AND kind = 'usage' AND created_at >= ?2 AND created_at < ?3
311 GROUP BY 1, 2, 3 LIMIT 20000",
312 meter = METER_KEY_SQL
313 ))
314 .bind(&[workspace.as_str().into(), from.as_str().into(), end.as_str().into()])?
315 .all()
316 .await?
317 .results::<Cell>()
318 };
319 let pending = async {
320 if !this_month {
321 return Ok(vec![]);
322 }
323 self.pending_this_month(&[workspace.as_str().into()], &month).await
324 };
325 let credits = self.credit_paid_between(&workspace, &from, &end);
326 let ((cells, pending, (credits_paid, credits)), account) =
327 try_join(try_join3(cells, pending, credits), self.account_of(&workspace)).await?;
328 let percent = account.terms.percent_off();
329 let plan = self.plan_kind_for(&workspace, &account).await?;
330 // What is metered so far is entered on the account's terms when the
331 // month closes: its discount, and what g1t covers, come off it now,
332 // and the plan's included usage still left pays first, as the
333 // limit counts it (`limits::limit_with`).
334 let mut splits: Vec<_> = pending.iter().map(|p| pending_split(p.cost(), p.charge(), self.margin_percent, percent)).collect();
335 if this_month && plan != PlanKind::Free && !splits.is_empty() {
336 let used = self.allowance_used("plan_credit", &workspace, &month).await?;
337 cover_with_included(&mut splits, crate::credits::left(self.plans.plan_included_micros, used));
338 }
339 let pending: Vec<Pending> = pending
340 .into_iter()
341 .zip(splits)
342 .map(|(p, split)| Pending { meter: meter_of_pending(&p.source), micros: split.price, covered: split.covered, discount: split.discount })
343 .filter(|p| p.micros > 0)
344 .collect();
345 let (mut products, days_out, mut totals, all_projects) = shape(&days, &cells, &pending, &a.products, &a.projects);
346 let filtered = !a.products.is_empty() || !a.projects.is_empty();
347 // Credit is the workspace's, not a product's or a project's: only
348 // without a filter is it taken off.
349 totals.credits_micros = if filtered { 0 } else { credits_paid.max(0) };
350 totals.charged_micros = charged(&totals);
351 // Allowances, for the range that includes this month.
352 let mut included = None;
353 let mut trial = None;
354 if this_month {
355 let (used, stored, operations) = futures_util::future::try_join3(
356 async {
357 if plan == PlanKind::Paid { self.allowance_used("plan_credit", &workspace, &month).await } else { Ok(0) }
358 },
359 self.private_storage(&workspace),
360 self.git_operations_this_month(&workspace),
361 )
362 .await?;
363 if plan == PlanKind::Paid {
364 included = Some(Allowance { used: used as f64, of: self.plans.plan_included_micros as f64, unit: "micros".to_owned() });
365 }
366 for product in &mut products {
367 for meter in &mut product.meters {
368 match meter.key.as_str() {
369 "storage" => {
370 meter.quantity = stored as f64;
371 meter.allowance = Some(Allowance { used: stored as f64, of: self.plans.free_storage_bytes as f64, unit: "bytes".to_owned() });
372 }
373 "git" => {
374 meter.quantity = operations as f64;
375 meter.allowance = Some(Allowance { used: operations as f64, of: self.plans.git_included as f64, unit: "operations".to_owned() });
376 }
377 _ => {}
378 }
379 }
380 }
381 if plan == PlanKind::Free
382 && let Some(grant) = self.grant_of(&workspace).await?
383 {
384 trial = Some(crate::credits::left(grant.granted_micros, grant.used_micros));
385 }
386 }
387 // AI credit bought before g1t stopped selling it: still the
388 // workspace's, and kept apart from credit for everything.
389 let ai: i64 = credits.grants.iter().filter(|g| g.scope == "models").map(|g| g.left_micros).sum();
390 Ok(Outcome::Ok(UsageReport {
391 from,
392 until,
393 totals,
394 days: days_out,
395 products,
396 projects: all_projects,
397 included,
398 discount_percent: (percent > 0).then_some(percent),
399 ai_credit_micros: ai,
400 credit_micros: credits.left_micros - ai,
401 trial_micros: trial,
402 plan,
403 free: self.free,
404 }))
405 }
406}
407
408#[cfg(test)]
409mod tests {
410 use super::*;
411
412 fn cell(day: &str, meter: &str, project: &str, price: f64) -> Cell {
413 Cell { day: day.into(), meter: meter.into(), project: project.into(), price: Some(price), entries: Some(1.0), ..Cell::default() }
414 }
415
416 #[test]
417 fn a_range_is_every_day_in_it() {
418 let days = days_between("2026-10-01", "2026-10-03").unwrap();
419 assert_eq!(days, ["2026-10-01", "2026-10-02", "2026-10-03"]);
420 assert_eq!(days_between("2026-10-01", "2026-10-01").unwrap().len(), 1);
421 assert!(days_between("2026-10-03", "2026-10-01").is_err());
422 assert!(days_between("2025-01-01", "2026-10-01").is_err());
423 assert!(days_between("Oct 1", "2026-10-01").is_err());
424 }
425
426 #[test]
427 fn usage_is_shaped_into_products_meters_days_and_projects() {
428 let days = days_between("2026-10-01", "2026-10-03").unwrap();
429 let mut sandbox = cell("2026-10-02", "sandbox", "acme/web", 120_000.0);
430 sandbox.quantity = Some(600.0);
431 let cells = vec![
432 cell("2026-10-01", "requests", "acme/web", 2_000_000.0),
433 cell("2026-10-03", "requests", "acme/api", 1_000_000.0),
434 sandbox,
435 Cell { covered: Some(1_000_000.0), discount: Some(0.0), ..cell("2026-10-03", "storage", "", 30_000.0) },
436 ];
437 let pending = vec![Pending { meter: "git", micros: 40_000, ..Pending::default() }];
438 let (products, by_day, totals, projects) = shape(&days, &cells, &pending, &[], &[]);
439 // Every product family with meters, in order; no Other without usage on it.
440 assert_eq!(products.iter().map(|p| p.key.as_str()).collect::<Vec<_>>(), ["sandboxes", "deployments", "git_storage", "packages", "security", "search", "observability"]);
441 let deployments = &products[1];
442 assert_eq!(deployments.micros, 3_000_000);
443 let requests = deployments.meters.iter().find(|m| m.key == "requests").unwrap();
444 assert_eq!(requests.daily, [2_000_000, 0, 1_000_000]);
445 assert_eq!(requests.by_project[0].project, "acme/web");
446 let sandbox = products[0].meters.iter().find(|m| m.key == "sandbox").unwrap();
447 assert_eq!((sandbox.micros, sandbox.quantity), (120_000, 600.0));
448 // Pending usage counts in the totals and its meter, not on a day.
449 let git = products[2].meters.iter().find(|m| m.key == "git").unwrap();
450 assert_eq!((git.micros, git.pending_micros), (40_000, 40_000));
451 assert_eq!(totals.price_micros, 3_000_000 + 120_000 + 30_000 + 40_000);
452 assert_eq!(totals.pending_micros, 40_000);
453 assert_eq!(totals.included_micros, 1_000_000);
454 assert_eq!(by_day.iter().filter(|d| d.product == "deployments").map(|d| d.micros).sum::<i64>(), 3_000_000);
455 assert_eq!(projects, ["acme/api", "acme/web"]);
456 }
457
458 #[test]
459 fn runner_minutes_are_by_machine_beside_older_sandbox_time() {
460 let days = days_between("2026-10-01", "2026-10-01").unwrap();
461 let minutes = |meter: &str, quantity: f64, price: f64| Cell { quantity: Some(quantity), ..cell("2026-10-01", meter, "acme/web", price) };
462 let cells = vec![
463 minutes("runner_2-core", 12.0, 48_000.0),
464 minutes("runner_4-core", 3.0, 21_000.0),
465 // A line from before minutes: seconds of sandbox time.
466 minutes("sandbox", 90.0, 1_000.0),
467 // A machine since taken out of the catalogue: kept, under Other.
468 minutes("runner_1-core", 5.0, 5_000.0),
469 ];
470 let (products, _, totals, _) = shape(&days, &cells, &[], &[], &[]);
471 let sandboxes = &products[0];
472 let line = |key: &str| sandboxes.meters.iter().find(|m| m.key == key).unwrap();
473 let two = line("runner_2-core");
474 assert_eq!((two.label.as_str(), two.unit.as_str(), two.quantity, two.micros), ("Runner minutes, 2-core (2 vCPU, 8 GiB, 16 GB)", "minutes", 12.0, 48_000));
475 assert_eq!((line("runner_4-core").quantity, line("runner_4-core").micros), (3.0, 21_000));
476 assert_eq!((line("sandbox").unit.as_str(), line("sandbox").quantity), ("seconds", 90.0));
477 assert_eq!(products.last().unwrap().micros, 5_000);
478 assert_eq!(totals.price_micros, 75_000);
479 assert!(METER_KEY_SQL.contains("'runner_' || machine"));
480 }
481
482 #[test]
483 fn usage_from_retired_or_unknown_meters_is_other_and_never_lost() {
484 let days = days_between("2026-10-01", "2026-10-02").unwrap();
485 // Lines from when g1t ran agents, as the ledger still has them, and
486 // a meter nothing knows.
487 let cells = vec![
488 cell("2026-10-01", "other", "acme/web", 2_000_000.0),
489 cell("2026-10-01", "agent_rate", "acme/web", 500_000.0),
490 cell("2026-10-02", "gateway", "", 70_000.0),
491 cell("2026-10-02", "sandbox", "acme/web", 10_000.0),
492 ];
493 let (products, by_day, totals, _) = shape(&days, &cells, &[], &[], &[]);
494 let other = products.last().unwrap();
495 assert_eq!((other.key.as_str(), other.label.as_str(), other.micros), ("other", "Other", 2_570_000));
496 assert_eq!(other.meters.len(), 1);
497 assert_eq!(other.meters[0].daily, [2_500_000, 70_000]);
498 // Everything charged is in the totals and on its day.
499 assert_eq!(totals.price_micros, 2_580_000);
500 assert_eq!(products.iter().map(|p| p.micros).sum::<i64>(), totals.price_micros);
501 assert_eq!(by_day.iter().map(|d| d.micros).sum::<i64>(), totals.price_micros);
502 // Asked for by name, Other is shown alone.
503 let (only, _, totals, _) = shape(&days, &cells, &[], &["other".into()], &[]);
504 assert_eq!(only.len(), 1);
505 assert_eq!(totals.price_micros, 2_570_000);
506 }
507
508 #[test]
509 fn filters_keep_only_the_products_and_projects_asked_for() {
510 let days = days_between("2026-10-01", "2026-10-01").unwrap();
511 let cells = vec![cell("2026-10-01", "sandbox", "acme/web", 100.0), cell("2026-10-01", "sandbox", "acme/api", 50.0), cell("2026-10-01", "requests", "acme/web", 10.0)];
512 let pending = vec![Pending { meter: "git", micros: 7, ..Pending::default() }];
513 let (products, _, totals, projects) = shape(&days, &cells, &pending, &["sandboxes".into()], &["acme/web".into()]);
514 assert_eq!(products.len(), 1);
515 assert_eq!(totals.price_micros, 100);
516 // Every project stays listed, for the filter itself.
517 assert_eq!(projects, ["acme/api", "acme/web"]);
518 }
519
520 #[test]
521 fn pending_usage_counts_at_price_with_what_is_covered_and_discounted() {
522 let days = days_between("2026-10-01", "2026-10-02").unwrap();
523 // Storage on a 100% discount, and scans g1t covers on a free workspace.
524 let pending = [
525 Pending { meter: "storage", micros: 9_000, covered: 0, discount: 9_000 },
526 Pending { meter: "security", micros: 2_400, covered: 2_400, discount: 0 },
527 ];
528 let (_, _, totals, _) = shape(&days, &[], &pending, &[], &[]);
529 assert_eq!(totals.price_micros, 11_400);
530 assert_eq!(totals.pending_micros, 11_400);
531 assert_eq!(totals.pending_charged_micros, 11_400 - totals.included_micros - totals.discount_micros);
532 assert_eq!(totals.included_micros, 2_400);
533 assert_eq!(totals.discount_micros, 9_000);
534 assert_eq!(charged(&totals), 0);
535 // With a project chosen, pending usage (no project's) is left out.
536 let (_, _, filtered, _) = shape(&days, &[], &pending, &[], &["acme/api".to_owned()]);
537 assert_eq!(filtered.pending_micros, 0);
538 }
539
540 #[test]
541 fn pending_usage_is_on_its_meter() {
542 assert_eq!(meter_of_pending("package_storage"), "packages");
543 assert_eq!(meter_of_pending("nonsense"), "other");
544 }
545
546 #[test]
547 fn pending_usage_counts_in_any_range_that_reaches_into_this_month() {
548 // The first of the month to today, as every page asks: counted.
549 assert!(reaches_this_month("2026-10-01", "2026-10-10", "2026-10-10"));
550 // To month end, or only the last week: counted too.
551 assert!(reaches_this_month("2026-10-01", "2026-10-31", "2026-10-10"));
552 assert!(reaches_this_month("2026-10-04", "2026-10-10", "2026-10-10"));
553 // Last month, or a month that has not started: not this month's.
554 assert!(!reaches_this_month("2026-09-01", "2026-09-30", "2026-10-10"));
555 assert!(!reaches_this_month("2026-11-01", "2026-11-30", "2026-10-10"));
556 // The month has turned on billing's clock: September's range no longer has it.
557 assert!(!reaches_this_month("2026-09-01", "2026-09-30", "2026-10-01"));
558 }
559}