Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh
834 linesCodeBlameRaw

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

The balance shows what was spent this month and in total1//! Who pays for a workspace, and on what terms.
2//!
3//! Every workspace is paid for by a billing account. By default that is
4//! its own (`ws_<slug>`), on standard terms, and needs no row. g1t staff
5//! can change that in sudo.g1t.sh:
6//!
7//! - **Terms.** Comped (nothing charged, usage still recorded with its
8//! cost; for g1t's own workspaces and partners), or custom (a discount,
9//! a ceiling of its own, or both), optionally until a date.
10//! - **Enterprises.** One account paying for several workspaces, as GitHub
11//! Enterprise does: their usage and payments count together against one
12//! limit, on one set of terms.
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging13//! - **Credits**: promotional, goodwill or refunds (see `grants`).
The balance shows what was spent this month and in total14//!
15//! Every change names who made it and is kept in `admin_actions`.
16
17use g1t_contracts::billing::{
18 AccountDetail, AccountKind, AccountSummary, AdminAccountArgs, AdminAccountsArgs, AdminAction, AdminAttachArgs,
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging19 AdminCreateEnterpriseArgs, AdminSetAllowancesArgs, AdminSetTermsArgs, Allowances, BillingAccount, LedgerEntry, Terms,
20 TermsKind, WorkspaceFigures,
The balance shows what was spent this month and in total21};
22use g1t_contracts::time::rfc3339;
23use g1t_contracts::{FailureCode, Outcome, new_id};
24use g1t_kit::now_ms;
25use serde::Deserialize;
26use worker::Result;
27use worker::wasm_bindgen::JsValue;
28
29use crate::{Billing, LedgerRow, optional};
30
31#[derive(Deserialize)]
32struct AccountRow {
33 id: String,
34 kind: String,
35 name: String,
36 terms_kind: String,
37 discount_percent: u32,
38 ceiling_micros: Option<i64>,
39 note: String,
40 terms_until: Option<String>,
41 terms_set_by: Option<String>,
42 terms_set_at: Option<String>,
43 created_at: String,
44 #[serde(default)]
45 billing_email: Option<String>,
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put46 #[serde(default)]
47 team_granted: Option<i64>,
48 #[serde(default)]
49 oss_repo_micros: Option<i64>,
50 #[serde(default)]
51 trial_micros: Option<i64>,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look52 #[serde(default)]
53 max_concurrent_agents: Option<u32>,
54 #[serde(default)]
55 run_cap_micros: Option<i64>,
56 #[serde(default)]
57 issue_cap_micros: Option<i64>,
58 #[serde(default)]
Audit logs are kept by plan: a week on free, 90 days on the plan, and what staff set for an account in sudo59 audit_retention_days: Option<u32>,
60 #[serde(default)]
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look61 hold: Option<String>,
The balance shows what was spent this month and in total62}
63
64impl AccountRow {
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put65 fn allowances(&self) -> Allowances {
66 Allowances {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look67 // The column is named for the plan's old name.
68 plan: self.team_granted.unwrap_or(0) != 0,
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put69 oss_repo_micros: self.oss_repo_micros,
70 trial_micros: self.trial_micros,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look71 max_concurrent_agents: self.max_concurrent_agents,
72 run_cap_micros: self.run_cap_micros,
73 issue_cap_micros: self.issue_cap_micros,
Audit logs are kept by plan: a week on free, 90 days on the plan, and what staff set for an account in sudo74 audit_retention_days: self.audit_retention_days,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look75 hold: self.hold.clone().filter(|h| !h.trim().is_empty()),
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put76 }
77 }
78}
79
80impl AccountRow {
The balance shows what was spent this month and in total81 fn terms(&self) -> Terms {
82 let expired = self.terms_until.as_deref().is_some_and(|until| until < rfc3339(now_ms()).as_str());
83 if expired {
84 return Terms::standard();
85 }
86 Terms {
87 kind: match self.terms_kind.as_str() {
88 "comped" => TermsKind::Comped,
89 "custom" => TermsKind::Custom,
90 _ => TermsKind::Standard,
91 },
92 discount_percent: self.discount_percent,
93 ceiling_micros: self.ceiling_micros,
94 note: self.note.clone(),
95 until: self.terms_until.clone(),
96 set_by: self.terms_set_by.clone(),
97 set_at: self.terms_set_at.clone(),
98 }
99 }
100}
101
102#[derive(Deserialize)]
103struct Member {
104 workspace: String,
105}
106
107#[derive(Deserialize)]
108struct ActionRow {
109 id: String,
110 account: String,
111 action: String,
112 detail: String,
113 by: String,
114 created_at: String,
115}
116
117/// `ws_<slug>`: a workspace's own account.
118pub(crate) fn own_account(workspace: &str) -> String {
119 format!("ws_{}", workspace.to_lowercase())
120}
121
122fn kind_text(kind: TermsKind) -> &'static str {
123 match kind {
124 TermsKind::Standard => "standard",
125 TermsKind::Comped => "comped",
126 TermsKind::Custom => "custom",
127 }
128}
129
130fn describe(terms: &Terms) -> String {
131 let mut text = match terms.kind {
132 TermsKind::Standard => "standard".to_owned(),
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging133 TermsKind::Comped | TermsKind::Custom => {
The balance shows what was spent this month and in total134 let mut parts = vec![];
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging135 if let Some(label) = terms.discount_label() {
136 parts.push(label);
The balance shows what was spent this month and in total137 }
138 if let Some(ceiling) = terms.ceiling_micros {
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging139 let what = if terms.full_discount() { "monthly budget" } else { "ceiling" };
140 parts.push(format!("{what} {}", crate::features::dollars(ceiling)));
The balance shows what was spent this month and in total141 }
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging142 if parts.is_empty() { "custom (no changes)".to_owned() } else { parts.join(", ") }
The balance shows what was spent this month and in total143 }
144 };
145 if let Some(until) = &terms.until {
146 text.push_str(&format!(" until {}", &until[..until.len().min(10)]));
147 }
148 if !terms.note.is_empty() {
149 text.push_str(&format!(": {}", terms.note));
150 }
151 text
152}
153
154impl Billing {
155 async fn account_row(&self, id: &str) -> Result<Option<AccountRow>> {
156 self.db
157 .prepare("SELECT * FROM billing_accounts WHERE id = ?")
158 .bind(&[id.into()])?
159 .first::<AccountRow>(None)
160 .await
161 }
162
163 async fn members(&self, account: &str) -> Result<Vec<String>> {
164 Ok(self
165 .db
166 .prepare("SELECT workspace FROM account_members WHERE account_id = ? ORDER BY workspace")
167 .bind(&[account.into()])?
168 .all()
169 .await?
170 .results::<Member>()?
171 .into_iter()
172 .map(|m| m.workspace)
173 .collect())
174 }
175
176 fn to_account(&self, row: &AccountRow, workspaces: Vec<String>) -> BillingAccount {
177 BillingAccount {
178 id: row.id.clone(),
179 kind: if row.kind == "enterprise" { AccountKind::Enterprise } else { AccountKind::Workspace },
180 name: row.name.clone(),
181 terms: row.terms(),
182 workspaces,
183 created_at: row.created_at.clone(),
184 billing_email: row.billing_email.clone(),
185 invoices: vec![],
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put186 allowances: row.allowances(),
The balance shows what was spent this month and in total187 }
188 }
189
190 /// The account that pays for a workspace.
191 pub(crate) async fn account_of(&self, workspace: &str) -> Result<BillingAccount> {
192 let workspace = workspace.to_lowercase();
193 #[derive(Deserialize)]
194 struct Link {
195 account_id: String,
196 }
197 let linked = self
198 .db
199 .prepare("SELECT account_id FROM account_members WHERE workspace = ?")
200 .bind(&[workspace.as_str().into()])?
201 .first::<Link>(None)
202 .await?;
Billing keeps Stripe's view itself: the saved card on the account, missed events replayed every 15 minutes, and the endpoint kept203 if let Some(link) = linked
204 && let Some(row) = self.account_row(&link.account_id).await? {
The balance shows what was spent this month and in total205 let members = self.members(&row.id).await?;
206 return Ok(self.to_account(&row, members));
207 }
208 let id = own_account(&workspace);
209 Ok(match self.account_row(&id).await? {
210 Some(row) => self.to_account(&row, vec![workspace]),
211 None => BillingAccount {
212 id,
213 kind: AccountKind::Workspace,
214 name: workspace.clone(),
215 terms: Terms::standard(),
216 workspaces: vec![workspace],
217 created_at: String::new(),
218 billing_email: None,
219 invoices: vec![],
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put220 allowances: Allowances::default(),
The balance shows what was spent this month and in total221 },
222 })
223 }
224
225 /// The terms a workspace is charged on.
226 pub(crate) async fn terms_of(&self, workspace: &str) -> Result<Terms> {
227 Ok(self.account_of(workspace).await?.terms)
228 }
229
230 /// An enterprise, with its invoices.
231 pub(crate) async fn enterprise(&self, id: &str) -> Result<Option<BillingAccount>> {
232 let Some(row) = self.account_row(id).await?.filter(|row| row.kind == "enterprise") else {
233 return Ok(None);
234 };
235 let members = self.members(&row.id).await?;
236 let mut account = self.to_account(&row, members);
237 account.invoices = self.enterprise_invoices(&row.id).await?;
238 Ok(Some(account))
239 }
240
241 /// An account by id, or the account of a workspace by its slug.
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays242 pub(crate) async fn find_account(&self, id: &str) -> Result<Option<BillingAccount>> {
The balance shows what was spent this month and in total243 let id = id.trim().to_lowercase();
244 if id.starts_with("ent_") {
245 return self.enterprise(&id).await;
246 }
247 let slug = id.strip_prefix("ws_").unwrap_or(&id);
248 if slug.is_empty() {
249 return Ok(None);
250 }
251 Ok(Some(self.account_of(slug).await?))
252 }
253
254 pub(crate) async fn audit(&self, account: &str, action: &str, detail: &str, by: &str) -> Result<()> {
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97255 self.audit_at(account, action, detail, by, now_ms()).await
256 }
257
258 /// `audit`, at a given instant: a testing reset's entry carries the
259 /// same `created_at` as the `reset_costs` it kept.
260 pub(crate) async fn audit_at(&self, account: &str, action: &str, detail: &str, by: &str, at_ms: u64) -> Result<()> {
The balance shows what was spent this month and in total261 self.db
262 .prepare("INSERT INTO admin_actions (id, account, action, detail, by, created_at) VALUES (?, ?, ?, ?, ?, ?)")
263 .bind(&[
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97264 new_id("adm", at_ms).into(),
The balance shows what was spent this month and in total265 account.into(),
266 action.into(),
267 detail.into(),
268 by.into(),
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97269 rfc3339(at_ms).into(),
The balance shows what was spent this month and in total270 ])?
271 .run()
272 .await?;
273 Ok(())
274 }
275
276 /// Where an account stands this month.
277 async fn summary(&self, account: BillingAccount) -> Result<AccountSummary> {
278 let first = account.workspaces.first().cloned().unwrap_or_else(|| account.name.clone());
279 let limit = self.limit_of(&first).await?;
280 #[derive(Deserialize)]
281 struct Totals {
282 workspace: String,
283 charged: Option<i64>,
284 cost: Option<i64>,
285 }
286 #[derive(Deserialize)]
287 struct Paid {
288 workspace: String,
289 paid: Option<i64>,
290 }
291 let marks = vec!["?"; account.workspaces.len().max(1)].join(", ");
292 let mut values: Vec<JsValue> = account.workspaces.iter().map(|w| JsValue::from(w.as_str())).collect();
293 if values.is_empty() {
294 values.push(JsValue::from(""));
295 }
296 let month_start = format!("{}-01", &rfc3339(now_ms())[..7]);
297 let mut with_month = values.clone();
298 with_month.push(month_start.as_str().into());
299 let totals = self
300 .db
301 .prepare(format!(
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look302 "SELECT workspace, -SUM(amount_micros) AS charged,
303 SUM(CASE WHEN COALESCE(billed_to, 'g1t') = 'g1t' THEN cost_micros ELSE 0 END) AS cost
304 FROM ledger WHERE kind = 'usage' AND workspace IN ({marks}) AND created_at >= ? GROUP BY workspace"
The balance shows what was spent this month and in total305 ))
306 .bind(&with_month)?
307 .all()
308 .await?
309 .results::<Totals>()?;
310 let paid = self
311 .db
312 .prepare(format!(
313 "SELECT workspace, SUM(amount_micros) AS paid FROM ledger
314 WHERE kind = 'top_up' AND workspace IN ({marks}) GROUP BY workspace"
315 ))
316 .bind(&values)?
317 .all()
318 .await?
319 .results::<Paid>()?;
320 // Each workspace's share, in the account's order; the account's
321 // figures are their sum.
322 let mut by_workspace: Vec<WorkspaceFigures> = vec![];
323 for workspace in &account.workspaces {
324 figures_for(&mut by_workspace, workspace);
325 }
326 for t in &totals {
327 let f = figures_for(&mut by_workspace, &t.workspace);
328 f.charged_micros += t.charged.unwrap_or(0);
329 f.cost_micros += t.cost.unwrap_or(0);
330 }
331 for p in &paid {
332 figures_for(&mut by_workspace, &p.workspace).paid_micros += p.paid.unwrap_or(0);
333 }
334 let months = self.months_for(&account.workspaces, 6).await?;
335 Ok(AccountSummary {
336 months,
337 charged_micros: by_workspace.iter().map(|f| f.charged_micros).sum(),
338 cost_micros: by_workspace.iter().map(|f| f.cost_micros).sum(),
339 paid_micros: by_workspace.iter().map(|f| f.paid_micros).sum(),
340 by_workspace,
341 account,
342 limit,
343 })
344 }
345
346 // --- Staff ------------------------------------------------------------
347
348 pub(crate) async fn admin_accounts(&self, a: AdminAccountsArgs) -> Result<Vec<AccountSummary>> {
349 // Exactly the workspaces asked for, such as one page of sudo's list.
350 if let Some(workspaces) = &a.workspaces {
351 let mut seen = std::collections::HashSet::new();
352 let mut summaries = vec![];
353 for slug in workspaces.iter().take(200) {
354 let account = self.account_of(slug).await?;
355 if seen.insert(account.id.clone()) {
356 summaries.push(self.summary(account).await?);
357 }
358 }
359 return Ok(summaries);
360 }
361 // Every workspace that has used or paid for anything, and every
362 // account with terms of its own.
363 #[derive(Deserialize)]
364 struct Slug {
365 workspace: String,
366 }
367 let mut slugs: Vec<String> = self
368 .db
369 .prepare(
370 "SELECT DISTINCT workspace FROM ledger
371 UNION SELECT workspace FROM accounts
372 UNION SELECT substr(id, 4) FROM billing_accounts WHERE kind = 'workspace'",
373 )
374 .all()
375 .await?
376 .results::<Slug>()?
377 .into_iter()
378 .map(|s| s.workspace)
379 .collect();
380 if let Some(query) = a.query.as_deref().map(str::trim).filter(|q| !q.is_empty()) {
381 let query = query.to_lowercase();
382 slugs.retain(|slug| slug.contains(&query));
383 }
384 let mut seen = std::collections::HashSet::new();
385 let mut summaries = vec![];
386 for slug in slugs.into_iter().take(200) {
387 let account = self.account_of(&slug).await?;
388 if !seen.insert(account.id.clone()) {
389 continue;
390 }
391 summaries.push(self.summary(account).await?);
392 }
393 // Enterprises with no usage yet.
394 #[derive(Deserialize)]
395 struct Id {
396 id: String,
397 }
398 let enterprises = self
399 .db
400 .prepare("SELECT id FROM billing_accounts WHERE kind = 'enterprise'")
401 .all()
402 .await?
403 .results::<Id>()?;
404 for Id { id } in enterprises {
405 if seen.contains(&id) {
406 continue;
407 }
Billing keeps Stripe's view itself: the saved card on the account, missed events replayed every 15 minutes, and the endpoint kept408 if let Some(account) = self.find_account(&id).await?
409 && a.query.as_deref().is_none_or(|q| account.name.to_lowercase().contains(&q.to_lowercase())) {
The balance shows what was spent this month and in total410 seen.insert(id);
411 summaries.push(self.summary(account).await?);
412 }
413 }
Billing keeps Stripe's view itself: the saved card on the account, missed events replayed every 15 minutes, and the endpoint kept414 summaries.sort_by_key(|x| std::cmp::Reverse(x.limit.exposure_micros));
The balance shows what was spent this month and in total415 Ok(summaries)
416 }
417
418 pub(crate) async fn admin_account(&self, a: AdminAccountArgs) -> Result<Outcome<AccountDetail>> {
419 let Some(account) = self.find_account(&a.id).await? else {
420 return Ok(Outcome::fail(FailureCode::NotFound, "No such account."));
421 };
422 let mut workspaces = vec![];
423 for workspace in &account.workspaces {
424 workspaces.push(self.limit_of(workspace).await?);
425 }
426 let marks = vec!["?"; account.workspaces.len().max(1)].join(", ");
427 let mut values: Vec<JsValue> = account.workspaces.iter().map(|w| JsValue::from(w.as_str())).collect();
428 if values.is_empty() {
429 values.push(JsValue::from(""));
430 }
431 let ledger = self
432 .db
433 .prepare(format!("SELECT * FROM ledger WHERE workspace IN ({marks}) ORDER BY id DESC LIMIT 100"))
434 .bind(&values)?
435 .all()
436 .await?
437 .results::<LedgerRow>()?
438 .into_iter()
439 .map(LedgerEntry::from)
440 .collect();
441 let audit = self
442 .db
443 .prepare("SELECT * FROM admin_actions WHERE account = ? ORDER BY created_at DESC LIMIT 50")
444 .bind(&[account.id.as_str().into()])?
445 .all()
446 .await?
447 .results::<ActionRow>()?
448 .into_iter()
449 .map(|row| AdminAction {
450 id: row.id,
451 account: row.account,
452 action: row.action,
453 detail: row.detail,
454 by: row.by,
455 created_at: row.created_at,
456 })
457 .collect();
458 Ok(Outcome::Ok(AccountDetail { summary: self.summary(account).await?, workspaces, ledger, audit }))
459 }
460
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging461 pub(crate) async fn admin_set_terms(&self, mut a: AdminSetTermsArgs) -> Result<Outcome<BillingAccount>> {
462 a.terms = normalized(a.terms);
The balance shows what was spent this month and in total463 if a.by.trim().is_empty() {
464 return Ok(Outcome::fail(FailureCode::Invalid, "Say who is making the change."));
465 }
466 if a.terms.kind != TermsKind::Standard && a.terms.note.trim().is_empty() {
467 return Ok(Outcome::fail(FailureCode::Invalid, "Say why, in the note."));
468 }
469 if a.terms.discount_percent > 100 || a.terms.ceiling_micros.is_some_and(|c| c < 0) {
470 return Ok(Outcome::fail(FailureCode::Invalid, "A discount is 0 to 100%, and a ceiling is not negative."));
471 }
472 let Some(account) = self.find_account(&a.id).await? else {
473 return Ok(Outcome::fail(FailureCode::NotFound, "No such account."));
474 };
475 let now = rfc3339(now_ms());
476 // A workspace's own account gets a row the first time its terms change.
477 self.db
478 .prepare(
479 "INSERT INTO billing_accounts (id, kind, name, terms_kind, discount_percent, ceiling_micros, note,
480 terms_until, terms_set_by, terms_set_at, created_by, created_at)
481 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?9, ?10)
482 ON CONFLICT (id) DO UPDATE SET terms_kind = ?4, discount_percent = ?5, ceiling_micros = ?6,
483 note = ?7, terms_until = ?8, terms_set_by = ?9, terms_set_at = ?10",
484 )
485 .bind(&[
486 account.id.as_str().into(),
487 if account.kind == AccountKind::Enterprise { "enterprise" } else { "workspace" }.into(),
488 account.name.as_str().into(),
489 kind_text(a.terms.kind).into(),
490 a.terms.discount_percent.into(),
491 a.terms.ceiling_micros.map_or(JsValue::NULL, |c| (c as f64).into()),
492 a.terms.note.trim().into(),
493 optional(a.terms.until.as_deref()),
494 a.by.as_str().into(),
495 now.as_str().into(),
496 ])?
497 .run()
498 .await?;
499 self.audit(&account.id, "terms", &format!("{} → {}", describe(&account.terms), describe(&a.terms)), &a.by)
500 .await?;
501 Ok(Outcome::Ok(self.find_account(&account.id).await?.unwrap_or(account)))
502 }
503
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look504 /// The plan without its price, the plan's caps, a hold on new compute,
505 /// and the account's share of g1t's pools.
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put506 pub(crate) async fn admin_set_allowances(&self, a: AdminSetAllowancesArgs) -> Result<Outcome<BillingAccount>> {
507 if a.by.trim().is_empty() || a.note.trim().is_empty() {
508 return Ok(Outcome::fail(FailureCode::Invalid, "Say who is making the change, and why, in the note."));
509 }
510 let money = |m: Option<i64>| m.is_none_or(|m| (0..=1_000 * g1t_contracts::billing::MICROS_PER_DOLLAR).contains(&m));
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look511 if !money(a.allowances.oss_repo_micros) || !money(a.allowances.trial_micros) || !money(a.allowances.run_cap_micros) || !money(a.allowances.issue_cap_micros) {
512 return Ok(Outcome::fail(FailureCode::Invalid, "A pool share or run cap is between $0 and $1,000."));
513 }
514 if a.allowances.max_concurrent_agents.is_some_and(|n| n == 0 || n > 1_000) {
515 return Ok(Outcome::fail(FailureCode::Invalid, "Agents at once is between 1 and 1,000."));
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put516 }
Audit logs are kept by plan: a week on free, 90 days on the plan, and what staff set for an account in sudo517 if let Some(why) = crate::retention::invalid_days(&self.plans, a.allowances.audit_retention_days) {
518 return Ok(Outcome::fail(FailureCode::Invalid, why));
519 }
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put520 let Some(account) = self.find_account(&a.id).await? else {
521 return Ok(Outcome::fail(FailureCode::NotFound, "No such account."));
522 };
523 let now = rfc3339(now_ms());
524 let opt = |m: Option<i64>| m.map_or(JsValue::NULL, |m| (m as f64).into());
525 // A workspace's own account gets a row the first time anything is set.
526 self.db
527 .prepare(
528 "INSERT INTO billing_accounts (id, kind, name, terms_kind, discount_percent, note, created_by, created_at,
Audit logs are kept by plan: a week on free, 90 days on the plan, and what staff set for an account in sudo529 team_granted, oss_repo_micros, trial_micros, max_concurrent_agents, run_cap_micros, hold, issue_cap_micros,
530 audit_retention_days)
531 VALUES (?1, ?2, ?3, 'standard', 0, '', ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look532 ON CONFLICT (id) DO UPDATE SET team_granted = ?6, oss_repo_micros = ?7, trial_micros = ?8,
Audit logs are kept by plan: a week on free, 90 days on the plan, and what staff set for an account in sudo533 max_concurrent_agents = ?9, run_cap_micros = ?10, hold = ?11, issue_cap_micros = ?12,
534 audit_retention_days = ?13",
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put535 )
536 .bind(&[
537 account.id.as_str().into(),
538 if account.kind == AccountKind::Enterprise { "enterprise" } else { "workspace" }.into(),
539 account.name.as_str().into(),
540 a.by.as_str().into(),
541 now.as_str().into(),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look542 u32::from(a.allowances.plan).into(),
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put543 opt(a.allowances.oss_repo_micros),
544 opt(a.allowances.trial_micros),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look545 a.allowances.max_concurrent_agents.map_or(JsValue::NULL, JsValue::from),
546 opt(a.allowances.run_cap_micros),
547 optional(a.allowances.hold.as_deref().map(str::trim).filter(|h| !h.is_empty())),
548 opt(a.allowances.issue_cap_micros),
Audit logs are kept by plan: a week on free, 90 days on the plan, and what staff set for an account in sudo549 a.allowances.audit_retention_days.map_or(JsValue::NULL, JsValue::from),
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put550 ])?
551 .run()
552 .await?;
553 // A trial amount from staff replaces each workspace's grant, outside
554 // the monthly pool; what was used stays used.
555 if let Some(amount) = a.allowances.trial_micros {
556 for workspace in &account.workspaces {
557 self.db
558 .prepare(
559 "INSERT INTO trial_grants (workspace, month, granted_micros, used_micros, created_at)
560 VALUES (?1, 'staff', ?2, 0, ?3)
561 ON CONFLICT (workspace) DO UPDATE SET month = 'staff', granted_micros = ?2",
562 )
563 .bind(&[workspace.as_str().into(), (amount as f64).into(), now.as_str().into()])?
564 .run()
565 .await?;
566 }
567 }
568 self.audit(
569 &account.id,
570 "allowances",
571 &format!("{} → {}: {}", describe_allowances(&account.allowances), describe_allowances(&a.allowances), a.note.trim()),
572 &a.by,
573 )
574 .await?;
575 Ok(Outcome::Ok(self.find_account(&account.id).await?.unwrap_or(account)))
576 }
577
The balance shows what was spent this month and in total578 pub(crate) async fn admin_create_enterprise(&self, a: AdminCreateEnterpriseArgs) -> Result<Outcome<BillingAccount>> {
579 let name = a.name.trim();
580 if name.is_empty() || a.by.trim().is_empty() {
581 return Ok(Outcome::fail(FailureCode::Invalid, "An enterprise needs a name, and who is making it."));
582 }
583 let now = now_ms();
584 let id = new_id("ent", now).to_lowercase();
585 self.db
586 .prepare(
587 "INSERT INTO billing_accounts (id, kind, name, terms_kind, discount_percent, note, created_by, created_at)
588 VALUES (?, 'enterprise', ?, 'standard', 0, '', ?, ?)",
589 )
590 .bind(&[id.as_str().into(), name.into(), a.by.as_str().into(), rfc3339(now).into()])?
591 .run()
592 .await?;
593 self.audit(&id, "create", &format!("Enterprise {name}"), &a.by).await?;
594 for workspace in &a.workspaces {
595 let workspace = workspace.trim().to_lowercase();
596 if !workspace.is_empty() {
597 self.attach(&workspace, Some(&id), &a.by).await?;
598 }
599 }
600 Ok(match self.find_account(&id).await? {
601 Some(account) => Outcome::Ok(account),
602 None => Outcome::fail(FailureCode::NotFound, "The enterprise was not saved."),
603 })
604 }
605
606 async fn attach(&self, workspace: &str, account: Option<&str>, by: &str) -> Result<()> {
607 let before = self.account_of(workspace).await?;
608 match account {
609 Some(account) => {
610 self.db
611 .prepare(
612 "INSERT INTO account_members (workspace, account_id, added_by, added_at) VALUES (?1, ?2, ?3, ?4)
613 ON CONFLICT (workspace) DO UPDATE SET account_id = ?2, added_by = ?3, added_at = ?4",
614 )
615 .bind(&[workspace.into(), account.into(), by.into(), rfc3339(now_ms()).into()])?
616 .run()
617 .await?;
618 self.audit(account, "attach", &format!("{workspace} joined, from {}", before.name), by).await?;
619 }
620 None => {
621 self.db
622 .prepare("DELETE FROM account_members WHERE workspace = ?")
623 .bind(&[workspace.into()])?
624 .run()
625 .await?;
626 self.audit(&before.id, "detach", &format!("{workspace} left, back to paying for itself"), by).await?;
627 }
628 }
629 Ok(())
630 }
631
632 pub(crate) async fn admin_attach(&self, a: AdminAttachArgs) -> Result<Outcome<BillingAccount>> {
633 let workspace = a.workspace.trim().to_lowercase();
634 if workspace.is_empty() || a.by.trim().is_empty() {
635 return Ok(Outcome::fail(FailureCode::Invalid, "Name the workspace, and who is making the change."));
636 }
637 if let Some(account) = &a.account {
638 match self.account_row(account).await? {
639 Some(row) if row.kind == "enterprise" => {}
640 _ => return Ok(Outcome::fail(FailureCode::NotFound, "Workspaces can only join an enterprise.")),
641 }
642 }
643 self.attach(&workspace, a.account.as_deref(), &a.by).await?;
644 Ok(Outcome::Ok(self.account_of(&workspace).await?))
645 }
646
647 pub(crate) async fn admin_billing_link(
648 &self,
649 a: g1t_contracts::billing::AdminBillingLinkArgs,
650 ) -> Result<Outcome<g1t_contracts::billing::BillingLink>> {
651 let workspace = a.workspace.trim().to_lowercase();
652 if workspace.is_empty() || a.by.trim().is_empty() {
653 return Ok(Outcome::fail(FailureCode::Invalid, "Name the workspace, and who is asking."));
654 }
655 let Some(stripe) = &self.stripe else {
656 return Ok(Outcome::fail(FailureCode::Conflict, "Payments are not set up on this g1t."));
657 };
658 let customer = match self.customer_for(&workspace).await {
659 Ok(customer) => customer,
Usage, Billing settings and prepaid AI credit; fixes from the UX audit660 Err(error) => return Ok(Outcome::fail(FailureCode::Conflict, crate::stripe::friendly(&error))),
The balance shows what was spent this month and in total661 };
662 let link = async {
663 let configuration = stripe.portal_configuration().await?;
664 let portal_url = stripe.portal_session(&customer, "https://g1t.sh/").await?;
665 let customer_email = stripe.customer_email(&customer).await.ok().flatten();
666 Ok::<_, worker::Error>(g1t_contracts::billing::BillingLink {
667 portal_url,
668 login_url: configuration.login_page.and_then(|page| page.url),
669 customer_email,
670 expires_note: "The one-time link works for a short while and only once; the sign-in page does not expire."
671 .to_owned(),
672 })
673 }
674 .await;
675 match link {
676 Ok(link) => {
677 let account = self.account_of(&workspace).await?;
678 self.audit(&account.id, "billing_link", &format!("Stripe billing link for {workspace}"), &a.by).await?;
679 Ok(Outcome::Ok(link))
680 }
Usage, Billing settings and prepaid AI credit; fixes from the UX audit681 Err(error) => Ok(Outcome::fail(FailureCode::Conflict, crate::stripe::friendly(&error))),
The balance shows what was spent this month and in total682 }
683 }
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging684}
The balance shows what was spent this month and in total685
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging686/// Terms as billing keeps them: "comped" is a 100% discount, and custom
687/// terms with nothing in them are standard.
688fn normalized(mut terms: Terms) -> Terms {
689 if terms.kind == TermsKind::Comped {
690 terms.kind = TermsKind::Custom;
691 terms.discount_percent = 100;
692 }
693 if terms.kind == TermsKind::Custom && terms.discount_percent == 0 && terms.ceiling_micros.is_none() {
694 terms.kind = TermsKind::Standard;
695 }
696 if terms.kind == TermsKind::Standard {
697 terms.discount_percent = 0;
698 terms.ceiling_micros = None;
The balance shows what was spent this month and in total699 }
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging700 terms
The balance shows what was spent this month and in total701}
702
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put703/// Allowances as the audit log reads them.
704fn describe_allowances(a: &Allowances) -> String {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look705 let mut parts = vec![if a.plan { "plan given" } else { "plan not given" }.to_owned()];
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put706 if let Some(m) = a.oss_repo_micros {
707 parts.push(format!("open-source share {} a repository", crate::features::dollars(m)));
708 }
709 if let Some(m) = a.trial_micros {
710 parts.push(format!("trial {}", crate::features::dollars(m)));
711 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look712 if let Some(n) = a.max_concurrent_agents {
713 parts.push(format!("{n} agents at once"));
714 }
715 if let Some(m) = a.run_cap_micros {
716 parts.push(format!("run cap {}", crate::features::dollars(m)));
717 }
718 if let Some(m) = a.issue_cap_micros {
719 parts.push(format!("issue cap {}", crate::features::dollars(m)));
720 }
Audit logs are kept by plan: a week on free, 90 days on the plan, and what staff set for an account in sudo721 if let Some(days) = a.audit_retention_days {
722 parts.push(format!("audit log {days} days"));
723 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look724 if let Some(hold) = &a.hold {
725 parts.push(format!("hold: {hold}"));
726 }
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put727 parts.join(", ")
728}
729
The balance shows what was spent this month and in total730/// A workspace's figures in `list`, added at the end the first time.
731fn figures_for<'a>(list: &'a mut Vec<WorkspaceFigures>, workspace: &str) -> &'a mut WorkspaceFigures {
732 let i = match list.iter().position(|f| f.workspace == workspace) {
733 Some(i) => i,
734 None => {
735 list.push(WorkspaceFigures { workspace: workspace.to_owned(), ..Default::default() });
736 list.len() - 1
737 }
738 };
739 &mut list[i]
740}
741
742#[cfg(test)]
743mod tests {
744 use super::*;
745
746 fn terms(kind: TermsKind, discount: u32) -> Terms {
747 Terms { kind, discount_percent: discount, ..Terms::standard() }
748 }
749
750 #[test]
751 fn terms_shape_every_charge() {
752 assert_eq!(terms(TermsKind::Standard, 0).apply(1_000), 1_000);
753 assert_eq!(terms(TermsKind::Comped, 0).apply(1_000), 0);
754 assert_eq!(terms(TermsKind::Custom, 25).apply(1_000), 750);
755 assert_eq!(terms(TermsKind::Custom, 250).apply(1_000), 0);
756 }
757
758 #[test]
Merge branch 'worktree-agent-a633ac0f7f66d419d'759 fn a_discount_below_cost_plus_the_margin_is_counted_as_given() {
760 // $1 of model cost at 20%: $1.20 is the floor of what a sale is worth.
761 let base = crate::charge_micros(1.0, 20);
762 assert_eq!(base, 1_200_000);
763 // Standard: all of it sold, nothing given.
764 assert_eq!(terms(TermsKind::Standard, 0).discounted(base), (1_200_000, 0));
765 // 30% off: charged $0.84, under cost; the $0.36 below the floor is
766 // given, so charged plus given is never under cost plus the margin.
767 let (charged, given) = terms(TermsKind::Custom, 30).discounted(base);
768 assert_eq!((charged, given), (840_000, 360_000));
769 assert_eq!(charged + given, base);
770 // Over 100% is everything given, never a negative charge.
771 assert_eq!(terms(TermsKind::Custom, 250).discounted(base), (0, base));
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging772 // A 100% discount (and "comped", from before discounts) charges
773 // nothing and records the whole price as the discount, so the
774 // statement shows what the workspace would pay.
775 assert_eq!(terms(TermsKind::Custom, 100).discounted(base), (0, base));
776 assert_eq!(terms(TermsKind::Comped, 0).discounted(base), (0, base));
777 assert!(terms(TermsKind::Comped, 0).full_discount() && terms(TermsKind::Custom, 100).full_discount());
778 assert!(!terms(TermsKind::Custom, 99).full_discount() && !Terms::standard().full_discount());
Merge branch 'worktree-agent-a633ac0f7f66d419d'779 // Every discount: charged plus given is the whole charge.
780 for percent in 0..=100 {
781 let (charged, given) = terms(TermsKind::Custom, percent).discounted(base);
782 assert_eq!(charged + given, base, "{percent}% off");
783 }
784 }
785
786 #[test]
The balance shows what was spent this month and in total787 fn terms_read_plainly_in_the_audit_log() {
788 let custom = Terms { ceiling_micros: Some(50_000_000), note: "Design partner".into(), ..terms(TermsKind::Custom, 20) };
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging789 assert_eq!(describe(&custom), "20% off, ceiling $50.00: Design partner");
The balance shows what was spent this month and in total790 assert_eq!(describe(&Terms::standard()), "standard");
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging791 let flagon = Terms { ceiling_micros: Some(150_000_000), note: "g1t's own".into(), ..terms(TermsKind::Custom, 100) };
792 assert_eq!(describe(&flagon), "100% discount, monthly budget $150.00: g1t's own");
793 }
794
795 #[test]
796 fn comped_terms_are_kept_as_a_100_percent_discount() {
797 let comped = normalized(Terms { note: "Partner".into(), ..terms(TermsKind::Comped, 0) });
798 assert_eq!((comped.kind, comped.discount_percent), (TermsKind::Custom, 100));
799 // Nothing in custom terms is standard, and standard keeps nothing.
800 assert_eq!(normalized(terms(TermsKind::Custom, 0)).kind, TermsKind::Standard);
801 let standard = normalized(Terms { ceiling_micros: Some(1), ..terms(TermsKind::Standard, 30) });
802 assert_eq!((standard.discount_percent, standard.ceiling_micros), (0, None));
The balance shows what was spent this month and in total803 }
804
805 #[test]
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put806 fn allowances_read_plainly_in_the_audit_log() {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look807 assert_eq!(describe_allowances(&Allowances::default()), "plan not given");
808 let given = Allowances {
809 plan: true,
810 oss_repo_micros: Some(5_000_000),
811 trial_micros: Some(2_000_000),
812 max_concurrent_agents: Some(4),
813 run_cap_micros: Some(3_000_000),
814 issue_cap_micros: None,
Audit logs are kept by plan: a week on free, 90 days on the plan, and what staff set for an account in sudo815 audit_retention_days: Some(365),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look816 hold: Some("mining".into()),
817 };
818 assert_eq!(
819 describe_allowances(&given),
Audit logs are kept by plan: a week on free, 90 days on the plan, and what staff set for an account in sudo820 "plan given, open-source share $5.00 a repository, trial $2.00, 4 agents at once, run cap $3.00, audit log 365 days, hold: mining"
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look821 );
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put822 }
823
824 #[test]
The balance shows what was spent this month and in total825 fn each_workspace_gets_one_share() {
826 let mut list = vec![];
827 figures_for(&mut list, "acme").charged_micros += 5;
828 figures_for(&mut list, "beta").cost_micros += 2;
829 figures_for(&mut list, "acme").charged_micros += 7;
830 assert_eq!(list.len(), 2);
831 assert_eq!(list[0], WorkspaceFigures { workspace: "acme".into(), charged_micros: 12, ..Default::default() });
832 assert_eq!(list[1].cost_micros, 2);
833 }
834}

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