Skip to content

g1t/apps/api/src/billing.rs

603 lines30,182 bytesCodeBlame

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 audit1//! 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
11use std::collections::BTreeMap;
12
13use g1t_contracts::{FailureCode, Outcome, PrincipalKind, User, Viewer};
14use serde_json::{Map, Value, json};
15use worker::Result;
16
17use crate::operations::{Op, Services};
18
19/// The product families usage is grouped into, in order.
20pub(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.
24pub(crate) const ALERT_LEVELS: [u32; 4] = [50, 75, 90, 100];
25
26/// How usage can be added up over its range.
27pub(crate) const GROUPS: [&str; 3] = ["product", "project", "day"];
28
29fn failed(code: FailureCode, message: &str) -> Result<Outcome<Value>> {
30 Ok(Outcome::fail(code, message))
31}
32
33/// `camelCase` as `snake_case`.
34fn 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.
52pub(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).
64fn 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
73fn 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`.
78fn 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)`.
85fn 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`.
99pub(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.
107pub(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.
124pub(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
133fn 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).
140fn 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.
180pub(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)]
198pub(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
208pub(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.
258pub(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.
270pub(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.
291pub 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, &current) {
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)]
420mod 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!({}), &current).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 }), &current).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, &current).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(&current);
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}