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

741 lines30,560 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>,
Billing accounts, terms and enterprises; g1t is no longer free52}
53
54impl AccountRow {
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put55 fn allowances(&self) -> Allowances {
56 Allowances {
57 team: self.team_granted.unwrap_or(0) != 0,
58 oss_repo_micros: self.oss_repo_micros,
59 trial_micros: self.trial_micros,
60 }
61 }
62}
63
64impl AccountRow {
Billing accounts, terms and enterprises; g1t is no longer free65 fn terms(&self) -> Terms {
66 let expired = self.terms_until.as_deref().is_some_and(|until| until < rfc3339(now_ms()).as_str());
67 if expired {
68 return Terms::standard();
69 }
70 Terms {
71 kind: match self.terms_kind.as_str() {
72 "comped" => TermsKind::Comped,
73 "custom" => TermsKind::Custom,
74 _ => TermsKind::Standard,
75 },
76 discount_percent: self.discount_percent,
77 ceiling_micros: self.ceiling_micros,
78 note: self.note.clone(),
79 until: self.terms_until.clone(),
80 set_by: self.terms_set_by.clone(),
81 set_at: self.terms_set_at.clone(),
82 }
83 }
84}
85
86#[derive(Deserialize)]
87struct Member {
88 workspace: String,
89}
90
91#[derive(Deserialize)]
92struct ActionRow {
93 id: String,
94 account: String,
95 action: String,
96 detail: String,
97 by: String,
98 created_at: String,
99}
100
101/// `ws_<slug>`: a workspace's own account.
102pub(crate) fn own_account(workspace: &str) -> String {
103 format!("ws_{}", workspace.to_lowercase())
104}
105
106fn kind_text(kind: TermsKind) -> &'static str {
107 match kind {
108 TermsKind::Standard => "standard",
109 TermsKind::Comped => "comped",
110 TermsKind::Custom => "custom",
111 }
112}
113
114fn describe(terms: &Terms) -> String {
115 let mut text = match terms.kind {
116 TermsKind::Standard => "standard".to_owned(),
117 TermsKind::Comped => "comped".to_owned(),
118 TermsKind::Custom => {
119 let mut parts = vec![];
120 if terms.discount_percent > 0 {
121 parts.push(format!("{}% off", terms.discount_percent));
122 }
123 if let Some(ceiling) = terms.ceiling_micros {
124 parts.push(format!("ceiling {}", crate::features::dollars(ceiling)));
125 }
126 format!("custom ({})", if parts.is_empty() { "no changes".to_owned() } else { parts.join(", ") })
127 }
128 };
129 if let Some(until) = &terms.until {
130 text.push_str(&format!(" until {}", &until[..until.len().min(10)]));
131 }
132 if !terms.note.is_empty() {
133 text.push_str(&format!(": {}", terms.note));
134 }
135 text
136}
137
138impl Billing {
139 async fn account_row(&self, id: &str) -> Result<Option<AccountRow>> {
140 self.db
141 .prepare("SELECT * FROM billing_accounts WHERE id = ?")
142 .bind(&[id.into()])?
143 .first::<AccountRow>(None)
144 .await
145 }
146
147 async fn members(&self, account: &str) -> Result<Vec<String>> {
148 Ok(self
149 .db
150 .prepare("SELECT workspace FROM account_members WHERE account_id = ? ORDER BY workspace")
151 .bind(&[account.into()])?
152 .all()
153 .await?
154 .results::<Member>()?
155 .into_iter()
156 .map(|m| m.workspace)
157 .collect())
158 }
159
160 fn to_account(&self, row: &AccountRow, workspaces: Vec<String>) -> BillingAccount {
161 BillingAccount {
162 id: row.id.clone(),
163 kind: if row.kind == "enterprise" { AccountKind::Enterprise } else { AccountKind::Workspace },
164 name: row.name.clone(),
165 terms: row.terms(),
166 workspaces,
167 created_at: row.created_at.clone(),
Stripe webhooks, enterprise invoices, and sudo for both168 billing_email: row.billing_email.clone(),
169 invoices: vec![],
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put170 allowances: row.allowances(),
Billing accounts, terms and enterprises; g1t is no longer free171 }
172 }
173
174 /// The account that pays for a workspace.
175 pub(crate) async fn account_of(&self, workspace: &str) -> Result<BillingAccount> {
176 let workspace = workspace.to_lowercase();
177 #[derive(Deserialize)]
178 struct Link {
179 account_id: String,
180 }
181 let linked = self
182 .db
183 .prepare("SELECT account_id FROM account_members WHERE workspace = ?")
184 .bind(&[workspace.as_str().into()])?
185 .first::<Link>(None)
186 .await?;
187 if let Some(link) = linked {
188 if let Some(row) = self.account_row(&link.account_id).await? {
189 let members = self.members(&row.id).await?;
190 return Ok(self.to_account(&row, members));
191 }
192 }
193 let id = own_account(&workspace);
194 Ok(match self.account_row(&id).await? {
195 Some(row) => self.to_account(&row, vec![workspace]),
196 None => BillingAccount {
197 id,
198 kind: AccountKind::Workspace,
199 name: workspace.clone(),
200 terms: Terms::standard(),
201 workspaces: vec![workspace],
202 created_at: String::new(),
Stripe webhooks, enterprise invoices, and sudo for both203 billing_email: None,
204 invoices: vec![],
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put205 allowances: Allowances::default(),
Billing accounts, terms and enterprises; g1t is no longer free206 },
207 })
208 }
209
210 /// The terms a workspace is charged on.
211 pub(crate) async fn terms_of(&self, workspace: &str) -> Result<Terms> {
212 Ok(self.account_of(workspace).await?.terms)
213 }
214
Stripe webhooks, enterprise invoices, and sudo for both215 /// An enterprise, with its invoices.
216 pub(crate) async fn enterprise(&self, id: &str) -> Result<Option<BillingAccount>> {
217 let Some(row) = self.account_row(id).await?.filter(|row| row.kind == "enterprise") else {
218 return Ok(None);
219 };
220 let members = self.members(&row.id).await?;
221 let mut account = self.to_account(&row, members);
222 account.invoices = self.enterprise_invoices(&row.id).await?;
223 Ok(Some(account))
224 }
225
Billing accounts, terms and enterprises; g1t is no longer free226 /// An account by id, or the account of a workspace by its slug.
227 async fn find_account(&self, id: &str) -> Result<Option<BillingAccount>> {
228 let id = id.trim().to_lowercase();
229 if id.starts_with("ent_") {
Stripe webhooks, enterprise invoices, and sudo for both230 return self.enterprise(&id).await;
Billing accounts, terms and enterprises; g1t is no longer free231 }
232 let slug = id.strip_prefix("ws_").unwrap_or(&id);
233 if slug.is_empty() {
234 return Ok(None);
235 }
236 Ok(Some(self.account_of(slug).await?))
237 }
238
Stripe webhooks, enterprise invoices, and sudo for both239 pub(crate) async fn audit(&self, account: &str, action: &str, detail: &str, by: &str) -> Result<()> {
Billing accounts, terms and enterprises; g1t is no longer free240 let now = now_ms();
241 self.db
242 .prepare("INSERT INTO admin_actions (id, account, action, detail, by, created_at) VALUES (?, ?, ?, ?, ?, ?)")
243 .bind(&[
244 new_id("adm", now).into(),
245 account.into(),
246 action.into(),
247 detail.into(),
248 by.into(),
249 rfc3339(now).into(),
250 ])?
251 .run()
252 .await?;
253 Ok(())
254 }
255
256 /// Where an account stands this month.
257 async fn summary(&self, account: BillingAccount) -> Result<AccountSummary> {
258 let first = account.workspaces.first().cloned().unwrap_or_else(|| account.name.clone());
259 let limit = self.limit_of(&first).await?;
260 #[derive(Deserialize)]
261 struct Totals {
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace262 workspace: String,
Billing accounts, terms and enterprises; g1t is no longer free263 charged: Option<i64>,
264 cost: Option<i64>,
265 }
266 #[derive(Deserialize)]
267 struct Paid {
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace268 workspace: String,
Billing accounts, terms and enterprises; g1t is no longer free269 paid: Option<i64>,
270 }
271 let marks = vec!["?"; account.workspaces.len().max(1)].join(", ");
272 let mut values: Vec<JsValue> = account.workspaces.iter().map(|w| JsValue::from(w.as_str())).collect();
273 if values.is_empty() {
274 values.push(JsValue::from(""));
275 }
276 let month_start = format!("{}-01", &rfc3339(now_ms())[..7]);
277 let mut with_month = values.clone();
278 with_month.push(month_start.as_str().into());
279 let totals = self
280 .db
281 .prepare(format!(
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace282 "SELECT workspace, -SUM(amount_micros) AS charged, SUM(cost_micros) AS cost FROM ledger
283 WHERE kind = 'usage' AND workspace IN ({marks}) AND created_at >= ? GROUP BY workspace"
Billing accounts, terms and enterprises; g1t is no longer free284 ))
285 .bind(&with_month)?
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace286 .all()
287 .await?
288 .results::<Totals>()?;
Billing accounts, terms and enterprises; g1t is no longer free289 let paid = self
290 .db
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace291 .prepare(format!(
292 "SELECT workspace, SUM(amount_micros) AS paid FROM ledger
293 WHERE kind = 'top_up' AND workspace IN ({marks}) GROUP BY workspace"
294 ))
Billing accounts, terms and enterprises; g1t is no longer free295 .bind(&values)?
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace296 .all()
297 .await?
298 .results::<Paid>()?;
299 // Each workspace's share, in the account's order; the account's
300 // figures are their sum.
301 let mut by_workspace: Vec<WorkspaceFigures> = vec![];
302 for workspace in &account.workspaces {
303 figures_for(&mut by_workspace, workspace);
304 }
305 for t in &totals {
306 let f = figures_for(&mut by_workspace, &t.workspace);
307 f.charged_micros += t.charged.unwrap_or(0);
308 f.cost_micros += t.cost.unwrap_or(0);
309 }
310 for p in &paid {
311 figures_for(&mut by_workspace, &p.workspace).paid_micros += p.paid.unwrap_or(0);
312 }
Two limits, real invoices, trust that grows by itself, sales signals313 let months = self.months_for(&account.workspaces, 6).await?;
Billing accounts, terms and enterprises; g1t is no longer free314 Ok(AccountSummary {
Two limits, real invoices, trust that grows by itself, sales signals315 months,
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace316 charged_micros: by_workspace.iter().map(|f| f.charged_micros).sum(),
317 cost_micros: by_workspace.iter().map(|f| f.cost_micros).sum(),
318 paid_micros: by_workspace.iter().map(|f| f.paid_micros).sum(),
319 by_workspace,
Billing accounts, terms and enterprises; g1t is no longer free320 account,
321 limit,
322 })
323 }
324
325 // --- Staff ------------------------------------------------------------
326
327 pub(crate) async fn admin_accounts(&self, a: AdminAccountsArgs) -> Result<Vec<AccountSummary>> {
Stripe webhooks, enterprise invoices, and sudo for both328 // Exactly the workspaces asked for, such as one page of sudo's list.
329 if let Some(workspaces) = &a.workspaces {
330 let mut seen = std::collections::HashSet::new();
331 let mut summaries = vec![];
332 for slug in workspaces.iter().take(200) {
333 let account = self.account_of(slug).await?;
334 if seen.insert(account.id.clone()) {
335 summaries.push(self.summary(account).await?);
336 }
337 }
338 return Ok(summaries);
339 }
Billing accounts, terms and enterprises; g1t is no longer free340 // Every workspace that has used or paid for anything, and every
341 // account with terms of its own.
342 #[derive(Deserialize)]
343 struct Slug {
344 workspace: String,
345 }
346 let mut slugs: Vec<String> = self
347 .db
348 .prepare(
349 "SELECT DISTINCT workspace FROM ledger
350 UNION SELECT workspace FROM accounts
351 UNION SELECT substr(id, 4) FROM billing_accounts WHERE kind = 'workspace'",
352 )
353 .all()
354 .await?
355 .results::<Slug>()?
356 .into_iter()
357 .map(|s| s.workspace)
358 .collect();
359 if let Some(query) = a.query.as_deref().map(str::trim).filter(|q| !q.is_empty()) {
360 let query = query.to_lowercase();
361 slugs.retain(|slug| slug.contains(&query));
362 }
363 let mut seen = std::collections::HashSet::new();
364 let mut summaries = vec![];
365 for slug in slugs.into_iter().take(200) {
366 let account = self.account_of(&slug).await?;
367 if !seen.insert(account.id.clone()) {
368 continue;
369 }
370 summaries.push(self.summary(account).await?);
371 }
372 // Enterprises with no usage yet.
373 #[derive(Deserialize)]
374 struct Id {
375 id: String,
376 }
377 let enterprises = self
378 .db
379 .prepare("SELECT id FROM billing_accounts WHERE kind = 'enterprise'")
380 .all()
381 .await?
382 .results::<Id>()?;
383 for Id { id } in enterprises {
384 if seen.contains(&id) {
385 continue;
386 }
387 if let Some(account) = self.find_account(&id).await? {
388 if a.query.as_deref().is_none_or(|q| account.name.to_lowercase().contains(&q.to_lowercase())) {
389 seen.insert(id);
390 summaries.push(self.summary(account).await?);
391 }
392 }
393 }
394 summaries.sort_by(|x, y| y.limit.exposure_micros.cmp(&x.limit.exposure_micros));
395 Ok(summaries)
396 }
397
398 pub(crate) async fn admin_account(&self, a: AdminAccountArgs) -> Result<Outcome<AccountDetail>> {
399 let Some(account) = self.find_account(&a.id).await? else {
400 return Ok(Outcome::fail(FailureCode::NotFound, "No such account."));
401 };
402 let mut workspaces = vec![];
403 for workspace in &account.workspaces {
404 workspaces.push(self.limit_of(workspace).await?);
405 }
406 let marks = vec!["?"; account.workspaces.len().max(1)].join(", ");
407 let mut values: Vec<JsValue> = account.workspaces.iter().map(|w| JsValue::from(w.as_str())).collect();
408 if values.is_empty() {
409 values.push(JsValue::from(""));
410 }
411 let ledger = self
412 .db
413 .prepare(format!("SELECT * FROM ledger WHERE workspace IN ({marks}) ORDER BY id DESC LIMIT 100"))
414 .bind(&values)?
415 .all()
416 .await?
417 .results::<LedgerRow>()?
418 .into_iter()
419 .map(LedgerEntry::from)
420 .collect();
421 let audit = self
422 .db
423 .prepare("SELECT * FROM admin_actions WHERE account = ? ORDER BY created_at DESC LIMIT 50")
424 .bind(&[account.id.as_str().into()])?
425 .all()
426 .await?
427 .results::<ActionRow>()?
428 .into_iter()
429 .map(|row| AdminAction {
430 id: row.id,
431 account: row.account,
432 action: row.action,
433 detail: row.detail,
434 by: row.by,
435 created_at: row.created_at,
436 })
437 .collect();
438 Ok(Outcome::Ok(AccountDetail { summary: self.summary(account).await?, workspaces, ledger, audit }))
439 }
440
441 pub(crate) async fn admin_set_terms(&self, a: AdminSetTermsArgs) -> Result<Outcome<BillingAccount>> {
442 if a.by.trim().is_empty() {
443 return Ok(Outcome::fail(FailureCode::Invalid, "Say who is making the change."));
444 }
445 if a.terms.kind != TermsKind::Standard && a.terms.note.trim().is_empty() {
446 return Ok(Outcome::fail(FailureCode::Invalid, "Say why, in the note."));
447 }
448 if a.terms.discount_percent > 100 || a.terms.ceiling_micros.is_some_and(|c| c < 0) {
449 return Ok(Outcome::fail(FailureCode::Invalid, "A discount is 0 to 100%, and a ceiling is not negative."));
450 }
451 let Some(account) = self.find_account(&a.id).await? else {
452 return Ok(Outcome::fail(FailureCode::NotFound, "No such account."));
453 };
454 let now = rfc3339(now_ms());
455 // A workspace's own account gets a row the first time its terms change.
456 self.db
457 .prepare(
458 "INSERT INTO billing_accounts (id, kind, name, terms_kind, discount_percent, ceiling_micros, note,
459 terms_until, terms_set_by, terms_set_at, created_by, created_at)
460 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?9, ?10)
461 ON CONFLICT (id) DO UPDATE SET terms_kind = ?4, discount_percent = ?5, ceiling_micros = ?6,
462 note = ?7, terms_until = ?8, terms_set_by = ?9, terms_set_at = ?10",
463 )
464 .bind(&[
465 account.id.as_str().into(),
466 if account.kind == AccountKind::Enterprise { "enterprise" } else { "workspace" }.into(),
467 account.name.as_str().into(),
468 kind_text(a.terms.kind).into(),
469 a.terms.discount_percent.into(),
470 a.terms.ceiling_micros.map_or(JsValue::NULL, |c| (c as f64).into()),
471 a.terms.note.trim().into(),
472 optional(a.terms.until.as_deref()),
473 a.by.as_str().into(),
474 now.as_str().into(),
475 ])?
476 .run()
477 .await?;
478 self.audit(&account.id, "terms", &format!("{} → {}", describe(&account.terms), describe(&a.terms)), &a.by)
479 .await?;
480 Ok(Outcome::Ok(self.find_account(&account.id).await?.unwrap_or(account)))
481 }
482
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put483 /// Team without charge, and the account's share of g1t's pools.
484 pub(crate) async fn admin_set_allowances(&self, a: AdminSetAllowancesArgs) -> Result<Outcome<BillingAccount>> {
485 if a.by.trim().is_empty() || a.note.trim().is_empty() {
486 return Ok(Outcome::fail(FailureCode::Invalid, "Say who is making the change, and why, in the note."));
487 }
488 let money = |m: Option<i64>| m.is_none_or(|m| (0..=1_000 * g1t_contracts::billing::MICROS_PER_DOLLAR).contains(&m));
489 if !money(a.allowances.oss_repo_micros) || !money(a.allowances.trial_micros) {
490 return Ok(Outcome::fail(FailureCode::Invalid, "A pool share is between $0 and $1,000."));
491 }
492 let Some(account) = self.find_account(&a.id).await? else {
493 return Ok(Outcome::fail(FailureCode::NotFound, "No such account."));
494 };
495 let now = rfc3339(now_ms());
496 let opt = |m: Option<i64>| m.map_or(JsValue::NULL, |m| (m as f64).into());
497 // A workspace's own account gets a row the first time anything is set.
498 self.db
499 .prepare(
500 "INSERT INTO billing_accounts (id, kind, name, terms_kind, discount_percent, note, created_by, created_at,
501 team_granted, oss_repo_micros, trial_micros)
502 VALUES (?1, ?2, ?3, 'standard', 0, '', ?4, ?5, ?6, ?7, ?8)
503 ON CONFLICT (id) DO UPDATE SET team_granted = ?6, oss_repo_micros = ?7, trial_micros = ?8",
504 )
505 .bind(&[
506 account.id.as_str().into(),
507 if account.kind == AccountKind::Enterprise { "enterprise" } else { "workspace" }.into(),
508 account.name.as_str().into(),
509 a.by.as_str().into(),
510 now.as_str().into(),
511 u32::from(a.allowances.team).into(),
512 opt(a.allowances.oss_repo_micros),
513 opt(a.allowances.trial_micros),
514 ])?
515 .run()
516 .await?;
517 // A trial amount from staff replaces each workspace's grant, outside
518 // the monthly pool; what was used stays used.
519 if let Some(amount) = a.allowances.trial_micros {
520 for workspace in &account.workspaces {
521 self.db
522 .prepare(
523 "INSERT INTO trial_grants (workspace, month, granted_micros, used_micros, created_at)
524 VALUES (?1, 'staff', ?2, 0, ?3)
525 ON CONFLICT (workspace) DO UPDATE SET month = 'staff', granted_micros = ?2",
526 )
527 .bind(&[workspace.as_str().into(), (amount as f64).into(), now.as_str().into()])?
528 .run()
529 .await?;
530 }
531 }
532 self.audit(
533 &account.id,
534 "allowances",
535 &format!("{} → {}: {}", describe_allowances(&account.allowances), describe_allowances(&a.allowances), a.note.trim()),
536 &a.by,
537 )
538 .await?;
539 Ok(Outcome::Ok(self.find_account(&account.id).await?.unwrap_or(account)))
540 }
541
Billing accounts, terms and enterprises; g1t is no longer free542 pub(crate) async fn admin_create_enterprise(&self, a: AdminCreateEnterpriseArgs) -> Result<Outcome<BillingAccount>> {
543 let name = a.name.trim();
544 if name.is_empty() || a.by.trim().is_empty() {
545 return Ok(Outcome::fail(FailureCode::Invalid, "An enterprise needs a name, and who is making it."));
546 }
547 let now = now_ms();
548 let id = new_id("ent", now).to_lowercase();
549 self.db
550 .prepare(
551 "INSERT INTO billing_accounts (id, kind, name, terms_kind, discount_percent, note, created_by, created_at)
552 VALUES (?, 'enterprise', ?, 'standard', 0, '', ?, ?)",
553 )
554 .bind(&[id.as_str().into(), name.into(), a.by.as_str().into(), rfc3339(now).into()])?
555 .run()
556 .await?;
557 self.audit(&id, "create", &format!("Enterprise {name}"), &a.by).await?;
558 for workspace in &a.workspaces {
559 let workspace = workspace.trim().to_lowercase();
560 if !workspace.is_empty() {
561 self.attach(&workspace, Some(&id), &a.by).await?;
562 }
563 }
564 Ok(match self.find_account(&id).await? {
565 Some(account) => Outcome::Ok(account),
566 None => Outcome::fail(FailureCode::NotFound, "The enterprise was not saved."),
567 })
568 }
569
570 async fn attach(&self, workspace: &str, account: Option<&str>, by: &str) -> Result<()> {
571 let before = self.account_of(workspace).await?;
572 match account {
573 Some(account) => {
574 self.db
575 .prepare(
576 "INSERT INTO account_members (workspace, account_id, added_by, added_at) VALUES (?1, ?2, ?3, ?4)
577 ON CONFLICT (workspace) DO UPDATE SET account_id = ?2, added_by = ?3, added_at = ?4",
578 )
579 .bind(&[workspace.into(), account.into(), by.into(), rfc3339(now_ms()).into()])?
580 .run()
581 .await?;
582 self.audit(account, "attach", &format!("{workspace} joined, from {}", before.name), by).await?;
583 }
584 None => {
585 self.db
586 .prepare("DELETE FROM account_members WHERE workspace = ?")
587 .bind(&[workspace.into()])?
588 .run()
589 .await?;
590 self.audit(&before.id, "detach", &format!("{workspace} left, back to paying for itself"), by).await?;
591 }
592 }
593 Ok(())
594 }
595
596 pub(crate) async fn admin_attach(&self, a: AdminAttachArgs) -> Result<Outcome<BillingAccount>> {
597 let workspace = a.workspace.trim().to_lowercase();
598 if workspace.is_empty() || a.by.trim().is_empty() {
599 return Ok(Outcome::fail(FailureCode::Invalid, "Name the workspace, and who is making the change."));
600 }
601 if let Some(account) = &a.account {
602 match self.account_row(account).await? {
603 Some(row) if row.kind == "enterprise" => {}
604 _ => return Ok(Outcome::fail(FailureCode::NotFound, "Workspaces can only join an enterprise.")),
605 }
606 }
607 self.attach(&workspace, a.account.as_deref(), &a.by).await?;
608 Ok(Outcome::Ok(self.account_of(&workspace).await?))
609 }
610
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace611 pub(crate) async fn admin_billing_link(
612 &self,
613 a: g1t_contracts::billing::AdminBillingLinkArgs,
614 ) -> Result<Outcome<g1t_contracts::billing::BillingLink>> {
615 let workspace = a.workspace.trim().to_lowercase();
616 if workspace.is_empty() || a.by.trim().is_empty() {
617 return Ok(Outcome::fail(FailureCode::Invalid, "Name the workspace, and who is asking."));
618 }
619 let Some(stripe) = &self.stripe else {
620 return Ok(Outcome::fail(FailureCode::Conflict, "Payments are not set up on this g1t."));
621 };
622 let customer = match self.customer_for(&workspace).await {
623 Ok(customer) => customer,
624 Err(error) => return Ok(Outcome::fail(FailureCode::Conflict, format!("Stripe could not be reached: {error}"))),
625 };
626 let link = async {
627 let configuration = stripe.portal_configuration().await?;
628 let portal_url = stripe.portal_session(&customer, "https://g1t.sh/").await?;
629 let customer_email = stripe.customer_email(&customer).await.ok().flatten();
630 Ok::<_, worker::Error>(g1t_contracts::billing::BillingLink {
631 portal_url,
632 login_url: configuration.login_page.and_then(|page| page.url),
633 customer_email,
634 expires_note: "The one-time link works for a short while and only once; the sign-in page does not expire."
635 .to_owned(),
636 })
637 }
638 .await;
639 match link {
640 Ok(link) => {
641 let account = self.account_of(&workspace).await?;
642 self.audit(&account.id, "billing_link", &format!("Stripe billing link for {workspace}"), &a.by).await?;
643 Ok(Outcome::Ok(link))
644 }
645 Err(error) => Ok(Outcome::fail(FailureCode::Conflict, format!("Stripe's billing page could not be opened: {error}"))),
646 }
647 }
648
Billing accounts, terms and enterprises; g1t is no longer free649 pub(crate) async fn admin_credit(&self, a: AdminCreditArgs) -> Result<Outcome<LedgerEntry>> {
650 let workspace = a.workspace.trim().to_lowercase();
651 if workspace.is_empty() || a.note.trim().is_empty() || a.by.trim().is_empty() {
652 return Ok(Outcome::fail(FailureCode::Invalid, "A credit needs a workspace, a note and who gave it."));
653 }
654 if a.amount_micros <= 0 || a.amount_micros > 10_000 * g1t_contracts::billing::MICROS_PER_DOLLAR {
655 return Ok(Outcome::fail(FailureCode::Invalid, "A credit is more than $0 and at most $10,000."));
656 }
657 let reference = new_id("crd", now_ms());
658 let description = format!("Credit from g1t: {}", a.note.trim());
659 self.enter(&workspace, EntryKind::TopUp, a.amount_micros, &description, &reference, None, None, Some(&a.by), None)
660 .await?;
661 let account = self.account_of(&workspace).await?;
662 self.audit(&account.id, "credit", &format!("{} to {workspace}: {}", crate::features::dollars(a.amount_micros), a.note.trim()), &a.by)
663 .await?;
664 let row = self
665 .db
666 .prepare("SELECT * FROM ledger WHERE reference = ?")
667 .bind(&[reference.as_str().into()])?
668 .first::<LedgerRow>(None)
669 .await?;
670 Ok(match row {
671 Some(row) => Outcome::Ok(LedgerEntry::from(row)),
672 None => Outcome::fail(FailureCode::NotFound, "The credit was not saved."),
673 })
674 }
675}
676
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put677/// Allowances as the audit log reads them.
678fn describe_allowances(a: &Allowances) -> String {
679 let mut parts = vec![if a.team { "Team on" } else { "Team off" }.to_owned()];
680 if let Some(m) = a.oss_repo_micros {
681 parts.push(format!("open-source share {} a repository", crate::features::dollars(m)));
682 }
683 if let Some(m) = a.trial_micros {
684 parts.push(format!("trial {}", crate::features::dollars(m)));
685 }
686 parts.join(", ")
687}
688
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace689/// A workspace's figures in `list`, added at the end the first time.
690fn figures_for<'a>(list: &'a mut Vec<WorkspaceFigures>, workspace: &str) -> &'a mut WorkspaceFigures {
691 let i = match list.iter().position(|f| f.workspace == workspace) {
692 Some(i) => i,
693 None => {
694 list.push(WorkspaceFigures { workspace: workspace.to_owned(), ..Default::default() });
695 list.len() - 1
696 }
697 };
698 &mut list[i]
699}
700
Billing accounts, terms and enterprises; g1t is no longer free701#[cfg(test)]
702mod tests {
703 use super::*;
704
705 fn terms(kind: TermsKind, discount: u32) -> Terms {
706 Terms { kind, discount_percent: discount, ..Terms::standard() }
707 }
708
709 #[test]
710 fn terms_shape_every_charge() {
711 assert_eq!(terms(TermsKind::Standard, 0).apply(1_000), 1_000);
712 assert_eq!(terms(TermsKind::Comped, 0).apply(1_000), 0);
713 assert_eq!(terms(TermsKind::Custom, 25).apply(1_000), 750);
714 assert_eq!(terms(TermsKind::Custom, 250).apply(1_000), 0);
715 }
716
717 #[test]
718 fn terms_read_plainly_in_the_audit_log() {
719 let custom = Terms { ceiling_micros: Some(50_000_000), note: "Design partner".into(), ..terms(TermsKind::Custom, 20) };
720 assert_eq!(describe(&custom), "custom (20% off, ceiling $50.00): Design partner");
721 assert_eq!(describe(&Terms::standard()), "standard");
722 }
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace723
724 #[test]
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put725 fn allowances_read_plainly_in_the_audit_log() {
726 assert_eq!(describe_allowances(&Allowances::default()), "Team off");
727 let given = Allowances { team: true, oss_repo_micros: Some(5_000_000), trial_micros: Some(2_000_000) };
728 assert_eq!(describe_allowances(&given), "Team on, open-source share $5.00 a repository, trial $2.00");
729 }
730
731 #[test]
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace732 fn each_workspace_gets_one_share() {
733 let mut list = vec![];
734 figures_for(&mut list, "acme").charged_micros += 5;
735 figures_for(&mut list, "beta").cost_micros += 2;
736 figures_for(&mut list, "acme").charged_micros += 7;
737 assert_eq!(list.len(), 2);
738 assert_eq!(list[0], WorkspaceFigures { workspace: "acme".into(), charged_micros: 12, ..Default::default() });
739 assert_eq!(list[1].cost_micros, 2);
740 }
Billing accounts, terms and enterprises; g1t is no longer free741}