Skip to content

g1t/apps/api/src/billing.rs

635 lines31,509 bytesCodeBlame
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
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 Op::ListGatewayRequests => {
416 let limit = match &input["limit"] {
417 Value::Null => None,
418 Value::Number(number) => number.as_u64(),
419 Value::String(digits) => digits.trim().parse().ok(),
420 _ => Some(0),
421 };
422 if limit.is_some_and(|limit| !(1..=200).contains(&limit)) {
423 return failed(FailureCode::Invalid, "limit is a number from 1 to 200.");
424 }
425 let before = input["before"].as_str().map(str::trim).filter(|id| !id.is_empty());
426 let page: Outcome<Value> = g1t_kit::call(
427 billing,
428 "gateway_requests",
429 &json!({ "workspace": workspace, "viewer": viewer, "limit": limit, "before": before }),
430 )
431 .await?;
432 shaped(page, &snake)
433 }
434 _ => failed(FailureCode::Invalid, "Not a billing operation."),
435 }
436}
437
438#[cfg(test)]
439mod tests {
440 use super::*;
441
442 #[test]
443 fn a_usage_report_is_snake_case_all_the_way_down() {
444 let report = json!({
445 "from": "2026-10-01", "until": "2026-10-07",
446 "totals": { "priceMicros": 12_500_000, "discountMicros": 0, "includedMicros": 2_000_000, "creditsMicros": 500_000,
447 "chargedMicros": 10_000_000, "pendingMicros": 300_000, "costMicros": 9_000_000 },
448 "days": [
449 { "day": "2026-10-02", "product": "agent", "micros": 4_000_000 },
450 { "day": "2026-10-02", "product": "sandboxes", "micros": 500_000 },
451 { "day": "2026-10-01", "product": "agent", "micros": 8_000_000 },
452 ],
453 "products": [
454 { "key": "agent", "label": "Agent", "micros": 12_000_000, "features": [{ "key": "runs", "label": "Runs", "micros": 12_000_000, "count": 3 }],
455 "meters": [{ "key": "agent_models", "label": "Models", "product": "agent", "unit": "tokens", "quantity": 1.5e6,
456 "micros": 12_000_000, "pendingMicros": 0, "daily": [8_000_000, 4_000_000], "allowance": null,
457 "byProject": [{ "project": "acme/web", "micros": 9_000_000, "quantity": 1e6 },
458 { "project": "", "micros": 3_000_000, "quantity": 5e5 }] }] },
459 { "key": "sandboxes", "label": "Sandboxes", "micros": 500_000, "features": [],
460 "meters": [{ "key": "sandbox", "label": "Sandbox time", "product": "sandboxes", "unit": "seconds", "quantity": 600.0,
461 "micros": 500_000, "pendingMicros": 0, "daily": [0, 500_000],
462 "allowance": { "used": 600.0, "of": 3600.0, "unit": "seconds" },
463 "byProject": [{ "project": "acme/web", "micros": 500_000, "quantity": 600.0 }] }] },
464 ],
465 "projects": ["acme/web"], "included": null, "discountPercent": null,
466 "aiCreditMicros": 40_000_000, "creditMicros": 0, "trialMicros": null, "plan": "pro", "free": false,
467 });
468 let usage = usage_json(&report, None);
469 assert_eq!(usage["totals"]["charged_micros"], 10_000_000);
470 assert_eq!(usage["products"][0]["meters"][0]["by_project"][0]["project"], "acme/web");
471 assert_eq!(usage["products"][0]["meters"][0]["pending_micros"], 0);
472 assert_eq!(usage["ai_credit_micros"], 40_000_000);
473 assert!(usage.get("groups").is_none());
474 let text = usage.to_string();
475 for camel in ["Micros", "byProject", "discountPercent"] {
476 assert!(!text.contains(camel), "{camel} in {text}");
477 }
478
479 let by_project = usage_json(&report, Some("project"));
480 assert_eq!(by_project["group_by"], "project");
481 assert_eq!(
482 by_project["groups"],
483 json!([{ "key": "acme/web", "micros": 9_500_000 }, { "key": null, "micros": 3_000_000 }])
484 );
485 let by_day = usage_json(&report, Some("day"));
486 assert_eq!(by_day["groups"], json!([{ "key": "2026-10-01", "micros": 8_000_000 }, { "key": "2026-10-02", "micros": 4_500_000 }]));
487 let by_product = usage_json(&report, Some("product"));
488 assert_eq!(by_product["groups"][1], json!({ "key": "sandboxes", "label": "Sandboxes", "micros": 500_000 }));
489 }
490
491 #[test]
492 fn ai_credit_is_snake_case() {
493 let credit = json!({
494 "balanceMicros": 42_000_000, "purchasedMicros": 40_000_000, "givenMicros": 2_000_000,
495 "grants": [{ "id": "crd_1", "kind": "purchase", "amountMicros": 40_000_000, "usedMicros": 0, "leftMicros": 40_000_000, "expiresAt": null }],
496 "freeViaDiscount": false, "postpaid": false, "blocked": false, "canBuy": true,
497 "presetsCents": [2500, 5000], "minCents": 1000, "maxCents": 100_000,
498 "cardFee": { "on": true, "percentMicros": 29_000.0, "fixedCents": 30 },
499 "reload": { "enabled": false, "thresholdMicros": 0, "targetMicros": 0, "monthlyMaxMicros": 0, "reloadedMicros": 0, "failedAt": null, "error": null },
500 "agentRateMicros": 3.6, "modelMarkupPercent": 10, "gatewayMarkupPercent": 5, "upgradeCreditMicros": 0, "expiresDays": 365,
501 });
502 let out = snake(&credit);
503 assert_eq!(out["balance_micros"], 42_000_000);
504 assert_eq!(out["grants"][0]["left_micros"], 40_000_000);
505 assert_eq!(out["card_fee"]["fixed_cents"], 30);
506 assert_eq!(out["reload"]["monthly_max_micros"], 0);
507 assert_eq!(out["can_buy"], true);
508 assert!(out.get("balanceMicros").is_none());
509 assert_eq!(snake_key("line1"), "line1");
510 assert_eq!(snake_key("last4"), "last4");
511 assert_eq!(snake_key("taxIdType"), "tax_id_type");
512 }
513
514 #[test]
515 fn agents_and_workspace_tokens_never_change_billing() {
516 let person_user = User { username: "ana".into(), ..User::default() };
517 assert!(person(&Some(person_user.clone()), false).is_ok());
518 // A person's token used by an agent's run is still an agent's.
519 assert_eq!(person(&Some(person_user), true).unwrap_err().0, FailureCode::Forbidden);
520 let agent = User { username: "g1t".into(), kind: PrincipalKind::Agent, ..User::default() };
521 let (code, message) = person(&Some(agent), false).unwrap_err();
522 assert_eq!(code, FailureCode::Forbidden);
523 assert!(message.contains("agents never change billing"));
524 let workspace = User { username: "acme".into(), kind: PrincipalKind::Workspace, ..User::default() };
525 let (code, message) = person(&Some(workspace), false).unwrap_err();
526 assert_eq!(code, FailureCode::Forbidden);
527 assert!(message.contains("personal access token"));
528 assert_eq!(person(&None, false).unwrap_err().0, FailureCode::Unauthenticated);
529 }
530
531 #[test]
532 fn a_budget_change_keeps_what_was_not_given() {
533 let current = json!({
534 "workspace": "acme", "spendLimitMicros": 300_000_000, "defaultSpendLimit": false, "spentMicros": 12_000_000,
535 "availableMicros": 1_000_000_000, "alertLevels": [100, 75, 50], "pauseAtLimit": true,
536 "budgetWebhook": "https://acme.dev/hooks/budget", "state": "ok", "message": null,
537 });
538 let kept = budget_change(&json!({}), &current).unwrap();
539 assert_eq!(
540 kept,
541 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()) }
542 );
543 let changed = budget_change(&json!({ "amount_micros": null, "alerts": [90, 50, 90], "pause_at_limit": false, "webhook": null }), &current).unwrap();
544 assert_eq!(changed, BudgetChange { keep_limit: false, amount_micros: None, alerts: vec![50, 90], pause_at_limit: false, webhook: None });
545 for bad in [json!({ "alerts": [60] }), json!({ "alerts": "50" }), json!({ "amount_micros": -1 }), json!({ "webhook": "http://x" }), json!({ "pause_at_limit": "yes" })] {
546 assert!(budget_change(&bad, &current).is_err(), "{bad}");
547 }
548 // The automatic limit stays automatic when no amount is given.
549 let automatic = json!({ "spendLimitMicros": 200_000_000, "defaultSpendLimit": true });
550 assert_eq!(budget_change(&json!({}), &automatic).unwrap().amount_micros, None);
551 let budget = budget_json(&current);
552 assert_eq!(budget["amount_micros"], 300_000_000);
553 assert_eq!(budget["max_amount_micros"], 1_000_000_000);
554 assert_eq!(budget["alerts"], json!([100, 75, 50]));
555 assert_eq!(budget["automatic"], false);
556 }
557
558 #[test]
559 fn invoices_put_every_invoice_beside_the_itemised_usage_ones() {
560 let details = json!({
561 "customer": true, "email": "billing@acme.dev",
562 "invoices": [{ "id": "in_1", "number": "ACME-0001", "status": "paid", "totalCents": 2000, "currency": "usd",
563 "createdAt": "2026-10-01T00:00:00Z", "description": null, "hostedUrl": null, "pdfUrl": null }],
564 "upcoming": { "closesAt": "2026-11-01T00:00:00Z", "subscriptionsMicros": 20_000_000, "usageMicros": 5_000_000, "totalMicros": 25_000_000 },
565 "unavailable": null,
566 });
567 let usage = [json!({ "invoiceId": "inv_1", "workspace": "acme", "reason": "month", "period": "2026-09", "amountMicros": 5_000_000,
568 "status": "paid", "hostedUrl": null, "pdfUrl": null, "lines": [{ "description": "Agent", "amountMicros": 5_000_000 }],
569 "createdAt": "2026-10-01T00:00:00Z" })];
570 let out = invoices_json(&details, &usage);
571 assert_eq!(out["invoices"][0]["total_cents"], 2000);
572 assert_eq!(out["usage_invoices"][0]["id"], "inv_1");
573 assert!(out["usage_invoices"][0].get("invoice_id").is_none());
574 assert_eq!(out["usage_invoices"][0]["lines"][0]["amount_micros"], 5_000_000);
575 assert_eq!(out["upcoming"]["total_micros"], 25_000_000);
576 let shown = details_json(&details);
577 assert_eq!(shown["email"], "billing@acme.dev");
578 assert!(shown.get("invoices").is_none() && shown.get("upcoming").is_none());
579 }
580
581 const OPS: [Op; 8] = [
582 Op::GetUsage,
583 Op::GetBudget,
584 Op::SetBudget,
585 Op::GetAiCredit,
586 Op::BuyAiCredit,
587 Op::ListInvoices,
588 Op::GetBillingDetails,
589 Op::ListGatewayRequests,
590 ];
591
592 /// Billing belongs to a workspace, needs someone signed in, and is one
593 /// MCP tool whose writes no preset but full access reaches.
594 #[test]
595 fn billing_operations_name_a_workspace_and_agents_only_read() {
596 use crate::tools::{Gate, Tool};
597 use g1t_contracts::scopes::{Preset, TokenAccess, scope_for};
598 for op in OPS {
599 assert!(!op.needs_repo(), "{}", op.name());
600 assert!(op.needs_user(), "{}", op.name());
601 assert!(op.required().contains(&"workspace".to_owned()), "{}", op.name());
602 assert!(scope_for(op.name()).is_some(), "{}", op.name());
603 }
604 let tool = Tool::by_name("billing").unwrap();
605 let token = |preset: Preset| TokenAccess {
606 token_id: "tok_1".into(),
607 scopes: preset.scopes().map(|scopes| scopes.iter().map(|scope| scope.as_str().to_owned()).collect()),
608 legacy: false,
609 name: None,
610 };
611 for preset in [Preset::ReadOnly, Preset::Agent] {
612 let access = token(preset);
613 let seen: Vec<&str> = tool.visible(&Gate::Token(&access)).iter().map(|action| action.name).collect();
614 assert_eq!(seen, ["usage", "budget", "ai_credit", "invoices", "billing_details", "gateway_requests"], "{}", preset.as_str());
615 }
616 // The AI Gateway's log needs models:read, and nothing of billing's.
617 let models = TokenAccess { scopes: Some(vec!["models:read".into()]), ..token(Preset::Ci) };
618 let seen: Vec<&str> = tool.visible(&Gate::Token(&models)).iter().map(|action| action.name).collect();
619 assert_eq!(seen, ["gateway_requests"]);
620 let full = TokenAccess::full();
621 assert_eq!(tool.visible(&Gate::Token(&full)).len(), OPS.len());
622 }
623
624 #[test]
625 fn the_month_so_far_is_read_from_the_clock() {
626 // 2026-10-07T12:00:00Z.
627 assert_eq!(this_month(1_791_374_400_000.0), ("2026-10-01".to_owned(), "2026-10-07".to_owned()));
628 assert_eq!(this_month(0.0), ("1970-01-01".to_owned(), "1970-01-01".to_owned()));
629 // 2024-02-29.
630 assert_eq!(this_month(1_709_208_000_000.0), ("2024-02-01".to_owned(), "2024-02-29".to_owned()));
631 assert!(is_day("2026-10-07") && !is_day("2026-10-7") && !is_day("20261007xx"));
632 assert_eq!(list(&json!({ "products": "agent, sandboxes,," }), "products"), vec!["agent", "sandboxes"]);
633 assert_eq!(list(&json!({ "products": ["agent"] }), "products"), vec!["agent"]);
634 }
635}