flagon-io/g1t

public

Git for AI scale: a forge for thousands of agents working on the same code at once.

g1t/services/billing/src/accounts.rs

787 lines32,719 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.

Billing accounts, terms and enterprises; g1t is no longer free1//! 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.
13//! - **Credits**, such as refunds.
14//!
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,
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put19 AdminCreateEnterpriseArgs, AdminCreditArgs, AdminSetAllowancesArgs, AdminSetTermsArgs, Allowances, BillingAccount,
20 EntryKind, LedgerEntry, Terms, TermsKind, WorkspaceFigures,
Billing accounts, terms and enterprises; g1t is no longer free21};
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,
Stripe webhooks, enterprise invoices, and sudo for both44 #[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)]
59 hold: Option<String>,
Billing accounts, terms and enterprises; g1t is no longer free60}
61
62impl AccountRow {
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put63 fn allowances(&self) -> Allowances {
64 Allowances {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look65 // The column is named for the plan's old name.
66 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 put67 oss_repo_micros: self.oss_repo_micros,
68 trial_micros: self.trial_micros,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look69 max_concurrent_agents: self.max_concurrent_agents,
70 run_cap_micros: self.run_cap_micros,
71 issue_cap_micros: self.issue_cap_micros,
72 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 put73 }
74 }
75}
76
77impl AccountRow {
Billing accounts, terms and enterprises; g1t is no longer free78 fn terms(&self) -> Terms {
79 let expired = self.terms_until.as_deref().is_some_and(|until| until < rfc3339(now_ms()).as_str());
80 if expired {
81 return Terms::standard();
82 }
83 Terms {
84 kind: match self.terms_kind.as_str() {
85 "comped" => TermsKind::Comped,
86 "custom" => TermsKind::Custom,
87 _ => TermsKind::Standard,
88 },
89 discount_percent: self.discount_percent,
90 ceiling_micros: self.ceiling_micros,
91 note: self.note.clone(),
92 until: self.terms_until.clone(),
93 set_by: self.terms_set_by.clone(),
94 set_at: self.terms_set_at.clone(),
95 }
96 }
97}
98
99#[derive(Deserialize)]
100struct Member {
101 workspace: String,
102}
103
104#[derive(Deserialize)]
105struct ActionRow {
106 id: String,
107 account: String,
108 action: String,
109 detail: String,
110 by: String,
111 created_at: String,
112}
113
114/// `ws_<slug>`: a workspace's own account.
115pub(crate) fn own_account(workspace: &str) -> String {
116 format!("ws_{}", workspace.to_lowercase())
117}
118
119fn kind_text(kind: TermsKind) -> &'static str {
120 match kind {
121 TermsKind::Standard => "standard",
122 TermsKind::Comped => "comped",
123 TermsKind::Custom => "custom",
124 }
125}
126
127fn describe(terms: &Terms) -> String {
128 let mut text = match terms.kind {
129 TermsKind::Standard => "standard".to_owned(),
130 TermsKind::Comped => "comped".to_owned(),
131 TermsKind::Custom => {
132 let mut parts = vec![];
133 if terms.discount_percent > 0 {
134 parts.push(format!("{}% off", terms.discount_percent));
135 }
136 if let Some(ceiling) = terms.ceiling_micros {
137 parts.push(format!("ceiling {}", crate::features::dollars(ceiling)));
138 }
139 format!("custom ({})", if parts.is_empty() { "no changes".to_owned() } else { parts.join(", ") })
140 }
141 };
142 if let Some(until) = &terms.until {
143 text.push_str(&format!(" until {}", &until[..until.len().min(10)]));
144 }
145 if !terms.note.is_empty() {
146 text.push_str(&format!(": {}", terms.note));
147 }
148 text
149}
150
151impl Billing {
152 async fn account_row(&self, id: &str) -> Result<Option<AccountRow>> {
153 self.db
154 .prepare("SELECT * FROM billing_accounts WHERE id = ?")
155 .bind(&[id.into()])?
156 .first::<AccountRow>(None)
157 .await
158 }
159
160 async fn members(&self, account: &str) -> Result<Vec<String>> {
161 Ok(self
162 .db
163 .prepare("SELECT workspace FROM account_members WHERE account_id = ? ORDER BY workspace")
164 .bind(&[account.into()])?
165 .all()
166 .await?
167 .results::<Member>()?
168 .into_iter()
169 .map(|m| m.workspace)
170 .collect())
171 }
172
173 fn to_account(&self, row: &AccountRow, workspaces: Vec<String>) -> BillingAccount {
174 BillingAccount {
175 id: row.id.clone(),
176 kind: if row.kind == "enterprise" { AccountKind::Enterprise } else { AccountKind::Workspace },
177 name: row.name.clone(),
178 terms: row.terms(),
179 workspaces,
180 created_at: row.created_at.clone(),
Stripe webhooks, enterprise invoices, and sudo for both181 billing_email: row.billing_email.clone(),
182 invoices: vec![],
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put183 allowances: row.allowances(),
Billing accounts, terms and enterprises; g1t is no longer free184 }
185 }
186
187 /// The account that pays for a workspace.
188 pub(crate) async fn account_of(&self, workspace: &str) -> Result<BillingAccount> {
189 let workspace = workspace.to_lowercase();
190 #[derive(Deserialize)]
191 struct Link {
192 account_id: String,
193 }
194 let linked = self
195 .db
196 .prepare("SELECT account_id FROM account_members WHERE workspace = ?")
197 .bind(&[workspace.as_str().into()])?
198 .first::<Link>(None)
199 .await?;
200 if let Some(link) = linked {
201 if let Some(row) = self.account_row(&link.account_id).await? {
202 let members = self.members(&row.id).await?;
203 return Ok(self.to_account(&row, members));
204 }
205 }
206 let id = own_account(&workspace);
207 Ok(match self.account_row(&id).await? {
208 Some(row) => self.to_account(&row, vec![workspace]),
209 None => BillingAccount {
210 id,
211 kind: AccountKind::Workspace,
212 name: workspace.clone(),
213 terms: Terms::standard(),
214 workspaces: vec![workspace],
215 created_at: String::new(),
Stripe webhooks, enterprise invoices, and sudo for both216 billing_email: None,
217 invoices: vec![],
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put218 allowances: Allowances::default(),
Billing accounts, terms and enterprises; g1t is no longer free219 },
220 })
221 }
222
223 /// The terms a workspace is charged on.
224 pub(crate) async fn terms_of(&self, workspace: &str) -> Result<Terms> {
225 Ok(self.account_of(workspace).await?.terms)
226 }
227
Stripe webhooks, enterprise invoices, and sudo for both228 /// An enterprise, with its invoices.
229 pub(crate) async fn enterprise(&self, id: &str) -> Result<Option<BillingAccount>> {
230 let Some(row) = self.account_row(id).await?.filter(|row| row.kind == "enterprise") else {
231 return Ok(None);
232 };
233 let members = self.members(&row.id).await?;
234 let mut account = self.to_account(&row, members);
235 account.invoices = self.enterprise_invoices(&row.id).await?;
236 Ok(Some(account))
237 }
238
Billing accounts, terms and enterprises; g1t is no longer free239 /// An account by id, or the account of a workspace by its slug.
240 async fn find_account(&self, id: &str) -> Result<Option<BillingAccount>> {
241 let id = id.trim().to_lowercase();
242 if id.starts_with("ent_") {
Stripe webhooks, enterprise invoices, and sudo for both243 return self.enterprise(&id).await;
Billing accounts, terms and enterprises; g1t is no longer free244 }
245 let slug = id.strip_prefix("ws_").unwrap_or(&id);
246 if slug.is_empty() {
247 return Ok(None);
248 }
249 Ok(Some(self.account_of(slug).await?))
250 }
251
Stripe webhooks, enterprise invoices, and sudo for both252 pub(crate) async fn audit(&self, account: &str, action: &str, detail: &str, by: &str) -> Result<()> {
Billing accounts, terms and enterprises; g1t is no longer free253 let now = now_ms();
254 self.db
255 .prepare("INSERT INTO admin_actions (id, account, action, detail, by, created_at) VALUES (?, ?, ?, ?, ?, ?)")
256 .bind(&[
257 new_id("adm", now).into(),
258 account.into(),
259 action.into(),
260 detail.into(),
261 by.into(),
262 rfc3339(now).into(),
263 ])?
264 .run()
265 .await?;
266 Ok(())
267 }
268
269 /// Where an account stands this month.
270 async fn summary(&self, account: BillingAccount) -> Result<AccountSummary> {
271 let first = account.workspaces.first().cloned().unwrap_or_else(|| account.name.clone());
272 let limit = self.limit_of(&first).await?;
273 #[derive(Deserialize)]
274 struct Totals {
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace275 workspace: String,
Billing accounts, terms and enterprises; g1t is no longer free276 charged: Option<i64>,
277 cost: Option<i64>,
278 }
279 #[derive(Deserialize)]
280 struct Paid {
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace281 workspace: String,
Billing accounts, terms and enterprises; g1t is no longer free282 paid: Option<i64>,
283 }
284 let marks = vec!["?"; account.workspaces.len().max(1)].join(", ");
285 let mut values: Vec<JsValue> = account.workspaces.iter().map(|w| JsValue::from(w.as_str())).collect();
286 if values.is_empty() {
287 values.push(JsValue::from(""));
288 }
289 let month_start = format!("{}-01", &rfc3339(now_ms())[..7]);
290 let mut with_month = values.clone();
291 with_month.push(month_start.as_str().into());
292 let totals = self
293 .db
294 .prepare(format!(
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look295 "SELECT workspace, -SUM(amount_micros) AS charged,
296 SUM(CASE WHEN COALESCE(billed_to, 'g1t') = 'g1t' THEN cost_micros ELSE 0 END) AS cost
297 FROM ledger WHERE kind = 'usage' AND workspace IN ({marks}) AND created_at >= ? GROUP BY workspace"
Billing accounts, terms and enterprises; g1t is no longer free298 ))
299 .bind(&with_month)?
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace300 .all()
301 .await?
302 .results::<Totals>()?;
Billing accounts, terms and enterprises; g1t is no longer free303 let paid = self
304 .db
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace305 .prepare(format!(
306 "SELECT workspace, SUM(amount_micros) AS paid FROM ledger
307 WHERE kind = 'top_up' AND workspace IN ({marks}) GROUP BY workspace"
308 ))
Billing accounts, terms and enterprises; g1t is no longer free309 .bind(&values)?
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace310 .all()
311 .await?
312 .results::<Paid>()?;
313 // Each workspace's share, in the account's order; the account's
314 // figures are their sum.
315 let mut by_workspace: Vec<WorkspaceFigures> = vec![];
316 for workspace in &account.workspaces {
317 figures_for(&mut by_workspace, workspace);
318 }
319 for t in &totals {
320 let f = figures_for(&mut by_workspace, &t.workspace);
321 f.charged_micros += t.charged.unwrap_or(0);
322 f.cost_micros += t.cost.unwrap_or(0);
323 }
324 for p in &paid {
325 figures_for(&mut by_workspace, &p.workspace).paid_micros += p.paid.unwrap_or(0);
326 }
Two limits, real invoices, trust that grows by itself, sales signals327 let months = self.months_for(&account.workspaces, 6).await?;
Billing accounts, terms and enterprises; g1t is no longer free328 Ok(AccountSummary {
Two limits, real invoices, trust that grows by itself, sales signals329 months,
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace330 charged_micros: by_workspace.iter().map(|f| f.charged_micros).sum(),
331 cost_micros: by_workspace.iter().map(|f| f.cost_micros).sum(),
332 paid_micros: by_workspace.iter().map(|f| f.paid_micros).sum(),
333 by_workspace,
Billing accounts, terms and enterprises; g1t is no longer free334 account,
335 limit,
336 })
337 }
338
339 // --- Staff ------------------------------------------------------------
340
341 pub(crate) async fn admin_accounts(&self, a: AdminAccountsArgs) -> Result<Vec<AccountSummary>> {
Stripe webhooks, enterprise invoices, and sudo for both342 // Exactly the workspaces asked for, such as one page of sudo's list.
343 if let Some(workspaces) = &a.workspaces {
344 let mut seen = std::collections::HashSet::new();
345 let mut summaries = vec![];
346 for slug in workspaces.iter().take(200) {
347 let account = self.account_of(slug).await?;
348 if seen.insert(account.id.clone()) {
349 summaries.push(self.summary(account).await?);
350 }
351 }
352 return Ok(summaries);
353 }
Billing accounts, terms and enterprises; g1t is no longer free354 // Every workspace that has used or paid for anything, and every
355 // account with terms of its own.
356 #[derive(Deserialize)]
357 struct Slug {
358 workspace: String,
359 }
360 let mut slugs: Vec<String> = self
361 .db
362 .prepare(
363 "SELECT DISTINCT workspace FROM ledger
364 UNION SELECT workspace FROM accounts
365 UNION SELECT substr(id, 4) FROM billing_accounts WHERE kind = 'workspace'",
366 )
367 .all()
368 .await?
369 .results::<Slug>()?
370 .into_iter()
371 .map(|s| s.workspace)
372 .collect();
373 if let Some(query) = a.query.as_deref().map(str::trim).filter(|q| !q.is_empty()) {
374 let query = query.to_lowercase();
375 slugs.retain(|slug| slug.contains(&query));
376 }
377 let mut seen = std::collections::HashSet::new();
378 let mut summaries = vec![];
379 for slug in slugs.into_iter().take(200) {
380 let account = self.account_of(&slug).await?;
381 if !seen.insert(account.id.clone()) {
382 continue;
383 }
384 summaries.push(self.summary(account).await?);
385 }
386 // Enterprises with no usage yet.
387 #[derive(Deserialize)]
388 struct Id {
389 id: String,
390 }
391 let enterprises = self
392 .db
393 .prepare("SELECT id FROM billing_accounts WHERE kind = 'enterprise'")
394 .all()
395 .await?
396 .results::<Id>()?;
397 for Id { id } in enterprises {
398 if seen.contains(&id) {
399 continue;
400 }
401 if let Some(account) = self.find_account(&id).await? {
402 if a.query.as_deref().is_none_or(|q| account.name.to_lowercase().contains(&q.to_lowercase())) {
403 seen.insert(id);
404 summaries.push(self.summary(account).await?);
405 }
406 }
407 }
408 summaries.sort_by(|x, y| y.limit.exposure_micros.cmp(&x.limit.exposure_micros));
409 Ok(summaries)
410 }
411
412 pub(crate) async fn admin_account(&self, a: AdminAccountArgs) -> Result<Outcome<AccountDetail>> {
413 let Some(account) = self.find_account(&a.id).await? else {
414 return Ok(Outcome::fail(FailureCode::NotFound, "No such account."));
415 };
416 let mut workspaces = vec![];
417 for workspace in &account.workspaces {
418 workspaces.push(self.limit_of(workspace).await?);
419 }
420 let marks = vec!["?"; account.workspaces.len().max(1)].join(", ");
421 let mut values: Vec<JsValue> = account.workspaces.iter().map(|w| JsValue::from(w.as_str())).collect();
422 if values.is_empty() {
423 values.push(JsValue::from(""));
424 }
425 let ledger = self
426 .db
427 .prepare(format!("SELECT * FROM ledger WHERE workspace IN ({marks}) ORDER BY id DESC LIMIT 100"))
428 .bind(&values)?
429 .all()
430 .await?
431 .results::<LedgerRow>()?
432 .into_iter()
433 .map(LedgerEntry::from)
434 .collect();
435 let audit = self
436 .db
437 .prepare("SELECT * FROM admin_actions WHERE account = ? ORDER BY created_at DESC LIMIT 50")
438 .bind(&[account.id.as_str().into()])?
439 .all()
440 .await?
441 .results::<ActionRow>()?
442 .into_iter()
443 .map(|row| AdminAction {
444 id: row.id,
445 account: row.account,
446 action: row.action,
447 detail: row.detail,
448 by: row.by,
449 created_at: row.created_at,
450 })
451 .collect();
452 Ok(Outcome::Ok(AccountDetail { summary: self.summary(account).await?, workspaces, ledger, audit }))
453 }
454
455 pub(crate) async fn admin_set_terms(&self, a: AdminSetTermsArgs) -> Result<Outcome<BillingAccount>> {
456 if a.by.trim().is_empty() {
457 return Ok(Outcome::fail(FailureCode::Invalid, "Say who is making the change."));
458 }
459 if a.terms.kind != TermsKind::Standard && a.terms.note.trim().is_empty() {
460 return Ok(Outcome::fail(FailureCode::Invalid, "Say why, in the note."));
461 }
462 if a.terms.discount_percent > 100 || a.terms.ceiling_micros.is_some_and(|c| c < 0) {
463 return Ok(Outcome::fail(FailureCode::Invalid, "A discount is 0 to 100%, and a ceiling is not negative."));
464 }
465 let Some(account) = self.find_account(&a.id).await? else {
466 return Ok(Outcome::fail(FailureCode::NotFound, "No such account."));
467 };
468 let now = rfc3339(now_ms());
469 // A workspace's own account gets a row the first time its terms change.
470 self.db
471 .prepare(
472 "INSERT INTO billing_accounts (id, kind, name, terms_kind, discount_percent, ceiling_micros, note,
473 terms_until, terms_set_by, terms_set_at, created_by, created_at)
474 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?9, ?10)
475 ON CONFLICT (id) DO UPDATE SET terms_kind = ?4, discount_percent = ?5, ceiling_micros = ?6,
476 note = ?7, terms_until = ?8, terms_set_by = ?9, terms_set_at = ?10",
477 )
478 .bind(&[
479 account.id.as_str().into(),
480 if account.kind == AccountKind::Enterprise { "enterprise" } else { "workspace" }.into(),
481 account.name.as_str().into(),
482 kind_text(a.terms.kind).into(),
483 a.terms.discount_percent.into(),
484 a.terms.ceiling_micros.map_or(JsValue::NULL, |c| (c as f64).into()),
485 a.terms.note.trim().into(),
486 optional(a.terms.until.as_deref()),
487 a.by.as_str().into(),
488 now.as_str().into(),
489 ])?
490 .run()
491 .await?;
492 self.audit(&account.id, "terms", &format!("{} → {}", describe(&account.terms), describe(&a.terms)), &a.by)
493 .await?;
494 Ok(Outcome::Ok(self.find_account(&account.id).await?.unwrap_or(account)))
495 }
496
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look497 /// The plan without its price, the plan's caps, a hold on new compute,
498 /// 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 put499 pub(crate) async fn admin_set_allowances(&self, a: AdminSetAllowancesArgs) -> Result<Outcome<BillingAccount>> {
500 if a.by.trim().is_empty() || a.note.trim().is_empty() {
501 return Ok(Outcome::fail(FailureCode::Invalid, "Say who is making the change, and why, in the note."));
502 }
503 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 look504 if !money(a.allowances.oss_repo_micros) || !money(a.allowances.trial_micros) || !money(a.allowances.run_cap_micros) || !money(a.allowances.issue_cap_micros) {
505 return Ok(Outcome::fail(FailureCode::Invalid, "A pool share or run cap is between $0 and $1,000."));
506 }
507 if a.allowances.max_concurrent_agents.is_some_and(|n| n == 0 || n > 1_000) {
508 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 put509 }
510 let Some(account) = self.find_account(&a.id).await? else {
511 return Ok(Outcome::fail(FailureCode::NotFound, "No such account."));
512 };
513 let now = rfc3339(now_ms());
514 let opt = |m: Option<i64>| m.map_or(JsValue::NULL, |m| (m as f64).into());
515 // A workspace's own account gets a row the first time anything is set.
516 self.db
517 .prepare(
518 "INSERT INTO billing_accounts (id, kind, name, terms_kind, discount_percent, note, created_by, created_at,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look519 team_granted, oss_repo_micros, trial_micros, max_concurrent_agents, run_cap_micros, hold, issue_cap_micros)
520 VALUES (?1, ?2, ?3, 'standard', 0, '', ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)
521 ON CONFLICT (id) DO UPDATE SET team_granted = ?6, oss_repo_micros = ?7, trial_micros = ?8,
522 max_concurrent_agents = ?9, run_cap_micros = ?10, hold = ?11, issue_cap_micros = ?12",
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put523 )
524 .bind(&[
525 account.id.as_str().into(),
526 if account.kind == AccountKind::Enterprise { "enterprise" } else { "workspace" }.into(),
527 account.name.as_str().into(),
528 a.by.as_str().into(),
529 now.as_str().into(),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look530 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 put531 opt(a.allowances.oss_repo_micros),
532 opt(a.allowances.trial_micros),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look533 a.allowances.max_concurrent_agents.map_or(JsValue::NULL, JsValue::from),
534 opt(a.allowances.run_cap_micros),
535 optional(a.allowances.hold.as_deref().map(str::trim).filter(|h| !h.is_empty())),
536 opt(a.allowances.issue_cap_micros),
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put537 ])?
538 .run()
539 .await?;
540 // A trial amount from staff replaces each workspace's grant, outside
541 // the monthly pool; what was used stays used.
542 if let Some(amount) = a.allowances.trial_micros {
543 for workspace in &account.workspaces {
544 self.db
545 .prepare(
546 "INSERT INTO trial_grants (workspace, month, granted_micros, used_micros, created_at)
547 VALUES (?1, 'staff', ?2, 0, ?3)
548 ON CONFLICT (workspace) DO UPDATE SET month = 'staff', granted_micros = ?2",
549 )
550 .bind(&[workspace.as_str().into(), (amount as f64).into(), now.as_str().into()])?
551 .run()
552 .await?;
553 }
554 }
555 self.audit(
556 &account.id,
557 "allowances",
558 &format!("{} → {}: {}", describe_allowances(&account.allowances), describe_allowances(&a.allowances), a.note.trim()),
559 &a.by,
560 )
561 .await?;
562 Ok(Outcome::Ok(self.find_account(&account.id).await?.unwrap_or(account)))
563 }
564
Billing accounts, terms and enterprises; g1t is no longer free565 pub(crate) async fn admin_create_enterprise(&self, a: AdminCreateEnterpriseArgs) -> Result<Outcome<BillingAccount>> {
566 let name = a.name.trim();
567 if name.is_empty() || a.by.trim().is_empty() {
568 return Ok(Outcome::fail(FailureCode::Invalid, "An enterprise needs a name, and who is making it."));
569 }
570 let now = now_ms();
571 let id = new_id("ent", now).to_lowercase();
572 self.db
573 .prepare(
574 "INSERT INTO billing_accounts (id, kind, name, terms_kind, discount_percent, note, created_by, created_at)
575 VALUES (?, 'enterprise', ?, 'standard', 0, '', ?, ?)",
576 )
577 .bind(&[id.as_str().into(), name.into(), a.by.as_str().into(), rfc3339(now).into()])?
578 .run()
579 .await?;
580 self.audit(&id, "create", &format!("Enterprise {name}"), &a.by).await?;
581 for workspace in &a.workspaces {
582 let workspace = workspace.trim().to_lowercase();
583 if !workspace.is_empty() {
584 self.attach(&workspace, Some(&id), &a.by).await?;
585 }
586 }
587 Ok(match self.find_account(&id).await? {
588 Some(account) => Outcome::Ok(account),
589 None => Outcome::fail(FailureCode::NotFound, "The enterprise was not saved."),
590 })
591 }
592
593 async fn attach(&self, workspace: &str, account: Option<&str>, by: &str) -> Result<()> {
594 let before = self.account_of(workspace).await?;
595 match account {
596 Some(account) => {
597 self.db
598 .prepare(
599 "INSERT INTO account_members (workspace, account_id, added_by, added_at) VALUES (?1, ?2, ?3, ?4)
600 ON CONFLICT (workspace) DO UPDATE SET account_id = ?2, added_by = ?3, added_at = ?4",
601 )
602 .bind(&[workspace.into(), account.into(), by.into(), rfc3339(now_ms()).into()])?
603 .run()
604 .await?;
605 self.audit(account, "attach", &format!("{workspace} joined, from {}", before.name), by).await?;
606 }
607 None => {
608 self.db
609 .prepare("DELETE FROM account_members WHERE workspace = ?")
610 .bind(&[workspace.into()])?
611 .run()
612 .await?;
613 self.audit(&before.id, "detach", &format!("{workspace} left, back to paying for itself"), by).await?;
614 }
615 }
616 Ok(())
617 }
618
619 pub(crate) async fn admin_attach(&self, a: AdminAttachArgs) -> Result<Outcome<BillingAccount>> {
620 let workspace = a.workspace.trim().to_lowercase();
621 if workspace.is_empty() || a.by.trim().is_empty() {
622 return Ok(Outcome::fail(FailureCode::Invalid, "Name the workspace, and who is making the change."));
623 }
624 if let Some(account) = &a.account {
625 match self.account_row(account).await? {
626 Some(row) if row.kind == "enterprise" => {}
627 _ => return Ok(Outcome::fail(FailureCode::NotFound, "Workspaces can only join an enterprise.")),
628 }
629 }
630 self.attach(&workspace, a.account.as_deref(), &a.by).await?;
631 Ok(Outcome::Ok(self.account_of(&workspace).await?))
632 }
633
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace634 pub(crate) async fn admin_billing_link(
635 &self,
636 a: g1t_contracts::billing::AdminBillingLinkArgs,
637 ) -> Result<Outcome<g1t_contracts::billing::BillingLink>> {
638 let workspace = a.workspace.trim().to_lowercase();
639 if workspace.is_empty() || a.by.trim().is_empty() {
640 return Ok(Outcome::fail(FailureCode::Invalid, "Name the workspace, and who is asking."));
641 }
642 let Some(stripe) = &self.stripe else {
643 return Ok(Outcome::fail(FailureCode::Conflict, "Payments are not set up on this g1t."));
644 };
645 let customer = match self.customer_for(&workspace).await {
646 Ok(customer) => customer,
647 Err(error) => return Ok(Outcome::fail(FailureCode::Conflict, format!("Stripe could not be reached: {error}"))),
648 };
649 let link = async {
650 let configuration = stripe.portal_configuration().await?;
651 let portal_url = stripe.portal_session(&customer, "https://g1t.sh/").await?;
652 let customer_email = stripe.customer_email(&customer).await.ok().flatten();
653 Ok::<_, worker::Error>(g1t_contracts::billing::BillingLink {
654 portal_url,
655 login_url: configuration.login_page.and_then(|page| page.url),
656 customer_email,
657 expires_note: "The one-time link works for a short while and only once; the sign-in page does not expire."
658 .to_owned(),
659 })
660 }
661 .await;
662 match link {
663 Ok(link) => {
664 let account = self.account_of(&workspace).await?;
665 self.audit(&account.id, "billing_link", &format!("Stripe billing link for {workspace}"), &a.by).await?;
666 Ok(Outcome::Ok(link))
667 }
668 Err(error) => Ok(Outcome::fail(FailureCode::Conflict, format!("Stripe's billing page could not be opened: {error}"))),
669 }
670 }
671
Billing accounts, terms and enterprises; g1t is no longer free672 pub(crate) async fn admin_credit(&self, a: AdminCreditArgs) -> Result<Outcome<LedgerEntry>> {
673 let workspace = a.workspace.trim().to_lowercase();
674 if workspace.is_empty() || a.note.trim().is_empty() || a.by.trim().is_empty() {
675 return Ok(Outcome::fail(FailureCode::Invalid, "A credit needs a workspace, a note and who gave it."));
676 }
677 if a.amount_micros <= 0 || a.amount_micros > 10_000 * g1t_contracts::billing::MICROS_PER_DOLLAR {
678 return Ok(Outcome::fail(FailureCode::Invalid, "A credit is more than $0 and at most $10,000."));
679 }
680 let reference = new_id("crd", now_ms());
681 let description = format!("Credit from g1t: {}", a.note.trim());
682 self.enter(&workspace, EntryKind::TopUp, a.amount_micros, &description, &reference, None, None, Some(&a.by), None)
683 .await?;
684 let account = self.account_of(&workspace).await?;
685 self.audit(&account.id, "credit", &format!("{} to {workspace}: {}", crate::features::dollars(a.amount_micros), a.note.trim()), &a.by)
686 .await?;
687 let row = self
688 .db
689 .prepare("SELECT * FROM ledger WHERE reference = ?")
690 .bind(&[reference.as_str().into()])?
691 .first::<LedgerRow>(None)
692 .await?;
693 Ok(match row {
694 Some(row) => Outcome::Ok(LedgerEntry::from(row)),
695 None => Outcome::fail(FailureCode::NotFound, "The credit was not saved."),
696 })
697 }
698}
699
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put700/// Allowances as the audit log reads them.
701fn describe_allowances(a: &Allowances) -> String {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look702 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 put703 if let Some(m) = a.oss_repo_micros {
704 parts.push(format!("open-source share {} a repository", crate::features::dollars(m)));
705 }
706 if let Some(m) = a.trial_micros {
707 parts.push(format!("trial {}", crate::features::dollars(m)));
708 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look709 if let Some(n) = a.max_concurrent_agents {
710 parts.push(format!("{n} agents at once"));
711 }
712 if let Some(m) = a.run_cap_micros {
713 parts.push(format!("run cap {}", crate::features::dollars(m)));
714 }
715 if let Some(m) = a.issue_cap_micros {
716 parts.push(format!("issue cap {}", crate::features::dollars(m)));
717 }
718 if let Some(hold) = &a.hold {
719 parts.push(format!("hold: {hold}"));
720 }
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put721 parts.join(", ")
722}
723
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace724/// A workspace's figures in `list`, added at the end the first time.
725fn figures_for<'a>(list: &'a mut Vec<WorkspaceFigures>, workspace: &str) -> &'a mut WorkspaceFigures {
726 let i = match list.iter().position(|f| f.workspace == workspace) {
727 Some(i) => i,
728 None => {
729 list.push(WorkspaceFigures { workspace: workspace.to_owned(), ..Default::default() });
730 list.len() - 1
731 }
732 };
733 &mut list[i]
734}
735
Billing accounts, terms and enterprises; g1t is no longer free736#[cfg(test)]
737mod tests {
738 use super::*;
739
740 fn terms(kind: TermsKind, discount: u32) -> Terms {
741 Terms { kind, discount_percent: discount, ..Terms::standard() }
742 }
743
744 #[test]
745 fn terms_shape_every_charge() {
746 assert_eq!(terms(TermsKind::Standard, 0).apply(1_000), 1_000);
747 assert_eq!(terms(TermsKind::Comped, 0).apply(1_000), 0);
748 assert_eq!(terms(TermsKind::Custom, 25).apply(1_000), 750);
749 assert_eq!(terms(TermsKind::Custom, 250).apply(1_000), 0);
750 }
751
752 #[test]
753 fn terms_read_plainly_in_the_audit_log() {
754 let custom = Terms { ceiling_micros: Some(50_000_000), note: "Design partner".into(), ..terms(TermsKind::Custom, 20) };
755 assert_eq!(describe(&custom), "custom (20% off, ceiling $50.00): Design partner");
756 assert_eq!(describe(&Terms::standard()), "standard");
757 }
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace758
759 #[test]
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put760 fn allowances_read_plainly_in_the_audit_log() {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look761 assert_eq!(describe_allowances(&Allowances::default()), "plan not given");
762 let given = Allowances {
763 plan: true,
764 oss_repo_micros: Some(5_000_000),
765 trial_micros: Some(2_000_000),
766 max_concurrent_agents: Some(4),
767 run_cap_micros: Some(3_000_000),
768 issue_cap_micros: None,
769 hold: Some("mining".into()),
770 };
771 assert_eq!(
772 describe_allowances(&given),
773 "plan given, open-source share $5.00 a repository, trial $2.00, 4 agents at once, run cap $3.00, hold: mining"
774 );
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put775 }
776
777 #[test]
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace778 fn each_workspace_gets_one_share() {
779 let mut list = vec![];
780 figures_for(&mut list, "acme").charged_micros += 5;
781 figures_for(&mut list, "beta").cost_micros += 2;
782 figures_for(&mut list, "acme").charged_micros += 7;
783 assert_eq!(list.len(), 2);
784 assert_eq!(list[0], WorkspaceFigures { workspace: "acme".into(), charged_micros: 12, ..Default::default() });
785 assert_eq!(list[1].cost_micros, 2);
786 }
Billing accounts, terms and enterprises; g1t is no longer free787}