flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t/services/billing/src/limits.rs

800 lines35,813 bytesCodeBlame
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
24use g1t_contracts::billing::{CheckLimitArgs, Limit, LimitArgs, NotePendingArgs, TermsKind, LimitState, SetSpendLimitArgs, Trust};
25use 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
71/// The automatic monthly spend limit's floor: $200.
72pub(crate) const DEFAULT_SPEND_MICROS: i64 = 200_000_000;
73/// Established workspaces' ceiling: three times their steady monthly
74/// spend, up to $10,000.
75const ESTABLISHED_FACTOR: i64 = 3;
76const ESTABLISHED_MAX_MICROS: i64 = 10_000_000_000;
77/// A month counts toward Established at this much spend or more.
78const ESTABLISHED_MONTH_MICROS: i64 = 20_000_000;
79/// Payments raise trust once this old: past the time most bad cards are
80/// caught.
81const SETTLE_DAYS: u64 = 7;
82
83/// The automatic spend limit: $200, or twice last month's spend.
84pub(crate) fn automatic_spend_limit(last_month_charged: i64) -> i64 {
85 DEFAULT_SPEND_MICROS.max(last_month_charged * 2)
86}
87
88/// An Established workspace's ceiling, from its last three months'
89/// charges, if each was steady enough.
90pub(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
98#[derive(Deserialize)]
99struct LimitRow {
100 spend_limit_micros: Option<i64>,
101 #[serde(default)]
102 spend_limit_full: Option<i64>,
103 autopay_failed_at: Option<String>,
104 autopay_error: Option<String>,
105}
106
107#[derive(Deserialize)]
108struct Month {
109 used: Option<i64>,
110 paid: Option<i64>,
111}
112
113#[derive(Deserialize)]
114struct Paid {
115 paid: Option<i64>,
116}
117
118impl Billing {
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.
122 pub(crate) async fn limit_of(&self, workspace: &str) -> Result<Limit> {
123 let workspace = workspace.to_lowercase();
124 let account = self.account_of(&workspace).await?;
125 let row = self
126 .db
127 .prepare("SELECT spend_limit_micros, spend_limit_full, autopay_failed_at, autopay_error FROM limits WHERE workspace = ?")
128 .bind(&[workspace.as_str().into()])?
129 .first::<LimitRow>(None)
130 .await?;
131 let month_start = format!("{}-01", &rfc3339(now_ms())[..7]);
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());
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
144 .prepare(format!(
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
152 FROM ledger WHERE workspace IN ({marks}) AND created_at >= ?"
153 ))
154 .bind(&with_month)?
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)));
158 // And what is metered but not charged until the month closes.
159 let mut pending_args = members.clone();
160 pending_args.push(month_start[..7].into());
161 let pending = self
162 .db
163 .prepare(format!(
164 "SELECT SUM(charge_micros) AS paid FROM pending_usage WHERE workspace IN ({marks}) AND month = ?"
165 ))
166 .bind(&pending_args)?
167 .first::<Paid>(None)
168 .await?
169 .and_then(|row| row.paid)
170 .unwrap_or(0);
171 let used = used + pending;
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);
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;
194
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?;
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)),
205 }
206 }
207 };
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 };
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())));
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
239 };
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 };
245 let message = match state {
246 LimitState::Ok => None,
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 )),
252 LimitState::Warning => Some(format!(
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.",
254 dollars_plain(exposure),
255 dollars_plain(ceiling.unwrap_or_default()),
256 )),
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 )),
261 LimitState::Stopped => Some(if over_budget {
262 format!(
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()),
265 )
266 } else {
267 format!(
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.",
269 dollars_plain(ceiling.unwrap_or_default()),
270 )
271 }),
272 };
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 };
281 Ok(Limit {
282 workspace,
283 account: account.id,
284 account_name: account.name,
285 spent_micros: spent,
286 default_spend_limit,
287 available_micros: trust_ceiling,
288 growth,
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
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> {
302 if !self.stripe.as_ref().is_some_and(crate::stripe::Stripe::live) {
303 return Ok(0);
304 }
305 let marks = vec!["?"; members.len().max(1)].join(", ");
306 Ok(self
307 .db
308 .prepare(format!(
309 "SELECT SUM(amount_micros) AS paid FROM ledger
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)
315 ))
316 .bind(members)?
317 .first::<Paid>(None)
318 .await?
319 .and_then(|row| row.paid)
320 .unwrap_or(0))
321 }
322
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
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
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
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<()> {
459 if !self.stripe.as_ref().is_some_and(crate::stripe::Stripe::live) {
460 return Ok(());
461 }
462 #[derive(Deserialize)]
463 struct Candidate {
464 workspace: String,
465 }
466 let month_start = format!("{}-01", &rfc3339(now_ms())[..7]);
467 // With a card, and not already declined: a declined card waits for
468 // the owners, rather than being tried again every few minutes.
469 let candidates = self
470 .db
471 .prepare(
472 "SELECT DISTINCT ledger.workspace AS workspace
473 FROM ledger JOIN accounts ON accounts.workspace = ledger.workspace
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",
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?;
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_") {
488 continue;
489 }
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),
496 }
497 }
498 Ok(())
499 }
500
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<()> {
506 if !self.stripe.as_ref().is_some_and(crate::stripe::Stripe::live) {
507 return Ok(());
508 }
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(
520 "SELECT accounts.workspace AS 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 }
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?;
564 }
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?;
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
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 }
691 let limit = if a.use_full_limit { JsValue::NULL } else { a.spend_limit_micros.map_or(JsValue::NULL, |limit| (limit as f64).into()) };
692 self.db
693 .prepare(
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",
696 )
697 .bind(&[workspace.as_str().into(), limit, (if a.use_full_limit { 1 } else { 0 }).into(), rfc3339(now_ms()).into()])?
698 .run()
699 .await?;
700 Ok(Outcome::Ok(self.limit_of(&workspace).await?))
701 }
702}
703
704/// Which warning a workspace has reached: 100, 80, 50 or none (0).
705pub(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.
718async 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`.
737pub(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
747#[cfg(test)]
748mod tests {
749 use super::*;
750
751 #[test]
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]
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
781 fn ceilings() -> Ceilings {
782 Ceilings { new: 3_000_000, paid_min: 25_000_000, paid_max: 1_000_000_000 }
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}