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

300 lines12,324 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, 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 // And what is metered but not charged until the month closes.
128 let pending = self
129 .db
130 .prepare("SELECT SUM(charge_micros) AS paid FROM pending_usage WHERE workspace = ? AND month = ?")
131 .bind(&[workspace.as_str().into(), month_start[..7].into()])?
132 .first::<Paid>(None)
133 .await?
134 .and_then(|row| row.paid)
135 .unwrap_or(0);
136 let used = used + pending;
137 // Test-mode payments are not money: they pay nothing off.
138 let live = self.stripe.as_ref().is_some_and(crate::stripe::Stripe::live);
139 let exposure = (used - if live { paid_month } else { 0 }).max(0);
140
141 let (trust, trust_ceiling) = if self.ceilings.exempt.iter().any(|name| *name == workspace) {
142 (Trust::Internal, None)
143 } else if let Some(ceiling) = row.as_ref().and_then(|row| row.ceiling_micros) {
144 (Trust::Reviewed, Some(ceiling))
145 } else {
146 let paid = self.live_paid(&workspace).await?;
147 if paid > 0 {
148 (Trust::Paid, Some(self.ceilings.for_paid(paid)))
149 } else {
150 (Trust::New, Some(self.ceilings.new))
151 }
152 };
153 let spend_limit = row.and_then(|row| row.spend_limit_micros);
154 let ceiling = match (trust_ceiling, spend_limit) {
155 (Some(ceiling), Some(own)) => Some(ceiling.min(own)),
156 (None, Some(own)) => Some(own),
157 (ceiling, None) => ceiling,
158 };
159 let state = state(exposure, ceiling);
160 let message = match state {
161 LimitState::Ok => None,
162 LimitState::Warning => Some(format!(
163 "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.",
164 dollars_plain(exposure),
165 dollars_plain(ceiling.unwrap_or_default()),
166 )),
167 LimitState::Stopped => Some(if spend_limit.is_some() && ceiling == spend_limit {
168 format!(
169 "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.",
170 dollars_plain(ceiling.unwrap_or_default()),
171 )
172 } else {
173 format!(
174 "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.",
175 dollars_plain(ceiling.unwrap_or_default()),
176 )
177 }),
178 };
179 Ok(Limit {
180 workspace,
181 trust,
182 exposure_micros: exposure,
183 ceiling_micros: ceiling,
184 trust_ceiling_micros: trust_ceiling,
185 spend_limit_micros: spend_limit,
186 state,
187 message,
188 })
189 }
190
191 /// Real money the workspace has paid g1t. Nothing in test mode.
192 async fn live_paid(&self, workspace: &str) -> Result<i64> {
193 if !self.stripe.as_ref().is_some_and(crate::stripe::Stripe::live) {
194 return Ok(0);
195 }
196 Ok(self
197 .db
198 .prepare("SELECT SUM(amount_micros) AS paid FROM ledger WHERE workspace = ? AND kind = 'top_up'")
199 .bind(&[workspace.into()])?
200 .first::<Paid>(None)
201 .await?
202 .and_then(|row| row.paid)
203 .unwrap_or(0))
204 }
205
206 /// A refusal, with the reason, when the workspace's work is stopped.
207 /// None while billing is off: a g1t without payments has no limits.
208 pub(crate) async fn stopped<T>(&self, workspace: &str) -> Result<Option<Outcome<T>>> {
209 if self.stripe.is_none() {
210 return Ok(None);
211 }
212 let limit = self.limit_of(workspace).await?;
213 Ok((limit.state == LimitState::Stopped).then(|| {
214 Outcome::fail(
215 FailureCode::PaymentRequired,
216 limit.message.unwrap_or_else(|| "This workspace is over its limit.".to_owned()),
217 )
218 }))
219 }
220
221 pub(crate) async fn limit(&self, a: LimitArgs) -> Result<Outcome<Limit>> {
222 let workspace = a.workspace.to_lowercase();
223 if !a.viewer.is_some_and(|viewer| viewer.is_member(&workspace)) {
224 return Ok(members_only());
225 }
226 Ok(Outcome::Ok(self.limit_of(&workspace).await?))
227 }
228
229 pub(crate) async fn note_pending(&self, a: NotePendingArgs) -> Result<bool> {
230 let now = rfc3339(now_ms());
231 let charge = crate::charge_micros(a.cost_micros.max(0) as f64 / g1t_contracts::billing::MICROS_PER_DOLLAR as f64, self.margin_percent);
232 self.db
233 .prepare(
234 "INSERT INTO pending_usage (workspace, source, month, charge_micros, updated_at) VALUES (?1, ?2, ?3, ?4, ?5)
235 ON CONFLICT (workspace, source, month) DO UPDATE SET charge_micros = ?4, updated_at = ?5",
236 )
237 .bind(&[
238 a.workspace.to_lowercase().into(),
239 a.source.as_str().into(),
240 now[..7].into(),
241 (charge as f64).into(),
242 now.as_str().into(),
243 ])?
244 .run()
245 .await?;
246 Ok(true)
247 }
248
249 pub(crate) async fn check_limit(&self, a: CheckLimitArgs) -> Result<Outcome<Limit>> {
250 Ok(Outcome::Ok(self.limit_of(&a.workspace).await?))
251 }
252
253 pub(crate) async fn set_spend_limit(&self, a: SetSpendLimitArgs) -> Result<Outcome<Limit>> {
254 let workspace = a.workspace.to_lowercase();
255 if a.actor.role_in(&workspace) != Some(Role::Owner) {
256 return Ok(Outcome::fail(
257 FailureCode::Forbidden,
258 "Only an owner can set the workspace's spend limit.",
259 ));
260 }
261 if a.spend_limit_micros.is_some_and(|limit| limit < 0) {
262 return Ok(Outcome::fail(FailureCode::Invalid, "A spend limit cannot be negative."));
263 }
264 let limit = a.spend_limit_micros.map_or(JsValue::NULL, |limit| (limit as f64).into());
265 self.db
266 .prepare(
267 "INSERT INTO limits (workspace, spend_limit_micros, updated_at) VALUES (?1, ?2, ?3)
268 ON CONFLICT (workspace) DO UPDATE SET spend_limit_micros = ?2, updated_at = ?3",
269 )
270 .bind(&[workspace.as_str().into(), limit, rfc3339(now_ms()).into()])?
271 .run()
272 .await?;
273 Ok(Outcome::Ok(self.limit_of(&workspace).await?))
274 }
275}
276
277#[cfg(test)]
278mod tests {
279 use super::*;
280
281 fn ceilings() -> Ceilings {
282 Ceilings { new: 3_000_000, paid_min: 25_000_000, paid_max: 1_000_000_000, exempt: vec![] }
283 }
284
285 #[test]
286 fn trust_grows_with_what_was_paid_within_bounds() {
287 assert_eq!(ceilings().for_paid(5_000_000), 25_000_000);
288 assert_eq!(ceilings().for_paid(100_000_000), 200_000_000);
289 assert_eq!(ceilings().for_paid(10_000_000_000), 1_000_000_000);
290 }
291
292 #[test]
293 fn work_warns_at_eighty_percent_and_stops_at_the_ceiling() {
294 assert_eq!(state(0, Some(100)), LimitState::Ok);
295 assert_eq!(state(79, Some(100)), LimitState::Ok);
296 assert_eq!(state(80, Some(100)), LimitState::Warning);
297 assert_eq!(state(100, Some(100)), LimitState::Stopped);
298 assert_eq!(state(1_000_000, None), LimitState::Ok);
299 }
300}