pr_01m47d24b0e6n91zwymwxg0vpx/services/billing/src/accounts.rs

537 lines21,280 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, AdminSetTermsArgs, BillingAccount, EntryKind, LedgerEntry, Terms,
20 TermsKind,
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}
45
46impl AccountRow {
47 fn terms(&self) -> Terms {
48 let expired = self.terms_until.as_deref().is_some_and(|until| until < rfc3339(now_ms()).as_str());
49 if expired {
50 return Terms::standard();
51 }
52 Terms {
53 kind: match self.terms_kind.as_str() {
54 "comped" => TermsKind::Comped,
55 "custom" => TermsKind::Custom,
56 _ => TermsKind::Standard,
57 },
58 discount_percent: self.discount_percent,
59 ceiling_micros: self.ceiling_micros,
60 note: self.note.clone(),
61 until: self.terms_until.clone(),
62 set_by: self.terms_set_by.clone(),
63 set_at: self.terms_set_at.clone(),
64 }
65 }
66}
67
68#[derive(Deserialize)]
69struct Member {
70 workspace: String,
71}
72
73#[derive(Deserialize)]
74struct ActionRow {
75 id: String,
76 account: String,
77 action: String,
78 detail: String,
79 by: String,
80 created_at: String,
81}
82
83/// `ws_<slug>`: a workspace's own account.
84pub(crate) fn own_account(workspace: &str) -> String {
85 format!("ws_{}", workspace.to_lowercase())
86}
87
88fn kind_text(kind: TermsKind) -> &'static str {
89 match kind {
90 TermsKind::Standard => "standard",
91 TermsKind::Comped => "comped",
92 TermsKind::Custom => "custom",
93 }
94}
95
96fn describe(terms: &Terms) -> String {
97 let mut text = match terms.kind {
98 TermsKind::Standard => "standard".to_owned(),
99 TermsKind::Comped => "comped".to_owned(),
100 TermsKind::Custom => {
101 let mut parts = vec![];
102 if terms.discount_percent > 0 {
103 parts.push(format!("{}% off", terms.discount_percent));
104 }
105 if let Some(ceiling) = terms.ceiling_micros {
106 parts.push(format!("ceiling {}", crate::features::dollars(ceiling)));
107 }
108 format!("custom ({})", if parts.is_empty() { "no changes".to_owned() } else { parts.join(", ") })
109 }
110 };
111 if let Some(until) = &terms.until {
112 text.push_str(&format!(" until {}", &until[..until.len().min(10)]));
113 }
114 if !terms.note.is_empty() {
115 text.push_str(&format!(": {}", terms.note));
116 }
117 text
118}
119
120impl Billing {
121 async fn account_row(&self, id: &str) -> Result<Option<AccountRow>> {
122 self.db
123 .prepare("SELECT * FROM billing_accounts WHERE id = ?")
124 .bind(&[id.into()])?
125 .first::<AccountRow>(None)
126 .await
127 }
128
129 async fn members(&self, account: &str) -> Result<Vec<String>> {
130 Ok(self
131 .db
132 .prepare("SELECT workspace FROM account_members WHERE account_id = ? ORDER BY workspace")
133 .bind(&[account.into()])?
134 .all()
135 .await?
136 .results::<Member>()?
137 .into_iter()
138 .map(|m| m.workspace)
139 .collect())
140 }
141
142 fn to_account(&self, row: &AccountRow, workspaces: Vec<String>) -> BillingAccount {
143 BillingAccount {
144 id: row.id.clone(),
145 kind: if row.kind == "enterprise" { AccountKind::Enterprise } else { AccountKind::Workspace },
146 name: row.name.clone(),
147 terms: row.terms(),
148 workspaces,
149 created_at: row.created_at.clone(),
150 }
151 }
152
153 /// The account that pays for a workspace.
154 pub(crate) async fn account_of(&self, workspace: &str) -> Result<BillingAccount> {
155 let workspace = workspace.to_lowercase();
156 #[derive(Deserialize)]
157 struct Link {
158 account_id: String,
159 }
160 let linked = self
161 .db
162 .prepare("SELECT account_id FROM account_members WHERE workspace = ?")
163 .bind(&[workspace.as_str().into()])?
164 .first::<Link>(None)
165 .await?;
166 if let Some(link) = linked {
167 if let Some(row) = self.account_row(&link.account_id).await? {
168 let members = self.members(&row.id).await?;
169 return Ok(self.to_account(&row, members));
170 }
171 }
172 let id = own_account(&workspace);
173 Ok(match self.account_row(&id).await? {
174 Some(row) => self.to_account(&row, vec![workspace]),
175 None => BillingAccount {
176 id,
177 kind: AccountKind::Workspace,
178 name: workspace.clone(),
179 terms: Terms::standard(),
180 workspaces: vec![workspace],
181 created_at: String::new(),
182 },
183 })
184 }
185
186 /// The terms a workspace is charged on.
187 pub(crate) async fn terms_of(&self, workspace: &str) -> Result<Terms> {
188 Ok(self.account_of(workspace).await?.terms)
189 }
190
191 /// An account by id, or the account of a workspace by its slug.
192 async fn find_account(&self, id: &str) -> Result<Option<BillingAccount>> {
193 let id = id.trim().to_lowercase();
194 if id.starts_with("ent_") {
195 return Ok(match self.account_row(&id).await? {
196 Some(row) => {
197 let members = self.members(&row.id).await?;
198 Some(self.to_account(&row, members))
199 }
200 None => None,
201 });
202 }
203 let slug = id.strip_prefix("ws_").unwrap_or(&id);
204 if slug.is_empty() {
205 return Ok(None);
206 }
207 Ok(Some(self.account_of(slug).await?))
208 }
209
210 async fn audit(&self, account: &str, action: &str, detail: &str, by: &str) -> Result<()> {
211 let now = now_ms();
212 self.db
213 .prepare("INSERT INTO admin_actions (id, account, action, detail, by, created_at) VALUES (?, ?, ?, ?, ?, ?)")
214 .bind(&[
215 new_id("adm", now).into(),
216 account.into(),
217 action.into(),
218 detail.into(),
219 by.into(),
220 rfc3339(now).into(),
221 ])?
222 .run()
223 .await?;
224 Ok(())
225 }
226
227 /// Where an account stands this month.
228 async fn summary(&self, account: BillingAccount) -> Result<AccountSummary> {
229 let first = account.workspaces.first().cloned().unwrap_or_else(|| account.name.clone());
230 let limit = self.limit_of(&first).await?;
231 #[derive(Deserialize)]
232 struct Totals {
233 charged: Option<i64>,
234 cost: Option<i64>,
235 }
236 #[derive(Deserialize)]
237 struct Paid {
238 paid: Option<i64>,
239 }
240 let marks = vec!["?"; account.workspaces.len().max(1)].join(", ");
241 let mut values: Vec<JsValue> = account.workspaces.iter().map(|w| JsValue::from(w.as_str())).collect();
242 if values.is_empty() {
243 values.push(JsValue::from(""));
244 }
245 let month_start = format!("{}-01", &rfc3339(now_ms())[..7]);
246 let mut with_month = values.clone();
247 with_month.push(month_start.as_str().into());
248 let totals = self
249 .db
250 .prepare(format!(
251 "SELECT -SUM(amount_micros) AS charged, SUM(cost_micros) AS cost FROM ledger
252 WHERE kind = 'usage' AND workspace IN ({marks}) AND created_at >= ?"
253 ))
254 .bind(&with_month)?
255 .first::<Totals>(None)
256 .await?;
257 let paid = self
258 .db
259 .prepare(format!("SELECT SUM(amount_micros) AS paid FROM ledger WHERE kind = 'top_up' AND workspace IN ({marks})"))
260 .bind(&values)?
261 .first::<Paid>(None)
262 .await?;
263 Ok(AccountSummary {
264 account,
265 limit,
266 charged_micros: totals.as_ref().and_then(|t| t.charged).unwrap_or(0),
267 cost_micros: totals.and_then(|t| t.cost).unwrap_or(0),
268 paid_micros: paid.and_then(|p| p.paid).unwrap_or(0),
269 })
270 }
271
272 // --- Staff ------------------------------------------------------------
273
274 pub(crate) async fn admin_accounts(&self, a: AdminAccountsArgs) -> Result<Vec<AccountSummary>> {
275 // Every workspace that has used or paid for anything, and every
276 // account with terms of its own.
277 #[derive(Deserialize)]
278 struct Slug {
279 workspace: String,
280 }
281 let mut slugs: Vec<String> = self
282 .db
283 .prepare(
284 "SELECT DISTINCT workspace FROM ledger
285 UNION SELECT workspace FROM accounts
286 UNION SELECT substr(id, 4) FROM billing_accounts WHERE kind = 'workspace'",
287 )
288 .all()
289 .await?
290 .results::<Slug>()?
291 .into_iter()
292 .map(|s| s.workspace)
293 .collect();
294 if let Some(query) = a.query.as_deref().map(str::trim).filter(|q| !q.is_empty()) {
295 let query = query.to_lowercase();
296 slugs.retain(|slug| slug.contains(&query));
297 }
298 let mut seen = std::collections::HashSet::new();
299 let mut summaries = vec![];
300 for slug in slugs.into_iter().take(200) {
301 let account = self.account_of(&slug).await?;
302 if !seen.insert(account.id.clone()) {
303 continue;
304 }
305 summaries.push(self.summary(account).await?);
306 }
307 // Enterprises with no usage yet.
308 #[derive(Deserialize)]
309 struct Id {
310 id: String,
311 }
312 let enterprises = self
313 .db
314 .prepare("SELECT id FROM billing_accounts WHERE kind = 'enterprise'")
315 .all()
316 .await?
317 .results::<Id>()?;
318 for Id { id } in enterprises {
319 if seen.contains(&id) {
320 continue;
321 }
322 if let Some(account) = self.find_account(&id).await? {
323 if a.query.as_deref().is_none_or(|q| account.name.to_lowercase().contains(&q.to_lowercase())) {
324 seen.insert(id);
325 summaries.push(self.summary(account).await?);
326 }
327 }
328 }
329 summaries.sort_by(|x, y| y.limit.exposure_micros.cmp(&x.limit.exposure_micros));
330 Ok(summaries)
331 }
332
333 pub(crate) async fn admin_account(&self, a: AdminAccountArgs) -> Result<Outcome<AccountDetail>> {
334 let Some(account) = self.find_account(&a.id).await? else {
335 return Ok(Outcome::fail(FailureCode::NotFound, "No such account."));
336 };
337 let mut workspaces = vec![];
338 for workspace in &account.workspaces {
339 workspaces.push(self.limit_of(workspace).await?);
340 }
341 let marks = vec!["?"; account.workspaces.len().max(1)].join(", ");
342 let mut values: Vec<JsValue> = account.workspaces.iter().map(|w| JsValue::from(w.as_str())).collect();
343 if values.is_empty() {
344 values.push(JsValue::from(""));
345 }
346 let ledger = self
347 .db
348 .prepare(format!("SELECT * FROM ledger WHERE workspace IN ({marks}) ORDER BY id DESC LIMIT 100"))
349 .bind(&values)?
350 .all()
351 .await?
352 .results::<LedgerRow>()?
353 .into_iter()
354 .map(LedgerEntry::from)
355 .collect();
356 let audit = self
357 .db
358 .prepare("SELECT * FROM admin_actions WHERE account = ? ORDER BY created_at DESC LIMIT 50")
359 .bind(&[account.id.as_str().into()])?
360 .all()
361 .await?
362 .results::<ActionRow>()?
363 .into_iter()
364 .map(|row| AdminAction {
365 id: row.id,
366 account: row.account,
367 action: row.action,
368 detail: row.detail,
369 by: row.by,
370 created_at: row.created_at,
371 })
372 .collect();
373 Ok(Outcome::Ok(AccountDetail { summary: self.summary(account).await?, workspaces, ledger, audit }))
374 }
375
376 pub(crate) async fn admin_set_terms(&self, a: AdminSetTermsArgs) -> Result<Outcome<BillingAccount>> {
377 if a.by.trim().is_empty() {
378 return Ok(Outcome::fail(FailureCode::Invalid, "Say who is making the change."));
379 }
380 if a.terms.kind != TermsKind::Standard && a.terms.note.trim().is_empty() {
381 return Ok(Outcome::fail(FailureCode::Invalid, "Say why, in the note."));
382 }
383 if a.terms.discount_percent > 100 || a.terms.ceiling_micros.is_some_and(|c| c < 0) {
384 return Ok(Outcome::fail(FailureCode::Invalid, "A discount is 0 to 100%, and a ceiling is not negative."));
385 }
386 let Some(account) = self.find_account(&a.id).await? else {
387 return Ok(Outcome::fail(FailureCode::NotFound, "No such account."));
388 };
389 let now = rfc3339(now_ms());
390 // A workspace's own account gets a row the first time its terms change.
391 self.db
392 .prepare(
393 "INSERT INTO billing_accounts (id, kind, name, terms_kind, discount_percent, ceiling_micros, note,
394 terms_until, terms_set_by, terms_set_at, created_by, created_at)
395 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?9, ?10)
396 ON CONFLICT (id) DO UPDATE SET terms_kind = ?4, discount_percent = ?5, ceiling_micros = ?6,
397 note = ?7, terms_until = ?8, terms_set_by = ?9, terms_set_at = ?10",
398 )
399 .bind(&[
400 account.id.as_str().into(),
401 if account.kind == AccountKind::Enterprise { "enterprise" } else { "workspace" }.into(),
402 account.name.as_str().into(),
403 kind_text(a.terms.kind).into(),
404 a.terms.discount_percent.into(),
405 a.terms.ceiling_micros.map_or(JsValue::NULL, |c| (c as f64).into()),
406 a.terms.note.trim().into(),
407 optional(a.terms.until.as_deref()),
408 a.by.as_str().into(),
409 now.as_str().into(),
410 ])?
411 .run()
412 .await?;
413 self.audit(&account.id, "terms", &format!("{} → {}", describe(&account.terms), describe(&a.terms)), &a.by)
414 .await?;
415 Ok(Outcome::Ok(self.find_account(&account.id).await?.unwrap_or(account)))
416 }
417
418 pub(crate) async fn admin_create_enterprise(&self, a: AdminCreateEnterpriseArgs) -> Result<Outcome<BillingAccount>> {
419 let name = a.name.trim();
420 if name.is_empty() || a.by.trim().is_empty() {
421 return Ok(Outcome::fail(FailureCode::Invalid, "An enterprise needs a name, and who is making it."));
422 }
423 let now = now_ms();
424 let id = new_id("ent", now).to_lowercase();
425 self.db
426 .prepare(
427 "INSERT INTO billing_accounts (id, kind, name, terms_kind, discount_percent, note, created_by, created_at)
428 VALUES (?, 'enterprise', ?, 'standard', 0, '', ?, ?)",
429 )
430 .bind(&[id.as_str().into(), name.into(), a.by.as_str().into(), rfc3339(now).into()])?
431 .run()
432 .await?;
433 self.audit(&id, "create", &format!("Enterprise {name}"), &a.by).await?;
434 for workspace in &a.workspaces {
435 let workspace = workspace.trim().to_lowercase();
436 if !workspace.is_empty() {
437 self.attach(&workspace, Some(&id), &a.by).await?;
438 }
439 }
440 Ok(match self.find_account(&id).await? {
441 Some(account) => Outcome::Ok(account),
442 None => Outcome::fail(FailureCode::NotFound, "The enterprise was not saved."),
443 })
444 }
445
446 async fn attach(&self, workspace: &str, account: Option<&str>, by: &str) -> Result<()> {
447 let before = self.account_of(workspace).await?;
448 match account {
449 Some(account) => {
450 self.db
451 .prepare(
452 "INSERT INTO account_members (workspace, account_id, added_by, added_at) VALUES (?1, ?2, ?3, ?4)
453 ON CONFLICT (workspace) DO UPDATE SET account_id = ?2, added_by = ?3, added_at = ?4",
454 )
455 .bind(&[workspace.into(), account.into(), by.into(), rfc3339(now_ms()).into()])?
456 .run()
457 .await?;
458 self.audit(account, "attach", &format!("{workspace} joined, from {}", before.name), by).await?;
459 }
460 None => {
461 self.db
462 .prepare("DELETE FROM account_members WHERE workspace = ?")
463 .bind(&[workspace.into()])?
464 .run()
465 .await?;
466 self.audit(&before.id, "detach", &format!("{workspace} left, back to paying for itself"), by).await?;
467 }
468 }
469 Ok(())
470 }
471
472 pub(crate) async fn admin_attach(&self, a: AdminAttachArgs) -> Result<Outcome<BillingAccount>> {
473 let workspace = a.workspace.trim().to_lowercase();
474 if workspace.is_empty() || a.by.trim().is_empty() {
475 return Ok(Outcome::fail(FailureCode::Invalid, "Name the workspace, and who is making the change."));
476 }
477 if let Some(account) = &a.account {
478 match self.account_row(account).await? {
479 Some(row) if row.kind == "enterprise" => {}
480 _ => return Ok(Outcome::fail(FailureCode::NotFound, "Workspaces can only join an enterprise.")),
481 }
482 }
483 self.attach(&workspace, a.account.as_deref(), &a.by).await?;
484 Ok(Outcome::Ok(self.account_of(&workspace).await?))
485 }
486
487 pub(crate) async fn admin_credit(&self, a: AdminCreditArgs) -> Result<Outcome<LedgerEntry>> {
488 let workspace = a.workspace.trim().to_lowercase();
489 if workspace.is_empty() || a.note.trim().is_empty() || a.by.trim().is_empty() {
490 return Ok(Outcome::fail(FailureCode::Invalid, "A credit needs a workspace, a note and who gave it."));
491 }
492 if a.amount_micros <= 0 || a.amount_micros > 10_000 * g1t_contracts::billing::MICROS_PER_DOLLAR {
493 return Ok(Outcome::fail(FailureCode::Invalid, "A credit is more than $0 and at most $10,000."));
494 }
495 let reference = new_id("crd", now_ms());
496 let description = format!("Credit from g1t: {}", a.note.trim());
497 self.enter(&workspace, EntryKind::TopUp, a.amount_micros, &description, &reference, None, None, Some(&a.by), None)
498 .await?;
499 let account = self.account_of(&workspace).await?;
500 self.audit(&account.id, "credit", &format!("{} to {workspace}: {}", crate::features::dollars(a.amount_micros), a.note.trim()), &a.by)
501 .await?;
502 let row = self
503 .db
504 .prepare("SELECT * FROM ledger WHERE reference = ?")
505 .bind(&[reference.as_str().into()])?
506 .first::<LedgerRow>(None)
507 .await?;
508 Ok(match row {
509 Some(row) => Outcome::Ok(LedgerEntry::from(row)),
510 None => Outcome::fail(FailureCode::NotFound, "The credit was not saved."),
511 })
512 }
513}
514
515#[cfg(test)]
516mod tests {
517 use super::*;
518
519 fn terms(kind: TermsKind, discount: u32) -> Terms {
520 Terms { kind, discount_percent: discount, ..Terms::standard() }
521 }
522
523 #[test]
524 fn terms_shape_every_charge() {
525 assert_eq!(terms(TermsKind::Standard, 0).apply(1_000), 1_000);
526 assert_eq!(terms(TermsKind::Comped, 0).apply(1_000), 0);
527 assert_eq!(terms(TermsKind::Custom, 25).apply(1_000), 750);
528 assert_eq!(terms(TermsKind::Custom, 250).apply(1_000), 0);
529 }
530
531 #[test]
532 fn terms_read_plainly_in_the_audit_log() {
533 let custom = Terms { ceiling_micros: Some(50_000_000), note: "Design partner".into(), ..terms(TermsKind::Custom, 20) };
534 assert_eq!(describe(&custom), "custom (20% off, ceiling $50.00): Design partner");
535 assert_eq!(describe(&Terms::standard()), "standard");
536 }
537}