flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t/services/billing/src/accounts.rs

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