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 pays | 1 | //! 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 | ||
| 33 | use g1t_contracts::billing::{ | |
| 34 | AdminLiftBreakerArgs, BillingAccount, CompedBudget, ComputeKind, PlanKind, SpendBucket, SpendCaps, TermsKind, | |
| 35 | }; | |
| 36 | use g1t_contracts::time::rfc3339; | |
| 37 | use g1t_contracts::{FailureCode, Outcome}; | |
| 38 | use g1t_kit::now_ms; | |
| 39 | use serde::Deserialize; | |
| 40 | use worker::{Env, Result}; | |
| 41 | ||
| 42 | use crate::Billing; | |
| 43 | use crate::credits::Drawn; | |
| 44 | use crate::limits::alert_level; | |
| 45 | ||
| 46 | /// The caps, from the billing service's variables. | |
| 47 | #[derive(Clone, Debug)] | |
| 48 | pub(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 | ||
| 62 | impl 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)] | |
| 78 | pub(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 | ||
| 86 | impl 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. | |
| 100 | pub(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. | |
| 120 | pub(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. | |
| 128 | pub(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. | |
| 133 | pub(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). | |
| 139 | pub(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. | |
| 146 | pub(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. | |
| 156 | pub(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. | |
| 161 | pub(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. | |
| 168 | pub(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. | |
| 177 | pub(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)] | |
| 186 | struct Sum { | |
| 187 | micros: Option<i64>, | |
| 188 | } | |
| 189 | ||
| 190 | #[derive(Deserialize)] | |
| 191 | struct 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 | ||
| 199 | fn today() -> String { | |
| 200 | rfc3339(now_ms())[..10].to_owned() | |
| 201 | } | |
| 202 | ||
| 203 | impl 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); | |
| 506 | Ok(SpendCaps { | |
| 507 | day, | |
| 508 | month, | |
| 509 | today_micros, | |
| 510 | daily_cap_micros: self.caps.daily, | |
| 511 | tripped: breaker_open(today_micros, self.caps.daily, lifted), | |
| 512 | tripped_at: row.as_ref().and_then(|r| r.tripped_at.clone()), | |
| 513 | lifted_by: row.as_ref().and_then(|r| r.lifted_by.clone()), | |
| 514 | lifted_at: row.as_ref().and_then(|r| r.lifted_at.clone()), | |
| 515 | lift_note: row.as_ref().and_then(|r| r.lift_note.clone()), | |
| 516 | month_buckets, | |
| 517 | comped, | |
| 518 | free_tier_micros, | |
| 519 | fixed_monthly_micros: self.caps.fixed_monthly, | |
| 520 | revenue_micros, | |
| 521 | }) | |
| 522 | } | |
| 523 | ||
| 524 | /// `admin_lift_breaker`: hosted-model runs start again for the rest of | |
| 525 | /// today (UTC). | |
| 526 | pub(crate) async fn admin_lift_breaker(&self, a: AdminLiftBreakerArgs) -> Result<Outcome<SpendCaps>> { | |
| 527 | let (by, note) = (a.by.trim(), a.note.trim()); | |
| 528 | if by.is_empty() || note.len() < 5 { | |
| 529 | return Ok(Outcome::fail(FailureCode::Invalid, "Say who is lifting it, and why, in the note.")); | |
| 530 | } | |
| 531 | let day = today(); | |
| 532 | let now = rfc3339(now_ms()); | |
| 533 | let note: String = note.chars().take(500).collect(); | |
| 534 | self.db | |
| 535 | .prepare( | |
| 536 | "INSERT INTO spend_breaker (day, lifted_by, lifted_at, lift_note) VALUES (?1, ?2, ?3, ?4) | |
| 537 | ON CONFLICT (day) DO UPDATE SET lifted_by = ?2, lifted_at = ?3, lift_note = ?4", | |
| 538 | ) | |
| 539 | .bind(&[day.as_str().into(), by.into(), now.as_str().into(), note.as_str().into()])? | |
| 540 | .run() | |
| 541 | .await?; | |
| 542 | let spent = self.spent_on(&day).await?; | |
| 543 | self.audit("costs", "breaker_lifted", &format!("{day}: lifted at {} of {}: {note}", cents(spent), cents(self.caps.daily)), by) | |
| 544 | .await?; | |
| 545 | Ok(Outcome::Ok(self.spend_caps().await?)) | |
| 546 | } | |
| 547 | } | |
| 548 | ||
| 549 | /// How sudo names a bucket of g1t's own spend. | |
| 550 | pub(crate) fn bucket_title(bucket: &str) -> &'static str { | |
| 551 | match bucket { | |
| 552 | "comped" => "Comped (g1t's own)", | |
| 553 | "trial" => "Trial pool", | |
| 554 | "oss" => "Open-source pool", | |
| 555 | "given" => "Free overruns g1t covered", | |
| 556 | "unpaid" => "Charged without real money", | |
| 557 | _ => "Other", | |
| 558 | } | |
| 559 | } | |
| 560 | ||
| 561 | #[cfg(test)] | |
| 562 | mod tests { | |
| 563 | use super::*; | |
| 564 | ||
| 565 | fn drawn(credit: i64, trial: i64, oss: i64, given: i64) -> Drawn { | |
| 566 | Drawn { credit, trial, oss, given } | |
| 567 | } | |
| 568 | ||
| 569 | #[test] | |
| 570 | fn comped_work_is_all_g1ts_at_cost() { | |
| 571 | let s = share(1_000_000, 0, &Drawn::default(), true, true); | |
| 572 | assert_eq!(s, Share { comped: 1_000_000, ..Share::default() }); | |
| 573 | // Nothing that cost nothing is counted. | |
| 574 | assert_eq!(share(0, 0, &Drawn::default(), true, true).total(), 0); | |
| 575 | assert_eq!(share(-5, 0, &Drawn::default(), false, false).total(), 0); | |
| 576 | } | |
| 577 | ||
| 578 | #[test] | |
| 579 | fn pools_pay_their_share_of_the_cost_not_the_price() { | |
| 580 | // $1 of cost charged at $1.20, all from the trial: $1 is g1t's. | |
| 581 | assert_eq!(share(1_000_000, 0, &drawn(0, 1_200_000, 0, 0), false, true), Share { trial: 1_000_000, ..Share::default() }); | |
| 582 | // Half the open-source pool, half charged on a live card: half is g1t's. | |
| 583 | assert_eq!(share(1_000_000, 600_000, &drawn(0, 0, 600_000, 0), false, true), Share { oss: 500_000, ..Share::default() }); | |
| 584 | // A free workspace's overrun past its trial. | |
| 585 | let s = share(1_000_000, 0, &drawn(0, 300_000, 0, 900_000), false, true); | |
| 586 | assert_eq!((s.trial, s.given), (250_000, 750_000)); | |
| 587 | } | |
| 588 | ||
| 589 | #[test] | |
| 590 | fn revenue_is_only_revenue_with_real_money() { | |
| 591 | // Plan credit and an on-demand charge, live: none of it is g1t's. | |
| 592 | assert_eq!(share(1_000_000, 600_000, &drawn(600_000, 0, 0, 0), false, true).total(), 0); | |
| 593 | // The same in test mode: all of it. | |
| 594 | assert_eq!(share(1_000_000, 600_000, &drawn(600_000, 0, 0, 0), false, false), Share { unpaid: 1_000_000, ..Share::default() }); | |
| 595 | // Free while building: charged nothing, all g1t's. | |
| 596 | assert_eq!(share(1_000_000, 0, &Drawn::default(), false, true), Share { unpaid: 1_000_000, ..Share::default() }); | |
| 597 | } | |
| 598 | ||
| 599 | #[test] | |
| 600 | fn a_comped_account_gets_the_default_budget_unless_its_terms_set_one() { | |
| 601 | assert_eq!(comped_ceiling(None, 150_000_000), (150_000_000, true)); | |
| 602 | assert_eq!(comped_ceiling(Some(400_000_000), 150_000_000), (400_000_000, false)); | |
| 603 | // Zero: no budget. | |
| 604 | assert_eq!(comped_ceiling(None, 0), (0, true)); | |
| 605 | assert!(!used_up(1_000_000_000, 0)); | |
| 606 | } | |
| 607 | ||
| 608 | #[test] | |
| 609 | fn a_comped_budget_refuses_new_work_at_one_hundred_percent() { | |
| 610 | let ceiling = 150_000_000; | |
| 611 | assert!(!used_up(149_999_999, ceiling)); | |
| 612 | assert!(used_up(150_000_000, ceiling)); | |
| 613 | assert!(used_up(151_000_000, ceiling)); | |
| 614 | let message = comped_refusal("flagon-io", 150_000_000, ceiling); | |
| 615 | assert!(message.contains("used up") && message.contains("$150.00 of $150.00") && message.contains("sudo"), "{message}"); | |
| 616 | } | |
| 617 | ||
| 618 | #[test] | |
| 619 | fn budget_alerts_go_once_per_level_each_month() { | |
| 620 | let ceiling = 150_000_000; | |
| 621 | let mut sent = 0; | |
| 622 | let mut emailed = vec![]; | |
| 623 | // Spend climbs through the month, checked every 15 minutes. | |
| 624 | 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] { | |
| 625 | if let Some(level) = alert_to_send(alert_level(used, ceiling), sent) { | |
| 626 | emailed.push(level); | |
| 627 | sent = level; | |
| 628 | } | |
| 629 | } | |
| 630 | assert_eq!(emailed, vec![50, 75, 90, 100]); | |
| 631 | // A jump straight past several levels sends only the highest. | |
| 632 | assert_eq!(alert_to_send(alert_level(140_000_000, ceiling), 0), Some(90)); | |
| 633 | // A new month starts from nothing sent. | |
| 634 | assert_eq!(alert_to_send(alert_level(80_000_000, ceiling), 0), Some(50)); | |
| 635 | } | |
| 636 | ||
| 637 | #[test] | |
| 638 | fn the_breaker_trips_at_the_cap_and_staff_can_lift_it_for_the_day() { | |
| 639 | let cap = 75_000_000; | |
| 640 | assert!(!breaker_open(74_999_999, cap, false)); | |
| 641 | assert!(breaker_open(75_000_000, cap, false)); | |
| 642 | // Lifted: open no more today. | |
| 643 | assert!(!breaker_open(90_000_000, cap, true)); | |
| 644 | // Off. | |
| 645 | assert!(!breaker_open(1_000_000_000, 0, false)); | |
| 646 | // Tomorrow's total starts at zero: the breaker resets by itself. | |
| 647 | assert!(!breaker_open(0, cap, false)); | |
| 648 | let message = breaker_refusal(80_000_000, cap); | |
| 649 | assert!(message.contains("$80.00 of its $75.00") && message.contains("00:00 UTC"), "{message}"); | |
| 650 | } | |
| 651 | ||
| 652 | #[test] | |
| 653 | fn the_breaker_is_about_hosted_model_agent_runs() { | |
| 654 | assert!(breaker_applies(ComputeKind::Agent, Some(true))); | |
| 655 | assert!(breaker_applies(ComputeKind::Agent, None)); | |
| 656 | assert!(!breaker_applies(ComputeKind::Agent, Some(false))); | |
| 657 | assert!(!breaker_applies(ComputeKind::Check, None)); | |
| 658 | assert!(!breaker_applies(ComputeKind::Workflow, Some(true))); | |
| 659 | } | |
| 660 | ||
| 661 | #[test] | |
| 662 | fn workspaces_paying_with_real_money_are_never_paused_by_the_breaker() { | |
| 663 | assert!(covered_by_revenue(PlanKind::Paid, false, false, true)); | |
| 664 | assert!(covered_by_revenue(PlanKind::Enterprise, false, false, true)); | |
| 665 | // Test-mode payments are not money. | |
| 666 | assert!(!covered_by_revenue(PlanKind::Paid, false, false, false)); | |
| 667 | // The plan given by staff, comped, free: g1t pays. | |
| 668 | assert!(!covered_by_revenue(PlanKind::Paid, false, true, true)); | |
| 669 | assert!(!covered_by_revenue(PlanKind::Internal, true, false, true)); | |
| 670 | assert!(!covered_by_revenue(PlanKind::Free, false, false, true)); | |
| 671 | } | |
| 672 | ||
| 673 | #[test] | |
| 674 | fn amounts_read_in_cents() { | |
| 675 | assert_eq!(cents(150_000_000), "$150.00"); | |
| 676 | assert_eq!(cents(1_234_567), "$1.23"); | |
| 677 | assert_eq!(cents(5_000), "$0.01"); | |
| 678 | } | |
| 679 | } |