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