g1t/services/billing/src/budget.rs

683 lines29,205 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.

Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays1//! What g1t pays for itself, and two caps on it.
2//!
3//! Every charge that settles (an agent run, sandbox time, a build) is
4//! split by what paid for it, at cost: a customer's real money, or g1t's.
5//! g1t's part goes to `g1t_spend` by day, bucket and billing account:
6//!
7//! - `comped`: work on a comped account (g1t's own, Flagon's), all of it.
8//! - `trial`, `oss`: the trial credit and the open-source pool.
9//! - `given`: a free workspace's overrun past its last bit of trial.
10//! - `unpaid`: charged, but with no real money behind it: Stripe's test
11//! key, or `FREE_WHILE_BUILDING`.
12//!
13//! The plan's included usage and on-demand charges with live payments are
14//! revenue, not g1t's. A workspace's own model provider costs g1t nothing.
15//!
16//! Two caps read it:
17//!
18//! 1. **A comped account's monthly budget**: `COMPED_MONTHLY_CEILING_MICROS`
19//! ($150), or the account's own limit in its terms (sudo, Accounts →
20//! Terms → Limit). Staff are emailed at 50, 75, 90 and 100%, once each a
21//! month; at 100% new work on it is refused until staff raise it or the
22//! month turns. Work already running finishes.
23//! 2. **The daily breaker**: when g1t's part across every workspace today
24//! (UTC) reaches `PLATFORM_DAILY_SPEND_CAP_MICROS` ($75), new agent runs
25//! on g1t's hosted models that g1t would pay for are paused for the rest
26//! of the day: everyone's except workspaces paying with real money on
27//! the plan or an enterprise contract. Staff are emailed at once and sudo
28//! shows a red bar; staff can lift it for the day.
29//!
30//! Zero for either variable turns that cap off. See
31//! docs/BILLING_OPERATIONS.md.
32
33use g1t_contracts::billing::{
34 AdminLiftBreakerArgs, BillingAccount, CompedBudget, ComputeKind, PlanKind, SpendBucket, SpendCaps, TermsKind,
35};
36use g1t_contracts::time::rfc3339;
37use g1t_contracts::{FailureCode, Outcome};
38use g1t_kit::now_ms;
39use serde::Deserialize;
40use worker::{Env, Result};
41
42use crate::Billing;
43use crate::credits::Drawn;
44use crate::limits::alert_level;
45
46/// The caps, from the billing service's variables.
47#[derive(Clone, Debug)]
48pub(crate) struct Caps {
49 /// `COMPED_MONTHLY_CEILING_MICROS`: a comped account's monthly budget
50 /// at cost, unless its terms set one. Zero: none.
51 pub comped_monthly: i64,
52 /// `PLATFORM_DAILY_SPEND_CAP_MICROS`: g1t's own spend a day before the
53 /// breaker trips. Zero: no breaker.
54 pub daily: i64,
55 /// `CLOUDFLARE_FIXED_MONTHLY_MICROS`: Cloudflare's subscriptions, an
56 /// estimate for sudo.
57 pub fixed_monthly: i64,
58 /// `COSTS_ALERT_EMAIL`. Empty: nothing is emailed.
59 pub alert_to: String,
60}
61
62impl Caps {
63 pub(crate) fn from_env(env: &Env) -> Self {
64 let number = |name: &str, default: i64| {
65 env.var(name).ok().and_then(|v| v.to_string().trim().parse::<i64>().ok()).unwrap_or(default).max(0)
66 };
67 Caps {
68 comped_monthly: number("COMPED_MONTHLY_CEILING_MICROS", 150_000_000),
69 daily: number("PLATFORM_DAILY_SPEND_CAP_MICROS", 75_000_000),
70 fixed_monthly: number("CLOUDFLARE_FIXED_MONTHLY_MICROS", 30_000_000),
71 alert_to: env.var("COSTS_ALERT_EMAIL").map(|v| v.to_string().trim().to_owned()).unwrap_or_default(),
72 }
73 }
74}
75
76/// g1t's part of one charge, at cost, by what paid for it.
77#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
78pub(crate) struct Share {
79 pub comped: i64,
80 pub trial: i64,
81 pub oss: i64,
82 pub given: i64,
83 pub unpaid: i64,
84}
85
86impl Share {
87 pub fn total(&self) -> i64 {
88 self.comped + self.trial + self.oss + self.given + self.unpaid
89 }
90
91 pub fn parts(&self) -> [(&'static str, i64); 5] {
92 [("comped", self.comped), ("trial", self.trial), ("oss", self.oss), ("given", self.given), ("unpaid", self.unpaid)]
93 }
94}
95
96/// What of a charge costing g1t `cost` g1t paid itself. `charged` is what
97/// the workspace was charged after `drawn` paid its part (both at price);
98/// `real_money` is whether payments are live. The plan's included usage
99/// and what the workspace is charged are revenue only with real money.
100pub(crate) fn share(cost: i64, charged: i64, drawn: &Drawn, comped: bool, real_money: bool) -> Share {
101 if cost <= 0 {
102 return Share::default();
103 }
104 if comped {
105 return Share { comped: cost, ..Share::default() };
106 }
107 let gross = charged.max(0) + drawn.total();
108 if gross <= 0 {
109 // Charged nothing at all (free while g1t is being built out).
110 return Share { unpaid: cost, ..Share::default() };
111 }
112 let part = |paid: i64| (i128::from(cost) * i128::from(paid.max(0)) / i128::from(gross)) as i64;
113 let (trial, oss, given) = (part(drawn.trial), part(drawn.oss), part(drawn.given));
114 let unpaid = if real_money { 0 } else { (cost - trial - oss - given).max(0) };
115 Share { comped: 0, trial, oss, given, unpaid }
116}
117
118/// A comped account's monthly budget: its own (terms' limit) or the
119/// default; and whether it is the default. Zero: none.
120pub(crate) fn comped_ceiling(own: Option<i64>, default: i64) -> (i64, bool) {
121 match own {
122 Some(own) => (own.max(0), false),
123 None => (default.max(0), true),
124 }
125}
126
127/// Whether a budget is used up.
128pub(crate) fn used_up(used: i64, ceiling: i64) -> bool {
129 ceiling > 0 && used >= ceiling
130}
131
132/// Whether the breaker stops new runs: on, reached, and not lifted today.
133pub(crate) fn breaker_open(today: i64, cap: i64, lifted: bool) -> bool {
134 cap > 0 && today >= cap && !lifted
135}
136
137/// Whether the breaker is about this start: an agent run on g1t's hosted
138/// models (an agent run that does not say is taken to be one).
139pub(crate) fn breaker_applies(kind: ComputeKind, hosted_model: Option<bool>) -> bool {
140 kind == ComputeKind::Agent && hosted_model.unwrap_or(true)
141}
142
143/// Whether a workspace's spend is covered by revenue, so the breaker
144/// leaves it alone: live payments, not comped, and on the plan it pays for
145/// (not given it by staff) or an enterprise contract.
146pub(crate) fn covered_by_revenue(plan: PlanKind, comped: bool, plan_given: bool, live: bool) -> bool {
147 live && !comped && match plan {
148 PlanKind::Enterprise => true,
149 PlanKind::Paid => !plan_given,
150 _ => false,
151 }
152}
153
154/// The alert to send now: the level reached, if higher than any sent this
155/// month.
156pub(crate) fn alert_to_send(level: u32, sent: u32) -> Option<u32> {
157 (level > 0 && level > sent).then_some(level)
158}
159
160/// `$150.00`: whole cents.
161pub(crate) fn cents(micros: i64) -> String {
162 let cents = (micros as f64 / 10_000.0).round() as i64;
163 format!("{}${}.{:02}", if cents < 0 { "-" } else { "" }, cents.abs() / 100, cents.abs() % 100)
164}
165
166/// What a start on a comped account past its budget is told. Staff-only:
167/// only comped (g1t's own) accounts see it.
168pub(crate) fn comped_refusal(name: &str, used: i64, ceiling: i64) -> String {
169 format!(
170 "{name}'s monthly budget for g1t's own agents is used up ({} of {} this month at cost), so new runs wait. Staff can raise it in sudo: Accounts, {name}, Terms, Limit.",
171 cents(used),
172 cents(ceiling)
173 )
174}
175
176/// What a hosted-model start is told while the breaker is open.
177pub(crate) fn breaker_refusal(today: i64, cap: i64) -> String {
178 format!(
179 "g1t's daily spend breaker is open: g1t has paid {} of its {} a day for work today, so new agent runs on g1t's hosted models wait until 00:00 UTC. Agents on the workspace's own model provider still run, and so does work on the paid plan.",
180 cents(today),
181 cents(cap)
182 )
183}
184
185#[derive(Deserialize)]
186struct Sum {
187 micros: Option<i64>,
188}
189
190#[derive(Deserialize)]
191struct BreakerRow {
192 tripped_at: Option<String>,
193 told_at: Option<String>,
194 lifted_by: Option<String>,
195 lifted_at: Option<String>,
196 lift_note: Option<String>,
197}
198
199fn today() -> String {
200 rfc3339(now_ms())[..10].to_owned()
201}
202
203impl Billing {
204 fn live(&self) -> bool {
205 self.stripe.as_ref().is_some_and(crate::stripe::Stripe::live)
206 }
207
208 /// Counts g1t's part of a charge that just settled, and trips the
209 /// breaker if today reached its cap. Never fails the charge: a problem
210 /// here is logged.
211 pub(crate) async fn count_spend(&self, workspace: &str, cost: i64, charged: i64, drawn: &Drawn) {
212 if let Err(error) = self.try_count_spend(workspace, cost, charged, drawn).await {
213 worker::console_error!("could not count g1t's spend for {workspace}: {error}");
214 }
215 }
216
217 async fn try_count_spend(&self, workspace: &str, cost: i64, charged: i64, drawn: &Drawn) -> Result<()> {
218 if cost <= 0 {
219 return Ok(());
220 }
221 let account = self.account_of(workspace).await?;
222 let paid = share(cost, charged, drawn, account.terms.kind == TermsKind::Comped, self.live());
223 if paid.total() == 0 {
224 return Ok(());
225 }
226 let day = today();
227 let mut writes = vec![];
228 for (bucket, micros) in paid.parts() {
229 if micros > 0 {
230 writes.push(
231 self.db
232 .prepare(
233 "INSERT INTO g1t_spend (day, bucket, account, micros) VALUES (?1, ?2, ?3, ?4)
234 ON CONFLICT (day, bucket, account) DO UPDATE SET micros = micros + ?4",
235 )
236 .bind(&[day.as_str().into(), bucket.into(), account.id.as_str().into(), (micros as f64).into()])?,
237 );
238 }
239 }
240 self.db.batch(writes).await?;
241 if self.caps.daily <= 0 {
242 return Ok(());
243 }
244 let total = self.spent_on(&day).await?;
245 if total < self.caps.daily {
246 return Ok(());
247 }
248 // Tripped: recorded once a day, and staff told at once.
249 let now = rfc3339(now_ms());
250 let tripped = self
251 .db
252 .prepare(
253 "INSERT INTO spend_breaker (day, tripped_at, tripped_micros) VALUES (?1, ?2, ?3)
254 ON CONFLICT (day) DO UPDATE SET tripped_at = ?2, tripped_micros = ?3 WHERE spend_breaker.tripped_at IS NULL
255 RETURNING day",
256 )
257 .bind(&[day.as_str().into(), now.as_str().into(), (total as f64).into()])?
258 .first::<serde_json::Value>(None)
259 .await?;
260 if tripped.is_some() {
261 self.tell_breaker(&day, total).await?;
262 }
263 Ok(())
264 }
265
266 /// g1t's own spend on `day`, across every workspace.
267 async fn spent_on(&self, day: &str) -> Result<i64> {
268 Ok(self
269 .db
270 .prepare("SELECT SUM(micros) AS micros FROM g1t_spend WHERE day = ?")
271 .bind(&[day.into()])?
272 .first::<Sum>(None)
273 .await?
274 .and_then(|s| s.micros)
275 .unwrap_or(0))
276 }
277
278 async fn breaker_row(&self, day: &str) -> Result<Option<BreakerRow>> {
279 self.db
280 .prepare("SELECT tripped_at, told_at, lifted_by, lifted_at, lift_note FROM spend_breaker WHERE day = ?")
281 .bind(&[day.into()])?
282 .first::<BreakerRow>(None)
283 .await
284 }
285
286 /// Emails staff that the breaker tripped, and notes it was told.
287 async fn tell_breaker(&self, day: &str, total: i64) -> Result<()> {
288 if self.caps.alert_to.is_empty() {
289 return Ok(());
290 }
291 let lifted = self.breaker_row(day).await?.and_then(|row| row.lifted_by);
292 let mut lines = vec![
293 format!(
294 "g1t paid {} for work today ({day}, UTC), its daily cap of {} (PLATFORM_DAILY_SPEND_CAP_MICROS). New agent runs on g1t's hosted models that g1t pays for are paused until 00:00 UTC; workspaces paying with real money, and agents on their own model provider, are not affected. Runs already going finish.",
295 cents(total),
296 cents(self.caps.daily)
297 ),
298 "To let them start again today: sudo, Costs & margin, Lift for today. To change the cap: PLATFORM_DAILY_SPEND_CAP_MICROS in services/billing/wrangler.jsonc.".to_owned(),
299 ];
300 if let Some(by) = lifted {
301 lines.insert(1, format!("{by} had already lifted it for today, so nothing is paused."));
302 }
303 match crate::margin::email_staff(&self.env, &self.caps.alert_to, &format!("g1t: the daily spend breaker tripped at {}", cents(total)), &lines).await {
304 Ok(()) => {
305 self.db
306 .prepare("UPDATE spend_breaker SET told_at = ? WHERE day = ?")
307 .bind(&[rfc3339(now_ms()).into(), day.into()])?
308 .run()
309 .await?;
310 }
311 Err(error) => worker::console_error!("could not email the breaker: {error}"),
312 }
313 Ok(())
314 }
315
316 /// Why a start is refused by the breaker, if it is.
317 pub(crate) async fn breaker_refuses(
318 &self,
319 plan: PlanKind,
320 account: &BillingAccount,
321 kind: ComputeKind,
322 hosted_model: Option<bool>,
323 ) -> Result<Option<String>> {
324 if self.caps.daily <= 0 || !breaker_applies(kind, hosted_model) {
325 return Ok(None);
326 }
327 let comped = account.terms.kind == TermsKind::Comped;
328 if covered_by_revenue(plan, comped, account.allowances.plan, self.live()) {
329 return Ok(None);
330 }
331 let day = today();
332 let spent = self.spent_on(&day).await?;
333 if spent < self.caps.daily {
334 return Ok(None);
335 }
336 let lifted = self.breaker_row(&day).await?.is_some_and(|row| row.lifted_at.is_some());
337 Ok(breaker_open(spent, self.caps.daily, lifted).then(|| breaker_refusal(spent, self.caps.daily)))
338 }
339
340 /// A comped account's budget this month.
341 pub(crate) async fn comped_budget(&self, account: &BillingAccount) -> Result<CompedBudget> {
342 let month = &rfc3339(now_ms())[..7];
343 let used = self
344 .db
345 .prepare("SELECT SUM(micros) AS micros FROM g1t_spend WHERE account = ? AND bucket = 'comped' AND day >= ?")
346 .bind(&[account.id.as_str().into(), format!("{month}-01").into()])?
347 .first::<Sum>(None)
348 .await?
349 .and_then(|s| s.micros)
350 .unwrap_or(0);
351 let (ceiling, default_ceiling) = comped_ceiling(account.terms.ceiling_micros, self.caps.comped_monthly);
352 Ok(CompedBudget {
353 account: account.id.clone(),
354 name: account.name.clone(),
355 used_micros: used,
356 ceiling_micros: ceiling,
357 default_ceiling,
358 level: alert_level(used, ceiling),
359 })
360 }
361
362 /// Why new work on a comped account is refused, if its budget is used
363 /// up. None for every other account.
364 pub(crate) async fn comped_stop(&self, account: &BillingAccount) -> Result<Option<String>> {
365 if account.terms.kind != TermsKind::Comped {
366 return Ok(None);
367 }
368 let budget = self.comped_budget(account).await?;
369 Ok(used_up(budget.used_micros, budget.ceiling_micros).then(|| comped_refusal(&account.name, budget.used_micros, budget.ceiling_micros)))
370 }
371
372 /// Every 15 minutes: comped budgets' alerts, once each level a month,
373 /// and a tripped breaker staff were not yet told about.
374 pub(crate) async fn watch_spend(&self) -> Result<()> {
375 if self.caps.alert_to.is_empty() {
376 return Ok(());
377 }
378 let month = rfc3339(now_ms())[..7].to_owned();
379 #[derive(Deserialize)]
380 struct Id {
381 id: String,
382 }
383 let comped = self
384 .db
385 .prepare("SELECT id FROM billing_accounts WHERE terms_kind = 'comped'")
386 .all()
387 .await?
388 .results::<Id>()?;
389 #[derive(Deserialize)]
390 struct Sent {
391 level: Option<i64>,
392 }
393 for Id { id } in comped {
394 let Some(account) = self.find_account(&id).await? else { continue };
395 let budget = self.comped_budget(&account).await?;
396 let sent = self
397 .db
398 .prepare("SELECT MAX(level) AS level FROM budget_alerts WHERE account = ? AND month = ?")
399 .bind(&[id.as_str().into(), month.as_str().into()])?
400 .first::<Sent>(None)
401 .await?
402 .and_then(|s| s.level)
403 .unwrap_or(0);
404 let Some(level) = alert_to_send(budget.level, u32::try_from(sent).unwrap_or(0)) else { continue };
405 let name = &account.name;
406 let mut lines = vec![format!(
407 "{name}'s work has cost g1t {} this month, {level}% of its {} monthly budget ({}).",
408 cents(budget.used_micros),
409 cents(budget.ceiling_micros),
410 if budget.default_ceiling { "COMPED_MONTHLY_CEILING_MICROS" } else { "its own limit, in its terms" }
411 )];
412 lines.push(if level >= 100 {
413 format!("New agent runs, checks and builds on {name} are refused until the budget is raised or the month turns. Runs already going finish. To raise it: sudo, Accounts, {name}, Terms, Limit.")
414 } else {
415 format!("At 100%, new work on {name} is refused until staff raise the budget. To raise it now: sudo, Accounts, {name}, Terms, Limit.")
416 });
417 let subject = format!("g1t: {name} has used {level}% of its monthly budget");
418 match crate::margin::email_staff(&self.env, &self.caps.alert_to, &subject, &lines).await {
419 Ok(()) => {
420 self.db
421 .prepare("INSERT OR IGNORE INTO budget_alerts (account, month, level, sent_at) VALUES (?, ?, ?, ?)")
422 .bind(&[id.as_str().into(), month.as_str().into(), level.into(), rfc3339(now_ms()).into()])?
423 .run()
424 .await?;
425 }
426 Err(error) => worker::console_error!("could not email {name}'s budget alert: {error}"),
427 }
428 }
429 // A trip whose email did not go out when it happened.
430 let day = today();
431 if self.breaker_row(&day).await?.is_some_and(|row| row.tripped_at.is_some() && row.told_at.is_none()) {
432 let total = self.spent_on(&day).await?;
433 self.tell_breaker(&day, total).await?;
434 }
435 Ok(())
436 }
437
438 /// `admin_spend_caps`: g1t's own spend against its caps.
439 pub(crate) async fn spend_caps(&self) -> Result<SpendCaps> {
440 let day = today();
441 let month = day[..7].to_owned();
442 let month_start = format!("{month}-01");
443 let today_micros = self.spent_on(&day).await?;
444 let row = self.breaker_row(&day).await?;
445 let lifted = row.as_ref().is_some_and(|r| r.lifted_at.is_some());
446 #[derive(Deserialize)]
447 struct Bucket {
448 bucket: String,
449 micros: Option<i64>,
450 }
451 let rows = self
452 .db
453 .prepare("SELECT bucket, SUM(micros) AS micros FROM g1t_spend WHERE day >= ? GROUP BY bucket")
454 .bind(&[month_start.as_str().into()])?
455 .all()
456 .await?
457 .results::<Bucket>()?;
458 let month_buckets = ["comped", "trial", "oss", "given", "unpaid"]
459 .iter()
460 .map(|bucket| SpendBucket {
461 bucket: (*bucket).to_owned(),
462 title: bucket_title(bucket).to_owned(),
463 micros: rows.iter().find(|r| r.bucket == *bucket).and_then(|r| r.micros).unwrap_or(0),
464 })
465 .collect();
466 #[derive(Deserialize)]
467 struct Id {
468 id: String,
469 }
470 let ids = self
471 .db
472 .prepare("SELECT id FROM billing_accounts WHERE terms_kind = 'comped' ORDER BY id")
473 .all()
474 .await?
475 .results::<Id>()?;
476 let mut comped = vec![];
477 for Id { id } in ids {
478 if let Some(account) = self.find_account(&id).await? {
479 comped.push(self.comped_budget(&account).await?);
480 }
481 }
482 // Free workspaces' share of the reconciled costs that are not on
483 // the ledger (models, sandboxes and builds are, above).
484 let free_tier_micros = self
485 .db
486 .prepare(format!(
487 "SELECT SUM(cost_micros) AS micros FROM workspace_costs
488 WHERE day >= ?1 AND bucket NOT IN ('models', 'sandboxes', 'deployments')
489 AND workspace NOT IN ({internal})
490 AND workspace NOT IN (SELECT workspace FROM workspace_costs WHERE day >= ?1 GROUP BY workspace HAVING SUM(revenue_micros) > 0)",
491 internal = crate::sales::INTERNAL_SQL
492 ))
493 .bind(&[month_start.as_str().into()])?
494 .first::<Sum>(None)
495 .await?
496 .and_then(|s| s.micros)
497 .unwrap_or(0);
498 let revenue_micros = self
499 .db
500 .prepare("SELECT SUM(cash_micros) AS micros FROM margin_days WHERE day >= ?")
501 .bind(&[month_start.as_str().into()])?
502 .first::<Sum>(None)
503 .await?
504 .and_then(|s| s.micros)
505 .unwrap_or(0);
Costs: Cloudflare's subscriptions read from Cloudflare each day, the estimate only until then; sudo's costs split into Costs & margin and Bill & pricing506 let fixed = self.fixed_monthly(self.caps.fixed_monthly).await?;
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays507 Ok(SpendCaps {
508 day,
509 month,
510 today_micros,
511 daily_cap_micros: self.caps.daily,
512 tripped: breaker_open(today_micros, self.caps.daily, lifted),
513 tripped_at: row.as_ref().and_then(|r| r.tripped_at.clone()),
514 lifted_by: row.as_ref().and_then(|r| r.lifted_by.clone()),
515 lifted_at: row.as_ref().and_then(|r| r.lifted_at.clone()),
516 lift_note: row.as_ref().and_then(|r| r.lift_note.clone()),
517 month_buckets,
518 comped,
519 free_tier_micros,
Costs: Cloudflare's subscriptions read from Cloudflare each day, the estimate only until then; sudo's costs split into Costs & margin and Bill & pricing520 fixed_monthly_micros: fixed.monthly_micros,
521 fixed_source: fixed.source.into(),
522 fixed_read_at: fixed.read_at,
523 fixed_items: fixed.items,
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays524 revenue_micros,
525 })
526 }
527
528 /// `admin_lift_breaker`: hosted-model runs start again for the rest of
529 /// today (UTC).
530 pub(crate) async fn admin_lift_breaker(&self, a: AdminLiftBreakerArgs) -> Result<Outcome<SpendCaps>> {
531 let (by, note) = (a.by.trim(), a.note.trim());
532 if by.is_empty() || note.len() < 5 {
533 return Ok(Outcome::fail(FailureCode::Invalid, "Say who is lifting it, and why, in the note."));
534 }
535 let day = today();
536 let now = rfc3339(now_ms());
537 let note: String = note.chars().take(500).collect();
538 self.db
539 .prepare(
540 "INSERT INTO spend_breaker (day, lifted_by, lifted_at, lift_note) VALUES (?1, ?2, ?3, ?4)
541 ON CONFLICT (day) DO UPDATE SET lifted_by = ?2, lifted_at = ?3, lift_note = ?4",
542 )
543 .bind(&[day.as_str().into(), by.into(), now.as_str().into(), note.as_str().into()])?
544 .run()
545 .await?;
546 let spent = self.spent_on(&day).await?;
547 self.audit("costs", "breaker_lifted", &format!("{day}: lifted at {} of {}: {note}", cents(spent), cents(self.caps.daily)), by)
548 .await?;
549 Ok(Outcome::Ok(self.spend_caps().await?))
550 }
551}
552
553/// How sudo names a bucket of g1t's own spend.
554pub(crate) fn bucket_title(bucket: &str) -> &'static str {
555 match bucket {
556 "comped" => "Comped (g1t's own)",
557 "trial" => "Trial pool",
558 "oss" => "Open-source pool",
559 "given" => "Free overruns g1t covered",
560 "unpaid" => "Charged without real money",
561 _ => "Other",
562 }
563}
564
565#[cfg(test)]
566mod tests {
567 use super::*;
568
569 fn drawn(credit: i64, trial: i64, oss: i64, given: i64) -> Drawn {
570 Drawn { credit, trial, oss, given }
571 }
572
573 #[test]
574 fn comped_work_is_all_g1ts_at_cost() {
575 let s = share(1_000_000, 0, &Drawn::default(), true, true);
576 assert_eq!(s, Share { comped: 1_000_000, ..Share::default() });
577 // Nothing that cost nothing is counted.
578 assert_eq!(share(0, 0, &Drawn::default(), true, true).total(), 0);
579 assert_eq!(share(-5, 0, &Drawn::default(), false, false).total(), 0);
580 }
581
582 #[test]
583 fn pools_pay_their_share_of_the_cost_not_the_price() {
584 // $1 of cost charged at $1.20, all from the trial: $1 is g1t's.
585 assert_eq!(share(1_000_000, 0, &drawn(0, 1_200_000, 0, 0), false, true), Share { trial: 1_000_000, ..Share::default() });
586 // Half the open-source pool, half charged on a live card: half is g1t's.
587 assert_eq!(share(1_000_000, 600_000, &drawn(0, 0, 600_000, 0), false, true), Share { oss: 500_000, ..Share::default() });
588 // A free workspace's overrun past its trial.
589 let s = share(1_000_000, 0, &drawn(0, 300_000, 0, 900_000), false, true);
590 assert_eq!((s.trial, s.given), (250_000, 750_000));
591 }
592
593 #[test]
594 fn revenue_is_only_revenue_with_real_money() {
595 // Plan credit and an on-demand charge, live: none of it is g1t's.
596 assert_eq!(share(1_000_000, 600_000, &drawn(600_000, 0, 0, 0), false, true).total(), 0);
597 // The same in test mode: all of it.
598 assert_eq!(share(1_000_000, 600_000, &drawn(600_000, 0, 0, 0), false, false), Share { unpaid: 1_000_000, ..Share::default() });
599 // Free while building: charged nothing, all g1t's.
600 assert_eq!(share(1_000_000, 0, &Drawn::default(), false, true), Share { unpaid: 1_000_000, ..Share::default() });
601 }
602
603 #[test]
604 fn a_comped_account_gets_the_default_budget_unless_its_terms_set_one() {
605 assert_eq!(comped_ceiling(None, 150_000_000), (150_000_000, true));
606 assert_eq!(comped_ceiling(Some(400_000_000), 150_000_000), (400_000_000, false));
607 // Zero: no budget.
608 assert_eq!(comped_ceiling(None, 0), (0, true));
609 assert!(!used_up(1_000_000_000, 0));
610 }
611
612 #[test]
613 fn a_comped_budget_refuses_new_work_at_one_hundred_percent() {
614 let ceiling = 150_000_000;
615 assert!(!used_up(149_999_999, ceiling));
616 assert!(used_up(150_000_000, ceiling));
617 assert!(used_up(151_000_000, ceiling));
618 let message = comped_refusal("flagon-io", 150_000_000, ceiling);
619 assert!(message.contains("used up") && message.contains("$150.00 of $150.00") && message.contains("sudo"), "{message}");
620 }
621
622 #[test]
623 fn budget_alerts_go_once_per_level_each_month() {
624 let ceiling = 150_000_000;
625 let mut sent = 0;
626 let mut emailed = vec![];
627 // Spend climbs through the month, checked every 15 minutes.
628 for used in [10_000_000, 74_000_000, 75_000_000, 80_000_000, 112_500_000, 120_000_000, 135_000_000, 140_000_000, 150_000_000, 170_000_000] {
629 if let Some(level) = alert_to_send(alert_level(used, ceiling), sent) {
630 emailed.push(level);
631 sent = level;
632 }
633 }
634 assert_eq!(emailed, vec![50, 75, 90, 100]);
635 // A jump straight past several levels sends only the highest.
636 assert_eq!(alert_to_send(alert_level(140_000_000, ceiling), 0), Some(90));
637 // A new month starts from nothing sent.
638 assert_eq!(alert_to_send(alert_level(80_000_000, ceiling), 0), Some(50));
639 }
640
641 #[test]
642 fn the_breaker_trips_at_the_cap_and_staff_can_lift_it_for_the_day() {
643 let cap = 75_000_000;
644 assert!(!breaker_open(74_999_999, cap, false));
645 assert!(breaker_open(75_000_000, cap, false));
646 // Lifted: open no more today.
647 assert!(!breaker_open(90_000_000, cap, true));
648 // Off.
649 assert!(!breaker_open(1_000_000_000, 0, false));
650 // Tomorrow's total starts at zero: the breaker resets by itself.
651 assert!(!breaker_open(0, cap, false));
652 let message = breaker_refusal(80_000_000, cap);
653 assert!(message.contains("$80.00 of its $75.00") && message.contains("00:00 UTC"), "{message}");
654 }
655
656 #[test]
657 fn the_breaker_is_about_hosted_model_agent_runs() {
658 assert!(breaker_applies(ComputeKind::Agent, Some(true)));
659 assert!(breaker_applies(ComputeKind::Agent, None));
660 assert!(!breaker_applies(ComputeKind::Agent, Some(false)));
661 assert!(!breaker_applies(ComputeKind::Check, None));
662 assert!(!breaker_applies(ComputeKind::Workflow, Some(true)));
663 }
664
665 #[test]
666 fn workspaces_paying_with_real_money_are_never_paused_by_the_breaker() {
667 assert!(covered_by_revenue(PlanKind::Paid, false, false, true));
668 assert!(covered_by_revenue(PlanKind::Enterprise, false, false, true));
669 // Test-mode payments are not money.
670 assert!(!covered_by_revenue(PlanKind::Paid, false, false, false));
671 // The plan given by staff, comped, free: g1t pays.
672 assert!(!covered_by_revenue(PlanKind::Paid, false, true, true));
673 assert!(!covered_by_revenue(PlanKind::Internal, true, false, true));
674 assert!(!covered_by_revenue(PlanKind::Free, false, false, true));
675 }
676
677 #[test]
678 fn amounts_read_in_cents() {
679 assert_eq!(cents(150_000_000), "$150.00");
680 assert_eq!(cents(1_234_567), "$1.23");
681 assert_eq!(cents(5_000), "$0.01");
682 }
683}