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 look | 1 | //! How far a workspace can run up costs g1t has not been paid for, and how |
| 2 | //! far its owners let it spend. | |
| The logo stands on the baseline beside its name | 3 | //! |
| 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 look | 6 | //! it. So each workspace has a ceiling on that unpaid usage: |
| The logo stands on the baseline beside its name | 7 | //! |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 8 | //! - **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 pays | 17 | //! - **Internal**: g1t's own workspaces, with none here: what their work |
| 18 | //! costs g1t has a monthly budget instead (see `budget`). | |
| The logo stands on the baseline beside its name | 19 | //! |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 20 | //! 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 look | 29 | //! 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 | |
| The logo stands on the baseline beside its name | 38 | //! 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 look | 41 | //! more. Test-mode payments are not money, so they do not raise trust. |
| The logo stands on the baseline beside its name | 42 | |
| Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails | 43 | use 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. | 45 | use 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 look | 46 | use g1t_contracts::billing::{ |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 47 | 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 look | 48 | }; |
| The logo stands on the baseline beside its name | 49 | use g1t_contracts::time::rfc3339; |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 50 | use g1t_contracts::{FailureCode, Outcome}; |
| The logo stands on the baseline beside its name | 51 | use g1t_kit::now_ms; |
| 52 | use serde::Deserialize; | |
| 53 | use worker::wasm_bindgen::JsValue; | |
| 54 | use 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. | 56 | use crate::charged::{PendingLine, PendingSplit, month_totals, pending_split}; |
| The logo stands on the baseline beside its name | 57 | use crate::features::dollars as dollars_plain; |
| 58 | use 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)] | |
| 62 | struct 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 | ||
| The logo stands on the baseline beside its name | 73 | /// The ceilings, from the billing service's variables. |
| 74 | pub(crate) struct Ceilings { | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 75 | /// `LIMIT_NEW_MICROS`: a free workspace's. |
| The logo stands on the baseline beside its name | 76 | pub new: i64, |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 77 | /// `LIMIT_PAID_MIN_MICROS` and `LIMIT_PAID_MAX_MICROS`: the bounds of a |
| 78 | /// paid workspace's, from what it has paid. | |
| The logo stands on the baseline beside its name | 79 | pub paid_min: i64, |
| 80 | pub paid_max: i64, | |
| 81 | } | |
| 82 | ||
| 83 | impl 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. | |
| 102 | pub(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 signals | 110 | /// The automatic monthly spend limit's floor: $200. |
| 111 | pub(crate) const DEFAULT_SPEND_MICROS: i64 = 200_000_000; | |
| 112 | /// Established workspaces' ceiling: three times their steady monthly | |
| 113 | /// spend, up to $10,000. | |
| 114 | const ESTABLISHED_FACTOR: i64 = 3; | |
| 115 | const ESTABLISHED_MAX_MICROS: i64 = 10_000_000_000; | |
| 116 | /// A month counts toward Established at this much spend or more. | |
| 117 | const 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 look | 120 | pub(crate) const SETTLE_DAYS: u64 = 7; |
| Two limits, real invoices, trust that grows by itself, sales signals | 121 | |
| 122 | /// The automatic spend limit: $200, or twice last month's spend. | |
| 123 | pub(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 free | 126 | |
| Two limits, real invoices, trust that grows by itself, sales signals | 127 | /// An Established workspace's ceiling, from its last three months' |
| 128 | /// charges, if each was steady enough. | |
| 129 | pub(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 look | 137 | /// Days since 1970-01-01 of a `YYYY-MM-DD…` date, for comparing dates |
| 138 | /// without a clock (Howard Hinnant's days-from-civil). | |
| 139 | pub(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. | |
| 156 | pub(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. | |
| 166 | pub(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. | |
| 178 | pub(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. | |
| 187 | pub(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). | |
| 213 | pub(crate) fn alert_level(used: i64, limit: i64) -> u32 { | |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 214 | alert_level_in(used, limit, &ALERT_LEVELS) |
| 215 | } | |
| 216 | ||
| 217 | /// Every alert a budget can have, highest first. | |
| 218 | pub(crate) const ALERT_LEVELS: [u32; 4] = [100, 90, 75, 50]; | |
| 219 | ||
| 220 | /// The highest of `levels` a measure has reached, or 0. | |
| 221 | pub(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 look | 222 | if limit <= 0 || used <= 0 { |
| 223 | return 0; | |
| 224 | } | |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 225 | 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. | |
| 230 | pub(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. | |
| 240 | pub(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 look | 244 | } |
| 245 | } | |
| 246 | ||
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 247 | /// Whether a budget webhook is an address g1t will post to: HTTPS, not |
| 248 | /// g1t's own, at most 500 characters. | |
| 249 | pub(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 look | 258 | /// 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). | |
| 261 | pub(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 | ||
| The logo stands on the baseline beside its name | 268 | #[derive(Deserialize)] |
| 269 | struct LimitRow { | |
| 270 | spend_limit_micros: Option<i64>, | |
| Two limits, real invoices, trust that grows by itself, sales signals | 271 | #[serde(default)] |
| 272 | spend_limit_full: Option<i64>, | |
| Billing accounts, terms and enterprises; g1t is no longer free | 273 | 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 look | 275 | #[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 audit | 281 | #[serde(default)] |
| 282 | alert_levels: Option<String>, | |
| 283 | #[serde(default)] | |
| 284 | pause_at_limit: Option<i64>, | |
| 285 | #[serde(default)] | |
| 286 | budget_webhook: Option<String>, | |
| The logo stands on the baseline beside its name | 287 | } |
| 288 | ||
| 289 | #[derive(Deserialize)] | |
| 290 | struct Month { | |
| 291 | used: Option<i64>, | |
| 292 | paid: Option<i64>, | |
| 293 | } | |
| 294 | ||
| 295 | #[derive(Deserialize)] | |
| 296 | struct Paid { | |
| 297 | paid: Option<i64>, | |
| 298 | } | |
| 299 | ||
| 300 | impl Billing { | |
| Billing accounts, terms and enterprises; g1t is no longer free | 301 | /// 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. | |
| The logo stands on the baseline beside its name | 304 | 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 free | 306 | 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 index | 307 | 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 look | 315 | let now = rfc3339(now_ms()); |
| 316 | let month_start = format!("{}-01", &now[..7]); | |
| Billing accounts, terms and enterprises; g1t is no longer free | 317 | 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 index | 320 | 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 audit | 324 | 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 index | 325 | 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 free | 331 | let mut with_month = members.clone(); |
| 332 | with_month.push(month_start.as_str().into()); | |
| The logo stands on the baseline beside its name | 333 | // 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 look | 334 | // 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 index | 338 | 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 | })); | |
| The logo stands on the baseline beside its name | 369 | // 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 look | 371 | // 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 workspace | 375 | 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 index | 377 | 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 charging | 398 | _ 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 index | 399 | _ 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 free | 417 | } |
| 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 index | 418 | }) |
| The logo stands on the baseline beside its name | 419 | }; |
| 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 index | 423 | // 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 index | 434 | 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 index | 436 | 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 look | 438 | // 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 signals | 466 | // 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 free | 477 | // 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 signals | 480 | // 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 audit | 482 | // 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 signals | 486 | 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 kept | 487 | let state = if (declined.is_some() && exposure > 0) || risk == LimitState::Stopped || over_budget { |
| Two limits, real invoices, trust that grows by itself, sales signals | 488 | LimitState::Stopped |
| 489 | } else if risk == LimitState::Warning || budget == LimitState::Warning { | |
| 490 | LimitState::Warning | |
| 491 | } else { | |
| 492 | LimitState::Ok | |
| The logo stands on the baseline beside its name | 493 | }; |
| Billing accounts, terms and enterprises; g1t is no longer free | 494 | 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 look | 499 | let billing = format!("/{workspace}/-/billing"); |
| The logo stands on the baseline beside its name | 500 | let message = match state { |
| 501 | LimitState::Ok => None, | |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 502 | 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 signals | 507 | LimitState::Warning if budget == LimitState::Warning => Some(format!( |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 508 | "{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 signals | 509 | dollars_plain(spent), |
| 510 | dollars_plain(spend_limit.unwrap_or_default()), | |
| 511 | )), | |
| The logo stands on the baseline beside its name | 512 | LimitState::Warning => Some(format!( |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 513 | "{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.", |
| The logo stands on the baseline beside its name | 514 | dollars_plain(exposure), |
| 515 | dollars_plain(ceiling.unwrap_or_default()), | |
| 516 | )), | |
| Billing accounts, terms and enterprises; g1t is no longer free | 517 | LimitState::Stopped if declined.is_some() => Some(format!( |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 518 | "{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 free | 519 | 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 signals | 521 | LimitState::Stopped => Some(if over_budget { |
| The logo stands on the baseline beside its name | 522 | format!( |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 523 | "{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 signals | 524 | dollars_plain(spend_limit.unwrap_or_default()), |
| The logo stands on the baseline beside its name | 525 | ) |
| 526 | } else { | |
| 527 | format!( | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 528 | "{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}.", |
| The logo stands on the baseline beside its name | 529 | dollars_plain(ceiling.unwrap_or_default()), |
| 530 | ) | |
| 531 | }), | |
| 532 | }; | |
| Two limits, real invoices, trust that grows by itself, sales signals | 533 | let growth = match trust { |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 534 | 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 signals | 539 | Trust::Paid => Some(format!( |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 540 | "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 signals | 541 | )), |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 542 | 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 signals | 543 | Trust::Reviewed | Trust::Internal => None, |
| 544 | }; | |
| The logo stands on the baseline beside its name | 545 | 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 index | 547 | account: account.id.clone(), |
| 548 | account_name: account.name.clone(), | |
| Two limits, real invoices, trust that grows by itself, sales signals | 549 | spent_micros: spent, |
| 550 | default_spend_limit, | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 551 | available_micros: available, |
| Two limits, real invoices, trust that grows by itself, sales signals | 552 | growth, |
| The logo stands on the baseline beside its name | 553 | 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 look | 560 | 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 audit | 565 | 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 look | 568 | }) |
| 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, | |
| The logo stands on the baseline beside its name | 576 | }) |
| 577 | } | |
| 578 | ||
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 579 | /// 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> { | |
| The logo stands on the baseline beside its name | 583 | 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 free | 586 | let marks = vec!["?"; members.len().max(1)].join(", "); |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 587 | let settled = rfc3339(now_ms() - SETTLE_DAYS * 24 * 60 * 60 * 1000); |
| The logo stands on the baseline beside its name | 588 | Ok(self |
| 589 | .db | |
| Billing accounts, terms and enterprises; g1t is no longer free | 590 | .prepare(format!( |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 591 | "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 free | 599 | )) |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 600 | .bind(&[members, members].concat())? |
| The logo stands on the baseline beside its name | 601 | .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 signals | 611 | #[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 signals | 617 | } |
| 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 signals | 628 | -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 signals | 631 | )) |
| 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 signals | 634 | .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 signals | 642 | } |
| 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 index | 660 | 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 signals | 679 | } |
| 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 index | 680 | let read_month = |month: &String| { |
| Two limits, real invoices, trust that grows by itself, sales signals | 681 | 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 index | 686 | 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 signals | 703 | 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 | ||
| The logo stands on the baseline beside its name | 712 | /// 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 look | 735 | /// 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 pays | 738 | pub(crate) async fn note_pending(&self, a: NotePendingArgs) -> Result<bool> { |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 739 | // 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 pays | 744 | let now = rfc3339(now_ms()); |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 745 | 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 pays | 747 | Ok(true) |
| 748 | } | |
| 749 | ||
| Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails | 750 | /// 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 free | 771 | /// 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 look | 775 | /// 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-guardrails | 777 | /// |
| 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 signals | 780 | if !self.stripe.as_ref().is_some_and(crate::stripe::Stripe::live) { |
| Billing accounts, terms and enterprises; g1t is no longer free | 781 | return Ok(()); |
| Two limits, real invoices, trust that grows by itself, sales signals | 782 | } |
| Billing accounts, terms and enterprises; g1t is no longer free | 783 | #[derive(Deserialize)] |
| 784 | struct Candidate { | |
| 785 | workspace: String, | |
| 786 | } | |
| Two limits, real invoices, trust that grows by itself, sales signals | 787 | // 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 free | 789 | let candidates = self |
| 790 | .db | |
| 791 | .prepare( | |
| Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails | 792 | "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 free | 795 | ) |
| 796 | .all() | |
| 797 | .await? | |
| Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails | 798 | .results::<Candidate>()? |
| 799 | .into_iter() | |
| 800 | .filter(|candidate| users.contains(&candidate.workspace)); | |
| Billing accounts, terms and enterprises; g1t is no longer free | 801 | for candidate in candidates { |
| 802 | let limit = self.limit_of(&candidate.workspace).await?; | |
| Two limits, real invoices, trust that grows by itself, sales signals | 803 | // 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 workspace | 807 | continue; |
| 808 | } | |
| Two limits, real invoices, trust that grows by itself, sales signals | 809 | 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 free | 813 | } |
| 814 | } | |
| 815 | Ok(()) | |
| 816 | } | |
| 817 | ||
| Billing on Stripe's pages, month-end charges, warnings; sudo by workspace | 818 | /// 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 look | 821 | /// 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 workspace | 823 | pub(crate) async fn close_months(&self) -> Result<()> { |
| Two limits, real invoices, trust that grows by itself, sales signals | 824 | if !self.stripe.as_ref().is_some_and(crate::stripe::Stripe::live) { |
| Billing on Stripe's pages, month-end charges, warnings; sudo by workspace | 825 | return Ok(()); |
| Two limits, real invoices, trust that grows by itself, sales signals | 826 | } |
| Billing on Stripe's pages, month-end charges, warnings; sudo by workspace | 827 | 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 signals | 838 | "SELECT accounts.workspace AS workspace, |
| Billing on Stripe's pages, month-end charges, warnings; sudo by workspace | 839 | (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 charging | 869 | if payer.terms.full_discount() || payer.id.starts_with("ent_") { |
| Billing on Stripe's pages, month-end charges, warnings; sudo by workspace | 870 | record("skipped", 0, None, None)?.run().await?; |
| 871 | continue; | |
| 872 | } | |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 873 | // 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 put | 877 | if owed == 0 { |
| Billing on Stripe's pages, month-end charges, warnings; sudo by workspace | 878 | 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 put | 881 | 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 signals | 888 | 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 workspace | 891 | } |
| Two limits, real invoices, trust that grows by itself, sales signals | 892 | 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 workspace | 897 | } |
| 898 | } | |
| 899 | } | |
| 900 | Ok(()) | |
| 901 | } | |
| 902 | ||
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 903 | /// 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-guardrails | 907 | /// |
| 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 workspace | 910 | 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-guardrails | 919 | 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 workspace | 929 | #[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 look | 934 | #[derive(Deserialize)] |
| 935 | struct Sent { | |
| 936 | meter: String, | |
| 937 | level: Option<i64>, | |
| 938 | } | |
| Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails | 939 | for workspace in candidates { |
| Billing on Stripe's pages, month-end charges, warnings; sudo by workspace | 940 | let told = self |
| 941 | .db | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 942 | .prepare("SELECT autopay_failed_at, declined_told_at FROM limits WHERE workspace = ?") |
| Billing on Stripe's pages, month-end charges, warnings; sudo by workspace | 943 | .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 kept | 949 | 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 look | 951 | let limit = self.limit_of(&workspace).await?; |
| Billing on Stripe's pages, month-end charges, warnings; sudo by workspace | 952 | 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 look | 970 | // 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 workspace | 974 | continue; |
| 975 | } | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 976 | 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 audit | 993 | // 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 look | 999 | 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 workspace | 1008 | } |
| 1009 | } | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 1010 | self.tell_spikes(identity).await?; |
| Billing on Stripe's pages, month-end charges, warnings; sudo by workspace | 1011 | Ok(()) |
| 1012 | } | |
| 1013 | ||
| The logo stands on the baseline beside its name | 1014 | 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 look | 1018 | /// 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. | |
| The logo stands on the baseline beside its name | 1021 | 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 tokens | 1023 | if !a.actor.manages_billing(&workspace) { |
| The logo stands on the baseline beside its name | 1024 | return Ok(Outcome::fail( |
| 1025 | FailureCode::Forbidden, | |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 1026 | "Only an owner or a billing manager can set the workspace's spend limit.", |
| The logo stands on the baseline beside its name | 1027 | )); |
| 1028 | } | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 1029 | let before = self.limit_of(&workspace).await?; |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 1030 | // 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 look | 1038 | 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 | } | |
| The logo stands on the baseline beside its name | 1051 | } |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 1052 | let now = rfc3339(now_ms()); |
| Two limits, real invoices, trust that grows by itself, sales signals | 1053 | let limit = if a.use_full_limit { JsValue::NULL } else { a.spend_limit_micros.map_or(JsValue::NULL, |limit| (limit as f64).into()) }; |
| The logo stands on the baseline beside its name | 1054 | self.db |
| 1055 | .prepare( | |
| Two limits, real invoices, trust that grows by itself, sales signals | 1056 | "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", | |
| The logo stands on the baseline beside its name | 1058 | ) |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 1059 | .bind(&[workspace.as_str().into(), limit, (if a.use_full_limit { 1 } else { 0 }).into(), now.as_str().into()])? |
| The logo stands on the baseline beside its name | 1060 | .run() |
| 1061 | .await?; | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 1062 | 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 | } | |
| The logo stands on the baseline beside its name | 1077 | Ok(Outcome::Ok(self.limit_of(&workspace).await?)) |
| 1078 | } | |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 1079 | |
| 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 tokens | 1086 | 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 audit | 1088 | } |
| 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 | } | |
| The logo stands on the baseline beside its name | 1197 | } |
| 1198 | ||
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 1199 | /// 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 put | 1202 | pub(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 workspace | 1206 | /// 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 look | 1207 | pub(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 charging | 1208 | 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. | |
| 1213 | pub(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 workspace | 1222 | 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 charging | 1228 | footer: footer.to_owned(), |
| Billing on Stripe's pages, month-end charges, warnings; sudo by workspace | 1229 | }; |
| 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`. | |
| 1240 | pub(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 | ||
| The logo stands on the baseline beside its name | 1250 | #[cfg(test)] |
| 1251 | mod tests { | |
| 1252 | use super::*; | |
| 1253 | ||
| Billing on Stripe's pages, month-end charges, warnings; sudo by workspace | 1254 | #[test] |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 1255 | 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 signals | 1284 | 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 look | 1299 | 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 workspace | 1310 | } |
| 1311 | ||
| 1312 | #[test] | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 1313 | 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 put | 1314 | 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 workspace | 1326 | 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 | ||
| The logo stands on the baseline beside its name | 1331 | fn ceilings() -> Ceilings { |
| Billing accounts, terms and enterprises; g1t is no longer free | 1332 | Ceilings { new: 3_000_000, paid_min: 25_000_000, paid_max: 1_000_000_000 } |
| The logo stands on the baseline beside its name | 1333 | } |
| 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 look | 1343 | 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] | |
| The logo stands on the baseline beside its name | 1416 | 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.