| 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 | } |