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

1,102 lines51,873 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

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.
Usage limits: unpaid usage can only go so far17//! - **Internal**: g1t's own workspaces, with none.
18//!
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look19//! A ceiling g1t granted (an approved request, or the owners' one-time
20//! raise) is a floor under the trust ceiling. Money paid in advance raises
21//! what can be used before work stops by the same amount, at once: it comes
22//! off what is owed before anything counts against the ceiling.
23//!
24//! Owners also set a monthly **spend limit** on what is charged. They may
25//! put it anywhere up to the highest ceiling the workspace has ever had,
26//! plus what is prepaid, without asking anyone; once per workspace they may
27//! raise it to twice that highest ceiling themselves. Past that, they ask
28//! (see `requests`), and g1t answers within one business day.
29//!
30//! Alerts go out at 50, 75, 90 and 100% of the plan's included usage, the
31//! spend limit and the ceiling, in the app and by email. At the ceiling or
32//! the spend limit, new work stops: no new sandboxes, builds or app
33//! requests until it is paid, raised, or the month turns. Runs already
Usage limits: unpaid usage can only go so far34//! under way finish.
35//!
36//! 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 look37//! more. Test-mode payments are not money, so they do not raise trust.
Usage limits: unpaid usage can only go so far38
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look39use g1t_contracts::billing::{
40 CheckLimitArgs, Limit, LimitArgs, LimitState, NotePendingArgs, PlanKind, SetSpendLimitArgs, TermsKind, Trust,
41};
Usage limits: unpaid usage can only go so far42use g1t_contracts::time::rfc3339;
43use g1t_contracts::{FailureCode, Outcome, Role};
44use g1t_kit::now_ms;
45use serde::Deserialize;
46use worker::wasm_bindgen::JsValue;
47use worker::{Env, Result};
48
49use crate::features::dollars as dollars_plain;
50use crate::{Billing, members_only};
51
52/// The ceilings, from the billing service's variables.
53pub(crate) struct Ceilings {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look54 /// `LIMIT_NEW_MICROS`: a free workspace's.
Usage limits: unpaid usage can only go so far55 pub new: i64,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look56 /// `LIMIT_PAID_MIN_MICROS` and `LIMIT_PAID_MAX_MICROS`: the bounds of a
57 /// paid workspace's, from what it has paid.
Usage limits: unpaid usage can only go so far58 pub paid_min: i64,
59 pub paid_max: i64,
60}
61
62impl Ceilings {
63 pub(crate) fn from_env(env: &Env) -> Self {
64 let number = |name: &str, default: i64| {
65 env.var(name).ok().and_then(|v| v.to_string().parse::<i64>().ok()).unwrap_or(default)
66 };
67 Ceilings {
68 new: number("LIMIT_NEW_MICROS", 3_000_000),
69 paid_min: number("LIMIT_PAID_MIN_MICROS", 25_000_000),
70 paid_max: number("LIMIT_PAID_MAX_MICROS", 1_000_000_000),
71 }
72 }
73
74 /// The ceiling for a workspace that has paid `paid` in live money.
75 pub(crate) fn for_paid(&self, paid: i64) -> i64 {
76 (paid * 2).clamp(self.paid_min, self.paid_max)
77 }
78}
79
80/// Where a workspace stands against its ceiling.
81pub(crate) fn state(exposure: i64, ceiling: Option<i64>) -> LimitState {
82 match ceiling {
83 Some(ceiling) if exposure >= ceiling => LimitState::Stopped,
84 Some(ceiling) if exposure * 5 >= ceiling * 4 => LimitState::Warning,
85 _ => LimitState::Ok,
86 }
87}
88
Two limits, real invoices, trust that grows by itself, sales signals89/// The automatic monthly spend limit's floor: $200.
90pub(crate) const DEFAULT_SPEND_MICROS: i64 = 200_000_000;
91/// Established workspaces' ceiling: three times their steady monthly
92/// spend, up to $10,000.
93const ESTABLISHED_FACTOR: i64 = 3;
94const ESTABLISHED_MAX_MICROS: i64 = 10_000_000_000;
95/// A month counts toward Established at this much spend or more.
96const ESTABLISHED_MONTH_MICROS: i64 = 20_000_000;
97/// Payments raise trust once this old: past the time most bad cards are
98/// caught.
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look99pub(crate) const SETTLE_DAYS: u64 = 7;
Two limits, real invoices, trust that grows by itself, sales signals100
101/// The automatic spend limit: $200, or twice last month's spend.
102pub(crate) fn automatic_spend_limit(last_month_charged: i64) -> i64 {
103 DEFAULT_SPEND_MICROS.max(last_month_charged * 2)
104}
Billing accounts, terms and enterprises; g1t is no longer free105
Two limits, real invoices, trust that grows by itself, sales signals106/// An Established workspace's ceiling, from its last three months'
107/// charges, if each was steady enough.
108pub(crate) fn established_ceiling(months: &[i64]) -> Option<i64> {
109 if months.len() < 3 || months.iter().any(|m| *m < ESTABLISHED_MONTH_MICROS) {
110 return None;
111 }
112 let average = months.iter().sum::<i64>() / months.len() as i64;
113 Some((average * ESTABLISHED_FACTOR).min(ESTABLISHED_MAX_MICROS))
114}
115
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look116/// Days since 1970-01-01 of a `YYYY-MM-DD…` date, for comparing dates
117/// without a clock (Howard Hinnant's days-from-civil).
118pub(crate) fn days(date: &str) -> i64 {
119 let year: i64 = date.get(..4).and_then(|y| y.parse().ok()).unwrap_or(1970);
120 let month: i64 = date.get(5..7).and_then(|m| m.parse().ok()).unwrap_or(1);
121 let day: i64 = date.get(8..10).and_then(|d| d.parse().ok()).unwrap_or(1);
122 let y = if month <= 2 { year - 1 } else { year };
123 let era = y.div_euclid(400);
124 let yoe = y - era * 400;
125 let mp = (month + 9) % 12;
126 let doy = (153 * mp + 2) / 5 + day - 1;
127 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
128 era * 146_097 + doe - 719_468
129}
130
131/// Whether a plan that started at `started_at` is still in its first
132/// billing cycle: its paid period ends no more than a month after it
133/// started (a renewal moves the end a month on), or, with no period known,
134/// it started within the last 31 days.
135pub(crate) fn in_first_cycle(started_at: &str, period_end: Option<&str>, now: &str) -> bool {
136 match period_end {
137 Some(end) => days(end) - days(started_at) <= 32 && days(now) <= days(end),
138 None => days(now) - days(started_at) <= 31,
139 }
140}
141
142/// g1t's ceiling for a workspace on the plan: the starting one in its
143/// first month; after it, what it has paid (or its Established ceiling),
144/// never less than the starting one.
145pub(crate) fn paid_ceiling(ceilings: &Ceilings, start: i64, first_month: bool, paid: i64, established: Option<i64>) -> i64 {
146 if first_month {
147 return start;
148 }
149 let from_paid = if paid > 0 { ceilings.for_paid(paid) } else { 0 };
150 start.max(from_paid).max(established.unwrap_or(0))
151}
152
153/// What the owners may set their spend limit to without asking, and the
154/// one-time raise if it is still theirs to use: up to the highest ceiling
155/// ever (or the current one, if higher) plus what is prepaid; once, twice
156/// the highest ceiling.
157pub(crate) fn spend_bounds(ceiling: i64, max_ever: i64, prepaid: i64, raised: bool) -> (i64, Option<i64>) {
158 let highest = ceiling.max(max_ever);
159 let available = highest + prepaid.max(0);
160 let once = (!raised).then(|| (highest * 2 + prepaid.max(0)).max(available));
161 (available, once)
162}
163
164/// Whether `requested` is a spend limit the owners may set themselves.
165/// `Ok(true)` when it takes the one-time raise.
166pub(crate) fn self_serve(requested: i64, available: i64, once: Option<i64>, raise_once: bool) -> std::result::Result<bool, String> {
167 if requested < 0 {
168 return Err("A spend limit cannot be negative.".to_owned());
169 }
170 if requested <= available {
171 return Ok(false);
172 }
173 match once {
174 Some(once) if raise_once && requested <= once => Ok(true),
175 Some(once) if raise_once => Err(format!(
176 "The one-time raise goes up to {}. For more, ask g1t with Raise my limit; the answer comes within one business day.",
177 dollars_plain(once)
178 )),
179 Some(once) => Err(format!(
180 "You can set up to {} yourself, or use your one-time raise to go up to {}. For more, ask g1t with Raise my limit.",
181 dollars_plain(available),
182 dollars_plain(once)
183 )),
184 None => Err(format!(
185 "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.",
186 dollars_plain(available)
187 )),
188 }
189}
190
191/// Which alert a measure has reached: 100, 90, 75, 50, or none (0).
192pub(crate) fn alert_level(used: i64, limit: i64) -> u32 {
193 if limit <= 0 || used <= 0 {
194 return 0;
195 }
196 for level in [100u32, 90, 75, 50] {
197 if used * 100 >= limit * i64::from(level) {
198 return level;
199 }
200 }
201 0
202}
203
204/// What is owed and what is prepaid, from this month's usage and payments
205/// and the balance the month started with (positive: paid in advance;
206/// negative: owed from before).
207pub(crate) fn exposure(used: i64, paid_month: i64, balance_before: i64) -> (i64, i64) {
208 let prepaid_in = balance_before.max(0);
209 let carried = (-balance_before).max(0);
210 let net = used - paid_month - prepaid_in;
211 (net.max(0) + carried, (-net).max(0))
212}
213
Usage limits: unpaid usage can only go so far214#[derive(Deserialize)]
215struct LimitRow {
216 spend_limit_micros: Option<i64>,
Two limits, real invoices, trust that grows by itself, sales signals217 #[serde(default)]
218 spend_limit_full: Option<i64>,
Billing accounts, terms and enterprises; g1t is no longer free219 autopay_failed_at: Option<String>,
220 autopay_error: Option<String>,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look221 #[serde(default)]
222 max_ceiling_micros: Option<i64>,
223 #[serde(default)]
224 granted_ceiling_micros: Option<i64>,
225 #[serde(default)]
226 raised_at: Option<String>,
Usage limits: unpaid usage can only go so far227}
228
229#[derive(Deserialize)]
230struct Month {
231 used: Option<i64>,
232 paid: Option<i64>,
233}
234
235#[derive(Deserialize)]
236struct Paid {
237 paid: Option<i64>,
238}
239
240impl Billing {
Billing accounts, terms and enterprises; g1t is no longer free241 /// The workspace's limit, worked out from the ledger of the account
242 /// that pays for it: its own, or its enterprise's, whose workspaces'
243 /// usage and payments count together.
Usage limits: unpaid usage can only go so far244 pub(crate) async fn limit_of(&self, workspace: &str) -> Result<Limit> {
245 let workspace = workspace.to_lowercase();
Billing accounts, terms and enterprises; g1t is no longer free246 let account = self.account_of(&workspace).await?;
Usage limits: unpaid usage can only go so far247 let row = self
248 .db
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look249 .prepare(
250 "SELECT spend_limit_micros, spend_limit_full, autopay_failed_at, autopay_error,
251 max_ceiling_micros, granted_ceiling_micros, raised_at
252 FROM limits WHERE workspace = ?",
253 )
Usage limits: unpaid usage can only go so far254 .bind(&[workspace.as_str().into()])?
255 .first::<LimitRow>(None)
256 .await?;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look257 let now = rfc3339(now_ms());
258 let month_start = format!("{}-01", &now[..7]);
Billing accounts, terms and enterprises; g1t is no longer free259 let marks = vec!["?"; account.workspaces.len().max(1)].join(", ");
260 let members: Vec<JsValue> = if account.workspaces.is_empty() {
261 vec![JsValue::from(workspace.as_str())]
262 } else {
263 account.workspaces.iter().map(|w| JsValue::from(w.as_str())).collect()
264 };
265 let mut with_month = members.clone();
266 with_month.push(month_start.as_str().into());
Usage limits: unpaid usage can only go so far267 // 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 look268 // more; on the workspace's own provider, only what g1t charged.
269 // What the plan's included usage, the trial, the open-source pool
270 // or g1t itself paid for is not unpaid: those are budgets already
271 // paid for.
Usage limits: unpaid usage can only go so far272 let month = self
273 .db
Billing accounts, terms and enterprises; g1t is no longer free274 .prepare(format!(
Usage limits: unpaid usage can only go so far275 "SELECT
276 SUM(CASE WHEN kind = 'usage' THEN
277 CASE WHEN COALESCE(billed_to, 'g1t') = 'g1t'
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put278 THEN MAX(COALESCE(cost_micros, 0) - COALESCE(credit_micros, 0)
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look279 - COALESCE(trial_micros, 0) - COALESCE(oss_micros, 0)
280 - COALESCE(given_micros, 0),
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put281 -amount_micros)
Usage limits: unpaid usage can only go so far282 ELSE -amount_micros END
283 END) AS used,
284 SUM(CASE WHEN kind = 'top_up' THEN amount_micros END) AS paid
Billing accounts, terms and enterprises; g1t is no longer free285 FROM ledger WHERE workspace IN ({marks}) AND created_at >= ?"
286 ))
287 .bind(&with_month)?
Usage limits: unpaid usage can only go so far288 .first::<Month>(None)
289 .await?;
290 let (used, paid_month) = month.map_or((0, 0), |m| (m.used.unwrap_or(0), m.paid.unwrap_or(0)));
Prices keep themselves current with what g1t pays291 // And what is metered but not charged until the month closes.
Billing accounts, terms and enterprises; g1t is no longer free292 let mut pending_args = members.clone();
293 pending_args.push(month_start[..7].into());
Prices keep themselves current with what g1t pays294 let pending = self
295 .db
Billing accounts, terms and enterprises; g1t is no longer free296 .prepare(format!(
297 "SELECT SUM(charge_micros) AS paid FROM pending_usage WHERE workspace IN ({marks}) AND month = ?"
298 ))
299 .bind(&pending_args)?
Prices keep themselves current with what g1t pays300 .first::<Paid>(None)
301 .await?
302 .and_then(|row| row.paid)
303 .unwrap_or(0);
304 let used = used + pending;
Usage limits: unpaid usage can only go so far305 // Test-mode payments are not money: they pay nothing off.
306 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 look307 // The balance the month started with: owed from before (so a new
308 // month is not a fresh allowance for an account that never pays),
309 // or paid in advance. Credits g1t gave count; test-mode payments
310 // do not.
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace311 let mut before = members.clone();
312 before.push(month_start.as_str().into());
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look313 let balance_before = self
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace314 .db
315 .prepare(format!(
316 "SELECT SUM(CASE WHEN kind = 'usage' THEN amount_micros
317 WHEN kind = 'top_up' AND ({live} = 1 OR reference LIKE 'crd%') THEN amount_micros
318 ELSE 0 END) AS paid
319 FROM ledger WHERE workspace IN ({marks}) AND created_at < ?",
320 live = u8::from(live)
321 ))
322 .bind(&before)?
323 .first::<Paid>(None)
324 .await?
325 .and_then(|row| row.paid)
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look326 .unwrap_or(0);
327 let (exposure, prepaid) = exposure(used, if live { paid_month } else { 0 }, balance_before);
Usage limits: unpaid usage can only go so far328
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look329 let plan = self.plan_kind(&workspace).await?;
330 let mut first_month = false;
Billing accounts, terms and enterprises; g1t is no longer free331 let (trust, trust_ceiling) = match account.terms.kind {
332 TermsKind::Comped => (Trust::Internal, None),
333 _ if account.terms.ceiling_micros.is_some() => (Trust::Reviewed, account.terms.ceiling_micros),
334 _ => {
335 let paid = self.live_paid(&members).await?;
Two limits, real invoices, trust that grows by itself, sales signals336 let established = if paid > 0 { self.established(&members).await? } else { None };
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look337 if plan == PlanKind::Free {
338 // Nothing on demand: only what a free workspace can owe.
339 (Trust::New, Some(self.ceilings.new))
340 } else {
341 first_month = plan == PlanKind::Paid && self.first_month(&workspace).await?;
342 let ceiling = paid_ceiling(&self.ceilings, self.plans.paid_start_micros, first_month, paid, established);
343 (if established.is_some() { Trust::Established } else { Trust::Paid }, Some(ceiling))
Billing accounts, terms and enterprises; g1t is no longer free344 }
Usage limits: unpaid usage can only go so far345 }
346 };
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look347 // A ceiling g1t granted is a floor under the trust ceiling.
348 let granted = row.as_ref().and_then(|row| row.granted_ceiling_micros);
349 let ceiling = trust_ceiling.map(|c| c.max(granted.unwrap_or(0)));
350 // The highest ceiling ever, kept as it rises.
351 let stored_max = row.as_ref().and_then(|row| row.max_ceiling_micros);
352 let max_ever = match (stored_max, ceiling) {
353 (Some(stored), Some(now)) => Some(stored.max(now)),
354 (stored, now) => stored.or(now),
355 };
356 if let (Some(max), true) = (max_ever, ceiling.is_some() && max_ever != stored_max && plan != PlanKind::Free) {
357 self.db
358 .prepare(
359 "INSERT INTO limits (workspace, max_ceiling_micros, updated_at) VALUES (?1, ?2, ?3)
360 ON CONFLICT (workspace) DO UPDATE SET max_ceiling_micros = MAX(COALESCE(max_ceiling_micros, 0), ?2), updated_at = ?3",
361 )
362 .bind(&[workspace.as_str().into(), (max as f64).into(), now.as_str().into()])?
363 .run()
364 .await?;
365 }
Two limits, real invoices, trust that grows by itself, sales signals366 // This month's charges, and last month's, for the spend limit.
367 let (spent, last_month) = self.charged_months(&members, &month_start).await?;
368 let spent = spent + pending;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look369 let raised_at = row.as_ref().and_then(|row| row.raised_at.clone());
370 let self_serve = matches!(trust, Trust::New | Trust::Paid | Trust::Established) && plan != PlanKind::Free;
371 let (available, raise_once) = match (ceiling, self_serve) {
372 (Some(ceiling), true) => {
373 let (available, once) = spend_bounds(ceiling, max_ever.unwrap_or(ceiling), prepaid, raised_at.is_some());
374 (Some(available), once)
375 }
376 (ceiling, _) => (ceiling, None),
377 };
Two limits, real invoices, trust that grows by itself, sales signals378 // The owners' own monthly limit: theirs, none, or the automatic one
379 // ($200, or twice last month), which self-serve workspaces start on.
380 let chosen = row.as_ref().and_then(|row| row.spend_limit_micros);
381 let full = row.as_ref().and_then(|row| row.spend_limit_full).unwrap_or(0) == 1;
382 let default_spend_limit = chosen.is_none() && !full && self_serve;
383 let spend_limit = match (chosen, full) {
384 (Some(own), _) => Some(own),
385 (None, true) => None,
386 (None, false) if self_serve => Some(automatic_spend_limit(last_month)),
387 _ => None,
388 };
Billing accounts, terms and enterprises; g1t is no longer free389 // A card declined when g1t charged it at the limit stops work until
390 // it is paid; any payment clears it.
391 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 signals392 // Two limits: g1t's on what is unpaid, the owners' on what is spent.
393 let risk = state(exposure, ceiling);
394 let budget = state(spent, spend_limit);
395 let over_budget = budget == LimitState::Stopped;
396 let state = if declined.is_some() && exposure > 0 {
397 LimitState::Stopped
398 } else if risk == LimitState::Stopped || over_budget {
399 LimitState::Stopped
400 } else if risk == LimitState::Warning || budget == LimitState::Warning {
401 LimitState::Warning
402 } else {
403 LimitState::Ok
Usage limits: unpaid usage can only go so far404 };
Billing accounts, terms and enterprises; g1t is no longer free405 let who = if account.kind == g1t_contracts::billing::AccountKind::Enterprise {
406 format!("The {} enterprise, which pays for {workspace},", account.name)
407 } else {
408 format!("The {workspace} workspace")
409 };
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look410 let billing = format!("/{workspace}/-/billing");
Usage limits: unpaid usage can only go so far411 let message = match state {
412 LimitState::Ok => None,
Two limits, real invoices, trust that grows by itself, sales signals413 LimitState::Warning if budget == LimitState::Warning => Some(format!(
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look414 "{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 signals415 dollars_plain(spent),
416 dollars_plain(spend_limit.unwrap_or_default()),
417 )),
Usage limits: unpaid usage can only go so far418 LimitState::Warning => Some(format!(
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look419 "{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 far420 dollars_plain(exposure),
421 dollars_plain(ceiling.unwrap_or_default()),
422 )),
Billing accounts, terms and enterprises; g1t is no longer free423 LimitState::Stopped if declined.is_some() => Some(format!(
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look424 "{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 free425 declined.as_ref().and_then(|(_, error)| error.clone()).unwrap_or_else(|| "the card was declined".to_owned()),
426 )),
Two limits, real invoices, trust that grows by itself, sales signals427 LimitState::Stopped => Some(if over_budget {
Usage limits: unpaid usage can only go so far428 format!(
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look429 "{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 signals430 dollars_plain(spend_limit.unwrap_or_default()),
Usage limits: unpaid usage can only go so far431 )
432 } else {
433 format!(
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look434 "{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 far435 dollars_plain(ceiling.unwrap_or_default()),
436 )
437 }),
438 };
Two limits, real invoices, trust that grows by itself, sales signals439 let growth = match trust {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look440 Trust::New => Some("Free workspaces have no on-demand usage: the g1t plan starts at a $100 limit.".to_owned()),
441 Trust::Paid if first_month => Some(format!(
442 "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.",
443 dollars_plain(self.plans.paid_start_micros)
444 )),
Two limits, real invoices, trust that grows by itself, sales signals445 Trust::Paid => Some(format!(
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look446 "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 signals447 )),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look448 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 signals449 Trust::Reviewed | Trust::Internal => None,
450 };
Usage limits: unpaid usage can only go so far451 Ok(Limit {
452 workspace,
Billing accounts, terms and enterprises; g1t is no longer free453 account: account.id,
454 account_name: account.name,
Two limits, real invoices, trust that grows by itself, sales signals455 spent_micros: spent,
456 default_spend_limit,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look457 available_micros: available,
Two limits, real invoices, trust that grows by itself, sales signals458 growth,
Usage limits: unpaid usage can only go so far459 trust,
460 exposure_micros: exposure,
461 ceiling_micros: ceiling,
462 trust_ceiling_micros: trust_ceiling,
463 spend_limit_micros: spend_limit,
464 state,
465 message,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look466 prepaid_micros: prepaid,
467 max_ceiling_micros: max_ever.filter(|_| plan != PlanKind::Free),
468 raise_once_micros: raise_once,
469 raised_at,
470 first_month,
471 })
472 }
473
474 /// Whether the workspace's plan is in its first billing cycle.
475 pub(crate) async fn first_month(&self, workspace: &str) -> Result<bool> {
476 Ok(match self.plan_cycle(workspace).await? {
477 Some((started_at, period_end)) => in_first_cycle(&started_at, period_end.as_deref(), &rfc3339(now_ms())),
478 None => false,
Usage limits: unpaid usage can only go so far479 })
480 }
481
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look482 /// Real money the workspaces have paid g1t, cleared: usage payments and
483 /// the plan's price. Nothing in test mode, and credits g1t gave are not
484 /// payments.
485 pub(crate) async fn live_paid(&self, members: &[JsValue]) -> Result<i64> {
Usage limits: unpaid usage can only go so far486 if !self.stripe.as_ref().is_some_and(crate::stripe::Stripe::live) {
487 return Ok(0);
488 }
Billing accounts, terms and enterprises; g1t is no longer free489 let marks = vec!["?"; members.len().max(1)].join(", ");
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look490 let settled = rfc3339(now_ms() - SETTLE_DAYS * 24 * 60 * 60 * 1000);
Usage limits: unpaid usage can only go so far491 Ok(self
492 .db
Billing accounts, terms and enterprises; g1t is no longer free493 .prepare(format!(
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look494 "SELECT
495 (SELECT COALESCE(SUM(amount_micros), 0) FROM ledger
496 WHERE workspace IN ({marks}) AND kind = 'top_up' AND reference NOT LIKE 'crd%'
497 AND (amount_micros < 0
498 OR (disputed = 0 AND COALESCE(funding, '') <> 'prepaid'
499 AND created_at <= '{settled}')))
500 + (SELECT COALESCE(SUM(amount_micros), 0) FROM plan_payments
501 WHERE workspace IN ({marks}) AND paid_at <= '{settled}') AS paid"
Billing accounts, terms and enterprises; g1t is no longer free502 ))
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look503 .bind(&[members, members].concat())?
Usage limits: unpaid usage can only go so far504 .first::<Paid>(None)
505 .await?
506 .and_then(|row| row.paid)
507 .unwrap_or(0))
508 }
509
Two limits, real invoices, trust that grows by itself, sales signals510 /// This month's charges and last month's, across the workspaces.
511 async fn charged_months(&self, members: &[JsValue], month_start: &str) -> Result<(i64, i64)> {
512 #[derive(Deserialize)]
513 struct Charged {
514 this_month: Option<i64>,
515 last_month: Option<i64>,
516 }
517 let last_start = format!("{}-01", previous_month(&month_start[..7]));
518 let marks = vec!["?"; members.len().max(1)].join(", ");
519 let row = self
520 .db
521 .prepare(format!(
522 "SELECT
523 -SUM(CASE WHEN created_at >= '{month_start}' THEN amount_micros END) AS this_month,
524 -SUM(CASE WHEN created_at >= '{last_start}' AND created_at < '{month_start}' THEN amount_micros END) AS last_month
525 FROM ledger WHERE kind = 'usage' AND workspace IN ({marks}) AND created_at >= '{last_start}'"
526 ))
527 .bind(members)?
528 .first::<Charged>(None)
529 .await?;
530 Ok(row.map_or((0, 0), |r| (r.this_month.unwrap_or(0).max(0), r.last_month.unwrap_or(0).max(0))))
531 }
532
533 /// An Established ceiling, if the workspaces have paid steadily: three
534 /// full months of real spend, each invoiced and paid, nothing declined
535 /// in 90 days and nothing ever disputed.
536 async fn established(&self, members: &[JsValue]) -> Result<Option<i64>> {
537 let marks = vec!["?"; members.len().max(1)].join(", ");
538 let now = rfc3339(now_ms());
539 let mut months = vec![];
540 let mut month = previous_month(&now[..7]);
541 for _ in 0..3 {
542 months.push(month.clone());
543 month = previous_month(&month);
544 }
545 #[derive(Deserialize)]
546 struct Count {
547 n: Option<i64>,
548 }
549 let troubled = self
550 .db
551 .prepare(format!(
552 "SELECT (SELECT COUNT(*) FROM ledger WHERE workspace IN ({marks}) AND disputed = 1)
553 + (SELECT COUNT(*) FROM limits WHERE workspace IN ({marks}) AND autopay_failed_at >= '{since}') AS n",
554 since = rfc3339(now_ms() - 90 * 24 * 60 * 60 * 1000)
555 ))
556 .bind(&[members, members].concat())?
557 .first::<Count>(None)
558 .await?
559 .and_then(|c| c.n)
560 .unwrap_or(0);
561 if troubled > 0 {
562 return Ok(None);
563 }
564 let mut charged = vec![];
565 for month in &months {
566 #[derive(Deserialize)]
567 struct Month {
568 charged: Option<i64>,
569 unpaid: Option<i64>,
570 }
571 let next = {
572 let year: i32 = month[..4].parse().unwrap_or(1970);
573 let number: u32 = month[5..7].parse().unwrap_or(1);
574 if number == 12 { format!("{}-01", year + 1) } else { format!("{year}-{:02}", number + 1) }
575 };
576 let row = self
577 .db
578 .prepare(format!(
579 "SELECT
580 (SELECT -SUM(amount_micros) FROM ledger WHERE kind = 'usage' AND workspace IN ({marks})
581 AND created_at >= '{month}-01' AND created_at < '{next}-01') AS charged,
582 (SELECT COUNT(*) FROM workspace_invoices WHERE workspace IN ({marks}) AND reason = 'month'
583 AND period = '{month}' AND status <> 'paid') AS unpaid"
584 ))
585 .bind(&[members, members].concat())?
586 .first::<Month>(None)
587 .await?;
588 let Some(row) = row else { return Ok(None) };
589 if row.unpaid.unwrap_or(0) > 0 {
590 return Ok(None);
591 }
592 charged.push(row.charged.unwrap_or(0));
593 }
594 Ok(established_ceiling(&charged))
595 }
596
Usage limits: unpaid usage can only go so far597 /// A refusal, with the reason, when the workspace's work is stopped.
598 /// None while billing is off: a g1t without payments has no limits.
599 pub(crate) async fn stopped<T>(&self, workspace: &str) -> Result<Option<Outcome<T>>> {
600 if self.stripe.is_none() {
601 return Ok(None);
602 }
603 let limit = self.limit_of(workspace).await?;
604 Ok((limit.state == LimitState::Stopped).then(|| {
605 Outcome::fail(
606 FailureCode::PaymentRequired,
607 limit.message.unwrap_or_else(|| "This workspace is over its limit.".to_owned()),
608 )
609 }))
610 }
611
612 pub(crate) async fn limit(&self, a: LimitArgs) -> Result<Outcome<Limit>> {
613 let workspace = a.workspace.to_lowercase();
614 if !a.viewer.is_some_and(|viewer| viewer.is_member(&workspace)) {
615 return Ok(members_only());
616 }
617 Ok(Outcome::Ok(self.limit_of(&workspace).await?))
618 }
619
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look620 /// What a source cost so far this month. `security`, `context`,
621 /// `storage` and `git` are charged by billing once the month is over
622 /// (see `storage`); `deployments` charges its own.
Prices keep themselves current with what g1t pays623 pub(crate) async fn note_pending(&self, a: NotePendingArgs) -> Result<bool> {
624 let now = rfc3339(now_ms());
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put625 self.set_pending(&a.workspace, &a.source, &now[..7], a.cost_micros).await?;
Prices keep themselves current with what g1t pays626 Ok(true)
627 }
628
Billing accounts, terms and enterprises; g1t is no longer free629 /// Charges the saved card of each workspace nearing its limit, for what
630 /// it owes, so that a workspace that pays never has its work stopped.
631 /// Only with live payments: test-mode payments are not money and lower
632 /// 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 look633 /// for enterprises, which are invoiced. A charge at the limit always
634 /// goes through, whatever the minimum charge.
Billing accounts, terms and enterprises; g1t is no longer free635 pub(crate) async fn autopay(&self) -> Result<()> {
Two limits, real invoices, trust that grows by itself, sales signals636 if !self.stripe.as_ref().is_some_and(crate::stripe::Stripe::live) {
Billing accounts, terms and enterprises; g1t is no longer free637 return Ok(());
Two limits, real invoices, trust that grows by itself, sales signals638 }
Billing accounts, terms and enterprises; g1t is no longer free639 #[derive(Deserialize)]
640 struct Candidate {
641 workspace: String,
642 }
643 let month_start = format!("{}-01", &rfc3339(now_ms())[..7]);
Two limits, real invoices, trust that grows by itself, sales signals644 // With a card, and not already declined: a declined card waits for
645 // the owners, rather than being tried again every few minutes.
Billing accounts, terms and enterprises; g1t is no longer free646 let candidates = self
647 .db
648 .prepare(
Two limits, real invoices, trust that grows by itself, sales signals649 "SELECT DISTINCT ledger.workspace AS workspace
Billing accounts, terms and enterprises; g1t is no longer free650 FROM ledger JOIN accounts ON accounts.workspace = ledger.workspace
Two limits, real invoices, trust that grows by itself, sales signals651 LEFT JOIN limits ON limits.workspace = ledger.workspace
652 WHERE ledger.kind = 'usage' AND ledger.created_at >= ? AND accounts.customer_id IS NOT NULL
653 AND limits.autopay_failed_at IS NULL",
Billing accounts, terms and enterprises; g1t is no longer free654 )
655 .bind(&[month_start.as_str().into()])?
656 .all()
657 .await?
658 .results::<Candidate>()?;
659 for candidate in candidates {
660 let limit = self.limit_of(&candidate.workspace).await?;
Two limits, real invoices, trust that grows by itself, sales signals661 // Near g1t's ceiling on what is unpaid; the spend limit is the
662 // owners' and stops work by itself, but what is owed is still owed.
663 let near = limit.ceiling_micros.is_some_and(|ceiling| limit.exposure_micros * 5 >= ceiling * 4);
664 if !near || limit.trust == Trust::Internal || limit.account.starts_with("ent_") {
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace665 continue;
666 }
Two limits, real invoices, trust that grows by itself, sales signals667 let today = rfc3339(now_ms())[..10].to_owned();
668 match self.invoice_workspace(&candidate.workspace, "threshold", &today).await? {
669 Ok(_) => {}
670 Err(why) => worker::console_log!("{}: no threshold invoice: {why}", candidate.workspace),
Billing accounts, terms and enterprises; g1t is no longer free671 }
672 }
673 Ok(())
674 }
675
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace676 /// Closes last month for each workspace with a card on file: charges
677 /// what it owed when the month ended. Live payments only, once per
678 /// 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 look679 /// Comped workspaces owe nothing, and enterprises are invoiced. Only
680 /// here does the minimum charge apply: less carries over.
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace681 pub(crate) async fn close_months(&self) -> Result<()> {
Two limits, real invoices, trust that grows by itself, sales signals682 if !self.stripe.as_ref().is_some_and(crate::stripe::Stripe::live) {
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace683 return Ok(());
Two limits, real invoices, trust that grows by itself, sales signals684 }
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace685 let now = rfc3339(now_ms());
686 let month_start = format!("{}-01", &now[..7]);
687 let closing = previous_month(&now[..7]);
688 #[derive(Deserialize)]
689 struct Open {
690 workspace: String,
691 balance: Option<i64>,
692 }
693 let open = self
694 .db
695 .prepare(
Two limits, real invoices, trust that grows by itself, sales signals696 "SELECT accounts.workspace AS workspace,
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace697 (SELECT SUM(amount_micros) FROM ledger
698 WHERE ledger.workspace = accounts.workspace AND ledger.created_at < ?1) AS balance
699 FROM accounts
700 WHERE accounts.customer_id IS NOT NULL
701 AND NOT EXISTS (SELECT 1 FROM month_closes
702 WHERE month_closes.workspace = accounts.workspace AND month_closes.month = ?2)
703 LIMIT 20",
704 )
705 .bind(&[month_start.as_str().into(), closing.as_str().into()])?
706 .all()
707 .await?
708 .results::<Open>()?;
709 for account in open {
710 let record = |status: &str, amount: i64, payment: Option<&str>, error: Option<&str>| {
711 self.db
712 .prepare(
713 "INSERT OR IGNORE INTO month_closes (workspace, month, status, amount_micros, payment_id, error, closed_at)
714 VALUES (?, ?, ?, ?, ?, ?, ?)",
715 )
716 .bind(&[
717 account.workspace.as_str().into(),
718 closing.as_str().into(),
719 status.into(),
720 (amount as f64).into(),
721 crate::optional(payment),
722 crate::optional(error),
723 now.as_str().into(),
724 ])
725 };
726 let payer = self.account_of(&account.workspace).await?;
727 if payer.terms.kind == TermsKind::Comped || payer.id.starts_with("ent_") {
728 record("skipped", 0, None, None)?.run().await?;
729 continue;
730 }
731 let owed = (-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 put732 if owed == 0 {
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace733 record("nothing", 0, None, None)?.run().await?;
734 continue;
735 }
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put736 if !worth_charging(owed, self.plans.min_charge_micros) {
737 // Under the minimum charge: a card payment's fee would be
738 // too much of it. It stays owed and goes on the next
739 // invoice that reaches the minimum.
740 record("carried", owed, None, None)?.run().await?;
741 continue;
742 }
Two limits, real invoices, trust that grows by itself, sales signals743 match self.invoice_workspace(&account.workspace, "month", &closing).await? {
744 Ok(invoice) if invoice.status == "paid" => {
745 record("paid", invoice.amount_micros, Some(&invoice.invoice_id), None)?.run().await?;
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace746 }
Two limits, real invoices, trust that grows by itself, sales signals747 Ok(invoice) => {
748 record("failed", invoice.amount_micros, Some(&invoice.invoice_id), Some("the card was declined"))?.run().await?;
749 }
750 Err(why) => {
751 record("nothing", 0, None, Some(&why))?.run().await?;
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace752 }
753 }
754 }
755 Ok(())
756 }
757
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look758 /// Emails a workspace's owners as it passes 50, 75, 90 and 100% of its
759 /// plan's included usage, its spend limit and g1t's ceiling, once each a
760 /// month; when its card was declined; and when a spend spike paused it.
761 /// The same alerts show in the app (`entitlements`).
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace762 pub(crate) async fn warn_limits(&self, identity: &worker::Fetcher) -> Result<()> {
763 if self.stripe.is_none() {
764 return Ok(());
765 }
766 let now = rfc3339(now_ms());
767 let month = &now[..7];
768 #[derive(Deserialize)]
769 struct Candidate {
770 workspace: String,
771 }
772 let candidates = self
773 .db
774 .prepare(
775 "SELECT DISTINCT workspace FROM ledger WHERE kind = 'usage' AND created_at >= ?1
776 UNION SELECT workspace FROM limits WHERE autopay_failed_at IS NOT NULL",
777 )
778 .bind(&[format!("{month}-01").into()])?
779 .all()
780 .await?
781 .results::<Candidate>()?;
782 #[derive(Deserialize)]
783 struct Told {
784 autopay_failed_at: Option<String>,
785 declined_told_at: Option<String>,
786 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look787 #[derive(Deserialize)]
788 struct Sent {
789 meter: String,
790 level: Option<i64>,
791 }
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace792 for Candidate { workspace } in candidates {
793 let told = self
794 .db
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look795 .prepare("SELECT autopay_failed_at, declined_told_at FROM limits WHERE workspace = ?")
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace796 .bind(&[workspace.as_str().into()])?
797 .first::<Told>(None)
798 .await?;
799 let billing = format!("https://g1t.sh/{workspace}/-/billing");
800
801 // A declined card, once per decline.
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look802 if let Some(Told { autopay_failed_at: Some(failed), declined_told_at }) = &told {
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace803 if 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 look804 let limit = self.limit_of(&workspace).await?;
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace805 let sent = notify(
806 identity,
807 &workspace,
808 &format!("g1t: the card for {workspace} was declined"),
809 &limit.message.clone().unwrap_or_else(|| format!("g1t could not charge the card on file for {workspace}.")),
810 "Update the card",
811 &billing,
812 )
813 .await;
814 if sent {
815 self.db
816 .prepare("UPDATE limits SET declined_told_at = ? WHERE workspace = ?")
817 .bind(&[now.as_str().into(), workspace.as_str().into()])?
818 .run()
819 .await?;
820 }
821 }
822 }
823
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look824 // 50, 75, 90 and 100%, once each a month and meter: only the
825 // highest new level is emailed.
826 let alerts = self.alerts_for(&workspace).await?;
827 if alerts.is_empty() {
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace828 continue;
829 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look830 let sent: Vec<Sent> = self
831 .db
832 .prepare("SELECT meter, MAX(level) AS level FROM alerts_sent WHERE workspace = ? AND month = ? GROUP BY meter")
833 .bind(&[workspace.as_str().into(), month.into()])?
834 .all()
835 .await?
836 .results::<Sent>()?;
837 for alert in alerts {
838 let already = sent.iter().find(|s| s.meter == alert.meter).and_then(|s| s.level).unwrap_or(0);
839 if i64::from(alert.level) <= already {
840 continue;
841 }
842 let subject = match alert.meter.as_str() {
843 "included" => format!("g1t: {workspace} has used {}% of its included usage", alert.level),
844 "spend_limit" => format!("g1t: {workspace} has used {}% of its spend limit", alert.level),
845 _ => format!("g1t: {workspace} has used {}% of its usage limit", alert.level),
846 };
847 if notify(identity, &workspace, &subject, &alert.message, "Open billing", &billing).await {
848 self.db
849 .prepare(
850 "INSERT OR IGNORE INTO alerts_sent (workspace, month, meter, level, sent_at) VALUES (?1, ?2, ?3, ?4, ?5)",
851 )
852 .bind(&[workspace.as_str().into(), month.into(), alert.meter.as_str().into(), alert.level.into(), now.as_str().into()])?
853 .run()
854 .await?;
855 }
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace856 }
857 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look858 self.tell_spikes(identity).await?;
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace859 Ok(())
860 }
861
Usage limits: unpaid usage can only go so far862 pub(crate) async fn check_limit(&self, a: CheckLimitArgs) -> Result<Outcome<Limit>> {
863 Ok(Outcome::Ok(self.limit_of(&a.workspace).await?))
864 }
865
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look866 /// The owners' spend limit: anywhere up to what is available without
867 /// asking; once, up to twice the highest ceiling (`raise_once`), which
868 /// also raises g1t's ceiling to match.
Usage limits: unpaid usage can only go so far869 pub(crate) async fn set_spend_limit(&self, a: SetSpendLimitArgs) -> Result<Outcome<Limit>> {
870 let workspace = a.workspace.to_lowercase();
871 if a.actor.role_in(&workspace) != Some(Role::Owner) {
872 return Ok(Outcome::fail(
873 FailureCode::Forbidden,
874 "Only an owner can set the workspace's spend limit.",
875 ));
876 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look877 let before = self.limit_of(&workspace).await?;
878 let mut raising = false;
879 if let (Some(requested), false) = (a.spend_limit_micros, a.use_full_limit) {
880 match (before.available_micros, matches!(before.trust, Trust::Internal | Trust::Reviewed)) {
881 (_, true) | (None, _) => {
882 if requested < 0 {
883 return Ok(Outcome::fail(FailureCode::Invalid, "A spend limit cannot be negative."));
884 }
885 }
886 (Some(available), false) => match self_serve(requested, available, before.raise_once_micros, a.raise_once) {
887 Ok(uses_raise) => raising = uses_raise,
888 Err(why) => return Ok(Outcome::fail(FailureCode::Invalid, why)),
889 },
890 }
Usage limits: unpaid usage can only go so far891 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look892 let now = rfc3339(now_ms());
Two limits, real invoices, trust that grows by itself, sales signals893 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 far894 self.db
895 .prepare(
Two limits, real invoices, trust that grows by itself, sales signals896 "INSERT INTO limits (workspace, spend_limit_micros, spend_limit_full, updated_at) VALUES (?1, ?2, ?3, ?4)
897 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 far898 )
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look899 .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 far900 .run()
901 .await?;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look902 if raising {
903 let raised = a.spend_limit_micros.unwrap_or_default();
904 // The ceiling rises with it, once.
905 self.db
906 .prepare(
907 "UPDATE limits SET granted_ceiling_micros = MAX(COALESCE(granted_ceiling_micros, 0), ?2),
908 raised_at = ?3, updated_at = ?3 WHERE workspace = ?1 AND raised_at IS NULL",
909 )
910 .bind(&[workspace.as_str().into(), (raised as f64).into(), now.as_str().into()])?
911 .run()
912 .await?;
913 let account = self.account_of(&workspace).await?;
914 self.audit(&account.id, "raise_once", &format!("{workspace} used its one-time raise: {}", dollars_plain(raised)), &a.actor.username)
915 .await?;
916 }
Usage limits: unpaid usage can only go so far917 Ok(Outcome::Ok(self.limit_of(&workspace).await?))
918 }
919}
920
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look921/// Whether `owed` is enough to charge a card when a month closes: at least
922/// the minimum charge (`MIN_CHARGE_MICROS`). Less carries over to the next
923/// 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 put924pub(crate) fn worth_charging(owed: i64, min_charge: i64) -> bool {
925 owed > 0 && owed >= min_charge
926}
927
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace928/// 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 look929pub(crate) async fn notify(identity: &worker::Fetcher, workspace: &str, subject: &str, intro: &str, action: &str, link: &str) -> bool {
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace930 let args = g1t_contracts::identity::NotifyOwnersArgs {
931 workspace: workspace.to_owned(),
932 subject: subject.to_owned(),
933 intro: intro.to_owned(),
934 action: action.to_owned(),
935 link: link.to_owned(),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look936 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".to_owned(),
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace937 };
938 match g1t_kit::call::<_, u32>(identity, "notify_owners", &args).await {
939 Ok(sent) => sent > 0,
940 Err(error) => {
941 worker::console_error!("could not tell {workspace}'s owners: {error}");
942 false
943 }
944 }
945}
946
947/// `2026-09` for `2026-10`, and `2025-12` for `2026-01`.
948pub(crate) fn previous_month(month: &str) -> String {
949 let year: i32 = month[..4].parse().unwrap_or(1970);
950 let number: u32 = month[5..7].parse().unwrap_or(1);
951 if number == 1 {
952 format!("{}-12", year - 1)
953 } else {
954 format!("{year}-{:02}", number - 1)
955 }
956}
957
Usage limits: unpaid usage can only go so far958#[cfg(test)]
959mod tests {
960 use super::*;
961
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace962 #[test]
Two limits, real invoices, trust that grows by itself, sales signals963 fn the_automatic_spend_limit_follows_last_month() {
964 assert_eq!(automatic_spend_limit(0), 200_000_000);
965 assert_eq!(automatic_spend_limit(50_000_000), 200_000_000);
966 assert_eq!(automatic_spend_limit(900_000_000), 1_800_000_000);
967 }
968
969 #[test]
970 fn three_steady_months_make_a_workspace_established() {
971 assert_eq!(established_ceiling(&[900_000_000, 850_000_000, 950_000_000]), Some(2_700_000_000));
972 assert_eq!(established_ceiling(&[5_000_000_000, 5_000_000_000, 5_000_000_000]), Some(10_000_000_000));
973 assert_eq!(established_ceiling(&[900_000_000, 10_000_000, 950_000_000]), None);
974 assert_eq!(established_ceiling(&[900_000_000, 900_000_000]), None);
975 }
976
977 #[test]
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look978 fn alerts_come_at_half_three_quarters_ninety_and_the_limit() {
979 assert_eq!(alert_level(0, 10_000_000), 0);
980 assert_eq!(alert_level(4_999_999, 10_000_000), 0);
981 assert_eq!(alert_level(5_000_000, 10_000_000), 50);
982 assert_eq!(alert_level(7_500_000, 10_000_000), 75);
983 assert_eq!(alert_level(8_999_999, 10_000_000), 75);
984 assert_eq!(alert_level(9_000_000, 10_000_000), 90);
985 assert_eq!(alert_level(10_000_000, 10_000_000), 100);
986 assert_eq!(alert_level(25_000_000, 10_000_000), 100);
987 // Nothing to measure against: no alert.
988 assert_eq!(alert_level(5, 0), 0);
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace989 }
990
991 #[test]
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look992 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 put993 let min = 5_000_000;
994 assert!(!worth_charging(0, min));
995 assert!(!worth_charging(4_990_000, min));
996 assert!(worth_charging(5_000_000, min));
997 assert!(worth_charging(12_000_000, min));
998 // $3 carried from last month and $2.50 this month: charged together.
999 let carried = 3_000_000;
1000 assert!(!worth_charging(carried, min));
1001 assert!(worth_charging(carried + 2_500_000, min));
1002 }
1003
1004 #[test]
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace1005 fn the_month_before_wraps_the_year() {
1006 assert_eq!(previous_month("2026-10"), "2026-09");
1007 assert_eq!(previous_month("2026-01"), "2025-12");
1008 }
1009
Usage limits: unpaid usage can only go so far1010 fn ceilings() -> Ceilings {
Billing accounts, terms and enterprises; g1t is no longer free1011 Ceilings { new: 3_000_000, paid_min: 25_000_000, paid_max: 1_000_000_000 }
Usage limits: unpaid usage can only go so far1012 }
1013
1014 #[test]
1015 fn trust_grows_with_what_was_paid_within_bounds() {
1016 assert_eq!(ceilings().for_paid(5_000_000), 25_000_000);
1017 assert_eq!(ceilings().for_paid(100_000_000), 200_000_000);
1018 assert_eq!(ceilings().for_paid(10_000_000_000), 1_000_000_000);
1019 }
1020
1021 #[test]
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1022 fn a_new_paid_workspace_starts_at_a_hundred_dollars_and_only_goes_up() {
1023 let start = 100_000_000;
1024 // The first month: the starting ceiling, whatever was paid.
1025 assert_eq!(paid_ceiling(&ceilings(), start, true, 900_000_000, None), start);
1026 // After it, with little paid: never below the start.
1027 assert_eq!(paid_ceiling(&ceilings(), start, false, 20_000_000, None), start);
1028 assert_eq!(paid_ceiling(&ceilings(), start, false, 0, None), start);
1029 // Payments that cleared raise it: twice what was paid.
1030 assert_eq!(paid_ceiling(&ceilings(), start, false, 300_000_000, None), 600_000_000);
1031 // Established follows the monthly spend.
1032 assert_eq!(paid_ceiling(&ceilings(), start, false, 300_000_000, Some(2_700_000_000)), 2_700_000_000);
1033 }
1034
1035 #[test]
1036 fn the_first_billing_cycle_is_the_first_month() {
1037 // A plan that started on the 5th, paid through the 5th of next month.
1038 assert!(in_first_cycle("2026-10-05T10:00:00Z", Some("2026-11-05T10:00:00Z"), "2026-10-20T00:00:00Z"));
1039 // Renewed: the period now ends two months after the start.
1040 assert!(!in_first_cycle("2026-10-05T10:00:00Z", Some("2026-12-05T10:00:00Z"), "2026-11-20T00:00:00Z"));
1041 // No period known yet: the first 31 days.
1042 assert!(in_first_cycle("2026-10-05T10:00:00Z", None, "2026-11-04T00:00:00Z"));
1043 assert!(!in_first_cycle("2026-10-05T10:00:00Z", None, "2026-11-10T00:00:00Z"));
1044 // Day counting is exact across months and years.
1045 assert_eq!(days("1970-01-01"), 0);
1046 assert_eq!(days("2026-11-01") - days("2026-10-01"), 31);
1047 assert_eq!(days("2028-03-01") - days("2028-02-28"), 2);
1048 assert_eq!(days("2027-01-01") - days("2026-12-31"), 1);
1049 }
1050
1051 #[test]
1052 fn owners_set_their_limit_up_to_the_highest_ceiling_without_asking() {
1053 // First month at $100; the highest ever is $100.
1054 let (available, once) = spend_bounds(100_000_000, 100_000_000, 0, false);
1055 assert_eq!(available, 100_000_000);
1056 assert_eq!(once, Some(200_000_000));
1057 assert_eq!(self_serve(80_000_000, available, once, false), Ok(false));
1058 assert_eq!(self_serve(100_000_000, available, once, false), Ok(false));
1059 // Above it without the raise: refused, saying what to do.
1060 assert!(self_serve(150_000_000, available, once, false).unwrap_err().contains("one-time raise"));
1061 // With the raise: up to twice the highest ceiling, once.
1062 assert_eq!(self_serve(200_000_000, available, once, true), Ok(true));
1063 assert!(self_serve(200_000_001, available, once, true).unwrap_err().contains("Raise my limit"));
1064 // Once used, it is gone.
1065 let (available, once) = spend_bounds(200_000_000, 200_000_000, 0, true);
1066 assert_eq!(once, None);
1067 assert_eq!(self_serve(200_000_000, available, once, false), Ok(false));
1068 assert!(self_serve(300_000_000, available, once, true).unwrap_err().contains("is used"));
1069 // A ceiling that came down still leaves the highest one available.
1070 let (available, _) = spend_bounds(100_000_000, 400_000_000, 0, true);
1071 assert_eq!(available, 400_000_000);
1072 assert!(self_serve(-1, available, None, false).is_err());
1073 }
1074
1075 #[test]
1076 fn prepaying_raises_what_can_be_used_at_once() {
1077 // $500 prepaid this month, $120 used: nothing owed, $380 left.
1078 assert_eq!(exposure(120_000_000, 500_000_000, 0), (0, 380_000_000));
1079 // Prepaid last month and carried in.
1080 assert_eq!(exposure(120_000_000, 0, 500_000_000), (0, 380_000_000));
1081 // Used past the prepayment: the rest is owed.
1082 assert_eq!(exposure(620_000_000, 500_000_000, 0), (120_000_000, 0));
1083 // Owed from before adds to this month's.
1084 assert_eq!(exposure(10_000_000, 0, -4_000_000), (14_000_000, 0));
1085 // With a $100 ceiling and $500 prepaid, work stops at $600 of use,
1086 // not at $100.
1087 let ceiling = 100_000_000;
1088 assert_eq!(state(exposure(599_000_000, 500_000_000, 0).0, Some(ceiling)), LimitState::Warning);
1089 assert_eq!(state(exposure(600_000_000, 500_000_000, 0).0, Some(ceiling)), LimitState::Stopped);
1090 // And the owners may set their spend limit that much higher.
1091 assert_eq!(spend_bounds(ceiling, ceiling, 500_000_000, true).0, 600_000_000);
1092 }
1093
1094 #[test]
Usage limits: unpaid usage can only go so far1095 fn work_warns_at_eighty_percent_and_stops_at_the_ceiling() {
1096 assert_eq!(state(0, Some(100)), LimitState::Ok);
1097 assert_eq!(state(79, Some(100)), LimitState::Ok);
1098 assert_eq!(state(80, Some(100)), LimitState::Warning);
1099 assert_eq!(state(100, Some(100)), LimitState::Stopped);
1100 assert_eq!(state(1_000_000, None), LimitState::Ok);
1101 }
1102}