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

270 lines11,003 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, 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 /// `LIMIT_EXEMPT`: g1t's own workspaces, comma-separated.
43 pub exempt: Vec<String>,
44}
45
46impl Ceilings {
47 pub(crate) fn from_env(env: &Env) -> Self {
48 let number = |name: &str, default: i64| {
49 env.var(name).ok().and_then(|v| v.to_string().parse::<i64>().ok()).unwrap_or(default)
50 };
51 Ceilings {
52 new: number("LIMIT_NEW_MICROS", 3_000_000),
53 paid_min: number("LIMIT_PAID_MIN_MICROS", 25_000_000),
54 paid_max: number("LIMIT_PAID_MAX_MICROS", 1_000_000_000),
55 exempt: env
56 .var("LIMIT_EXEMPT")
57 .map(|v| v.to_string())
58 .unwrap_or_default()
59 .split(',')
60 .map(|name| name.trim().to_lowercase())
61 .filter(|name| !name.is_empty())
62 .collect(),
63 }
64 }
65
66 /// The ceiling for a workspace that has paid `paid` in live money.
67 pub(crate) fn for_paid(&self, paid: i64) -> i64 {
68 (paid * 2).clamp(self.paid_min, self.paid_max)
69 }
70}
71
72/// Where a workspace stands against its ceiling.
73pub(crate) fn state(exposure: i64, ceiling: Option<i64>) -> LimitState {
74 match ceiling {
75 Some(ceiling) if exposure >= ceiling => LimitState::Stopped,
76 Some(ceiling) if exposure * 5 >= ceiling * 4 => LimitState::Warning,
77 _ => LimitState::Ok,
78 }
79}
80
81#[derive(Deserialize)]
82struct LimitRow {
83 ceiling_micros: Option<i64>,
84 spend_limit_micros: Option<i64>,
85}
86
87#[derive(Deserialize)]
88struct Month {
89 used: Option<i64>,
90 paid: Option<i64>,
91}
92
93#[derive(Deserialize)]
94struct Paid {
95 paid: Option<i64>,
96}
97
98impl Billing {
99 /// The workspace's limit, worked out from its ledger.
100 pub(crate) async fn limit_of(&self, workspace: &str) -> Result<Limit> {
101 let workspace = workspace.to_lowercase();
102 let row = self
103 .db
104 .prepare("SELECT ceiling_micros, spend_limit_micros FROM limits WHERE workspace = ?")
105 .bind(&[workspace.as_str().into()])?
106 .first::<LimitRow>(None)
107 .await?;
108 let month_start = format!("{}-01", &rfc3339(now_ms())[..7]);
109 // Each usage entry at its cost to g1t or its charge, whichever is
110 // more; on the workspace's own provider, only g1t's fee is g1t's.
111 let month = self
112 .db
113 .prepare(
114 "SELECT
115 SUM(CASE WHEN kind = 'usage' THEN
116 CASE WHEN COALESCE(billed_to, 'g1t') = 'g1t'
117 THEN MAX(COALESCE(cost_micros, 0), -amount_micros)
118 ELSE -amount_micros END
119 END) AS used,
120 SUM(CASE WHEN kind = 'top_up' THEN amount_micros END) AS paid
121 FROM ledger WHERE workspace = ?1 AND created_at >= ?2",
122 )
123 .bind(&[workspace.as_str().into(), month_start.as_str().into()])?
124 .first::<Month>(None)
125 .await?;
126 let (used, paid_month) = month.map_or((0, 0), |m| (m.used.unwrap_or(0), m.paid.unwrap_or(0)));
127 // Test-mode payments are not money: they pay nothing off.
128 let live = self.stripe.as_ref().is_some_and(crate::stripe::Stripe::live);
129 let exposure = (used - if live { paid_month } else { 0 }).max(0);
130
131 let (trust, trust_ceiling) = if self.ceilings.exempt.iter().any(|name| *name == workspace) {
132 (Trust::Internal, None)
133 } else if let Some(ceiling) = row.as_ref().and_then(|row| row.ceiling_micros) {
134 (Trust::Reviewed, Some(ceiling))
135 } else {
136 let paid = self.live_paid(&workspace).await?;
137 if paid > 0 {
138 (Trust::Paid, Some(self.ceilings.for_paid(paid)))
139 } else {
140 (Trust::New, Some(self.ceilings.new))
141 }
142 };
143 let spend_limit = row.and_then(|row| row.spend_limit_micros);
144 let ceiling = match (trust_ceiling, spend_limit) {
145 (Some(ceiling), Some(own)) => Some(ceiling.min(own)),
146 (None, Some(own)) => Some(own),
147 (ceiling, None) => ceiling,
148 };
149 let state = state(exposure, ceiling);
150 let message = match state {
151 LimitState::Ok => None,
152 LimitState::Warning => Some(format!(
153 "The {workspace} workspace has used {} of its {} limit this month. At the limit, its sandboxes, builds and apps stop until it pays or the month turns.",
154 dollars_plain(exposure),
155 dollars_plain(ceiling.unwrap_or_default()),
156 )),
157 LimitState::Stopped => Some(if spend_limit.is_some() && ceiling == spend_limit {
158 format!(
159 "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.",
160 dollars_plain(ceiling.unwrap_or_default()),
161 )
162 } else {
163 format!(
164 "The {workspace} workspace 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.",
165 dollars_plain(ceiling.unwrap_or_default()),
166 )
167 }),
168 };
169 Ok(Limit {
170 workspace,
171 trust,
172 exposure_micros: exposure,
173 ceiling_micros: ceiling,
174 trust_ceiling_micros: trust_ceiling,
175 spend_limit_micros: spend_limit,
176 state,
177 message,
178 })
179 }
180
181 /// Real money the workspace has paid g1t. Nothing in test mode.
182 async fn live_paid(&self, workspace: &str) -> Result<i64> {
183 if !self.stripe.as_ref().is_some_and(crate::stripe::Stripe::live) {
184 return Ok(0);
185 }
186 Ok(self
187 .db
188 .prepare("SELECT SUM(amount_micros) AS paid FROM ledger WHERE workspace = ? AND kind = 'top_up'")
189 .bind(&[workspace.into()])?
190 .first::<Paid>(None)
191 .await?
192 .and_then(|row| row.paid)
193 .unwrap_or(0))
194 }
195
196 /// A refusal, with the reason, when the workspace's work is stopped.
197 /// None while billing is off: a g1t without payments has no limits.
198 pub(crate) async fn stopped<T>(&self, workspace: &str) -> Result<Option<Outcome<T>>> {
199 if self.stripe.is_none() {
200 return Ok(None);
201 }
202 let limit = self.limit_of(workspace).await?;
203 Ok((limit.state == LimitState::Stopped).then(|| {
204 Outcome::fail(
205 FailureCode::PaymentRequired,
206 limit.message.unwrap_or_else(|| "This workspace is over its limit.".to_owned()),
207 )
208 }))
209 }
210
211 pub(crate) async fn limit(&self, a: LimitArgs) -> Result<Outcome<Limit>> {
212 let workspace = a.workspace.to_lowercase();
213 if !a.viewer.is_some_and(|viewer| viewer.is_member(&workspace)) {
214 return Ok(members_only());
215 }
216 Ok(Outcome::Ok(self.limit_of(&workspace).await?))
217 }
218
219 pub(crate) async fn check_limit(&self, a: CheckLimitArgs) -> Result<Outcome<Limit>> {
220 Ok(Outcome::Ok(self.limit_of(&a.workspace).await?))
221 }
222
223 pub(crate) async fn set_spend_limit(&self, a: SetSpendLimitArgs) -> Result<Outcome<Limit>> {
224 let workspace = a.workspace.to_lowercase();
225 if a.actor.role_in(&workspace) != Some(Role::Owner) {
226 return Ok(Outcome::fail(
227 FailureCode::Forbidden,
228 "Only an owner can set the workspace's spend limit.",
229 ));
230 }
231 if a.spend_limit_micros.is_some_and(|limit| limit < 0) {
232 return Ok(Outcome::fail(FailureCode::Invalid, "A spend limit cannot be negative."));
233 }
234 let limit = a.spend_limit_micros.map_or(JsValue::NULL, |limit| (limit as f64).into());
235 self.db
236 .prepare(
237 "INSERT INTO limits (workspace, spend_limit_micros, updated_at) VALUES (?1, ?2, ?3)
238 ON CONFLICT (workspace) DO UPDATE SET spend_limit_micros = ?2, updated_at = ?3",
239 )
240 .bind(&[workspace.as_str().into(), limit, rfc3339(now_ms()).into()])?
241 .run()
242 .await?;
243 Ok(Outcome::Ok(self.limit_of(&workspace).await?))
244 }
245}
246
247#[cfg(test)]
248mod tests {
249 use super::*;
250
251 fn ceilings() -> Ceilings {
252 Ceilings { new: 3_000_000, paid_min: 25_000_000, paid_max: 1_000_000_000, exempt: vec![] }
253 }
254
255 #[test]
256 fn trust_grows_with_what_was_paid_within_bounds() {
257 assert_eq!(ceilings().for_paid(5_000_000), 25_000_000);
258 assert_eq!(ceilings().for_paid(100_000_000), 200_000_000);
259 assert_eq!(ceilings().for_paid(10_000_000_000), 1_000_000_000);
260 }
261
262 #[test]
263 fn work_warns_at_eighty_percent_and_stops_at_the_ceiling() {
264 assert_eq!(state(0, Some(100)), LimitState::Ok);
265 assert_eq!(state(79, Some(100)), LimitState::Ok);
266 assert_eq!(state(80, Some(100)), LimitState::Warning);
267 assert_eq!(state(100, Some(100)), LimitState::Stopped);
268 assert_eq!(state(1_000_000, None), LimitState::Ok);
269 }
270}