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