g1t/services/billing/src/accounts.rs

620 lines25,002 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, 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}
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 workspace: String,
234 charged: Option<i64>,
235 cost: Option<i64>,
236 }
237 #[derive(Deserialize)]
238 struct Paid {
239 workspace: String,
240 paid: Option<i64>,
241 }
242 let marks = vec!["?"; account.workspaces.len().max(1)].join(", ");
243 let mut values: Vec<JsValue> = account.workspaces.iter().map(|w| JsValue::from(w.as_str())).collect();
244 if values.is_empty() {
245 values.push(JsValue::from(""));
246 }
247 let month_start = format!("{}-01", &rfc3339(now_ms())[..7]);
248 let mut with_month = values.clone();
249 with_month.push(month_start.as_str().into());
250 let totals = self
251 .db
252 .prepare(format!(
253 "SELECT workspace, -SUM(amount_micros) AS charged, SUM(cost_micros) AS cost FROM ledger
254 WHERE kind = 'usage' AND workspace IN ({marks}) AND created_at >= ? GROUP BY workspace"
255 ))
256 .bind(&with_month)?
257 .all()
258 .await?
259 .results::<Totals>()?;
260 let paid = self
261 .db
262 .prepare(format!(
263 "SELECT workspace, SUM(amount_micros) AS paid FROM ledger
264 WHERE kind = 'top_up' AND workspace IN ({marks}) GROUP BY workspace"
265 ))
266 .bind(&values)?
267 .all()
268 .await?
269 .results::<Paid>()?;
270 // Each workspace's share, in the account's order; the account's
271 // figures are their sum.
272 let mut by_workspace: Vec<WorkspaceFigures> = vec![];
273 for workspace in &account.workspaces {
274 figures_for(&mut by_workspace, workspace);
275 }
276 for t in &totals {
277 let f = figures_for(&mut by_workspace, &t.workspace);
278 f.charged_micros += t.charged.unwrap_or(0);
279 f.cost_micros += t.cost.unwrap_or(0);
280 }
281 for p in &paid {
282 figures_for(&mut by_workspace, &p.workspace).paid_micros += p.paid.unwrap_or(0);
283 }
284 Ok(AccountSummary {
285 charged_micros: by_workspace.iter().map(|f| f.charged_micros).sum(),
286 cost_micros: by_workspace.iter().map(|f| f.cost_micros).sum(),
287 paid_micros: by_workspace.iter().map(|f| f.paid_micros).sum(),
288 by_workspace,
289 account,
290 limit,
291 })
292 }
293
294 // --- Staff ------------------------------------------------------------
295
296 pub(crate) async fn admin_accounts(&self, a: AdminAccountsArgs) -> Result<Vec<AccountSummary>> {
297 // Every workspace that has used or paid for anything, and every
298 // account with terms of its own.
299 #[derive(Deserialize)]
300 struct Slug {
301 workspace: String,
302 }
303 let mut slugs: Vec<String> = self
304 .db
305 .prepare(
306 "SELECT DISTINCT workspace FROM ledger
307 UNION SELECT workspace FROM accounts
308 UNION SELECT substr(id, 4) FROM billing_accounts WHERE kind = 'workspace'",
309 )
310 .all()
311 .await?
312 .results::<Slug>()?
313 .into_iter()
314 .map(|s| s.workspace)
315 .collect();
316 if let Some(query) = a.query.as_deref().map(str::trim).filter(|q| !q.is_empty()) {
317 let query = query.to_lowercase();
318 slugs.retain(|slug| slug.contains(&query));
319 }
320 let mut seen = std::collections::HashSet::new();
321 let mut summaries = vec![];
322 for slug in slugs.into_iter().take(200) {
323 let account = self.account_of(&slug).await?;
324 if !seen.insert(account.id.clone()) {
325 continue;
326 }
327 summaries.push(self.summary(account).await?);
328 }
329 // Enterprises with no usage yet.
330 #[derive(Deserialize)]
331 struct Id {
332 id: String,
333 }
334 let enterprises = self
335 .db
336 .prepare("SELECT id FROM billing_accounts WHERE kind = 'enterprise'")
337 .all()
338 .await?
339 .results::<Id>()?;
340 for Id { id } in enterprises {
341 if seen.contains(&id) {
342 continue;
343 }
344 if let Some(account) = self.find_account(&id).await? {
345 if a.query.as_deref().is_none_or(|q| account.name.to_lowercase().contains(&q.to_lowercase())) {
346 seen.insert(id);
347 summaries.push(self.summary(account).await?);
348 }
349 }
350 }
351 summaries.sort_by(|x, y| y.limit.exposure_micros.cmp(&x.limit.exposure_micros));
352 Ok(summaries)
353 }
354
355 pub(crate) async fn admin_account(&self, a: AdminAccountArgs) -> Result<Outcome<AccountDetail>> {
356 let Some(account) = self.find_account(&a.id).await? else {
357 return Ok(Outcome::fail(FailureCode::NotFound, "No such account."));
358 };
359 let mut workspaces = vec![];
360 for workspace in &account.workspaces {
361 workspaces.push(self.limit_of(workspace).await?);
362 }
363 let marks = vec!["?"; account.workspaces.len().max(1)].join(", ");
364 let mut values: Vec<JsValue> = account.workspaces.iter().map(|w| JsValue::from(w.as_str())).collect();
365 if values.is_empty() {
366 values.push(JsValue::from(""));
367 }
368 let ledger = self
369 .db
370 .prepare(format!("SELECT * FROM ledger WHERE workspace IN ({marks}) ORDER BY id DESC LIMIT 100"))
371 .bind(&values)?
372 .all()
373 .await?
374 .results::<LedgerRow>()?
375 .into_iter()
376 .map(LedgerEntry::from)
377 .collect();
378 let audit = self
379 .db
380 .prepare("SELECT * FROM admin_actions WHERE account = ? ORDER BY created_at DESC LIMIT 50")
381 .bind(&[account.id.as_str().into()])?
382 .all()
383 .await?
384 .results::<ActionRow>()?
385 .into_iter()
386 .map(|row| AdminAction {
387 id: row.id,
388 account: row.account,
389 action: row.action,
390 detail: row.detail,
391 by: row.by,
392 created_at: row.created_at,
393 })
394 .collect();
395 Ok(Outcome::Ok(AccountDetail { summary: self.summary(account).await?, workspaces, ledger, audit }))
396 }
397
398 pub(crate) async fn admin_set_terms(&self, a: AdminSetTermsArgs) -> Result<Outcome<BillingAccount>> {
399 if a.by.trim().is_empty() {
400 return Ok(Outcome::fail(FailureCode::Invalid, "Say who is making the change."));
401 }
402 if a.terms.kind != TermsKind::Standard && a.terms.note.trim().is_empty() {
403 return Ok(Outcome::fail(FailureCode::Invalid, "Say why, in the note."));
404 }
405 if a.terms.discount_percent > 100 || a.terms.ceiling_micros.is_some_and(|c| c < 0) {
406 return Ok(Outcome::fail(FailureCode::Invalid, "A discount is 0 to 100%, and a ceiling is not negative."));
407 }
408 let Some(account) = self.find_account(&a.id).await? else {
409 return Ok(Outcome::fail(FailureCode::NotFound, "No such account."));
410 };
411 let now = rfc3339(now_ms());
412 // A workspace's own account gets a row the first time its terms change.
413 self.db
414 .prepare(
415 "INSERT INTO billing_accounts (id, kind, name, terms_kind, discount_percent, ceiling_micros, note,
416 terms_until, terms_set_by, terms_set_at, created_by, created_at)
417 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?9, ?10)
418 ON CONFLICT (id) DO UPDATE SET terms_kind = ?4, discount_percent = ?5, ceiling_micros = ?6,
419 note = ?7, terms_until = ?8, terms_set_by = ?9, terms_set_at = ?10",
420 )
421 .bind(&[
422 account.id.as_str().into(),
423 if account.kind == AccountKind::Enterprise { "enterprise" } else { "workspace" }.into(),
424 account.name.as_str().into(),
425 kind_text(a.terms.kind).into(),
426 a.terms.discount_percent.into(),
427 a.terms.ceiling_micros.map_or(JsValue::NULL, |c| (c as f64).into()),
428 a.terms.note.trim().into(),
429 optional(a.terms.until.as_deref()),
430 a.by.as_str().into(),
431 now.as_str().into(),
432 ])?
433 .run()
434 .await?;
435 self.audit(&account.id, "terms", &format!("{} → {}", describe(&account.terms), describe(&a.terms)), &a.by)
436 .await?;
437 Ok(Outcome::Ok(self.find_account(&account.id).await?.unwrap_or(account)))
438 }
439
440 pub(crate) async fn admin_create_enterprise(&self, a: AdminCreateEnterpriseArgs) -> Result<Outcome<BillingAccount>> {
441 let name = a.name.trim();
442 if name.is_empty() || a.by.trim().is_empty() {
443 return Ok(Outcome::fail(FailureCode::Invalid, "An enterprise needs a name, and who is making it."));
444 }
445 let now = now_ms();
446 let id = new_id("ent", now).to_lowercase();
447 self.db
448 .prepare(
449 "INSERT INTO billing_accounts (id, kind, name, terms_kind, discount_percent, note, created_by, created_at)
450 VALUES (?, 'enterprise', ?, 'standard', 0, '', ?, ?)",
451 )
452 .bind(&[id.as_str().into(), name.into(), a.by.as_str().into(), rfc3339(now).into()])?
453 .run()
454 .await?;
455 self.audit(&id, "create", &format!("Enterprise {name}"), &a.by).await?;
456 for workspace in &a.workspaces {
457 let workspace = workspace.trim().to_lowercase();
458 if !workspace.is_empty() {
459 self.attach(&workspace, Some(&id), &a.by).await?;
460 }
461 }
462 Ok(match self.find_account(&id).await? {
463 Some(account) => Outcome::Ok(account),
464 None => Outcome::fail(FailureCode::NotFound, "The enterprise was not saved."),
465 })
466 }
467
468 async fn attach(&self, workspace: &str, account: Option<&str>, by: &str) -> Result<()> {
469 let before = self.account_of(workspace).await?;
470 match account {
471 Some(account) => {
472 self.db
473 .prepare(
474 "INSERT INTO account_members (workspace, account_id, added_by, added_at) VALUES (?1, ?2, ?3, ?4)
475 ON CONFLICT (workspace) DO UPDATE SET account_id = ?2, added_by = ?3, added_at = ?4",
476 )
477 .bind(&[workspace.into(), account.into(), by.into(), rfc3339(now_ms()).into()])?
478 .run()
479 .await?;
480 self.audit(account, "attach", &format!("{workspace} joined, from {}", before.name), by).await?;
481 }
482 None => {
483 self.db
484 .prepare("DELETE FROM account_members WHERE workspace = ?")
485 .bind(&[workspace.into()])?
486 .run()
487 .await?;
488 self.audit(&before.id, "detach", &format!("{workspace} left, back to paying for itself"), by).await?;
489 }
490 }
491 Ok(())
492 }
493
494 pub(crate) async fn admin_attach(&self, a: AdminAttachArgs) -> Result<Outcome<BillingAccount>> {
495 let workspace = a.workspace.trim().to_lowercase();
496 if workspace.is_empty() || a.by.trim().is_empty() {
497 return Ok(Outcome::fail(FailureCode::Invalid, "Name the workspace, and who is making the change."));
498 }
499 if let Some(account) = &a.account {
500 match self.account_row(account).await? {
501 Some(row) if row.kind == "enterprise" => {}
502 _ => return Ok(Outcome::fail(FailureCode::NotFound, "Workspaces can only join an enterprise.")),
503 }
504 }
505 self.attach(&workspace, a.account.as_deref(), &a.by).await?;
506 Ok(Outcome::Ok(self.account_of(&workspace).await?))
507 }
508
509 pub(crate) async fn admin_billing_link(
510 &self,
511 a: g1t_contracts::billing::AdminBillingLinkArgs,
512 ) -> Result<Outcome<g1t_contracts::billing::BillingLink>> {
513 let workspace = a.workspace.trim().to_lowercase();
514 if workspace.is_empty() || a.by.trim().is_empty() {
515 return Ok(Outcome::fail(FailureCode::Invalid, "Name the workspace, and who is asking."));
516 }
517 let Some(stripe) = &self.stripe else {
518 return Ok(Outcome::fail(FailureCode::Conflict, "Payments are not set up on this g1t."));
519 };
520 let customer = match self.customer_for(&workspace).await {
521 Ok(customer) => customer,
522 Err(error) => return Ok(Outcome::fail(FailureCode::Conflict, format!("Stripe could not be reached: {error}"))),
523 };
524 let link = async {
525 let configuration = stripe.portal_configuration().await?;
526 let portal_url = stripe.portal_session(&customer, "https://g1t.sh/").await?;
527 let customer_email = stripe.customer_email(&customer).await.ok().flatten();
528 Ok::<_, worker::Error>(g1t_contracts::billing::BillingLink {
529 portal_url,
530 login_url: configuration.login_page.and_then(|page| page.url),
531 customer_email,
532 expires_note: "The one-time link works for a short while and only once; the sign-in page does not expire."
533 .to_owned(),
534 })
535 }
536 .await;
537 match link {
538 Ok(link) => {
539 let account = self.account_of(&workspace).await?;
540 self.audit(&account.id, "billing_link", &format!("Stripe billing link for {workspace}"), &a.by).await?;
541 Ok(Outcome::Ok(link))
542 }
543 Err(error) => Ok(Outcome::fail(FailureCode::Conflict, format!("Stripe's billing page could not be opened: {error}"))),
544 }
545 }
546
547 pub(crate) async fn admin_credit(&self, a: AdminCreditArgs) -> Result<Outcome<LedgerEntry>> {
548 let workspace = a.workspace.trim().to_lowercase();
549 if workspace.is_empty() || a.note.trim().is_empty() || a.by.trim().is_empty() {
550 return Ok(Outcome::fail(FailureCode::Invalid, "A credit needs a workspace, a note and who gave it."));
551 }
552 if a.amount_micros <= 0 || a.amount_micros > 10_000 * g1t_contracts::billing::MICROS_PER_DOLLAR {
553 return Ok(Outcome::fail(FailureCode::Invalid, "A credit is more than $0 and at most $10,000."));
554 }
555 let reference = new_id("crd", now_ms());
556 let description = format!("Credit from g1t: {}", a.note.trim());
557 self.enter(&workspace, EntryKind::TopUp, a.amount_micros, &description, &reference, None, None, Some(&a.by), None)
558 .await?;
559 let account = self.account_of(&workspace).await?;
560 self.audit(&account.id, "credit", &format!("{} to {workspace}: {}", crate::features::dollars(a.amount_micros), a.note.trim()), &a.by)
561 .await?;
562 let row = self
563 .db
564 .prepare("SELECT * FROM ledger WHERE reference = ?")
565 .bind(&[reference.as_str().into()])?
566 .first::<LedgerRow>(None)
567 .await?;
568 Ok(match row {
569 Some(row) => Outcome::Ok(LedgerEntry::from(row)),
570 None => Outcome::fail(FailureCode::NotFound, "The credit was not saved."),
571 })
572 }
573}
574
575/// A workspace's figures in `list`, added at the end the first time.
576fn figures_for<'a>(list: &'a mut Vec<WorkspaceFigures>, workspace: &str) -> &'a mut WorkspaceFigures {
577 let i = match list.iter().position(|f| f.workspace == workspace) {
578 Some(i) => i,
579 None => {
580 list.push(WorkspaceFigures { workspace: workspace.to_owned(), ..Default::default() });
581 list.len() - 1
582 }
583 };
584 &mut list[i]
585}
586
587#[cfg(test)]
588mod tests {
589 use super::*;
590
591 fn terms(kind: TermsKind, discount: u32) -> Terms {
592 Terms { kind, discount_percent: discount, ..Terms::standard() }
593 }
594
595 #[test]
596 fn terms_shape_every_charge() {
597 assert_eq!(terms(TermsKind::Standard, 0).apply(1_000), 1_000);
598 assert_eq!(terms(TermsKind::Comped, 0).apply(1_000), 0);
599 assert_eq!(terms(TermsKind::Custom, 25).apply(1_000), 750);
600 assert_eq!(terms(TermsKind::Custom, 250).apply(1_000), 0);
601 }
602
603 #[test]
604 fn terms_read_plainly_in_the_audit_log() {
605 let custom = Terms { ceiling_micros: Some(50_000_000), note: "Design partner".into(), ..terms(TermsKind::Custom, 20) };
606 assert_eq!(describe(&custom), "custom (20% off, ceiling $50.00): Design partner");
607 assert_eq!(describe(&Terms::standard()), "standard");
608 }
609
610 #[test]
611 fn each_workspace_gets_one_share() {
612 let mut list = vec![];
613 figures_for(&mut list, "acme").charged_micros += 5;
614 figures_for(&mut list, "beta").cost_micros += 2;
615 figures_for(&mut list, "acme").charged_micros += 7;
616 assert_eq!(list.len(), 2);
617 assert_eq!(list[0], WorkspaceFigures { workspace: "acme".into(), charged_micros: 12, ..Default::default() });
618 assert_eq!(list[1].cost_micros, 2);
619 }
620}