g1t/services/billing/src/accounts.rs

785 lines32,692 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?;
Billing keeps Stripe's view itself: the saved card on the account, missed events replayed every 15 minutes, and the endpoint kept200 if let Some(link) = linked
201 && let Some(row) = self.account_row(&link.account_id).await? {
Billing accounts, terms and enterprises; g1t is no longer free202 let members = self.members(&row.id).await?;
203 return Ok(self.to_account(&row, members));
204 }
205 let id = own_account(&workspace);
206 Ok(match self.account_row(&id).await? {
207 Some(row) => self.to_account(&row, vec![workspace]),
208 None => BillingAccount {
209 id,
210 kind: AccountKind::Workspace,
211 name: workspace.clone(),
212 terms: Terms::standard(),
213 workspaces: vec![workspace],
214 created_at: String::new(),
Stripe webhooks, enterprise invoices, and sudo for both215 billing_email: None,
216 invoices: vec![],
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put217 allowances: Allowances::default(),
Billing accounts, terms and enterprises; g1t is no longer free218 },
219 })
220 }
221
222 /// The terms a workspace is charged on.
223 pub(crate) async fn terms_of(&self, workspace: &str) -> Result<Terms> {
224 Ok(self.account_of(workspace).await?.terms)
225 }
226
Stripe webhooks, enterprise invoices, and sudo for both227 /// An enterprise, with its invoices.
228 pub(crate) async fn enterprise(&self, id: &str) -> Result<Option<BillingAccount>> {
229 let Some(row) = self.account_row(id).await?.filter(|row| row.kind == "enterprise") else {
230 return Ok(None);
231 };
232 let members = self.members(&row.id).await?;
233 let mut account = self.to_account(&row, members);
234 account.invoices = self.enterprise_invoices(&row.id).await?;
235 Ok(Some(account))
236 }
237
Billing accounts, terms and enterprises; g1t is no longer free238 /// 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 pays239 pub(crate) async fn find_account(&self, id: &str) -> Result<Option<BillingAccount>> {
Billing accounts, terms and enterprises; g1t is no longer free240 let id = id.trim().to_lowercase();
241 if id.starts_with("ent_") {
Stripe webhooks, enterprise invoices, and sudo for both242 return self.enterprise(&id).await;
Billing accounts, terms and enterprises; g1t is no longer free243 }
244 let slug = id.strip_prefix("ws_").unwrap_or(&id);
245 if slug.is_empty() {
246 return Ok(None);
247 }
248 Ok(Some(self.account_of(slug).await?))
249 }
250
Stripe webhooks, enterprise invoices, and sudo for both251 pub(crate) async fn audit(&self, account: &str, action: &str, detail: &str, by: &str) -> Result<()> {
Billing accounts, terms and enterprises; g1t is no longer free252 let now = now_ms();
253 self.db
254 .prepare("INSERT INTO admin_actions (id, account, action, detail, by, created_at) VALUES (?, ?, ?, ?, ?, ?)")
255 .bind(&[
256 new_id("adm", now).into(),
257 account.into(),
258 action.into(),
259 detail.into(),
260 by.into(),
261 rfc3339(now).into(),
262 ])?
263 .run()
264 .await?;
265 Ok(())
266 }
267
268 /// Where an account stands this month.
269 async fn summary(&self, account: BillingAccount) -> Result<AccountSummary> {
270 let first = account.workspaces.first().cloned().unwrap_or_else(|| account.name.clone());
271 let limit = self.limit_of(&first).await?;
272 #[derive(Deserialize)]
273 struct Totals {
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace274 workspace: String,
Billing accounts, terms and enterprises; g1t is no longer free275 charged: Option<i64>,
276 cost: Option<i64>,
277 }
278 #[derive(Deserialize)]
279 struct Paid {
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace280 workspace: String,
Billing accounts, terms and enterprises; g1t is no longer free281 paid: Option<i64>,
282 }
283 let marks = vec!["?"; account.workspaces.len().max(1)].join(", ");
284 let mut values: Vec<JsValue> = account.workspaces.iter().map(|w| JsValue::from(w.as_str())).collect();
285 if values.is_empty() {
286 values.push(JsValue::from(""));
287 }
288 let month_start = format!("{}-01", &rfc3339(now_ms())[..7]);
289 let mut with_month = values.clone();
290 with_month.push(month_start.as_str().into());
291 let totals = self
292 .db
293 .prepare(format!(
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look294 "SELECT workspace, -SUM(amount_micros) AS charged,
295 SUM(CASE WHEN COALESCE(billed_to, 'g1t') = 'g1t' THEN cost_micros ELSE 0 END) AS cost
296 FROM ledger WHERE kind = 'usage' AND workspace IN ({marks}) AND created_at >= ? GROUP BY workspace"
Billing accounts, terms and enterprises; g1t is no longer free297 ))
298 .bind(&with_month)?
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace299 .all()
300 .await?
301 .results::<Totals>()?;
Billing accounts, terms and enterprises; g1t is no longer free302 let paid = self
303 .db
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace304 .prepare(format!(
305 "SELECT workspace, SUM(amount_micros) AS paid FROM ledger
306 WHERE kind = 'top_up' AND workspace IN ({marks}) GROUP BY workspace"
307 ))
Billing accounts, terms and enterprises; g1t is no longer free308 .bind(&values)?
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace309 .all()
310 .await?
311 .results::<Paid>()?;
312 // Each workspace's share, in the account's order; the account's
313 // figures are their sum.
314 let mut by_workspace: Vec<WorkspaceFigures> = vec![];
315 for workspace in &account.workspaces {
316 figures_for(&mut by_workspace, workspace);
317 }
318 for t in &totals {
319 let f = figures_for(&mut by_workspace, &t.workspace);
320 f.charged_micros += t.charged.unwrap_or(0);
321 f.cost_micros += t.cost.unwrap_or(0);
322 }
323 for p in &paid {
324 figures_for(&mut by_workspace, &p.workspace).paid_micros += p.paid.unwrap_or(0);
325 }
Two limits, real invoices, trust that grows by itself, sales signals326 let months = self.months_for(&account.workspaces, 6).await?;
Billing accounts, terms and enterprises; g1t is no longer free327 Ok(AccountSummary {
Two limits, real invoices, trust that grows by itself, sales signals328 months,
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace329 charged_micros: by_workspace.iter().map(|f| f.charged_micros).sum(),
330 cost_micros: by_workspace.iter().map(|f| f.cost_micros).sum(),
331 paid_micros: by_workspace.iter().map(|f| f.paid_micros).sum(),
332 by_workspace,
Billing accounts, terms and enterprises; g1t is no longer free333 account,
334 limit,
335 })
336 }
337
338 // --- Staff ------------------------------------------------------------
339
340 pub(crate) async fn admin_accounts(&self, a: AdminAccountsArgs) -> Result<Vec<AccountSummary>> {
Stripe webhooks, enterprise invoices, and sudo for both341 // Exactly the workspaces asked for, such as one page of sudo's list.
342 if let Some(workspaces) = &a.workspaces {
343 let mut seen = std::collections::HashSet::new();
344 let mut summaries = vec![];
345 for slug in workspaces.iter().take(200) {
346 let account = self.account_of(slug).await?;
347 if seen.insert(account.id.clone()) {
348 summaries.push(self.summary(account).await?);
349 }
350 }
351 return Ok(summaries);
352 }
Billing accounts, terms and enterprises; g1t is no longer free353 // Every workspace that has used or paid for anything, and every
354 // account with terms of its own.
355 #[derive(Deserialize)]
356 struct Slug {
357 workspace: String,
358 }
359 let mut slugs: Vec<String> = self
360 .db
361 .prepare(
362 "SELECT DISTINCT workspace FROM ledger
363 UNION SELECT workspace FROM accounts
364 UNION SELECT substr(id, 4) FROM billing_accounts WHERE kind = 'workspace'",
365 )
366 .all()
367 .await?
368 .results::<Slug>()?
369 .into_iter()
370 .map(|s| s.workspace)
371 .collect();
372 if let Some(query) = a.query.as_deref().map(str::trim).filter(|q| !q.is_empty()) {
373 let query = query.to_lowercase();
374 slugs.retain(|slug| slug.contains(&query));
375 }
376 let mut seen = std::collections::HashSet::new();
377 let mut summaries = vec![];
378 for slug in slugs.into_iter().take(200) {
379 let account = self.account_of(&slug).await?;
380 if !seen.insert(account.id.clone()) {
381 continue;
382 }
383 summaries.push(self.summary(account).await?);
384 }
385 // Enterprises with no usage yet.
386 #[derive(Deserialize)]
387 struct Id {
388 id: String,
389 }
390 let enterprises = self
391 .db
392 .prepare("SELECT id FROM billing_accounts WHERE kind = 'enterprise'")
393 .all()
394 .await?
395 .results::<Id>()?;
396 for Id { id } in enterprises {
397 if seen.contains(&id) {
398 continue;
399 }
Billing keeps Stripe's view itself: the saved card on the account, missed events replayed every 15 minutes, and the endpoint kept400 if let Some(account) = self.find_account(&id).await?
401 && a.query.as_deref().is_none_or(|q| account.name.to_lowercase().contains(&q.to_lowercase())) {
Billing accounts, terms and enterprises; g1t is no longer free402 seen.insert(id);
403 summaries.push(self.summary(account).await?);
404 }
405 }
Billing keeps Stripe's view itself: the saved card on the account, missed events replayed every 15 minutes, and the endpoint kept406 summaries.sort_by_key(|x| std::cmp::Reverse(x.limit.exposure_micros));
Billing accounts, terms and enterprises; g1t is no longer free407 Ok(summaries)
408 }
409
410 pub(crate) async fn admin_account(&self, a: AdminAccountArgs) -> Result<Outcome<AccountDetail>> {
411 let Some(account) = self.find_account(&a.id).await? else {
412 return Ok(Outcome::fail(FailureCode::NotFound, "No such account."));
413 };
414 let mut workspaces = vec![];
415 for workspace in &account.workspaces {
416 workspaces.push(self.limit_of(workspace).await?);
417 }
418 let marks = vec!["?"; account.workspaces.len().max(1)].join(", ");
419 let mut values: Vec<JsValue> = account.workspaces.iter().map(|w| JsValue::from(w.as_str())).collect();
420 if values.is_empty() {
421 values.push(JsValue::from(""));
422 }
423 let ledger = self
424 .db
425 .prepare(format!("SELECT * FROM ledger WHERE workspace IN ({marks}) ORDER BY id DESC LIMIT 100"))
426 .bind(&values)?
427 .all()
428 .await?
429 .results::<LedgerRow>()?
430 .into_iter()
431 .map(LedgerEntry::from)
432 .collect();
433 let audit = self
434 .db
435 .prepare("SELECT * FROM admin_actions WHERE account = ? ORDER BY created_at DESC LIMIT 50")
436 .bind(&[account.id.as_str().into()])?
437 .all()
438 .await?
439 .results::<ActionRow>()?
440 .into_iter()
441 .map(|row| AdminAction {
442 id: row.id,
443 account: row.account,
444 action: row.action,
445 detail: row.detail,
446 by: row.by,
447 created_at: row.created_at,
448 })
449 .collect();
450 Ok(Outcome::Ok(AccountDetail { summary: self.summary(account).await?, workspaces, ledger, audit }))
451 }
452
453 pub(crate) async fn admin_set_terms(&self, a: AdminSetTermsArgs) -> Result<Outcome<BillingAccount>> {
454 if a.by.trim().is_empty() {
455 return Ok(Outcome::fail(FailureCode::Invalid, "Say who is making the change."));
456 }
457 if a.terms.kind != TermsKind::Standard && a.terms.note.trim().is_empty() {
458 return Ok(Outcome::fail(FailureCode::Invalid, "Say why, in the note."));
459 }
460 if a.terms.discount_percent > 100 || a.terms.ceiling_micros.is_some_and(|c| c < 0) {
461 return Ok(Outcome::fail(FailureCode::Invalid, "A discount is 0 to 100%, and a ceiling is not negative."));
462 }
463 let Some(account) = self.find_account(&a.id).await? else {
464 return Ok(Outcome::fail(FailureCode::NotFound, "No such account."));
465 };
466 let now = rfc3339(now_ms());
467 // A workspace's own account gets a row the first time its terms change.
468 self.db
469 .prepare(
470 "INSERT INTO billing_accounts (id, kind, name, terms_kind, discount_percent, ceiling_micros, note,
471 terms_until, terms_set_by, terms_set_at, created_by, created_at)
472 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?9, ?10)
473 ON CONFLICT (id) DO UPDATE SET terms_kind = ?4, discount_percent = ?5, ceiling_micros = ?6,
474 note = ?7, terms_until = ?8, terms_set_by = ?9, terms_set_at = ?10",
475 )
476 .bind(&[
477 account.id.as_str().into(),
478 if account.kind == AccountKind::Enterprise { "enterprise" } else { "workspace" }.into(),
479 account.name.as_str().into(),
480 kind_text(a.terms.kind).into(),
481 a.terms.discount_percent.into(),
482 a.terms.ceiling_micros.map_or(JsValue::NULL, |c| (c as f64).into()),
483 a.terms.note.trim().into(),
484 optional(a.terms.until.as_deref()),
485 a.by.as_str().into(),
486 now.as_str().into(),
487 ])?
488 .run()
489 .await?;
490 self.audit(&account.id, "terms", &format!("{} → {}", describe(&account.terms), describe(&a.terms)), &a.by)
491 .await?;
492 Ok(Outcome::Ok(self.find_account(&account.id).await?.unwrap_or(account)))
493 }
494
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look495 /// The plan without its price, the plan's caps, a hold on new compute,
496 /// 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 put497 pub(crate) async fn admin_set_allowances(&self, a: AdminSetAllowancesArgs) -> Result<Outcome<BillingAccount>> {
498 if a.by.trim().is_empty() || a.note.trim().is_empty() {
499 return Ok(Outcome::fail(FailureCode::Invalid, "Say who is making the change, and why, in the note."));
500 }
501 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 look502 if !money(a.allowances.oss_repo_micros) || !money(a.allowances.trial_micros) || !money(a.allowances.run_cap_micros) || !money(a.allowances.issue_cap_micros) {
503 return Ok(Outcome::fail(FailureCode::Invalid, "A pool share or run cap is between $0 and $1,000."));
504 }
505 if a.allowances.max_concurrent_agents.is_some_and(|n| n == 0 || n > 1_000) {
506 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 put507 }
508 let Some(account) = self.find_account(&a.id).await? else {
509 return Ok(Outcome::fail(FailureCode::NotFound, "No such account."));
510 };
511 let now = rfc3339(now_ms());
512 let opt = |m: Option<i64>| m.map_or(JsValue::NULL, |m| (m as f64).into());
513 // A workspace's own account gets a row the first time anything is set.
514 self.db
515 .prepare(
516 "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 look517 team_granted, oss_repo_micros, trial_micros, max_concurrent_agents, run_cap_micros, hold, issue_cap_micros)
518 VALUES (?1, ?2, ?3, 'standard', 0, '', ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)
519 ON CONFLICT (id) DO UPDATE SET team_granted = ?6, oss_repo_micros = ?7, trial_micros = ?8,
520 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 put521 )
522 .bind(&[
523 account.id.as_str().into(),
524 if account.kind == AccountKind::Enterprise { "enterprise" } else { "workspace" }.into(),
525 account.name.as_str().into(),
526 a.by.as_str().into(),
527 now.as_str().into(),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look528 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 put529 opt(a.allowances.oss_repo_micros),
530 opt(a.allowances.trial_micros),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look531 a.allowances.max_concurrent_agents.map_or(JsValue::NULL, JsValue::from),
532 opt(a.allowances.run_cap_micros),
533 optional(a.allowances.hold.as_deref().map(str::trim).filter(|h| !h.is_empty())),
534 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 put535 ])?
536 .run()
537 .await?;
538 // A trial amount from staff replaces each workspace's grant, outside
539 // the monthly pool; what was used stays used.
540 if let Some(amount) = a.allowances.trial_micros {
541 for workspace in &account.workspaces {
542 self.db
543 .prepare(
544 "INSERT INTO trial_grants (workspace, month, granted_micros, used_micros, created_at)
545 VALUES (?1, 'staff', ?2, 0, ?3)
546 ON CONFLICT (workspace) DO UPDATE SET month = 'staff', granted_micros = ?2",
547 )
548 .bind(&[workspace.as_str().into(), (amount as f64).into(), now.as_str().into()])?
549 .run()
550 .await?;
551 }
552 }
553 self.audit(
554 &account.id,
555 "allowances",
556 &format!("{} → {}: {}", describe_allowances(&account.allowances), describe_allowances(&a.allowances), a.note.trim()),
557 &a.by,
558 )
559 .await?;
560 Ok(Outcome::Ok(self.find_account(&account.id).await?.unwrap_or(account)))
561 }
562
Billing accounts, terms and enterprises; g1t is no longer free563 pub(crate) async fn admin_create_enterprise(&self, a: AdminCreateEnterpriseArgs) -> Result<Outcome<BillingAccount>> {
564 let name = a.name.trim();
565 if name.is_empty() || a.by.trim().is_empty() {
566 return Ok(Outcome::fail(FailureCode::Invalid, "An enterprise needs a name, and who is making it."));
567 }
568 let now = now_ms();
569 let id = new_id("ent", now).to_lowercase();
570 self.db
571 .prepare(
572 "INSERT INTO billing_accounts (id, kind, name, terms_kind, discount_percent, note, created_by, created_at)
573 VALUES (?, 'enterprise', ?, 'standard', 0, '', ?, ?)",
574 )
575 .bind(&[id.as_str().into(), name.into(), a.by.as_str().into(), rfc3339(now).into()])?
576 .run()
577 .await?;
578 self.audit(&id, "create", &format!("Enterprise {name}"), &a.by).await?;
579 for workspace in &a.workspaces {
580 let workspace = workspace.trim().to_lowercase();
581 if !workspace.is_empty() {
582 self.attach(&workspace, Some(&id), &a.by).await?;
583 }
584 }
585 Ok(match self.find_account(&id).await? {
586 Some(account) => Outcome::Ok(account),
587 None => Outcome::fail(FailureCode::NotFound, "The enterprise was not saved."),
588 })
589 }
590
591 async fn attach(&self, workspace: &str, account: Option<&str>, by: &str) -> Result<()> {
592 let before = self.account_of(workspace).await?;
593 match account {
594 Some(account) => {
595 self.db
596 .prepare(
597 "INSERT INTO account_members (workspace, account_id, added_by, added_at) VALUES (?1, ?2, ?3, ?4)
598 ON CONFLICT (workspace) DO UPDATE SET account_id = ?2, added_by = ?3, added_at = ?4",
599 )
600 .bind(&[workspace.into(), account.into(), by.into(), rfc3339(now_ms()).into()])?
601 .run()
602 .await?;
603 self.audit(account, "attach", &format!("{workspace} joined, from {}", before.name), by).await?;
604 }
605 None => {
606 self.db
607 .prepare("DELETE FROM account_members WHERE workspace = ?")
608 .bind(&[workspace.into()])?
609 .run()
610 .await?;
611 self.audit(&before.id, "detach", &format!("{workspace} left, back to paying for itself"), by).await?;
612 }
613 }
614 Ok(())
615 }
616
617 pub(crate) async fn admin_attach(&self, a: AdminAttachArgs) -> Result<Outcome<BillingAccount>> {
618 let workspace = a.workspace.trim().to_lowercase();
619 if workspace.is_empty() || a.by.trim().is_empty() {
620 return Ok(Outcome::fail(FailureCode::Invalid, "Name the workspace, and who is making the change."));
621 }
622 if let Some(account) = &a.account {
623 match self.account_row(account).await? {
624 Some(row) if row.kind == "enterprise" => {}
625 _ => return Ok(Outcome::fail(FailureCode::NotFound, "Workspaces can only join an enterprise.")),
626 }
627 }
628 self.attach(&workspace, a.account.as_deref(), &a.by).await?;
629 Ok(Outcome::Ok(self.account_of(&workspace).await?))
630 }
631
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace632 pub(crate) async fn admin_billing_link(
633 &self,
634 a: g1t_contracts::billing::AdminBillingLinkArgs,
635 ) -> Result<Outcome<g1t_contracts::billing::BillingLink>> {
636 let workspace = a.workspace.trim().to_lowercase();
637 if workspace.is_empty() || a.by.trim().is_empty() {
638 return Ok(Outcome::fail(FailureCode::Invalid, "Name the workspace, and who is asking."));
639 }
640 let Some(stripe) = &self.stripe else {
641 return Ok(Outcome::fail(FailureCode::Conflict, "Payments are not set up on this g1t."));
642 };
643 let customer = match self.customer_for(&workspace).await {
644 Ok(customer) => customer,
645 Err(error) => return Ok(Outcome::fail(FailureCode::Conflict, format!("Stripe could not be reached: {error}"))),
646 };
647 let link = async {
648 let configuration = stripe.portal_configuration().await?;
649 let portal_url = stripe.portal_session(&customer, "https://g1t.sh/").await?;
650 let customer_email = stripe.customer_email(&customer).await.ok().flatten();
651 Ok::<_, worker::Error>(g1t_contracts::billing::BillingLink {
652 portal_url,
653 login_url: configuration.login_page.and_then(|page| page.url),
654 customer_email,
655 expires_note: "The one-time link works for a short while and only once; the sign-in page does not expire."
656 .to_owned(),
657 })
658 }
659 .await;
660 match link {
661 Ok(link) => {
662 let account = self.account_of(&workspace).await?;
663 self.audit(&account.id, "billing_link", &format!("Stripe billing link for {workspace}"), &a.by).await?;
664 Ok(Outcome::Ok(link))
665 }
666 Err(error) => Ok(Outcome::fail(FailureCode::Conflict, format!("Stripe's billing page could not be opened: {error}"))),
667 }
668 }
669
Billing accounts, terms and enterprises; g1t is no longer free670 pub(crate) async fn admin_credit(&self, a: AdminCreditArgs) -> Result<Outcome<LedgerEntry>> {
671 let workspace = a.workspace.trim().to_lowercase();
672 if workspace.is_empty() || a.note.trim().is_empty() || a.by.trim().is_empty() {
673 return Ok(Outcome::fail(FailureCode::Invalid, "A credit needs a workspace, a note and who gave it."));
674 }
675 if a.amount_micros <= 0 || a.amount_micros > 10_000 * g1t_contracts::billing::MICROS_PER_DOLLAR {
676 return Ok(Outcome::fail(FailureCode::Invalid, "A credit is more than $0 and at most $10,000."));
677 }
678 let reference = new_id("crd", now_ms());
679 let description = format!("Credit from g1t: {}", a.note.trim());
680 self.enter(&workspace, EntryKind::TopUp, a.amount_micros, &description, &reference, None, None, Some(&a.by), None)
681 .await?;
682 let account = self.account_of(&workspace).await?;
683 self.audit(&account.id, "credit", &format!("{} to {workspace}: {}", crate::features::dollars(a.amount_micros), a.note.trim()), &a.by)
684 .await?;
685 let row = self
686 .db
687 .prepare("SELECT * FROM ledger WHERE reference = ?")
688 .bind(&[reference.as_str().into()])?
689 .first::<LedgerRow>(None)
690 .await?;
691 Ok(match row {
692 Some(row) => Outcome::Ok(LedgerEntry::from(row)),
693 None => Outcome::fail(FailureCode::NotFound, "The credit was not saved."),
694 })
695 }
696}
697
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put698/// Allowances as the audit log reads them.
699fn describe_allowances(a: &Allowances) -> String {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look700 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 put701 if let Some(m) = a.oss_repo_micros {
702 parts.push(format!("open-source share {} a repository", crate::features::dollars(m)));
703 }
704 if let Some(m) = a.trial_micros {
705 parts.push(format!("trial {}", crate::features::dollars(m)));
706 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look707 if let Some(n) = a.max_concurrent_agents {
708 parts.push(format!("{n} agents at once"));
709 }
710 if let Some(m) = a.run_cap_micros {
711 parts.push(format!("run cap {}", crate::features::dollars(m)));
712 }
713 if let Some(m) = a.issue_cap_micros {
714 parts.push(format!("issue cap {}", crate::features::dollars(m)));
715 }
716 if let Some(hold) = &a.hold {
717 parts.push(format!("hold: {hold}"));
718 }
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put719 parts.join(", ")
720}
721
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace722/// A workspace's figures in `list`, added at the end the first time.
723fn figures_for<'a>(list: &'a mut Vec<WorkspaceFigures>, workspace: &str) -> &'a mut WorkspaceFigures {
724 let i = match list.iter().position(|f| f.workspace == workspace) {
725 Some(i) => i,
726 None => {
727 list.push(WorkspaceFigures { workspace: workspace.to_owned(), ..Default::default() });
728 list.len() - 1
729 }
730 };
731 &mut list[i]
732}
733
Billing accounts, terms and enterprises; g1t is no longer free734#[cfg(test)]
735mod tests {
736 use super::*;
737
738 fn terms(kind: TermsKind, discount: u32) -> Terms {
739 Terms { kind, discount_percent: discount, ..Terms::standard() }
740 }
741
742 #[test]
743 fn terms_shape_every_charge() {
744 assert_eq!(terms(TermsKind::Standard, 0).apply(1_000), 1_000);
745 assert_eq!(terms(TermsKind::Comped, 0).apply(1_000), 0);
746 assert_eq!(terms(TermsKind::Custom, 25).apply(1_000), 750);
747 assert_eq!(terms(TermsKind::Custom, 250).apply(1_000), 0);
748 }
749
750 #[test]
751 fn terms_read_plainly_in_the_audit_log() {
752 let custom = Terms { ceiling_micros: Some(50_000_000), note: "Design partner".into(), ..terms(TermsKind::Custom, 20) };
753 assert_eq!(describe(&custom), "custom (20% off, ceiling $50.00): Design partner");
754 assert_eq!(describe(&Terms::standard()), "standard");
755 }
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace756
757 #[test]
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put758 fn allowances_read_plainly_in_the_audit_log() {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look759 assert_eq!(describe_allowances(&Allowances::default()), "plan not given");
760 let given = Allowances {
761 plan: true,
762 oss_repo_micros: Some(5_000_000),
763 trial_micros: Some(2_000_000),
764 max_concurrent_agents: Some(4),
765 run_cap_micros: Some(3_000_000),
766 issue_cap_micros: None,
767 hold: Some("mining".into()),
768 };
769 assert_eq!(
770 describe_allowances(&given),
771 "plan given, open-source share $5.00 a repository, trial $2.00, 4 agents at once, run cap $3.00, hold: mining"
772 );
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put773 }
774
775 #[test]
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace776 fn each_workspace_gets_one_share() {
777 let mut list = vec![];
778 figures_for(&mut list, "acme").charged_micros += 5;
779 figures_for(&mut list, "beta").cost_micros += 2;
780 figures_for(&mut list, "acme").charged_micros += 7;
781 assert_eq!(list.len(), 2);
782 assert_eq!(list[0], WorkspaceFigures { workspace: "acme".into(), charged_micros: 12, ..Default::default() });
783 assert_eq!(list[1].cost_micros, 2);
784 }
Billing accounts, terms and enterprises; g1t is no longer free785}