Skip to content
1,392 linesCodeBlameRaw

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.

Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1//! How far a workspace can run up costs g1t has not been paid for, and how
2//! far its owners let it spend.
Usage limits: unpaid usage can only go so far3//!
4//! Every sandbox second, build, app request and model token costs g1t
5//! money at Cloudflare or a model provider before the workspace pays for
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look6//! it. So each workspace has a ceiling on that unpaid usage:
Usage limits: unpaid usage can only go so far7//!
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look8//! - **Free**: a few dollars (`LIMIT_NEW_MICROS`), for what a free
9//! workspace can owe at all (private storage past 1 GB). Free workspaces
10//! have no on-demand compute: the trial and g1t's pools pay for it.
11//! - **Paid, first month**: `LIMIT_PAID_START_MICROS` ($100) while the plan
12//! is in its first billing cycle.
13//! - **Paid, after**: twice what it has paid g1t once payments clear
14//! (`SETTLE_DAYS`), never less than the starting ceiling; after three
15//! steady months it follows the monthly spend, up to $10,000.
16//! - **Reviewed**: a ceiling g1t staff set by hand.
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays17//! - **Internal**: g1t's own workspaces, with none here: what their work
18//! costs g1t has a monthly budget instead (see `budget`).
Usage limits: unpaid usage can only go so far19//!
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look20//! A ceiling g1t granted (an approved request, or the owners' one-time
21//! raise) is a floor under the trust ceiling. Money paid in advance raises
22//! what can be used before work stops by the same amount, at once: it comes
23//! off what is owed before anything counts against the ceiling.
24//!
25//! Owners also set a monthly **spend limit** on what is charged. They may
26//! put it anywhere up to the highest ceiling the workspace has ever had,
27//! plus what is prepaid, without asking anyone; once per workspace they may
28//! raise it to twice that highest ceiling themselves. Past that, they ask
29//! (see `requests`), and g1t answers within one business day.
30//!
31//! Alerts go out at 50, 75, 90 and 100% of the plan's included usage, the
32//! spend limit and the ceiling, in the app and by email. At the ceiling or
33//! the spend limit, new work stops: no new sandboxes, builds or app
34//! requests until it is paid, raised, or the month turns. Runs already
Usage limits: unpaid usage can only go so far35//! under way finish.
36//!
37//! Usage counts at what it cost g1t or what it is charged, whichever is
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look38//! more. Test-mode payments are not money, so they do not raise trust.
Usage limits: unpaid usage can only go so far39
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails40use std::collections::BTreeSet;
41
Billing answers every page in a few round trips, not forty: its reads run together, Stripe is asked at most hourly, and the ledger has a time index42use futures_util::future::{try_join, try_join5, try_join_all};
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look43use g1t_contracts::billing::{
Usage, Billing settings and prepaid AI credit; fixes from the UX audit44 BillingAccount, CheckLimitArgs, Limit, LimitArgs, LimitState, NotePendingArgs, PlanKind, SetBudgetArgs, SetSpendLimitArgs, Trust,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look45};
Usage limits: unpaid usage can only go so far46use g1t_contracts::time::rfc3339;
Merge main (membership, two-factor, GitHub repo roles) into tokens47use g1t_contracts::{FailureCode, Outcome};
Usage limits: unpaid usage can only go so far48use g1t_kit::now_ms;
49use serde::Deserialize;
50use worker::wasm_bindgen::JsValue;
51use worker::{Env, Result};
52
53use crate::features::dollars as dollars_plain;
54use crate::{Billing, members_only};
55
56/// The ceilings, from the billing service's variables.
57pub(crate) struct Ceilings {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look58 /// `LIMIT_NEW_MICROS`: a free workspace's.
Usage limits: unpaid usage can only go so far59 pub new: i64,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look60 /// `LIMIT_PAID_MIN_MICROS` and `LIMIT_PAID_MAX_MICROS`: the bounds of a
61 /// paid workspace's, from what it has paid.
Usage limits: unpaid usage can only go so far62 pub paid_min: i64,
63 pub paid_max: i64,
64}
65
66impl Ceilings {
67 pub(crate) fn from_env(env: &Env) -> Self {
68 let number = |name: &str, default: i64| {
69 env.var(name).ok().and_then(|v| v.to_string().parse::<i64>().ok()).unwrap_or(default)
70 };
71 Ceilings {
72 new: number("LIMIT_NEW_MICROS", 3_000_000),
73 paid_min: number("LIMIT_PAID_MIN_MICROS", 25_000_000),
74 paid_max: number("LIMIT_PAID_MAX_MICROS", 1_000_000_000),
75 }
76 }
77
78 /// The ceiling for a workspace that has paid `paid` in live money.
79 pub(crate) fn for_paid(&self, paid: i64) -> i64 {
80 (paid * 2).clamp(self.paid_min, self.paid_max)
81 }
82}
83
84/// Where a workspace stands against its ceiling.
85pub(crate) fn state(exposure: i64, ceiling: Option<i64>) -> LimitState {
86 match ceiling {
87 Some(ceiling) if exposure >= ceiling => LimitState::Stopped,
88 Some(ceiling) if exposure * 5 >= ceiling * 4 => LimitState::Warning,
89 _ => LimitState::Ok,
90 }
91}
92
Two limits, real invoices, trust that grows by itself, sales signals93/// The automatic monthly spend limit's floor: $200.
94pub(crate) const DEFAULT_SPEND_MICROS: i64 = 200_000_000;
95/// Established workspaces' ceiling: three times their steady monthly
96/// spend, up to $10,000.
97const ESTABLISHED_FACTOR: i64 = 3;
98const ESTABLISHED_MAX_MICROS: i64 = 10_000_000_000;
99/// A month counts toward Established at this much spend or more.
100const ESTABLISHED_MONTH_MICROS: i64 = 20_000_000;
101/// Payments raise trust once this old: past the time most bad cards are
102/// caught.
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look103pub(crate) const SETTLE_DAYS: u64 = 7;
Two limits, real invoices, trust that grows by itself, sales signals104
105/// The automatic spend limit: $200, or twice last month's spend.
106pub(crate) fn automatic_spend_limit(last_month_charged: i64) -> i64 {
107 DEFAULT_SPEND_MICROS.max(last_month_charged * 2)
108}
Billing accounts, terms and enterprises; g1t is no longer free109
Two limits, real invoices, trust that grows by itself, sales signals110/// An Established workspace's ceiling, from its last three months'
111/// charges, if each was steady enough.
112pub(crate) fn established_ceiling(months: &[i64]) -> Option<i64> {
113 if months.len() < 3 || months.iter().any(|m| *m < ESTABLISHED_MONTH_MICROS) {
114 return None;
115 }
116 let average = months.iter().sum::<i64>() / months.len() as i64;
117 Some((average * ESTABLISHED_FACTOR).min(ESTABLISHED_MAX_MICROS))
118}
119
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look120/// Days since 1970-01-01 of a `YYYY-MM-DD…` date, for comparing dates
121/// without a clock (Howard Hinnant's days-from-civil).
122pub(crate) fn days(date: &str) -> i64 {
123 let year: i64 = date.get(..4).and_then(|y| y.parse().ok()).unwrap_or(1970);
124 let month: i64 = date.get(5..7).and_then(|m| m.parse().ok()).unwrap_or(1);
125 let day: i64 = date.get(8..10).and_then(|d| d.parse().ok()).unwrap_or(1);
126 let y = if month <= 2 { year - 1 } else { year };
127 let era = y.div_euclid(400);
128 let yoe = y - era * 400;
129 let mp = (month + 9) % 12;
130 let doy = (153 * mp + 2) / 5 + day - 1;
131 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
132 era * 146_097 + doe - 719_468
133}
134
135/// Whether a plan that started at `started_at` is still in its first
136/// billing cycle: its paid period ends no more than a month after it
137/// started (a renewal moves the end a month on), or, with no period known,
138/// it started within the last 31 days.
139pub(crate) fn in_first_cycle(started_at: &str, period_end: Option<&str>, now: &str) -> bool {
140 match period_end {
141 Some(end) => days(end) - days(started_at) <= 32 && days(now) <= days(end),
142 None => days(now) - days(started_at) <= 31,
143 }
144}
145
146/// g1t's ceiling for a workspace on the plan: the starting one in its
147/// first month; after it, what it has paid (or its Established ceiling),
148/// never less than the starting one.
149pub(crate) fn paid_ceiling(ceilings: &Ceilings, start: i64, first_month: bool, paid: i64, established: Option<i64>) -> i64 {
150 if first_month {
151 return start;
152 }
153 let from_paid = if paid > 0 { ceilings.for_paid(paid) } else { 0 };
154 start.max(from_paid).max(established.unwrap_or(0))
155}
156
157/// What the owners may set their spend limit to without asking, and the
158/// one-time raise if it is still theirs to use: up to the highest ceiling
159/// ever (or the current one, if higher) plus what is prepaid; once, twice
160/// the highest ceiling.
161pub(crate) fn spend_bounds(ceiling: i64, max_ever: i64, prepaid: i64, raised: bool) -> (i64, Option<i64>) {
162 let highest = ceiling.max(max_ever);
163 let available = highest + prepaid.max(0);
164 let once = (!raised).then(|| (highest * 2 + prepaid.max(0)).max(available));
165 (available, once)
166}
167
168/// Whether `requested` is a spend limit the owners may set themselves.
169/// `Ok(true)` when it takes the one-time raise.
170pub(crate) fn self_serve(requested: i64, available: i64, once: Option<i64>, raise_once: bool) -> std::result::Result<bool, String> {
171 if requested < 0 {
172 return Err("A spend limit cannot be negative.".to_owned());
173 }
174 if requested <= available {
175 return Ok(false);
176 }
177 match once {
178 Some(once) if raise_once && requested <= once => Ok(true),
179 Some(once) if raise_once => Err(format!(
180 "The one-time raise goes up to {}. For more, ask g1t with Raise my limit; the answer comes within one business day.",
181 dollars_plain(once)
182 )),
183 Some(once) => Err(format!(
184 "You can set up to {} yourself, or use your one-time raise to go up to {}. For more, ask g1t with Raise my limit.",
185 dollars_plain(available),
186 dollars_plain(once)
187 )),
188 None => Err(format!(
189 "You can set up to {} yourself, and the one-time raise is used. For more, ask g1t with Raise my limit; the answer comes within one business day.",
190 dollars_plain(available)
191 )),
192 }
193}
194
195/// Which alert a measure has reached: 100, 90, 75, 50, or none (0).
196pub(crate) fn alert_level(used: i64, limit: i64) -> u32 {
Usage, Billing settings and prepaid AI credit; fixes from the UX audit197 alert_level_in(used, limit, &ALERT_LEVELS)
198}
199
200/// Every alert a budget can have, highest first.
201pub(crate) const ALERT_LEVELS: [u32; 4] = [100, 90, 75, 50];
202
203/// The highest of `levels` a measure has reached, or 0.
204pub(crate) fn alert_level_in(used: i64, limit: i64, levels: &[u32]) -> u32 {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look205 if limit <= 0 || used <= 0 {
206 return 0;
207 }
Usage, Billing settings and prepaid AI credit; fixes from the UX audit208 levels.iter().copied().filter(|level| used * 100 >= limit * i64::from(*level)).max().unwrap_or(0)
209}
210
211/// A budget's alerts as stored (`50,75,100`): every level when none were
212/// chosen, highest first.
213pub(crate) fn alert_levels(stored: Option<&str>) -> Vec<u32> {
214 let Some(stored) = stored else { return ALERT_LEVELS.to_vec() };
215 let mut levels: Vec<u32> = stored.split(',').filter_map(|l| l.trim().parse().ok()).filter(|l| ALERT_LEVELS.contains(l)).collect();
216 levels.sort_by(|a, b| b.cmp(a));
217 levels.dedup();
218 levels
219}
220
221/// Where spending stands against the budget: at 100% it stops work only
222/// when the budget pauses usage; otherwise it is a warning.
223pub(crate) fn budget_state(spent: i64, budget: Option<i64>, pause: bool) -> LimitState {
224 match state(spent, budget) {
225 LimitState::Stopped if !pause => LimitState::Warning,
226 other => other,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look227 }
228}
229
Usage, Billing settings and prepaid AI credit; fixes from the UX audit230/// Whether a budget webhook is an address g1t will post to: HTTPS, not
231/// g1t's own, at most 500 characters.
232pub(crate) fn webhook_ok(url: &str) -> bool {
233 let url = url.trim();
234 url.len() <= 500
235 && url.starts_with("https://")
236 && url.len() > "https://".len() + 3
237 && !url.contains(char::is_whitespace)
238 && !url["https://".len()..].split('/').next().is_some_and(|host| host == "g1t.sh" || host.ends_with(".g1t.sh") || host.starts_with("localhost") || host.starts_with("127."))
239}
240
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look241/// What is owed and what is prepaid, from this month's usage and payments
242/// and the balance the month started with (positive: paid in advance;
243/// negative: owed from before).
244pub(crate) fn exposure(used: i64, paid_month: i64, balance_before: i64) -> (i64, i64) {
245 let prepaid_in = balance_before.max(0);
246 let carried = (-balance_before).max(0);
247 let net = used - paid_month - prepaid_in;
248 (net.max(0) + carried, (-net).max(0))
249}
250
Usage limits: unpaid usage can only go so far251#[derive(Deserialize)]
252struct LimitRow {
253 spend_limit_micros: Option<i64>,
Two limits, real invoices, trust that grows by itself, sales signals254 #[serde(default)]
255 spend_limit_full: Option<i64>,
Billing accounts, terms and enterprises; g1t is no longer free256 autopay_failed_at: Option<String>,
257 autopay_error: Option<String>,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look258 #[serde(default)]
259 max_ceiling_micros: Option<i64>,
260 #[serde(default)]
261 granted_ceiling_micros: Option<i64>,
262 #[serde(default)]
263 raised_at: Option<String>,
Usage, Billing settings and prepaid AI credit; fixes from the UX audit264 #[serde(default)]
265 alert_levels: Option<String>,
266 #[serde(default)]
267 pause_at_limit: Option<i64>,
268 #[serde(default)]
269 budget_webhook: Option<String>,
Usage limits: unpaid usage can only go so far270}
271
272#[derive(Deserialize)]
273struct Month {
274 used: Option<i64>,
275 paid: Option<i64>,
276}
277
278#[derive(Deserialize)]
279struct Paid {
280 paid: Option<i64>,
281}
282
283impl Billing {
Billing accounts, terms and enterprises; g1t is no longer free284 /// The workspace's limit, worked out from the ledger of the account
285 /// that pays for it: its own, or its enterprise's, whose workspaces'
286 /// usage and payments count together.
Usage limits: unpaid usage can only go so far287 pub(crate) async fn limit_of(&self, workspace: &str) -> Result<Limit> {
288 let workspace = workspace.to_lowercase();
Billing accounts, terms and enterprises; g1t is no longer free289 let account = self.account_of(&workspace).await?;
Billing answers every page in a few round trips, not forty: its reads run together, Stripe is asked at most hourly, and the ledger has a time index290 let plan = self.plan_kind_for(&workspace, &account).await?;
291 self.limit_with(&workspace, &account, plan).await
292 }
293
294 /// The workspace's limit, from the account and plan already read for
295 /// it, so a caller that has them does not read them again.
296 pub(crate) async fn limit_with(&self, workspace: &str, account: &BillingAccount, plan: PlanKind) -> Result<Limit> {
297 let workspace = workspace.to_lowercase();
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look298 let now = rfc3339(now_ms());
299 let month_start = format!("{}-01", &now[..7]);
Billing accounts, terms and enterprises; g1t is no longer free300 let marks = vec!["?"; account.workspaces.len().max(1)].join(", ");
301 let members: Vec<JsValue> = if account.workspaces.is_empty() {
302 vec![JsValue::from(workspace.as_str())]
303 } else {
304 account.workspaces.iter().map(|w| JsValue::from(w.as_str())).collect()
305 };
Billing answers every page in a few round trips, not forty: its reads run together, Stripe is asked at most hourly, and the ledger has a time index306 let row = async {
307 self.db
308 .prepare(
309 "SELECT spend_limit_micros, spend_limit_full, autopay_failed_at, autopay_error,
Usage, Billing settings and prepaid AI credit; fixes from the UX audit310 max_ceiling_micros, granted_ceiling_micros, raised_at, alert_levels, pause_at_limit, budget_webhook
Billing answers every page in a few round trips, not forty: its reads run together, Stripe is asked at most hourly, and the ledger has a time index311 FROM limits WHERE workspace = ?",
312 )
313 .bind(&[workspace.as_str().into()])?
314 .first::<LimitRow>(None)
315 .await
316 };
Billing accounts, terms and enterprises; g1t is no longer free317 let mut with_month = members.clone();
318 with_month.push(month_start.as_str().into());
Usage limits: unpaid usage can only go so far319 // Each usage entry at its cost to g1t or its charge, whichever is
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look320 // more; on the workspace's own provider, only what g1t charged.
321 // What the plan's included usage, the trial, the open-source pool
322 // or g1t itself paid for is not unpaid: those are budgets already
323 // paid for.
Billing answers every page in a few round trips, not forty: its reads run together, Stripe is asked at most hourly, and the ledger has a time index324 let month = async {
325 self.db
326 .prepare(format!(
327 "SELECT
328 SUM(CASE WHEN kind = 'usage' THEN
329 CASE WHEN COALESCE(billed_to, 'g1t') = 'g1t'
330 THEN MAX(COALESCE(cost_micros, 0) - COALESCE(credit_micros, 0)
331 - COALESCE(trial_micros, 0) - COALESCE(oss_micros, 0)
332 - COALESCE(given_micros, 0),
333 -amount_micros)
334 ELSE -amount_micros END
335 END) AS used,
336 SUM(CASE WHEN kind = 'top_up' THEN amount_micros END) AS paid
337 FROM ledger WHERE workspace IN ({marks}) AND created_at >= ?"
338 ))
339 .bind(&with_month)?
340 .first::<Month>(None)
341 .await
342 };
Prices keep themselves current with what g1t pays343 // And what is metered but not charged until the month closes.
Billing accounts, terms and enterprises; g1t is no longer free344 let mut pending_args = members.clone();
345 pending_args.push(month_start[..7].into());
Billing answers every page in a few round trips, not forty: its reads run together, Stripe is asked at most hourly, and the ledger has a time index346 let pending = async {
347 Ok::<i64, worker::Error>(
348 self.db
349 .prepare(format!(
350 "SELECT SUM(charge_micros) AS paid FROM pending_usage WHERE workspace IN ({marks}) AND month = ?"
351 ))
352 .bind(&pending_args)?
353 .first::<Paid>(None)
354 .await?
355 .and_then(|row| row.paid)
356 .unwrap_or(0),
357 )
358 };
Usage limits: unpaid usage can only go so far359 // Test-mode payments are not money: they pay nothing off.
360 let live = self.stripe.as_ref().is_some_and(crate::stripe::Stripe::live);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look361 // The balance the month started with: owed from before (so a new
362 // month is not a fresh allowance for an account that never pays),
363 // or paid in advance. Credits g1t gave count; test-mode payments
364 // do not.
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace365 let mut before = members.clone();
366 before.push(month_start.as_str().into());
Billing answers every page in a few round trips, not forty: its reads run together, Stripe is asked at most hourly, and the ledger has a time index367 let balance_before = async {
368 Ok::<i64, worker::Error>(
369 self.db
370 .prepare(format!(
371 "SELECT SUM(CASE WHEN kind = 'usage' THEN amount_micros
372 WHEN kind = 'top_up' AND ({live} = 1 OR reference LIKE 'crd%') THEN amount_micros
373 ELSE 0 END) AS paid
374 FROM ledger WHERE workspace IN ({marks}) AND created_at < ?",
375 live = u8::from(live)
376 ))
377 .bind(&before)?
378 .first::<Paid>(None)
379 .await?
380 .and_then(|row| row.paid)
381 .unwrap_or(0),
382 )
383 };
384 // The trust ceiling, from what has been paid and how steadily. The
385 // first month is asked for beside it, since neither needs the other.
386 let trust = async {
387 Ok::<_, worker::Error>(match account.terms.kind {
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging388 _ if account.terms.full_discount() => (Trust::Internal, None, false),
Billing answers every page in a few round trips, not forty: its reads run together, Stripe is asked at most hourly, and the ledger has a time index389 _ if account.terms.ceiling_micros.is_some() => (Trust::Reviewed, account.terms.ceiling_micros, false),
390 _ => {
391 let standing = async {
392 let paid = self.live_paid(&members).await?;
393 let established = if paid > 0 { self.established(&members).await? } else { None };
394 Ok::<_, worker::Error>((paid, established))
395 };
396 let first = async {
397 if plan == PlanKind::Paid { self.first_month(&workspace).await } else { Ok(false) }
398 };
399 let ((paid, established), first_month) = try_join(standing, first).await?;
400 if plan == PlanKind::Free {
401 // Nothing on demand: only what a free workspace can owe.
402 (Trust::New, Some(self.ceilings.new), false)
403 } else {
404 let ceiling = paid_ceiling(&self.ceilings, self.plans.paid_start_micros, first_month, paid, established);
405 (if established.is_some() { Trust::Established } else { Trust::Paid }, Some(ceiling), first_month)
406 }
Billing accounts, terms and enterprises; g1t is no longer free407 }
Billing answers every page in a few round trips, not forty: its reads run together, Stripe is asked at most hourly, and the ledger has a time index408 })
Usage limits: unpaid usage can only go so far409 };
Billing answers every page in a few round trips, not forty: its reads run together, Stripe is asked at most hourly, and the ledger has a time index410 // This month's charges, and last month's, for the spend limit.
411 let charged = self.charged_months(&members, &month_start);
412 // None of these reads needs another's answer, so they go to D1 at
413 // once: the limit is on every signed-in page.
414 let ((row, month, pending, balance_before, (spent, last_month)), (trust, trust_ceiling, first_month)) =
415 try_join(try_join5(row, month, pending, balance_before, charged), trust).await?;
416 let (used, paid_month) = month.map_or((0, 0), |m| (m.used.unwrap_or(0), m.paid.unwrap_or(0)));
417 let used = used + pending;
418 let (exposure, prepaid) = exposure(used, if live { paid_month } else { 0 }, balance_before);
419
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look420 // A ceiling g1t granted is a floor under the trust ceiling.
421 let granted = row.as_ref().and_then(|row| row.granted_ceiling_micros);
422 let ceiling = trust_ceiling.map(|c| c.max(granted.unwrap_or(0)));
423 // The highest ceiling ever, kept as it rises.
424 let stored_max = row.as_ref().and_then(|row| row.max_ceiling_micros);
425 let max_ever = match (stored_max, ceiling) {
426 (Some(stored), Some(now)) => Some(stored.max(now)),
427 (stored, now) => stored.or(now),
428 };
429 if let (Some(max), true) = (max_ever, ceiling.is_some() && max_ever != stored_max && plan != PlanKind::Free) {
430 self.db
431 .prepare(
432 "INSERT INTO limits (workspace, max_ceiling_micros, updated_at) VALUES (?1, ?2, ?3)
433 ON CONFLICT (workspace) DO UPDATE SET max_ceiling_micros = MAX(COALESCE(max_ceiling_micros, 0), ?2), updated_at = ?3",
434 )
435 .bind(&[workspace.as_str().into(), (max as f64).into(), now.as_str().into()])?
436 .run()
437 .await?;
438 }
Two limits, real invoices, trust that grows by itself, sales signals439 let spent = spent + pending;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look440 let raised_at = row.as_ref().and_then(|row| row.raised_at.clone());
441 let self_serve = matches!(trust, Trust::New | Trust::Paid | Trust::Established) && plan != PlanKind::Free;
442 let (available, raise_once) = match (ceiling, self_serve) {
443 (Some(ceiling), true) => {
444 let (available, once) = spend_bounds(ceiling, max_ever.unwrap_or(ceiling), prepaid, raised_at.is_some());
445 (Some(available), once)
446 }
447 (ceiling, _) => (ceiling, None),
448 };
Two limits, real invoices, trust that grows by itself, sales signals449 // The owners' own monthly limit: theirs, none, or the automatic one
450 // ($200, or twice last month), which self-serve workspaces start on.
451 let chosen = row.as_ref().and_then(|row| row.spend_limit_micros);
452 let full = row.as_ref().and_then(|row| row.spend_limit_full).unwrap_or(0) == 1;
453 let default_spend_limit = chosen.is_none() && !full && self_serve;
454 let spend_limit = match (chosen, full) {
455 (Some(own), _) => Some(own),
456 (None, true) => None,
457 (None, false) if self_serve => Some(automatic_spend_limit(last_month)),
458 _ => None,
459 };
Billing accounts, terms and enterprises; g1t is no longer free460 // A card declined when g1t charged it at the limit stops work until
461 // it is paid; any payment clears it.
462 let declined = row.as_ref().and_then(|row| row.autopay_failed_at.clone().map(|at| (at, row.autopay_error.clone())));
Two limits, real invoices, trust that grows by itself, sales signals463 // Two limits: g1t's on what is unpaid, the owners' on what is spent.
464 let risk = state(exposure, ceiling);
Usage, Billing settings and prepaid AI credit; fixes from the UX audit465 // A budget that does not pause usage only alerts: at 100% it is a
466 // warning, never a stop. g1t's own ceiling still stops work.
467 let pause = row.as_ref().and_then(|row| row.pause_at_limit).unwrap_or(1) != 0;
468 let budget = budget_state(spent, spend_limit, pause);
Two limits, real invoices, trust that grows by itself, sales signals469 let over_budget = budget == LimitState::Stopped;
Billing keeps Stripe's view itself: the saved card on the account, missed events replayed every 15 minutes, and the endpoint kept470 let state = if (declined.is_some() && exposure > 0) || risk == LimitState::Stopped || over_budget {
Two limits, real invoices, trust that grows by itself, sales signals471 LimitState::Stopped
472 } else if risk == LimitState::Warning || budget == LimitState::Warning {
473 LimitState::Warning
474 } else {
475 LimitState::Ok
Usage limits: unpaid usage can only go so far476 };
Billing accounts, terms and enterprises; g1t is no longer free477 let who = if account.kind == g1t_contracts::billing::AccountKind::Enterprise {
478 format!("The {} enterprise, which pays for {workspace},", account.name)
479 } else {
480 format!("The {workspace} workspace")
481 };
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look482 let billing = format!("/{workspace}/-/billing");
Usage limits: unpaid usage can only go so far483 let message = match state {
484 LimitState::Ok => None,
Usage, Billing settings and prepaid AI credit; fixes from the UX audit485 LimitState::Warning if budget == LimitState::Warning && !pause => Some(format!(
486 "{who} has spent {} of its {} monthly budget. Usage does not pause at the budget; an owner can change that at {billing}.",
487 dollars_plain(spent),
488 dollars_plain(spend_limit.unwrap_or_default()),
489 )),
Two limits, real invoices, trust that grows by itself, sales signals490 LimitState::Warning if budget == LimitState::Warning => Some(format!(
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look491 "{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 at {billing}.",
Two limits, real invoices, trust that grows by itself, sales signals492 dollars_plain(spent),
493 dollars_plain(spend_limit.unwrap_or_default()),
494 )),
Usage limits: unpaid usage can only go so far495 LimitState::Warning => Some(format!(
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look496 "{who} has {} of usage not yet paid for, of the {} g1t allows. With a card on file g1t charges it now; prepaying at {billing} raises what it can use at once.",
Usage limits: unpaid usage can only go so far497 dollars_plain(exposure),
498 dollars_plain(ceiling.unwrap_or_default()),
499 )),
Billing accounts, terms and enterprises; g1t is no longer free500 LimitState::Stopped if declined.is_some() => Some(format!(
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look501 "{who} could not be charged for its usage ({}), so its sandboxes, builds and apps are stopped. An owner can pay with another card at {billing}.",
Billing accounts, terms and enterprises; g1t is no longer free502 declined.as_ref().and_then(|(_, error)| error.clone()).unwrap_or_else(|| "the card was declined".to_owned()),
503 )),
Two limits, real invoices, trust that grows by itself, sales signals504 LimitState::Stopped => Some(if over_budget {
Usage limits: unpaid usage can only go so far505 format!(
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look506 "{who} reached its {} monthly spend limit, so its sandboxes, builds and apps are stopped until the month turns. An owner can raise it at {billing}.",
Two limits, real invoices, trust that grows by itself, sales signals507 dollars_plain(spend_limit.unwrap_or_default()),
Usage limits: unpaid usage can only go so far508 )
509 } else {
510 format!(
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look511 "{who} reached its {} limit for usage not yet paid for, so its sandboxes, builds and apps are stopped. An owner can pay or prepay, or ask for a higher limit, at {billing}.",
Usage limits: unpaid usage can only go so far512 dollars_plain(ceiling.unwrap_or_default()),
513 )
514 }),
515 };
Two limits, real invoices, trust that grows by itself, sales signals516 let growth = match trust {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look517 Trust::New => Some("Free workspaces have no on-demand usage: the g1t plan starts at a $100 limit.".to_owned()),
518 Trust::Paid if first_month => Some(format!(
519 "Your first month's limit is {}. After it, the limit grows to twice what you have paid as payments clear ({SETTLE_DAYS} days), up to $1,000. Prepaying raises it at once, and you can ask for more.",
520 dollars_plain(self.plans.paid_start_micros)
521 )),
Two limits, real invoices, trust that grows by itself, sales signals522 Trust::Paid => Some(format!(
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look523 "Grows to twice what you have paid, as payments clear ({SETTLE_DAYS} days), up to $1,000. After three steady months it follows your monthly spend, up to $10,000, by itself. Prepaying raises it at once."
Two limits, real invoices, trust that grows by itself, sales signals524 )),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look525 Trust::Established => Some("Follows your monthly spend, up to $10,000, by itself. Prepaying raises it at once, and you can ask for more.".to_owned()),
Two limits, real invoices, trust that grows by itself, sales signals526 Trust::Reviewed | Trust::Internal => None,
527 };
Usage limits: unpaid usage can only go so far528 Ok(Limit {
529 workspace,
Billing answers every page in a few round trips, not forty: its reads run together, Stripe is asked at most hourly, and the ledger has a time index530 account: account.id.clone(),
531 account_name: account.name.clone(),
Two limits, real invoices, trust that grows by itself, sales signals532 spent_micros: spent,
533 default_spend_limit,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look534 available_micros: available,
Two limits, real invoices, trust that grows by itself, sales signals535 growth,
Usage limits: unpaid usage can only go so far536 trust,
537 exposure_micros: exposure,
538 ceiling_micros: ceiling,
539 trust_ceiling_micros: trust_ceiling,
540 spend_limit_micros: spend_limit,
541 state,
542 message,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look543 prepaid_micros: prepaid,
544 max_ceiling_micros: max_ever.filter(|_| plan != PlanKind::Free),
545 raise_once_micros: raise_once,
546 raised_at,
547 first_month,
Usage, Billing settings and prepaid AI credit; fixes from the UX audit548 alert_levels: alert_levels(row.as_ref().and_then(|row| row.alert_levels.as_deref())),
549 pause_at_limit: pause,
550 budget_webhook: row.as_ref().and_then(|row| row.budget_webhook.clone()),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look551 })
552 }
553
554 /// Whether the workspace's plan is in its first billing cycle.
555 pub(crate) async fn first_month(&self, workspace: &str) -> Result<bool> {
556 Ok(match self.plan_cycle(workspace).await? {
557 Some((started_at, period_end)) => in_first_cycle(&started_at, period_end.as_deref(), &rfc3339(now_ms())),
558 None => false,
Usage limits: unpaid usage can only go so far559 })
560 }
561
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look562 /// Real money the workspaces have paid g1t, cleared: usage payments and
563 /// the plan's price. Nothing in test mode, and credits g1t gave are not
564 /// payments.
565 pub(crate) async fn live_paid(&self, members: &[JsValue]) -> Result<i64> {
Usage limits: unpaid usage can only go so far566 if !self.stripe.as_ref().is_some_and(crate::stripe::Stripe::live) {
567 return Ok(0);
568 }
Billing accounts, terms and enterprises; g1t is no longer free569 let marks = vec!["?"; members.len().max(1)].join(", ");
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look570 let settled = rfc3339(now_ms() - SETTLE_DAYS * 24 * 60 * 60 * 1000);
Usage limits: unpaid usage can only go so far571 Ok(self
572 .db
Billing accounts, terms and enterprises; g1t is no longer free573 .prepare(format!(
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look574 "SELECT
575 (SELECT COALESCE(SUM(amount_micros), 0) FROM ledger
576 WHERE workspace IN ({marks}) AND kind = 'top_up' AND reference NOT LIKE 'crd%'
577 AND (amount_micros < 0
578 OR (disputed = 0 AND COALESCE(funding, '') <> 'prepaid'
579 AND created_at <= '{settled}')))
580 + (SELECT COALESCE(SUM(amount_micros), 0) FROM plan_payments
581 WHERE workspace IN ({marks}) AND paid_at <= '{settled}') AS paid"
Billing accounts, terms and enterprises; g1t is no longer free582 ))
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look583 .bind(&[members, members].concat())?
Usage limits: unpaid usage can only go so far584 .first::<Paid>(None)
585 .await?
586 .and_then(|row| row.paid)
587 .unwrap_or(0))
588 }
589
Two limits, real invoices, trust that grows by itself, sales signals590 /// This month's charges and last month's, across the workspaces.
591 async fn charged_months(&self, members: &[JsValue], month_start: &str) -> Result<(i64, i64)> {
592 #[derive(Deserialize)]
593 struct Charged {
594 this_month: Option<i64>,
595 last_month: Option<i64>,
596 }
597 let last_start = format!("{}-01", previous_month(&month_start[..7]));
598 let marks = vec!["?"; members.len().max(1)].join(", ");
599 let row = self
600 .db
601 .prepare(format!(
602 "SELECT
603 -SUM(CASE WHEN created_at >= '{month_start}' THEN amount_micros END) AS this_month,
604 -SUM(CASE WHEN created_at >= '{last_start}' AND created_at < '{month_start}' THEN amount_micros END) AS last_month
605 FROM ledger WHERE kind = 'usage' AND workspace IN ({marks}) AND created_at >= '{last_start}'"
606 ))
607 .bind(members)?
608 .first::<Charged>(None)
609 .await?;
610 Ok(row.map_or((0, 0), |r| (r.this_month.unwrap_or(0).max(0), r.last_month.unwrap_or(0).max(0))))
611 }
612
613 /// An Established ceiling, if the workspaces have paid steadily: three
614 /// full months of real spend, each invoiced and paid, nothing declined
615 /// in 90 days and nothing ever disputed.
616 async fn established(&self, members: &[JsValue]) -> Result<Option<i64>> {
617 let marks = vec!["?"; members.len().max(1)].join(", ");
618 let now = rfc3339(now_ms());
619 let mut months = vec![];
620 let mut month = previous_month(&now[..7]);
621 for _ in 0..3 {
622 months.push(month.clone());
623 month = previous_month(&month);
624 }
625 #[derive(Deserialize)]
626 struct Count {
627 n: Option<i64>,
628 }
Billing answers every page in a few round trips, not forty: its reads run together, Stripe is asked at most hourly, and the ledger has a time index629 let troubled = async {
630 Ok::<i64, worker::Error>(
631 self.db
632 .prepare(format!(
633 "SELECT (SELECT COUNT(*) FROM ledger WHERE workspace IN ({marks}) AND disputed = 1)
634 + (SELECT COUNT(*) FROM limits WHERE workspace IN ({marks}) AND autopay_failed_at >= '{since}') AS n",
635 since = rfc3339(now_ms() - 90 * 24 * 60 * 60 * 1000)
636 ))
637 .bind(&[members, members].concat())?
638 .first::<Count>(None)
639 .await?
640 .and_then(|c| c.n)
641 .unwrap_or(0),
642 )
643 };
644 #[derive(Deserialize)]
645 struct Month {
646 charged: Option<i64>,
647 unpaid: Option<i64>,
Two limits, real invoices, trust that grows by itself, sales signals648 }
Billing answers every page in a few round trips, not forty: its reads run together, Stripe is asked at most hourly, and the ledger has a time index649 let read_month = |month: &String| {
Two limits, real invoices, trust that grows by itself, sales signals650 let next = {
651 let year: i32 = month[..4].parse().unwrap_or(1970);
652 let number: u32 = month[5..7].parse().unwrap_or(1);
653 if number == 12 { format!("{}-01", year + 1) } else { format!("{year}-{:02}", number + 1) }
654 };
Billing answers every page in a few round trips, not forty: its reads run together, Stripe is asked at most hourly, and the ledger has a time index655 let sql = format!(
656 "SELECT
657 (SELECT -SUM(amount_micros) FROM ledger WHERE kind = 'usage' AND workspace IN ({marks})
658 AND created_at >= '{month}-01' AND created_at < '{next}-01') AS charged,
659 (SELECT COUNT(*) FROM workspace_invoices WHERE workspace IN ({marks}) AND reason = 'month'
660 AND period = '{month}' AND status <> 'paid') AS unpaid"
661 );
662 async move { self.db.prepare(sql).bind(&[members, members].concat())?.first::<Month>(None).await }
663 };
664 // The check for trouble and the three months are read at once; the
665 // answer is the one reading them in turn and stopping early gives.
666 let (troubled, rows) = try_join(troubled, try_join_all(months.iter().map(read_month))).await?;
667 if troubled > 0 {
668 return Ok(None);
669 }
670 let mut charged = vec![];
671 for row in rows {
Two limits, real invoices, trust that grows by itself, sales signals672 let Some(row) = row else { return Ok(None) };
673 if row.unpaid.unwrap_or(0) > 0 {
674 return Ok(None);
675 }
676 charged.push(row.charged.unwrap_or(0));
677 }
678 Ok(established_ceiling(&charged))
679 }
680
Usage limits: unpaid usage can only go so far681 /// A refusal, with the reason, when the workspace's work is stopped.
682 /// None while billing is off: a g1t without payments has no limits.
683 pub(crate) async fn stopped<T>(&self, workspace: &str) -> Result<Option<Outcome<T>>> {
684 if self.stripe.is_none() {
685 return Ok(None);
686 }
687 let limit = self.limit_of(workspace).await?;
688 Ok((limit.state == LimitState::Stopped).then(|| {
689 Outcome::fail(
690 FailureCode::PaymentRequired,
691 limit.message.unwrap_or_else(|| "This workspace is over its limit.".to_owned()),
692 )
693 }))
694 }
695
696 pub(crate) async fn limit(&self, a: LimitArgs) -> Result<Outcome<Limit>> {
697 let workspace = a.workspace.to_lowercase();
698 if !a.viewer.is_some_and(|viewer| viewer.is_member(&workspace)) {
699 return Ok(members_only());
700 }
701 Ok(Outcome::Ok(self.limit_of(&workspace).await?))
702 }
703
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look704 /// What a source cost so far this month. `security`, `context`,
705 /// `storage` and `git` are charged by billing once the month is over
706 /// (see `storage`); `deployments` charges its own.
Prices keep themselves current with what g1t pays707 pub(crate) async fn note_pending(&self, a: NotePendingArgs) -> Result<bool> {
Fast pages, required checks on the branch, self-hosted runners, honest incidents708 // The actions cache is the plan's to pay for; free workspaces are
709 // held to its quota instead.
710 if crate::storage::PLAN_ONLY.contains(&a.source.as_str()) && !self.has_plan(&a.workspace.to_lowercase()).await? {
711 return Ok(false);
712 }
Prices keep themselves current with what g1t pays713 let now = rfc3339(now_ms());
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas714 let detail = a.detail.as_deref().map(str::trim).filter(|d| !d.is_empty()).map(|d| d.chars().take(200).collect::<String>());
715 self.set_pending(&a.workspace, &a.source, &now[..7], a.cost_micros, detail.as_deref()).await?;
Prices keep themselves current with what g1t pays716 Ok(true)
717 }
718
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails719 /// The workspaces with usage on the ledger this month: autopay's and
720 /// the limit warnings' candidates, read once a tick for both. Served by
721 /// `ledger_usage_by_time` (migration 0050), not a scan of the ledger.
722 pub(crate) async fn month_users(&self) -> Result<BTreeSet<String>> {
723 #[derive(Deserialize)]
724 struct User {
725 workspace: String,
726 }
727 let month_start = format!("{}-01", &rfc3339(now_ms())[..7]);
728 Ok(self
729 .db
730 .prepare("SELECT DISTINCT workspace FROM ledger WHERE kind = 'usage' AND created_at >= ?")
731 .bind(&[month_start.into()])?
732 .all()
733 .await?
734 .results::<User>()?
735 .into_iter()
736 .map(|user| user.workspace)
737 .collect())
738 }
739
Billing accounts, terms and enterprises; g1t is no longer free740 /// Charges the saved card of each workspace nearing its limit, for what
741 /// it owes, so that a workspace that pays never has its work stopped.
742 /// Only with live payments: test-mode payments are not money and lower
743 /// nothing. Not for a workspace's own spend limit, which means stop, nor
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look744 /// for enterprises, which are invoiced. A charge at the limit always
745 /// goes through, whatever the minimum charge.
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails746 ///
747 /// `users` are the workspaces with usage this month ([`Self::month_users`]).
748 pub(crate) async fn autopay(&self, users: &BTreeSet<String>) -> Result<()> {
Two limits, real invoices, trust that grows by itself, sales signals749 if !self.stripe.as_ref().is_some_and(crate::stripe::Stripe::live) {
Billing accounts, terms and enterprises; g1t is no longer free750 return Ok(());
Two limits, real invoices, trust that grows by itself, sales signals751 }
Billing accounts, terms and enterprises; g1t is no longer free752 #[derive(Deserialize)]
753 struct Candidate {
754 workspace: String,
755 }
Two limits, real invoices, trust that grows by itself, sales signals756 // With a card, and not already declined: a declined card waits for
757 // the owners, rather than being tried again every few minutes.
Billing accounts, terms and enterprises; g1t is no longer free758 let candidates = self
759 .db
760 .prepare(
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails761 "SELECT accounts.workspace AS workspace
762 FROM accounts LEFT JOIN limits ON limits.workspace = accounts.workspace
763 WHERE accounts.customer_id IS NOT NULL AND limits.autopay_failed_at IS NULL",
Billing accounts, terms and enterprises; g1t is no longer free764 )
765 .all()
766 .await?
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails767 .results::<Candidate>()?
768 .into_iter()
769 .filter(|candidate| users.contains(&candidate.workspace));
Billing accounts, terms and enterprises; g1t is no longer free770 for candidate in candidates {
771 let limit = self.limit_of(&candidate.workspace).await?;
Two limits, real invoices, trust that grows by itself, sales signals772 // Near g1t's ceiling on what is unpaid; the spend limit is the
773 // owners' and stops work by itself, but what is owed is still owed.
774 let near = limit.ceiling_micros.is_some_and(|ceiling| limit.exposure_micros * 5 >= ceiling * 4);
775 if !near || limit.trust == Trust::Internal || limit.account.starts_with("ent_") {
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace776 continue;
777 }
Two limits, real invoices, trust that grows by itself, sales signals778 let today = rfc3339(now_ms())[..10].to_owned();
779 match self.invoice_workspace(&candidate.workspace, "threshold", &today).await? {
780 Ok(_) => {}
781 Err(why) => worker::console_log!("{}: no threshold invoice: {why}", candidate.workspace),
Billing accounts, terms and enterprises; g1t is no longer free782 }
783 }
784 Ok(())
785 }
786
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace787 /// Closes last month for each workspace with a card on file: charges
788 /// what it owed when the month ended. Live payments only, once per
789 /// workspace and month; a declined card stops work until it is paid.
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look790 /// Comped workspaces owe nothing, and enterprises are invoiced. Only
791 /// here does the minimum charge apply: less carries over.
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace792 pub(crate) async fn close_months(&self) -> Result<()> {
Two limits, real invoices, trust that grows by itself, sales signals793 if !self.stripe.as_ref().is_some_and(crate::stripe::Stripe::live) {
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace794 return Ok(());
Two limits, real invoices, trust that grows by itself, sales signals795 }
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace796 let now = rfc3339(now_ms());
797 let month_start = format!("{}-01", &now[..7]);
798 let closing = previous_month(&now[..7]);
799 #[derive(Deserialize)]
800 struct Open {
801 workspace: String,
802 balance: Option<i64>,
803 }
804 let open = self
805 .db
806 .prepare(
Two limits, real invoices, trust that grows by itself, sales signals807 "SELECT accounts.workspace AS workspace,
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace808 (SELECT SUM(amount_micros) FROM ledger
809 WHERE ledger.workspace = accounts.workspace AND ledger.created_at < ?1) AS balance
810 FROM accounts
811 WHERE accounts.customer_id IS NOT NULL
812 AND NOT EXISTS (SELECT 1 FROM month_closes
813 WHERE month_closes.workspace = accounts.workspace AND month_closes.month = ?2)
814 LIMIT 20",
815 )
816 .bind(&[month_start.as_str().into(), closing.as_str().into()])?
817 .all()
818 .await?
819 .results::<Open>()?;
820 for account in open {
821 let record = |status: &str, amount: i64, payment: Option<&str>, error: Option<&str>| {
822 self.db
823 .prepare(
824 "INSERT OR IGNORE INTO month_closes (workspace, month, status, amount_micros, payment_id, error, closed_at)
825 VALUES (?, ?, ?, ?, ?, ?, ?)",
826 )
827 .bind(&[
828 account.workspace.as_str().into(),
829 closing.as_str().into(),
830 status.into(),
831 (amount as f64).into(),
832 crate::optional(payment),
833 crate::optional(error),
834 now.as_str().into(),
835 ])
836 };
837 let payer = self.account_of(&account.workspace).await?;
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging838 if payer.terms.full_discount() || payer.id.starts_with("ent_") {
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace839 record("skipped", 0, None, None)?.run().await?;
840 continue;
841 }
Usage, Billing settings and prepaid AI credit; fixes from the UX audit842 // AI credit left at the month's end pays only for models: not
843 // money for anything else that is owed (ai.rs).
844 let ai_left = self.models_left_before(&account.workspace, &month_start).await?;
845 let owed = (ai_left - account.balance.unwrap_or(0)).max(0);
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put846 if owed == 0 {
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace847 record("nothing", 0, None, None)?.run().await?;
848 continue;
849 }
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put850 if !worth_charging(owed, self.plans.min_charge_micros) {
851 // Under the minimum charge: a card payment's fee would be
852 // too much of it. It stays owed and goes on the next
853 // invoice that reaches the minimum.
854 record("carried", owed, None, None)?.run().await?;
855 continue;
856 }
Two limits, real invoices, trust that grows by itself, sales signals857 match self.invoice_workspace(&account.workspace, "month", &closing).await? {
858 Ok(invoice) if invoice.status == "paid" => {
859 record("paid", invoice.amount_micros, Some(&invoice.invoice_id), None)?.run().await?;
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace860 }
Two limits, real invoices, trust that grows by itself, sales signals861 Ok(invoice) => {
862 record("failed", invoice.amount_micros, Some(&invoice.invoice_id), Some("the card was declined"))?.run().await?;
863 }
864 Err(why) => {
865 record("nothing", 0, None, Some(&why))?.run().await?;
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace866 }
867 }
868 }
869 Ok(())
870 }
871
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look872 /// Emails a workspace's owners as it passes 50, 75, 90 and 100% of its
873 /// plan's included usage, its spend limit and g1t's ceiling, once each a
874 /// month; when its card was declined; and when a spend spike paused it.
875 /// The same alerts show in the app (`entitlements`).
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails876 ///
877 /// `users` are the workspaces with usage this month ([`Self::month_users`]).
878 pub(crate) async fn warn_limits(&self, identity: &worker::Fetcher, users: &BTreeSet<String>) -> Result<()> {
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace879 if self.stripe.is_none() {
880 return Ok(());
881 }
882 let now = rfc3339(now_ms());
883 let month = &now[..7];
884 #[derive(Deserialize)]
885 struct Candidate {
886 workspace: String,
887 }
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails888 let mut candidates = users.clone();
889 candidates.extend(
890 self.db
891 .prepare("SELECT workspace FROM limits WHERE autopay_failed_at IS NOT NULL")
892 .all()
893 .await?
894 .results::<Candidate>()?
895 .into_iter()
896 .map(|candidate| candidate.workspace),
897 );
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace898 #[derive(Deserialize)]
899 struct Told {
900 autopay_failed_at: Option<String>,
901 declined_told_at: Option<String>,
902 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look903 #[derive(Deserialize)]
904 struct Sent {
905 meter: String,
906 level: Option<i64>,
907 }
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails908 for workspace in candidates {
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace909 let told = self
910 .db
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look911 .prepare("SELECT autopay_failed_at, declined_told_at FROM limits WHERE workspace = ?")
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace912 .bind(&[workspace.as_str().into()])?
913 .first::<Told>(None)
914 .await?;
915 let billing = format!("https://g1t.sh/{workspace}/-/billing");
916
917 // A declined card, once per decline.
Billing keeps Stripe's view itself: the saved card on the account, missed events replayed every 15 minutes, and the endpoint kept918 if let Some(Told { autopay_failed_at: Some(failed), declined_told_at }) = &told
919 && declined_told_at.as_deref().is_none_or(|at| at < failed.as_str()) {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look920 let limit = self.limit_of(&workspace).await?;
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace921 let sent = notify(
922 identity,
923 &workspace,
924 &format!("g1t: the card for {workspace} was declined"),
925 &limit.message.clone().unwrap_or_else(|| format!("g1t could not charge the card on file for {workspace}.")),
926 "Update the card",
927 &billing,
928 )
929 .await;
930 if sent {
931 self.db
932 .prepare("UPDATE limits SET declined_told_at = ? WHERE workspace = ?")
933 .bind(&[now.as_str().into(), workspace.as_str().into()])?
934 .run()
935 .await?;
936 }
937 }
938
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look939 // 50, 75, 90 and 100%, once each a month and meter: only the
940 // highest new level is emailed.
941 let alerts = self.alerts_for(&workspace).await?;
942 if alerts.is_empty() {
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace943 continue;
944 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look945 let sent: Vec<Sent> = self
946 .db
947 .prepare("SELECT meter, MAX(level) AS level FROM alerts_sent WHERE workspace = ? AND month = ? GROUP BY meter")
948 .bind(&[workspace.as_str().into(), month.into()])?
949 .all()
950 .await?
951 .results::<Sent>()?;
952 for alert in alerts {
953 let already = sent.iter().find(|s| s.meter == alert.meter).and_then(|s| s.level).unwrap_or(0);
954 if i64::from(alert.level) <= already {
955 continue;
956 }
957 let subject = match alert.meter.as_str() {
958 "included" => format!("g1t: {workspace} has used {}% of its included usage", alert.level),
959 "spend_limit" => format!("g1t: {workspace} has used {}% of its spend limit", alert.level),
960 _ => format!("g1t: {workspace} has used {}% of its usage limit", alert.level),
961 };
Usage, Billing settings and prepaid AI credit; fixes from the UX audit962 // The budget's webhook hears of its alerts too, once each.
963 if alert.meter == "spend_limit"
964 && let Some(url) = self.budget_webhook_of(&workspace).await?
965 {
966 self.post_budget_webhook(&url, &workspace, alert.level, alert.used_micros, alert.limit_micros).await;
967 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look968 if notify(identity, &workspace, &subject, &alert.message, "Open billing", &billing).await {
969 self.db
970 .prepare(
971 "INSERT OR IGNORE INTO alerts_sent (workspace, month, meter, level, sent_at) VALUES (?1, ?2, ?3, ?4, ?5)",
972 )
973 .bind(&[workspace.as_str().into(), month.into(), alert.meter.as_str().into(), alert.level.into(), now.as_str().into()])?
974 .run()
975 .await?;
976 }
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace977 }
978 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look979 self.tell_spikes(identity).await?;
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace980 Ok(())
981 }
982
Usage limits: unpaid usage can only go so far983 pub(crate) async fn check_limit(&self, a: CheckLimitArgs) -> Result<Outcome<Limit>> {
984 Ok(Outcome::Ok(self.limit_of(&a.workspace).await?))
985 }
986
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look987 /// The owners' spend limit: anywhere up to what is available without
988 /// asking; once, up to twice the highest ceiling (`raise_once`), which
989 /// also raises g1t's ceiling to match.
Usage limits: unpaid usage can only go so far990 pub(crate) async fn set_spend_limit(&self, a: SetSpendLimitArgs) -> Result<Outcome<Limit>> {
991 let workspace = a.workspace.to_lowercase();
Merge main (membership, two-factor, GitHub repo roles) into tokens992 if !a.actor.manages_billing(&workspace) {
Usage limits: unpaid usage can only go so far993 return Ok(Outcome::fail(
994 FailureCode::Forbidden,
Merge main (membership, two-factor, GitHub repo roles) into tokens995 "Only an owner or a billing manager can set the workspace's spend limit.",
Usage limits: unpaid usage can only go so far996 ));
997 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look998 let before = self.limit_of(&workspace).await?;
Usage, Billing settings and prepaid AI credit; fixes from the UX audit999 // A free workspace has no on-demand usage to limit: its ceiling is
1000 // only what it can owe for storage, and no raise applies to it.
1001 if before.trust == Trust::New {
1002 return Ok(Outcome::fail(
1003 FailureCode::Conflict,
1004 format!("{workspace} is not on the g1t plan, so it has no usage to put a spend limit on. The plan starts with a {} limit for its first month.", dollars_plain(self.plans.paid_start_micros)),
1005 ));
1006 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1007 let mut raising = false;
1008 if let (Some(requested), false) = (a.spend_limit_micros, a.use_full_limit) {
1009 match (before.available_micros, matches!(before.trust, Trust::Internal | Trust::Reviewed)) {
1010 (_, true) | (None, _) => {
1011 if requested < 0 {
1012 return Ok(Outcome::fail(FailureCode::Invalid, "A spend limit cannot be negative."));
1013 }
1014 }
1015 (Some(available), false) => match self_serve(requested, available, before.raise_once_micros, a.raise_once) {
1016 Ok(uses_raise) => raising = uses_raise,
1017 Err(why) => return Ok(Outcome::fail(FailureCode::Invalid, why)),
1018 },
1019 }
Usage limits: unpaid usage can only go so far1020 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1021 let now = rfc3339(now_ms());
Two limits, real invoices, trust that grows by itself, sales signals1022 let limit = if a.use_full_limit { JsValue::NULL } else { a.spend_limit_micros.map_or(JsValue::NULL, |limit| (limit as f64).into()) };
Usage limits: unpaid usage can only go so far1023 self.db
1024 .prepare(
Two limits, real invoices, trust that grows by itself, sales signals1025 "INSERT INTO limits (workspace, spend_limit_micros, spend_limit_full, updated_at) VALUES (?1, ?2, ?3, ?4)
1026 ON CONFLICT (workspace) DO UPDATE SET spend_limit_micros = ?2, spend_limit_full = ?3, updated_at = ?4",
Usage limits: unpaid usage can only go so far1027 )
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1028 .bind(&[workspace.as_str().into(), limit, (if a.use_full_limit { 1 } else { 0 }).into(), now.as_str().into()])?
Usage limits: unpaid usage can only go so far1029 .run()
1030 .await?;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1031 if raising {
1032 let raised = a.spend_limit_micros.unwrap_or_default();
1033 // The ceiling rises with it, once.
1034 self.db
1035 .prepare(
1036 "UPDATE limits SET granted_ceiling_micros = MAX(COALESCE(granted_ceiling_micros, 0), ?2),
1037 raised_at = ?3, updated_at = ?3 WHERE workspace = ?1 AND raised_at IS NULL",
1038 )
1039 .bind(&[workspace.as_str().into(), (raised as f64).into(), now.as_str().into()])?
1040 .run()
1041 .await?;
1042 let account = self.account_of(&workspace).await?;
1043 self.audit(&account.id, "raise_once", &format!("{workspace} used its one-time raise: {}", dollars_plain(raised)), &a.actor.username)
1044 .await?;
1045 }
Usage limits: unpaid usage can only go so far1046 Ok(Outcome::Ok(self.limit_of(&workspace).await?))
1047 }
Usage, Billing settings and prepaid AI credit; fixes from the UX audit1048
1049 /// `set_budget`: the owners' monthly budget on usage after what the
1050 /// plan includes: the spend limit (set as `set_spend_limit` sets it,
1051 /// with the same bounds), which alerts to send, whether usage pauses at
1052 /// 100%, and a webhook told at each alert.
1053 pub(crate) async fn set_budget(&self, a: SetBudgetArgs) -> Result<Outcome<Limit>> {
1054 let workspace = a.workspace.to_lowercase();
Merge main (membership, two-factor, GitHub repo roles) into tokens1055 if !a.actor.manages_billing(&workspace) {
1056 return Ok(Outcome::fail(FailureCode::Forbidden, "Only an owner or a billing manager can set the workspace's budget."));
Usage, Billing settings and prepaid AI credit; fixes from the UX audit1057 }
1058 if a.alerts.iter().any(|level| !ALERT_LEVELS.contains(level)) {
1059 return Ok(Outcome::fail(FailureCode::Invalid, "Alerts are at 50, 75, 90 or 100% of the budget."));
1060 }
1061 let webhook = a.webhook.as_deref().map(str::trim).filter(|url| !url.is_empty());
1062 if let Some(url) = webhook
1063 && !webhook_ok(url)
1064 {
1065 return Ok(Outcome::fail(FailureCode::Invalid, "The webhook is an https:// address of your own, at most 500 characters."));
1066 }
1067 if !a.keep_limit {
1068 let set = self
1069 .set_spend_limit(SetSpendLimitArgs {
1070 actor: a.actor.clone(),
1071 workspace: workspace.clone(),
1072 spend_limit_micros: a.amount_micros,
1073 use_full_limit: false,
1074 raise_once: false,
1075 })
1076 .await?;
1077 if let Outcome::Fail(failure) = set {
1078 return Ok(Outcome::Fail(failure));
1079 }
1080 } else if self.limit_of(&workspace).await?.trust == Trust::New {
1081 return Ok(Outcome::fail(FailureCode::Conflict, format!("{workspace} is not on the g1t plan, so it has no budget to alert on.")));
1082 } else {
1083 // The row the alerts are kept on, if the workspace has none yet.
1084 self.db
1085 .prepare("INSERT OR IGNORE INTO limits (workspace, updated_at) VALUES (?, ?)")
1086 .bind(&[workspace.as_str().into(), rfc3339(now_ms()).into()])?
1087 .run()
1088 .await?;
1089 }
1090 let mut levels = a.alerts.clone();
1091 levels.sort_by(|x, y| y.cmp(x));
1092 levels.dedup();
1093 let stored = levels.iter().map(u32::to_string).collect::<Vec<_>>().join(",");
1094 self.db
1095 .prepare(
1096 "UPDATE limits SET alert_levels = ?2, pause_at_limit = ?3, budget_webhook = ?4, updated_at = ?5 WHERE workspace = ?1",
1097 )
1098 .bind(&[
1099 workspace.as_str().into(),
1100 stored.as_str().into(),
1101 i32::from(a.pause_at_limit).into(),
1102 crate::optional(webhook),
1103 rfc3339(now_ms()).into(),
1104 ])?
1105 .run()
1106 .await?;
1107 let account = self.account_of(&workspace).await?;
1108 self.audit(
1109 &account.id,
1110 "budget",
1111 &format!(
1112 "{workspace}: budget {}, alerts at {}, {}{}",
1113 a.amount_micros.map_or_else(|| "automatic".to_owned(), dollars_plain),
1114 if stored.is_empty() { "none".to_owned() } else { format!("{stored}%") },
1115 if a.pause_at_limit { "pauses usage at 100%" } else { "alerts only" },
1116 if webhook.is_some() { ", with a webhook" } else { "" }
1117 ),
1118 &a.actor.username,
1119 )
1120 .await?;
1121 Ok(Outcome::Ok(self.limit_of(&workspace).await?))
1122 }
1123
1124
1125 /// The budget's webhook, if the workspace has one.
1126 async fn budget_webhook_of(&self, workspace: &str) -> Result<Option<String>> {
1127 #[derive(Deserialize)]
1128 struct Row {
1129 budget_webhook: Option<String>,
1130 }
1131 Ok(self
1132 .db
1133 .prepare("SELECT budget_webhook FROM limits WHERE workspace = ?")
1134 .bind(&[workspace.into()])?
1135 .first::<Row>(None)
1136 .await?
1137 .and_then(|row| row.budget_webhook)
1138 .filter(|url| webhook_ok(url)))
1139 }
1140 /// Posts a budget alert to the workspace's webhook, if it has one.
1141 /// Never fails the alert: a receiver that does not answer is logged.
1142 pub(crate) async fn post_budget_webhook(&self, url: &str, workspace: &str, level: u32, spent: i64, budget: i64) {
1143 let body = serde_json::json!({
1144 "event": "budget.alert",
1145 "workspace": workspace,
1146 "level_percent": level,
1147 "spent_micros": spent,
1148 "budget_micros": budget,
1149 "sent_at": rfc3339(now_ms()),
1150 });
1151 let headers = worker::Headers::new();
1152 let _ = headers.set("content-type", "application/json");
1153 let _ = headers.set("user-agent", "g1t-billing");
1154 let mut init = worker::RequestInit::new();
1155 init.with_method(worker::Method::Post).with_headers(headers).with_body(Some(body.to_string().into()));
1156 let sent = match worker::Request::new_with_init(url, &init) {
1157 Ok(request) => worker::Fetch::Request(request).send().await.map(|r| r.status_code()),
1158 Err(error) => Err(error),
1159 };
1160 match sent {
1161 Ok(status) if (200..300).contains(&status) => {}
1162 Ok(status) => worker::console_warn!("{workspace}'s budget webhook answered {status}"),
1163 Err(error) => worker::console_warn!("{workspace}'s budget webhook could not be reached: {error}"),
1164 }
1165 }
Usage limits: unpaid usage can only go so far1166}
1167
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1168/// Whether `owed` is enough to charge a card when a month closes: at least
1169/// the minimum charge (`MIN_CHARGE_MICROS`). Less carries over to the next
1170/// invoice. Charges at a limit do not ask.
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put1171pub(crate) fn worth_charging(owed: i64, min_charge: i64) -> bool {
1172 owed > 0 && owed >= min_charge
1173}
1174
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace1175/// Emails the workspace's owners through identity. False if nothing was sent.
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1176pub(crate) async fn notify(identity: &worker::Fetcher, workspace: &str, subject: &str, intro: &str, action: &str, link: &str) -> bool {
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging1177 let footer = "You get this because you own this workspace on g1t. Limits and alerts are explained at https://docs.g1t.sh/guides/usage-and-billing/#limits";
1178 notify_with(identity, workspace, subject, intro, action, link, footer).await
1179}
1180
1181/// `notify`, with a footer of its own.
1182pub(crate) async fn notify_with(
1183 identity: &worker::Fetcher,
1184 workspace: &str,
1185 subject: &str,
1186 intro: &str,
1187 action: &str,
1188 link: &str,
1189 footer: &str,
1190) -> bool {
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace1191 let args = g1t_contracts::identity::NotifyOwnersArgs {
1192 workspace: workspace.to_owned(),
1193 subject: subject.to_owned(),
1194 intro: intro.to_owned(),
1195 action: action.to_owned(),
1196 link: link.to_owned(),
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging1197 footer: footer.to_owned(),
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace1198 };
1199 match g1t_kit::call::<_, u32>(identity, "notify_owners", &args).await {
1200 Ok(sent) => sent > 0,
1201 Err(error) => {
1202 worker::console_error!("could not tell {workspace}'s owners: {error}");
1203 false
1204 }
1205 }
1206}
1207
1208/// `2026-09` for `2026-10`, and `2025-12` for `2026-01`.
1209pub(crate) fn previous_month(month: &str) -> String {
1210 let year: i32 = month[..4].parse().unwrap_or(1970);
1211 let number: u32 = month[5..7].parse().unwrap_or(1);
1212 if number == 1 {
1213 format!("{}-12", year - 1)
1214 } else {
1215 format!("{year}-{:02}", number - 1)
1216 }
1217}
1218
Usage limits: unpaid usage can only go so far1219#[cfg(test)]
1220mod tests {
1221 use super::*;
1222
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace1223 #[test]
Usage, Billing settings and prepaid AI credit; fixes from the UX audit1224 fn a_budget_alerts_at_the_levels_chosen_and_pauses_only_if_asked() {
1225 // Every level when none were chosen; the chosen ones, highest first.
1226 assert_eq!(alert_levels(None), [100, 90, 75, 50]);
1227 assert_eq!(alert_levels(Some("50,100,75")), [100, 75, 50]);
1228 assert_eq!(alert_levels(Some("")), Vec::<u32>::new());
1229 assert_eq!(alert_levels(Some("40, 50")), [50]);
1230 // The highest chosen level reached.
1231 assert_eq!(alert_level_in(80, 100, &[100, 75, 50]), 75);
1232 assert_eq!(alert_level_in(80, 100, &[100, 90]), 0);
1233 assert_eq!(alert_level_in(100, 100, &[100, 75, 50]), 100);
1234 assert_eq!(alert_level_in(10, 0, &[50]), 0);
1235 // At 100%: a stop when the budget pauses usage, else a warning.
1236 assert_eq!(budget_state(100, Some(100), true), LimitState::Stopped);
1237 assert_eq!(budget_state(100, Some(100), false), LimitState::Warning);
1238 assert_eq!(budget_state(10, Some(100), false), LimitState::Ok);
1239 assert_eq!(budget_state(10, None, true), LimitState::Ok);
1240 }
1241
1242 #[test]
1243 fn a_budget_webhook_is_someone_elses_https_address() {
1244 assert!(webhook_ok("https://hooks.acme.test/g1t"));
1245 assert!(!webhook_ok("http://hooks.acme.test/g1t"));
1246 assert!(!webhook_ok("https://g1t.sh/x"));
1247 assert!(!webhook_ok("https://api.g1t.sh/x"));
1248 assert!(!webhook_ok("https://localhost:3000/x"));
1249 assert!(!webhook_ok("https://a b.test/"));
1250 assert!(!webhook_ok(&format!("https://acme.test/{}", "x".repeat(600))));
1251 }
1252 #[test]
Two limits, real invoices, trust that grows by itself, sales signals1253 fn the_automatic_spend_limit_follows_last_month() {
1254 assert_eq!(automatic_spend_limit(0), 200_000_000);
1255 assert_eq!(automatic_spend_limit(50_000_000), 200_000_000);
1256 assert_eq!(automatic_spend_limit(900_000_000), 1_800_000_000);
1257 }
1258
1259 #[test]
1260 fn three_steady_months_make_a_workspace_established() {
1261 assert_eq!(established_ceiling(&[900_000_000, 850_000_000, 950_000_000]), Some(2_700_000_000));
1262 assert_eq!(established_ceiling(&[5_000_000_000, 5_000_000_000, 5_000_000_000]), Some(10_000_000_000));
1263 assert_eq!(established_ceiling(&[900_000_000, 10_000_000, 950_000_000]), None);
1264 assert_eq!(established_ceiling(&[900_000_000, 900_000_000]), None);
1265 }
1266
1267 #[test]
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1268 fn alerts_come_at_half_three_quarters_ninety_and_the_limit() {
1269 assert_eq!(alert_level(0, 10_000_000), 0);
1270 assert_eq!(alert_level(4_999_999, 10_000_000), 0);
1271 assert_eq!(alert_level(5_000_000, 10_000_000), 50);
1272 assert_eq!(alert_level(7_500_000, 10_000_000), 75);
1273 assert_eq!(alert_level(8_999_999, 10_000_000), 75);
1274 assert_eq!(alert_level(9_000_000, 10_000_000), 90);
1275 assert_eq!(alert_level(10_000_000, 10_000_000), 100);
1276 assert_eq!(alert_level(25_000_000, 10_000_000), 100);
1277 // Nothing to measure against: no alert.
1278 assert_eq!(alert_level(5, 0), 0);
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace1279 }
1280
1281 #[test]
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1282 fn amounts_under_the_minimum_carry_over_only_at_the_month_close() {
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put1283 let min = 5_000_000;
1284 assert!(!worth_charging(0, min));
1285 assert!(!worth_charging(4_990_000, min));
1286 assert!(worth_charging(5_000_000, min));
1287 assert!(worth_charging(12_000_000, min));
1288 // $3 carried from last month and $2.50 this month: charged together.
1289 let carried = 3_000_000;
1290 assert!(!worth_charging(carried, min));
1291 assert!(worth_charging(carried + 2_500_000, min));
1292 }
1293
1294 #[test]
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace1295 fn the_month_before_wraps_the_year() {
1296 assert_eq!(previous_month("2026-10"), "2026-09");
1297 assert_eq!(previous_month("2026-01"), "2025-12");
1298 }
1299
Usage limits: unpaid usage can only go so far1300 fn ceilings() -> Ceilings {
Billing accounts, terms and enterprises; g1t is no longer free1301 Ceilings { new: 3_000_000, paid_min: 25_000_000, paid_max: 1_000_000_000 }
Usage limits: unpaid usage can only go so far1302 }
1303
1304 #[test]
1305 fn trust_grows_with_what_was_paid_within_bounds() {
1306 assert_eq!(ceilings().for_paid(5_000_000), 25_000_000);
1307 assert_eq!(ceilings().for_paid(100_000_000), 200_000_000);
1308 assert_eq!(ceilings().for_paid(10_000_000_000), 1_000_000_000);
1309 }
1310
1311 #[test]
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1312 fn a_new_paid_workspace_starts_at_a_hundred_dollars_and_only_goes_up() {
1313 let start = 100_000_000;
1314 // The first month: the starting ceiling, whatever was paid.
1315 assert_eq!(paid_ceiling(&ceilings(), start, true, 900_000_000, None), start);
1316 // After it, with little paid: never below the start.
1317 assert_eq!(paid_ceiling(&ceilings(), start, false, 20_000_000, None), start);
1318 assert_eq!(paid_ceiling(&ceilings(), start, false, 0, None), start);
1319 // Payments that cleared raise it: twice what was paid.
1320 assert_eq!(paid_ceiling(&ceilings(), start, false, 300_000_000, None), 600_000_000);
1321 // Established follows the monthly spend.
1322 assert_eq!(paid_ceiling(&ceilings(), start, false, 300_000_000, Some(2_700_000_000)), 2_700_000_000);
1323 }
1324
1325 #[test]
1326 fn the_first_billing_cycle_is_the_first_month() {
1327 // A plan that started on the 5th, paid through the 5th of next month.
1328 assert!(in_first_cycle("2026-10-05T10:00:00Z", Some("2026-11-05T10:00:00Z"), "2026-10-20T00:00:00Z"));
1329 // Renewed: the period now ends two months after the start.
1330 assert!(!in_first_cycle("2026-10-05T10:00:00Z", Some("2026-12-05T10:00:00Z"), "2026-11-20T00:00:00Z"));
1331 // No period known yet: the first 31 days.
1332 assert!(in_first_cycle("2026-10-05T10:00:00Z", None, "2026-11-04T00:00:00Z"));
1333 assert!(!in_first_cycle("2026-10-05T10:00:00Z", None, "2026-11-10T00:00:00Z"));
1334 // Day counting is exact across months and years.
1335 assert_eq!(days("1970-01-01"), 0);
1336 assert_eq!(days("2026-11-01") - days("2026-10-01"), 31);
1337 assert_eq!(days("2028-03-01") - days("2028-02-28"), 2);
1338 assert_eq!(days("2027-01-01") - days("2026-12-31"), 1);
1339 }
1340
1341 #[test]
1342 fn owners_set_their_limit_up_to_the_highest_ceiling_without_asking() {
1343 // First month at $100; the highest ever is $100.
1344 let (available, once) = spend_bounds(100_000_000, 100_000_000, 0, false);
1345 assert_eq!(available, 100_000_000);
1346 assert_eq!(once, Some(200_000_000));
1347 assert_eq!(self_serve(80_000_000, available, once, false), Ok(false));
1348 assert_eq!(self_serve(100_000_000, available, once, false), Ok(false));
1349 // Above it without the raise: refused, saying what to do.
1350 assert!(self_serve(150_000_000, available, once, false).unwrap_err().contains("one-time raise"));
1351 // With the raise: up to twice the highest ceiling, once.
1352 assert_eq!(self_serve(200_000_000, available, once, true), Ok(true));
1353 assert!(self_serve(200_000_001, available, once, true).unwrap_err().contains("Raise my limit"));
1354 // Once used, it is gone.
1355 let (available, once) = spend_bounds(200_000_000, 200_000_000, 0, true);
1356 assert_eq!(once, None);
1357 assert_eq!(self_serve(200_000_000, available, once, false), Ok(false));
1358 assert!(self_serve(300_000_000, available, once, true).unwrap_err().contains("is used"));
1359 // A ceiling that came down still leaves the highest one available.
1360 let (available, _) = spend_bounds(100_000_000, 400_000_000, 0, true);
1361 assert_eq!(available, 400_000_000);
1362 assert!(self_serve(-1, available, None, false).is_err());
1363 }
1364
1365 #[test]
1366 fn prepaying_raises_what_can_be_used_at_once() {
1367 // $500 prepaid this month, $120 used: nothing owed, $380 left.
1368 assert_eq!(exposure(120_000_000, 500_000_000, 0), (0, 380_000_000));
1369 // Prepaid last month and carried in.
1370 assert_eq!(exposure(120_000_000, 0, 500_000_000), (0, 380_000_000));
1371 // Used past the prepayment: the rest is owed.
1372 assert_eq!(exposure(620_000_000, 500_000_000, 0), (120_000_000, 0));
1373 // Owed from before adds to this month's.
1374 assert_eq!(exposure(10_000_000, 0, -4_000_000), (14_000_000, 0));
1375 // With a $100 ceiling and $500 prepaid, work stops at $600 of use,
1376 // not at $100.
1377 let ceiling = 100_000_000;
1378 assert_eq!(state(exposure(599_000_000, 500_000_000, 0).0, Some(ceiling)), LimitState::Warning);
1379 assert_eq!(state(exposure(600_000_000, 500_000_000, 0).0, Some(ceiling)), LimitState::Stopped);
1380 // And the owners may set their spend limit that much higher.
1381 assert_eq!(spend_bounds(ceiling, ceiling, 500_000_000, true).0, 600_000_000);
1382 }
1383
1384 #[test]
Usage limits: unpaid usage can only go so far1385 fn work_warns_at_eighty_percent_and_stops_at_the_ceiling() {
1386 assert_eq!(state(0, Some(100)), LimitState::Ok);
1387 assert_eq!(state(79, Some(100)), LimitState::Ok);
1388 assert_eq!(state(80, Some(100)), LimitState::Warning);
1389 assert_eq!(state(100, Some(100)), LimitState::Stopped);
1390 assert_eq!(state(1_000_000, None), LimitState::Ok);
1391 }
1392}

This file's history is long; its oldest lines are credited to the oldest commit read.