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 | //! Billing: a workspace's usage, budget, AI credit and invoices. The |
| 2 | //! billing service keeps them and answers in camelCase; this is their | |
| 3 | //! public shape, in snake_case, with money as whole millionths of a dollar | |
| 4 | //! (`_micros`) or, for invoices, cents (`_cents`). | |
| 5 | //! | |
| 6 | //! Reading is for the workspace's members, a workspace's own token | |
| 7 | //! included. Changing the budget and buying AI credit are for its owners, | |
| 8 | //! as people: signed in or with a personal access token. A workspace's | |
| 9 | //! token and g1t's agents never change billing, whatever their scopes say. | |
| 10 | ||
| 11 | use std::collections::BTreeMap; | |
| 12 | ||
| 13 | use g1t_contracts::{FailureCode, Outcome, PrincipalKind, User, Viewer}; | |
| 14 | use serde_json::{Map, Value, json}; | |
| 15 | use worker::Result; | |
| 16 | ||
| 17 | use crate::operations::{Op, Services}; | |
| 18 | ||
| 19 | /// The product families usage is grouped into, in order. | |
| 20 | pub(crate) const PRODUCTS: [&str; 8] = | |
| 21 | ["agent", "sandboxes", "gateway", "deployments", "git_storage", "packages", "security", "search"]; | |
| 22 | ||
| 23 | /// Where a budget's alerts can be, in percent of its limit. | |
| 24 | pub(crate) const ALERT_LEVELS: [u32; 4] = [50, 75, 90, 100]; | |
| 25 | ||
| 26 | /// How usage can be added up over its range. | |
| 27 | pub(crate) const GROUPS: [&str; 3] = ["product", "project", "day"]; | |
| 28 | ||
| 29 | fn failed(code: FailureCode, message: &str) -> Result<Outcome<Value>> { | |
| 30 | Ok(Outcome::fail(code, message)) | |
| 31 | } | |
| 32 | ||
| 33 | /// `camelCase` as `snake_case`. | |
| 34 | fn snake_key(key: &str) -> String { | |
| 35 | let mut out = String::with_capacity(key.len() + 4); | |
| 36 | for c in key.chars() { | |
| 37 | if c.is_ascii_uppercase() { | |
| 38 | if !out.is_empty() { | |
| 39 | out.push('_'); | |
| 40 | } | |
| 41 | out.push(c.to_ascii_lowercase()); | |
| 42 | } else { | |
| 43 | out.push(c); | |
| 44 | } | |
| 45 | } | |
| 46 | out | |
| 47 | } | |
| 48 | ||
| 49 | /// A service's answer with every object key in `snake_case`, all the way | |
| 50 | /// down. Billing's answers have no keys that are data, so all of them are | |
| 51 | /// names. | |
| 52 | pub(crate) fn snake(value: &Value) -> Value { | |
| 53 | match value { | |
| 54 | Value::Object(fields) => { | |
| 55 | Value::Object(fields.iter().map(|(key, value)| (snake_key(key), snake(value))).collect()) | |
| 56 | } | |
| 57 | Value::Array(items) => Value::Array(items.iter().map(snake).collect()), | |
| 58 | other => other.clone(), | |
| 59 | } | |
| 60 | } | |
| 61 | ||
| 62 | /// Strings given as an array, or as one string separated by commas (a | |
| 63 | /// query string's way). | |
| 64 | fn list(input: &Value, key: &str) -> Vec<String> { | |
| 65 | let items: Vec<String> = match &input[key] { | |
| 66 | Value::Array(items) => items.iter().filter_map(|item| item.as_str().map(str::to_owned)).collect(), | |
| 67 | Value::String(text) => text.split(',').map(str::to_owned).collect(), | |
| 68 | _ => Vec::new(), | |
| 69 | }; | |
| 70 | items.into_iter().map(|item| item.trim().to_owned()).filter(|item| !item.is_empty()).collect() | |
| 71 | } | |
| 72 | ||
| 73 | fn workspace(input: &Value) -> Option<String> { | |
| 74 | input["workspace"].as_str().map(str::trim).filter(|slug| !slug.is_empty()).map(str::to_lowercase) | |
| 75 | } | |
| 76 | ||
| 77 | /// `YYYY-MM-DD`. | |
| 78 | fn is_day(text: &str) -> bool { | |
| 79 | let bytes = text.as_bytes(); | |
| 80 | bytes.len() == 10 | |
| 81 | && bytes.iter().enumerate().all(|(at, byte)| if at == 4 || at == 7 { *byte == b'-' } else { byte.is_ascii_digit() }) | |
| 82 | } | |
| 83 | ||
| 84 | /// The UTC day `days` after 1970-01-01, as `(year, month, day)`. | |
| 85 | fn civil(days: i64) -> (i64, u32, u32) { | |
| 86 | let z = days + 719_468; | |
| 87 | let era = z.div_euclid(146_097); | |
| 88 | let doe = z.rem_euclid(146_097); | |
| 89 | let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; | |
| 90 | let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); | |
| 91 | let mp = (5 * doy + 2) / 153; | |
| 92 | let day = (doy - (153 * mp + 2) / 5 + 1) as u32; | |
| 93 | let month = if mp < 10 { mp + 3 } else { mp - 9 } as u32; | |
| 94 | let year = yoe + era * 400 + i64::from(month <= 2); | |
| 95 | (year, month, day) | |
| 96 | } | |
| 97 | ||
| 98 | /// The first day of the current UTC month, and today, at `now_ms`. | |
| 99 | pub(crate) fn this_month(now_ms: f64) -> (String, String) { | |
| 100 | let (year, month, day) = civil((now_ms / 86_400_000.0).floor() as i64); | |
| 101 | (format!("{year:04}-{month:02}-01"), format!("{year:04}-{month:02}-{day:02}")) | |
| 102 | } | |
| 103 | ||
| 104 | /// The person who may change a workspace's billing: a person, never a | |
| 105 | /// workspace's own token or one of g1t's agents. `agent_scoped` is whether | |
| 106 | /// the request came with an agent's token. | |
| 107 | pub(crate) fn person(viewer: &Viewer, agent_scoped: bool) -> std::result::Result<&User, (FailureCode, &'static str)> { | |
| 108 | match viewer { | |
| 109 | None => Err((FailureCode::Unauthenticated, "This needs a g1t access token.")), | |
| 110 | Some(user) if agent_scoped || user.kind == PrincipalKind::Agent => Err(( | |
| 111 | FailureCode::Forbidden, | |
| 112 | "g1t's agents never change billing: a workspace's budget and AI credit are for its owners.", | |
| 113 | )), | |
| 114 | Some(user) if user.kind != PrincipalKind::User => Err(( | |
| 115 | FailureCode::Forbidden, | |
| 116 | "Changing billing needs a person: sign in, or use a personal access token. A workspace's own token can read billing, not change it.", | |
| 117 | )), | |
| 118 | Some(user) => Ok(user), | |
| 119 | } | |
| 120 | } | |
| 121 | ||
| 122 | /// A usage report (camelCase, as billing answers) in its public shape, | |
| 123 | /// with `groups` when `group_by` asks for them. | |
| 124 | pub(crate) fn usage_json(report: &Value, group_by: Option<&str>) -> Value { | |
| 125 | let mut out = snake(report); | |
| 126 | if let (Some(by), Some(fields)) = (group_by, out.as_object_mut()) { | |
| 127 | fields.insert("group_by".to_owned(), json!(by)); | |
| 128 | fields.insert("groups".to_owned(), groups(report, by)); | |
| 129 | } | |
| 130 | out | |
| 131 | } | |
| 132 | ||
| 133 | fn micros(value: &Value) -> i64 { | |
| 134 | value.as_i64().or_else(|| value.as_f64().map(|n| n as i64)).unwrap_or(0) | |
| 135 | } | |
| 136 | ||
| 137 | /// The report's range added up by product (every family, in order), by | |
| 138 | /// project (most first; `key` null for usage that is no one project's) or | |
| 139 | /// by day (oldest first). | |
| 140 | fn groups(report: &Value, by: &str) -> Value { | |
| 141 | let products = report["products"].as_array().cloned().unwrap_or_default(); | |
| 142 | match by { | |
| 143 | "product" => Value::Array( | |
| 144 | products | |
| 145 | .iter() | |
| 146 | .map(|product| json!({ "key": product["key"], "label": product["label"], "micros": micros(&product["micros"]) })) | |
| 147 | .collect(), | |
| 148 | ), | |
| 149 | "project" => { | |
| 150 | let mut sums: BTreeMap<String, i64> = BTreeMap::new(); | |
| 151 | for product in &products { | |
| 152 | for meter in product["meters"].as_array().into_iter().flatten() { | |
| 153 | for part in meter["byProject"].as_array().into_iter().flatten() { | |
| 154 | *sums.entry(part["project"].as_str().unwrap_or_default().to_owned()).or_default() += micros(&part["micros"]); | |
| 155 | } | |
| 156 | } | |
| 157 | } | |
| 158 | let mut sums: Vec<(String, i64)> = sums.into_iter().collect(); | |
| 159 | sums.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0))); | |
| 160 | Value::Array( | |
| 161 | sums.into_iter() | |
| 162 | .map(|(project, micros)| { | |
| 163 | let key = if project.is_empty() { Value::Null } else { Value::String(project) }; | |
| 164 | json!({ "key": key, "micros": micros }) | |
| 165 | }) | |
| 166 | .collect(), | |
| 167 | ) | |
| 168 | } | |
| 169 | _ => { | |
| 170 | let mut sums: BTreeMap<String, i64> = BTreeMap::new(); | |
| 171 | for day in report["days"].as_array().into_iter().flatten() { | |
| 172 | *sums.entry(day["day"].as_str().unwrap_or_default().to_owned()).or_default() += micros(&day["micros"]); | |
| 173 | } | |
| 174 | Value::Array(sums.into_iter().map(|(day, micros)| json!({ "key": day, "micros": micros })).collect()) | |
| 175 | } | |
| 176 | } | |
| 177 | } | |
| 178 | ||
| 179 | /// A workspace's limit (camelCase, as billing answers) as its budget. | |
| 180 | pub(crate) fn budget_json(limit: &Value) -> Value { | |
| 181 | json!({ | |
| 182 | "workspace": limit["workspace"], | |
| 183 | "amount_micros": limit["spendLimitMicros"], | |
| 184 | "automatic": limit["defaultSpendLimit"].as_bool().unwrap_or(false), | |
| 185 | "spent_micros": micros(&limit["spentMicros"]), | |
| 186 | "max_amount_micros": limit["availableMicros"], | |
| 187 | "alerts": limit["alertLevels"].as_array().cloned().unwrap_or_default(), | |
| 188 | "pause_at_limit": limit["pauseAtLimit"].as_bool().unwrap_or(true), | |
| 189 | "webhook": limit["budgetWebhook"], | |
| 190 | "state": limit["state"], | |
| 191 | "message": limit["message"], | |
| 192 | }) | |
| 193 | } | |
| 194 | ||
| 195 | /// What set_budget asks billing for: the fields given, and the rest as | |
| 196 | /// they are in `current` (the workspace's limit, camelCase). | |
| 197 | #[derive(Debug, PartialEq)] | |
| 198 | pub(crate) struct BudgetChange { | |
| 199 | /// No amount was given: billing leaves the limit as it is, whatever | |
| 200 | /// it is by the time it is asked. | |
| 201 | pub keep_limit: bool, | |
| 202 | pub amount_micros: Option<i64>, | |
| 203 | pub alerts: Vec<u32>, | |
| 204 | pub pause_at_limit: bool, | |
| 205 | pub webhook: Option<String>, | |
| 206 | } | |
| 207 | ||
| 208 | pub(crate) fn budget_change(input: &Value, current: &Value) -> std::result::Result<BudgetChange, String> { | |
| 209 | let amount_micros = match input.get("amount_micros") { | |
| 210 | None if current["defaultSpendLimit"].as_bool() == Some(true) => None, | |
| 211 | None => current["spendLimitMicros"].as_i64(), | |
| 212 | Some(Value::Null) => None, | |
| 213 | Some(value) => { | |
| 214 | let amount = value.as_i64().or_else(|| value.as_str().and_then(|digits| digits.trim().parse().ok())); | |
| 215 | match amount { | |
| 216 | Some(amount) if amount >= 0 => Some(amount), | |
| 217 | _ => return Err("amount_micros is a whole number of millionths of a dollar, or null for the automatic limit.".to_owned()), | |
| 218 | } | |
| 219 | } | |
| 220 | }; | |
| 221 | let alerts = match input.get("alerts") { | |
| 222 | None | Some(Value::Null) => current["alertLevels"] | |
| 223 | .as_array() | |
| 224 | .map(|levels| levels.iter().filter_map(|level| level.as_u64()).filter_map(|level| u32::try_from(level).ok()).collect()) | |
| 225 | .unwrap_or_default(), | |
| 226 | Some(Value::Array(levels)) => { | |
| 227 | let mut chosen = Vec::new(); | |
| 228 | for level in levels { | |
| 229 | let level = level.as_u64().or_else(|| level.as_str().and_then(|digits| digits.trim().parse().ok())); | |
| 230 | match level.and_then(|level| u32::try_from(level).ok()).filter(|level| ALERT_LEVELS.contains(level)) { | |
| 231 | Some(level) if !chosen.contains(&level) => chosen.push(level), | |
| 232 | Some(_) => {} | |
| 233 | None => return Err("alerts are some of 50, 75, 90 and 100.".to_owned()), | |
| 234 | } | |
| 235 | } | |
| 236 | chosen.sort_unstable(); | |
| 237 | chosen | |
| 238 | } | |
| 239 | Some(_) => return Err("alerts is a list: some of 50, 75, 90 and 100.".to_owned()), | |
| 240 | }; | |
| 241 | let pause_at_limit = match input.get("pause_at_limit") { | |
| 242 | None | Some(Value::Null) => current["pauseAtLimit"].as_bool().unwrap_or(true), | |
| 243 | Some(Value::Bool(pause)) => *pause, | |
| 244 | Some(Value::String(word)) if word == "true" || word == "false" => word == "true", | |
| 245 | Some(_) => return Err("pause_at_limit is true or false.".to_owned()), | |
| 246 | }; | |
| 247 | let webhook = match input.get("webhook") { | |
| 248 | None => current["budgetWebhook"].as_str().map(str::to_owned), | |
| 249 | Some(Value::Null) => None, | |
| 250 | Some(Value::String(url)) if url.trim().is_empty() => None, | |
| 251 | Some(Value::String(url)) if url.trim().starts_with("https://") => Some(url.trim().to_owned()), | |
| 252 | Some(_) => return Err("webhook is an https:// address, or null for none.".to_owned()), | |
| 253 | }; | |
| 254 | Ok(BudgetChange { keep_limit: input.get("amount_micros").is_none(), amount_micros, alerts, pause_at_limit, webhook }) | |
| 255 | } | |
| 256 | ||
| 257 | /// Billing details without the invoices, which list_invoices gives. | |
| 258 | pub(crate) fn details_json(details: &Value) -> Value { | |
| 259 | let mut out = snake(details); | |
| 260 | if let Some(fields) = out.as_object_mut() { | |
| 261 | fields.remove("invoices"); | |
| 262 | fields.remove("upcoming"); | |
| 263 | fields.remove("unavailable"); | |
| 264 | } | |
| 265 | out | |
| 266 | } | |
| 267 | ||
| 268 | /// Every invoice billed to the workspace, g1t's itemised usage invoices, | |
| 269 | /// and what the next one comes to so far. | |
| 270 | pub(crate) fn invoices_json(details: &Value, usage: &[Value]) -> Value { | |
| 271 | let usage: Vec<Value> = usage | |
| 272 | .iter() | |
| 273 | .map(|invoice| { | |
| 274 | let mut fields = Map::new(); | |
| 275 | fields.insert("id".to_owned(), invoice["invoiceId"].clone()); | |
| 276 | if let Value::Object(rest) = snake(invoice) { | |
| 277 | fields.extend(rest.into_iter().filter(|(key, _)| key != "invoice_id")); | |
| 278 | } | |
| 279 | Value::Object(fields) | |
| 280 | }) | |
| 281 | .collect(); | |
| 282 | json!({ | |
| 283 | "invoices": snake(&details["invoices"]).as_array().cloned().unwrap_or_default(), | |
| 284 | "usage_invoices": usage, | |
| 285 | "upcoming": snake(&details["upcoming"]), | |
| 286 | "unavailable": details["unavailable"], | |
| 287 | }) | |
| 288 | } | |
| 289 | ||
| 290 | /// Runs one of the billing operations. | |
| 291 | pub async fn run(op: Op, services: &Services, viewer: &Viewer, input: &Value) -> Result<Outcome<Value>> { | |
| 292 | if viewer.is_none() { | |
| 293 | return failed(FailureCode::Unauthenticated, "This needs a g1t access token."); | |
| 294 | } | |
| 295 | let Some(workspace) = workspace(input) else { | |
| 296 | return failed(FailureCode::Invalid, "Give the workspace's slug."); | |
| 297 | }; | |
| 298 | let billing = &services.billing; | |
| 299 | let shaped = |outcome: Outcome<Value>, shape: &dyn Fn(&Value) -> Value| -> Result<Outcome<Value>> { | |
| 300 | Ok(match outcome { | |
| 301 | Outcome::Ok(value) => Outcome::Ok(shape(&value)), | |
| 302 | Outcome::Fail(failure) => Outcome::Fail(failure), | |
| 303 | }) | |
| 304 | }; | |
| 305 | let account = json!({ "workspace": workspace, "viewer": viewer }); | |
| 306 | match op { | |
| 307 | Op::GetUsage => { | |
| 308 | let group_by = input["group_by"].as_str().map(str::trim).filter(|by| !by.is_empty()).map(str::to_lowercase); | |
| 309 | if let Some(by) = &group_by | |
| 310 | && !GROUPS.contains(&by.as_str()) | |
| 311 | { | |
| 312 | return failed(FailureCode::Invalid, "group_by is product, project or day."); | |
| 313 | } | |
| 314 | let products = list(input, "products"); | |
| 315 | if let Some(unknown) = products.iter().find(|product| !PRODUCTS.contains(&product.as_str())) { | |
| 316 | return failed( | |
| 317 | FailureCode::Invalid, | |
| 318 | &format!("{unknown} is not a product. Give some of {}.", PRODUCTS.join(", ")), | |
| 319 | ); | |
| 320 | } | |
| 321 | let (month_start, today) = this_month(worker::Date::now().as_millis() as f64); | |
| 322 | let day = |key: &str, default: String| -> std::result::Result<String, String> { | |
| 323 | match input[key].as_str().map(str::trim).filter(|day| !day.is_empty()) { | |
| 324 | None => Ok(default), | |
| 325 | Some(day) if is_day(day) => Ok(day.to_owned()), | |
| 326 | Some(day) => Err(format!("{key} is a day, YYYY-MM-DD, not {day}.")), | |
| 327 | } | |
| 328 | }; | |
| 329 | let (from, until) = match (day("from", month_start), day("until", today)) { | |
| 330 | (Ok(from), Ok(until)) => (from, until), | |
| 331 | (Err(message), _) | (_, Err(message)) => return failed(FailureCode::Invalid, &message), | |
| 332 | }; | |
| 333 | let report: Outcome<Value> = g1t_kit::call( | |
| 334 | billing, | |
| 335 | "usage_report", | |
| 336 | &json!({ | |
| 337 | "workspace": workspace, | |
| 338 | "viewer": viewer, | |
| 339 | "from": from, | |
| 340 | "until": until, | |
| 341 | "products": products, | |
| 342 | "projects": list(input, "projects"), | |
| 343 | }), | |
| 344 | ) | |
| 345 | .await?; | |
| 346 | shaped(report, &|report| usage_json(report, group_by.as_deref())) | |
| 347 | } | |
| 348 | Op::GetBudget => shaped(g1t_kit::call(billing, "limit", &account).await?, &budget_json), | |
| 349 | Op::SetBudget => { | |
| 350 | let actor = match person(viewer, services.scope.is_some()) { | |
| 351 | Ok(user) => user, | |
| 352 | Err((code, message)) => return failed(code, message), | |
| 353 | }; | |
| 354 | let current: Outcome<Value> = g1t_kit::call(billing, "limit", &account).await?; | |
| 355 | let current = match current { | |
| 356 | Outcome::Ok(limit) => limit, | |
| 357 | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), | |
| 358 | }; | |
| 359 | let change = match budget_change(input, ¤t) { | |
| 360 | Ok(change) => change, | |
| 361 | Err(message) => return failed(FailureCode::Invalid, &message), | |
| 362 | }; | |
| 363 | let set: Outcome<Value> = g1t_kit::call( | |
| 364 | billing, | |
| 365 | "set_budget", | |
| 366 | &json!({ | |
| 367 | "actor": actor, | |
| 368 | "workspace": workspace, | |
| 369 | "keepLimit": change.keep_limit, | |
| 370 | "amountMicros": change.amount_micros, | |
| 371 | "alerts": change.alerts, | |
| 372 | "pauseAtLimit": change.pause_at_limit, | |
| 373 | "webhook": change.webhook, | |
| 374 | }), | |
| 375 | ) | |
| 376 | .await?; | |
| 377 | shaped(set, &budget_json) | |
| 378 | } | |
| 379 | Op::GetAiCredit => shaped(g1t_kit::call(billing, "ai_credit", &account).await?, &snake), | |
| 380 | Op::BuyAiCredit => { | |
| 381 | let actor = match person(viewer, services.scope.is_some()) { | |
| 382 | Ok(user) => user, | |
| 383 | Err((code, message)) => return failed(code, message), | |
| 384 | }; | |
| 385 | let cents = match &input["amount_cents"] { | |
| 386 | Value::Number(number) => number.as_u64(), | |
| 387 | Value::String(digits) => digits.trim().parse().ok(), | |
| 388 | _ => None, | |
| 389 | }; | |
| 390 | let Some(cents) = cents.and_then(|cents| u32::try_from(cents).ok()).filter(|cents| *cents > 0) else { | |
| 391 | return failed(FailureCode::Invalid, "Give amount_cents: the credit in cents, in whole dollars, such as 5000 for $50."); | |
| 392 | }; | |
| 393 | let return_url = format!("{}/{workspace}/-/billing", services.addresses.site.trim_end_matches('/')); | |
| 394 | let checkout: Outcome<Value> = g1t_kit::call( | |
| 395 | billing, | |
| 396 | "buy_ai_credit", | |
| 397 | &json!({ "actor": actor, "workspace": workspace, "amountCents": cents, "returnUrl": return_url }), | |
| 398 | ) | |
| 399 | .await?; | |
| 400 | shaped(checkout, &|checkout| json!({ "url": checkout["url"] })) | |
| 401 | } | |
| 402 | Op::ListInvoices => { | |
| 403 | let details: Outcome<Value> = g1t_kit::call(billing, "billing_details", &account).await?; | |
| 404 | let details = match details { | |
| 405 | Outcome::Ok(details) => details, | |
| 406 | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), | |
| 407 | }; | |
| 408 | let usage: Outcome<Vec<Value>> = g1t_kit::call(billing, "invoices", &account).await?; | |
| 409 | Ok(match usage { | |
| 410 | Outcome::Ok(usage) => Outcome::Ok(invoices_json(&details, &usage)), | |
| 411 | Outcome::Fail(failure) => Outcome::Fail(failure), | |
| 412 | }) | |
| 413 | } | |
| 414 | Op::GetBillingDetails => shaped(g1t_kit::call(billing, "billing_details", &account).await?, &details_json), | |
| 415 | _ => failed(FailureCode::Invalid, "Not a billing operation."), | |
| 416 | } | |
| 417 | } | |
| 418 | ||
| 419 | #[cfg(test)] | |
| 420 | mod tests { | |
| 421 | use super::*; | |
| 422 | ||
| 423 | #[test] | |
| 424 | fn a_usage_report_is_snake_case_all_the_way_down() { | |
| 425 | let report = json!({ | |
| 426 | "from": "2026-10-01", "until": "2026-10-07", | |
| 427 | "totals": { "priceMicros": 12_500_000, "discountMicros": 0, "includedMicros": 2_000_000, "creditsMicros": 500_000, | |
| 428 | "chargedMicros": 10_000_000, "pendingMicros": 300_000, "costMicros": 9_000_000 }, | |
| 429 | "days": [ | |
| 430 | { "day": "2026-10-02", "product": "agent", "micros": 4_000_000 }, | |
| 431 | { "day": "2026-10-02", "product": "sandboxes", "micros": 500_000 }, | |
| 432 | { "day": "2026-10-01", "product": "agent", "micros": 8_000_000 }, | |
| 433 | ], | |
| 434 | "products": [ | |
| 435 | { "key": "agent", "label": "Agent", "micros": 12_000_000, "features": [{ "key": "runs", "label": "Runs", "micros": 12_000_000, "count": 3 }], | |
| 436 | "meters": [{ "key": "agent_models", "label": "Models", "product": "agent", "unit": "tokens", "quantity": 1.5e6, | |
| 437 | "micros": 12_000_000, "pendingMicros": 0, "daily": [8_000_000, 4_000_000], "allowance": null, | |
| 438 | "byProject": [{ "project": "acme/web", "micros": 9_000_000, "quantity": 1e6 }, | |
| 439 | { "project": "", "micros": 3_000_000, "quantity": 5e5 }] }] }, | |
| 440 | { "key": "sandboxes", "label": "Sandboxes", "micros": 500_000, "features": [], | |
| 441 | "meters": [{ "key": "sandbox", "label": "Sandbox time", "product": "sandboxes", "unit": "seconds", "quantity": 600.0, | |
| 442 | "micros": 500_000, "pendingMicros": 0, "daily": [0, 500_000], | |
| 443 | "allowance": { "used": 600.0, "of": 3600.0, "unit": "seconds" }, | |
| 444 | "byProject": [{ "project": "acme/web", "micros": 500_000, "quantity": 600.0 }] }] }, | |
| 445 | ], | |
| 446 | "projects": ["acme/web"], "included": null, "discountPercent": null, | |
| 447 | "aiCreditMicros": 40_000_000, "creditMicros": 0, "trialMicros": null, "plan": "pro", "free": false, | |
| 448 | }); | |
| 449 | let usage = usage_json(&report, None); | |
| 450 | assert_eq!(usage["totals"]["charged_micros"], 10_000_000); | |
| 451 | assert_eq!(usage["products"][0]["meters"][0]["by_project"][0]["project"], "acme/web"); | |
| 452 | assert_eq!(usage["products"][0]["meters"][0]["pending_micros"], 0); | |
| 453 | assert_eq!(usage["ai_credit_micros"], 40_000_000); | |
| 454 | assert!(usage.get("groups").is_none()); | |
| 455 | let text = usage.to_string(); | |
| 456 | for camel in ["Micros", "byProject", "discountPercent"] { | |
| 457 | assert!(!text.contains(camel), "{camel} in {text}"); | |
| 458 | } | |
| 459 | ||
| 460 | let by_project = usage_json(&report, Some("project")); | |
| 461 | assert_eq!(by_project["group_by"], "project"); | |
| 462 | assert_eq!( | |
| 463 | by_project["groups"], | |
| 464 | json!([{ "key": "acme/web", "micros": 9_500_000 }, { "key": null, "micros": 3_000_000 }]) | |
| 465 | ); | |
| 466 | let by_day = usage_json(&report, Some("day")); | |
| 467 | assert_eq!(by_day["groups"], json!([{ "key": "2026-10-01", "micros": 8_000_000 }, { "key": "2026-10-02", "micros": 4_500_000 }])); | |
| 468 | let by_product = usage_json(&report, Some("product")); | |
| 469 | assert_eq!(by_product["groups"][1], json!({ "key": "sandboxes", "label": "Sandboxes", "micros": 500_000 })); | |
| 470 | } | |
| 471 | ||
| 472 | #[test] | |
| 473 | fn ai_credit_is_snake_case() { | |
| 474 | let credit = json!({ | |
| 475 | "balanceMicros": 42_000_000, "purchasedMicros": 40_000_000, "givenMicros": 2_000_000, | |
| 476 | "grants": [{ "id": "crd_1", "kind": "purchase", "amountMicros": 40_000_000, "usedMicros": 0, "leftMicros": 40_000_000, "expiresAt": null }], | |
| 477 | "freeViaDiscount": false, "postpaid": false, "blocked": false, "canBuy": true, | |
| 478 | "presetsCents": [2500, 5000], "minCents": 1000, "maxCents": 100_000, | |
| 479 | "cardFee": { "on": true, "percentMicros": 29_000.0, "fixedCents": 30 }, | |
| 480 | "reload": { "enabled": false, "thresholdMicros": 0, "targetMicros": 0, "monthlyMaxMicros": 0, "reloadedMicros": 0, "failedAt": null, "error": null }, | |
| 481 | "agentRateMicros": 3.6, "modelMarkupPercent": 10, "gatewayMarkupPercent": 5, "upgradeCreditMicros": 0, "expiresDays": 365, | |
| 482 | }); | |
| 483 | let out = snake(&credit); | |
| 484 | assert_eq!(out["balance_micros"], 42_000_000); | |
| 485 | assert_eq!(out["grants"][0]["left_micros"], 40_000_000); | |
| 486 | assert_eq!(out["card_fee"]["fixed_cents"], 30); | |
| 487 | assert_eq!(out["reload"]["monthly_max_micros"], 0); | |
| 488 | assert_eq!(out["can_buy"], true); | |
| 489 | assert!(out.get("balanceMicros").is_none()); | |
| 490 | assert_eq!(snake_key("line1"), "line1"); | |
| 491 | assert_eq!(snake_key("last4"), "last4"); | |
| 492 | assert_eq!(snake_key("taxIdType"), "tax_id_type"); | |
| 493 | } | |
| 494 | ||
| 495 | #[test] | |
| 496 | fn agents_and_workspace_tokens_never_change_billing() { | |
| 497 | let person_user = User { username: "ana".into(), ..User::default() }; | |
| 498 | assert!(person(&Some(person_user.clone()), false).is_ok()); | |
| 499 | // A person's token used by an agent's run is still an agent's. | |
| 500 | assert_eq!(person(&Some(person_user), true).unwrap_err().0, FailureCode::Forbidden); | |
| 501 | let agent = User { username: "g1t".into(), kind: PrincipalKind::Agent, ..User::default() }; | |
| 502 | let (code, message) = person(&Some(agent), false).unwrap_err(); | |
| 503 | assert_eq!(code, FailureCode::Forbidden); | |
| 504 | assert!(message.contains("agents never change billing")); | |
| 505 | let workspace = User { username: "acme".into(), kind: PrincipalKind::Workspace, ..User::default() }; | |
| 506 | let (code, message) = person(&Some(workspace), false).unwrap_err(); | |
| 507 | assert_eq!(code, FailureCode::Forbidden); | |
| 508 | assert!(message.contains("personal access token")); | |
| 509 | assert_eq!(person(&None, false).unwrap_err().0, FailureCode::Unauthenticated); | |
| 510 | } | |
| 511 | ||
| 512 | #[test] | |
| 513 | fn a_budget_change_keeps_what_was_not_given() { | |
| 514 | let current = json!({ | |
| 515 | "workspace": "acme", "spendLimitMicros": 300_000_000, "defaultSpendLimit": false, "spentMicros": 12_000_000, | |
| 516 | "availableMicros": 1_000_000_000, "alertLevels": [100, 75, 50], "pauseAtLimit": true, | |
| 517 | "budgetWebhook": "https://acme.dev/hooks/budget", "state": "ok", "message": null, | |
| 518 | }); | |
| 519 | let kept = budget_change(&json!({}), ¤t).unwrap(); | |
| 520 | assert_eq!( | |
| 521 | kept, | |
| 522 | BudgetChange { keep_limit: true, amount_micros: Some(300_000_000), alerts: vec![100, 75, 50], pause_at_limit: true, webhook: Some("https://acme.dev/hooks/budget".into()) } | |
| 523 | ); | |
| 524 | let changed = budget_change(&json!({ "amount_micros": null, "alerts": [90, 50, 90], "pause_at_limit": false, "webhook": null }), ¤t).unwrap(); | |
| 525 | assert_eq!(changed, BudgetChange { keep_limit: false, amount_micros: None, alerts: vec![50, 90], pause_at_limit: false, webhook: None }); | |
| 526 | for bad in [json!({ "alerts": [60] }), json!({ "alerts": "50" }), json!({ "amount_micros": -1 }), json!({ "webhook": "http://x" }), json!({ "pause_at_limit": "yes" })] { | |
| 527 | assert!(budget_change(&bad, ¤t).is_err(), "{bad}"); | |
| 528 | } | |
| 529 | // The automatic limit stays automatic when no amount is given. | |
| 530 | let automatic = json!({ "spendLimitMicros": 200_000_000, "defaultSpendLimit": true }); | |
| 531 | assert_eq!(budget_change(&json!({}), &automatic).unwrap().amount_micros, None); | |
| 532 | let budget = budget_json(¤t); | |
| 533 | assert_eq!(budget["amount_micros"], 300_000_000); | |
| 534 | assert_eq!(budget["max_amount_micros"], 1_000_000_000); | |
| 535 | assert_eq!(budget["alerts"], json!([100, 75, 50])); | |
| 536 | assert_eq!(budget["automatic"], false); | |
| 537 | } | |
| 538 | ||
| 539 | #[test] | |
| 540 | fn invoices_put_every_invoice_beside_the_itemised_usage_ones() { | |
| 541 | let details = json!({ | |
| 542 | "customer": true, "email": "billing@acme.dev", | |
| 543 | "invoices": [{ "id": "in_1", "number": "ACME-0001", "status": "paid", "totalCents": 2000, "currency": "usd", | |
| 544 | "createdAt": "2026-10-01T00:00:00Z", "description": null, "hostedUrl": null, "pdfUrl": null }], | |
| 545 | "upcoming": { "closesAt": "2026-11-01T00:00:00Z", "subscriptionsMicros": 20_000_000, "usageMicros": 5_000_000, "totalMicros": 25_000_000 }, | |
| 546 | "unavailable": null, | |
| 547 | }); | |
| 548 | let usage = [json!({ "invoiceId": "inv_1", "workspace": "acme", "reason": "month", "period": "2026-09", "amountMicros": 5_000_000, | |
| 549 | "status": "paid", "hostedUrl": null, "pdfUrl": null, "lines": [{ "description": "Agent", "amountMicros": 5_000_000 }], | |
| 550 | "createdAt": "2026-10-01T00:00:00Z" })]; | |
| 551 | let out = invoices_json(&details, &usage); | |
| 552 | assert_eq!(out["invoices"][0]["total_cents"], 2000); | |
| 553 | assert_eq!(out["usage_invoices"][0]["id"], "inv_1"); | |
| 554 | assert!(out["usage_invoices"][0].get("invoice_id").is_none()); | |
| 555 | assert_eq!(out["usage_invoices"][0]["lines"][0]["amount_micros"], 5_000_000); | |
| 556 | assert_eq!(out["upcoming"]["total_micros"], 25_000_000); | |
| 557 | let shown = details_json(&details); | |
| 558 | assert_eq!(shown["email"], "billing@acme.dev"); | |
| 559 | assert!(shown.get("invoices").is_none() && shown.get("upcoming").is_none()); | |
| 560 | } | |
| 561 | ||
| 562 | const OPS: [Op; 7] = | |
| 563 | [Op::GetUsage, Op::GetBudget, Op::SetBudget, Op::GetAiCredit, Op::BuyAiCredit, Op::ListInvoices, Op::GetBillingDetails]; | |
| 564 | ||
| 565 | /// Billing belongs to a workspace, needs someone signed in, and is one | |
| 566 | /// MCP tool whose writes no preset but full access reaches. | |
| 567 | #[test] | |
| 568 | fn billing_operations_name_a_workspace_and_agents_only_read() { | |
| 569 | use crate::tools::{Gate, Tool}; | |
| 570 | use g1t_contracts::scopes::{Preset, TokenAccess, scope_for}; | |
| 571 | for op in OPS { | |
| 572 | assert!(!op.needs_repo(), "{}", op.name()); | |
| 573 | assert!(op.needs_user(), "{}", op.name()); | |
| 574 | assert!(op.required().contains(&"workspace".to_owned()), "{}", op.name()); | |
| 575 | assert!(scope_for(op.name()).is_some(), "{}", op.name()); | |
| 576 | } | |
| 577 | let tool = Tool::by_name("billing").unwrap(); | |
| 578 | let token = |preset: Preset| TokenAccess { | |
| 579 | token_id: "tok_1".into(), | |
| 580 | scopes: preset.scopes().map(|scopes| scopes.iter().map(|scope| scope.as_str().to_owned()).collect()), | |
| 581 | legacy: false, | |
| 582 | }; | |
| 583 | for preset in [Preset::ReadOnly, Preset::Agent] { | |
| 584 | let access = token(preset); | |
| 585 | let seen: Vec<&str> = tool.visible(&Gate::Token(&access)).iter().map(|action| action.name).collect(); | |
| 586 | assert_eq!(seen, ["usage", "budget", "ai_credit", "invoices", "billing_details"], "{}", preset.as_str()); | |
| 587 | } | |
| 588 | let full = TokenAccess::full(); | |
| 589 | assert_eq!(tool.visible(&Gate::Token(&full)).len(), OPS.len()); | |
| 590 | } | |
| 591 | ||
| 592 | #[test] | |
| 593 | fn the_month_so_far_is_read_from_the_clock() { | |
| 594 | // 2026-10-07T12:00:00Z. | |
| 595 | assert_eq!(this_month(1_791_374_400_000.0), ("2026-10-01".to_owned(), "2026-10-07".to_owned())); | |
| 596 | assert_eq!(this_month(0.0), ("1970-01-01".to_owned(), "1970-01-01".to_owned())); | |
| 597 | // 2024-02-29. | |
| 598 | assert_eq!(this_month(1_709_208_000_000.0), ("2024-02-01".to_owned(), "2024-02-29".to_owned())); | |
| 599 | assert!(is_day("2026-10-07") && !is_day("2026-10-7") && !is_day("20261007xx")); | |
| 600 | assert_eq!(list(&json!({ "products": "agent, sandboxes,," }), "products"), vec!["agent", "sandboxes"]); | |
| 601 | assert_eq!(list(&json!({ "products": ["agent"] }), "products"), vec!["agent"]); | |
| 602 | } | |
| 603 | } |