g1t/services/billing/src/accounts.rs

798 lines33,352 bytesCodeBlame
1//! 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,
19 AdminCreateEnterpriseArgs, AdminCreditArgs, AdminSetAllowancesArgs, AdminSetTermsArgs, Allowances, BillingAccount,
20 EntryKind, LedgerEntry, Terms, TermsKind, WorkspaceFigures,
21};
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>,
46 #[serde(default)]
47 team_granted: Option<i64>,
48 #[serde(default)]
49 oss_repo_micros: Option<i64>,
50 #[serde(default)]
51 trial_micros: Option<i64>,
52 #[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 audit_retention_days: Option<u32>,
60 #[serde(default)]
61 hold: Option<String>,
62}
63
64impl AccountRow {
65 fn allowances(&self) -> Allowances {
66 Allowances {
67 // The column is named for the plan's old name.
68 plan: self.team_granted.unwrap_or(0) != 0,
69 oss_repo_micros: self.oss_repo_micros,
70 trial_micros: self.trial_micros,
71 max_concurrent_agents: self.max_concurrent_agents,
72 run_cap_micros: self.run_cap_micros,
73 issue_cap_micros: self.issue_cap_micros,
74 audit_retention_days: self.audit_retention_days,
75 hold: self.hold.clone().filter(|h| !h.trim().is_empty()),
76 }
77 }
78}
79
80impl AccountRow {
81 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(),
133 TermsKind::Comped => "comped".to_owned(),
134 TermsKind::Custom => {
135 let mut parts = vec![];
136 if terms.discount_percent > 0 {
137 parts.push(format!("{}% off", terms.discount_percent));
138 }
139 if let Some(ceiling) = terms.ceiling_micros {
140 parts.push(format!("ceiling {}", crate::features::dollars(ceiling)));
141 }
142 format!("custom ({})", if parts.is_empty() { "no changes".to_owned() } else { parts.join(", ") })
143 }
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![],
186 allowances: row.allowances(),
187 }
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?;
203 if let Some(link) = linked
204 && let Some(row) = self.account_row(&link.account_id).await? {
205 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![],
220 allowances: Allowances::default(),
221 },
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.
242 pub(crate) async fn find_account(&self, id: &str) -> Result<Option<BillingAccount>> {
243 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<()> {
255 let now = now_ms();
256 self.db
257 .prepare("INSERT INTO admin_actions (id, account, action, detail, by, created_at) VALUES (?, ?, ?, ?, ?, ?)")
258 .bind(&[
259 new_id("adm", now).into(),
260 account.into(),
261 action.into(),
262 detail.into(),
263 by.into(),
264 rfc3339(now).into(),
265 ])?
266 .run()
267 .await?;
268 Ok(())
269 }
270
271 /// Where an account stands this month.
272 async fn summary(&self, account: BillingAccount) -> Result<AccountSummary> {
273 let first = account.workspaces.first().cloned().unwrap_or_else(|| account.name.clone());
274 let limit = self.limit_of(&first).await?;
275 #[derive(Deserialize)]
276 struct Totals {
277 workspace: String,
278 charged: Option<i64>,
279 cost: Option<i64>,
280 }
281 #[derive(Deserialize)]
282 struct Paid {
283 workspace: String,
284 paid: Option<i64>,
285 }
286 let marks = vec!["?"; account.workspaces.len().max(1)].join(", ");
287 let mut values: Vec<JsValue> = account.workspaces.iter().map(|w| JsValue::from(w.as_str())).collect();
288 if values.is_empty() {
289 values.push(JsValue::from(""));
290 }
291 let month_start = format!("{}-01", &rfc3339(now_ms())[..7]);
292 let mut with_month = values.clone();
293 with_month.push(month_start.as_str().into());
294 let totals = self
295 .db
296 .prepare(format!(
297 "SELECT workspace, -SUM(amount_micros) AS charged,
298 SUM(CASE WHEN COALESCE(billed_to, 'g1t') = 'g1t' THEN cost_micros ELSE 0 END) AS cost
299 FROM ledger WHERE kind = 'usage' AND workspace IN ({marks}) AND created_at >= ? GROUP BY workspace"
300 ))
301 .bind(&with_month)?
302 .all()
303 .await?
304 .results::<Totals>()?;
305 let paid = self
306 .db
307 .prepare(format!(
308 "SELECT workspace, SUM(amount_micros) AS paid FROM ledger
309 WHERE kind = 'top_up' AND workspace IN ({marks}) GROUP BY workspace"
310 ))
311 .bind(&values)?
312 .all()
313 .await?
314 .results::<Paid>()?;
315 // Each workspace's share, in the account's order; the account's
316 // figures are their sum.
317 let mut by_workspace: Vec<WorkspaceFigures> = vec![];
318 for workspace in &account.workspaces {
319 figures_for(&mut by_workspace, workspace);
320 }
321 for t in &totals {
322 let f = figures_for(&mut by_workspace, &t.workspace);
323 f.charged_micros += t.charged.unwrap_or(0);
324 f.cost_micros += t.cost.unwrap_or(0);
325 }
326 for p in &paid {
327 figures_for(&mut by_workspace, &p.workspace).paid_micros += p.paid.unwrap_or(0);
328 }
329 let months = self.months_for(&account.workspaces, 6).await?;
330 Ok(AccountSummary {
331 months,
332 charged_micros: by_workspace.iter().map(|f| f.charged_micros).sum(),
333 cost_micros: by_workspace.iter().map(|f| f.cost_micros).sum(),
334 paid_micros: by_workspace.iter().map(|f| f.paid_micros).sum(),
335 by_workspace,
336 account,
337 limit,
338 })
339 }
340
341 // --- Staff ------------------------------------------------------------
342
343 pub(crate) async fn admin_accounts(&self, a: AdminAccountsArgs) -> Result<Vec<AccountSummary>> {
344 // Exactly the workspaces asked for, such as one page of sudo's list.
345 if let Some(workspaces) = &a.workspaces {
346 let mut seen = std::collections::HashSet::new();
347 let mut summaries = vec![];
348 for slug in workspaces.iter().take(200) {
349 let account = self.account_of(slug).await?;
350 if seen.insert(account.id.clone()) {
351 summaries.push(self.summary(account).await?);
352 }
353 }
354 return Ok(summaries);
355 }
356 // Every workspace that has used or paid for anything, and every
357 // account with terms of its own.
358 #[derive(Deserialize)]
359 struct Slug {
360 workspace: String,
361 }
362 let mut slugs: Vec<String> = self
363 .db
364 .prepare(
365 "SELECT DISTINCT workspace FROM ledger
366 UNION SELECT workspace FROM accounts
367 UNION SELECT substr(id, 4) FROM billing_accounts WHERE kind = 'workspace'",
368 )
369 .all()
370 .await?
371 .results::<Slug>()?
372 .into_iter()
373 .map(|s| s.workspace)
374 .collect();
375 if let Some(query) = a.query.as_deref().map(str::trim).filter(|q| !q.is_empty()) {
376 let query = query.to_lowercase();
377 slugs.retain(|slug| slug.contains(&query));
378 }
379 let mut seen = std::collections::HashSet::new();
380 let mut summaries = vec![];
381 for slug in slugs.into_iter().take(200) {
382 let account = self.account_of(&slug).await?;
383 if !seen.insert(account.id.clone()) {
384 continue;
385 }
386 summaries.push(self.summary(account).await?);
387 }
388 // Enterprises with no usage yet.
389 #[derive(Deserialize)]
390 struct Id {
391 id: String,
392 }
393 let enterprises = self
394 .db
395 .prepare("SELECT id FROM billing_accounts WHERE kind = 'enterprise'")
396 .all()
397 .await?
398 .results::<Id>()?;
399 for Id { id } in enterprises {
400 if seen.contains(&id) {
401 continue;
402 }
403 if let Some(account) = self.find_account(&id).await?
404 && a.query.as_deref().is_none_or(|q| account.name.to_lowercase().contains(&q.to_lowercase())) {
405 seen.insert(id);
406 summaries.push(self.summary(account).await?);
407 }
408 }
409 summaries.sort_by_key(|x| std::cmp::Reverse(x.limit.exposure_micros));
410 Ok(summaries)
411 }
412
413 pub(crate) async fn admin_account(&self, a: AdminAccountArgs) -> Result<Outcome<AccountDetail>> {
414 let Some(account) = self.find_account(&a.id).await? else {
415 return Ok(Outcome::fail(FailureCode::NotFound, "No such account."));
416 };
417 let mut workspaces = vec![];
418 for workspace in &account.workspaces {
419 workspaces.push(self.limit_of(workspace).await?);
420 }
421 let marks = vec!["?"; account.workspaces.len().max(1)].join(", ");
422 let mut values: Vec<JsValue> = account.workspaces.iter().map(|w| JsValue::from(w.as_str())).collect();
423 if values.is_empty() {
424 values.push(JsValue::from(""));
425 }
426 let ledger = self
427 .db
428 .prepare(format!("SELECT * FROM ledger WHERE workspace IN ({marks}) ORDER BY id DESC LIMIT 100"))
429 .bind(&values)?
430 .all()
431 .await?
432 .results::<LedgerRow>()?
433 .into_iter()
434 .map(LedgerEntry::from)
435 .collect();
436 let audit = self
437 .db
438 .prepare("SELECT * FROM admin_actions WHERE account = ? ORDER BY created_at DESC LIMIT 50")
439 .bind(&[account.id.as_str().into()])?
440 .all()
441 .await?
442 .results::<ActionRow>()?
443 .into_iter()
444 .map(|row| AdminAction {
445 id: row.id,
446 account: row.account,
447 action: row.action,
448 detail: row.detail,
449 by: row.by,
450 created_at: row.created_at,
451 })
452 .collect();
453 Ok(Outcome::Ok(AccountDetail { summary: self.summary(account).await?, workspaces, ledger, audit }))
454 }
455
456 pub(crate) async fn admin_set_terms(&self, a: AdminSetTermsArgs) -> Result<Outcome<BillingAccount>> {
457 if a.by.trim().is_empty() {
458 return Ok(Outcome::fail(FailureCode::Invalid, "Say who is making the change."));
459 }
460 if a.terms.kind != TermsKind::Standard && a.terms.note.trim().is_empty() {
461 return Ok(Outcome::fail(FailureCode::Invalid, "Say why, in the note."));
462 }
463 if a.terms.discount_percent > 100 || a.terms.ceiling_micros.is_some_and(|c| c < 0) {
464 return Ok(Outcome::fail(FailureCode::Invalid, "A discount is 0 to 100%, and a ceiling is not negative."));
465 }
466 let Some(account) = self.find_account(&a.id).await? else {
467 return Ok(Outcome::fail(FailureCode::NotFound, "No such account."));
468 };
469 let now = rfc3339(now_ms());
470 // A workspace's own account gets a row the first time its terms change.
471 self.db
472 .prepare(
473 "INSERT INTO billing_accounts (id, kind, name, terms_kind, discount_percent, ceiling_micros, note,
474 terms_until, terms_set_by, terms_set_at, created_by, created_at)
475 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?9, ?10)
476 ON CONFLICT (id) DO UPDATE SET terms_kind = ?4, discount_percent = ?5, ceiling_micros = ?6,
477 note = ?7, terms_until = ?8, terms_set_by = ?9, terms_set_at = ?10",
478 )
479 .bind(&[
480 account.id.as_str().into(),
481 if account.kind == AccountKind::Enterprise { "enterprise" } else { "workspace" }.into(),
482 account.name.as_str().into(),
483 kind_text(a.terms.kind).into(),
484 a.terms.discount_percent.into(),
485 a.terms.ceiling_micros.map_or(JsValue::NULL, |c| (c as f64).into()),
486 a.terms.note.trim().into(),
487 optional(a.terms.until.as_deref()),
488 a.by.as_str().into(),
489 now.as_str().into(),
490 ])?
491 .run()
492 .await?;
493 self.audit(&account.id, "terms", &format!("{} → {}", describe(&account.terms), describe(&a.terms)), &a.by)
494 .await?;
495 Ok(Outcome::Ok(self.find_account(&account.id).await?.unwrap_or(account)))
496 }
497
498 /// The plan without its price, the plan's caps, a hold on new compute,
499 /// and the account's share of g1t's pools.
500 pub(crate) async fn admin_set_allowances(&self, a: AdminSetAllowancesArgs) -> Result<Outcome<BillingAccount>> {
501 if a.by.trim().is_empty() || a.note.trim().is_empty() {
502 return Ok(Outcome::fail(FailureCode::Invalid, "Say who is making the change, and why, in the note."));
503 }
504 let money = |m: Option<i64>| m.is_none_or(|m| (0..=1_000 * g1t_contracts::billing::MICROS_PER_DOLLAR).contains(&m));
505 if !money(a.allowances.oss_repo_micros) || !money(a.allowances.trial_micros) || !money(a.allowances.run_cap_micros) || !money(a.allowances.issue_cap_micros) {
506 return Ok(Outcome::fail(FailureCode::Invalid, "A pool share or run cap is between $0 and $1,000."));
507 }
508 if a.allowances.max_concurrent_agents.is_some_and(|n| n == 0 || n > 1_000) {
509 return Ok(Outcome::fail(FailureCode::Invalid, "Agents at once is between 1 and 1,000."));
510 }
511 if let Some(why) = crate::retention::invalid_days(&self.plans, a.allowances.audit_retention_days) {
512 return Ok(Outcome::fail(FailureCode::Invalid, why));
513 }
514 let Some(account) = self.find_account(&a.id).await? else {
515 return Ok(Outcome::fail(FailureCode::NotFound, "No such account."));
516 };
517 let now = rfc3339(now_ms());
518 let opt = |m: Option<i64>| m.map_or(JsValue::NULL, |m| (m as f64).into());
519 // A workspace's own account gets a row the first time anything is set.
520 self.db
521 .prepare(
522 "INSERT INTO billing_accounts (id, kind, name, terms_kind, discount_percent, note, created_by, created_at,
523 team_granted, oss_repo_micros, trial_micros, max_concurrent_agents, run_cap_micros, hold, issue_cap_micros,
524 audit_retention_days)
525 VALUES (?1, ?2, ?3, 'standard', 0, '', ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)
526 ON CONFLICT (id) DO UPDATE SET team_granted = ?6, oss_repo_micros = ?7, trial_micros = ?8,
527 max_concurrent_agents = ?9, run_cap_micros = ?10, hold = ?11, issue_cap_micros = ?12,
528 audit_retention_days = ?13",
529 )
530 .bind(&[
531 account.id.as_str().into(),
532 if account.kind == AccountKind::Enterprise { "enterprise" } else { "workspace" }.into(),
533 account.name.as_str().into(),
534 a.by.as_str().into(),
535 now.as_str().into(),
536 u32::from(a.allowances.plan).into(),
537 opt(a.allowances.oss_repo_micros),
538 opt(a.allowances.trial_micros),
539 a.allowances.max_concurrent_agents.map_or(JsValue::NULL, JsValue::from),
540 opt(a.allowances.run_cap_micros),
541 optional(a.allowances.hold.as_deref().map(str::trim).filter(|h| !h.is_empty())),
542 opt(a.allowances.issue_cap_micros),
543 a.allowances.audit_retention_days.map_or(JsValue::NULL, JsValue::from),
544 ])?
545 .run()
546 .await?;
547 // A trial amount from staff replaces each workspace's grant, outside
548 // the monthly pool; what was used stays used.
549 if let Some(amount) = a.allowances.trial_micros {
550 for workspace in &account.workspaces {
551 self.db
552 .prepare(
553 "INSERT INTO trial_grants (workspace, month, granted_micros, used_micros, created_at)
554 VALUES (?1, 'staff', ?2, 0, ?3)
555 ON CONFLICT (workspace) DO UPDATE SET month = 'staff', granted_micros = ?2",
556 )
557 .bind(&[workspace.as_str().into(), (amount as f64).into(), now.as_str().into()])?
558 .run()
559 .await?;
560 }
561 }
562 self.audit(
563 &account.id,
564 "allowances",
565 &format!("{} → {}: {}", describe_allowances(&account.allowances), describe_allowances(&a.allowances), a.note.trim()),
566 &a.by,
567 )
568 .await?;
569 Ok(Outcome::Ok(self.find_account(&account.id).await?.unwrap_or(account)))
570 }
571
572 pub(crate) async fn admin_create_enterprise(&self, a: AdminCreateEnterpriseArgs) -> Result<Outcome<BillingAccount>> {
573 let name = a.name.trim();
574 if name.is_empty() || a.by.trim().is_empty() {
575 return Ok(Outcome::fail(FailureCode::Invalid, "An enterprise needs a name, and who is making it."));
576 }
577 let now = now_ms();
578 let id = new_id("ent", now).to_lowercase();
579 self.db
580 .prepare(
581 "INSERT INTO billing_accounts (id, kind, name, terms_kind, discount_percent, note, created_by, created_at)
582 VALUES (?, 'enterprise', ?, 'standard', 0, '', ?, ?)",
583 )
584 .bind(&[id.as_str().into(), name.into(), a.by.as_str().into(), rfc3339(now).into()])?
585 .run()
586 .await?;
587 self.audit(&id, "create", &format!("Enterprise {name}"), &a.by).await?;
588 for workspace in &a.workspaces {
589 let workspace = workspace.trim().to_lowercase();
590 if !workspace.is_empty() {
591 self.attach(&workspace, Some(&id), &a.by).await?;
592 }
593 }
594 Ok(match self.find_account(&id).await? {
595 Some(account) => Outcome::Ok(account),
596 None => Outcome::fail(FailureCode::NotFound, "The enterprise was not saved."),
597 })
598 }
599
600 async fn attach(&self, workspace: &str, account: Option<&str>, by: &str) -> Result<()> {
601 let before = self.account_of(workspace).await?;
602 match account {
603 Some(account) => {
604 self.db
605 .prepare(
606 "INSERT INTO account_members (workspace, account_id, added_by, added_at) VALUES (?1, ?2, ?3, ?4)
607 ON CONFLICT (workspace) DO UPDATE SET account_id = ?2, added_by = ?3, added_at = ?4",
608 )
609 .bind(&[workspace.into(), account.into(), by.into(), rfc3339(now_ms()).into()])?
610 .run()
611 .await?;
612 self.audit(account, "attach", &format!("{workspace} joined, from {}", before.name), by).await?;
613 }
614 None => {
615 self.db
616 .prepare("DELETE FROM account_members WHERE workspace = ?")
617 .bind(&[workspace.into()])?
618 .run()
619 .await?;
620 self.audit(&before.id, "detach", &format!("{workspace} left, back to paying for itself"), by).await?;
621 }
622 }
623 Ok(())
624 }
625
626 pub(crate) async fn admin_attach(&self, a: AdminAttachArgs) -> Result<Outcome<BillingAccount>> {
627 let workspace = a.workspace.trim().to_lowercase();
628 if workspace.is_empty() || a.by.trim().is_empty() {
629 return Ok(Outcome::fail(FailureCode::Invalid, "Name the workspace, and who is making the change."));
630 }
631 if let Some(account) = &a.account {
632 match self.account_row(account).await? {
633 Some(row) if row.kind == "enterprise" => {}
634 _ => return Ok(Outcome::fail(FailureCode::NotFound, "Workspaces can only join an enterprise.")),
635 }
636 }
637 self.attach(&workspace, a.account.as_deref(), &a.by).await?;
638 Ok(Outcome::Ok(self.account_of(&workspace).await?))
639 }
640
641 pub(crate) async fn admin_billing_link(
642 &self,
643 a: g1t_contracts::billing::AdminBillingLinkArgs,
644 ) -> Result<Outcome<g1t_contracts::billing::BillingLink>> {
645 let workspace = a.workspace.trim().to_lowercase();
646 if workspace.is_empty() || a.by.trim().is_empty() {
647 return Ok(Outcome::fail(FailureCode::Invalid, "Name the workspace, and who is asking."));
648 }
649 let Some(stripe) = &self.stripe else {
650 return Ok(Outcome::fail(FailureCode::Conflict, "Payments are not set up on this g1t."));
651 };
652 let customer = match self.customer_for(&workspace).await {
653 Ok(customer) => customer,
654 Err(error) => return Ok(Outcome::fail(FailureCode::Conflict, format!("Stripe could not be reached: {error}"))),
655 };
656 let link = async {
657 let configuration = stripe.portal_configuration().await?;
658 let portal_url = stripe.portal_session(&customer, "https://g1t.sh/").await?;
659 let customer_email = stripe.customer_email(&customer).await.ok().flatten();
660 Ok::<_, worker::Error>(g1t_contracts::billing::BillingLink {
661 portal_url,
662 login_url: configuration.login_page.and_then(|page| page.url),
663 customer_email,
664 expires_note: "The one-time link works for a short while and only once; the sign-in page does not expire."
665 .to_owned(),
666 })
667 }
668 .await;
669 match link {
670 Ok(link) => {
671 let account = self.account_of(&workspace).await?;
672 self.audit(&account.id, "billing_link", &format!("Stripe billing link for {workspace}"), &a.by).await?;
673 Ok(Outcome::Ok(link))
674 }
675 Err(error) => Ok(Outcome::fail(FailureCode::Conflict, format!("Stripe's billing page could not be opened: {error}"))),
676 }
677 }
678
679 pub(crate) async fn admin_credit(&self, a: AdminCreditArgs) -> Result<Outcome<LedgerEntry>> {
680 let workspace = a.workspace.trim().to_lowercase();
681 if workspace.is_empty() || a.note.trim().is_empty() || a.by.trim().is_empty() {
682 return Ok(Outcome::fail(FailureCode::Invalid, "A credit needs a workspace, a note and who gave it."));
683 }
684 if a.amount_micros <= 0 || a.amount_micros > 10_000 * g1t_contracts::billing::MICROS_PER_DOLLAR {
685 return Ok(Outcome::fail(FailureCode::Invalid, "A credit is more than $0 and at most $10,000."));
686 }
687 let reference = new_id("crd", now_ms());
688 let description = format!("Credit from g1t: {}", a.note.trim());
689 self.enter(&workspace, EntryKind::TopUp, a.amount_micros, &description, &reference, None, None, Some(&a.by), None)
690 .await?;
691 let account = self.account_of(&workspace).await?;
692 self.audit(&account.id, "credit", &format!("{} to {workspace}: {}", crate::features::dollars(a.amount_micros), a.note.trim()), &a.by)
693 .await?;
694 let row = self
695 .db
696 .prepare("SELECT * FROM ledger WHERE reference = ?")
697 .bind(&[reference.as_str().into()])?
698 .first::<LedgerRow>(None)
699 .await?;
700 Ok(match row {
701 Some(row) => Outcome::Ok(LedgerEntry::from(row)),
702 None => Outcome::fail(FailureCode::NotFound, "The credit was not saved."),
703 })
704 }
705}
706
707/// Allowances as the audit log reads them.
708fn describe_allowances(a: &Allowances) -> String {
709 let mut parts = vec![if a.plan { "plan given" } else { "plan not given" }.to_owned()];
710 if let Some(m) = a.oss_repo_micros {
711 parts.push(format!("open-source share {} a repository", crate::features::dollars(m)));
712 }
713 if let Some(m) = a.trial_micros {
714 parts.push(format!("trial {}", crate::features::dollars(m)));
715 }
716 if let Some(n) = a.max_concurrent_agents {
717 parts.push(format!("{n} agents at once"));
718 }
719 if let Some(m) = a.run_cap_micros {
720 parts.push(format!("run cap {}", crate::features::dollars(m)));
721 }
722 if let Some(m) = a.issue_cap_micros {
723 parts.push(format!("issue cap {}", crate::features::dollars(m)));
724 }
725 if let Some(days) = a.audit_retention_days {
726 parts.push(format!("audit log {days} days"));
727 }
728 if let Some(hold) = &a.hold {
729 parts.push(format!("hold: {hold}"));
730 }
731 parts.join(", ")
732}
733
734/// A workspace's figures in `list`, added at the end the first time.
735fn figures_for<'a>(list: &'a mut Vec<WorkspaceFigures>, workspace: &str) -> &'a mut WorkspaceFigures {
736 let i = match list.iter().position(|f| f.workspace == workspace) {
737 Some(i) => i,
738 None => {
739 list.push(WorkspaceFigures { workspace: workspace.to_owned(), ..Default::default() });
740 list.len() - 1
741 }
742 };
743 &mut list[i]
744}
745
746#[cfg(test)]
747mod tests {
748 use super::*;
749
750 fn terms(kind: TermsKind, discount: u32) -> Terms {
751 Terms { kind, discount_percent: discount, ..Terms::standard() }
752 }
753
754 #[test]
755 fn terms_shape_every_charge() {
756 assert_eq!(terms(TermsKind::Standard, 0).apply(1_000), 1_000);
757 assert_eq!(terms(TermsKind::Comped, 0).apply(1_000), 0);
758 assert_eq!(terms(TermsKind::Custom, 25).apply(1_000), 750);
759 assert_eq!(terms(TermsKind::Custom, 250).apply(1_000), 0);
760 }
761
762 #[test]
763 fn terms_read_plainly_in_the_audit_log() {
764 let custom = Terms { ceiling_micros: Some(50_000_000), note: "Design partner".into(), ..terms(TermsKind::Custom, 20) };
765 assert_eq!(describe(&custom), "custom (20% off, ceiling $50.00): Design partner");
766 assert_eq!(describe(&Terms::standard()), "standard");
767 }
768
769 #[test]
770 fn allowances_read_plainly_in_the_audit_log() {
771 assert_eq!(describe_allowances(&Allowances::default()), "plan not given");
772 let given = Allowances {
773 plan: true,
774 oss_repo_micros: Some(5_000_000),
775 trial_micros: Some(2_000_000),
776 max_concurrent_agents: Some(4),
777 run_cap_micros: Some(3_000_000),
778 issue_cap_micros: None,
779 audit_retention_days: Some(365),
780 hold: Some("mining".into()),
781 };
782 assert_eq!(
783 describe_allowances(&given),
784 "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"
785 );
786 }
787
788 #[test]
789 fn each_workspace_gets_one_share() {
790 let mut list = vec![];
791 figures_for(&mut list, "acme").charged_micros += 5;
792 figures_for(&mut list, "beta").cost_micros += 2;
793 figures_for(&mut list, "acme").charged_micros += 7;
794 assert_eq!(list.len(), 2);
795 assert_eq!(list[0], WorkspaceFigures { workspace: "acme".into(), charged_micros: 12, ..Default::default() });
796 assert_eq!(list[1].cost_micros, 2);
797 }
798}