Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 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 | ||
| 13 | use std::collections::{BTreeMap, BTreeSet}; | |
| 14 | ||
| 15 | use futures_util::future::{try_join, try_join5}; | |
| 16 | use g1t_contracts::billing::{ | |
| Spend adds up on one ledger. Charged this month has one definition, price less discount, included usage and credit, shared by the plan card, the spend limit, Spend and the top bar; the limit had counted usage not yet closed at full price before the discount, so a comped workspace read as charged a cent. Every agent line on the ledger names the agent and who asked, repository runs by g1t included, so Spent is the sum of its products, Agents is the agent product, and by agent adds up to it; the billing API returns by_agent and by_person. The usage and billing guide says how spend is counted. | 17 | Allowance, FeatureUsage, MeterLine, ModelTokens, PlanKind, ProductUsage, ProjectUsage, UsageDay, UsageReport, UsageReportArgs, UsageShare, |
| 18 | UsageTotals, PRODUCTS, | |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 19 | }; |
| 20 | use g1t_contracts::time::{parse_rfc3339, rfc3339}; | |
| 21 | use g1t_contracts::{FailureCode, Outcome}; | |
| 22 | use g1t_kit::now_ms; | |
| 23 | use serde::Deserialize; | |
| 24 | use worker::Result; | |
| 25 | ||
| Spend adds up on one ledger. Charged this month has one definition, price less discount, included usage and credit, shared by the plan card, the spend limit, Spend and the top bar; the limit had counted usage not yet closed at full price before the discount, so a comped workspace read as charged a cent. Every agent line on the ledger names the agent and who asked, repository runs by g1t included, so Spent is the sum of its products, Agents is the agent product, and by agent adds up to it; the billing API returns by_agent and by_person. The usage and billing guide says how spend is counted. | 26 | use crate::charged::{charged, pending_split}; |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 27 | use crate::statement::{PRICE_SQL, RUN_SQL}; |
| 28 | use crate::{Billing, members_only}; | |
| 29 | ||
| Spend adds up on one ledger. Charged this month has one definition, price less discount, included usage and credit, shared by the plan card, the spend limit, Spend and the top bar; the limit had counted usage not yet closed at full price before the discount, so a comped workspace read as charged a cent. Every agent line on the ledger names the agent and who asked, repository runs by g1t included, so Spent is the sum of its products, Agents is the agent product, and by agent adds up to it; the billing API returns by_agent and by_person. The usage and billing guide says how spend is counted. | 30 | /// The meters that make up the agent product, as `METER_KEY_SQL` names |
| 31 | /// them: what agents cost, attributed to the agent and the person asking. | |
| 32 | pub(crate) const AGENT_METERS_SQL: &str = "('agent_models', 'agent_rate', 'agent_rate_own', 'agent_sandbox')"; | |
| 33 | ||
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 34 | /// The longest range the page reads at once. |
| 35 | pub(crate) const MAX_DAYS: u64 = 400; | |
| 36 | const DAY_MS: u64 = 86_400_000; | |
| 37 | ||
| 38 | /// Which meter a ledger line is on. One SQL expression, so every query | |
| 39 | /// agrees with `METERS`. | |
| 40 | pub(crate) const METER_KEY_SQL: &str = "CASE | |
| 41 | WHEN task = 'sandbox' AND compute = 'agent' THEN 'agent_sandbox' | |
| 42 | WHEN task = 'sandbox' THEN 'sandbox' | |
| 43 | WHEN task = 'self_hosted' THEN 'self_hosted' | |
| 44 | WHEN task = 'deployments' AND reference LIKE 'deploy/%' THEN 'builds' | |
| 45 | WHEN task = 'deployments' THEN 'requests' | |
| 46 | WHEN task = 'domains' THEN 'domains' | |
| 47 | WHEN task = 'git' THEN 'git' | |
| 48 | WHEN task = 'storage' THEN 'storage' | |
| 49 | WHEN task = 'cache' THEN 'cache' | |
| 50 | WHEN task = 'package_storage' THEN 'packages' | |
| 51 | WHEN task = 'security' THEN 'security' | |
| 52 | WHEN task = 'context' THEN 'context' | |
| 53 | WHEN task = 'gateway' THEN 'gateway' | |
| Merge branch 'model-routing' | 54 | WHEN reference LIKE '%/agent-own%' THEN 'agent_rate_own' |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 55 | WHEN reference LIKE '%/agent%' THEN 'agent_rate' |
| 56 | ELSE 'agent_models' END"; | |
| 57 | ||
| 58 | /// Every meter: key, name, product family and the unit of its quantity. | |
| Merge branch 'model-routing' | 59 | pub(crate) const METERS: [(&str, &str, &str, &str); 17] = [ |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 60 | ("agent_models", "Model tokens", "agent", "tokens"), |
| 61 | ("agent_rate", "Agent rate", "agent", "tokens"), | |
| Merge branch 'model-routing' | 62 | ("agent_rate_own", "Agent rate, your own model key", "agent", "tokens"), |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 63 | ("agent_sandbox", "Agent sandbox time", "agent", "seconds"), |
| 64 | ("sandbox", "Sandbox time", "sandboxes", "seconds"), | |
| 65 | ("self_hosted", "Self-hosted runner time", "sandboxes", "seconds"), | |
| 66 | ("gateway", "AI Gateway models", "gateway", "requests"), | |
| 67 | ("builds", "Builds", "deployments", "entries"), | |
| 68 | ("requests", "Requests & CPU", "deployments", "entries"), | |
| 69 | ("domains", "Custom domains", "deployments", "entries"), | |
| 70 | ("git", "Git operations", "git_storage", "operations"), | |
| 71 | ("storage", "Private storage", "git_storage", "bytes"), | |
| 72 | ("cache", "Actions cache", "git_storage", "entries"), | |
| 73 | ("packages", "Package storage", "packages", "entries"), | |
| 74 | ("security", "Security scans", "security", "entries"), | |
| 75 | ("context", "Search embeddings", "search", "entries"), | |
| 76 | // Kept apart so nothing is ever lost: a line whose meter is unknown. | |
| 77 | ("other", "Other", "agent", "entries"), | |
| 78 | ]; | |
| 79 | ||
| 80 | /// The meter month-end usage noted so far (`pending_usage.source`) is on. | |
| 81 | pub(crate) fn meter_of_pending(source: &str) -> &'static str { | |
| 82 | match source { | |
| 83 | "deployments" => "requests", | |
| 84 | "domains" => "domains", | |
| 85 | "storage" => "storage", | |
| 86 | "git" => "git", | |
| 87 | "cache" => "cache", | |
| 88 | "package_storage" => "packages", | |
| 89 | "security" => "security", | |
| 90 | "context" => "context", | |
| 91 | _ => "other", | |
| 92 | } | |
| 93 | } | |
| 94 | ||
| 95 | /// What the agent was doing, by a run's task. | |
| 96 | pub(crate) fn feature_of(task: &str) -> (&'static str, &'static str) { | |
| 97 | match task { | |
| 98 | "review" => ("reviews", "Reviews"), | |
| 99 | "plan" => ("plans", "Plans"), | |
| 100 | "update" => ("catch_ups", "Catch-ups"), | |
| 101 | "check" | "mergecheck" | "queue" => ("checks", "Checks"), | |
| 102 | _ => ("runs", "Runs"), | |
| 103 | } | |
| 104 | } | |
| 105 | ||
| 106 | /// Every day from `from` to `until`, both included, or why not. | |
| 107 | pub(crate) fn days_between(from: &str, until: &str) -> std::result::Result<Vec<String>, &'static str> { | |
| 108 | let (Some(start), Some(end)) = (parse_rfc3339(&format!("{from}T00:00:00Z")), parse_rfc3339(&format!("{until}T00:00:00Z"))) else { | |
| 109 | return Err("Give the range as days, YYYY-MM-DD."); | |
| 110 | }; | |
| 111 | if end < start { | |
| 112 | return Err("The range ends before it starts."); | |
| 113 | } | |
| 114 | if (end - start) / DAY_MS + 1 > MAX_DAYS { | |
| 115 | return Err("Ask for at most 400 days at a time."); | |
| 116 | } | |
| 117 | Ok((0..=(end - start) / DAY_MS).map(|n| rfc3339(start + n * DAY_MS)[..10].to_owned()).collect()) | |
| 118 | } | |
| 119 | ||
| 120 | /// One group of ledger lines: a day, a meter and a project. | |
| 121 | #[derive(Clone, Debug, Default, Deserialize, PartialEq)] | |
| 122 | pub(crate) struct Cell { | |
| 123 | pub day: String, | |
| 124 | pub meter: String, | |
| 125 | #[serde(default)] | |
| 126 | pub project: String, | |
| 127 | #[serde(default)] | |
| 128 | pub price: Option<f64>, | |
| 129 | #[serde(default)] | |
| 130 | pub quantity: Option<f64>, | |
| 131 | #[serde(default)] | |
| 132 | pub entries: Option<f64>, | |
| 133 | #[serde(default)] | |
| 134 | pub discount: Option<f64>, | |
| 135 | #[serde(default)] | |
| 136 | pub covered: Option<f64>, | |
| 137 | #[serde(default)] | |
| 138 | pub cost: Option<f64>, | |
| 139 | } | |
| 140 | ||
| 141 | /// Tokens a day's runs used, by project. | |
| 142 | #[derive(Clone, Debug, Default, Deserialize, PartialEq)] | |
| 143 | pub(crate) struct TokenCell { | |
| 144 | pub day: String, | |
| 145 | #[serde(default)] | |
| 146 | pub project: String, | |
| 147 | #[serde(default)] | |
| 148 | pub tokens: Option<f64>, | |
| 149 | } | |
| 150 | ||
| Spend adds up on one ledger. Charged this month has one definition, price less discount, included usage and credit, shared by the plan card, the spend limit, Spend and the top bar; the limit had counted usage not yet closed at full price before the discount, so a comped workspace read as charged a cent. Every agent line on the ledger names the agent and who asked, repository runs by g1t included, so Spent is the sum of its products, Agents is the agent product, and by agent adds up to it; the billing API returns by_agent and by_person. The usage and billing guide says how spend is counted. | 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)] | |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 155 | pub(crate) struct Pending { |
| 156 | pub meter: &'static str, | |
| 157 | pub micros: i64, | |
| Spend adds up on one ledger. Charged this month has one definition, price less discount, included usage and credit, shared by the plan card, the spend limit, Spend and the top bar; the limit had counted usage not yet closed at full price before the discount, so a comped workspace read as charged a cent. Every agent line on the ledger names the agent and who asked, repository runs by g1t included, so Spent is the sum of its products, Agents is the agent product, and by agent adds up to it; the billing API returns by_agent and by_person. The usage and billing guide says how spend is counted. | 158 | pub covered: i64, |
| 159 | pub discount: i64, | |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 160 | } |
| 161 | ||
| Money is written one way. A single formatter turns millionths of a dollar into dollars, rounding half up on whole micros rather than on a float, so the same sum reads the same on every page: under a cent reads <$0.01 on a total and exactly nothing is $0.00, while the statement's lines, a session's receipt, the price book and an agent's effort costs carry up to four places where the fraction of a cent is the point; the six formatters that each rounded their own way are gone. This month is the calendar month in UTC from its first day to today everywhere, and billing counts the month's not-yet-closed usage in any range that reaches into the current month, so the top bar's pill, Spend, Home and Usage ask for the same days and get the same figure; Home now reads the usage report the others read instead of adding up statements. Usage's pending sentence says what of that usage the close will charge after the discount and included usage, which the billing API returns as pending_charged_micros. A reconciliation test holds the pill, Spend, Home and Usage to one number for one month. The usage and billing guide says how amounts are written and what this month means. | 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. | |
| 168 | pub(crate) fn reaches_this_month(from: &str, until: &str, today: &str) -> bool { | |
| 169 | from <= today && until >= &format!("{}-01", &today[..7])[..] | |
| 170 | } | |
| 171 | ||
| Spend adds up on one ledger. Charged this month has one definition, price less discount, included usage and credit, shared by the plan card, the spend limit, Spend and the top bar; the limit had counted usage not yet closed at full price before the discount, so a comped workspace read as charged a cent. Every agent line on the ledger names the agent and who asked, repository runs by g1t included, so Spent is the sum of its products, Agents is the agent product, and by agent adds up to it; the billing API returns by_agent and by_person. The usage and billing guide says how spend is counted. | 172 | /// One agent's and one asker's agent-product lines over the range. |
| 173 | #[derive(Clone, Debug, Default, Deserialize, PartialEq)] | |
| 174 | pub(crate) struct Attributed { | |
| 175 | #[serde(default)] | |
| 176 | pub agent: String, | |
| 177 | #[serde(default)] | |
| 178 | pub person: String, | |
| 179 | #[serde(default)] | |
| 180 | pub price: Option<f64>, | |
| 181 | #[serde(default)] | |
| 182 | pub entries: Option<f64>, | |
| 183 | } | |
| 184 | ||
| 185 | /// The agent product by agent and by person, most first, from its lines: | |
| 186 | /// each group's price under the agent's handle and the asker's username, | |
| 187 | /// and lines attributed to no one under an empty key. Both sum to the | |
| 188 | /// agent product's total. | |
| 189 | pub(crate) fn attribution(rows: &[Attributed]) -> (Vec<UsageShare>, Vec<UsageShare>) { | |
| 190 | fn fold(rows: &[Attributed], key_of: impl Fn(&Attributed) -> &str, label_of: impl Fn(&str) -> String) -> Vec<UsageShare> { | |
| 191 | let mut by: BTreeMap<&str, UsageShare> = BTreeMap::new(); | |
| 192 | for row in rows { | |
| 193 | let key = key_of(row); | |
| 194 | let slice = by.entry(key).or_insert_with(|| UsageShare { key: key.to_owned(), label: label_of(key), ..UsageShare::default() }); | |
| 195 | slice.micros += row.price.unwrap_or(0.0).round() as i64; | |
| 196 | slice.count += row.entries.unwrap_or(0.0).max(0.0) as u32; | |
| 197 | } | |
| 198 | let mut out: Vec<UsageShare> = by.into_values().filter(|s| s.micros != 0 || s.count > 0).collect(); | |
| 199 | out.sort_by(|a, b| b.micros.cmp(&a.micros).then(a.key.cmp(&b.key))); | |
| 200 | out | |
| 201 | } | |
| 202 | let agents = fold(rows, |r| r.agent.as_str(), |k| if k.is_empty() { "Not attributed to an agent".to_owned() } else { format!("@{k}") }); | |
| 203 | let people = fold(rows, |r| r.person.as_str(), |k| if k.is_empty() { "No one asked".to_owned() } else { format!("@{k}") }); | |
| 204 | (agents, people) | |
| 205 | } | |
| 206 | ||
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 207 | /// Shapes the groups into the page: products in order, each with its |
| 208 | /// meters, each meter with every day of the range and its projects. Only | |
| 209 | /// the products and projects asked for (all, when none are). | |
| 210 | pub(crate) fn shape( | |
| 211 | days: &[String], | |
| 212 | cells: &[Cell], | |
| 213 | tokens: &[TokenCell], | |
| 214 | pending: &[Pending], | |
| 215 | products: &[String], | |
| 216 | projects: &[String], | |
| 217 | ) -> (Vec<ProductUsage>, Vec<UsageDay>, UsageTotals, Vec<String>) { | |
| 218 | let wanted_product = |product: &str| products.is_empty() || products.iter().any(|p| p == product); | |
| 219 | let wanted_project = |project: &str| projects.is_empty() || projects.iter().any(|p| p == project); | |
| 220 | let index: BTreeMap<&str, usize> = days.iter().enumerate().map(|(i, d)| (d.as_str(), i)).collect(); | |
| 221 | let product_of = |meter: &str| METERS.iter().find(|m| m.0 == meter).map_or("agent", |m| m.2); | |
| 222 | let mut all_projects = BTreeSet::new(); | |
| 223 | let mut lines: Vec<MeterLine> = METERS | |
| 224 | .iter() | |
| 225 | .map(|(key, label, product, unit)| MeterLine { | |
| 226 | key: (*key).to_owned(), | |
| 227 | label: (*label).to_owned(), | |
| 228 | product: (*product).to_owned(), | |
| 229 | unit: (*unit).to_owned(), | |
| 230 | quantity: 0.0, | |
| 231 | micros: 0, | |
| 232 | pending_micros: 0, | |
| 233 | daily: vec![0; days.len()], | |
| 234 | allowance: None, | |
| 235 | by_project: vec![], | |
| Merge branch 'model-routing' | 236 | note: None, |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 237 | }) |
| 238 | .collect(); | |
| 239 | let mut by_day: BTreeMap<(String, String), i64> = BTreeMap::new(); | |
| 240 | let mut totals = UsageTotals::default(); | |
| 241 | let mut projects_of: BTreeMap<(String, String), (i64, f64)> = BTreeMap::new(); | |
| 242 | for cell in cells { | |
| 243 | if !cell.project.is_empty() { | |
| 244 | all_projects.insert(cell.project.clone()); | |
| 245 | } | |
| 246 | let product = product_of(&cell.meter); | |
| 247 | if !wanted_product(product) || !wanted_project(&cell.project) { | |
| 248 | continue; | |
| 249 | } | |
| 250 | let Some(line) = lines.iter_mut().find(|l| l.key == cell.meter) else { continue }; | |
| 251 | let price = cell.price.unwrap_or(0.0).round() as i64; | |
| 252 | line.micros += price; | |
| 253 | // Tokens for the model meter come from the token counts below. | |
| 254 | if line.key != "agent_models" { | |
| 255 | line.quantity += if line.unit == "entries" { cell.entries.unwrap_or(0.0) } else { cell.quantity.unwrap_or(0.0) }; | |
| 256 | } | |
| 257 | if let Some(i) = index.get(cell.day.as_str()) { | |
| 258 | line.daily[*i] += price; | |
| 259 | } | |
| 260 | *by_day.entry((cell.day.clone(), product.to_owned())).or_default() += price; | |
| 261 | let part = projects_of.entry((cell.meter.clone(), cell.project.clone())).or_default(); | |
| 262 | part.0 += price; | |
| 263 | part.1 += if line.unit == "entries" { cell.entries.unwrap_or(0.0) } else { cell.quantity.unwrap_or(0.0) }; | |
| 264 | totals.price_micros += price; | |
| 265 | totals.discount_micros += cell.discount.unwrap_or(0.0).round() as i64; | |
| 266 | totals.included_micros += cell.covered.unwrap_or(0.0).round() as i64; | |
| 267 | totals.cost_micros += cell.cost.unwrap_or(0.0).round() as i64; | |
| 268 | } | |
| 269 | if wanted_product("agent") { | |
| 270 | for cell in tokens { | |
| 271 | if !wanted_project(&cell.project) { | |
| 272 | continue; | |
| 273 | } | |
| 274 | let tokens = cell.tokens.unwrap_or(0.0); | |
| 275 | if let Some(line) = lines.iter_mut().find(|l| l.key == "agent_models") { | |
| 276 | line.quantity += tokens; | |
| 277 | } | |
| 278 | projects_of.entry(("agent_models".to_owned(), cell.project.clone())).or_default().1 += tokens; | |
| 279 | } | |
| 280 | } | |
| 281 | // Pending usage is no one project's: only when all projects are shown. | |
| 282 | if projects.is_empty() { | |
| 283 | for p in pending { | |
| 284 | let product = product_of(p.meter); | |
| 285 | if !wanted_product(product) { | |
| 286 | continue; | |
| 287 | } | |
| 288 | if let Some(line) = lines.iter_mut().find(|l| l.key == p.meter) { | |
| 289 | line.micros += p.micros; | |
| 290 | line.pending_micros += p.micros; | |
| 291 | } | |
| 292 | totals.price_micros += p.micros; | |
| 293 | totals.pending_micros += p.micros; | |
| Money is written one way. A single formatter turns millionths of a dollar into dollars, rounding half up on whole micros rather than on a float, so the same sum reads the same on every page: under a cent reads <$0.01 on a total and exactly nothing is $0.00, while the statement's lines, a session's receipt, the price book and an agent's effort costs carry up to four places where the fraction of a cent is the point; the six formatters that each rounded their own way are gone. This month is the calendar month in UTC from its first day to today everywhere, and billing counts the month's not-yet-closed usage in any range that reaches into the current month, so the top bar's pill, Spend, Home and Usage ask for the same days and get the same figure; Home now reads the usage report the others read instead of adding up statements. Usage's pending sentence says what of that usage the close will charge after the discount and included usage, which the billing API returns as pending_charged_micros. A reconciliation test holds the pill, Spend, Home and Usage to one number for one month. The usage and billing guide says how amounts are written and what this month means. | 294 | totals.pending_charged_micros += p.micros - p.covered - p.discount; |
| Spend adds up on one ledger. Charged this month has one definition, price less discount, included usage and credit, shared by the plan card, the spend limit, Spend and the top bar; the limit had counted usage not yet closed at full price before the discount, so a comped workspace read as charged a cent. Every agent line on the ledger names the agent and who asked, repository runs by g1t included, so Spent is the sum of its products, Agents is the agent product, and by agent adds up to it; the billing API returns by_agent and by_person. The usage and billing guide says how spend is counted. | 295 | totals.included_micros += p.covered; |
| 296 | totals.discount_micros += p.discount; | |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 297 | } |
| 298 | } | |
| 299 | for ((meter, project), (micros, quantity)) in projects_of { | |
| 300 | if let Some(line) = lines.iter_mut().find(|l| l.key == meter) { | |
| 301 | line.by_project.push(ProjectUsage { project, micros, quantity }); | |
| 302 | } | |
| 303 | } | |
| 304 | for line in &mut lines { | |
| 305 | line.by_project.sort_by(|a, b| b.micros.cmp(&a.micros).then(a.project.cmp(&b.project))); | |
| 306 | } | |
| 307 | let products_out = PRODUCTS | |
| 308 | .iter() | |
| 309 | .filter(|(key, _)| wanted_product(key)) | |
| 310 | .map(|(key, label)| { | |
| 311 | let meters: Vec<MeterLine> = lines.iter().filter(|l| l.product == *key && (l.key != "other" || l.micros != 0)).cloned().collect(); | |
| 312 | ProductUsage { | |
| 313 | key: (*key).to_owned(), | |
| 314 | label: (*label).to_owned(), | |
| 315 | micros: meters.iter().map(|m| m.micros).sum(), | |
| 316 | meters, | |
| 317 | features: vec![], | |
| 318 | } | |
| 319 | }) | |
| 320 | .collect(); | |
| 321 | let days_out = by_day.into_iter().filter(|(_, micros)| *micros != 0).map(|((day, product), micros)| UsageDay { day, product, micros }).collect(); | |
| 322 | (products_out, days_out, totals, all_projects.into_iter().collect()) | |
| 323 | } | |
| 324 | ||
| 325 | impl Billing { | |
| 326 | /// `usage_report`: members only. | |
| 327 | pub(crate) async fn usage_report(&self, a: UsageReportArgs) -> Result<Outcome<UsageReport>> { | |
| 328 | let workspace = a.workspace.to_lowercase(); | |
| 329 | if !a.viewer.is_some_and(|viewer| viewer.is_member(&workspace)) { | |
| 330 | return Ok(members_only()); | |
| 331 | } | |
| 332 | let days = match days_between(&a.from, &a.until) { | |
| 333 | Ok(days) => days, | |
| 334 | Err(why) => return Ok(Outcome::fail(FailureCode::Invalid, why)), | |
| 335 | }; | |
| 336 | let from = days[0].clone(); | |
| 337 | let until = days[days.len() - 1].clone(); | |
| 338 | // Lines entered on the last day count, whatever the hour. | |
| 339 | let end = rfc3339(parse_rfc3339(&format!("{until}T00:00:00Z")).unwrap_or(0) + DAY_MS); | |
| 340 | let measure = if self.free { "COALESCE(cost_micros, 0)" } else { PRICE_SQL }; | |
| 341 | let now = rfc3339(now_ms()); | |
| 342 | let month = now[..7].to_owned(); | |
| Money is written one way. A single formatter turns millionths of a dollar into dollars, rounding half up on whole micros rather than on a float, so the same sum reads the same on every page: under a cent reads <$0.01 on a total and exactly nothing is $0.00, while the statement's lines, a session's receipt, the price book and an agent's effort costs carry up to four places where the fraction of a cent is the point; the six formatters that each rounded their own way are gone. This month is the calendar month in UTC from its first day to today everywhere, and billing counts the month's not-yet-closed usage in any range that reaches into the current month, so the top bar's pill, Spend, Home and Usage ask for the same days and get the same figure; Home now reads the usage report the others read instead of adding up statements. Usage's pending sentence says what of that usage the close will charge after the discount and included usage, which the billing API returns as pending_charged_micros. A reconciliation test holds the pill, Spend, Home and Usage to one number for one month. The usage and billing guide says how amounts are written and what this month means. | 343 | let this_month = reaches_this_month(&from, &until, &now[..10]); |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 344 | |
| 345 | let cells = async { | |
| 346 | self.db | |
| 347 | .prepare(format!( | |
| 348 | "SELECT substr(created_at, 1, 10) AS day, {meter} AS meter, COALESCE(repo, '') AS project, | |
| 349 | SUM({measure}) AS price, SUM(COALESCE(quantity, 0)) AS quantity, COUNT(*) AS entries, | |
| 350 | SUM(COALESCE(discount_micros, 0)) AS discount, | |
| 351 | SUM(COALESCE(credit_micros, 0) + COALESCE(trial_micros, 0) + COALESCE(oss_micros, 0) + COALESCE(given_micros, 0)) AS covered, | |
| 352 | SUM(COALESCE(cost_micros, 0)) AS cost | |
| 353 | FROM ledger WHERE workspace = ?1 AND kind = 'usage' AND created_at >= ?2 AND created_at < ?3 | |
| 354 | GROUP BY 1, 2, 3 LIMIT 20000", | |
| 355 | meter = METER_KEY_SQL | |
| 356 | )) | |
| 357 | .bind(&[workspace.as_str().into(), from.as_str().into(), end.as_str().into()])? | |
| 358 | .all() | |
| 359 | .await? | |
| 360 | .results::<Cell>() | |
| 361 | }; | |
| 362 | let tokens = async { | |
| 363 | self.db | |
| 364 | .prepare( | |
| 365 | "SELECT t.day AS day, COALESCE(r.repo, '') AS project, | |
| 366 | SUM(t.input + t.output + t.cache_read + t.cache_write) AS tokens | |
| 367 | FROM token_usage t LEFT JOIN runs r ON r.session_id = t.session | |
| 368 | WHERE t.workspace = ?1 AND t.day >= ?2 AND t.day <= ?3 | |
| 369 | GROUP BY 1, 2 LIMIT 20000", | |
| 370 | ) | |
| 371 | .bind(&[workspace.as_str().into(), from.as_str().into(), until.as_str().into()])? | |
| 372 | .all() | |
| 373 | .await? | |
| 374 | .results::<TokenCell>() | |
| 375 | }; | |
| 376 | #[derive(Deserialize)] | |
| 377 | struct Feature { | |
| 378 | task: Option<String>, | |
| 379 | price: Option<f64>, | |
| 380 | runs: Option<f64>, | |
| 381 | } | |
| 382 | let features = async { | |
| 383 | self.db | |
| 384 | .prepare(format!( | |
| 385 | "SELECT COALESCE(task, 'implement') AS task, SUM({measure}) AS price, SUM(CASE WHEN {RUN_SQL} THEN 1 ELSE 0 END) AS runs | |
| 386 | FROM ledger WHERE workspace = ?1 AND kind = 'usage' AND created_at >= ?2 AND created_at < ?3 | |
| Merge branch 'model-routing' | 387 | AND ({meter}) IN ('agent_models', 'agent_rate', 'agent_rate_own', 'agent_sandbox') |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 388 | GROUP BY 1", |
| 389 | meter = METER_KEY_SQL | |
| 390 | )) | |
| 391 | .bind(&[workspace.as_str().into(), from.as_str().into(), end.as_str().into()])? | |
| 392 | .all() | |
| 393 | .await? | |
| 394 | .results::<Feature>() | |
| 395 | }; | |
| 396 | let pending = async { | |
| 397 | if !this_month { | |
| 398 | return Ok(vec![]); | |
| 399 | } | |
| Spend adds up on one ledger. Charged this month has one definition, price less discount, included usage and credit, shared by the plan card, the spend limit, Spend and the top bar; the limit had counted usage not yet closed at full price before the discount, so a comped workspace read as charged a cent. Every agent line on the ledger names the agent and who asked, repository runs by g1t included, so Spent is the sum of its products, Agents is the agent product, and by agent adds up to it; the billing API returns by_agent and by_person. The usage and billing guide says how spend is counted. | 400 | self.pending_this_month(&[workspace.as_str().into()], &month).await |
| 401 | }; | |
| 402 | // The agent product by the agent that did the work and by who | |
| 403 | // asked, from the same lines: only when the agent product is shown. | |
| 404 | let agents_wanted = a.products.is_empty() || a.products.iter().any(|p| p == "agent"); | |
| 405 | let attributed = async { | |
| 406 | if !agents_wanted { | |
| 407 | return Ok(vec![]); | |
| 408 | } | |
| 409 | let mut binds: Vec<worker::wasm_bindgen::JsValue> = vec![workspace.as_str().into(), from.as_str().into(), end.as_str().into()]; | |
| 410 | let projects = if a.projects.is_empty() { | |
| 411 | String::new() | |
| 412 | } else { | |
| 413 | binds.extend(a.projects.iter().map(|p| worker::wasm_bindgen::JsValue::from(p.as_str()))); | |
| 414 | format!(" AND COALESCE(repo, '') IN ({})", vec!["?"; a.projects.len()].join(", ")) | |
| 415 | }; | |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 416 | self.db |
| Spend adds up on one ledger. Charged this month has one definition, price less discount, included usage and credit, shared by the plan card, the spend limit, Spend and the top bar; the limit had counted usage not yet closed at full price before the discount, so a comped workspace read as charged a cent. Every agent line on the ledger names the agent and who asked, repository runs by g1t included, so Spent is the sum of its products, Agents is the agent product, and by agent adds up to it; the billing API returns by_agent and by_person. The usage and billing guide says how spend is counted. | 417 | .prepare(format!( |
| 418 | "SELECT COALESCE(agent, '') AS agent, COALESCE(asked_by, '') AS person, SUM({measure}) AS price, COUNT(*) AS entries | |
| 419 | FROM ledger WHERE workspace = ?1 AND kind = 'usage' AND created_at >= ?2 AND created_at < ?3 | |
| 420 | AND ({meter}) IN {AGENT_METERS_SQL}{projects} | |
| 421 | GROUP BY 1, 2 LIMIT 5000", | |
| 422 | meter = METER_KEY_SQL | |
| 423 | )) | |
| 424 | .bind(&binds)? | |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 425 | .all() |
| 426 | .await? | |
| Spend adds up on one ledger. Charged this month has one definition, price less discount, included usage and credit, shared by the plan card, the spend limit, Spend and the top bar; the limit had counted usage not yet closed at full price before the discount, so a comped workspace read as charged a cent. Every agent line on the ledger names the agent and who asked, repository runs by g1t included, so Spent is the sum of its products, Agents is the agent product, and by agent adds up to it; the billing API returns by_agent and by_person. The usage and billing guide says how spend is counted. | 427 | .results::<Attributed>() |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 428 | }; |
| 429 | let credits = self.credit_paid_between(&workspace, &from, &end); | |
| Spend adds up on one ledger. Charged this month has one definition, price less discount, included usage and credit, shared by the plan card, the spend limit, Spend and the top bar; the limit had counted usage not yet closed at full price before the discount, so a comped workspace read as charged a cent. Every agent line on the ledger names the agent and who asked, repository runs by g1t included, so Spent is the sum of its products, Agents is the agent product, and by agent adds up to it; the billing API returns by_agent and by_person. The usage and billing guide says how spend is counted. | 430 | let ((cells, tokens, features, pending, (credits_paid, credits)), (attributed, account)) = |
| 431 | try_join(try_join5(cells, tokens, features, pending, credits), try_join(attributed, self.account_of(&workspace))).await?; | |
| 432 | let percent = account.terms.percent_off(); | |
| 433 | // What is metered so far is entered on the account's terms when the | |
| 434 | // month closes: its discount, and what g1t covers, come off it now. | |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 435 | let pending: Vec<Pending> = pending |
| 436 | .into_iter() | |
| Spend adds up on one ledger. Charged this month has one definition, price less discount, included usage and credit, shared by the plan card, the spend limit, Spend and the top bar; the limit had counted usage not yet closed at full price before the discount, so a comped workspace read as charged a cent. Every agent line on the ledger names the agent and who asked, repository runs by g1t included, so Spent is the sum of its products, Agents is the agent product, and by agent adds up to it; the billing API returns by_agent and by_person. The usage and billing guide says how spend is counted. | 437 | .map(|p| { |
| 438 | let split = pending_split(p.cost(), p.charge(), self.margin_percent, percent); | |
| 439 | Pending { meter: meter_of_pending(&p.source), micros: split.price, covered: split.covered, discount: split.discount } | |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 440 | }) |
| 441 | .filter(|p| p.micros > 0) | |
| 442 | .collect(); | |
| 443 | let (mut products, days_out, mut totals, all_projects) = shape(&days, &cells, &tokens, &pending, &a.products, &a.projects); | |
| Spend adds up on one ledger. Charged this month has one definition, price less discount, included usage and credit, shared by the plan card, the spend limit, Spend and the top bar; the limit had counted usage not yet closed at full price before the discount, so a comped workspace read as charged a cent. Every agent line on the ledger names the agent and who asked, repository runs by g1t included, so Spent is the sum of its products, Agents is the agent product, and by agent adds up to it; the billing API returns by_agent and by_person. The usage and billing guide says how spend is counted. | 444 | let (by_agent, by_person) = attribution(&attributed); |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 445 | let filtered = !a.products.is_empty() || !a.projects.is_empty(); |
| 446 | // Credit is the workspace's, not a product's or a project's: only | |
| 447 | // without a filter is it taken off. | |
| 448 | totals.credits_micros = if filtered { 0 } else { credits_paid.max(0) }; | |
| 449 | totals.charged_micros = charged(&totals); | |
| 450 | if let Some(agent) = products.iter_mut().find(|p| p.key == "agent") { | |
| 451 | let mut grouped: BTreeMap<&str, FeatureUsage> = BTreeMap::new(); | |
| 452 | for f in &features { | |
| 453 | let (key, label) = feature_of(f.task.as_deref().unwrap_or("implement")); | |
| 454 | let entry = grouped.entry(key).or_insert_with(|| FeatureUsage { key: key.to_owned(), label: label.to_owned(), micros: 0, count: 0 }); | |
| 455 | entry.micros += f.price.unwrap_or(0.0).round() as i64; | |
| 456 | entry.count += f.runs.unwrap_or(0.0) as u32; | |
| 457 | } | |
| 458 | agent.features = grouped.into_values().filter(|f| f.micros != 0 || f.count > 0).collect(); | |
| 459 | agent.features.sort_by_key(|f| std::cmp::Reverse(f.micros)); | |
| 460 | } | |
| 461 | let plan = self.plan_kind_for(&workspace, &account).await?; | |
| 462 | // Allowances, for the range that includes this month. | |
| 463 | let mut included = None; | |
| 464 | let mut trial = None; | |
| 465 | if this_month { | |
| 466 | let (used, stored, operations) = futures_util::future::try_join3( | |
| 467 | async { | |
| 468 | if plan == PlanKind::Paid { self.allowance_used("plan_credit", &workspace, &month).await } else { Ok(0) } | |
| 469 | }, | |
| 470 | self.private_storage(&workspace), | |
| 471 | self.git_operations_this_month(&workspace), | |
| 472 | ) | |
| 473 | .await?; | |
| 474 | if plan == PlanKind::Paid { | |
| 475 | included = Some(Allowance { used: used as f64, of: self.plans.plan_included_micros as f64, unit: "micros".to_owned() }); | |
| 476 | } | |
| 477 | for product in &mut products { | |
| 478 | for meter in &mut product.meters { | |
| 479 | match meter.key.as_str() { | |
| 480 | "storage" => { | |
| 481 | meter.quantity = stored as f64; | |
| 482 | meter.allowance = Some(Allowance { used: stored as f64, of: self.plans.free_storage_bytes as f64, unit: "bytes".to_owned() }); | |
| 483 | } | |
| 484 | "git" => { | |
| 485 | meter.quantity = operations as f64; | |
| 486 | meter.allowance = Some(Allowance { used: operations as f64, of: self.plans.git_included as f64, unit: "operations".to_owned() }); | |
| 487 | } | |
| 488 | _ => {} | |
| 489 | } | |
| 490 | } | |
| 491 | } | |
| 492 | if plan == PlanKind::Free | |
| 493 | && let Some(grant) = self.grant_of(&workspace).await? | |
| 494 | { | |
| 495 | trial = Some(crate::credits::left(grant.granted_micros, grant.used_micros)); | |
| 496 | } | |
| 497 | } | |
| Merge branch 'model-routing' | 498 | // The agent rate's tokens are weighted by kind: say how. |
| 499 | let weights = self.token_weights().await?; | |
| 500 | for product in &mut products { | |
| 501 | for meter in &mut product.meters { | |
| 502 | if meter.key == "agent_rate" || meter.key == "agent_rate_own" { | |
| 503 | meter.note = Some(agent_rate_note(&weights)); | |
| 504 | } | |
| 505 | } | |
| 506 | } | |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 507 | let ai: i64 = credits.grants.iter().filter(|g| g.scope == "models").map(|g| g.left_micros).sum(); |
| Merge branch 'model-routing' | 508 | let models = self.tokens_by_model(&workspace, &from, &until).await?; |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 509 | Ok(Outcome::Ok(UsageReport { |
| 510 | from, | |
| 511 | until, | |
| 512 | totals, | |
| 513 | days: days_out, | |
| 514 | products, | |
| 515 | projects: all_projects, | |
| Merge branch 'model-routing' | 516 | models, |
| Spend adds up on one ledger. Charged this month has one definition, price less discount, included usage and credit, shared by the plan card, the spend limit, Spend and the top bar; the limit had counted usage not yet closed at full price before the discount, so a comped workspace read as charged a cent. Every agent line on the ledger names the agent and who asked, repository runs by g1t included, so Spent is the sum of its products, Agents is the agent product, and by agent adds up to it; the billing API returns by_agent and by_person. The usage and billing guide says how spend is counted. | 517 | by_agent, |
| 518 | by_person, | |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 519 | included, |
| 520 | discount_percent: (percent > 0).then_some(percent), | |
| 521 | ai_credit_micros: ai, | |
| 522 | credit_micros: credits.left_micros - ai, | |
| 523 | trial_micros: trial, | |
| 524 | plan, | |
| 525 | free: self.free, | |
| 526 | })) | |
| 527 | } | |
| 528 | } | |
| 529 | ||
| Merge branch 'model-routing' | 530 | /// What the agent rate's meters say of their tokens. |
| 531 | pub(crate) fn agent_rate_note(weights: &crate::ai::TokenWeights) -> String { | |
| 532 | if weights.is_flat() { | |
| 533 | "Weighted tokens: every token counts once (input ×1, output ×1, cache reads ×1, cache writes ×1)".to_owned() | |
| 534 | } else { | |
| 535 | format!("Weighted tokens: {}", weights.describe()) | |
| 536 | } | |
| 537 | } | |
| 538 | ||
| 539 | impl Billing { | |
| 540 | /// Agent tokens by model over the days `from` to `until`, most first: | |
| 541 | /// what the model proxy counted, on g1t's models and the workspace's | |
| 542 | /// own provider alike. | |
| 543 | async fn tokens_by_model(&self, workspace: &str, from: &str, until: &str) -> Result<Vec<ModelTokens>> { | |
| 544 | #[derive(Deserialize)] | |
| 545 | struct Row { | |
| 546 | model: String, | |
| 547 | input: Option<f64>, | |
| 548 | output: Option<f64>, | |
| 549 | cache_read: Option<f64>, | |
| 550 | cache_write: Option<f64>, | |
| 551 | } | |
| 552 | let rows = self | |
| 553 | .db | |
| 554 | .prepare( | |
| 555 | "SELECT model, SUM(input) AS input, SUM(output) AS output, SUM(cache_read) AS cache_read, SUM(cache_write) AS cache_write | |
| 556 | FROM token_usage WHERE workspace = ?1 AND day >= ?2 AND day <= ?3 | |
| 557 | GROUP BY model ORDER BY SUM(input + output + cache_read + cache_write) DESC LIMIT 20", | |
| 558 | ) | |
| 559 | .bind(&[workspace.into(), from.into(), until.into()])? | |
| 560 | .all() | |
| 561 | .await? | |
| 562 | .results::<Row>()?; | |
| 563 | let n = |v: Option<f64>| v.unwrap_or(0.0).max(0.0) as u64; | |
| 564 | Ok(rows | |
| 565 | .into_iter() | |
| 566 | .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) }) | |
| 567 | .collect()) | |
| 568 | } | |
| 569 | } | |
| 570 | ||
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 571 | #[cfg(test)] |
| 572 | mod tests { | |
| 573 | use super::*; | |
| 574 | ||
| 575 | fn cell(day: &str, meter: &str, project: &str, price: f64) -> Cell { | |
| 576 | Cell { day: day.into(), meter: meter.into(), project: project.into(), price: Some(price), entries: Some(1.0), ..Cell::default() } | |
| 577 | } | |
| 578 | ||
| 579 | #[test] | |
| 580 | fn a_range_is_every_day_in_it() { | |
| 581 | let days = days_between("2026-10-01", "2026-10-03").unwrap(); | |
| 582 | assert_eq!(days, ["2026-10-01", "2026-10-02", "2026-10-03"]); | |
| 583 | assert_eq!(days_between("2026-10-01", "2026-10-01").unwrap().len(), 1); | |
| 584 | assert!(days_between("2026-10-03", "2026-10-01").is_err()); | |
| 585 | assert!(days_between("2025-01-01", "2026-10-01").is_err()); | |
| 586 | assert!(days_between("Oct 1", "2026-10-01").is_err()); | |
| 587 | } | |
| 588 | ||
| 589 | #[test] | |
| 590 | fn usage_is_shaped_into_products_meters_days_and_projects() { | |
| 591 | let days = days_between("2026-10-01", "2026-10-03").unwrap(); | |
| 592 | let mut sandbox = cell("2026-10-02", "sandbox", "acme/web", 120_000.0); | |
| 593 | sandbox.quantity = Some(600.0); | |
| 594 | let cells = vec![ | |
| 595 | cell("2026-10-01", "agent_models", "acme/web", 2_000_000.0), | |
| 596 | cell("2026-10-01", "agent_rate", "acme/web", 500_000.0), | |
| 597 | cell("2026-10-03", "agent_models", "acme/api", 1_000_000.0), | |
| 598 | sandbox, | |
| 599 | Cell { covered: Some(1_000_000.0), discount: Some(0.0), ..cell("2026-10-03", "storage", "", 30_000.0) }, | |
| 600 | ]; | |
| 601 | let tokens = vec![TokenCell { day: "2026-10-01".into(), project: "acme/web".into(), tokens: Some(2_000_000.0) }]; | |
| Spend adds up on one ledger. Charged this month has one definition, price less discount, included usage and credit, shared by the plan card, the spend limit, Spend and the top bar; the limit had counted usage not yet closed at full price before the discount, so a comped workspace read as charged a cent. Every agent line on the ledger names the agent and who asked, repository runs by g1t included, so Spent is the sum of its products, Agents is the agent product, and by agent adds up to it; the billing API returns by_agent and by_person. The usage and billing guide says how spend is counted. | 602 | let pending = vec![Pending { meter: "git", micros: 40_000, ..Pending::default() }]; |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 603 | let (products, by_day, totals, projects) = shape(&days, &cells, &tokens, &pending, &[], &[]); |
| 604 | // Every product family, in order. | |
| 605 | assert_eq!(products.iter().map(|p| p.key.as_str()).collect::<Vec<_>>(), ["agent", "sandboxes", "gateway", "deployments", "git_storage", "packages", "security", "search"]); | |
| 606 | let agent = &products[0]; | |
| 607 | assert_eq!(agent.micros, 3_500_000); | |
| 608 | let models = agent.meters.iter().find(|m| m.key == "agent_models").unwrap(); | |
| 609 | assert_eq!(models.daily, [2_000_000, 0, 1_000_000]); | |
| 610 | assert_eq!(models.quantity, 2_000_000.0); | |
| 611 | assert_eq!(models.by_project[0].project, "acme/web"); | |
| 612 | let sandbox = products[1].meters.iter().find(|m| m.key == "sandbox").unwrap(); | |
| 613 | assert_eq!((sandbox.micros, sandbox.quantity), (120_000, 600.0)); | |
| 614 | // Pending usage counts in the totals and its meter, not on a day. | |
| 615 | let git = products[4].meters.iter().find(|m| m.key == "git").unwrap(); | |
| 616 | assert_eq!((git.micros, git.pending_micros), (40_000, 40_000)); | |
| 617 | assert_eq!(totals.price_micros, 3_500_000 + 120_000 + 30_000 + 40_000); | |
| 618 | assert_eq!(totals.pending_micros, 40_000); | |
| 619 | assert_eq!(totals.included_micros, 1_000_000); | |
| 620 | assert_eq!(by_day.iter().filter(|d| d.product == "agent").map(|d| d.micros).sum::<i64>(), 3_500_000); | |
| 621 | assert_eq!(projects, ["acme/api", "acme/web"]); | |
| 622 | } | |
| 623 | ||
| 624 | #[test] | |
| 625 | fn filters_keep_only_the_products_and_projects_asked_for() { | |
| 626 | let days = days_between("2026-10-01", "2026-10-01").unwrap(); | |
| 627 | 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)]; | |
| Spend adds up on one ledger. Charged this month has one definition, price less discount, included usage and credit, shared by the plan card, the spend limit, Spend and the top bar; the limit had counted usage not yet closed at full price before the discount, so a comped workspace read as charged a cent. Every agent line on the ledger names the agent and who asked, repository runs by g1t included, so Spent is the sum of its products, Agents is the agent product, and by agent adds up to it; the billing API returns by_agent and by_person. The usage and billing guide says how spend is counted. | 628 | let pending = vec![Pending { meter: "git", micros: 7, ..Pending::default() }]; |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 629 | let (products, _, totals, projects) = shape(&days, &cells, &[], &pending, &["agent".into()], &["acme/web".into()]); |
| 630 | assert_eq!(products.len(), 1); | |
| 631 | assert_eq!(totals.price_micros, 100); | |
| 632 | // Every project stays listed, for the filter itself. | |
| 633 | assert_eq!(projects, ["acme/api", "acme/web"]); | |
| 634 | } | |
| 635 | ||
| 636 | #[test] | |
| Spend adds up on one ledger. Charged this month has one definition, price less discount, included usage and credit, shared by the plan card, the spend limit, Spend and the top bar; the limit had counted usage not yet closed at full price before the discount, so a comped workspace read as charged a cent. Every agent line on the ledger names the agent and who asked, repository runs by g1t included, so Spent is the sum of its products, Agents is the agent product, and by agent adds up to it; the billing API returns by_agent and by_person. The usage and billing guide says how spend is counted. | 637 | fn pending_usage_counts_at_price_with_what_is_covered_and_discounted() { |
| 638 | let days = days_between("2026-10-01", "2026-10-02").unwrap(); | |
| 639 | // Storage on a 100% discount, and scans g1t covers on a free workspace. | |
| 640 | let pending = [ | |
| 641 | Pending { meter: "storage", micros: 9_000, covered: 0, discount: 9_000 }, | |
| 642 | Pending { meter: "security", micros: 2_400, covered: 2_400, discount: 0 }, | |
| 643 | ]; | |
| 644 | let (_, _, totals, _) = shape(&days, &[], &[], &pending, &[], &[]); | |
| 645 | assert_eq!(totals.price_micros, 11_400); | |
| 646 | assert_eq!(totals.pending_micros, 11_400); | |
| Money is written one way. A single formatter turns millionths of a dollar into dollars, rounding half up on whole micros rather than on a float, so the same sum reads the same on every page: under a cent reads <$0.01 on a total and exactly nothing is $0.00, while the statement's lines, a session's receipt, the price book and an agent's effort costs carry up to four places where the fraction of a cent is the point; the six formatters that each rounded their own way are gone. This month is the calendar month in UTC from its first day to today everywhere, and billing counts the month's not-yet-closed usage in any range that reaches into the current month, so the top bar's pill, Spend, Home and Usage ask for the same days and get the same figure; Home now reads the usage report the others read instead of adding up statements. Usage's pending sentence says what of that usage the close will charge after the discount and included usage, which the billing API returns as pending_charged_micros. A reconciliation test holds the pill, Spend, Home and Usage to one number for one month. The usage and billing guide says how amounts are written and what this month means. | 647 | assert_eq!(totals.pending_charged_micros, 11_400 - totals.included_micros - totals.discount_micros); |
| Spend adds up on one ledger. Charged this month has one definition, price less discount, included usage and credit, shared by the plan card, the spend limit, Spend and the top bar; the limit had counted usage not yet closed at full price before the discount, so a comped workspace read as charged a cent. Every agent line on the ledger names the agent and who asked, repository runs by g1t included, so Spent is the sum of its products, Agents is the agent product, and by agent adds up to it; the billing API returns by_agent and by_person. The usage and billing guide says how spend is counted. | 648 | assert_eq!(totals.included_micros, 2_400); |
| 649 | assert_eq!(totals.discount_micros, 9_000); | |
| 650 | assert_eq!(charged(&totals), 0); | |
| 651 | // With a project chosen, pending usage (no project's) is left out. | |
| 652 | let (_, _, filtered, _) = shape(&days, &[], &[], &pending, &[], &["acme/api".to_owned()]); | |
| 653 | assert_eq!(filtered.pending_micros, 0); | |
| 654 | } | |
| 655 | ||
| 656 | fn attributed(agent: &str, person: &str, price: f64, entries: f64) -> Attributed { | |
| 657 | Attributed { agent: agent.into(), person: person.into(), price: Some(price), entries: Some(entries) } | |
| 658 | } | |
| 659 | ||
| 660 | #[test] | |
| 661 | fn the_agent_product_is_attributed_to_agents_and_to_who_asked() { | |
| 662 | let rows = [ | |
| 663 | attributed("g1t", "chase", 2_000_000.0, 3.0), | |
| 664 | attributed("mike", "chase", 20_000.0, 2.0), | |
| 665 | attributed("mike", "", 5_000.0, 1.0), | |
| 666 | attributed("", "", 500_000.0, 4.0), | |
| 667 | ]; | |
| 668 | let (agents, people) = attribution(&rows); | |
| 669 | assert_eq!(agents.iter().map(|s| (s.key.as_str(), s.micros, s.count)).collect::<Vec<_>>(), [("g1t", 2_000_000, 3), ("", 500_000, 4), ("mike", 25_000, 3)]); | |
| 670 | assert_eq!(agents[0].label, "@g1t"); | |
| 671 | assert_eq!(agents[1].label, "Not attributed to an agent"); | |
| 672 | assert_eq!(people.iter().map(|s| (s.key.as_str(), s.micros)).collect::<Vec<_>>(), [("chase", 2_020_000), ("", 505_000)]); | |
| 673 | assert_eq!(people[1].label, "No one asked"); | |
| 674 | // Both views are the same money. | |
| 675 | let total: i64 = rows.iter().map(|r| r.price.unwrap() as i64).sum(); | |
| 676 | assert_eq!(agents.iter().map(|s| s.micros).sum::<i64>(), total); | |
| 677 | assert_eq!(people.iter().map(|s| s.micros).sum::<i64>(), total); | |
| 678 | } | |
| 679 | ||
| 680 | /// The figures every page shows come from one ledger: Home's and | |
| 681 | /// Spend's "Spent" are the products' sum at price, the top bar's and | |
| 682 | /// Spend's "Agents" are the agent product, agents by handle sum to it, | |
| 683 | /// and a full discount is charged nothing. | |
| 684 | #[test] | |
| 685 | fn home_spend_and_the_top_bar_reconcile_on_the_ledger() { | |
| 686 | let days = days_between("2026-10-01", "2026-10-10").unwrap(); | |
| 687 | // The same lines, grouped two ways: by day, meter and project for | |
| 688 | // the products; by agent and asker for the attribution. | |
| 689 | let lines: [(&str, &str, &str, &str, &str, f64); 7] = [ | |
| 690 | ("2026-10-02", "agent_models", "flagon-io/g1t", "g1t", "chase", 1_900_000.0), | |
| 691 | ("2026-10-02", "agent_rate", "flagon-io/g1t", "g1t", "chase", 400_000.0), | |
| 692 | ("2026-10-03", "agent_sandbox", "flagon-io/g1t", "g1t", "chase", 220_000.0), | |
| 693 | ("2026-10-04", "agent_models", "flagon-io/@mike", "mike", "chase", 20_000.0), | |
| 694 | ("2026-10-05", "agent_models", "flagon-io/@margo", "margo", "", 4_000.0), | |
| 695 | ("2026-10-03", "sandbox", "flagon-io/g1t", "", "", 3_360_000.0), | |
| 696 | ("2026-10-06", "requests", "flagon-io/site", "", "", 4_480_000.0), | |
| 697 | ]; | |
| 698 | let cells: Vec<Cell> = lines | |
| 699 | .iter() | |
| 700 | .map(|(day, meter, project, _, _, price)| Cell { discount: Some(*price), ..cell(day, meter, project, *price) }) | |
| 701 | .collect(); | |
| 702 | let rows: Vec<Attributed> = lines | |
| 703 | .iter() | |
| 704 | .filter(|(_, meter, ..)| meter.starts_with("agent")) | |
| 705 | .map(|(_, _, _, agent, person, price)| attributed(agent, person, *price, 1.0)) | |
| 706 | .collect(); | |
| 707 | let (products, by_day, totals, _) = shape(&days, &cells, &[], &[], &[], &[]); | |
| 708 | let (agents, people) = attribution(&rows); | |
| 709 | // Spent, on Home and Spend: every product at price. | |
| 710 | let spent: i64 = lines.iter().map(|l| l.5 as i64).sum(); | |
| 711 | assert_eq!(totals.price_micros, spent); | |
| 712 | assert_eq!(products.iter().map(|p| p.micros).sum::<i64>(), spent); | |
| 713 | assert_eq!(by_day.iter().map(|d| d.micros).sum::<i64>(), spent); | |
| 714 | // Agents, in the top bar and on Spend: the agent product, which is | |
| 715 | // what the agents by handle add up to, g1t's own runs included. | |
| 716 | let agent_product = products.iter().find(|p| p.key == "agent").unwrap().micros; | |
| 717 | assert_eq!(agent_product, 1_900_000 + 400_000 + 220_000 + 20_000 + 4_000); | |
| 718 | assert_eq!(agents.iter().map(|s| s.micros).sum::<i64>(), agent_product); | |
| 719 | assert_eq!(people.iter().map(|s| s.micros).sum::<i64>(), agent_product); | |
| 720 | assert!(agents.iter().all(|s| s.micros <= agent_product)); | |
| 721 | assert_eq!(agents[0].key, "g1t"); | |
| 722 | // Charged, on Billing, Spend and in the top bar: nothing, on a 100% discount. | |
| 723 | assert_eq!(totals.discount_micros, spent); | |
| 724 | assert_eq!(charged(&totals), 0); | |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 725 | } |
| 726 | ||
| 727 | #[test] | |
| 728 | fn the_agent_is_counted_by_what_it_was_doing() { | |
| 729 | assert_eq!(feature_of("review").0, "reviews"); | |
| 730 | assert_eq!(feature_of("plan").0, "plans"); | |
| 731 | assert_eq!(feature_of("implement").0, "runs"); | |
| 732 | assert_eq!(meter_of_pending("package_storage"), "packages"); | |
| 733 | assert_eq!(meter_of_pending("nonsense"), "other"); | |
| 734 | } | |
| Money is written one way. A single formatter turns millionths of a dollar into dollars, rounding half up on whole micros rather than on a float, so the same sum reads the same on every page: under a cent reads <$0.01 on a total and exactly nothing is $0.00, while the statement's lines, a session's receipt, the price book and an agent's effort costs carry up to four places where the fraction of a cent is the point; the six formatters that each rounded their own way are gone. This month is the calendar month in UTC from its first day to today everywhere, and billing counts the month's not-yet-closed usage in any range that reaches into the current month, so the top bar's pill, Spend, Home and Usage ask for the same days and get the same figure; Home now reads the usage report the others read instead of adding up statements. Usage's pending sentence says what of that usage the close will charge after the discount and included usage, which the billing API returns as pending_charged_micros. A reconciliation test holds the pill, Spend, Home and Usage to one number for one month. The usage and billing guide says how amounts are written and what this month means. | 735 | |
| 736 | #[test] | |
| 737 | fn pending_usage_counts_in_any_range_that_reaches_into_this_month() { | |
| 738 | // The first of the month to today, as every page asks: counted. | |
| 739 | assert!(reaches_this_month("2026-10-01", "2026-10-10", "2026-10-10")); | |
| 740 | // To month end, or only the last week: counted too. | |
| 741 | assert!(reaches_this_month("2026-10-01", "2026-10-31", "2026-10-10")); | |
| 742 | assert!(reaches_this_month("2026-10-04", "2026-10-10", "2026-10-10")); | |
| 743 | // Last month, or a month that has not started: not this month's. | |
| 744 | assert!(!reaches_this_month("2026-09-01", "2026-09-30", "2026-10-10")); | |
| 745 | assert!(!reaches_this_month("2026-11-01", "2026-11-30", "2026-10-10")); | |
| 746 | // The month has turned on billing's clock: September's range no longer has it. | |
| 747 | assert!(!reaches_this_month("2026-09-01", "2026-09-30", "2026-10-01")); | |
| 748 | } | |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 749 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.