g1t/services/billing/src/accounts.rs

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