g1t/services/billing/src/limits.rs
Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Usage limits: unpaid usage can only go so far | 1 | //! How far a workspace can run up costs g1t has not been paid for. |
| 2 | //! | |
| 3 | //! Every sandbox second, build, app request and model token costs g1t | |
| 4 | //! money at Cloudflare or a model provider before the workspace pays for | |
| 5 | //! it. So, like Fly or Cloudflare with new accounts, each workspace has a | |
| 6 | //! ceiling on that unpaid usage, set by how much it has paid g1t before: | |
| 7 | //! | |
| 8 | //! - **New**: no live payment yet. A few dollars, enough for the free | |
| 9 | //! allowances and a little more. | |
| 10 | //! - **Paid**: twice what it has paid g1t, within bounds. | |
| 11 | //! - **Reviewed**: a ceiling g1t set by hand. | |
| 12 | //! - **Internal**: g1t's own workspaces, with none. | |
| 13 | //! | |
| 14 | //! An owner can set a lower spend limit of their own. Past 80% the | |
| 15 | //! workspace is warned; at the ceiling its work stops: no new sandboxes, | |
| 16 | //! builds or app requests, until it pays or the month turns. Runs already | |
| 17 | //! under way finish. | |
| 18 | //! | |
| 19 | //! Usage counts at what it cost g1t or what it is charged, whichever is | |
| 20 | //! more, so it counts while g1t is free too: free is a price, not an | |
| 21 | //! exemption from the ceiling. Test-mode payments are not money, so they | |
| 22 | //! do not raise trust. | |
| 23 | ||
| Billing accounts, terms and enterprises; g1t is no longer free | 24 | use g1t_contracts::billing::{CheckLimitArgs, Limit, LimitArgs, NotePendingArgs, TermsKind, LimitState, SetSpendLimitArgs, Trust}; |
| Usage limits: unpaid usage can only go so far | 25 | use g1t_contracts::time::rfc3339; |
| 26 | use g1t_contracts::{FailureCode, Outcome, Role}; | |
| 27 | use g1t_kit::now_ms; | |
| 28 | use serde::Deserialize; | |
| 29 | use worker::wasm_bindgen::JsValue; | |
| 30 | use worker::{Env, Result}; | |
| 31 | ||
| 32 | use crate::features::dollars as dollars_plain; | |
| 33 | use crate::{Billing, members_only}; | |
| 34 | ||
| 35 | /// The ceilings, from the billing service's variables. | |
| 36 | pub(crate) struct Ceilings { | |
| 37 | /// `LIMIT_NEW_MICROS`. | |
| 38 | pub new: i64, | |
| 39 | /// `LIMIT_PAID_MIN_MICROS` and `LIMIT_PAID_MAX_MICROS`. | |
| 40 | pub paid_min: i64, | |
| 41 | pub paid_max: i64, | |
| 42 | } | |
| 43 | ||
| 44 | impl Ceilings { | |
| 45 | pub(crate) fn from_env(env: &Env) -> Self { | |
| 46 | let number = |name: &str, default: i64| { | |
| 47 | env.var(name).ok().and_then(|v| v.to_string().parse::<i64>().ok()).unwrap_or(default) | |
| 48 | }; | |
| 49 | Ceilings { | |
| 50 | new: number("LIMIT_NEW_MICROS", 3_000_000), | |
| 51 | paid_min: number("LIMIT_PAID_MIN_MICROS", 25_000_000), | |
| 52 | paid_max: number("LIMIT_PAID_MAX_MICROS", 1_000_000_000), | |
| 53 | } | |
| 54 | } | |
| 55 | ||
| 56 | /// The ceiling for a workspace that has paid `paid` in live money. | |
| 57 | pub(crate) fn for_paid(&self, paid: i64) -> i64 { | |
| 58 | (paid * 2).clamp(self.paid_min, self.paid_max) | |
| 59 | } | |
| 60 | } | |
| 61 | ||
| 62 | /// Where a workspace stands against its ceiling. | |
| 63 | pub(crate) fn state(exposure: i64, ceiling: Option<i64>) -> LimitState { | |
| 64 | match ceiling { | |
| 65 | Some(ceiling) if exposure >= ceiling => LimitState::Stopped, | |
| 66 | Some(ceiling) if exposure * 5 >= ceiling * 4 => LimitState::Warning, | |
| 67 | _ => LimitState::Ok, | |
| 68 | } | |
| 69 | } | |
| 70 | ||
| Two limits, real invoices, trust that grows by itself, sales signals | 71 | /// The automatic monthly spend limit's floor: $200. |
| 72 | pub(crate) const DEFAULT_SPEND_MICROS: i64 = 200_000_000; | |
| 73 | /// Established workspaces' ceiling: three times their steady monthly | |
| 74 | /// spend, up to $10,000. | |
| 75 | const ESTABLISHED_FACTOR: i64 = 3; | |
| 76 | const ESTABLISHED_MAX_MICROS: i64 = 10_000_000_000; | |
| 77 | /// A month counts toward Established at this much spend or more. | |
| 78 | const ESTABLISHED_MONTH_MICROS: i64 = 20_000_000; | |
| 79 | /// Payments raise trust once this old: past the time most bad cards are | |
| 80 | /// caught. | |
| 81 | const SETTLE_DAYS: u64 = 7; | |
| 82 | ||
| 83 | /// The automatic spend limit: $200, or twice last month's spend. | |
| 84 | pub(crate) fn automatic_spend_limit(last_month_charged: i64) -> i64 { | |
| 85 | DEFAULT_SPEND_MICROS.max(last_month_charged * 2) | |
| 86 | } | |
| Billing accounts, terms and enterprises; g1t is no longer free | 87 | |
| Two limits, real invoices, trust that grows by itself, sales signals | 88 | /// An Established workspace's ceiling, from its last three months' |
| 89 | /// charges, if each was steady enough. | |
| 90 | pub(crate) fn established_ceiling(months: &[i64]) -> Option<i64> { | |
| 91 | if months.len() < 3 || months.iter().any(|m| *m < ESTABLISHED_MONTH_MICROS) { | |
| 92 | return None; | |
| 93 | } | |
| 94 | let average = months.iter().sum::<i64>() / months.len() as i64; | |
| 95 | Some((average * ESTABLISHED_FACTOR).min(ESTABLISHED_MAX_MICROS)) | |
| 96 | } | |
| 97 | ||
| Usage limits: unpaid usage can only go so far | 98 | #[derive(Deserialize)] |
| 99 | struct LimitRow { | |
| 100 | spend_limit_micros: Option<i64>, | |
| Two limits, real invoices, trust that grows by itself, sales signals | 101 | #[serde(default)] |
| 102 | spend_limit_full: Option<i64>, | |
| Billing accounts, terms and enterprises; g1t is no longer free | 103 | autopay_failed_at: Option<String>, |
| 104 | autopay_error: Option<String>, | |
| Usage limits: unpaid usage can only go so far | 105 | } |
| 106 | ||
| 107 | #[derive(Deserialize)] | |
| 108 | struct Month { | |
| 109 | used: Option<i64>, | |
| 110 | paid: Option<i64>, | |
| 111 | } | |
| 112 | ||
| 113 | #[derive(Deserialize)] | |
| 114 | struct Paid { | |
| 115 | paid: Option<i64>, | |
| 116 | } | |
| 117 | ||
| 118 | impl Billing { | |
| Billing accounts, terms and enterprises; g1t is no longer free | 119 | /// The workspace's limit, worked out from the ledger of the account |
| 120 | /// that pays for it: its own, or its enterprise's, whose workspaces' | |
| 121 | /// usage and payments count together. | |
| Usage limits: unpaid usage can only go so far | 122 | pub(crate) async fn limit_of(&self, workspace: &str) -> Result<Limit> { |
| 123 | let workspace = workspace.to_lowercase(); | |
| Billing accounts, terms and enterprises; g1t is no longer free | 124 | let account = self.account_of(&workspace).await?; |
| Usage limits: unpaid usage can only go so far | 125 | let row = self |
| 126 | .db | |
| Two limits, real invoices, trust that grows by itself, sales signals | 127 | .prepare("SELECT spend_limit_micros, spend_limit_full, autopay_failed_at, autopay_error FROM limits WHERE workspace = ?") |
| Usage limits: unpaid usage can only go so far | 128 | .bind(&[workspace.as_str().into()])? |
| 129 | .first::<LimitRow>(None) | |
| 130 | .await?; | |
| 131 | let month_start = format!("{}-01", &rfc3339(now_ms())[..7]); | |
| Billing accounts, terms and enterprises; g1t is no longer free | 132 | let marks = vec!["?"; account.workspaces.len().max(1)].join(", "); |
| 133 | let members: Vec<JsValue> = if account.workspaces.is_empty() { | |
| 134 | vec![JsValue::from(workspace.as_str())] | |
| 135 | } else { | |
| 136 | account.workspaces.iter().map(|w| JsValue::from(w.as_str())).collect() | |
| 137 | }; | |
| 138 | let mut with_month = members.clone(); | |
| 139 | with_month.push(month_start.as_str().into()); | |
| Usage limits: unpaid usage can only go so far | 140 | // Each usage entry at its cost to g1t or its charge, whichever is |
| 141 | // more; on the workspace's own provider, only g1t's fee is g1t's. | |
| 142 | let month = self | |
| 143 | .db | |
| Billing accounts, terms and enterprises; g1t is no longer free | 144 | .prepare(format!( |
| Usage limits: unpaid usage can only go so far | 145 | "SELECT |
| 146 | SUM(CASE WHEN kind = 'usage' THEN | |
| 147 | CASE WHEN COALESCE(billed_to, 'g1t') = 'g1t' | |
| 148 | THEN MAX(COALESCE(cost_micros, 0), -amount_micros) | |
| 149 | ELSE -amount_micros END | |
| 150 | END) AS used, | |
| 151 | SUM(CASE WHEN kind = 'top_up' THEN amount_micros END) AS paid | |
| Billing accounts, terms and enterprises; g1t is no longer free | 152 | FROM ledger WHERE workspace IN ({marks}) AND created_at >= ?" |
| 153 | )) | |
| 154 | .bind(&with_month)? | |
| Usage limits: unpaid usage can only go so far | 155 | .first::<Month>(None) |
| 156 | .await?; | |
| 157 | let (used, paid_month) = month.map_or((0, 0), |m| (m.used.unwrap_or(0), m.paid.unwrap_or(0))); | |
| Prices keep themselves current with what g1t pays | 158 | // And what is metered but not charged until the month closes. |
| Billing accounts, terms and enterprises; g1t is no longer free | 159 | let mut pending_args = members.clone(); |
| 160 | pending_args.push(month_start[..7].into()); | |
| Prices keep themselves current with what g1t pays | 161 | let pending = self |
| 162 | .db | |
| Billing accounts, terms and enterprises; g1t is no longer free | 163 | .prepare(format!( |
| 164 | "SELECT SUM(charge_micros) AS paid FROM pending_usage WHERE workspace IN ({marks}) AND month = ?" | |
| 165 | )) | |
| 166 | .bind(&pending_args)? | |
| Prices keep themselves current with what g1t pays | 167 | .first::<Paid>(None) |
| 168 | .await? | |
| 169 | .and_then(|row| row.paid) | |
| 170 | .unwrap_or(0); | |
| 171 | let used = used + pending; | |
| Usage limits: unpaid usage can only go so far | 172 | // Test-mode payments are not money: they pay nothing off. |
| 173 | let live = self.stripe.as_ref().is_some_and(crate::stripe::Stripe::live); | |
| Billing on Stripe's pages, month-end charges, warnings; sudo by workspace | 174 | // Charges from earlier months still unpaid carry over, so a new |
| 175 | // month is not a fresh allowance for an account that never pays. | |
| 176 | // Credits g1t gave count as paid; test-mode payments do not. | |
| 177 | let mut before = members.clone(); | |
| 178 | before.push(month_start.as_str().into()); | |
| 179 | let carried = self | |
| 180 | .db | |
| 181 | .prepare(format!( | |
| 182 | "SELECT SUM(CASE WHEN kind = 'usage' THEN amount_micros | |
| 183 | WHEN kind = 'top_up' AND ({live} = 1 OR reference LIKE 'crd%') THEN amount_micros | |
| 184 | ELSE 0 END) AS paid | |
| 185 | FROM ledger WHERE workspace IN ({marks}) AND created_at < ?", | |
| 186 | live = u8::from(live) | |
| 187 | )) | |
| 188 | .bind(&before)? | |
| 189 | .first::<Paid>(None) | |
| 190 | .await? | |
| 191 | .and_then(|row| row.paid) | |
| 192 | .map_or(0, |balance| (-balance).max(0)); | |
| 193 | let exposure = (used - if live { paid_month } else { 0 }).max(0) + carried; | |
| Usage limits: unpaid usage can only go so far | 194 | |
| Billing accounts, terms and enterprises; g1t is no longer free | 195 | let (trust, trust_ceiling) = match account.terms.kind { |
| 196 | TermsKind::Comped => (Trust::Internal, None), | |
| 197 | _ if account.terms.ceiling_micros.is_some() => (Trust::Reviewed, account.terms.ceiling_micros), | |
| 198 | _ => { | |
| 199 | let paid = self.live_paid(&members).await?; | |
| Two limits, real invoices, trust that grows by itself, sales signals | 200 | let established = if paid > 0 { self.established(&members).await? } else { None }; |
| 201 | match established { | |
| 202 | Some(ceiling) => (Trust::Established, Some(ceiling.max(self.ceilings.for_paid(paid)))), | |
| 203 | None if paid > 0 => (Trust::Paid, Some(self.ceilings.for_paid(paid))), | |
| 204 | None => (Trust::New, Some(self.ceilings.new)), | |
| Billing accounts, terms and enterprises; g1t is no longer free | 205 | } |
| Usage limits: unpaid usage can only go so far | 206 | } |
| 207 | }; | |
| Two limits, real invoices, trust that grows by itself, sales signals | 208 | // This month's charges, and last month's, for the spend limit. |
| 209 | let (spent, last_month) = self.charged_months(&members, &month_start).await?; | |
| 210 | let spent = spent + pending; | |
| 211 | // The owners' own monthly limit: theirs, none, or the automatic one | |
| 212 | // ($200, or twice last month), which self-serve workspaces start on. | |
| 213 | let chosen = row.as_ref().and_then(|row| row.spend_limit_micros); | |
| 214 | let full = row.as_ref().and_then(|row| row.spend_limit_full).unwrap_or(0) == 1; | |
| 215 | let self_serve = matches!(trust, Trust::New | Trust::Paid | Trust::Established); | |
| 216 | let default_spend_limit = chosen.is_none() && !full && self_serve; | |
| 217 | let spend_limit = match (chosen, full) { | |
| 218 | (Some(own), _) => Some(own), | |
| 219 | (None, true) => None, | |
| 220 | (None, false) if self_serve => Some(automatic_spend_limit(last_month)), | |
| 221 | _ => None, | |
| 222 | }; | |
| Billing accounts, terms and enterprises; g1t is no longer free | 223 | // A card declined when g1t charged it at the limit stops work until |
| 224 | // it is paid; any payment clears it. | |
| 225 | let declined = row.as_ref().and_then(|row| row.autopay_failed_at.clone().map(|at| (at, row.autopay_error.clone()))); | |
| Two limits, real invoices, trust that grows by itself, sales signals | 226 | // Two limits: g1t's on what is unpaid, the owners' on what is spent. |
| 227 | let ceiling = trust_ceiling; | |
| 228 | let risk = state(exposure, ceiling); | |
| 229 | let budget = state(spent, spend_limit); | |
| 230 | let over_budget = budget == LimitState::Stopped; | |
| 231 | let state = if declined.is_some() && exposure > 0 { | |
| 232 | LimitState::Stopped | |
| 233 | } else if risk == LimitState::Stopped || over_budget { | |
| 234 | LimitState::Stopped | |
| 235 | } else if risk == LimitState::Warning || budget == LimitState::Warning { | |
| 236 | LimitState::Warning | |
| 237 | } else { | |
| 238 | LimitState::Ok | |
| Usage limits: unpaid usage can only go so far | 239 | }; |
| Billing accounts, terms and enterprises; g1t is no longer free | 240 | let who = if account.kind == g1t_contracts::billing::AccountKind::Enterprise { |
| 241 | format!("The {} enterprise, which pays for {workspace},", account.name) | |
| 242 | } else { | |
| 243 | format!("The {workspace} workspace") | |
| 244 | }; | |
| Usage limits: unpaid usage can only go so far | 245 | let message = match state { |
| 246 | LimitState::Ok => None, | |
| Two limits, real invoices, trust that grows by itself, sales signals | 247 | LimitState::Warning if budget == LimitState::Warning => Some(format!( |
| 248 | "{who} has spent {} of its {} monthly spend limit. At the limit, its sandboxes, builds and apps stop until the month turns or an owner raises it under Billing.", | |
| 249 | dollars_plain(spent), | |
| 250 | dollars_plain(spend_limit.unwrap_or_default()), | |
| 251 | )), | |
| Usage limits: unpaid usage can only go so far | 252 | LimitState::Warning => Some(format!( |
| Two limits, real invoices, trust that grows by itself, sales signals | 253 | "{who} has {} of usage not yet paid for, of the {} g1t allows. With a card on file g1t charges it now; without one, at the limit its sandboxes, builds and apps stop until it pays.", |
| Usage limits: unpaid usage can only go so far | 254 | dollars_plain(exposure), |
| 255 | dollars_plain(ceiling.unwrap_or_default()), | |
| 256 | )), | |
| Billing accounts, terms and enterprises; g1t is no longer free | 257 | LimitState::Stopped if declined.is_some() => Some(format!( |
| 258 | "{who} could not be charged for its usage ({}), so its sandboxes, builds and apps are stopped. An owner can pay under Billing with another card.", | |
| 259 | declined.as_ref().and_then(|(_, error)| error.clone()).unwrap_or_else(|| "the card was declined".to_owned()), | |
| 260 | )), | |
| Two limits, real invoices, trust that grows by itself, sales signals | 261 | LimitState::Stopped => Some(if over_budget { |
| Usage limits: unpaid usage can only go so far | 262 | format!( |
| Two limits, real invoices, trust that grows by itself, sales signals | 263 | "{who} reached its {} monthly spend limit, so its sandboxes, builds and apps are stopped until the month turns. An owner can raise it under Billing.", |
| 264 | dollars_plain(spend_limit.unwrap_or_default()), | |
| Usage limits: unpaid usage can only go so far | 265 | ) |
| 266 | } else { | |
| 267 | format!( | |
| Billing accounts, terms and enterprises; g1t is no longer free | 268 | "{who} reached its {} limit for usage not yet paid for, so its sandboxes, builds and apps are stopped. The limit grows as a workspace pays g1t; an owner can pay under Billing, or write to support to have it raised.", |
| Usage limits: unpaid usage can only go so far | 269 | dollars_plain(ceiling.unwrap_or_default()), |
| 270 | ) | |
| 271 | }), | |
| 272 | }; | |
| Two limits, real invoices, trust that grows by itself, sales signals | 273 | let growth = match trust { |
| 274 | Trust::New => Some("Pay g1t once, by card or credit, and this grows to $25; after that it grows with every payment.".to_owned()), | |
| 275 | Trust::Paid => Some(format!( | |
| 276 | "Grows to twice what you have paid, as payments clear (after {SETTLE_DAYS} days), up to $1,000. After three steady months it follows your monthly spend, up to $10,000, by itself." | |
| 277 | )), | |
| 278 | Trust::Established => Some("Follows your monthly spend, up to $10,000, by itself. For more, contact us.".to_owned()), | |
| 279 | Trust::Reviewed | Trust::Internal => None, | |
| 280 | }; | |
| Usage limits: unpaid usage can only go so far | 281 | Ok(Limit { |
| 282 | workspace, | |
| Billing accounts, terms and enterprises; g1t is no longer free | 283 | account: account.id, |
| 284 | account_name: account.name, | |
| Two limits, real invoices, trust that grows by itself, sales signals | 285 | spent_micros: spent, |
| 286 | default_spend_limit, | |
| 287 | available_micros: trust_ceiling, | |
| 288 | growth, | |
| Usage limits: unpaid usage can only go so far | 289 | trust, |
| 290 | exposure_micros: exposure, | |
| 291 | ceiling_micros: ceiling, | |
| 292 | trust_ceiling_micros: trust_ceiling, | |
| 293 | spend_limit_micros: spend_limit, | |
| 294 | state, | |
| 295 | message, | |
| 296 | }) | |
| 297 | } | |
| 298 | ||
| Billing accounts, terms and enterprises; g1t is no longer free | 299 | /// Real money the workspaces have paid g1t. Nothing in test mode, and |
| 300 | /// credits g1t gave are not payments. | |
| 301 | async fn live_paid(&self, members: &[JsValue]) -> Result<i64> { | |
| Usage limits: unpaid usage can only go so far | 302 | if !self.stripe.as_ref().is_some_and(crate::stripe::Stripe::live) { |
| 303 | return Ok(0); | |
| 304 | } | |
| Billing accounts, terms and enterprises; g1t is no longer free | 305 | let marks = vec!["?"; members.len().max(1)].join(", "); |
| Usage limits: unpaid usage can only go so far | 306 | Ok(self |
| 307 | .db | |
| Billing accounts, terms and enterprises; g1t is no longer free | 308 | .prepare(format!( |
| 309 | "SELECT SUM(amount_micros) AS paid FROM ledger | |
| Two limits, real invoices, trust that grows by itself, sales signals | 310 | WHERE workspace IN ({marks}) AND kind = 'top_up' AND reference NOT LIKE 'crd%' |
| 311 | AND (amount_micros < 0 | |
| 312 | OR (disputed = 0 AND COALESCE(funding, '') <> 'prepaid' | |
| 313 | AND created_at <= '{settled}'))", | |
| 314 | settled = rfc3339(now_ms() - SETTLE_DAYS * 24 * 60 * 60 * 1000) | |
| Billing accounts, terms and enterprises; g1t is no longer free | 315 | )) |
| 316 | .bind(members)? | |
| Usage limits: unpaid usage can only go so far | 317 | .first::<Paid>(None) |
| 318 | .await? | |
| 319 | .and_then(|row| row.paid) | |
| 320 | .unwrap_or(0)) | |
| 321 | } | |
| 322 | ||
| Two limits, real invoices, trust that grows by itself, sales signals | 323 | /// This month's charges and last month's, across the workspaces. |
| 324 | async fn charged_months(&self, members: &[JsValue], month_start: &str) -> Result<(i64, i64)> { | |
| 325 | #[derive(Deserialize)] | |
| 326 | struct Charged { | |
| 327 | this_month: Option<i64>, | |
| 328 | last_month: Option<i64>, | |
| 329 | } | |
| 330 | let last_start = format!("{}-01", previous_month(&month_start[..7])); | |
| 331 | let marks = vec!["?"; members.len().max(1)].join(", "); | |
| 332 | let row = self | |
| 333 | .db | |
| 334 | .prepare(format!( | |
| 335 | "SELECT | |
| 336 | -SUM(CASE WHEN created_at >= '{month_start}' THEN amount_micros END) AS this_month, | |
| 337 | -SUM(CASE WHEN created_at >= '{last_start}' AND created_at < '{month_start}' THEN amount_micros END) AS last_month | |
| 338 | FROM ledger WHERE kind = 'usage' AND workspace IN ({marks}) AND created_at >= '{last_start}'" | |
| 339 | )) | |
| 340 | .bind(members)? | |
| 341 | .first::<Charged>(None) | |
| 342 | .await?; | |
| 343 | Ok(row.map_or((0, 0), |r| (r.this_month.unwrap_or(0).max(0), r.last_month.unwrap_or(0).max(0)))) | |
| 344 | } | |
| 345 | ||
| 346 | /// An Established ceiling, if the workspaces have paid steadily: three | |
| 347 | /// full months of real spend, each invoiced and paid, nothing declined | |
| 348 | /// in 90 days and nothing ever disputed. | |
| 349 | async fn established(&self, members: &[JsValue]) -> Result<Option<i64>> { | |
| 350 | let marks = vec!["?"; members.len().max(1)].join(", "); | |
| 351 | let now = rfc3339(now_ms()); | |
| 352 | let mut months = vec![]; | |
| 353 | let mut month = previous_month(&now[..7]); | |
| 354 | for _ in 0..3 { | |
| 355 | months.push(month.clone()); | |
| 356 | month = previous_month(&month); | |
| 357 | } | |
| 358 | #[derive(Deserialize)] | |
| 359 | struct Count { | |
| 360 | n: Option<i64>, | |
| 361 | } | |
| 362 | let troubled = self | |
| 363 | .db | |
| 364 | .prepare(format!( | |
| 365 | "SELECT (SELECT COUNT(*) FROM ledger WHERE workspace IN ({marks}) AND disputed = 1) | |
| 366 | + (SELECT COUNT(*) FROM limits WHERE workspace IN ({marks}) AND autopay_failed_at >= '{since}') AS n", | |
| 367 | since = rfc3339(now_ms() - 90 * 24 * 60 * 60 * 1000) | |
| 368 | )) | |
| 369 | .bind(&[members, members].concat())? | |
| 370 | .first::<Count>(None) | |
| 371 | .await? | |
| 372 | .and_then(|c| c.n) | |
| 373 | .unwrap_or(0); | |
| 374 | if troubled > 0 { | |
| 375 | return Ok(None); | |
| 376 | } | |
| 377 | let mut charged = vec![]; | |
| 378 | for month in &months { | |
| 379 | #[derive(Deserialize)] | |
| 380 | struct Month { | |
| 381 | charged: Option<i64>, | |
| 382 | unpaid: Option<i64>, | |
| 383 | } | |
| 384 | let next = { | |
| 385 | let year: i32 = month[..4].parse().unwrap_or(1970); | |
| 386 | let number: u32 = month[5..7].parse().unwrap_or(1); | |
| 387 | if number == 12 { format!("{}-01", year + 1) } else { format!("{year}-{:02}", number + 1) } | |
| 388 | }; | |
| 389 | let row = self | |
| 390 | .db | |
| 391 | .prepare(format!( | |
| 392 | "SELECT | |
| 393 | (SELECT -SUM(amount_micros) FROM ledger WHERE kind = 'usage' AND workspace IN ({marks}) | |
| 394 | AND created_at >= '{month}-01' AND created_at < '{next}-01') AS charged, | |
| 395 | (SELECT COUNT(*) FROM workspace_invoices WHERE workspace IN ({marks}) AND reason = 'month' | |
| 396 | AND period = '{month}' AND status <> 'paid') AS unpaid" | |
| 397 | )) | |
| 398 | .bind(&[members, members].concat())? | |
| 399 | .first::<Month>(None) | |
| 400 | .await?; | |
| 401 | let Some(row) = row else { return Ok(None) }; | |
| 402 | if row.unpaid.unwrap_or(0) > 0 { | |
| 403 | return Ok(None); | |
| 404 | } | |
| 405 | charged.push(row.charged.unwrap_or(0)); | |
| 406 | } | |
| 407 | Ok(established_ceiling(&charged)) | |
| 408 | } | |
| 409 | ||
| Usage limits: unpaid usage can only go so far | 410 | /// A refusal, with the reason, when the workspace's work is stopped. |
| 411 | /// None while billing is off: a g1t without payments has no limits. | |
| 412 | pub(crate) async fn stopped<T>(&self, workspace: &str) -> Result<Option<Outcome<T>>> { | |
| 413 | if self.stripe.is_none() { | |
| 414 | return Ok(None); | |
| 415 | } | |
| 416 | let limit = self.limit_of(workspace).await?; | |
| 417 | Ok((limit.state == LimitState::Stopped).then(|| { | |
| 418 | Outcome::fail( | |
| 419 | FailureCode::PaymentRequired, | |
| 420 | limit.message.unwrap_or_else(|| "This workspace is over its limit.".to_owned()), | |
| 421 | ) | |
| 422 | })) | |
| 423 | } | |
| 424 | ||
| 425 | pub(crate) async fn limit(&self, a: LimitArgs) -> Result<Outcome<Limit>> { | |
| 426 | let workspace = a.workspace.to_lowercase(); | |
| 427 | if !a.viewer.is_some_and(|viewer| viewer.is_member(&workspace)) { | |
| 428 | return Ok(members_only()); | |
| 429 | } | |
| 430 | Ok(Outcome::Ok(self.limit_of(&workspace).await?)) | |
| 431 | } | |
| 432 | ||
| Prices keep themselves current with what g1t pays | 433 | pub(crate) async fn note_pending(&self, a: NotePendingArgs) -> Result<bool> { |
| 434 | let now = rfc3339(now_ms()); | |
| 435 | let charge = crate::charge_micros(a.cost_micros.max(0) as f64 / g1t_contracts::billing::MICROS_PER_DOLLAR as f64, self.margin_percent); | |
| 436 | self.db | |
| 437 | .prepare( | |
| 438 | "INSERT INTO pending_usage (workspace, source, month, charge_micros, updated_at) VALUES (?1, ?2, ?3, ?4, ?5) | |
| 439 | ON CONFLICT (workspace, source, month) DO UPDATE SET charge_micros = ?4, updated_at = ?5", | |
| 440 | ) | |
| 441 | .bind(&[ | |
| 442 | a.workspace.to_lowercase().into(), | |
| 443 | a.source.as_str().into(), | |
| 444 | now[..7].into(), | |
| 445 | (charge as f64).into(), | |
| 446 | now.as_str().into(), | |
| 447 | ])? | |
| 448 | .run() | |
| 449 | .await?; | |
| 450 | Ok(true) | |
| 451 | } | |
| 452 | ||
| Billing accounts, terms and enterprises; g1t is no longer free | 453 | /// Charges the saved card of each workspace nearing its limit, for what |
| 454 | /// it owes, so that a workspace that pays never has its work stopped. | |
| 455 | /// Only with live payments: test-mode payments are not money and lower | |
| 456 | /// nothing. Not for a workspace's own spend limit, which means stop, nor | |
| 457 | /// for enterprises, which are invoiced. | |
| 458 | pub(crate) async fn autopay(&self) -> Result<()> { | |
| Two limits, real invoices, trust that grows by itself, sales signals | 459 | if !self.stripe.as_ref().is_some_and(crate::stripe::Stripe::live) { |
| Billing accounts, terms and enterprises; g1t is no longer free | 460 | return Ok(()); |
| Two limits, real invoices, trust that grows by itself, sales signals | 461 | } |
| Billing accounts, terms and enterprises; g1t is no longer free | 462 | #[derive(Deserialize)] |
| 463 | struct Candidate { | |
| 464 | workspace: String, | |
| 465 | } | |
| 466 | let month_start = format!("{}-01", &rfc3339(now_ms())[..7]); | |
| Two limits, real invoices, trust that grows by itself, sales signals | 467 | // With a card, and not already declined: a declined card waits for |
| 468 | // the owners, rather than being tried again every few minutes. | |
| Billing accounts, terms and enterprises; g1t is no longer free | 469 | let candidates = self |
| 470 | .db | |
| 471 | .prepare( | |
| Two limits, real invoices, trust that grows by itself, sales signals | 472 | "SELECT DISTINCT ledger.workspace AS workspace |
| Billing accounts, terms and enterprises; g1t is no longer free | 473 | FROM ledger JOIN accounts ON accounts.workspace = ledger.workspace |
| Two limits, real invoices, trust that grows by itself, sales signals | 474 | LEFT JOIN limits ON limits.workspace = ledger.workspace |
| 475 | WHERE ledger.kind = 'usage' AND ledger.created_at >= ? AND accounts.customer_id IS NOT NULL | |
| 476 | AND limits.autopay_failed_at IS NULL", | |
| Billing accounts, terms and enterprises; g1t is no longer free | 477 | ) |
| 478 | .bind(&[month_start.as_str().into()])? | |
| 479 | .all() | |
| 480 | .await? | |
| 481 | .results::<Candidate>()?; | |
| 482 | for candidate in candidates { | |
| 483 | let limit = self.limit_of(&candidate.workspace).await?; | |
| Two limits, real invoices, trust that grows by itself, sales signals | 484 | // Near g1t's ceiling on what is unpaid; the spend limit is the |
| 485 | // owners' and stops work by itself, but what is owed is still owed. | |
| 486 | let near = limit.ceiling_micros.is_some_and(|ceiling| limit.exposure_micros * 5 >= ceiling * 4); | |
| 487 | if !near || limit.trust == Trust::Internal || limit.account.starts_with("ent_") { | |
| Billing on Stripe's pages, month-end charges, warnings; sudo by workspace | 488 | continue; |
| 489 | } | |
| Two limits, real invoices, trust that grows by itself, sales signals | 490 | // An invoice for what it owes, charged to its card now: never |
| 491 | // the cost of what was free to it. | |
| 492 | let today = rfc3339(now_ms())[..10].to_owned(); | |
| 493 | match self.invoice_workspace(&candidate.workspace, "threshold", &today).await? { | |
| 494 | Ok(_) => {} | |
| 495 | Err(why) => worker::console_log!("{}: no threshold invoice: {why}", candidate.workspace), | |
| Billing accounts, terms and enterprises; g1t is no longer free | 496 | } |
| 497 | } | |
| 498 | Ok(()) | |
| 499 | } | |
| 500 | ||
| Billing on Stripe's pages, month-end charges, warnings; sudo by workspace | 501 | /// Closes last month for each workspace with a card on file: charges |
| 502 | /// what it owed when the month ended. Live payments only, once per | |
| 503 | /// workspace and month; a declined card stops work until it is paid. | |
| 504 | /// Comped workspaces owe nothing, and enterprises are invoiced. | |
| 505 | pub(crate) async fn close_months(&self) -> Result<()> { | |
| Two limits, real invoices, trust that grows by itself, sales signals | 506 | if !self.stripe.as_ref().is_some_and(crate::stripe::Stripe::live) { |
| Billing on Stripe's pages, month-end charges, warnings; sudo by workspace | 507 | return Ok(()); |
| Two limits, real invoices, trust that grows by itself, sales signals | 508 | } |
| Billing on Stripe's pages, month-end charges, warnings; sudo by workspace | 509 | let now = rfc3339(now_ms()); |
| 510 | let month_start = format!("{}-01", &now[..7]); | |
| 511 | let closing = previous_month(&now[..7]); | |
| 512 | #[derive(Deserialize)] | |
| 513 | struct Open { | |
| 514 | workspace: String, | |
| 515 | balance: Option<i64>, | |
| 516 | } | |
| 517 | let open = self | |
| 518 | .db | |
| 519 | .prepare( | |
| Two limits, real invoices, trust that grows by itself, sales signals | 520 | "SELECT accounts.workspace AS workspace, |
| Billing on Stripe's pages, month-end charges, warnings; sudo by workspace | 521 | (SELECT SUM(amount_micros) FROM ledger |
| 522 | WHERE ledger.workspace = accounts.workspace AND ledger.created_at < ?1) AS balance | |
| 523 | FROM accounts | |
| 524 | WHERE accounts.customer_id IS NOT NULL | |
| 525 | AND NOT EXISTS (SELECT 1 FROM month_closes | |
| 526 | WHERE month_closes.workspace = accounts.workspace AND month_closes.month = ?2) | |
| 527 | LIMIT 20", | |
| 528 | ) | |
| 529 | .bind(&[month_start.as_str().into(), closing.as_str().into()])? | |
| 530 | .all() | |
| 531 | .await? | |
| 532 | .results::<Open>()?; | |
| 533 | for account in open { | |
| 534 | let record = |status: &str, amount: i64, payment: Option<&str>, error: Option<&str>| { | |
| 535 | self.db | |
| 536 | .prepare( | |
| 537 | "INSERT OR IGNORE INTO month_closes (workspace, month, status, amount_micros, payment_id, error, closed_at) | |
| 538 | VALUES (?, ?, ?, ?, ?, ?, ?)", | |
| 539 | ) | |
| 540 | .bind(&[ | |
| 541 | account.workspace.as_str().into(), | |
| 542 | closing.as_str().into(), | |
| 543 | status.into(), | |
| 544 | (amount as f64).into(), | |
| 545 | crate::optional(payment), | |
| 546 | crate::optional(error), | |
| 547 | now.as_str().into(), | |
| 548 | ]) | |
| 549 | }; | |
| 550 | let payer = self.account_of(&account.workspace).await?; | |
| 551 | if payer.terms.kind == TermsKind::Comped || payer.id.starts_with("ent_") { | |
| 552 | record("skipped", 0, None, None)?.run().await?; | |
| 553 | continue; | |
| 554 | } | |
| 555 | let owed = (-account.balance.unwrap_or(0)).max(0); | |
| 556 | if owed < 10_000 { | |
| 557 | // Under a cent: nothing worth charging. | |
| 558 | record("nothing", 0, None, None)?.run().await?; | |
| 559 | continue; | |
| 560 | } | |
| Two limits, real invoices, trust that grows by itself, sales signals | 561 | match self.invoice_workspace(&account.workspace, "month", &closing).await? { |
| 562 | Ok(invoice) if invoice.status == "paid" => { | |
| 563 | record("paid", invoice.amount_micros, Some(&invoice.invoice_id), None)?.run().await?; | |
| Billing on Stripe's pages, month-end charges, warnings; sudo by workspace | 564 | } |
| Two limits, real invoices, trust that grows by itself, sales signals | 565 | Ok(invoice) => { |
| 566 | record("failed", invoice.amount_micros, Some(&invoice.invoice_id), Some("the card was declined"))?.run().await?; | |
| 567 | } | |
| 568 | Err(why) => { | |
| 569 | record("nothing", 0, None, Some(&why))?.run().await?; | |
| Billing on Stripe's pages, month-end charges, warnings; sudo by workspace | 570 | } |
| 571 | } | |
| 572 | } | |
| 573 | Ok(()) | |
| 574 | } | |
| 575 | ||
| 576 | /// Emails a workspace's owners as it passes 50%, 80% and 100% of its | |
| 577 | /// limit, once each a month, and when its card was declined, so that | |
| 578 | /// work never stops without warning. | |
| 579 | pub(crate) async fn warn_limits(&self, identity: &worker::Fetcher) -> Result<()> { | |
| 580 | if self.stripe.is_none() { | |
| 581 | return Ok(()); | |
| 582 | } | |
| 583 | let now = rfc3339(now_ms()); | |
| 584 | let month = &now[..7]; | |
| 585 | #[derive(Deserialize)] | |
| 586 | struct Candidate { | |
| 587 | workspace: String, | |
| 588 | } | |
| 589 | let candidates = self | |
| 590 | .db | |
| 591 | .prepare( | |
| 592 | "SELECT DISTINCT workspace FROM ledger WHERE kind = 'usage' AND created_at >= ?1 | |
| 593 | UNION SELECT workspace FROM limits WHERE autopay_failed_at IS NOT NULL", | |
| 594 | ) | |
| 595 | .bind(&[format!("{month}-01").into()])? | |
| 596 | .all() | |
| 597 | .await? | |
| 598 | .results::<Candidate>()?; | |
| 599 | #[derive(Deserialize)] | |
| 600 | struct Told { | |
| 601 | warned_month: Option<String>, | |
| 602 | warned_level: Option<i64>, | |
| 603 | autopay_failed_at: Option<String>, | |
| 604 | declined_told_at: Option<String>, | |
| 605 | } | |
| 606 | for Candidate { workspace } in candidates { | |
| 607 | let limit = self.limit_of(&workspace).await?; | |
| 608 | let told = self | |
| 609 | .db | |
| 610 | .prepare("SELECT warned_month, warned_level, autopay_failed_at, declined_told_at FROM limits WHERE workspace = ?") | |
| 611 | .bind(&[workspace.as_str().into()])? | |
| 612 | .first::<Told>(None) | |
| 613 | .await?; | |
| 614 | let billing = format!("https://g1t.sh/{workspace}/-/billing"); | |
| 615 | ||
| 616 | // A declined card, once per decline. | |
| 617 | if let Some(Told { autopay_failed_at: Some(failed), declined_told_at, .. }) = &told { | |
| 618 | if declined_told_at.as_deref().is_none_or(|at| at < failed.as_str()) { | |
| 619 | let sent = notify( | |
| 620 | identity, | |
| 621 | &workspace, | |
| 622 | &format!("g1t: the card for {workspace} was declined"), | |
| 623 | &limit.message.clone().unwrap_or_else(|| format!("g1t could not charge the card on file for {workspace}.")), | |
| 624 | "Update the card", | |
| 625 | &billing, | |
| 626 | ) | |
| 627 | .await; | |
| 628 | if sent { | |
| 629 | self.db | |
| 630 | .prepare("UPDATE limits SET declined_told_at = ? WHERE workspace = ?") | |
| 631 | .bind(&[now.as_str().into(), workspace.as_str().into()])? | |
| 632 | .run() | |
| 633 | .await?; | |
| 634 | } | |
| 635 | } | |
| 636 | } | |
| 637 | ||
| 638 | let Some(ceiling) = limit.ceiling_micros.filter(|c| *c > 0) else { continue }; | |
| 639 | let level = warning_level(limit.exposure_micros, ceiling); | |
| 640 | let already = told | |
| 641 | .as_ref() | |
| 642 | .filter(|t| t.warned_month.as_deref() == Some(month)) | |
| 643 | .and_then(|t| t.warned_level) | |
| 644 | .unwrap_or(0); | |
| 645 | if level <= already { | |
| 646 | continue; | |
| 647 | } | |
| 648 | let (subject, intro) = match level { | |
| 649 | 100 => ( | |
| 650 | format!("g1t: {workspace} reached its usage limit"), | |
| 651 | limit.message.clone().unwrap_or_else(|| format!("{workspace} reached its usage limit.")), | |
| 652 | ), | |
| 653 | _ => ( | |
| 654 | format!("g1t: {workspace} has used {level}% of its usage limit"), | |
| 655 | format!( | |
| 656 | "{workspace} has used {} of its {} usage limit this month. At the limit its sandboxes, builds and apps stop until it pays or the month turns. With a card on file, g1t charges it as the limit nears, so work keeps going.", | |
| 657 | dollars_plain(limit.exposure_micros), | |
| 658 | dollars_plain(ceiling), | |
| 659 | ), | |
| 660 | ), | |
| 661 | }; | |
| 662 | if notify(identity, &workspace, &subject, &intro, "Open billing", &billing).await { | |
| 663 | self.db | |
| 664 | .prepare( | |
| 665 | "INSERT INTO limits (workspace, warned_month, warned_level, updated_at) VALUES (?1, ?2, ?3, ?4) | |
| 666 | ON CONFLICT (workspace) DO UPDATE SET warned_month = ?2, warned_level = ?3, updated_at = ?4", | |
| 667 | ) | |
| 668 | .bind(&[workspace.as_str().into(), month.into(), (level as f64).into(), now.as_str().into()])? | |
| 669 | .run() | |
| 670 | .await?; | |
| 671 | } | |
| 672 | } | |
| 673 | Ok(()) | |
| 674 | } | |
| 675 | ||
| Usage limits: unpaid usage can only go so far | 676 | pub(crate) async fn check_limit(&self, a: CheckLimitArgs) -> Result<Outcome<Limit>> { |
| 677 | Ok(Outcome::Ok(self.limit_of(&a.workspace).await?)) | |
| 678 | } | |
| 679 | ||
| 680 | pub(crate) async fn set_spend_limit(&self, a: SetSpendLimitArgs) -> Result<Outcome<Limit>> { | |
| 681 | let workspace = a.workspace.to_lowercase(); | |
| 682 | if a.actor.role_in(&workspace) != Some(Role::Owner) { | |
| 683 | return Ok(Outcome::fail( | |
| 684 | FailureCode::Forbidden, | |
| 685 | "Only an owner can set the workspace's spend limit.", | |
| 686 | )); | |
| 687 | } | |
| 688 | if a.spend_limit_micros.is_some_and(|limit| limit < 0) { | |
| 689 | return Ok(Outcome::fail(FailureCode::Invalid, "A spend limit cannot be negative.")); | |
| 690 | } | |
| Two limits, real invoices, trust that grows by itself, sales signals | 691 | let limit = if a.use_full_limit { JsValue::NULL } else { a.spend_limit_micros.map_or(JsValue::NULL, |limit| (limit as f64).into()) }; |
| Usage limits: unpaid usage can only go so far | 692 | self.db |
| 693 | .prepare( | |
| Two limits, real invoices, trust that grows by itself, sales signals | 694 | "INSERT INTO limits (workspace, spend_limit_micros, spend_limit_full, updated_at) VALUES (?1, ?2, ?3, ?4) |
| 695 | ON CONFLICT (workspace) DO UPDATE SET spend_limit_micros = ?2, spend_limit_full = ?3, updated_at = ?4", | |
| Usage limits: unpaid usage can only go so far | 696 | ) |
| Two limits, real invoices, trust that grows by itself, sales signals | 697 | .bind(&[workspace.as_str().into(), limit, (if a.use_full_limit { 1 } else { 0 }).into(), rfc3339(now_ms()).into()])? |
| Usage limits: unpaid usage can only go so far | 698 | .run() |
| 699 | .await?; | |
| 700 | Ok(Outcome::Ok(self.limit_of(&workspace).await?)) | |
| 701 | } | |
| 702 | } | |
| 703 | ||
| Billing on Stripe's pages, month-end charges, warnings; sudo by workspace | 704 | /// Which warning a workspace has reached: 100, 80, 50 or none (0). |
| 705 | pub(crate) fn warning_level(exposure: i64, ceiling: i64) -> i64 { | |
| 706 | if exposure >= ceiling { | |
| 707 | 100 | |
| 708 | } else if exposure * 5 >= ceiling * 4 { | |
| 709 | 80 | |
| 710 | } else if exposure * 2 >= ceiling { | |
| 711 | 50 | |
| 712 | } else { | |
| 713 | 0 | |
| 714 | } | |
| 715 | } | |
| 716 | ||
| 717 | /// Emails the workspace's owners through identity. False if nothing was sent. | |
| 718 | async fn notify(identity: &worker::Fetcher, workspace: &str, subject: &str, intro: &str, action: &str, link: &str) -> bool { | |
| 719 | let args = g1t_contracts::identity::NotifyOwnersArgs { | |
| 720 | workspace: workspace.to_owned(), | |
| 721 | subject: subject.to_owned(), | |
| 722 | intro: intro.to_owned(), | |
| 723 | action: action.to_owned(), | |
| 724 | link: link.to_owned(), | |
| 725 | footer: "You get this because you own this workspace on g1t. Usage limits are explained at https://docs.g1t.sh/guides/usage-and-billing/#usage-limits".to_owned(), | |
| 726 | }; | |
| 727 | match g1t_kit::call::<_, u32>(identity, "notify_owners", &args).await { | |
| 728 | Ok(sent) => sent > 0, | |
| 729 | Err(error) => { | |
| 730 | worker::console_error!("could not tell {workspace}'s owners: {error}"); | |
| 731 | false | |
| 732 | } | |
| 733 | } | |
| 734 | } | |
| 735 | ||
| 736 | /// `2026-09` for `2026-10`, and `2025-12` for `2026-01`. | |
| 737 | pub(crate) fn previous_month(month: &str) -> String { | |
| 738 | let year: i32 = month[..4].parse().unwrap_or(1970); | |
| 739 | let number: u32 = month[5..7].parse().unwrap_or(1); | |
| 740 | if number == 1 { | |
| 741 | format!("{}-12", year - 1) | |
| 742 | } else { | |
| 743 | format!("{year}-{:02}", number - 1) | |
| 744 | } | |
| 745 | } | |
| 746 | ||
| Usage limits: unpaid usage can only go so far | 747 | #[cfg(test)] |
| 748 | mod tests { | |
| 749 | use super::*; | |
| 750 | ||
| Billing on Stripe's pages, month-end charges, warnings; sudo by workspace | 751 | #[test] |
| Two limits, real invoices, trust that grows by itself, sales signals | 752 | fn the_automatic_spend_limit_follows_last_month() { |
| 753 | assert_eq!(automatic_spend_limit(0), 200_000_000); | |
| 754 | assert_eq!(automatic_spend_limit(50_000_000), 200_000_000); | |
| 755 | assert_eq!(automatic_spend_limit(900_000_000), 1_800_000_000); | |
| 756 | } | |
| 757 | ||
| 758 | #[test] | |
| 759 | fn three_steady_months_make_a_workspace_established() { | |
| 760 | assert_eq!(established_ceiling(&[900_000_000, 850_000_000, 950_000_000]), Some(2_700_000_000)); | |
| 761 | assert_eq!(established_ceiling(&[5_000_000_000, 5_000_000_000, 5_000_000_000]), Some(10_000_000_000)); | |
| 762 | assert_eq!(established_ceiling(&[900_000_000, 10_000_000, 950_000_000]), None); | |
| 763 | assert_eq!(established_ceiling(&[900_000_000, 900_000_000]), None); | |
| 764 | } | |
| 765 | ||
| 766 | #[test] | |
| Billing on Stripe's pages, month-end charges, warnings; sudo by workspace | 767 | fn warnings_come_at_half_four_fifths_and_the_limit() { |
| 768 | assert_eq!(warning_level(0, 300), 0); | |
| 769 | assert_eq!(warning_level(149, 300), 0); | |
| 770 | assert_eq!(warning_level(150, 300), 50); | |
| 771 | assert_eq!(warning_level(240, 300), 80); | |
| 772 | assert_eq!(warning_level(300, 300), 100); | |
| 773 | } | |
| 774 | ||
| 775 | #[test] | |
| 776 | fn the_month_before_wraps_the_year() { | |
| 777 | assert_eq!(previous_month("2026-10"), "2026-09"); | |
| 778 | assert_eq!(previous_month("2026-01"), "2025-12"); | |
| 779 | } | |
| 780 | ||
| Usage limits: unpaid usage can only go so far | 781 | fn ceilings() -> Ceilings { |
| Billing accounts, terms and enterprises; g1t is no longer free | 782 | Ceilings { new: 3_000_000, paid_min: 25_000_000, paid_max: 1_000_000_000 } |
| Usage limits: unpaid usage can only go so far | 783 | } |
| 784 | ||
| 785 | #[test] | |
| 786 | fn trust_grows_with_what_was_paid_within_bounds() { | |
| 787 | assert_eq!(ceilings().for_paid(5_000_000), 25_000_000); | |
| 788 | assert_eq!(ceilings().for_paid(100_000_000), 200_000_000); | |
| 789 | assert_eq!(ceilings().for_paid(10_000_000_000), 1_000_000_000); | |
| 790 | } | |
| 791 | ||
| 792 | #[test] | |
| 793 | fn work_warns_at_eighty_percent_and_stops_at_the_ceiling() { | |
| 794 | assert_eq!(state(0, Some(100)), LimitState::Ok); | |
| 795 | assert_eq!(state(79, Some(100)), LimitState::Ok); | |
| 796 | assert_eq!(state(80, Some(100)), LimitState::Warning); | |
| 797 | assert_eq!(state(100, Some(100)), LimitState::Stopped); | |
| 798 | assert_eq!(state(1_000_000, None), LimitState::Ok); | |
| 799 | } | |
| 800 | } |