pr_01m47d15m3e54sn21z27rpy5n9/services/billing/src/limits.rs

408 lines17,975 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);
148 let exposure = (used - if live { paid_month } else { 0 }).max(0);
149
Billing accounts, terms and enterprises; g1t is no longer free150 let (trust, trust_ceiling) = match account.terms.kind {
151 TermsKind::Comped => (Trust::Internal, None),
152 _ if account.terms.ceiling_micros.is_some() => (Trust::Reviewed, account.terms.ceiling_micros),
153 _ => {
154 let paid = self.live_paid(&members).await?;
155 if paid > 0 {
156 (Trust::Paid, Some(self.ceilings.for_paid(paid)))
157 } else {
158 (Trust::New, Some(self.ceilings.new))
159 }
Usage limits: unpaid usage can only go so far160 }
161 };
Billing accounts, terms and enterprises; g1t is no longer free162 let spend_limit = row.as_ref().and_then(|row| row.spend_limit_micros);
163 // A card declined when g1t charged it at the limit stops work until
164 // it is paid; any payment clears it.
165 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 far166 let ceiling = match (trust_ceiling, spend_limit) {
167 (Some(ceiling), Some(own)) => Some(ceiling.min(own)),
168 (None, Some(own)) => Some(own),
169 (ceiling, None) => ceiling,
170 };
Billing accounts, terms and enterprises; g1t is no longer free171 let state = if declined.is_some() && exposure > 0 { LimitState::Stopped } else { state(exposure, ceiling) };
172 let who = if account.kind == g1t_contracts::billing::AccountKind::Enterprise {
173 format!("The {} enterprise, which pays for {workspace},", account.name)
174 } else {
175 format!("The {workspace} workspace")
176 };
Usage limits: unpaid usage can only go so far177 let message = match state {
178 LimitState::Ok => None,
179 LimitState::Warning => Some(format!(
Billing accounts, terms and enterprises; g1t is no longer free180 "{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 far181 dollars_plain(exposure),
182 dollars_plain(ceiling.unwrap_or_default()),
183 )),
Billing accounts, terms and enterprises; g1t is no longer free184 LimitState::Stopped if declined.is_some() => Some(format!(
185 "{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.",
186 declined.as_ref().and_then(|(_, error)| error.clone()).unwrap_or_else(|| "the card was declined".to_owned()),
187 )),
Usage limits: unpaid usage can only go so far188 LimitState::Stopped => Some(if spend_limit.is_some() && ceiling == spend_limit {
189 format!(
190 "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.",
191 dollars_plain(ceiling.unwrap_or_default()),
192 )
193 } else {
194 format!(
Billing accounts, terms and enterprises; g1t is no longer free195 "{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 far196 dollars_plain(ceiling.unwrap_or_default()),
197 )
198 }),
199 };
200 Ok(Limit {
201 workspace,
Billing accounts, terms and enterprises; g1t is no longer free202 account: account.id,
203 account_name: account.name,
Usage limits: unpaid usage can only go so far204 trust,
205 exposure_micros: exposure,
206 ceiling_micros: ceiling,
207 trust_ceiling_micros: trust_ceiling,
208 spend_limit_micros: spend_limit,
209 state,
210 message,
211 })
212 }
213
Billing accounts, terms and enterprises; g1t is no longer free214 /// Real money the workspaces have paid g1t. Nothing in test mode, and
215 /// credits g1t gave are not payments.
216 async fn live_paid(&self, members: &[JsValue]) -> Result<i64> {
Usage limits: unpaid usage can only go so far217 if !self.stripe.as_ref().is_some_and(crate::stripe::Stripe::live) {
218 return Ok(0);
219 }
Billing accounts, terms and enterprises; g1t is no longer free220 let marks = vec!["?"; members.len().max(1)].join(", ");
Usage limits: unpaid usage can only go so far221 Ok(self
222 .db
Billing accounts, terms and enterprises; g1t is no longer free223 .prepare(format!(
224 "SELECT SUM(amount_micros) AS paid FROM ledger
225 WHERE workspace IN ({marks}) AND kind = 'top_up' AND reference NOT LIKE 'crd%'"
226 ))
227 .bind(members)?
Usage limits: unpaid usage can only go so far228 .first::<Paid>(None)
229 .await?
230 .and_then(|row| row.paid)
231 .unwrap_or(0))
232 }
233
234 /// A refusal, with the reason, when the workspace's work is stopped.
235 /// None while billing is off: a g1t without payments has no limits.
236 pub(crate) async fn stopped<T>(&self, workspace: &str) -> Result<Option<Outcome<T>>> {
237 if self.stripe.is_none() {
238 return Ok(None);
239 }
240 let limit = self.limit_of(workspace).await?;
241 Ok((limit.state == LimitState::Stopped).then(|| {
242 Outcome::fail(
243 FailureCode::PaymentRequired,
244 limit.message.unwrap_or_else(|| "This workspace is over its limit.".to_owned()),
245 )
246 }))
247 }
248
249 pub(crate) async fn limit(&self, a: LimitArgs) -> Result<Outcome<Limit>> {
250 let workspace = a.workspace.to_lowercase();
251 if !a.viewer.is_some_and(|viewer| viewer.is_member(&workspace)) {
252 return Ok(members_only());
253 }
254 Ok(Outcome::Ok(self.limit_of(&workspace).await?))
255 }
256
Prices keep themselves current with what g1t pays257 pub(crate) async fn note_pending(&self, a: NotePendingArgs) -> Result<bool> {
258 let now = rfc3339(now_ms());
259 let charge = crate::charge_micros(a.cost_micros.max(0) as f64 / g1t_contracts::billing::MICROS_PER_DOLLAR as f64, self.margin_percent);
260 self.db
261 .prepare(
262 "INSERT INTO pending_usage (workspace, source, month, charge_micros, updated_at) VALUES (?1, ?2, ?3, ?4, ?5)
263 ON CONFLICT (workspace, source, month) DO UPDATE SET charge_micros = ?4, updated_at = ?5",
264 )
265 .bind(&[
266 a.workspace.to_lowercase().into(),
267 a.source.as_str().into(),
268 now[..7].into(),
269 (charge as f64).into(),
270 now.as_str().into(),
271 ])?
272 .run()
273 .await?;
274 Ok(true)
275 }
276
Billing accounts, terms and enterprises; g1t is no longer free277 /// Charges the saved card of each workspace nearing its limit, for what
278 /// it owes, so that a workspace that pays never has its work stopped.
279 /// Only with live payments: test-mode payments are not money and lower
280 /// nothing. Not for a workspace's own spend limit, which means stop, nor
281 /// for enterprises, which are invoiced.
282 pub(crate) async fn autopay(&self) -> Result<()> {
283 let Some(stripe) = self.stripe.as_ref().filter(|stripe| stripe.live()) else {
284 return Ok(());
285 };
286 #[derive(Deserialize)]
287 struct Candidate {
288 workspace: String,
289 customer_id: Option<String>,
290 }
291 let month_start = format!("{}-01", &rfc3339(now_ms())[..7]);
292 let candidates = self
293 .db
294 .prepare(
295 "SELECT DISTINCT ledger.workspace AS workspace, accounts.customer_id AS customer_id
296 FROM ledger JOIN accounts ON accounts.workspace = ledger.workspace
297 WHERE ledger.kind = 'usage' AND ledger.created_at >= ? AND accounts.customer_id IS NOT NULL",
298 )
299 .bind(&[month_start.as_str().into()])?
300 .all()
301 .await?
302 .results::<Candidate>()?;
303 for candidate in candidates {
304 let Some(customer) = candidate.customer_id else { continue };
305 let limit = self.limit_of(&candidate.workspace).await?;
306 let own_limit = limit.spend_limit_micros.is_some() && limit.ceiling_micros == limit.spend_limit_micros;
307 if limit.state == LimitState::Ok
308 || own_limit
309 || limit.trust == Trust::Internal
310 || limit.account.starts_with("ent_")
311 {
312 continue;
313 }
314 let cents = ((limit.exposure_micros + 9_999) / 10_000).max(AUTOPAY_MIN_CENTS);
315 let key = format!("autopay/{}/{}/{}", candidate.workspace, &month_start[..7], limit.exposure_micros / 1_000_000);
316 let description = format!("g1t usage for {}, paid automatically near its limit", candidate.workspace);
317 let now = rfc3339(now_ms());
318 match stripe.charge_saved_card(&customer, cents, &description, &key).await {
319 Ok(payment) if payment.status == "succeeded" => {
320 self.enter(
321 &candidate.workspace,
322 g1t_contracts::billing::EntryKind::TopUp,
323 payment.amount_received.max(cents) * 10_000,
324 &format!("Paid automatically by card, near the {} limit", dollars_plain(limit.ceiling_micros.unwrap_or_default())),
325 &payment.id,
326 None,
327 None,
328 None,
329 Some(&customer),
330 )
331 .await?;
332 self.db
333 .prepare("UPDATE limits SET autopay_failed_at = NULL, autopay_error = NULL WHERE workspace = ?")
334 .bind(&[candidate.workspace.as_str().into()])?
335 .run()
336 .await?;
337 }
338 outcome => {
339 let error = match outcome {
340 Ok(payment) => format!("the payment is {}", payment.status.replace('_', " ")),
341 Err(error) => error.to_string().chars().take(200).collect(),
342 };
343 self.db
344 .prepare(
345 "INSERT INTO limits (workspace, autopay_failed_at, autopay_error, updated_at) VALUES (?1, ?2, ?3, ?2)
346 ON CONFLICT (workspace) DO UPDATE SET autopay_failed_at = ?2, autopay_error = ?3, updated_at = ?2",
347 )
348 .bind(&[candidate.workspace.as_str().into(), now.as_str().into(), error.as_str().into()])?
349 .run()
350 .await?;
351 }
352 }
353 }
354 Ok(())
355 }
356
Usage limits: unpaid usage can only go so far357 pub(crate) async fn check_limit(&self, a: CheckLimitArgs) -> Result<Outcome<Limit>> {
358 Ok(Outcome::Ok(self.limit_of(&a.workspace).await?))
359 }
360
361 pub(crate) async fn set_spend_limit(&self, a: SetSpendLimitArgs) -> Result<Outcome<Limit>> {
362 let workspace = a.workspace.to_lowercase();
363 if a.actor.role_in(&workspace) != Some(Role::Owner) {
364 return Ok(Outcome::fail(
365 FailureCode::Forbidden,
366 "Only an owner can set the workspace's spend limit.",
367 ));
368 }
369 if a.spend_limit_micros.is_some_and(|limit| limit < 0) {
370 return Ok(Outcome::fail(FailureCode::Invalid, "A spend limit cannot be negative."));
371 }
372 let limit = a.spend_limit_micros.map_or(JsValue::NULL, |limit| (limit as f64).into());
373 self.db
374 .prepare(
375 "INSERT INTO limits (workspace, spend_limit_micros, updated_at) VALUES (?1, ?2, ?3)
376 ON CONFLICT (workspace) DO UPDATE SET spend_limit_micros = ?2, updated_at = ?3",
377 )
378 .bind(&[workspace.as_str().into(), limit, rfc3339(now_ms()).into()])?
379 .run()
380 .await?;
381 Ok(Outcome::Ok(self.limit_of(&workspace).await?))
382 }
383}
384
385#[cfg(test)]
386mod tests {
387 use super::*;
388
389 fn ceilings() -> Ceilings {
Billing accounts, terms and enterprises; g1t is no longer free390 Ceilings { new: 3_000_000, paid_min: 25_000_000, paid_max: 1_000_000_000 }
Usage limits: unpaid usage can only go so far391 }
392
393 #[test]
394 fn trust_grows_with_what_was_paid_within_bounds() {
395 assert_eq!(ceilings().for_paid(5_000_000), 25_000_000);
396 assert_eq!(ceilings().for_paid(100_000_000), 200_000_000);
397 assert_eq!(ceilings().for_paid(10_000_000_000), 1_000_000_000);
398 }
399
400 #[test]
401 fn work_warns_at_eighty_percent_and_stops_at_the_ceiling() {
402 assert_eq!(state(0, Some(100)), LimitState::Ok);
403 assert_eq!(state(79, Some(100)), LimitState::Ok);
404 assert_eq!(state(80, Some(100)), LimitState::Warning);
405 assert_eq!(state(100, Some(100)), LimitState::Stopped);
406 assert_eq!(state(1_000_000, None), LimitState::Ok);
407 }
408}