Skip to content
1,423 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//!
Spend adds up on one ledger. Charged this month has one definition, price less discount, included usage and credit, shared by the plan card, the spend limit, Spend and the top bar; the limit had counted usage not yet closed at full price before the discount, so a comped workspace read as charged a cent. Every agent line on the ledger names the agent and who asked, repository runs by g1t included, so Spent is the sum of its products, Agents is the agent product, and by agent adds up to it; the billing API returns by_agent and by_person. The usage and billing guide says how spend is counted.25//! Owners also set a monthly **spend limit** on what is charged: the
26//! month's usage at price less the discount, included usage, pools and
27//! credit, with month-end usage counted on the same terms (`charged`, the
28//! one definition the Billing page and Usage share). They may
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look29//! put it anywhere up to the highest ceiling the workspace has ever had,
30//! plus what is prepaid, without asking anyone; once per workspace they may
31//! raise it to twice that highest ceiling themselves. Past that, they ask
32//! (see `requests`), and g1t answers within one business day.
33//!
34//! Alerts go out at 50, 75, 90 and 100% of the plan's included usage, the
35//! spend limit and the ceiling, in the app and by email. At the ceiling or
36//! the spend limit, new work stops: no new sandboxes, builds or app
37//! requests until it is paid, raised, or the month turns. Runs already
Usage limits: unpaid usage can only go so far38//! under way finish.
39//!
40//! 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 look41//! more. Test-mode payments are not money, so they do not raise trust.
Usage limits: unpaid usage can only go so far42
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails43use std::collections::BTreeSet;
44
Spend adds up on one ledger. Charged this month has one definition, price less discount, included usage and credit, shared by the plan card, the spend limit, Spend and the top bar; the limit had counted usage not yet closed at full price before the discount, so a comped workspace read as charged a cent. Every agent line on the ledger names the agent and who asked, repository runs by g1t included, so Spent is the sum of its products, Agents is the agent product, and by agent adds up to it; the billing API returns by_agent and by_person. The usage and billing guide says how spend is counted.45use futures_util::future::{try_join, try_join3, try_join4, try_join_all};
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look46use g1t_contracts::billing::{
Usage, Billing settings and prepaid AI credit; fixes from the UX audit47 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 look48};
Usage limits: unpaid usage can only go so far49use g1t_contracts::time::rfc3339;
Merge main (membership, two-factor, GitHub repo roles) into tokens50use g1t_contracts::{FailureCode, Outcome};
Usage limits: unpaid usage can only go so far51use g1t_kit::now_ms;
52use serde::Deserialize;
53use worker::wasm_bindgen::JsValue;
54use worker::{Env, Result};
55
Spend adds up on one ledger. Charged this month has one definition, price less discount, included usage and credit, shared by the plan card, the spend limit, Spend and the top bar; the limit had counted usage not yet closed at full price before the discount, so a comped workspace read as charged a cent. Every agent line on the ledger names the agent and who asked, repository runs by g1t included, so Spent is the sum of its products, Agents is the agent product, and by agent adds up to it; the billing API returns by_agent and by_person. The usage and billing guide says how spend is counted.56use crate::charged::{PendingLine, PendingSplit, month_totals, pending_split};
Usage limits: unpaid usage can only go so far57use crate::features::dollars as dollars_plain;
58use crate::{Billing, members_only};
59
Spend adds up on one ledger. Charged this month has one definition, price less discount, included usage and credit, shared by the plan card, the spend limit, Spend and the top bar; the limit had counted usage not yet closed at full price before the discount, so a comped workspace read as charged a cent. Every agent line on the ledger names the agent and who asked, repository runs by g1t included, so Spent is the sum of its products, Agents is the agent product, and by agent adds up to it; the billing API returns by_agent and by_person. The usage and billing guide says how spend is counted.60/// A month's usage on the ledger, in the parts `charged` is made of.
61#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
62struct MonthCharges {
63 /// At price.
64 price: i64,
65 /// What the account's discount took off.
66 discount: i64,
67 /// What the plan's included usage, the trial, a pool or g1t paid.
68 covered: i64,
69 /// Last month's charge, as the ledger has it, for the automatic limit.
70 last_month: i64,
71}
72
Usage limits: unpaid usage can only go so far73/// The ceilings, from the billing service's variables.
74pub(crate) struct Ceilings {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look75 /// `LIMIT_NEW_MICROS`: a free workspace's.
Usage limits: unpaid usage can only go so far76 pub new: i64,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look77 /// `LIMIT_PAID_MIN_MICROS` and `LIMIT_PAID_MAX_MICROS`: the bounds of a
78 /// paid workspace's, from what it has paid.
Usage limits: unpaid usage can only go so far79 pub paid_min: i64,
80 pub paid_max: i64,
81}
82
83impl Ceilings {
84 pub(crate) fn from_env(env: &Env) -> Self {
85 let number = |name: &str, default: i64| {
86 env.var(name).ok().and_then(|v| v.to_string().parse::<i64>().ok()).unwrap_or(default)
87 };
88 Ceilings {
89 new: number("LIMIT_NEW_MICROS", 3_000_000),
90 paid_min: number("LIMIT_PAID_MIN_MICROS", 25_000_000),
91 paid_max: number("LIMIT_PAID_MAX_MICROS", 1_000_000_000),
92 }
93 }
94
95 /// The ceiling for a workspace that has paid `paid` in live money.
96 pub(crate) fn for_paid(&self, paid: i64) -> i64 {
97 (paid * 2).clamp(self.paid_min, self.paid_max)
98 }
99}
100
101/// Where a workspace stands against its ceiling.
102pub(crate) fn state(exposure: i64, ceiling: Option<i64>) -> LimitState {
103 match ceiling {
104 Some(ceiling) if exposure >= ceiling => LimitState::Stopped,
105 Some(ceiling) if exposure * 5 >= ceiling * 4 => LimitState::Warning,
106 _ => LimitState::Ok,
107 }
108}
109
Two limits, real invoices, trust that grows by itself, sales signals110/// The automatic monthly spend limit's floor: $200.
111pub(crate) const DEFAULT_SPEND_MICROS: i64 = 200_000_000;
112/// Established workspaces' ceiling: three times their steady monthly
113/// spend, up to $10,000.
114const ESTABLISHED_FACTOR: i64 = 3;
115const ESTABLISHED_MAX_MICROS: i64 = 10_000_000_000;
116/// A month counts toward Established at this much spend or more.
117const ESTABLISHED_MONTH_MICROS: i64 = 20_000_000;
118/// Payments raise trust once this old: past the time most bad cards are
119/// caught.
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look120pub(crate) const SETTLE_DAYS: u64 = 7;
Two limits, real invoices, trust that grows by itself, sales signals121
122/// The automatic spend limit: $200, or twice last month's spend.
123pub(crate) fn automatic_spend_limit(last_month_charged: i64) -> i64 {
124 DEFAULT_SPEND_MICROS.max(last_month_charged * 2)
125}
Billing accounts, terms and enterprises; g1t is no longer free126
Two limits, real invoices, trust that grows by itself, sales signals127/// An Established workspace's ceiling, from its last three months'
128/// charges, if each was steady enough.
129pub(crate) fn established_ceiling(months: &[i64]) -> Option<i64> {
130 if months.len() < 3 || months.iter().any(|m| *m < ESTABLISHED_MONTH_MICROS) {
131 return None;
132 }
133 let average = months.iter().sum::<i64>() / months.len() as i64;
134 Some((average * ESTABLISHED_FACTOR).min(ESTABLISHED_MAX_MICROS))
135}
136
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look137/// Days since 1970-01-01 of a `YYYY-MM-DD…` date, for comparing dates
138/// without a clock (Howard Hinnant's days-from-civil).
139pub(crate) fn days(date: &str) -> i64 {
140 let year: i64 = date.get(..4).and_then(|y| y.parse().ok()).unwrap_or(1970);
141 let month: i64 = date.get(5..7).and_then(|m| m.parse().ok()).unwrap_or(1);
142 let day: i64 = date.get(8..10).and_then(|d| d.parse().ok()).unwrap_or(1);
143 let y = if month <= 2 { year - 1 } else { year };
144 let era = y.div_euclid(400);
145 let yoe = y - era * 400;
146 let mp = (month + 9) % 12;
147 let doy = (153 * mp + 2) / 5 + day - 1;
148 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
149 era * 146_097 + doe - 719_468
150}
151
152/// Whether a plan that started at `started_at` is still in its first
153/// billing cycle: its paid period ends no more than a month after it
154/// started (a renewal moves the end a month on), or, with no period known,
155/// it started within the last 31 days.
156pub(crate) fn in_first_cycle(started_at: &str, period_end: Option<&str>, now: &str) -> bool {
157 match period_end {
158 Some(end) => days(end) - days(started_at) <= 32 && days(now) <= days(end),
159 None => days(now) - days(started_at) <= 31,
160 }
161}
162
163/// g1t's ceiling for a workspace on the plan: the starting one in its
164/// first month; after it, what it has paid (or its Established ceiling),
165/// never less than the starting one.
166pub(crate) fn paid_ceiling(ceilings: &Ceilings, start: i64, first_month: bool, paid: i64, established: Option<i64>) -> i64 {
167 if first_month {
168 return start;
169 }
170 let from_paid = if paid > 0 { ceilings.for_paid(paid) } else { 0 };
171 start.max(from_paid).max(established.unwrap_or(0))
172}
173
174/// What the owners may set their spend limit to without asking, and the
175/// one-time raise if it is still theirs to use: up to the highest ceiling
176/// ever (or the current one, if higher) plus what is prepaid; once, twice
177/// the highest ceiling.
178pub(crate) fn spend_bounds(ceiling: i64, max_ever: i64, prepaid: i64, raised: bool) -> (i64, Option<i64>) {
179 let highest = ceiling.max(max_ever);
180 let available = highest + prepaid.max(0);
181 let once = (!raised).then(|| (highest * 2 + prepaid.max(0)).max(available));
182 (available, once)
183}
184
185/// Whether `requested` is a spend limit the owners may set themselves.
186/// `Ok(true)` when it takes the one-time raise.
187pub(crate) fn self_serve(requested: i64, available: i64, once: Option<i64>, raise_once: bool) -> std::result::Result<bool, String> {
188 if requested < 0 {
189 return Err("A spend limit cannot be negative.".to_owned());
190 }
191 if requested <= available {
192 return Ok(false);
193 }
194 match once {
195 Some(once) if raise_once && requested <= once => Ok(true),
196 Some(once) if raise_once => Err(format!(
197 "The one-time raise goes up to {}. For more, ask g1t with Raise my limit; the answer comes within one business day.",
198 dollars_plain(once)
199 )),
200 Some(once) => Err(format!(
201 "You can set up to {} yourself, or use your one-time raise to go up to {}. For more, ask g1t with Raise my limit.",
202 dollars_plain(available),
203 dollars_plain(once)
204 )),
205 None => Err(format!(
206 "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.",
207 dollars_plain(available)
208 )),
209 }
210}
211
212/// Which alert a measure has reached: 100, 90, 75, 50, or none (0).
213pub(crate) fn alert_level(used: i64, limit: i64) -> u32 {
Usage, Billing settings and prepaid AI credit; fixes from the UX audit214 alert_level_in(used, limit, &ALERT_LEVELS)
215}
216
217/// Every alert a budget can have, highest first.
218pub(crate) const ALERT_LEVELS: [u32; 4] = [100, 90, 75, 50];
219
220/// The highest of `levels` a measure has reached, or 0.
221pub(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 look222 if limit <= 0 || used <= 0 {
223 return 0;
224 }
Usage, Billing settings and prepaid AI credit; fixes from the UX audit225 levels.iter().copied().filter(|level| used * 100 >= limit * i64::from(*level)).max().unwrap_or(0)
226}
227
228/// A budget's alerts as stored (`50,75,100`): every level when none were
229/// chosen, highest first.
230pub(crate) fn alert_levels(stored: Option<&str>) -> Vec<u32> {
231 let Some(stored) = stored else { return ALERT_LEVELS.to_vec() };
232 let mut levels: Vec<u32> = stored.split(',').filter_map(|l| l.trim().parse().ok()).filter(|l| ALERT_LEVELS.contains(l)).collect();
233 levels.sort_by(|a, b| b.cmp(a));
234 levels.dedup();
235 levels
236}
237
238/// Where spending stands against the budget: at 100% it stops work only
239/// when the budget pauses usage; otherwise it is a warning.
240pub(crate) fn budget_state(spent: i64, budget: Option<i64>, pause: bool) -> LimitState {
241 match state(spent, budget) {
242 LimitState::Stopped if !pause => LimitState::Warning,
243 other => other,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look244 }
245}
246
Usage, Billing settings and prepaid AI credit; fixes from the UX audit247/// Whether a budget webhook is an address g1t will post to: HTTPS, not
248/// g1t's own, at most 500 characters.
249pub(crate) fn webhook_ok(url: &str) -> bool {
250 let url = url.trim();
251 url.len() <= 500
252 && url.starts_with("https://")
253 && url.len() > "https://".len() + 3
254 && !url.contains(char::is_whitespace)
255 && !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."))
256}
257
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look258/// What is owed and what is prepaid, from this month's usage and payments
259/// and the balance the month started with (positive: paid in advance;
260/// negative: owed from before).
261pub(crate) fn exposure(used: i64, paid_month: i64, balance_before: i64) -> (i64, i64) {
262 let prepaid_in = balance_before.max(0);
263 let carried = (-balance_before).max(0);
264 let net = used - paid_month - prepaid_in;
265 (net.max(0) + carried, (-net).max(0))
266}
267
Usage limits: unpaid usage can only go so far268#[derive(Deserialize)]
269struct LimitRow {
270 spend_limit_micros: Option<i64>,
Two limits, real invoices, trust that grows by itself, sales signals271 #[serde(default)]
272 spend_limit_full: Option<i64>,
Billing accounts, terms and enterprises; g1t is no longer free273 autopay_failed_at: Option<String>,
274 autopay_error: Option<String>,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look275 #[serde(default)]
276 max_ceiling_micros: Option<i64>,
277 #[serde(default)]
278 granted_ceiling_micros: Option<i64>,
279 #[serde(default)]
280 raised_at: Option<String>,
Usage, Billing settings and prepaid AI credit; fixes from the UX audit281 #[serde(default)]
282 alert_levels: Option<String>,
283 #[serde(default)]
284 pause_at_limit: Option<i64>,
285 #[serde(default)]
286 budget_webhook: Option<String>,
Usage limits: unpaid usage can only go so far287}
288
289#[derive(Deserialize)]
290struct Month {
291 used: Option<i64>,
292 paid: Option<i64>,
293}
294
295#[derive(Deserialize)]
296struct Paid {
297 paid: Option<i64>,
298}
299
300impl Billing {
Billing accounts, terms and enterprises; g1t is no longer free301 /// The workspace's limit, worked out from the ledger of the account
302 /// that pays for it: its own, or its enterprise's, whose workspaces'
303 /// usage and payments count together.
Usage limits: unpaid usage can only go so far304 pub(crate) async fn limit_of(&self, workspace: &str) -> Result<Limit> {
305 let workspace = workspace.to_lowercase();
Billing accounts, terms and enterprises; g1t is no longer free306 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 index307 let plan = self.plan_kind_for(&workspace, &account).await?;
308 self.limit_with(&workspace, &account, plan).await
309 }
310
311 /// The workspace's limit, from the account and plan already read for
312 /// it, so a caller that has them does not read them again.
313 pub(crate) async fn limit_with(&self, workspace: &str, account: &BillingAccount, plan: PlanKind) -> Result<Limit> {
314 let workspace = workspace.to_lowercase();
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look315 let now = rfc3339(now_ms());
316 let month_start = format!("{}-01", &now[..7]);
Billing accounts, terms and enterprises; g1t is no longer free317 let marks = vec!["?"; account.workspaces.len().max(1)].join(", ");
Spend adds up on one ledger. Charged this month has one definition, price less discount, included usage and credit, shared by the plan card, the spend limit, Spend and the top bar; the limit had counted usage not yet closed at full price before the discount, so a comped workspace read as charged a cent. Every agent line on the ledger names the agent and who asked, repository runs by g1t included, so Spent is the sum of its products, Agents is the agent product, and by agent adds up to it; the billing API returns by_agent and by_person. The usage and billing guide says how spend is counted.318 let names: Vec<String> = if account.workspaces.is_empty() { vec![workspace.clone()] } else { account.workspaces.clone() };
319 let members: Vec<JsValue> = names.iter().map(|w| JsValue::from(w.as_str())).collect();
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 index320 let row = async {
321 self.db
322 .prepare(
323 "SELECT spend_limit_micros, spend_limit_full, autopay_failed_at, autopay_error,
Usage, Billing settings and prepaid AI credit; fixes from the UX audit324 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 index325 FROM limits WHERE workspace = ?",
326 )
327 .bind(&[workspace.as_str().into()])?
328 .first::<LimitRow>(None)
329 .await
330 };
Billing accounts, terms and enterprises; g1t is no longer free331 let mut with_month = members.clone();
332 with_month.push(month_start.as_str().into());
Usage limits: unpaid usage can only go so far333 // 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 look334 // more; on the workspace's own provider, only what g1t charged.
335 // What the plan's included usage, the trial, the open-source pool
336 // or g1t itself paid for is not unpaid: those are budgets already
337 // 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 index338 let month = async {
339 self.db
340 .prepare(format!(
341 "SELECT
342 SUM(CASE WHEN kind = 'usage' THEN
343 CASE WHEN COALESCE(billed_to, 'g1t') = 'g1t'
344 THEN MAX(COALESCE(cost_micros, 0) - COALESCE(credit_micros, 0)
345 - COALESCE(trial_micros, 0) - COALESCE(oss_micros, 0)
346 - COALESCE(given_micros, 0),
347 -amount_micros)
348 ELSE -amount_micros END
349 END) AS used,
350 SUM(CASE WHEN kind = 'top_up' THEN amount_micros END) AS paid
351 FROM ledger WHERE workspace IN ({marks}) AND created_at >= ?"
352 ))
353 .bind(&with_month)?
354 .first::<Month>(None)
355 .await
356 };
Spend adds up on one ledger. Charged this month has one definition, price less discount, included usage and credit, shared by the plan card, the spend limit, Spend and the top bar; the limit had counted usage not yet closed at full price before the discount, so a comped workspace read as charged a cent. Every agent line on the ledger names the agent and who asked, repository runs by g1t included, so Spent is the sum of its products, Agents is the agent product, and by agent adds up to it; the billing API returns by_agent and by_person. The usage and billing guide says how spend is counted.357 // And what is metered but not charged until the month closes: it
358 // counts toward what is unpaid at its charge, and toward what is
359 // charged on the account's terms (see `charged`).
360 let pending = self.pending_this_month(&members, &month_start[..7]);
361 // What credit (AI credit, credit from g1t) paid this month: charged
362 // to no one, so not against the spend limit. Read only for
363 // workspaces with grants; the rest answer at once.
364 let next_month = crate::credits::next_month_start(&month_start[..7]);
365 let credits = try_join_all(names.iter().map(|name| {
366 let (from, until) = (month_start.as_str(), next_month.as_str());
367 async move { Ok::<i64, worker::Error>(self.credit_paid_between(name, from, until).await?.0) }
368 }));
Usage limits: unpaid usage can only go so far369 // Test-mode payments are not money: they pay nothing off.
370 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 look371 // The balance the month started with: owed from before (so a new
372 // month is not a fresh allowance for an account that never pays),
373 // or paid in advance. Credits g1t gave count; test-mode payments
374 // do not.
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace375 let mut before = members.clone();
376 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 index377 let balance_before = async {
378 Ok::<i64, worker::Error>(
379 self.db
380 .prepare(format!(
381 "SELECT SUM(CASE WHEN kind = 'usage' THEN amount_micros
382 WHEN kind = 'top_up' AND ({live} = 1 OR reference LIKE 'crd%') THEN amount_micros
383 ELSE 0 END) AS paid
384 FROM ledger WHERE workspace IN ({marks}) AND created_at < ?",
385 live = u8::from(live)
386 ))
387 .bind(&before)?
388 .first::<Paid>(None)
389 .await?
390 .and_then(|row| row.paid)
391 .unwrap_or(0),
392 )
393 };
394 // The trust ceiling, from what has been paid and how steadily. The
395 // first month is asked for beside it, since neither needs the other.
396 let trust = async {
397 Ok::<_, worker::Error>(match account.terms.kind {
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging398 _ 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 index399 _ if account.terms.ceiling_micros.is_some() => (Trust::Reviewed, account.terms.ceiling_micros, false),
400 _ => {
401 let standing = async {
402 let paid = self.live_paid(&members).await?;
403 let established = if paid > 0 { self.established(&members).await? } else { None };
404 Ok::<_, worker::Error>((paid, established))
405 };
406 let first = async {
407 if plan == PlanKind::Paid { self.first_month(&workspace).await } else { Ok(false) }
408 };
409 let ((paid, established), first_month) = try_join(standing, first).await?;
410 if plan == PlanKind::Free {
411 // Nothing on demand: only what a free workspace can owe.
412 (Trust::New, Some(self.ceilings.new), false)
413 } else {
414 let ceiling = paid_ceiling(&self.ceilings, self.plans.paid_start_micros, first_month, paid, established);
415 (if established.is_some() { Trust::Established } else { Trust::Paid }, Some(ceiling), first_month)
416 }
Billing accounts, terms and enterprises; g1t is no longer free417 }
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 index418 })
Usage limits: unpaid usage can only go so far419 };
Spend adds up on one ledger. Charged this month has one definition, price less discount, included usage and credit, shared by the plan card, the spend limit, Spend and the top bar; the limit had counted usage not yet closed at full price before the discount, so a comped workspace read as charged a cent. Every agent line on the ledger names the agent and who asked, repository runs by g1t included, so Spent is the sum of its products, Agents is the agent product, and by agent adds up to it; the billing API returns by_agent and by_person. The usage and billing guide says how spend is counted.420 // This month's charges on the ledger, measured as every page
421 // measures them, and last month's, for the automatic spend limit.
422 let charges = self.month_charges(&members, &month_start);
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 index423 // None of these reads needs another's answer, so they go to D1 at
424 // once: the limit is on every signed-in page.
Spend adds up on one ledger. Charged this month has one definition, price less discount, included usage and credit, shared by the plan card, the spend limit, Spend and the top bar; the limit had counted usage not yet closed at full price before the discount, so a comped workspace read as charged a cent. Every agent line on the ledger names the agent and who asked, repository runs by g1t included, so Spent is the sum of its products, Agents is the agent product, and by agent adds up to it; the billing API returns by_agent and by_person. The usage and billing guide says how spend is counted.425 let ((row, month, balance_before, charges), (pending, credits), (trust, trust_ceiling, first_month)) =
426 try_join3(try_join4(row, month, balance_before, charges), try_join(pending, credits), trust).await?;
427 // Charged this month: the one definition (`charged`), so this is
428 // the Billing page's and Usage's figure, to the micro.
429 let percent = account.terms.percent_off();
430 let splits: Vec<PendingSplit> = pending.iter().map(|p| pending_split(p.cost(), p.charge(), self.margin_percent, percent)).collect();
431 let totals = month_totals(charges.price, charges.discount, charges.covered, credits.into_iter().sum(), &splits);
432 let spent = totals.charged_micros;
433 let last_month = charges.last_month;
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 index434 let (used, paid_month) = month.map_or((0, 0), |m| (m.used.unwrap_or(0), m.paid.unwrap_or(0)));
Spend adds up on one ledger. Charged this month has one definition, price less discount, included usage and credit, shared by the plan card, the spend limit, Spend and the top bar; the limit had counted usage not yet closed at full price before the discount, so a comped workspace read as charged a cent. Every agent line on the ledger names the agent and who asked, repository runs by g1t included, so Spent is the sum of its products, Agents is the agent product, and by agent adds up to it; the billing API returns by_agent and by_person. The usage and billing guide says how spend is counted.435 let used = used + pending.iter().map(PendingLine::charge).sum::<i64>();
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 index436 let (exposure, prepaid) = exposure(used, if live { paid_month } else { 0 }, balance_before);
437
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look438 // A ceiling g1t granted is a floor under the trust ceiling.
439 let granted = row.as_ref().and_then(|row| row.granted_ceiling_micros);
440 let ceiling = trust_ceiling.map(|c| c.max(granted.unwrap_or(0)));
441 // The highest ceiling ever, kept as it rises.
442 let stored_max = row.as_ref().and_then(|row| row.max_ceiling_micros);
443 let max_ever = match (stored_max, ceiling) {
444 (Some(stored), Some(now)) => Some(stored.max(now)),
445 (stored, now) => stored.or(now),
446 };
447 if let (Some(max), true) = (max_ever, ceiling.is_some() && max_ever != stored_max && plan != PlanKind::Free) {
448 self.db
449 .prepare(
450 "INSERT INTO limits (workspace, max_ceiling_micros, updated_at) VALUES (?1, ?2, ?3)
451 ON CONFLICT (workspace) DO UPDATE SET max_ceiling_micros = MAX(COALESCE(max_ceiling_micros, 0), ?2), updated_at = ?3",
452 )
453 .bind(&[workspace.as_str().into(), (max as f64).into(), now.as_str().into()])?
454 .run()
455 .await?;
456 }
457 let raised_at = row.as_ref().and_then(|row| row.raised_at.clone());
458 let self_serve = matches!(trust, Trust::New | Trust::Paid | Trust::Established) && plan != PlanKind::Free;
459 let (available, raise_once) = match (ceiling, self_serve) {
460 (Some(ceiling), true) => {
461 let (available, once) = spend_bounds(ceiling, max_ever.unwrap_or(ceiling), prepaid, raised_at.is_some());
462 (Some(available), once)
463 }
464 (ceiling, _) => (ceiling, None),
465 };
Two limits, real invoices, trust that grows by itself, sales signals466 // The owners' own monthly limit: theirs, none, or the automatic one
467 // ($200, or twice last month), which self-serve workspaces start on.
468 let chosen = row.as_ref().and_then(|row| row.spend_limit_micros);
469 let full = row.as_ref().and_then(|row| row.spend_limit_full).unwrap_or(0) == 1;
470 let default_spend_limit = chosen.is_none() && !full && self_serve;
471 let spend_limit = match (chosen, full) {
472 (Some(own), _) => Some(own),
473 (None, true) => None,
474 (None, false) if self_serve => Some(automatic_spend_limit(last_month)),
475 _ => None,
476 };
Billing accounts, terms and enterprises; g1t is no longer free477 // A card declined when g1t charged it at the limit stops work until
478 // it is paid; any payment clears it.
479 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 signals480 // Two limits: g1t's on what is unpaid, the owners' on what is spent.
481 let risk = state(exposure, ceiling);
Usage, Billing settings and prepaid AI credit; fixes from the UX audit482 // A budget that does not pause usage only alerts: at 100% it is a
483 // warning, never a stop. g1t's own ceiling still stops work.
484 let pause = row.as_ref().and_then(|row| row.pause_at_limit).unwrap_or(1) != 0;
485 let budget = budget_state(spent, spend_limit, pause);
Two limits, real invoices, trust that grows by itself, sales signals486 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 kept487 let state = if (declined.is_some() && exposure > 0) || risk == LimitState::Stopped || over_budget {
Two limits, real invoices, trust that grows by itself, sales signals488 LimitState::Stopped
489 } else if risk == LimitState::Warning || budget == LimitState::Warning {
490 LimitState::Warning
491 } else {
492 LimitState::Ok
Usage limits: unpaid usage can only go so far493 };
Billing accounts, terms and enterprises; g1t is no longer free494 let who = if account.kind == g1t_contracts::billing::AccountKind::Enterprise {
495 format!("The {} enterprise, which pays for {workspace},", account.name)
496 } else {
497 format!("The {workspace} workspace")
498 };
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look499 let billing = format!("/{workspace}/-/billing");
Usage limits: unpaid usage can only go so far500 let message = match state {
501 LimitState::Ok => None,
Usage, Billing settings and prepaid AI credit; fixes from the UX audit502 LimitState::Warning if budget == LimitState::Warning && !pause => Some(format!(
503 "{who} has spent {} of its {} monthly budget. Usage does not pause at the budget; an owner can change that at {billing}.",
504 dollars_plain(spent),
505 dollars_plain(spend_limit.unwrap_or_default()),
506 )),
Two limits, real invoices, trust that grows by itself, sales signals507 LimitState::Warning if budget == LimitState::Warning => Some(format!(
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look508 "{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 signals509 dollars_plain(spent),
510 dollars_plain(spend_limit.unwrap_or_default()),
511 )),
Usage limits: unpaid usage can only go so far512 LimitState::Warning => Some(format!(
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look513 "{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 far514 dollars_plain(exposure),
515 dollars_plain(ceiling.unwrap_or_default()),
516 )),
Billing accounts, terms and enterprises; g1t is no longer free517 LimitState::Stopped if declined.is_some() => Some(format!(
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look518 "{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 free519 declined.as_ref().and_then(|(_, error)| error.clone()).unwrap_or_else(|| "the card was declined".to_owned()),
520 )),
Two limits, real invoices, trust that grows by itself, sales signals521 LimitState::Stopped => Some(if over_budget {
Usage limits: unpaid usage can only go so far522 format!(
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look523 "{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 signals524 dollars_plain(spend_limit.unwrap_or_default()),
Usage limits: unpaid usage can only go so far525 )
526 } else {
527 format!(
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look528 "{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 far529 dollars_plain(ceiling.unwrap_or_default()),
530 )
531 }),
532 };
Two limits, real invoices, trust that grows by itself, sales signals533 let growth = match trust {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look534 Trust::New => Some("Free workspaces have no on-demand usage: the g1t plan starts at a $100 limit.".to_owned()),
535 Trust::Paid if first_month => Some(format!(
536 "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.",
537 dollars_plain(self.plans.paid_start_micros)
538 )),
Two limits, real invoices, trust that grows by itself, sales signals539 Trust::Paid => Some(format!(
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look540 "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 signals541 )),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look542 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 signals543 Trust::Reviewed | Trust::Internal => None,
544 };
Usage limits: unpaid usage can only go so far545 Ok(Limit {
546 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 index547 account: account.id.clone(),
548 account_name: account.name.clone(),
Two limits, real invoices, trust that grows by itself, sales signals549 spent_micros: spent,
550 default_spend_limit,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look551 available_micros: available,
Two limits, real invoices, trust that grows by itself, sales signals552 growth,
Usage limits: unpaid usage can only go so far553 trust,
554 exposure_micros: exposure,
555 ceiling_micros: ceiling,
556 trust_ceiling_micros: trust_ceiling,
557 spend_limit_micros: spend_limit,
558 state,
559 message,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look560 prepaid_micros: prepaid,
561 max_ceiling_micros: max_ever.filter(|_| plan != PlanKind::Free),
562 raise_once_micros: raise_once,
563 raised_at,
564 first_month,
Usage, Billing settings and prepaid AI credit; fixes from the UX audit565 alert_levels: alert_levels(row.as_ref().and_then(|row| row.alert_levels.as_deref())),
566 pause_at_limit: pause,
567 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 look568 })
569 }
570
571 /// Whether the workspace's plan is in its first billing cycle.
572 pub(crate) async fn first_month(&self, workspace: &str) -> Result<bool> {
573 Ok(match self.plan_cycle(workspace).await? {
574 Some((started_at, period_end)) => in_first_cycle(&started_at, period_end.as_deref(), &rfc3339(now_ms())),
575 None => false,
Usage limits: unpaid usage can only go so far576 })
577 }
578
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look579 /// Real money the workspaces have paid g1t, cleared: usage payments and
580 /// the plan's price. Nothing in test mode, and credits g1t gave are not
581 /// payments.
582 pub(crate) async fn live_paid(&self, members: &[JsValue]) -> Result<i64> {
Usage limits: unpaid usage can only go so far583 if !self.stripe.as_ref().is_some_and(crate::stripe::Stripe::live) {
584 return Ok(0);
585 }
Billing accounts, terms and enterprises; g1t is no longer free586 let marks = vec!["?"; members.len().max(1)].join(", ");
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look587 let settled = rfc3339(now_ms() - SETTLE_DAYS * 24 * 60 * 60 * 1000);
Usage limits: unpaid usage can only go so far588 Ok(self
589 .db
Billing accounts, terms and enterprises; g1t is no longer free590 .prepare(format!(
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look591 "SELECT
592 (SELECT COALESCE(SUM(amount_micros), 0) FROM ledger
593 WHERE workspace IN ({marks}) AND kind = 'top_up' AND reference NOT LIKE 'crd%'
594 AND (amount_micros < 0
595 OR (disputed = 0 AND COALESCE(funding, '') <> 'prepaid'
596 AND created_at <= '{settled}')))
597 + (SELECT COALESCE(SUM(amount_micros), 0) FROM plan_payments
598 WHERE workspace IN ({marks}) AND paid_at <= '{settled}') AS paid"
Billing accounts, terms and enterprises; g1t is no longer free599 ))
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look600 .bind(&[members, members].concat())?
Usage limits: unpaid usage can only go so far601 .first::<Paid>(None)
602 .await?
603 .and_then(|row| row.paid)
604 .unwrap_or(0))
605 }
606
Spend adds up on one ledger. Charged this month has one definition, price less discount, included usage and credit, shared by the plan card, the spend limit, Spend and the top bar; the limit had counted usage not yet closed at full price before the discount, so a comped workspace read as charged a cent. Every agent line on the ledger names the agent and who asked, repository runs by g1t included, so Spent is the sum of its products, Agents is the agent product, and by agent adds up to it; the billing API returns by_agent and by_person. The usage and billing guide says how spend is counted.607 /// This month's usage on the ledger across the workspaces, in the parts
608 /// `charged` is made of (at price, the discount, what included usage,
609 /// the trial, a pool or g1t paid), and last month's charge.
610 async fn month_charges(&self, members: &[JsValue], month_start: &str) -> Result<MonthCharges> {
Two limits, real invoices, trust that grows by itself, sales signals611 #[derive(Deserialize)]
Spend adds up on one ledger. Charged this month has one definition, price less discount, included usage and credit, shared by the plan card, the spend limit, Spend and the top bar; the limit had counted usage not yet closed at full price before the discount, so a comped workspace read as charged a cent. Every agent line on the ledger names the agent and who asked, repository runs by g1t included, so Spent is the sum of its products, Agents is the agent product, and by agent adds up to it; the billing API returns by_agent and by_person. The usage and billing guide says how spend is counted.612 struct Row {
613 price: Option<f64>,
614 discount: Option<f64>,
615 covered: Option<f64>,
616 last_month: Option<f64>,
Two limits, real invoices, trust that grows by itself, sales signals617 }
618 let last_start = format!("{}-01", previous_month(&month_start[..7]));
619 let marks = vec!["?"; members.len().max(1)].join(", ");
620 let row = self
621 .db
622 .prepare(format!(
623 "SELECT
Spend adds up on one ledger. Charged this month has one definition, price less discount, included usage and credit, shared by the plan card, the spend limit, Spend and the top bar; the limit had counted usage not yet closed at full price before the discount, so a comped workspace read as charged a cent. Every agent line on the ledger names the agent and who asked, repository runs by g1t included, so Spent is the sum of its products, Agents is the agent product, and by agent adds up to it; the billing API returns by_agent and by_person. The usage and billing guide says how spend is counted.624 SUM(CASE WHEN created_at >= '{month_start}' THEN {price} END) AS price,
625 SUM(CASE WHEN created_at >= '{month_start}' THEN COALESCE(discount_micros, 0) END) AS discount,
626 SUM(CASE WHEN created_at >= '{month_start}' THEN COALESCE(credit_micros, 0) + COALESCE(trial_micros, 0)
627 + COALESCE(oss_micros, 0) + COALESCE(given_micros, 0) END) AS covered,
Two limits, real invoices, trust that grows by itself, sales signals628 -SUM(CASE WHEN created_at >= '{last_start}' AND created_at < '{month_start}' THEN amount_micros END) AS last_month
Spend adds up on one ledger. Charged this month has one definition, price less discount, included usage and credit, shared by the plan card, the spend limit, Spend and the top bar; the limit had counted usage not yet closed at full price before the discount, so a comped workspace read as charged a cent. Every agent line on the ledger names the agent and who asked, repository runs by g1t included, so Spent is the sum of its products, Agents is the agent product, and by agent adds up to it; the billing API returns by_agent and by_person. The usage and billing guide says how spend is counted.629 FROM ledger WHERE kind = 'usage' AND workspace IN ({marks}) AND created_at >= '{last_start}'",
630 price = crate::statement::PRICE_SQL
Two limits, real invoices, trust that grows by itself, sales signals631 ))
632 .bind(members)?
Spend adds up on one ledger. Charged this month has one definition, price less discount, included usage and credit, shared by the plan card, the spend limit, Spend and the top bar; the limit had counted usage not yet closed at full price before the discount, so a comped workspace read as charged a cent. Every agent line on the ledger names the agent and who asked, repository runs by g1t included, so Spent is the sum of its products, Agents is the agent product, and by agent adds up to it; the billing API returns by_agent and by_person. The usage and billing guide says how spend is counted.633 .first::<Row>(None)
Two limits, real invoices, trust that grows by itself, sales signals634 .await?;
Spend adds up on one ledger. Charged this month has one definition, price less discount, included usage and credit, shared by the plan card, the spend limit, Spend and the top bar; the limit had counted usage not yet closed at full price before the discount, so a comped workspace read as charged a cent. Every agent line on the ledger names the agent and who asked, repository runs by g1t included, so Spent is the sum of its products, Agents is the agent product, and by agent adds up to it; the billing API returns by_agent and by_person. The usage and billing guide says how spend is counted.635 let whole = |n: Option<f64>| n.unwrap_or(0.0).round() as i64;
636 Ok(row.map_or(MonthCharges::default(), |r| MonthCharges {
637 price: whole(r.price),
638 discount: whole(r.discount),
639 covered: whole(r.covered),
640 last_month: whole(r.last_month).max(0),
641 }))
Two limits, real invoices, trust that grows by itself, sales signals642 }
643
644 /// An Established ceiling, if the workspaces have paid steadily: three
645 /// full months of real spend, each invoiced and paid, nothing declined
646 /// in 90 days and nothing ever disputed.
647 async fn established(&self, members: &[JsValue]) -> Result<Option<i64>> {
648 let marks = vec!["?"; members.len().max(1)].join(", ");
649 let now = rfc3339(now_ms());
650 let mut months = vec![];
651 let mut month = previous_month(&now[..7]);
652 for _ in 0..3 {
653 months.push(month.clone());
654 month = previous_month(&month);
655 }
656 #[derive(Deserialize)]
657 struct Count {
658 n: Option<i64>,
659 }
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 index660 let troubled = async {
661 Ok::<i64, worker::Error>(
662 self.db
663 .prepare(format!(
664 "SELECT (SELECT COUNT(*) FROM ledger WHERE workspace IN ({marks}) AND disputed = 1)
665 + (SELECT COUNT(*) FROM limits WHERE workspace IN ({marks}) AND autopay_failed_at >= '{since}') AS n",
666 since = rfc3339(now_ms() - 90 * 24 * 60 * 60 * 1000)
667 ))
668 .bind(&[members, members].concat())?
669 .first::<Count>(None)
670 .await?
671 .and_then(|c| c.n)
672 .unwrap_or(0),
673 )
674 };
675 #[derive(Deserialize)]
676 struct Month {
677 charged: Option<i64>,
678 unpaid: Option<i64>,
Two limits, real invoices, trust that grows by itself, sales signals679 }
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 index680 let read_month = |month: &String| {
Two limits, real invoices, trust that grows by itself, sales signals681 let next = {
682 let year: i32 = month[..4].parse().unwrap_or(1970);
683 let number: u32 = month[5..7].parse().unwrap_or(1);
684 if number == 12 { format!("{}-01", year + 1) } else { format!("{year}-{:02}", number + 1) }
685 };
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 index686 let sql = format!(
687 "SELECT
688 (SELECT -SUM(amount_micros) FROM ledger WHERE kind = 'usage' AND workspace IN ({marks})
689 AND created_at >= '{month}-01' AND created_at < '{next}-01') AS charged,
690 (SELECT COUNT(*) FROM workspace_invoices WHERE workspace IN ({marks}) AND reason = 'month'
691 AND period = '{month}' AND status <> 'paid') AS unpaid"
692 );
693 async move { self.db.prepare(sql).bind(&[members, members].concat())?.first::<Month>(None).await }
694 };
695 // The check for trouble and the three months are read at once; the
696 // answer is the one reading them in turn and stopping early gives.
697 let (troubled, rows) = try_join(troubled, try_join_all(months.iter().map(read_month))).await?;
698 if troubled > 0 {
699 return Ok(None);
700 }
701 let mut charged = vec![];
702 for row in rows {
Two limits, real invoices, trust that grows by itself, sales signals703 let Some(row) = row else { return Ok(None) };
704 if row.unpaid.unwrap_or(0) > 0 {
705 return Ok(None);
706 }
707 charged.push(row.charged.unwrap_or(0));
708 }
709 Ok(established_ceiling(&charged))
710 }
711
Usage limits: unpaid usage can only go so far712 /// A refusal, with the reason, when the workspace's work is stopped.
713 /// None while billing is off: a g1t without payments has no limits.
714 pub(crate) async fn stopped<T>(&self, workspace: &str) -> Result<Option<Outcome<T>>> {
715 if self.stripe.is_none() {
716 return Ok(None);
717 }
718 let limit = self.limit_of(workspace).await?;
719 Ok((limit.state == LimitState::Stopped).then(|| {
720 Outcome::fail(
721 FailureCode::PaymentRequired,
722 limit.message.unwrap_or_else(|| "This workspace is over its limit.".to_owned()),
723 )
724 }))
725 }
726
727 pub(crate) async fn limit(&self, a: LimitArgs) -> Result<Outcome<Limit>> {
728 let workspace = a.workspace.to_lowercase();
729 if !a.viewer.is_some_and(|viewer| viewer.is_member(&workspace)) {
730 return Ok(members_only());
731 }
732 Ok(Outcome::Ok(self.limit_of(&workspace).await?))
733 }
734
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look735 /// What a source cost so far this month. `security`, `context`,
736 /// `storage` and `git` are charged by billing once the month is over
737 /// (see `storage`); `deployments` charges its own.
Prices keep themselves current with what g1t pays738 pub(crate) async fn note_pending(&self, a: NotePendingArgs) -> Result<bool> {
Fast pages, required checks on the branch, self-hosted runners, honest incidents739 // The actions cache is the plan's to pay for; free workspaces are
740 // held to its quota instead.
741 if crate::storage::PLAN_ONLY.contains(&a.source.as_str()) && !self.has_plan(&a.workspace.to_lowercase()).await? {
742 return Ok(false);
743 }
Prices keep themselves current with what g1t pays744 let now = rfc3339(now_ms());
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas745 let detail = a.detail.as_deref().map(str::trim).filter(|d| !d.is_empty()).map(|d| d.chars().take(200).collect::<String>());
746 self.set_pending(&a.workspace, &a.source, &now[..7], a.cost_micros, detail.as_deref()).await?;
Prices keep themselves current with what g1t pays747 Ok(true)
748 }
749
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails750 /// The workspaces with usage on the ledger this month: autopay's and
751 /// the limit warnings' candidates, read once a tick for both. Served by
752 /// `ledger_usage_by_time` (migration 0050), not a scan of the ledger.
753 pub(crate) async fn month_users(&self) -> Result<BTreeSet<String>> {
754 #[derive(Deserialize)]
755 struct User {
756 workspace: String,
757 }
758 let month_start = format!("{}-01", &rfc3339(now_ms())[..7]);
759 Ok(self
760 .db
761 .prepare("SELECT DISTINCT workspace FROM ledger WHERE kind = 'usage' AND created_at >= ?")
762 .bind(&[month_start.into()])?
763 .all()
764 .await?
765 .results::<User>()?
766 .into_iter()
767 .map(|user| user.workspace)
768 .collect())
769 }
770
Billing accounts, terms and enterprises; g1t is no longer free771 /// Charges the saved card of each workspace nearing its limit, for what
772 /// it owes, so that a workspace that pays never has its work stopped.
773 /// Only with live payments: test-mode payments are not money and lower
774 /// 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 look775 /// for enterprises, which are invoiced. A charge at the limit always
776 /// goes through, whatever the minimum charge.
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails777 ///
778 /// `users` are the workspaces with usage this month ([`Self::month_users`]).
779 pub(crate) async fn autopay(&self, users: &BTreeSet<String>) -> Result<()> {
Two limits, real invoices, trust that grows by itself, sales signals780 if !self.stripe.as_ref().is_some_and(crate::stripe::Stripe::live) {
Billing accounts, terms and enterprises; g1t is no longer free781 return Ok(());
Two limits, real invoices, trust that grows by itself, sales signals782 }
Billing accounts, terms and enterprises; g1t is no longer free783 #[derive(Deserialize)]
784 struct Candidate {
785 workspace: String,
786 }
Two limits, real invoices, trust that grows by itself, sales signals787 // With a card, and not already declined: a declined card waits for
788 // the owners, rather than being tried again every few minutes.
Billing accounts, terms and enterprises; g1t is no longer free789 let candidates = self
790 .db
791 .prepare(
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails792 "SELECT accounts.workspace AS workspace
793 FROM accounts LEFT JOIN limits ON limits.workspace = accounts.workspace
794 WHERE accounts.customer_id IS NOT NULL AND limits.autopay_failed_at IS NULL",
Billing accounts, terms and enterprises; g1t is no longer free795 )
796 .all()
797 .await?
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails798 .results::<Candidate>()?
799 .into_iter()
800 .filter(|candidate| users.contains(&candidate.workspace));
Billing accounts, terms and enterprises; g1t is no longer free801 for candidate in candidates {
802 let limit = self.limit_of(&candidate.workspace).await?;
Two limits, real invoices, trust that grows by itself, sales signals803 // Near g1t's ceiling on what is unpaid; the spend limit is the
804 // owners' and stops work by itself, but what is owed is still owed.
805 let near = limit.ceiling_micros.is_some_and(|ceiling| limit.exposure_micros * 5 >= ceiling * 4);
806 if !near || limit.trust == Trust::Internal || limit.account.starts_with("ent_") {
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace807 continue;
808 }
Two limits, real invoices, trust that grows by itself, sales signals809 let today = rfc3339(now_ms())[..10].to_owned();
810 match self.invoice_workspace(&candidate.workspace, "threshold", &today).await? {
811 Ok(_) => {}
812 Err(why) => worker::console_log!("{}: no threshold invoice: {why}", candidate.workspace),
Billing accounts, terms and enterprises; g1t is no longer free813 }
814 }
815 Ok(())
816 }
817
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace818 /// Closes last month for each workspace with a card on file: charges
819 /// what it owed when the month ended. Live payments only, once per
820 /// 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 look821 /// Comped workspaces owe nothing, and enterprises are invoiced. Only
822 /// here does the minimum charge apply: less carries over.
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace823 pub(crate) async fn close_months(&self) -> Result<()> {
Two limits, real invoices, trust that grows by itself, sales signals824 if !self.stripe.as_ref().is_some_and(crate::stripe::Stripe::live) {
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace825 return Ok(());
Two limits, real invoices, trust that grows by itself, sales signals826 }
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace827 let now = rfc3339(now_ms());
828 let month_start = format!("{}-01", &now[..7]);
829 let closing = previous_month(&now[..7]);
830 #[derive(Deserialize)]
831 struct Open {
832 workspace: String,
833 balance: Option<i64>,
834 }
835 let open = self
836 .db
837 .prepare(
Two limits, real invoices, trust that grows by itself, sales signals838 "SELECT accounts.workspace AS workspace,
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace839 (SELECT SUM(amount_micros) FROM ledger
840 WHERE ledger.workspace = accounts.workspace AND ledger.created_at < ?1) AS balance
841 FROM accounts
842 WHERE accounts.customer_id IS NOT NULL
843 AND NOT EXISTS (SELECT 1 FROM month_closes
844 WHERE month_closes.workspace = accounts.workspace AND month_closes.month = ?2)
845 LIMIT 20",
846 )
847 .bind(&[month_start.as_str().into(), closing.as_str().into()])?
848 .all()
849 .await?
850 .results::<Open>()?;
851 for account in open {
852 let record = |status: &str, amount: i64, payment: Option<&str>, error: Option<&str>| {
853 self.db
854 .prepare(
855 "INSERT OR IGNORE INTO month_closes (workspace, month, status, amount_micros, payment_id, error, closed_at)
856 VALUES (?, ?, ?, ?, ?, ?, ?)",
857 )
858 .bind(&[
859 account.workspace.as_str().into(),
860 closing.as_str().into(),
861 status.into(),
862 (amount as f64).into(),
863 crate::optional(payment),
864 crate::optional(error),
865 now.as_str().into(),
866 ])
867 };
868 let payer = self.account_of(&account.workspace).await?;
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging869 if payer.terms.full_discount() || payer.id.starts_with("ent_") {
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace870 record("skipped", 0, None, None)?.run().await?;
871 continue;
872 }
Usage, Billing settings and prepaid AI credit; fixes from the UX audit873 // AI credit left at the month's end pays only for models: not
874 // money for anything else that is owed (ai.rs).
875 let ai_left = self.models_left_before(&account.workspace, &month_start).await?;
876 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 put877 if owed == 0 {
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace878 record("nothing", 0, None, None)?.run().await?;
879 continue;
880 }
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put881 if !worth_charging(owed, self.plans.min_charge_micros) {
882 // Under the minimum charge: a card payment's fee would be
883 // too much of it. It stays owed and goes on the next
884 // invoice that reaches the minimum.
885 record("carried", owed, None, None)?.run().await?;
886 continue;
887 }
Two limits, real invoices, trust that grows by itself, sales signals888 match self.invoice_workspace(&account.workspace, "month", &closing).await? {
889 Ok(invoice) if invoice.status == "paid" => {
890 record("paid", invoice.amount_micros, Some(&invoice.invoice_id), None)?.run().await?;
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace891 }
Two limits, real invoices, trust that grows by itself, sales signals892 Ok(invoice) => {
893 record("failed", invoice.amount_micros, Some(&invoice.invoice_id), Some("the card was declined"))?.run().await?;
894 }
895 Err(why) => {
896 record("nothing", 0, None, Some(&why))?.run().await?;
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace897 }
898 }
899 }
900 Ok(())
901 }
902
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look903 /// Emails a workspace's owners as it passes 50, 75, 90 and 100% of its
904 /// plan's included usage, its spend limit and g1t's ceiling, once each a
905 /// month; when its card was declined; and when a spend spike paused it.
906 /// The same alerts show in the app (`entitlements`).
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails907 ///
908 /// `users` are the workspaces with usage this month ([`Self::month_users`]).
909 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 workspace910 if self.stripe.is_none() {
911 return Ok(());
912 }
913 let now = rfc3339(now_ms());
914 let month = &now[..7];
915 #[derive(Deserialize)]
916 struct Candidate {
917 workspace: String,
918 }
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails919 let mut candidates = users.clone();
920 candidates.extend(
921 self.db
922 .prepare("SELECT workspace FROM limits WHERE autopay_failed_at IS NOT NULL")
923 .all()
924 .await?
925 .results::<Candidate>()?
926 .into_iter()
927 .map(|candidate| candidate.workspace),
928 );
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace929 #[derive(Deserialize)]
930 struct Told {
931 autopay_failed_at: Option<String>,
932 declined_told_at: Option<String>,
933 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look934 #[derive(Deserialize)]
935 struct Sent {
936 meter: String,
937 level: Option<i64>,
938 }
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails939 for workspace in candidates {
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace940 let told = self
941 .db
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look942 .prepare("SELECT autopay_failed_at, declined_told_at FROM limits WHERE workspace = ?")
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace943 .bind(&[workspace.as_str().into()])?
944 .first::<Told>(None)
945 .await?;
946 let billing = format!("https://g1t.sh/{workspace}/-/billing");
947
948 // 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 kept949 if let Some(Told { autopay_failed_at: Some(failed), declined_told_at }) = &told
950 && 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 look951 let limit = self.limit_of(&workspace).await?;
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace952 let sent = notify(
953 identity,
954 &workspace,
955 &format!("g1t: the card for {workspace} was declined"),
956 &limit.message.clone().unwrap_or_else(|| format!("g1t could not charge the card on file for {workspace}.")),
957 "Update the card",
958 &billing,
959 )
960 .await;
961 if sent {
962 self.db
963 .prepare("UPDATE limits SET declined_told_at = ? WHERE workspace = ?")
964 .bind(&[now.as_str().into(), workspace.as_str().into()])?
965 .run()
966 .await?;
967 }
968 }
969
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look970 // 50, 75, 90 and 100%, once each a month and meter: only the
971 // highest new level is emailed.
972 let alerts = self.alerts_for(&workspace).await?;
973 if alerts.is_empty() {
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace974 continue;
975 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look976 let sent: Vec<Sent> = self
977 .db
978 .prepare("SELECT meter, MAX(level) AS level FROM alerts_sent WHERE workspace = ? AND month = ? GROUP BY meter")
979 .bind(&[workspace.as_str().into(), month.into()])?
980 .all()
981 .await?
982 .results::<Sent>()?;
983 for alert in alerts {
984 let already = sent.iter().find(|s| s.meter == alert.meter).and_then(|s| s.level).unwrap_or(0);
985 if i64::from(alert.level) <= already {
986 continue;
987 }
988 let subject = match alert.meter.as_str() {
989 "included" => format!("g1t: {workspace} has used {}% of its included usage", alert.level),
990 "spend_limit" => format!("g1t: {workspace} has used {}% of its spend limit", alert.level),
991 _ => format!("g1t: {workspace} has used {}% of its usage limit", alert.level),
992 };
Usage, Billing settings and prepaid AI credit; fixes from the UX audit993 // The budget's webhook hears of its alerts too, once each.
994 if alert.meter == "spend_limit"
995 && let Some(url) = self.budget_webhook_of(&workspace).await?
996 {
997 self.post_budget_webhook(&url, &workspace, alert.level, alert.used_micros, alert.limit_micros).await;
998 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look999 if notify(identity, &workspace, &subject, &alert.message, "Open billing", &billing).await {
1000 self.db
1001 .prepare(
1002 "INSERT OR IGNORE INTO alerts_sent (workspace, month, meter, level, sent_at) VALUES (?1, ?2, ?3, ?4, ?5)",
1003 )
1004 .bind(&[workspace.as_str().into(), month.into(), alert.meter.as_str().into(), alert.level.into(), now.as_str().into()])?
1005 .run()
1006 .await?;
1007 }
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace1008 }
1009 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1010 self.tell_spikes(identity).await?;
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace1011 Ok(())
1012 }
1013
Usage limits: unpaid usage can only go so far1014 pub(crate) async fn check_limit(&self, a: CheckLimitArgs) -> Result<Outcome<Limit>> {
1015 Ok(Outcome::Ok(self.limit_of(&a.workspace).await?))
1016 }
1017
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1018 /// The owners' spend limit: anywhere up to what is available without
1019 /// asking; once, up to twice the highest ceiling (`raise_once`), which
1020 /// also raises g1t's ceiling to match.
Usage limits: unpaid usage can only go so far1021 pub(crate) async fn set_spend_limit(&self, a: SetSpendLimitArgs) -> Result<Outcome<Limit>> {
1022 let workspace = a.workspace.to_lowercase();
Merge main (membership, two-factor, GitHub repo roles) into tokens1023 if !a.actor.manages_billing(&workspace) {
Usage limits: unpaid usage can only go so far1024 return Ok(Outcome::fail(
1025 FailureCode::Forbidden,
Merge main (membership, two-factor, GitHub repo roles) into tokens1026 "Only an owner or a billing manager can set the workspace's spend limit.",
Usage limits: unpaid usage can only go so far1027 ));
1028 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1029 let before = self.limit_of(&workspace).await?;
Usage, Billing settings and prepaid AI credit; fixes from the UX audit1030 // A free workspace has no on-demand usage to limit: its ceiling is
1031 // only what it can owe for storage, and no raise applies to it.
1032 if before.trust == Trust::New {
1033 return Ok(Outcome::fail(
1034 FailureCode::Conflict,
1035 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)),
1036 ));
1037 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1038 let mut raising = false;
1039 if let (Some(requested), false) = (a.spend_limit_micros, a.use_full_limit) {
1040 match (before.available_micros, matches!(before.trust, Trust::Internal | Trust::Reviewed)) {
1041 (_, true) | (None, _) => {
1042 if requested < 0 {
1043 return Ok(Outcome::fail(FailureCode::Invalid, "A spend limit cannot be negative."));
1044 }
1045 }
1046 (Some(available), false) => match self_serve(requested, available, before.raise_once_micros, a.raise_once) {
1047 Ok(uses_raise) => raising = uses_raise,
1048 Err(why) => return Ok(Outcome::fail(FailureCode::Invalid, why)),
1049 },
1050 }
Usage limits: unpaid usage can only go so far1051 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1052 let now = rfc3339(now_ms());
Two limits, real invoices, trust that grows by itself, sales signals1053 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 far1054 self.db
1055 .prepare(
Two limits, real invoices, trust that grows by itself, sales signals1056 "INSERT INTO limits (workspace, spend_limit_micros, spend_limit_full, updated_at) VALUES (?1, ?2, ?3, ?4)
1057 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 far1058 )
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1059 .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 far1060 .run()
1061 .await?;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1062 if raising {
1063 let raised = a.spend_limit_micros.unwrap_or_default();
1064 // The ceiling rises with it, once.
1065 self.db
1066 .prepare(
1067 "UPDATE limits SET granted_ceiling_micros = MAX(COALESCE(granted_ceiling_micros, 0), ?2),
1068 raised_at = ?3, updated_at = ?3 WHERE workspace = ?1 AND raised_at IS NULL",
1069 )
1070 .bind(&[workspace.as_str().into(), (raised as f64).into(), now.as_str().into()])?
1071 .run()
1072 .await?;
1073 let account = self.account_of(&workspace).await?;
1074 self.audit(&account.id, "raise_once", &format!("{workspace} used its one-time raise: {}", dollars_plain(raised)), &a.actor.username)
1075 .await?;
1076 }
Usage limits: unpaid usage can only go so far1077 Ok(Outcome::Ok(self.limit_of(&workspace).await?))
1078 }
Usage, Billing settings and prepaid AI credit; fixes from the UX audit1079
1080 /// `set_budget`: the owners' monthly budget on usage after what the
1081 /// plan includes: the spend limit (set as `set_spend_limit` sets it,
1082 /// with the same bounds), which alerts to send, whether usage pauses at
1083 /// 100%, and a webhook told at each alert.
1084 pub(crate) async fn set_budget(&self, a: SetBudgetArgs) -> Result<Outcome<Limit>> {
1085 let workspace = a.workspace.to_lowercase();
Merge main (membership, two-factor, GitHub repo roles) into tokens1086 if !a.actor.manages_billing(&workspace) {
1087 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 audit1088 }
1089 if a.alerts.iter().any(|level| !ALERT_LEVELS.contains(level)) {
1090 return Ok(Outcome::fail(FailureCode::Invalid, "Alerts are at 50, 75, 90 or 100% of the budget."));
1091 }
1092 let webhook = a.webhook.as_deref().map(str::trim).filter(|url| !url.is_empty());
1093 if let Some(url) = webhook
1094 && !webhook_ok(url)
1095 {
1096 return Ok(Outcome::fail(FailureCode::Invalid, "The webhook is an https:// address of your own, at most 500 characters."));
1097 }
1098 if !a.keep_limit {
1099 let set = self
1100 .set_spend_limit(SetSpendLimitArgs {
1101 actor: a.actor.clone(),
1102 workspace: workspace.clone(),
1103 spend_limit_micros: a.amount_micros,
1104 use_full_limit: false,
1105 raise_once: false,
1106 })
1107 .await?;
1108 if let Outcome::Fail(failure) = set {
1109 return Ok(Outcome::Fail(failure));
1110 }
1111 } else if self.limit_of(&workspace).await?.trust == Trust::New {
1112 return Ok(Outcome::fail(FailureCode::Conflict, format!("{workspace} is not on the g1t plan, so it has no budget to alert on.")));
1113 } else {
1114 // The row the alerts are kept on, if the workspace has none yet.
1115 self.db
1116 .prepare("INSERT OR IGNORE INTO limits (workspace, updated_at) VALUES (?, ?)")
1117 .bind(&[workspace.as_str().into(), rfc3339(now_ms()).into()])?
1118 .run()
1119 .await?;
1120 }
1121 let mut levels = a.alerts.clone();
1122 levels.sort_by(|x, y| y.cmp(x));
1123 levels.dedup();
1124 let stored = levels.iter().map(u32::to_string).collect::<Vec<_>>().join(",");
1125 self.db
1126 .prepare(
1127 "UPDATE limits SET alert_levels = ?2, pause_at_limit = ?3, budget_webhook = ?4, updated_at = ?5 WHERE workspace = ?1",
1128 )
1129 .bind(&[
1130 workspace.as_str().into(),
1131 stored.as_str().into(),
1132 i32::from(a.pause_at_limit).into(),
1133 crate::optional(webhook),
1134 rfc3339(now_ms()).into(),
1135 ])?
1136 .run()
1137 .await?;
1138 let account = self.account_of(&workspace).await?;
1139 self.audit(
1140 &account.id,
1141 "budget",
1142 &format!(
1143 "{workspace}: budget {}, alerts at {}, {}{}",
1144 a.amount_micros.map_or_else(|| "automatic".to_owned(), dollars_plain),
1145 if stored.is_empty() { "none".to_owned() } else { format!("{stored}%") },
1146 if a.pause_at_limit { "pauses usage at 100%" } else { "alerts only" },
1147 if webhook.is_some() { ", with a webhook" } else { "" }
1148 ),
1149 &a.actor.username,
1150 )
1151 .await?;
1152 Ok(Outcome::Ok(self.limit_of(&workspace).await?))
1153 }
1154
1155
1156 /// The budget's webhook, if the workspace has one.
1157 async fn budget_webhook_of(&self, workspace: &str) -> Result<Option<String>> {
1158 #[derive(Deserialize)]
1159 struct Row {
1160 budget_webhook: Option<String>,
1161 }
1162 Ok(self
1163 .db
1164 .prepare("SELECT budget_webhook FROM limits WHERE workspace = ?")
1165 .bind(&[workspace.into()])?
1166 .first::<Row>(None)
1167 .await?
1168 .and_then(|row| row.budget_webhook)
1169 .filter(|url| webhook_ok(url)))
1170 }
1171 /// Posts a budget alert to the workspace's webhook, if it has one.
1172 /// Never fails the alert: a receiver that does not answer is logged.
1173 pub(crate) async fn post_budget_webhook(&self, url: &str, workspace: &str, level: u32, spent: i64, budget: i64) {
1174 let body = serde_json::json!({
1175 "event": "budget.alert",
1176 "workspace": workspace,
1177 "level_percent": level,
1178 "spent_micros": spent,
1179 "budget_micros": budget,
1180 "sent_at": rfc3339(now_ms()),
1181 });
1182 let headers = worker::Headers::new();
1183 let _ = headers.set("content-type", "application/json");
1184 let _ = headers.set("user-agent", "g1t-billing");
1185 let mut init = worker::RequestInit::new();
1186 init.with_method(worker::Method::Post).with_headers(headers).with_body(Some(body.to_string().into()));
1187 let sent = match worker::Request::new_with_init(url, &init) {
1188 Ok(request) => worker::Fetch::Request(request).send().await.map(|r| r.status_code()),
1189 Err(error) => Err(error),
1190 };
1191 match sent {
1192 Ok(status) if (200..300).contains(&status) => {}
1193 Ok(status) => worker::console_warn!("{workspace}'s budget webhook answered {status}"),
1194 Err(error) => worker::console_warn!("{workspace}'s budget webhook could not be reached: {error}"),
1195 }
1196 }
Usage limits: unpaid usage can only go so far1197}
1198
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1199/// Whether `owed` is enough to charge a card when a month closes: at least
1200/// the minimum charge (`MIN_CHARGE_MICROS`). Less carries over to the next
1201/// 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 put1202pub(crate) fn worth_charging(owed: i64, min_charge: i64) -> bool {
1203 owed > 0 && owed >= min_charge
1204}
1205
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace1206/// 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 look1207pub(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 charging1208 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";
1209 notify_with(identity, workspace, subject, intro, action, link, footer).await
1210}
1211
1212/// `notify`, with a footer of its own.
1213pub(crate) async fn notify_with(
1214 identity: &worker::Fetcher,
1215 workspace: &str,
1216 subject: &str,
1217 intro: &str,
1218 action: &str,
1219 link: &str,
1220 footer: &str,
1221) -> bool {
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace1222 let args = g1t_contracts::identity::NotifyOwnersArgs {
1223 workspace: workspace.to_owned(),
1224 subject: subject.to_owned(),
1225 intro: intro.to_owned(),
1226 action: action.to_owned(),
1227 link: link.to_owned(),
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging1228 footer: footer.to_owned(),
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace1229 };
1230 match g1t_kit::call::<_, u32>(identity, "notify_owners", &args).await {
1231 Ok(sent) => sent > 0,
1232 Err(error) => {
1233 worker::console_error!("could not tell {workspace}'s owners: {error}");
1234 false
1235 }
1236 }
1237}
1238
1239/// `2026-09` for `2026-10`, and `2025-12` for `2026-01`.
1240pub(crate) fn previous_month(month: &str) -> String {
1241 let year: i32 = month[..4].parse().unwrap_or(1970);
1242 let number: u32 = month[5..7].parse().unwrap_or(1);
1243 if number == 1 {
1244 format!("{}-12", year - 1)
1245 } else {
1246 format!("{year}-{:02}", number - 1)
1247 }
1248}
1249
Usage limits: unpaid usage can only go so far1250#[cfg(test)]
1251mod tests {
1252 use super::*;
1253
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace1254 #[test]
Usage, Billing settings and prepaid AI credit; fixes from the UX audit1255 fn a_budget_alerts_at_the_levels_chosen_and_pauses_only_if_asked() {
1256 // Every level when none were chosen; the chosen ones, highest first.
1257 assert_eq!(alert_levels(None), [100, 90, 75, 50]);
1258 assert_eq!(alert_levels(Some("50,100,75")), [100, 75, 50]);
1259 assert_eq!(alert_levels(Some("")), Vec::<u32>::new());
1260 assert_eq!(alert_levels(Some("40, 50")), [50]);
1261 // The highest chosen level reached.
1262 assert_eq!(alert_level_in(80, 100, &[100, 75, 50]), 75);
1263 assert_eq!(alert_level_in(80, 100, &[100, 90]), 0);
1264 assert_eq!(alert_level_in(100, 100, &[100, 75, 50]), 100);
1265 assert_eq!(alert_level_in(10, 0, &[50]), 0);
1266 // At 100%: a stop when the budget pauses usage, else a warning.
1267 assert_eq!(budget_state(100, Some(100), true), LimitState::Stopped);
1268 assert_eq!(budget_state(100, Some(100), false), LimitState::Warning);
1269 assert_eq!(budget_state(10, Some(100), false), LimitState::Ok);
1270 assert_eq!(budget_state(10, None, true), LimitState::Ok);
1271 }
1272
1273 #[test]
1274 fn a_budget_webhook_is_someone_elses_https_address() {
1275 assert!(webhook_ok("https://hooks.acme.test/g1t"));
1276 assert!(!webhook_ok("http://hooks.acme.test/g1t"));
1277 assert!(!webhook_ok("https://g1t.sh/x"));
1278 assert!(!webhook_ok("https://api.g1t.sh/x"));
1279 assert!(!webhook_ok("https://localhost:3000/x"));
1280 assert!(!webhook_ok("https://a b.test/"));
1281 assert!(!webhook_ok(&format!("https://acme.test/{}", "x".repeat(600))));
1282 }
1283 #[test]
Two limits, real invoices, trust that grows by itself, sales signals1284 fn the_automatic_spend_limit_follows_last_month() {
1285 assert_eq!(automatic_spend_limit(0), 200_000_000);
1286 assert_eq!(automatic_spend_limit(50_000_000), 200_000_000);
1287 assert_eq!(automatic_spend_limit(900_000_000), 1_800_000_000);
1288 }
1289
1290 #[test]
1291 fn three_steady_months_make_a_workspace_established() {
1292 assert_eq!(established_ceiling(&[900_000_000, 850_000_000, 950_000_000]), Some(2_700_000_000));
1293 assert_eq!(established_ceiling(&[5_000_000_000, 5_000_000_000, 5_000_000_000]), Some(10_000_000_000));
1294 assert_eq!(established_ceiling(&[900_000_000, 10_000_000, 950_000_000]), None);
1295 assert_eq!(established_ceiling(&[900_000_000, 900_000_000]), None);
1296 }
1297
1298 #[test]
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1299 fn alerts_come_at_half_three_quarters_ninety_and_the_limit() {
1300 assert_eq!(alert_level(0, 10_000_000), 0);
1301 assert_eq!(alert_level(4_999_999, 10_000_000), 0);
1302 assert_eq!(alert_level(5_000_000, 10_000_000), 50);
1303 assert_eq!(alert_level(7_500_000, 10_000_000), 75);
1304 assert_eq!(alert_level(8_999_999, 10_000_000), 75);
1305 assert_eq!(alert_level(9_000_000, 10_000_000), 90);
1306 assert_eq!(alert_level(10_000_000, 10_000_000), 100);
1307 assert_eq!(alert_level(25_000_000, 10_000_000), 100);
1308 // Nothing to measure against: no alert.
1309 assert_eq!(alert_level(5, 0), 0);
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace1310 }
1311
1312 #[test]
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1313 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 put1314 let min = 5_000_000;
1315 assert!(!worth_charging(0, min));
1316 assert!(!worth_charging(4_990_000, min));
1317 assert!(worth_charging(5_000_000, min));
1318 assert!(worth_charging(12_000_000, min));
1319 // $3 carried from last month and $2.50 this month: charged together.
1320 let carried = 3_000_000;
1321 assert!(!worth_charging(carried, min));
1322 assert!(worth_charging(carried + 2_500_000, min));
1323 }
1324
1325 #[test]
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace1326 fn the_month_before_wraps_the_year() {
1327 assert_eq!(previous_month("2026-10"), "2026-09");
1328 assert_eq!(previous_month("2026-01"), "2025-12");
1329 }
1330
Usage limits: unpaid usage can only go so far1331 fn ceilings() -> Ceilings {
Billing accounts, terms and enterprises; g1t is no longer free1332 Ceilings { new: 3_000_000, paid_min: 25_000_000, paid_max: 1_000_000_000 }
Usage limits: unpaid usage can only go so far1333 }
1334
1335 #[test]
1336 fn trust_grows_with_what_was_paid_within_bounds() {
1337 assert_eq!(ceilings().for_paid(5_000_000), 25_000_000);
1338 assert_eq!(ceilings().for_paid(100_000_000), 200_000_000);
1339 assert_eq!(ceilings().for_paid(10_000_000_000), 1_000_000_000);
1340 }
1341
1342 #[test]
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1343 fn a_new_paid_workspace_starts_at_a_hundred_dollars_and_only_goes_up() {
1344 let start = 100_000_000;
1345 // The first month: the starting ceiling, whatever was paid.
1346 assert_eq!(paid_ceiling(&ceilings(), start, true, 900_000_000, None), start);
1347 // After it, with little paid: never below the start.
1348 assert_eq!(paid_ceiling(&ceilings(), start, false, 20_000_000, None), start);
1349 assert_eq!(paid_ceiling(&ceilings(), start, false, 0, None), start);
1350 // Payments that cleared raise it: twice what was paid.
1351 assert_eq!(paid_ceiling(&ceilings(), start, false, 300_000_000, None), 600_000_000);
1352 // Established follows the monthly spend.
1353 assert_eq!(paid_ceiling(&ceilings(), start, false, 300_000_000, Some(2_700_000_000)), 2_700_000_000);
1354 }
1355
1356 #[test]
1357 fn the_first_billing_cycle_is_the_first_month() {
1358 // A plan that started on the 5th, paid through the 5th of next month.
1359 assert!(in_first_cycle("2026-10-05T10:00:00Z", Some("2026-11-05T10:00:00Z"), "2026-10-20T00:00:00Z"));
1360 // Renewed: the period now ends two months after the start.
1361 assert!(!in_first_cycle("2026-10-05T10:00:00Z", Some("2026-12-05T10:00:00Z"), "2026-11-20T00:00:00Z"));
1362 // No period known yet: the first 31 days.
1363 assert!(in_first_cycle("2026-10-05T10:00:00Z", None, "2026-11-04T00:00:00Z"));
1364 assert!(!in_first_cycle("2026-10-05T10:00:00Z", None, "2026-11-10T00:00:00Z"));
1365 // Day counting is exact across months and years.
1366 assert_eq!(days("1970-01-01"), 0);
1367 assert_eq!(days("2026-11-01") - days("2026-10-01"), 31);
1368 assert_eq!(days("2028-03-01") - days("2028-02-28"), 2);
1369 assert_eq!(days("2027-01-01") - days("2026-12-31"), 1);
1370 }
1371
1372 #[test]
1373 fn owners_set_their_limit_up_to_the_highest_ceiling_without_asking() {
1374 // First month at $100; the highest ever is $100.
1375 let (available, once) = spend_bounds(100_000_000, 100_000_000, 0, false);
1376 assert_eq!(available, 100_000_000);
1377 assert_eq!(once, Some(200_000_000));
1378 assert_eq!(self_serve(80_000_000, available, once, false), Ok(false));
1379 assert_eq!(self_serve(100_000_000, available, once, false), Ok(false));
1380 // Above it without the raise: refused, saying what to do.
1381 assert!(self_serve(150_000_000, available, once, false).unwrap_err().contains("one-time raise"));
1382 // With the raise: up to twice the highest ceiling, once.
1383 assert_eq!(self_serve(200_000_000, available, once, true), Ok(true));
1384 assert!(self_serve(200_000_001, available, once, true).unwrap_err().contains("Raise my limit"));
1385 // Once used, it is gone.
1386 let (available, once) = spend_bounds(200_000_000, 200_000_000, 0, true);
1387 assert_eq!(once, None);
1388 assert_eq!(self_serve(200_000_000, available, once, false), Ok(false));
1389 assert!(self_serve(300_000_000, available, once, true).unwrap_err().contains("is used"));
1390 // A ceiling that came down still leaves the highest one available.
1391 let (available, _) = spend_bounds(100_000_000, 400_000_000, 0, true);
1392 assert_eq!(available, 400_000_000);
1393 assert!(self_serve(-1, available, None, false).is_err());
1394 }
1395
1396 #[test]
1397 fn prepaying_raises_what_can_be_used_at_once() {
1398 // $500 prepaid this month, $120 used: nothing owed, $380 left.
1399 assert_eq!(exposure(120_000_000, 500_000_000, 0), (0, 380_000_000));
1400 // Prepaid last month and carried in.
1401 assert_eq!(exposure(120_000_000, 0, 500_000_000), (0, 380_000_000));
1402 // Used past the prepayment: the rest is owed.
1403 assert_eq!(exposure(620_000_000, 500_000_000, 0), (120_000_000, 0));
1404 // Owed from before adds to this month's.
1405 assert_eq!(exposure(10_000_000, 0, -4_000_000), (14_000_000, 0));
1406 // With a $100 ceiling and $500 prepaid, work stops at $600 of use,
1407 // not at $100.
1408 let ceiling = 100_000_000;
1409 assert_eq!(state(exposure(599_000_000, 500_000_000, 0).0, Some(ceiling)), LimitState::Warning);
1410 assert_eq!(state(exposure(600_000_000, 500_000_000, 0).0, Some(ceiling)), LimitState::Stopped);
1411 // And the owners may set their spend limit that much higher.
1412 assert_eq!(spend_bounds(ceiling, ceiling, 500_000_000, true).0, 600_000_000);
1413 }
1414
1415 #[test]
Usage limits: unpaid usage can only go so far1416 fn work_warns_at_eighty_percent_and_stops_at_the_ceiling() {
1417 assert_eq!(state(0, Some(100)), LimitState::Ok);
1418 assert_eq!(state(79, Some(100)), LimitState::Ok);
1419 assert_eq!(state(80, Some(100)), LimitState::Warning);
1420 assert_eq!(state(100, Some(100)), LimitState::Stopped);
1421 assert_eq!(state(1_000_000, None), LimitState::Ok);
1422 }
1423}

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