g1t/services/billing/src/credits.rs

770 lines33,469 bytesCodeBlame

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

Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put1//! What pays for usage before the workspace does.
2//!
3//! Every charge is worked out the same way: its cost plus the margin, then
4//! the account's terms. What is left is drawn down, in this order, from:
5//!
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look6//! 1. **The plan's included usage** (`PLAN_INCLUDED_MICROS` a month, $10),
7//! when the workspace has the g1t plan. Any usage draws on it. Unused
8//! included usage does not roll over.
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put9//! 2. **The trial credit**: one grant per workspace
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look10//! (`TRIAL_WORKSPACE_MICROS`, $5), made once its card is checked (see
11//! `cards`), out of a pool for everyone that resets each calendar month
12//! (`TRIAL_MONTHLY_POOL_MICROS`, $100). Never for deployments.
13//! 3. **g1t's open-source pool** (`OSS_POOL_MICROS` a month, $25, at most
14//! `OSS_REPO_MICROS`, $2, for any one repository): checks, workflows and
15//! the merge queue on a public repository.
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put16//!
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look17//! Whatever is left is charged: from what was paid in advance first, since
18//! a charge comes off the balance, and then owed. For a free workspace's
19//! compute, what is left past its trial is covered by g1t (`given`): a free
20//! workspace is never charged for compute, and `reserve` keeps that to the
21//! runs already in flight when the trial ran out.
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put22//!
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look23//! Each source is a fixed, capped budget that something pays for: the
24//! plan, or g1t. Nothing here is an open-ended allowance per workspace.
25//!
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put26//! Months are calendar months in UTC, the same as the limits'. Every draw
27//! is one D1 batch, which runs as a transaction, so two charges at once
28//! never take more than a budget holds.
29
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 index30use g1t_contracts::billing::{BillingAccount, ComputeKind, Feature, PlanKind, Pools, TermsKind, Trial, TrialArgs};
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put31use g1t_contracts::time::rfc3339;
32use g1t_kit::now_ms;
33use serde::Deserialize;
34use worker::{Env, Result};
35
36use crate::Billing;
37use crate::features::dollars;
38
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look39/// Every number of the plan and the pools, from the billing service's
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put40/// variables, each with its default.
41#[derive(Clone, Debug)]
42pub(crate) struct Config {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look43 /// `PLAN_MONTHLY_CENTS`: the plan's price, per workspace: $20.
44 pub plan_monthly_cents: u32,
45 /// `PLAN_INCLUDED_MICROS`: its included usage each month: $10.
46 pub plan_included_micros: i64,
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put47 /// `OSS_POOL_MICROS`: g1t's open-source pool each month, in all.
48 pub oss_pool_micros: i64,
49 /// `OSS_REPO_MICROS`: any one public repository's share of it.
50 pub oss_repo_micros: i64,
51 /// `TRIAL_WORKSPACE_MICROS`: each new workspace's trial credit.
52 pub trial_workspace_micros: i64,
53 /// `TRIAL_MONTHLY_POOL_MICROS`: trial grants each month, in all.
54 pub trial_monthly_pool_micros: i64,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look55 /// `MIN_CHARGE_MICROS`: a month's close charges no less; smaller
56 /// amounts carry over. Charges at a limit always go through.
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put57 pub min_charge_micros: i64,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas58 /// `FREE_PRIVATE_STORAGE_BYTES`: private repository storage that is
59 /// free for every workspace. Past it, the plan pays at cost plus the
60 /// margin; a free workspace's pushes to private repositories stop.
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put61 pub free_storage_bytes: i64,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look62 /// `AUDIT_RETENTION_DAYS`: the same on every plan.
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put63 pub audit_days: u32,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look64 /// `RUN_CAP_MICROS` and `ISSUE_CAP_MICROS`: one run's spend cap, and
65 /// agents' spend on one issue in all.
66 pub run_cap_micros: i64,
67 pub issue_cap_micros: i64,
68 /// `LIMIT_PAID_START_MICROS`: a new paid workspace's ceiling in its
69 /// first month.
70 pub paid_start_micros: i64,
71 /// `SPIKE_FACTOR` and `SPIKE_FLOOR_MICROS`: an hour above this many
72 /// times the usual hour, and at least this much, is a spike.
73 pub spike_factor: i64,
74 pub spike_floor_micros: i64,
75 /// `OVERAGE_FORGIVE_COST_MICROS`: the most of an overage's real cost a
76 /// one-click goodwill credit covers.
77 pub forgive_cost_micros: i64,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas78 /// `GIT_OPERATIONS_INCLUDED`: git operations a month that are free for
79 /// every workspace. Past it, the plan pays at cost plus the margin and
80 /// is never slowed; a free workspace is slowed down (the repos
81 /// service's `GIT_OPERATIONS_FREE_CAP`, the same number), never charged.
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look82 pub git_included: u64,
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put83}
84
85impl Default for Config {
86 fn default() -> Self {
87 Config {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look88 plan_monthly_cents: 2_000,
89 plan_included_micros: 10_000_000,
90 oss_pool_micros: 25_000_000,
91 oss_repo_micros: 2_000_000,
92 trial_workspace_micros: 5_000_000,
93 trial_monthly_pool_micros: 100_000_000,
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put94 min_charge_micros: 5_000_000,
95 free_storage_bytes: 1_000_000_000,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look96 audit_days: 90,
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily97 run_cap_micros: g1t_contracts::guardrails::DEFAULT_RUN_CAP_MICROS,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look98 issue_cap_micros: 10_000_000,
99 paid_start_micros: 100_000_000,
100 spike_factor: 5,
101 spike_floor_micros: 5_000_000,
102 forgive_cost_micros: 50_000_000,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas103 git_included: 50_000,
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put104 }
105 }
106}
107
108impl Config {
109 pub(crate) fn from_env(env: &Env) -> Self {
110 let d = Config::default();
111 let number = |name: &str, default: i64| -> i64 {
112 env.var(name).ok().and_then(|v| v.to_string().trim().parse::<i64>().ok()).filter(|n| *n >= 0).unwrap_or(default)
113 };
114 Config {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look115 plan_monthly_cents: number("PLAN_MONTHLY_CENTS", d.plan_monthly_cents.into()) as u32,
116 plan_included_micros: number("PLAN_INCLUDED_MICROS", d.plan_included_micros),
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put117 oss_pool_micros: number("OSS_POOL_MICROS", d.oss_pool_micros),
118 oss_repo_micros: number("OSS_REPO_MICROS", d.oss_repo_micros),
119 trial_workspace_micros: number("TRIAL_WORKSPACE_MICROS", d.trial_workspace_micros),
120 trial_monthly_pool_micros: number("TRIAL_MONTHLY_POOL_MICROS", d.trial_monthly_pool_micros),
121 min_charge_micros: number("MIN_CHARGE_MICROS", d.min_charge_micros),
122 free_storage_bytes: number("FREE_PRIVATE_STORAGE_BYTES", d.free_storage_bytes),
123 audit_days: number("AUDIT_RETENTION_DAYS", d.audit_days.into()) as u32,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look124 run_cap_micros: number("RUN_CAP_MICROS", d.run_cap_micros),
125 issue_cap_micros: number("ISSUE_CAP_MICROS", d.issue_cap_micros),
126 paid_start_micros: number("LIMIT_PAID_START_MICROS", d.paid_start_micros),
127 spike_factor: number("SPIKE_FACTOR", d.spike_factor).max(1),
128 spike_floor_micros: number("SPIKE_FLOOR_MICROS", d.spike_floor_micros),
129 forgive_cost_micros: number("OVERAGE_FORGIVE_COST_MICROS", d.forgive_cost_micros),
130 git_included: number("GIT_OPERATIONS_INCLUDED", d.git_included as i64) as u64,
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put131 }
132 }
133}
134
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look135/// What may pay for a charge besides the plan's included usage, which any
136/// usage may draw on.
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put137#[derive(Clone, Debug, Default)]
138pub(crate) struct Eligible {
139 /// The trial credit: everything but deployments.
140 pub trial: bool,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look141 /// The open-source pool: this repository (`owner/name`), if it is
142 /// public. Only checks, workflows and the merge queue name one.
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put143 pub repo: Option<String>,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look144 /// g1t covers what is left, rather than charging it, when the workspace
145 /// has no plan: a free workspace's compute.
146 pub cover_rest: bool,
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put147}
148
149/// What paid for a charge before the workspace did.
150#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
151pub(crate) struct Drawn {
152 pub credit: i64,
153 pub trial: i64,
154 pub oss: i64,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look155 /// What g1t covered itself.
156 pub given: i64,
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put157}
158
159impl Drawn {
160 pub fn total(&self) -> i64 {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look161 self.credit + self.trial + self.oss + self.given
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put162 }
163
164 /// For the statement: what paid for the entry, e.g. ` ($0.12 paid by
165 /// g1t's open-source pool)`. Empty when nothing did.
166 pub fn note(&self) -> String {
167 let parts: Vec<String> = [
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look168 (self.credit, "paid by your plan's included usage"),
169 (self.trial, "paid by your trial credit"),
170 (self.oss, "paid by g1t's open-source pool"),
171 (self.given, "covered by g1t"),
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put172 ]
173 .iter()
174 .filter(|(micros, _)| *micros > 0)
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look175 .map(|(micros, by)| format!("{} {by}", dollars(*micros)))
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put176 .collect();
177 if parts.is_empty() { String::new() } else { format!(" ({})", parts.join(", ")) }
178 }
179}
180
181/// How `gross` is paid for from sources with `available` left each, in
182/// order: each takes what it can of what is still unpaid. The rest is
183/// charged.
184pub(crate) fn split(gross: i64, available: &[i64]) -> Vec<i64> {
185 let mut left = gross.max(0);
186 available
187 .iter()
188 .map(|available| {
189 let take = left.min((*available).max(0));
190 left -= take;
191 take
192 })
193 .collect()
194}
195
196/// What a budget with `cap` and `used` so far has left.
197pub(crate) fn left(cap: i64, used: i64) -> i64 {
198 (cap - used).max(0)
199}
200
201/// `YYYY-MM` of an RFC 3339 time.
202pub(crate) fn month_of(timestamp: &str) -> String {
203 timestamp[..7].to_owned()
204}
205
206/// The first instant of the month after `month`: when this month's pools
207/// reset.
208pub(crate) fn next_month_start(month: &str) -> String {
209 let year: i32 = month[..4].parse().unwrap_or(1970);
210 let number: u32 = month[5..7].parse().unwrap_or(1);
211 if number == 12 {
212 format!("{}-01-01T00:00:00Z", year + 1)
213 } else {
214 format!("{year}-{:02}-01T00:00:00Z", number + 1)
215 }
216}
217
218/// The last second of `month`, for a charge that belongs to a month that
219/// is over.
220pub(crate) fn month_end(month: &str) -> String {
221 let year: i32 = month[..4].parse().unwrap_or(1970);
222 let number: u32 = month[5..7].parse().unwrap_or(1);
223 let leap = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0;
224 let days = match number {
225 2 if leap => 29,
226 2 => 28,
227 4 | 6 | 9 | 11 => 30,
228 _ => 31,
229 };
230 format!("{month}-{days:02}T23:59:59Z")
231}
232
233/// What a trial grant would be: the account's own amount from sudo, or the
234/// default.
235pub(crate) fn grant_size(config: &Config, staff: Option<i64>) -> i64 {
236 staff.unwrap_or(config.trial_workspace_micros).max(0)
237}
238
239/// Whether this month's pool can still make a grant of `amount`.
240pub(crate) fn pool_has_room(pool: i64, granted_this_month: i64, amount: i64) -> bool {
241 amount > 0 && granted_this_month + amount <= pool
242}
243
244#[derive(Deserialize)]
245struct Used {
246 used: Option<i64>,
247}
248
249#[derive(Deserialize)]
250pub(crate) struct Grant {
251 pub granted_micros: i64,
252 pub used_micros: i64,
253}
254
255impl Billing {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look256 /// The workspace's plan: comped terms are internal, an enterprise's
257 /// workspaces are invoiced, and otherwise the plan is paid for (or
258 /// given by staff without its price) or not. A Deployments subscription
259 /// from before the plan counts as the plan until its period ends.
260 /// Without a card processor every workspace has the plan: a g1t that
261 /// does not charge has nothing to gate.
262 pub(crate) async fn plan_kind(&self, workspace: &str) -> Result<PlanKind> {
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put263 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 index264 self.plan_kind_for(workspace, &account).await
265 }
266
267 /// The plan, from the account already read for the workspace.
268 pub(crate) async fn plan_kind_for(&self, workspace: &str, account: &BillingAccount) -> Result<PlanKind> {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look269 if account.terms.kind == TermsKind::Comped {
270 return Ok(PlanKind::Internal);
271 }
272 if account.kind == g1t_contracts::billing::AccountKind::Enterprise {
273 return Ok(PlanKind::Enterprise);
274 }
275 if self.stripe.is_none() || account.allowances.plan {
276 return Ok(PlanKind::Paid);
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put277 }
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 index278 // Both subscriptions are asked for at once; either one is the plan.
279 let (plan, deployments) = futures_util::future::try_join(
280 self.plan_on(workspace, Feature::Plan),
281 self.plan_on(workspace, Feature::Deployments),
282 )
283 .await?;
284 if plan || deployments {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look285 return Ok(PlanKind::Paid);
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put286 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look287 Ok(PlanKind::Free)
288 }
289
290 /// Whether the workspace has the g1t plan now, whoever pays for it.
291 pub(crate) async fn has_plan(&self, workspace: &str) -> Result<bool> {
292 Ok(self.plan_kind(workspace).await? != PlanKind::Free)
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put293 }
294
295 /// What one monthly allowance has used.
296 pub(crate) async fn allowance_used(&self, kind: &str, scope: &str, month: &str) -> Result<i64> {
297 Ok(self
298 .db
299 .prepare("SELECT used FROM allowance_use WHERE kind = ? AND scope = ? AND month = ?")
300 .bind(&[kind.into(), scope.into(), month.into()])?
301 .first::<Used>(None)
302 .await?
303 .and_then(|u| u.used)
304 .unwrap_or(0))
305 }
306
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas307 /// Adds `amount` to a monthly count with no cap, such as the month's
308 /// build seconds, which the Billing page shows beside what they cost.
309 pub(crate) async fn tally(&self, kind: &str, scope: &str, month: &str, amount: i64) -> Result<()> {
310 if amount <= 0 {
311 return Ok(());
312 }
313 self.db
314 .prepare(
315 "INSERT INTO allowance_use (kind, scope, month, used) VALUES (?1, ?2, ?3, ?4)
316 ON CONFLICT (kind, scope, month) DO UPDATE SET used = used + ?4",
317 )
318 .bind(&[kind.into(), scope.into(), month.into(), (amount as f64).into()])?
319 .run()
320 .await?;
321 Ok(())
322 }
323
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put324 /// Takes up to `want` from a monthly allowance with `cap`, as one
325 /// transaction. Returns what it took.
326 pub(crate) async fn draw_allowance(&self, kind: &str, scope: &str, month: &str, want: i64, cap: i64) -> Result<i64> {
327 if want <= 0 || cap <= 0 {
328 return Ok(0);
329 }
330 let key = [kind.into(), scope.into(), month.into()];
331 let results = self
332 .db
333 .batch(vec![
334 self.db
335 .prepare("INSERT OR IGNORE INTO allowance_use (kind, scope, month, used) VALUES (?1, ?2, ?3, 0)")
336 .bind(&key)?,
337 self.db
338 .prepare("SELECT used FROM allowance_use WHERE kind = ?1 AND scope = ?2 AND month = ?3")
339 .bind(&key)?,
340 self.db
341 .prepare(
342 "UPDATE allowance_use SET used = MIN(?4, used + ?5)
343 WHERE kind = ?1 AND scope = ?2 AND month = ?3 AND used < ?4",
344 )
345 .bind(&[kind.into(), scope.into(), month.into(), (cap as f64).into(), (want as f64).into()])?,
346 self.db
347 .prepare("SELECT used FROM allowance_use WHERE kind = ?1 AND scope = ?2 AND month = ?3")
348 .bind(&key)?,
349 ])
350 .await?;
351 let read = |i: usize| -> Result<i64> {
352 Ok(results[i].results::<Used>()?.first().and_then(|u| u.used).unwrap_or(0))
353 };
354 Ok((read(3)? - read(1)?).max(0))
355 }
356
357 /// Gives back what was drawn and not used.
358 async fn return_allowance(&self, kind: &str, scope: &str, month: &str, amount: i64) -> Result<()> {
359 if amount > 0 {
360 self.db
361 .prepare("UPDATE allowance_use SET used = MAX(0, used - ?4) WHERE kind = ?1 AND scope = ?2 AND month = ?3")
362 .bind(&[kind.into(), scope.into(), month.into(), (amount as f64).into()])?
363 .run()
364 .await?;
365 }
366 Ok(())
367 }
368
369 // --- Trials -----------------------------------------------------------
370
371 pub(crate) async fn grant_of(&self, workspace: &str) -> Result<Option<Grant>> {
372 self.db
373 .prepare("SELECT granted_micros, used_micros FROM trial_grants WHERE workspace = ?")
374 .bind(&[workspace.into()])?
375 .first::<Grant>(None)
376 .await
377 }
378
379 /// Trial grants made this month, in all.
380 pub(crate) async fn trial_granted(&self, month: &str) -> Result<(i64, u32)> {
381 #[derive(Deserialize)]
382 struct Row {
383 micros: Option<i64>,
384 n: Option<u32>,
385 }
386 let row = self
387 .db
388 .prepare("SELECT SUM(granted_micros) AS micros, COUNT(*) AS n FROM trial_grants WHERE month = ?")
389 .bind(&[month.into()])?
390 .first::<Row>(None)
391 .await?;
392 Ok(row.map_or((0, 0), |r| (r.micros.unwrap_or(0), r.n.unwrap_or(0))))
393 }
394
395 /// The workspace's grant, made now out of this month's pool if it has
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look396 /// none and the pool has room. Called once its card is checked, never
397 /// before: the trial needs a card check. A grant g1t staff set comes
398 /// from no pool.
399 pub(crate) async fn ensure_grant(&self, workspace: &str) -> Result<Option<Grant>> {
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put400 if let Some(grant) = self.grant_of(workspace).await? {
401 return Ok(Some(grant));
402 }
403 if !self.trials_on {
404 return Ok(None);
405 }
406 let staff = self.account_of(workspace).await?.allowances.trial_micros;
407 let amount = grant_size(&self.plans, staff);
408 if amount <= 0 {
409 return Ok(None);
410 }
411 let now = rfc3339(now_ms());
412 let month = if staff.is_some() { "staff".to_owned() } else { month_of(&now) };
413 // One statement: the pool is checked and the grant made together.
414 self.db
415 .prepare(
416 "INSERT INTO trial_grants (workspace, month, granted_micros, used_micros, created_at)
417 SELECT ?1, ?2, ?3, 0, ?4
418 WHERE ?2 = 'staff'
419 OR (SELECT COALESCE(SUM(granted_micros), 0) FROM trial_grants WHERE month = ?2) + ?3 <= ?5
420 ON CONFLICT (workspace) DO NOTHING",
421 )
422 .bind(&[
423 workspace.into(),
424 month.as_str().into(),
425 (amount as f64).into(),
426 now.as_str().into(),
427 (self.plans.trial_monthly_pool_micros as f64).into(),
428 ])?
429 .run()
430 .await?;
431 self.grant_of(workspace).await
432 }
433
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look434 /// Takes up to `want` from the workspace's trial credit, if it has a
435 /// grant.
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put436 async fn draw_trial(&self, workspace: &str, want: i64) -> Result<i64> {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look437 if want <= 0 || self.grant_of(workspace).await?.is_none() {
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put438 return Ok(0);
439 }
440 #[derive(Deserialize)]
441 struct Row {
442 used_micros: i64,
443 }
444 let results = self
445 .db
446 .batch(vec![
447 self.db.prepare("SELECT used_micros FROM trial_grants WHERE workspace = ?1").bind(&[workspace.into()])?,
448 self.db
449 .prepare(
450 "UPDATE trial_grants SET used_micros = MIN(granted_micros, used_micros + ?2)
451 WHERE workspace = ?1 AND used_micros < granted_micros",
452 )
453 .bind(&[workspace.into(), (want as f64).into()])?,
454 self.db.prepare("SELECT used_micros FROM trial_grants WHERE workspace = ?1").bind(&[workspace.into()])?,
455 ])
456 .await?;
457 let read = |i: usize| -> Result<i64> { Ok(results[i].results::<Row>()?.first().map_or(0, |r| r.used_micros)) };
458 Ok((read(2)? - read(0)?).max(0))
459 }
460
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look461 /// `trial`: where the workspace's trial credit stands. Not granted yet,
462 /// it waits for a card check (`verify`), or for next month's pool
463 /// (`pool`).
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put464 pub(crate) async fn trial(&self, a: TrialArgs) -> Result<Trial> {
465 let workspace = a.workspace.to_lowercase();
466 let closed = |reason: &str| Trial {
467 open: false,
468 used_micros: 0,
469 limit_micros: 0,
470 ends_at: None,
471 reason: Some(reason.to_owned()),
472 granted: false,
473 waits_until: None,
474 };
475 if let Some(grant) = self.grant_of(&workspace).await? {
476 let open = grant.used_micros < grant.granted_micros;
477 return Ok(Trial {
478 open,
479 used_micros: grant.used_micros,
480 limit_micros: grant.granted_micros,
481 ends_at: None,
482 reason: (!open).then(|| "used".to_owned()),
483 granted: true,
484 waits_until: None,
485 });
486 }
487 if !self.trials_on {
488 return Ok(closed("off"));
489 }
490 let staff = self.account_of(&workspace).await?.allowances.trial_micros;
491 let amount = grant_size(&self.plans, staff);
492 if amount <= 0 {
493 return Ok(closed("off"));
494 }
495 let month = month_of(&rfc3339(now_ms()));
496 let (granted, _) = self.trial_granted(&month).await?;
497 let room = staff.is_some() || pool_has_room(self.plans.trial_monthly_pool_micros, granted, amount);
498 Ok(Trial {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look499 open: false,
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put500 used_micros: 0,
501 limit_micros: amount,
502 ends_at: None,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look503 reason: Some(if room { "verify" } else { "pool" }.to_owned()),
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put504 granted: false,
505 waits_until: (!room).then(|| next_month_start(&month)),
506 })
507 }
508
509 // --- The open-source pool ---------------------------------------------
510
511 /// Whether `repo` (`owner/name`) is public, asked of the repos service.
512 /// Unknown counts as private: the pool pays only for what is known to
513 /// be open.
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look514 pub(crate) async fn is_public(&self, repo: &str) -> bool {
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put515 let Some(repos) = &self.repos else { return false };
516 let found: Result<Vec<g1t_contracts::repos::RepoVisibility>> = g1t_kit::call(
517 repos,
518 "visibility",
519 &g1t_contracts::repos::VisibilityArgs { paths: vec![repo.to_owned()] },
520 )
521 .await;
522 match found {
523 Ok(list) => list.iter().any(|v| v.path.eq_ignore_ascii_case(repo) && !v.is_private),
524 Err(error) => {
525 worker::console_error!("could not ask whether {repo} is public: {error}");
526 false
527 }
528 }
529 }
530
531 /// A public repository's monthly cap on the pool: its account's own
532 /// from sudo, or `OSS_REPO_MICROS`.
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look533 pub(crate) async fn oss_repo_cap(&self, workspace: &str) -> Result<i64> {
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put534 Ok(self.account_of(workspace).await?.allowances.oss_repo_micros.unwrap_or(self.plans.oss_repo_micros))
535 }
536
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look537 /// What the open-source pool has left this month for `repo`: the
538 /// pool's and the repository's share, whichever is less.
539 pub(crate) async fn oss_left(&self, workspace: &str, repo: &str, month: &str) -> Result<i64> {
540 let pool = left(self.plans.oss_pool_micros, self.allowance_used("oss_pool", "", month).await?);
541 let share = left(self.oss_repo_cap(workspace).await?, self.allowance_used("oss_repo", &repo.to_lowercase(), month).await?);
542 Ok(pool.min(share))
543 }
544
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put545 /// Takes up to `want` from the open-source pool for `repo`, within the
546 /// pool's cap and the repository's.
547 async fn draw_oss(&self, workspace: &str, repo: &str, month: &str, want: i64) -> Result<i64> {
548 let repo = repo.to_lowercase();
549 let cap = self.oss_repo_cap(workspace).await?;
550 let room = left(cap, self.allowance_used("oss_repo", &repo, month).await?);
551 let from_pool = self.draw_allowance("oss_pool", "", month, want.min(room), self.plans.oss_pool_micros).await?;
552 let for_repo = self.draw_allowance("oss_repo", &repo, month, from_pool, cap).await?;
553 // The repository's cap filled up meanwhile: give the pool back the rest.
554 self.return_allowance("oss_pool", "", month, from_pool - for_repo).await?;
555 Ok(for_repo)
556 }
557
558 // --- Drawing down -----------------------------------------------------
559
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look560 /// Pays for a `gross` charge from the plan's included usage, the trial
561 /// credit and the open-source pool, in that order, for usage in
562 /// `month`; then, for a free workspace's compute, g1t covers the rest.
563 /// Returns what each paid; the rest is the workspace's to pay.
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put564 pub(crate) async fn draw(&self, workspace: &str, gross: i64, month: &str, eligible: &Eligible) -> Result<Drawn> {
565 if gross <= 0 {
566 return Ok(Drawn::default());
567 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look568 let plan = self.has_plan(workspace).await?;
569 let credit_left = if plan {
570 left(self.plans.plan_included_micros, self.allowance_used("plan_credit", workspace, month).await?)
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put571 } else {
572 0
573 };
574 let trial_left = if eligible.trial {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look575 self.grant_of(workspace).await?.map_or(0, |grant| left(grant.granted_micros, grant.used_micros))
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put576 } else {
577 0
578 };
579 // Asked only when the rest has not paid for it all.
580 let public_repo = match &eligible.repo {
581 Some(repo) if gross > credit_left + trial_left && self.is_public(repo).await => Some(repo.clone()),
582 _ => None,
583 };
584 let oss_left = match &public_repo {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look585 Some(repo) => self.oss_left(workspace, repo, month).await?,
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put586 None => 0,
587 };
588 let planned = split(gross, &[credit_left, trial_left, oss_left]);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look589 let mut drawn = Drawn {
590 credit: self.draw_allowance("plan_credit", workspace, month, planned[0], self.plans.plan_included_micros).await?,
591 trial: self.draw_trial(workspace, planned[1]).await?,
592 ..Drawn::default()
593 };
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put594 if let Some(repo) = &public_repo {
595 drawn.oss = self.draw_oss(workspace, repo, month, planned[2]).await?;
596 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look597 if eligible.cover_rest && !plan {
598 drawn.given = (gross - drawn.credit - drawn.trial - drawn.oss).max(0);
599 }
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put600 Ok(drawn)
601 }
602
603 /// Writes down on a usage entry what paid for it.
604 pub(crate) async fn record_drawn(&self, reference: &str, drawn: &Drawn) -> Result<()> {
605 if drawn.total() == 0 {
606 return Ok(());
607 }
608 self.db
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look609 .prepare("UPDATE ledger SET credit_micros = ?, trial_micros = ?, oss_micros = ?, given_micros = ? WHERE reference = ?")
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put610 .bind(&[
611 (drawn.credit as f64).into(),
612 (drawn.trial as f64).into(),
613 (drawn.oss as f64).into(),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look614 (drawn.given as f64).into(),
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put615 reference.into(),
616 ])?
617 .run()
618 .await?;
619 Ok(())
620 }
621
622 /// g1t's pools this month, for sudo.
623 pub(crate) async fn pools(&self) -> Result<Pools> {
624 let month = month_of(&rfc3339(now_ms()));
625 let (granted, grants) = self.trial_granted(&month).await?;
626 Ok(Pools {
627 oss_used_micros: self.allowance_used("oss_pool", "", &month).await?,
628 oss_pool_micros: self.plans.oss_pool_micros,
629 oss_repo_micros: self.plans.oss_repo_micros,
630 trial_granted_micros: granted,
631 trial_pool_micros: self.plans.trial_monthly_pool_micros,
632 trial_grants: grants,
633 month,
634 })
635 }
636}
637
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look638/// What may pay for compute: the trial (never for deployments), the
639/// open-source pool for checks, workflows and the merge queue on `repo`,
640/// and g1t for a free workspace's overrun. Work whose kind is not known is
641/// taken as an agent's: never the pool.
642pub(crate) fn eligible_for(kind: Option<ComputeKind>, repo: Option<&str>) -> Eligible {
643 let kind = kind.unwrap_or(ComputeKind::Agent);
644 Eligible {
645 trial: kind != ComputeKind::Deploy,
646 repo: repo.filter(|_| kind.open_source_pool()).map(str::to_owned),
647 cover_rest: kind != ComputeKind::Deploy,
648 }
649}
650
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put651/// A charge in millionths of a dollar for `micros` of cost plus `margin`.
652pub(crate) fn with_margin(cost_micros: i64, margin_percent: u32) -> i64 {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look653 crate::charge_micros(cost_micros.max(0) as f64 / g1t_contracts::billing::MICROS_PER_DOLLAR as f64, margin_percent)
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put654}
655
656#[cfg(test)]
657mod tests {
658 use super::*;
659
660 #[test]
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look661 fn included_usage_pays_first_then_the_trial_then_the_pool_then_the_workspace() {
662 // $0.50 of usage; $0.20 included, $1 of trial, $1 of pool.
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put663 assert_eq!(split(500_000, &[200_000, 1_000_000, 1_000_000]), [200_000, 300_000, 0]);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look664 // No plan: the trial pays all of it.
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put665 assert_eq!(split(500_000, &[0, 1_000_000, 1_000_000]), [0, 500_000, 0]);
666 // Trial spent: the pool pays, where it applies.
667 assert_eq!(split(500_000, &[0, 0, 1_000_000]), [0, 0, 500_000]);
668 // Everything spent: the workspace pays all of it.
669 let planned = split(500_000, &[0, 0, 0]);
670 assert_eq!(planned, [0, 0, 0]);
671 assert_eq!(500_000 - planned.iter().sum::<i64>(), 500_000);
672 // Each pays what it can, and the rest is charged.
673 let planned = split(500_000, &[100_000, 150_000, 50_000]);
674 assert_eq!(planned, [100_000, 150_000, 50_000]);
675 assert_eq!(500_000 - planned.iter().sum::<i64>(), 200_000);
676 // Nothing is drawn for nothing, nor from a negative balance.
677 assert_eq!(split(0, &[1, 1, 1]), [0, 0, 0]);
678 assert_eq!(split(100, &[-5, 50, 100]), [0, 50, 50]);
679 }
680
681 #[test]
682 fn a_budget_never_gives_more_than_its_cap() {
683 assert_eq!(left(1_000_000, 400_000), 600_000);
684 assert_eq!(left(1_000_000, 1_000_000), 0);
685 assert_eq!(left(1_000_000, 1_200_000), 0);
686 // The open-source pool: the repository's share and the pool's both bound it.
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look687 let pool = left(25_000_000, 24_900_000);
688 let repo = left(2_000_000, 300_000);
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put689 assert_eq!(split(800_000, &[pool.min(repo)]), [100_000]);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look690 // A repository past its $2 share gets nothing, however full the pool.
691 assert_eq!(split(800_000, &[left(25_000_000, 0).min(left(2_000_000, 2_000_000))]), [0]);
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put692 }
693
694 #[test]
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look695 fn the_open_source_pool_pays_only_for_checks_workflows_and_the_queue() {
696 assert_eq!(eligible_for(Some(ComputeKind::Check), Some("acme/web")).repo.as_deref(), Some("acme/web"));
697 assert_eq!(eligible_for(Some(ComputeKind::Queue), Some("acme/web")).repo.as_deref(), Some("acme/web"));
698 assert_eq!(eligible_for(Some(ComputeKind::Workflow), Some("acme/web")).repo.as_deref(), Some("acme/web"));
699 // An agent on a public repository pays as any agent does.
700 assert!(eligible_for(Some(ComputeKind::Agent), Some("acme/web")).repo.is_none());
701 // Unknown work is never the pool's.
702 assert!(eligible_for(None, Some("acme/web")).repo.is_none());
703 // Deployments are never the trial's, and never covered.
704 let deploy = eligible_for(Some(ComputeKind::Deploy), Some("acme/web"));
705 assert!(!deploy.trial && !deploy.cover_rest && deploy.repo.is_none());
706 assert!(eligible_for(Some(ComputeKind::Agent), None).trial);
707 }
708
709 #[test]
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put710 fn pools_reset_each_calendar_month() {
711 assert_eq!(month_of("2026-10-31T23:59:59Z"), "2026-10");
712 assert_eq!(month_of("2026-11-01T00:00:00Z"), "2026-11");
713 assert_eq!(next_month_start("2026-10"), "2026-11-01T00:00:00Z");
714 assert_eq!(next_month_start("2026-12"), "2027-01-01T00:00:00Z");
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look715 // $100 a month in $5 grants: twenty trials, then the next month.
716 assert!(pool_has_room(100_000_000, 95_000_000, 5_000_000));
717 assert!(!pool_has_room(100_000_000, 100_000_000, 5_000_000));
718 assert!(!pool_has_room(100_000_000, 97_500_000, 5_000_000));
719 assert!(pool_has_room(100_000_000, 0, 5_000_000));
720 assert!(!pool_has_room(100_000_000, 0, 0));
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put721 }
722
723 #[test]
724 fn a_trial_grant_is_the_default_unless_staff_set_one() {
725 let config = Config::default();
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look726 assert_eq!(grant_size(&config, None), 5_000_000);
727 assert_eq!(grant_size(&config, Some(20_000_000)), 20_000_000);
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put728 assert_eq!(grant_size(&config, Some(-1)), 0);
729 }
730
731 #[test]
732 fn a_month_ends_on_its_last_day() {
733 assert_eq!(month_end("2026-10"), "2026-10-31T23:59:59Z");
734 assert_eq!(month_end("2026-09"), "2026-09-30T23:59:59Z");
735 assert_eq!(month_end("2028-02"), "2028-02-29T23:59:59Z");
736 assert_eq!(month_end("2027-02"), "2027-02-28T23:59:59Z");
737 }
738
739 #[test]
740 fn what_paid_is_said_on_the_statement() {
741 assert_eq!(Drawn::default().note(), "");
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look742 let drawn = Drawn { oss: 120_000, ..Drawn::default() };
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put743 assert_eq!(drawn.note(), " ($0.12 paid by g1t's open-source pool)");
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look744 let drawn = Drawn { credit: 50_000, trial: 20_000, ..Drawn::default() };
745 assert_eq!(drawn.note(), " ($0.05 paid by your plan's included usage, $0.02 paid by your trial credit)");
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put746 assert_eq!(drawn.total(), 70_000);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look747 let drawn = Drawn { trial: 300_000, given: 40_000, ..Drawn::default() };
748 assert_eq!(drawn.note(), " ($0.30 paid by your trial credit, $0.04 covered by g1t)");
749 assert_eq!(drawn.total(), 340_000);
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put750 }
751
752 #[test]
753 fn the_defaults_are_the_published_ones() {
754 let c = Config::default();
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look755 assert_eq!(c.plan_monthly_cents, 2_000);
756 assert_eq!(c.plan_included_micros, 10_000_000);
757 assert_eq!(c.oss_pool_micros, 25_000_000);
758 assert_eq!(c.oss_repo_micros, 2_000_000);
759 assert_eq!(c.trial_workspace_micros, 5_000_000);
760 assert_eq!(c.trial_monthly_pool_micros, 100_000_000);
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put761 assert_eq!(c.min_charge_micros, 5_000_000);
762 assert_eq!(c.free_storage_bytes, 1_000_000_000);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look763 assert_eq!(c.audit_days, 90);
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily764 assert_eq!(c.run_cap_micros, g1t_contracts::guardrails::DEFAULT_RUN_CAP_MICROS);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look765 assert_eq!(c.issue_cap_micros, 10_000_000);
766 assert_eq!(c.paid_start_micros, 100_000_000);
767 assert_eq!(c.forgive_cost_micros, 50_000_000);
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas768 assert_eq!(c.git_included, 50_000);
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put769 }
770}