pr_01m47d24b0e6n91zwymwxg0vpx/services/billing/src/limits.rs

711 lines31,632 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.

Usage limits: unpaid usage can only go so far1//! 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 free24use g1t_contracts::billing::{CheckLimitArgs, Limit, LimitArgs, NotePendingArgs, TermsKind, LimitState, SetSpendLimitArgs, Trust};
Usage limits: unpaid usage can only go so far25use g1t_contracts::time::rfc3339;
26use g1t_contracts::{FailureCode, Outcome, Role};
27use g1t_kit::now_ms;
28use serde::Deserialize;
29use worker::wasm_bindgen::JsValue;
30use worker::{Env, Result};
31
32use crate::features::dollars as dollars_plain;
33use crate::{Billing, members_only};
34
35/// The ceilings, from the billing service's variables.
36pub(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
44impl 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.
63pub(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
Billing accounts, terms and enterprises; g1t is no longer free71/// Never charged automatically for less.
72const AUTOPAY_MIN_CENTS: i64 = 500;
73
Usage limits: unpaid usage can only go so far74#[derive(Deserialize)]
75struct LimitRow {
76 spend_limit_micros: Option<i64>,
Billing accounts, terms and enterprises; g1t is no longer free77 autopay_failed_at: Option<String>,
78 autopay_error: Option<String>,
Usage limits: unpaid usage can only go so far79}
80
81#[derive(Deserialize)]
82struct Month {
83 used: Option<i64>,
84 paid: Option<i64>,
85}
86
87#[derive(Deserialize)]
88struct Paid {
89 paid: Option<i64>,
90}
91
92impl Billing {
Billing accounts, terms and enterprises; g1t is no longer free93 /// The workspace's limit, worked out from the ledger of the account
94 /// that pays for it: its own, or its enterprise's, whose workspaces'
95 /// usage and payments count together.
Usage limits: unpaid usage can only go so far96 pub(crate) async fn limit_of(&self, workspace: &str) -> Result<Limit> {
97 let workspace = workspace.to_lowercase();
Billing accounts, terms and enterprises; g1t is no longer free98 let account = self.account_of(&workspace).await?;
Usage limits: unpaid usage can only go so far99 let row = self
100 .db
Billing accounts, terms and enterprises; g1t is no longer free101 .prepare("SELECT spend_limit_micros, autopay_failed_at, autopay_error FROM limits WHERE workspace = ?")
Usage limits: unpaid usage can only go so far102 .bind(&[workspace.as_str().into()])?
103 .first::<LimitRow>(None)
104 .await?;
105 let month_start = format!("{}-01", &rfc3339(now_ms())[..7]);
Billing accounts, terms and enterprises; g1t is no longer free106 let marks = vec!["?"; account.workspaces.len().max(1)].join(", ");
107 let members: Vec<JsValue> = if account.workspaces.is_empty() {
108 vec![JsValue::from(workspace.as_str())]
109 } else {
110 account.workspaces.iter().map(|w| JsValue::from(w.as_str())).collect()
111 };
112 let mut with_month = members.clone();
113 with_month.push(month_start.as_str().into());
Usage limits: unpaid usage can only go so far114 // Each usage entry at its cost to g1t or its charge, whichever is
115 // more; on the workspace's own provider, only g1t's fee is g1t's.
116 let month = self
117 .db
Billing accounts, terms and enterprises; g1t is no longer free118 .prepare(format!(
Usage limits: unpaid usage can only go so far119 "SELECT
120 SUM(CASE WHEN kind = 'usage' THEN
121 CASE WHEN COALESCE(billed_to, 'g1t') = 'g1t'
122 THEN MAX(COALESCE(cost_micros, 0), -amount_micros)
123 ELSE -amount_micros END
124 END) AS used,
125 SUM(CASE WHEN kind = 'top_up' THEN amount_micros END) AS paid
Billing accounts, terms and enterprises; g1t is no longer free126 FROM ledger WHERE workspace IN ({marks}) AND created_at >= ?"
127 ))
128 .bind(&with_month)?
Usage limits: unpaid usage can only go so far129 .first::<Month>(None)
130 .await?;
131 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 pays132 // And what is metered but not charged until the month closes.
Billing accounts, terms and enterprises; g1t is no longer free133 let mut pending_args = members.clone();
134 pending_args.push(month_start[..7].into());
Prices keep themselves current with what g1t pays135 let pending = self
136 .db
Billing accounts, terms and enterprises; g1t is no longer free137 .prepare(format!(
138 "SELECT SUM(charge_micros) AS paid FROM pending_usage WHERE workspace IN ({marks}) AND month = ?"
139 ))
140 .bind(&pending_args)?
Prices keep themselves current with what g1t pays141 .first::<Paid>(None)
142 .await?
143 .and_then(|row| row.paid)
144 .unwrap_or(0);
145 let used = used + pending;
Usage limits: unpaid usage can only go so far146 // Test-mode payments are not money: they pay nothing off.
147 let live = self.stripe.as_ref().is_some_and(crate::stripe::Stripe::live);
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace148 // Charges from earlier months still unpaid carry over, so a new
149 // month is not a fresh allowance for an account that never pays.
150 // Credits g1t gave count as paid; test-mode payments do not.
151 let mut before = members.clone();
152 before.push(month_start.as_str().into());
153 let carried = self
154 .db
155 .prepare(format!(
156 "SELECT SUM(CASE WHEN kind = 'usage' THEN amount_micros
157 WHEN kind = 'top_up' AND ({live} = 1 OR reference LIKE 'crd%') THEN amount_micros
158 ELSE 0 END) AS paid
159 FROM ledger WHERE workspace IN ({marks}) AND created_at < ?",
160 live = u8::from(live)
161 ))
162 .bind(&before)?
163 .first::<Paid>(None)
164 .await?
165 .and_then(|row| row.paid)
166 .map_or(0, |balance| (-balance).max(0));
167 let exposure = (used - if live { paid_month } else { 0 }).max(0) + carried;
Usage limits: unpaid usage can only go so far168
Billing accounts, terms and enterprises; g1t is no longer free169 let (trust, trust_ceiling) = match account.terms.kind {
170 TermsKind::Comped => (Trust::Internal, None),
171 _ if account.terms.ceiling_micros.is_some() => (Trust::Reviewed, account.terms.ceiling_micros),
172 _ => {
173 let paid = self.live_paid(&members).await?;
174 if paid > 0 {
175 (Trust::Paid, Some(self.ceilings.for_paid(paid)))
176 } else {
177 (Trust::New, Some(self.ceilings.new))
178 }
Usage limits: unpaid usage can only go so far179 }
180 };
Billing accounts, terms and enterprises; g1t is no longer free181 let spend_limit = row.as_ref().and_then(|row| row.spend_limit_micros);
182 // A card declined when g1t charged it at the limit stops work until
183 // it is paid; any payment clears it.
184 let declined = row.as_ref().and_then(|row| row.autopay_failed_at.clone().map(|at| (at, row.autopay_error.clone())));
Usage limits: unpaid usage can only go so far185 let ceiling = match (trust_ceiling, spend_limit) {
186 (Some(ceiling), Some(own)) => Some(ceiling.min(own)),
187 (None, Some(own)) => Some(own),
188 (ceiling, None) => ceiling,
189 };
Billing accounts, terms and enterprises; g1t is no longer free190 let state = if declined.is_some() && exposure > 0 { LimitState::Stopped } else { state(exposure, ceiling) };
191 let who = if account.kind == g1t_contracts::billing::AccountKind::Enterprise {
192 format!("The {} enterprise, which pays for {workspace},", account.name)
193 } else {
194 format!("The {workspace} workspace")
195 };
Usage limits: unpaid usage can only go so far196 let message = match state {
197 LimitState::Ok => None,
198 LimitState::Warning => Some(format!(
Billing accounts, terms and enterprises; g1t is no longer free199 "{who} has used {} of its {} limit this month. At the limit, its sandboxes, builds and apps stop until it pays or the month turns.",
Usage limits: unpaid usage can only go so far200 dollars_plain(exposure),
201 dollars_plain(ceiling.unwrap_or_default()),
202 )),
Billing accounts, terms and enterprises; g1t is no longer free203 LimitState::Stopped if declined.is_some() => Some(format!(
204 "{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.",
205 declined.as_ref().and_then(|(_, error)| error.clone()).unwrap_or_else(|| "the card was declined".to_owned()),
206 )),
Usage limits: unpaid usage can only go so far207 LimitState::Stopped => Some(if spend_limit.is_some() && ceiling == spend_limit {
208 format!(
209 "The {workspace} workspace reached the {} spend limit its owners set for this month, so its sandboxes, builds and apps are stopped. An owner can raise it under Billing.",
210 dollars_plain(ceiling.unwrap_or_default()),
211 )
212 } else {
213 format!(
Billing accounts, terms and enterprises; g1t is no longer free214 "{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 far215 dollars_plain(ceiling.unwrap_or_default()),
216 )
217 }),
218 };
219 Ok(Limit {
220 workspace,
Billing accounts, terms and enterprises; g1t is no longer free221 account: account.id,
222 account_name: account.name,
Usage limits: unpaid usage can only go so far223 trust,
224 exposure_micros: exposure,
225 ceiling_micros: ceiling,
226 trust_ceiling_micros: trust_ceiling,
227 spend_limit_micros: spend_limit,
228 state,
229 message,
230 })
231 }
232
Billing accounts, terms and enterprises; g1t is no longer free233 /// Real money the workspaces have paid g1t. Nothing in test mode, and
234 /// credits g1t gave are not payments.
235 async fn live_paid(&self, members: &[JsValue]) -> Result<i64> {
Usage limits: unpaid usage can only go so far236 if !self.stripe.as_ref().is_some_and(crate::stripe::Stripe::live) {
237 return Ok(0);
238 }
Billing accounts, terms and enterprises; g1t is no longer free239 let marks = vec!["?"; members.len().max(1)].join(", ");
Usage limits: unpaid usage can only go so far240 Ok(self
241 .db
Billing accounts, terms and enterprises; g1t is no longer free242 .prepare(format!(
243 "SELECT SUM(amount_micros) AS paid FROM ledger
244 WHERE workspace IN ({marks}) AND kind = 'top_up' AND reference NOT LIKE 'crd%'"
245 ))
246 .bind(members)?
Usage limits: unpaid usage can only go so far247 .first::<Paid>(None)
248 .await?
249 .and_then(|row| row.paid)
250 .unwrap_or(0))
251 }
252
253 /// A refusal, with the reason, when the workspace's work is stopped.
254 /// None while billing is off: a g1t without payments has no limits.
255 pub(crate) async fn stopped<T>(&self, workspace: &str) -> Result<Option<Outcome<T>>> {
256 if self.stripe.is_none() {
257 return Ok(None);
258 }
259 let limit = self.limit_of(workspace).await?;
260 Ok((limit.state == LimitState::Stopped).then(|| {
261 Outcome::fail(
262 FailureCode::PaymentRequired,
263 limit.message.unwrap_or_else(|| "This workspace is over its limit.".to_owned()),
264 )
265 }))
266 }
267
268 pub(crate) async fn limit(&self, a: LimitArgs) -> Result<Outcome<Limit>> {
269 let workspace = a.workspace.to_lowercase();
270 if !a.viewer.is_some_and(|viewer| viewer.is_member(&workspace)) {
271 return Ok(members_only());
272 }
273 Ok(Outcome::Ok(self.limit_of(&workspace).await?))
274 }
275
Prices keep themselves current with what g1t pays276 pub(crate) async fn note_pending(&self, a: NotePendingArgs) -> Result<bool> {
277 let now = rfc3339(now_ms());
278 let charge = crate::charge_micros(a.cost_micros.max(0) as f64 / g1t_contracts::billing::MICROS_PER_DOLLAR as f64, self.margin_percent);
279 self.db
280 .prepare(
281 "INSERT INTO pending_usage (workspace, source, month, charge_micros, updated_at) VALUES (?1, ?2, ?3, ?4, ?5)
282 ON CONFLICT (workspace, source, month) DO UPDATE SET charge_micros = ?4, updated_at = ?5",
283 )
284 .bind(&[
285 a.workspace.to_lowercase().into(),
286 a.source.as_str().into(),
287 now[..7].into(),
288 (charge as f64).into(),
289 now.as_str().into(),
290 ])?
291 .run()
292 .await?;
293 Ok(true)
294 }
295
Billing accounts, terms and enterprises; g1t is no longer free296 /// Charges the saved card of each workspace nearing its limit, for what
297 /// it owes, so that a workspace that pays never has its work stopped.
298 /// Only with live payments: test-mode payments are not money and lower
299 /// nothing. Not for a workspace's own spend limit, which means stop, nor
300 /// for enterprises, which are invoiced.
301 pub(crate) async fn autopay(&self) -> Result<()> {
302 let Some(stripe) = self.stripe.as_ref().filter(|stripe| stripe.live()) else {
303 return Ok(());
304 };
305 #[derive(Deserialize)]
306 struct Candidate {
307 workspace: String,
308 customer_id: Option<String>,
309 }
310 let month_start = format!("{}-01", &rfc3339(now_ms())[..7]);
311 let candidates = self
312 .db
313 .prepare(
314 "SELECT DISTINCT ledger.workspace AS workspace, accounts.customer_id AS customer_id
315 FROM ledger JOIN accounts ON accounts.workspace = ledger.workspace
316 WHERE ledger.kind = 'usage' AND ledger.created_at >= ? AND accounts.customer_id IS NOT NULL",
317 )
318 .bind(&[month_start.as_str().into()])?
319 .all()
320 .await?
321 .results::<Candidate>()?;
322 for candidate in candidates {
323 let Some(customer) = candidate.customer_id else { continue };
324 let limit = self.limit_of(&candidate.workspace).await?;
325 let own_limit = limit.spend_limit_micros.is_some() && limit.ceiling_micros == limit.spend_limit_micros;
326 if limit.state == LimitState::Ok
327 || own_limit
328 || limit.trust == Trust::Internal
329 || limit.account.starts_with("ent_")
330 {
331 continue;
332 }
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace333 // What it owes: its charges less what it has paid, never the cost
334 // of what was free to it. At least the minimum, which is credit
335 // toward what comes next.
336 let balance = self.row(&candidate.workspace).await?.map_or(0, |row| row.balance_micros);
337 let owed = (-balance).max(0);
338 if owed == 0 {
339 continue;
340 }
341 let cents = ((owed + 9_999) / 10_000).max(AUTOPAY_MIN_CENTS);
342 let key = format!("autopay/{}/{}/{}", candidate.workspace, &month_start[..7], owed / 1_000_000);
Billing accounts, terms and enterprises; g1t is no longer free343 let description = format!("g1t usage for {}, paid automatically near its limit", candidate.workspace);
344 let now = rfc3339(now_ms());
345 match stripe.charge_saved_card(&customer, cents, &description, &key).await {
346 Ok(payment) if payment.status == "succeeded" => {
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace347 // A retried charge is the same payment: credited once.
348 let seen = self
349 .db
350 .prepare("SELECT id FROM ledger WHERE reference = ?")
351 .bind(&[payment.id.as_str().into()])?
352 .first::<serde_json::Value>(None)
353 .await?;
354 if seen.is_some() {
355 continue;
356 }
Billing accounts, terms and enterprises; g1t is no longer free357 self.enter(
358 &candidate.workspace,
359 g1t_contracts::billing::EntryKind::TopUp,
360 payment.amount_received.max(cents) * 10_000,
361 &format!("Paid automatically by card, near the {} limit", dollars_plain(limit.ceiling_micros.unwrap_or_default())),
362 &payment.id,
363 None,
364 None,
365 None,
366 Some(&customer),
367 )
368 .await?;
369 self.db
370 .prepare("UPDATE limits SET autopay_failed_at = NULL, autopay_error = NULL WHERE workspace = ?")
371 .bind(&[candidate.workspace.as_str().into()])?
372 .run()
373 .await?;
374 }
375 outcome => {
376 let error = match outcome {
377 Ok(payment) => format!("the payment is {}", payment.status.replace('_', " ")),
378 Err(error) => error.to_string().chars().take(200).collect(),
379 };
380 self.db
381 .prepare(
382 "INSERT INTO limits (workspace, autopay_failed_at, autopay_error, updated_at) VALUES (?1, ?2, ?3, ?2)
383 ON CONFLICT (workspace) DO UPDATE SET autopay_failed_at = ?2, autopay_error = ?3, updated_at = ?2",
384 )
385 .bind(&[candidate.workspace.as_str().into(), now.as_str().into(), error.as_str().into()])?
386 .run()
387 .await?;
388 }
389 }
390 }
391 Ok(())
392 }
393
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace394 /// Closes last month for each workspace with a card on file: charges
395 /// what it owed when the month ended. Live payments only, once per
396 /// workspace and month; a declined card stops work until it is paid.
397 /// Comped workspaces owe nothing, and enterprises are invoiced.
398 pub(crate) async fn close_months(&self) -> Result<()> {
399 let Some(stripe) = self.stripe.as_ref().filter(|stripe| stripe.live()) else {
400 return Ok(());
401 };
402 let now = rfc3339(now_ms());
403 let month_start = format!("{}-01", &now[..7]);
404 let closing = previous_month(&now[..7]);
405 #[derive(Deserialize)]
406 struct Open {
407 workspace: String,
408 customer_id: String,
409 balance: Option<i64>,
410 }
411 let open = self
412 .db
413 .prepare(
414 "SELECT accounts.workspace AS workspace, accounts.customer_id AS customer_id,
415 (SELECT SUM(amount_micros) FROM ledger
416 WHERE ledger.workspace = accounts.workspace AND ledger.created_at < ?1) AS balance
417 FROM accounts
418 WHERE accounts.customer_id IS NOT NULL
419 AND NOT EXISTS (SELECT 1 FROM month_closes
420 WHERE month_closes.workspace = accounts.workspace AND month_closes.month = ?2)
421 LIMIT 20",
422 )
423 .bind(&[month_start.as_str().into(), closing.as_str().into()])?
424 .all()
425 .await?
426 .results::<Open>()?;
427 for account in open {
428 let record = |status: &str, amount: i64, payment: Option<&str>, error: Option<&str>| {
429 self.db
430 .prepare(
431 "INSERT OR IGNORE INTO month_closes (workspace, month, status, amount_micros, payment_id, error, closed_at)
432 VALUES (?, ?, ?, ?, ?, ?, ?)",
433 )
434 .bind(&[
435 account.workspace.as_str().into(),
436 closing.as_str().into(),
437 status.into(),
438 (amount as f64).into(),
439 crate::optional(payment),
440 crate::optional(error),
441 now.as_str().into(),
442 ])
443 };
444 let payer = self.account_of(&account.workspace).await?;
445 if payer.terms.kind == TermsKind::Comped || payer.id.starts_with("ent_") {
446 record("skipped", 0, None, None)?.run().await?;
447 continue;
448 }
449 let owed = (-account.balance.unwrap_or(0)).max(0);
450 if owed < 10_000 {
451 // Under a cent: nothing worth charging.
452 record("nothing", 0, None, None)?.run().await?;
453 continue;
454 }
455 let cents = (owed + 9_999) / 10_000;
456 let key = format!("close/{}/{closing}", account.workspace);
457 let description = format!("g1t usage for {} in {closing}", account.workspace);
458 match stripe.charge_saved_card(&account.customer_id, cents, &description, &key).await {
459 Ok(payment) if payment.status == "succeeded" => {
460 let seen = self
461 .db
462 .prepare("SELECT id FROM ledger WHERE reference = ?")
463 .bind(&[payment.id.as_str().into()])?
464 .first::<serde_json::Value>(None)
465 .await?;
466 if seen.is_none() {
467 self.enter(
468 &account.workspace,
469 g1t_contracts::billing::EntryKind::TopUp,
470 payment.amount_received.max(cents) * 10_000,
471 &format!("Usage for {closing}, charged to the card on file when the month closed"),
472 &payment.id,
473 None,
474 None,
475 None,
476 Some(&account.customer_id),
477 )
478 .await?;
479 }
480 record("paid", cents * 10_000, Some(&payment.id), None)?.run().await?;
481 }
482 outcome => {
483 let error = match outcome {
484 Ok(payment) => format!("the payment is {}", payment.status.replace('_', " ")),
485 Err(error) => error.to_string().chars().take(200).collect(),
486 };
487 self.db
488 .prepare(
489 "INSERT INTO limits (workspace, autopay_failed_at, autopay_error, updated_at) VALUES (?1, ?2, ?3, ?2)
490 ON CONFLICT (workspace) DO UPDATE SET autopay_failed_at = ?2, autopay_error = ?3, updated_at = ?2",
491 )
492 .bind(&[account.workspace.as_str().into(), now.as_str().into(), error.as_str().into()])?
493 .run()
494 .await?;
495 record("failed", cents * 10_000, None, Some(&error))?.run().await?;
496 }
497 }
498 }
499 Ok(())
500 }
501
502 /// Emails a workspace's owners as it passes 50%, 80% and 100% of its
503 /// limit, once each a month, and when its card was declined, so that
504 /// work never stops without warning.
505 pub(crate) async fn warn_limits(&self, identity: &worker::Fetcher) -> Result<()> {
506 if self.stripe.is_none() {
507 return Ok(());
508 }
509 let now = rfc3339(now_ms());
510 let month = &now[..7];
511 #[derive(Deserialize)]
512 struct Candidate {
513 workspace: String,
514 }
515 let candidates = self
516 .db
517 .prepare(
518 "SELECT DISTINCT workspace FROM ledger WHERE kind = 'usage' AND created_at >= ?1
519 UNION SELECT workspace FROM limits WHERE autopay_failed_at IS NOT NULL",
520 )
521 .bind(&[format!("{month}-01").into()])?
522 .all()
523 .await?
524 .results::<Candidate>()?;
525 #[derive(Deserialize)]
526 struct Told {
527 warned_month: Option<String>,
528 warned_level: Option<i64>,
529 autopay_failed_at: Option<String>,
530 declined_told_at: Option<String>,
531 }
532 for Candidate { workspace } in candidates {
533 let limit = self.limit_of(&workspace).await?;
534 let told = self
535 .db
536 .prepare("SELECT warned_month, warned_level, autopay_failed_at, declined_told_at FROM limits WHERE workspace = ?")
537 .bind(&[workspace.as_str().into()])?
538 .first::<Told>(None)
539 .await?;
540 let billing = format!("https://g1t.sh/{workspace}/-/billing");
541
542 // A declined card, once per decline.
543 if let Some(Told { autopay_failed_at: Some(failed), declined_told_at, .. }) = &told {
544 if declined_told_at.as_deref().is_none_or(|at| at < failed.as_str()) {
545 let sent = notify(
546 identity,
547 &workspace,
548 &format!("g1t: the card for {workspace} was declined"),
549 &limit.message.clone().unwrap_or_else(|| format!("g1t could not charge the card on file for {workspace}.")),
550 "Update the card",
551 &billing,
552 )
553 .await;
554 if sent {
555 self.db
556 .prepare("UPDATE limits SET declined_told_at = ? WHERE workspace = ?")
557 .bind(&[now.as_str().into(), workspace.as_str().into()])?
558 .run()
559 .await?;
560 }
561 }
562 }
563
564 let Some(ceiling) = limit.ceiling_micros.filter(|c| *c > 0) else { continue };
565 let level = warning_level(limit.exposure_micros, ceiling);
566 let already = told
567 .as_ref()
568 .filter(|t| t.warned_month.as_deref() == Some(month))
569 .and_then(|t| t.warned_level)
570 .unwrap_or(0);
571 if level <= already {
572 continue;
573 }
574 let (subject, intro) = match level {
575 100 => (
576 format!("g1t: {workspace} reached its usage limit"),
577 limit.message.clone().unwrap_or_else(|| format!("{workspace} reached its usage limit.")),
578 ),
579 _ => (
580 format!("g1t: {workspace} has used {level}% of its usage limit"),
581 format!(
582 "{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.",
583 dollars_plain(limit.exposure_micros),
584 dollars_plain(ceiling),
585 ),
586 ),
587 };
588 if notify(identity, &workspace, &subject, &intro, "Open billing", &billing).await {
589 self.db
590 .prepare(
591 "INSERT INTO limits (workspace, warned_month, warned_level, updated_at) VALUES (?1, ?2, ?3, ?4)
592 ON CONFLICT (workspace) DO UPDATE SET warned_month = ?2, warned_level = ?3, updated_at = ?4",
593 )
594 .bind(&[workspace.as_str().into(), month.into(), (level as f64).into(), now.as_str().into()])?
595 .run()
596 .await?;
597 }
598 }
599 Ok(())
600 }
601
Usage limits: unpaid usage can only go so far602 pub(crate) async fn check_limit(&self, a: CheckLimitArgs) -> Result<Outcome<Limit>> {
603 Ok(Outcome::Ok(self.limit_of(&a.workspace).await?))
604 }
605
606 pub(crate) async fn set_spend_limit(&self, a: SetSpendLimitArgs) -> Result<Outcome<Limit>> {
607 let workspace = a.workspace.to_lowercase();
608 if a.actor.role_in(&workspace) != Some(Role::Owner) {
609 return Ok(Outcome::fail(
610 FailureCode::Forbidden,
611 "Only an owner can set the workspace's spend limit.",
612 ));
613 }
614 if a.spend_limit_micros.is_some_and(|limit| limit < 0) {
615 return Ok(Outcome::fail(FailureCode::Invalid, "A spend limit cannot be negative."));
616 }
617 let limit = a.spend_limit_micros.map_or(JsValue::NULL, |limit| (limit as f64).into());
618 self.db
619 .prepare(
620 "INSERT INTO limits (workspace, spend_limit_micros, updated_at) VALUES (?1, ?2, ?3)
621 ON CONFLICT (workspace) DO UPDATE SET spend_limit_micros = ?2, updated_at = ?3",
622 )
623 .bind(&[workspace.as_str().into(), limit, rfc3339(now_ms()).into()])?
624 .run()
625 .await?;
626 Ok(Outcome::Ok(self.limit_of(&workspace).await?))
627 }
628}
629
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace630/// Which warning a workspace has reached: 100, 80, 50 or none (0).
631pub(crate) fn warning_level(exposure: i64, ceiling: i64) -> i64 {
632 if exposure >= ceiling {
633 100
634 } else if exposure * 5 >= ceiling * 4 {
635 80
636 } else if exposure * 2 >= ceiling {
637 50
638 } else {
639 0
640 }
641}
642
643/// Emails the workspace's owners through identity. False if nothing was sent.
644async fn notify(identity: &worker::Fetcher, workspace: &str, subject: &str, intro: &str, action: &str, link: &str) -> bool {
645 let args = g1t_contracts::identity::NotifyOwnersArgs {
646 workspace: workspace.to_owned(),
647 subject: subject.to_owned(),
648 intro: intro.to_owned(),
649 action: action.to_owned(),
650 link: link.to_owned(),
651 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(),
652 };
653 match g1t_kit::call::<_, u32>(identity, "notify_owners", &args).await {
654 Ok(sent) => sent > 0,
655 Err(error) => {
656 worker::console_error!("could not tell {workspace}'s owners: {error}");
657 false
658 }
659 }
660}
661
662/// `2026-09` for `2026-10`, and `2025-12` for `2026-01`.
663pub(crate) fn previous_month(month: &str) -> String {
664 let year: i32 = month[..4].parse().unwrap_or(1970);
665 let number: u32 = month[5..7].parse().unwrap_or(1);
666 if number == 1 {
667 format!("{}-12", year - 1)
668 } else {
669 format!("{year}-{:02}", number - 1)
670 }
671}
672
Usage limits: unpaid usage can only go so far673#[cfg(test)]
674mod tests {
675 use super::*;
676
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace677 #[test]
678 fn warnings_come_at_half_four_fifths_and_the_limit() {
679 assert_eq!(warning_level(0, 300), 0);
680 assert_eq!(warning_level(149, 300), 0);
681 assert_eq!(warning_level(150, 300), 50);
682 assert_eq!(warning_level(240, 300), 80);
683 assert_eq!(warning_level(300, 300), 100);
684 }
685
686 #[test]
687 fn the_month_before_wraps_the_year() {
688 assert_eq!(previous_month("2026-10"), "2026-09");
689 assert_eq!(previous_month("2026-01"), "2025-12");
690 }
691
Usage limits: unpaid usage can only go so far692 fn ceilings() -> Ceilings {
Billing accounts, terms and enterprises; g1t is no longer free693 Ceilings { new: 3_000_000, paid_min: 25_000_000, paid_max: 1_000_000_000 }
Usage limits: unpaid usage can only go so far694 }
695
696 #[test]
697 fn trust_grows_with_what_was_paid_within_bounds() {
698 assert_eq!(ceilings().for_paid(5_000_000), 25_000_000);
699 assert_eq!(ceilings().for_paid(100_000_000), 200_000_000);
700 assert_eq!(ceilings().for_paid(10_000_000_000), 1_000_000_000);
701 }
702
703 #[test]
704 fn work_warns_at_eighty_percent_and_stops_at_the_ceiling() {
705 assert_eq!(state(0, Some(100)), LimitState::Ok);
706 assert_eq!(state(79, Some(100)), LimitState::Ok);
707 assert_eq!(state(80, Some(100)), LimitState::Warning);
708 assert_eq!(state(100, Some(100)), LimitState::Stopped);
709 assert_eq!(state(1_000_000, None), LimitState::Ok);
710 }
711}